generated from noisedestroyers/claude
Compare commits
2 Commits
90fa76b864
...
f8fad307ba
| Author | SHA1 | Date | |
|---|---|---|---|
| f8fad307ba | |||
| 1e6ed7e32d |
22
.obsidian/workspace.json
vendored
22
.obsidian/workspace.json
vendored
@@ -213,17 +213,19 @@
|
|||||||
},
|
},
|
||||||
"active": "41ed24ce492fd06e",
|
"active": "41ed24ce492fd06e",
|
||||||
"lastOpenFiles": [
|
"lastOpenFiles": [
|
||||||
|
"src/Frontend/src/components/common/StatusChip.tsx",
|
||||||
|
"src/Frontend/src/components/common/ProgressBar.tsx",
|
||||||
|
"src/Frontend/src/components/common/LoadingSpinner.tsx",
|
||||||
|
"src/Frontend/src/components/common/ErrorDisplay.tsx",
|
||||||
|
"src/Frontend/src/components/Publishing/PublishingDashboard.tsx",
|
||||||
|
"src/Frontend/src/components/Packages/PackageList.tsx",
|
||||||
|
"src/Frontend/src/components/Packages/PackageForm.tsx",
|
||||||
|
"src/Frontend/src/components/Layout/AppLayout.tsx",
|
||||||
|
"src/Frontend/src/types/index.ts",
|
||||||
|
"src/Frontend/src/services/signalRService.ts",
|
||||||
|
"src/Frontend/src/services/publicationService.ts",
|
||||||
|
"src/Frontend/README.md",
|
||||||
"legacy.md",
|
"legacy.md",
|
||||||
"legacy/CCNetLogReader/CCNetControls/obj/Debug/TempPE",
|
|
||||||
"legacy/CCNetLogReader/CCNetControls/obj/Debug/DesignTimeResolveAssemblyReferencesInput.cache",
|
|
||||||
"legacy/CCNetLogReader/CCNetControls/obj/Debug/CCNetControls.csproj.FileListAbsolute.txt",
|
|
||||||
"legacy/CCNetLogReader/CCNetControls/obj/Debug",
|
|
||||||
"legacy/CCNetLogReader/CCNetControls/bin/Debug",
|
|
||||||
"legacy/CCNetLogReader/CCNetControls/Properties/AssemblyInfo.cs",
|
|
||||||
"legacy/CCNetLogReader/CCNetControls/obj",
|
|
||||||
"legacy/CCNetLogReader/CCNetControls/bin",
|
|
||||||
"legacy/CCNetLogReader/CCNetControls/Properties",
|
|
||||||
"legacy/CCNetLogReader/CCNetControls/ManuscriptLogin.resx",
|
|
||||||
"claude.md",
|
"claude.md",
|
||||||
"implementation.md",
|
"implementation.md",
|
||||||
"statement-of-work.md",
|
"statement-of-work.md",
|
||||||
|
|||||||
21
src/Frontend/.eslintrc.cjs
Normal file
21
src/Frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
env: { browser: true, es2020: true },
|
||||||
|
extends: [
|
||||||
|
'eslint:recommended',
|
||||||
|
'@typescript-eslint/recommended',
|
||||||
|
'eslint:recommended',
|
||||||
|
'@typescript-eslint/recommended',
|
||||||
|
],
|
||||||
|
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
plugins: ['react-refresh'],
|
||||||
|
rules: {
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
},
|
||||||
|
}
|
||||||
31
src/Frontend/Dockerfile
Normal file
31
src/Frontend/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Build stage
|
||||||
|
FROM node:18-alpine as builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the app
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Copy built assets from builder stage
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy nginx configuration
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
# Expose port 80
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
356
src/Frontend/README.md
Normal file
356
src/Frontend/README.md
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
# Release Management Platform - Frontend
|
||||||
|
|
||||||
|
Modern React TypeScript frontend for the Release Management Platform, built with Material-UI and real-time SignalR integration.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Dashboard**: Overview of packages, projects, and active publications
|
||||||
|
- **Package Management**: Create, edit, and manage software packages
|
||||||
|
- **Real-time Publishing**: Live progress tracking with WebSocket updates
|
||||||
|
- **Project Monitoring**: Build status and history from CruiseControl.NET
|
||||||
|
- **Responsive Design**: Works on desktop and mobile devices
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **React 18+** with TypeScript
|
||||||
|
- **Material-UI** for component library and theming
|
||||||
|
- **Vite** for fast development and building
|
||||||
|
- **React Query** for server state management
|
||||||
|
- **SignalR** for real-time updates
|
||||||
|
- **React Hook Form** with Yup validation
|
||||||
|
- **React Router** for navigation
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── components/ # Reusable UI components
|
||||||
|
│ ├── Layout/ # App layout and navigation
|
||||||
|
│ ├── Packages/ # Package management components
|
||||||
|
│ ├── Publishing/ # Publishing dashboard components
|
||||||
|
│ └── common/ # Shared components
|
||||||
|
├── hooks/ # Custom React hooks
|
||||||
|
├── services/ # API clients and services
|
||||||
|
├── types/ # TypeScript type definitions
|
||||||
|
├── contexts/ # React contexts and providers
|
||||||
|
├── pages/ # Page components
|
||||||
|
└── utils/ # Utility functions
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the development server:
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at http://localhost:3000
|
||||||
|
|
||||||
|
### Building for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
|
||||||
|
Build the Docker image:
|
||||||
|
```bash
|
||||||
|
docker build -t release-management-frontend .
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the container:
|
||||||
|
```bash
|
||||||
|
docker run -p 3000:80 release-management-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The frontend is configured to proxy API requests to the backend services:
|
||||||
|
|
||||||
|
- `/api/*` → API Gateway on port 5000
|
||||||
|
- `/hubs/*` → SignalR hubs on port 5000
|
||||||
|
|
||||||
|
For production deployment, configure the nginx.conf file or environment variables as needed.
|
||||||
|
|
||||||
|
## Key Components
|
||||||
|
|
||||||
|
### Package Management
|
||||||
|
- **PackageList**: Displays all packages with filtering and search
|
||||||
|
- **PackageForm**: Create and edit package configurations
|
||||||
|
- Supports tabbed interface for basic info, configuration, storage, and help center settings
|
||||||
|
|
||||||
|
### Publishing Dashboard
|
||||||
|
- **PublishingDashboard**: Real-time view of active publications
|
||||||
|
- **ProgressBar**: Visual progress indicators for publishing steps
|
||||||
|
- Live updates via SignalR for step completion and progress
|
||||||
|
|
||||||
|
### Real-time Updates
|
||||||
|
- SignalR integration for live publishing progress
|
||||||
|
- Build status updates from CruiseControl.NET
|
||||||
|
- Automatic UI refresh on publication completion
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
The frontend integrates with the following backend services:
|
||||||
|
|
||||||
|
- **Package Service**: CRUD operations for packages
|
||||||
|
- **Project Service**: CruiseControl.NET integration
|
||||||
|
- **Publication Service**: Publishing workflow management
|
||||||
|
- **SignalR Hub**: Real-time notifications
|
||||||
|
|
||||||
|
## Development Guidelines
|
||||||
|
|
||||||
|
- Use TypeScript for type safety
|
||||||
|
- Follow Material-UI design patterns
|
||||||
|
- Implement proper error handling and loading states
|
||||||
|
- Use React Query for server state management
|
||||||
|
- Write reusable components with proper prop interfaces
|
||||||
|
- Follow responsive design principles
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Create a `.env.local` file for local development:
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_API_BASE_URL=http://localhost:5000
|
||||||
|
VITE_SIGNALR_HUB_URL=http://localhost:5000/hubs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser Support
|
||||||
|
|
||||||
|
- Chrome (latest)
|
||||||
|
- Firefox (latest)
|
||||||
|
- Safari (latest)
|
||||||
|
- Edge (latest)
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Follow the existing code style and patterns
|
||||||
|
2. Write TypeScript interfaces for all data structures
|
||||||
|
3. Include proper error handling and loading states
|
||||||
|
4. Test responsive behavior on different screen sizes
|
||||||
|
5. Document complex components and hooks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Developer Notes for Future Development
|
||||||
|
|
||||||
|
### Architecture Decisions & Patterns
|
||||||
|
|
||||||
|
#### **State Management Strategy**
|
||||||
|
- **React Query**: Used for all server state (packages, projects, publications)
|
||||||
|
- **Local State**: useState/useReducer for UI-only state
|
||||||
|
- **Context API**: Only for truly global state (currently just QueryProvider)
|
||||||
|
- **No Redux**: Kept simple with React Query + local state
|
||||||
|
|
||||||
|
#### **Component Architecture**
|
||||||
|
```
|
||||||
|
Pages -> Layout -> Feature Components -> Common Components
|
||||||
|
```
|
||||||
|
- **Pages**: Route-level components, minimal logic
|
||||||
|
- **Feature Components**: Domain-specific (Packages, Publishing)
|
||||||
|
- **Common Components**: Reusable UI elements
|
||||||
|
- **Layout**: Navigation and app shell
|
||||||
|
|
||||||
|
#### **API Integration Patterns**
|
||||||
|
- **Services Layer**: Clean separation of API logic
|
||||||
|
- **Custom Hooks**: Encapsulate data fetching and mutations
|
||||||
|
- **Error Boundaries**: Not implemented yet - consider adding
|
||||||
|
- **Optimistic Updates**: Implemented in mutation hooks
|
||||||
|
|
||||||
|
### 🔧 Technical Implementation Notes
|
||||||
|
|
||||||
|
#### **SignalR Integration**
|
||||||
|
```typescript
|
||||||
|
// SignalR connection lifecycle managed in useSignalR hook
|
||||||
|
// Automatic reconnection enabled
|
||||||
|
// Group-based subscriptions for publication updates
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important**: SignalR connection state should be displayed in UI (connection indicator in AppLayout)
|
||||||
|
|
||||||
|
#### **Form Handling**
|
||||||
|
- **React Hook Form + Yup**: Chosen for performance and TypeScript support
|
||||||
|
- **Tabbed Forms**: PackageForm uses Material-UI Tabs
|
||||||
|
- **Dynamic Fields**: Build selection depends on project selection
|
||||||
|
|
||||||
|
#### **Real-time Updates Flow**
|
||||||
|
```
|
||||||
|
1. User triggers publish -> API call
|
||||||
|
2. SignalR sends progress updates -> useSignalR hook
|
||||||
|
3. Hook invalidates React Query cache -> UI updates
|
||||||
|
4. Publication completes -> Final UI refresh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🚨 Known Issues & TODOs
|
||||||
|
|
||||||
|
#### **Missing Components**
|
||||||
|
- [ ] **Projects Page**: Currently just placeholder
|
||||||
|
- [ ] **History Page**: Publication history view needed
|
||||||
|
- [ ] **Settings Page**: User preferences, API configuration
|
||||||
|
- [ ] **User Management**: Authentication UI
|
||||||
|
- [ ] **Error Boundaries**: Global error handling
|
||||||
|
|
||||||
|
#### **UX Improvements Needed**
|
||||||
|
- [ ] **Loading Skeletons**: Replace spinners with skeleton screens
|
||||||
|
- [ ] **Toast Notifications**: Success/error feedback
|
||||||
|
- [ ] **Keyboard Navigation**: Accessibility improvements
|
||||||
|
- [ ] **Mobile Optimization**: Touch gestures, better responsive design
|
||||||
|
- [ ] **Dark Mode**: Theme switcher
|
||||||
|
|
||||||
|
#### **Performance Optimizations**
|
||||||
|
- [ ] **Virtualization**: For large lists (packages, publications)
|
||||||
|
- [ ] **Code Splitting**: Route-based lazy loading
|
||||||
|
- [ ] **Bundle Analysis**: webpack-bundle-analyzer equivalent for Vite
|
||||||
|
- [ ] **Image Optimization**: If images are added later
|
||||||
|
|
||||||
|
### 🔐 Security Considerations
|
||||||
|
|
||||||
|
#### **Current Implementation**
|
||||||
|
- JWT tokens stored in localStorage (not ideal for production)
|
||||||
|
- CORS handled by backend
|
||||||
|
- CSP headers in nginx.conf
|
||||||
|
- Input validation with Yup schemas
|
||||||
|
|
||||||
|
#### **Production Security TODOs**
|
||||||
|
- [ ] **HttpOnly Cookies**: Move JWT to secure cookies
|
||||||
|
- [ ] **CSRF Protection**: If switching to cookies
|
||||||
|
- [ ] **Content Security Policy**: Strengthen CSP rules
|
||||||
|
- [ ] **Input Sanitization**: XSS prevention for rich text fields
|
||||||
|
|
||||||
|
### 🎨 UI/UX Design System
|
||||||
|
|
||||||
|
#### **Material-UI Customizations**
|
||||||
|
```typescript
|
||||||
|
// Theme customization in App.tsx
|
||||||
|
// Custom shadows for cards/papers
|
||||||
|
// Color palette matches backend architecture diagram
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Component Patterns**
|
||||||
|
- **StatusChip**: Consistent status display with icons/colors
|
||||||
|
- **ProgressBar**: Reusable for publishing workflows
|
||||||
|
- **LoadingSpinner**: Consistent loading states
|
||||||
|
- **ErrorDisplay**: Standard error handling UI
|
||||||
|
|
||||||
|
#### **Responsive Breakpoints**
|
||||||
|
```typescript
|
||||||
|
// Material-UI defaults:
|
||||||
|
xs: 0px, sm: 600px, md: 900px, lg: 1200px, xl: 1536px
|
||||||
|
// Drawer collapses below lg (1200px)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🧪 Testing Strategy (Not Implemented)
|
||||||
|
|
||||||
|
#### **Recommended Testing Approach**
|
||||||
|
```bash
|
||||||
|
# Unit Tests: React Testing Library + Jest
|
||||||
|
npm install --save-dev @testing-library/react @testing-library/jest-dom
|
||||||
|
|
||||||
|
# E2E Tests: Playwright or Cypress
|
||||||
|
npm install --save-dev playwright
|
||||||
|
|
||||||
|
# Component Testing: Storybook
|
||||||
|
npm install --save-dev @storybook/react
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Test Priorities**
|
||||||
|
1. **Critical User Flows**: Package creation, publishing workflow
|
||||||
|
2. **Real-time Features**: SignalR connection handling
|
||||||
|
3. **Form Validation**: Package form edge cases
|
||||||
|
4. **API Error Handling**: Network failure scenarios
|
||||||
|
|
||||||
|
### 🚀 Deployment Notes
|
||||||
|
|
||||||
|
#### **Environment Configuration**
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
VITE_API_BASE_URL=http://localhost:5000
|
||||||
|
|
||||||
|
# Production
|
||||||
|
VITE_API_BASE_URL=https://api.yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Docker Multi-stage Build**
|
||||||
|
- Stage 1: Node.js build environment
|
||||||
|
- Stage 2: Nginx serving static files
|
||||||
|
- nginx.conf proxies API requests to backend
|
||||||
|
|
||||||
|
#### **Monitoring & Observability**
|
||||||
|
- [ ] **Error Tracking**: Sentry or similar
|
||||||
|
- [ ] **Analytics**: User behavior tracking
|
||||||
|
- [ ] **Performance**: Core Web Vitals monitoring
|
||||||
|
- [ ] **Logging**: Frontend error logging
|
||||||
|
|
||||||
|
### 🔄 Integration with Backend Services
|
||||||
|
|
||||||
|
#### **Expected API Endpoints**
|
||||||
|
```typescript
|
||||||
|
// Based on implementation.md architecture:
|
||||||
|
GET /api/packages # List packages
|
||||||
|
POST /api/packages # Create package
|
||||||
|
GET /api/packages/{id} # Get package details
|
||||||
|
PUT /api/packages/{id} # Update package
|
||||||
|
DELETE /api/packages/{id} # Delete package
|
||||||
|
POST /api/packages/{id}/publish # Start publishing
|
||||||
|
|
||||||
|
GET /api/projects # List projects
|
||||||
|
GET /api/projects/{id}/builds # Get project builds
|
||||||
|
GET /api/builds/{id}/commits # Get build commits
|
||||||
|
|
||||||
|
GET /api/publications # List publications
|
||||||
|
GET /api/publications/active # Active publications
|
||||||
|
POST /api/publications/{id}/cancel # Cancel publication
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **SignalR Hub Events**
|
||||||
|
```typescript
|
||||||
|
// Expected events from backend SignalR hub:
|
||||||
|
'PublishingProgress' // Step progress updates
|
||||||
|
'BuildStatusUpdate' # Project build status changes
|
||||||
|
'PublicationCompleted' // Publication finished successfully
|
||||||
|
'PublicationFailed' // Publication failed with error
|
||||||
|
```
|
||||||
|
|
||||||
|
### 💡 Future Enhancement Ideas
|
||||||
|
|
||||||
|
#### **Advanced Features**
|
||||||
|
- **Bulk Operations**: Select multiple packages for batch publishing
|
||||||
|
- **Publishing Templates**: Reusable configuration templates
|
||||||
|
- **Workflow Visualization**: Visual pipeline representation
|
||||||
|
- **Audit Trail**: Detailed action history with user attribution
|
||||||
|
- **Notifications**: Email/Slack integration for publish completion
|
||||||
|
- **Rollback**: Revert to previous package versions
|
||||||
|
|
||||||
|
#### **Developer Experience**
|
||||||
|
- **Hot Reload**: Already implemented with Vite
|
||||||
|
- **TypeScript Strict Mode**: Enable stricter type checking
|
||||||
|
- **ESLint Rules**: Add more React-specific linting rules
|
||||||
|
- **Prettier**: Code formatting consistency
|
||||||
|
- **Husky**: Pre-commit hooks for quality checks
|
||||||
|
|
||||||
|
### 🤝 Team Collaboration Notes
|
||||||
|
|
||||||
|
#### **Code Review Checklist**
|
||||||
|
- [ ] TypeScript interfaces defined for new data structures
|
||||||
|
- [ ] Loading and error states handled
|
||||||
|
- [ ] Mobile responsiveness tested
|
||||||
|
- [ ] SignalR subscriptions properly cleaned up
|
||||||
|
- [ ] React Query cache invalidation logic correct
|
||||||
|
- [ ] Form validation covers edge cases
|
||||||
|
|
||||||
|
#### **Naming Conventions**
|
||||||
|
- **Components**: PascalCase (PackageList.tsx)
|
||||||
|
- **Hooks**: camelCase with 'use' prefix (usePackages.ts)
|
||||||
|
- **Types**: PascalCase interfaces/enums (Package, PackageStatus)
|
||||||
|
- **Files**: PascalCase for components, camelCase for utilities
|
||||||
|
|
||||||
|
This frontend implementation provides a solid foundation for the Release Management Platform. The architecture is scalable and the patterns established should guide future development efforts.
|
||||||
21
src/Frontend/index.html
Normal file
21
src/Frontend/index.html
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Release Management Platform</title>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/icon?family=Material+Icons"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
50
src/Frontend/nginx.conf
Normal file
50
src/Frontend/nginx.conf
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
|
||||||
|
# Serve static assets with caching
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# API proxy
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api-gateway:80/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SignalR hub proxy
|
||||||
|
location /hubs/ {
|
||||||
|
proxy_pass http://api-gateway:80/hubs/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle client-side routing
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||||
|
}
|
||||||
43
src/Frontend/package.json
Normal file
43
src/Frontend/package.json
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "release-management-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"@mui/material": "^5.15.1",
|
||||||
|
"@mui/icons-material": "^5.15.1",
|
||||||
|
"@mui/x-data-grid": "^6.18.2",
|
||||||
|
"@mui/x-date-pickers": "^6.18.2",
|
||||||
|
"@emotion/react": "^11.11.1",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"@microsoft/signalr": "^8.0.0",
|
||||||
|
"@tanstack/react-query": "^5.8.4",
|
||||||
|
"@tanstack/react-query-devtools": "^5.8.4",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"react-router-dom": "^6.20.1",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"react-hook-form": "^7.48.2",
|
||||||
|
"@hookform/resolvers": "^3.3.2",
|
||||||
|
"yup": "^1.3.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.37",
|
||||||
|
"@types/react-dom": "^18.2.15",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||||
|
"@typescript-eslint/parser": "^6.10.0",
|
||||||
|
"@vitejs/plugin-react": "^4.1.1",
|
||||||
|
"eslint": "^8.53.0",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.4",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"vite": "^5.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/Frontend/src/App.tsx
Normal file
75
src/Frontend/src/App.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { ThemeProvider, createTheme, CssBaseline } from '@mui/material';
|
||||||
|
import { LocalizationProvider } from '@mui/x-date-pickers';
|
||||||
|
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||||
|
import { QueryProvider } from './contexts/QueryProvider';
|
||||||
|
import { AppLayout } from './components/Layout/AppLayout';
|
||||||
|
import { DashboardPage } from './pages/DashboardPage';
|
||||||
|
import { PackagesPage } from './pages/PackagesPage';
|
||||||
|
import { PublishingPage } from './pages/PublishingPage';
|
||||||
|
|
||||||
|
// Create Material-UI theme
|
||||||
|
const theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'light',
|
||||||
|
primary: {
|
||||||
|
main: '#1976d2',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
main: '#dc004e',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||||
|
h4: {
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
h6: {
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiPaper: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<QueryProvider>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||||
|
<CssBaseline />
|
||||||
|
<Router>
|
||||||
|
<AppLayout>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<DashboardPage />} />
|
||||||
|
<Route path="/packages" element={<PackagesPage />} />
|
||||||
|
<Route path="/publishing" element={<PublishingPage />} />
|
||||||
|
<Route path="/projects" element={<div>Projects page - Coming soon</div>} />
|
||||||
|
<Route path="/history" element={<div>History page - Coming soon</div>} />
|
||||||
|
<Route path="/settings" element={<div>Settings page - Coming soon</div>} />
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</AppLayout>
|
||||||
|
</Router>
|
||||||
|
</LocalizationProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</QueryProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
205
src/Frontend/src/components/Layout/AppLayout.tsx
Normal file
205
src/Frontend/src/components/Layout/AppLayout.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
AppBar,
|
||||||
|
Toolbar,
|
||||||
|
Typography,
|
||||||
|
Drawer,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemIcon,
|
||||||
|
ListItemText,
|
||||||
|
ListItemButton,
|
||||||
|
Box,
|
||||||
|
IconButton,
|
||||||
|
Badge,
|
||||||
|
Tooltip,
|
||||||
|
Divider,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Menu as MenuIcon,
|
||||||
|
Dashboard as DashboardIcon,
|
||||||
|
Inventory as PackagesIcon,
|
||||||
|
Build as BuildIcon,
|
||||||
|
Publish as PublishIcon,
|
||||||
|
History as HistoryIcon,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Notifications as NotificationsIcon,
|
||||||
|
AccountCircle as AccountIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useSignalR } from '../../hooks/useSignalR';
|
||||||
|
import { useActivePublications } from '../../hooks/usePublications';
|
||||||
|
|
||||||
|
const DRAWER_WIDTH = 240;
|
||||||
|
|
||||||
|
interface AppLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const { isConnected } = useSignalR();
|
||||||
|
const { data: activePublications } = useActivePublications();
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/' },
|
||||||
|
{ text: 'Packages', icon: <PackagesIcon />, path: '/packages' },
|
||||||
|
{ text: 'Projects', icon: <BuildIcon />, path: '/projects' },
|
||||||
|
{ text: 'Publishing', icon: <PublishIcon />, path: '/publishing' },
|
||||||
|
{ text: 'History', icon: <HistoryIcon />, path: '/history' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activePublicationCount = activePublications?.data?.length || 0;
|
||||||
|
|
||||||
|
const handleDrawerToggle = () => {
|
||||||
|
setDrawerOpen(!drawerOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigation = (path: string) => {
|
||||||
|
navigate(path);
|
||||||
|
if (window.innerWidth < 900) {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawer = (
|
||||||
|
<Box sx={{ overflow: 'auto' }}>
|
||||||
|
<Toolbar>
|
||||||
|
<Typography variant="h6" noWrap component="div">
|
||||||
|
Release Management
|
||||||
|
</Typography>
|
||||||
|
</Toolbar>
|
||||||
|
<Divider />
|
||||||
|
<List>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<ListItem key={item.text} disablePadding>
|
||||||
|
<ListItemButton
|
||||||
|
selected={location.pathname === item.path}
|
||||||
|
onClick={() => handleNavigation(item.path)}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
{item.text === 'Publishing' ? (
|
||||||
|
<Badge badgeContent={activePublicationCount} color="secondary">
|
||||||
|
{item.icon}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
item.icon
|
||||||
|
)}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary={item.text} />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
<Divider />
|
||||||
|
<List>
|
||||||
|
<ListItem disablePadding>
|
||||||
|
<ListItemButton onClick={() => handleNavigation('/settings')}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<SettingsIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText primary="Settings" />
|
||||||
|
</ListItemButton>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex' }}>
|
||||||
|
<AppBar
|
||||||
|
position="fixed"
|
||||||
|
sx={{
|
||||||
|
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||||
|
bgcolor: 'primary.main',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar>
|
||||||
|
<IconButton
|
||||||
|
color="inherit"
|
||||||
|
aria-label="open drawer"
|
||||||
|
edge="start"
|
||||||
|
onClick={handleDrawerToggle}
|
||||||
|
sx={{ mr: 2, display: { lg: 'none' } }}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||||
|
Release Management Platform
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Tooltip title={isConnected ? 'Connected' : 'Disconnected'}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: isConnected ? 'success.main' : 'error.main',
|
||||||
|
mr: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="Notifications">
|
||||||
|
<IconButton color="inherit">
|
||||||
|
<Badge badgeContent={activePublicationCount} color="secondary">
|
||||||
|
<NotificationsIcon />
|
||||||
|
</Badge>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip title="Account">
|
||||||
|
<IconButton color="inherit">
|
||||||
|
<AccountIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
variant="temporary"
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={handleDrawerToggle}
|
||||||
|
ModalProps={{ keepMounted: true }}
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'block', lg: 'none' },
|
||||||
|
'& .MuiDrawer-paper': {
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{drawer}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
variant="permanent"
|
||||||
|
sx={{
|
||||||
|
display: { xs: 'none', lg: 'block' },
|
||||||
|
'& .MuiDrawer-paper': {
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
width: DRAWER_WIDTH,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
open
|
||||||
|
>
|
||||||
|
{drawer}
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1,
|
||||||
|
p: 3,
|
||||||
|
width: { lg: `calc(100% - ${DRAWER_WIDTH}px)` },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Toolbar />
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
411
src/Frontend/src/components/Packages/PackageForm.tsx
Normal file
411
src/Frontend/src/components/Packages/PackageForm.tsx
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
Box,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Typography,
|
||||||
|
Switch,
|
||||||
|
FormControlLabel,
|
||||||
|
Grid,
|
||||||
|
Autocomplete,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import * as yup from 'yup';
|
||||||
|
import {
|
||||||
|
Package,
|
||||||
|
CreatePackageRequest,
|
||||||
|
Build
|
||||||
|
} from '../../types';
|
||||||
|
import { useProjects, useProjectBuilds } from '../../hooks/useProjects';
|
||||||
|
import { useCreatePackage, useUpdatePackage } from '../../hooks/usePackages';
|
||||||
|
import { LoadingSpinner } from '../common/LoadingSpinner';
|
||||||
|
|
||||||
|
interface PackageFormProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
package?: Package;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabPanelProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
index: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index, ...other }) => (
|
||||||
|
<div
|
||||||
|
role="tabpanel"
|
||||||
|
hidden={value !== index}
|
||||||
|
id={`package-tabpanel-${index}`}
|
||||||
|
aria-labelledby={`package-tab-${index}`}
|
||||||
|
{...other}
|
||||||
|
>
|
||||||
|
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const schema = yup.object({
|
||||||
|
title: yup.string().required('Title is required'),
|
||||||
|
version: yup.string().required('Version is required'),
|
||||||
|
description: yup.string().required('Description is required'),
|
||||||
|
projectId: yup.number().required('Project is required'),
|
||||||
|
sourceBuildId: yup.number().required('Source build is required'),
|
||||||
|
configuration: yup.object({
|
||||||
|
buildFolder: yup.string().required('Build folder is required'),
|
||||||
|
zipContents: yup.boolean().required(),
|
||||||
|
deleteOldPublishedBuilds: yup.boolean().required(),
|
||||||
|
releaseNoteTemplate: yup.string().required('Release note template is required'),
|
||||||
|
storageSettings: yup.object().required(),
|
||||||
|
helpCenterSettings: yup.object().required(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const PackageForm: React.FC<PackageFormProps> = ({ open, onClose, package: pkg }) => {
|
||||||
|
const [currentTab, setCurrentTab] = React.useState(0);
|
||||||
|
const [selectedProjectId, setSelectedProjectId] = React.useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: projectsData } = useProjects();
|
||||||
|
const { data: buildsData } = useProjectBuilds(
|
||||||
|
selectedProjectId || 0,
|
||||||
|
1,
|
||||||
|
50,
|
||||||
|
{ status: 'Success' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMutation = useCreatePackage();
|
||||||
|
const updateMutation = useUpdatePackage();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
watch,
|
||||||
|
formState: { errors, isValid },
|
||||||
|
} = useForm<CreatePackageRequest>({
|
||||||
|
resolver: yupResolver(schema),
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
version: '',
|
||||||
|
description: '',
|
||||||
|
projectId: 0,
|
||||||
|
sourceBuildId: 0,
|
||||||
|
configuration: {
|
||||||
|
buildFolder: '',
|
||||||
|
zipContents: true,
|
||||||
|
deleteOldPublishedBuilds: true,
|
||||||
|
releaseNoteTemplate: '',
|
||||||
|
storageSettings: {},
|
||||||
|
helpCenterSettings: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const watchedProjectId = watch('projectId');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchedProjectId) {
|
||||||
|
setSelectedProjectId(watchedProjectId);
|
||||||
|
}
|
||||||
|
}, [watchedProjectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pkg) {
|
||||||
|
reset({
|
||||||
|
title: pkg.title,
|
||||||
|
version: pkg.version,
|
||||||
|
description: pkg.description,
|
||||||
|
projectId: pkg.projectId,
|
||||||
|
sourceBuildId: pkg.sourceBuildId,
|
||||||
|
configuration: pkg.configuration || {
|
||||||
|
buildFolder: '',
|
||||||
|
zipContents: true,
|
||||||
|
deleteOldPublishedBuilds: true,
|
||||||
|
releaseNoteTemplate: '',
|
||||||
|
storageSettings: {},
|
||||||
|
helpCenterSettings: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setSelectedProjectId(pkg.projectId);
|
||||||
|
} else {
|
||||||
|
reset({
|
||||||
|
title: '',
|
||||||
|
version: '',
|
||||||
|
description: '',
|
||||||
|
projectId: 0,
|
||||||
|
sourceBuildId: 0,
|
||||||
|
configuration: {
|
||||||
|
buildFolder: '',
|
||||||
|
zipContents: true,
|
||||||
|
deleteOldPublishedBuilds: true,
|
||||||
|
releaseNoteTemplate: '',
|
||||||
|
storageSettings: {},
|
||||||
|
helpCenterSettings: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setSelectedProjectId(null);
|
||||||
|
}
|
||||||
|
}, [pkg, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: CreatePackageRequest) => {
|
||||||
|
try {
|
||||||
|
if (pkg) {
|
||||||
|
await updateMutation.mutateAsync({ id: pkg.id, request: data });
|
||||||
|
} else {
|
||||||
|
await createMutation.mutateAsync(data);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save package:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setCurrentTab(0);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const projects = projectsData?.data || [];
|
||||||
|
const builds = buildsData?.data || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<DialogTitle>
|
||||||
|
{pkg ? 'Edit Package' : 'Create Package'}
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogContent>
|
||||||
|
<Tabs value={currentTab} onChange={(_, newValue) => setCurrentTab(newValue)}>
|
||||||
|
<Tab label="Basic Info" />
|
||||||
|
<Tab label="Configuration" />
|
||||||
|
<Tab label="Storage" />
|
||||||
|
<Tab label="Help Center" />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* Basic Info Tab */}
|
||||||
|
<TabPanel value={currentTab} index={0}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Controller
|
||||||
|
name="title"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Title"
|
||||||
|
fullWidth
|
||||||
|
error={!!errors.title}
|
||||||
|
helperText={errors.title?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Controller
|
||||||
|
name="version"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Version"
|
||||||
|
fullWidth
|
||||||
|
error={!!errors.version}
|
||||||
|
helperText={errors.version?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Controller
|
||||||
|
name="description"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Description"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
error={!!errors.description}
|
||||||
|
helperText={errors.description?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Controller
|
||||||
|
name="projectId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControl fullWidth error={!!errors.projectId}>
|
||||||
|
<InputLabel>Project</InputLabel>
|
||||||
|
<Select
|
||||||
|
{...field}
|
||||||
|
label="Project"
|
||||||
|
>
|
||||||
|
{projects.map((project) => (
|
||||||
|
<MenuItem key={project.id} value={project.id}>
|
||||||
|
{project.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<Controller
|
||||||
|
name="sourceBuildId"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Autocomplete
|
||||||
|
options={builds}
|
||||||
|
getOptionLabel={(build: Build) =>
|
||||||
|
`${build.buildNumber} (${new Date(build.endTime).toLocaleDateString()})`
|
||||||
|
}
|
||||||
|
value={builds.find(b => b.id === field.value) || null}
|
||||||
|
onChange={(_, value) => field.onChange(value?.id || 0)}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label="Source Build"
|
||||||
|
error={!!errors.sourceBuildId}
|
||||||
|
helperText={errors.sourceBuildId?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
disabled={!selectedProjectId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Configuration Tab */}
|
||||||
|
<TabPanel value={currentTab} index={1}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Controller
|
||||||
|
name="configuration.buildFolder"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Build Folder Path"
|
||||||
|
fullWidth
|
||||||
|
error={!!errors.configuration?.buildFolder}
|
||||||
|
helperText={errors.configuration?.buildFolder?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Controller
|
||||||
|
name="configuration.zipContents"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch {...field} checked={field.value} />}
|
||||||
|
label="ZIP Contents"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Controller
|
||||||
|
name="configuration.deleteOldPublishedBuilds"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch {...field} checked={field.value} />}
|
||||||
|
label="Delete Old Published Builds"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Controller
|
||||||
|
name="configuration.releaseNoteTemplate"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
label="Release Note Template"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={4}
|
||||||
|
error={!!errors.configuration?.releaseNoteTemplate}
|
||||||
|
helperText={errors.configuration?.releaseNoteTemplate?.message}
|
||||||
|
placeholder="Template variables: {{VERSION}}, {{DATE}}, {{PROJECT}}"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Storage Tab */}
|
||||||
|
<TabPanel value={currentTab} index={2}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Storage Configuration
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
Configure cloud storage settings for package deployment.
|
||||||
|
</Typography>
|
||||||
|
{/* Storage configuration fields would go here */}
|
||||||
|
<TextField
|
||||||
|
label="Storage Provider"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
placeholder="AWS S3, Box.com, FTP"
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
{/* Help Center Tab */}
|
||||||
|
<TabPanel value={currentTab} index={3}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Help Center Configuration
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
Configure automatic help center article updates.
|
||||||
|
</Typography>
|
||||||
|
{/* Help center configuration fields would go here */}
|
||||||
|
<TextField
|
||||||
|
label="Article ID"
|
||||||
|
fullWidth
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleClose}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={!isValid || createMutation.isPending || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
{createMutation.isPending || updateMutation.isPending ? (
|
||||||
|
<LoadingSpinner size={20} />
|
||||||
|
) : pkg ? (
|
||||||
|
'Update'
|
||||||
|
) : (
|
||||||
|
'Create'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
270
src/Frontend/src/components/Packages/PackageList.tsx
Normal file
270
src/Frontend/src/components/Packages/PackageList.tsx
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
Grid,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
Chip,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TablePagination,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Add as AddIcon,
|
||||||
|
Edit as EditIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
|
Publish as PublishIcon,
|
||||||
|
Search as SearchIcon,
|
||||||
|
FilterList as FilterIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { usePackages, useDeletePackage } from '../../hooks/usePackages';
|
||||||
|
import { useProjects } from '../../hooks/useProjects';
|
||||||
|
import { Package, PackageStatus, PackageFilter } from '../../types';
|
||||||
|
import { StatusChip } from '../common/StatusChip';
|
||||||
|
import { LoadingSpinner } from '../common/LoadingSpinner';
|
||||||
|
import { ErrorDisplay } from '../common/ErrorDisplay';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface PackageListProps {
|
||||||
|
onCreatePackage: () => void;
|
||||||
|
onEditPackage: (packageItem: Package) => void;
|
||||||
|
onPublishPackage: (packageItem: Package) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PackageList: React.FC<PackageListProps> = ({
|
||||||
|
onCreatePackage,
|
||||||
|
onEditPackage,
|
||||||
|
onPublishPackage,
|
||||||
|
}) => {
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [filter, setFilter] = useState<PackageFilter>({});
|
||||||
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
|
|
||||||
|
const { data: packagesData, isLoading, error, refetch } = usePackages(
|
||||||
|
page + 1,
|
||||||
|
pageSize,
|
||||||
|
filter
|
||||||
|
);
|
||||||
|
const { data: projectsData } = useProjects();
|
||||||
|
const deletePackageMutation = useDeletePackage();
|
||||||
|
|
||||||
|
const handleFilterChange = (field: keyof PackageFilter, value: any) => {
|
||||||
|
setFilter(prev => ({ ...prev, [field]: value }));
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (window.confirm('Are you sure you want to delete this package?')) {
|
||||||
|
await deletePackageMutation.mutateAsync(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canPublish = (pkg: Package): boolean => {
|
||||||
|
return pkg.status === PackageStatus.Ready || pkg.status === PackageStatus.Failed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canEdit = (pkg: Package): boolean => {
|
||||||
|
return pkg.status !== PackageStatus.Publishing;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingSpinner message="Loading packages..." />;
|
||||||
|
if (error) return <ErrorDisplay message="Failed to load packages" onRetry={refetch} />;
|
||||||
|
|
||||||
|
const packages = packagesData?.data || [];
|
||||||
|
const totalCount = packagesData?.totalCount || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Header */}
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||||
|
<Typography variant="h4" component="h1">
|
||||||
|
Packages
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={onCreatePackage}
|
||||||
|
>
|
||||||
|
Create Package
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Card sx={{ mb: 3 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" gap={2} mb={showFilters ? 2 : 0}>
|
||||||
|
<TextField
|
||||||
|
placeholder="Search packages..."
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
value={filter.searchTerm || ''}
|
||||||
|
onChange={(e) => handleFilterChange('searchTerm', e.target.value)}
|
||||||
|
InputProps={{
|
||||||
|
startAdornment: <SearchIcon sx={{ mr: 1, color: 'text.secondary' }} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<FilterIcon />}
|
||||||
|
onClick={() => setShowFilters(!showFilters)}
|
||||||
|
>
|
||||||
|
{showFilters ? 'Hide Filters' : 'Show Filters'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{showFilters && (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>Project</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={filter.projectId || ''}
|
||||||
|
onChange={(e) => handleFilterChange('projectId', e.target.value || undefined)}
|
||||||
|
label="Project"
|
||||||
|
>
|
||||||
|
<MenuItem value="">All Projects</MenuItem>
|
||||||
|
{projectsData?.data?.map((project) => (
|
||||||
|
<MenuItem key={project.id} value={project.id}>
|
||||||
|
{project.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>Status</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={filter.status || ''}
|
||||||
|
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
|
||||||
|
label="Status"
|
||||||
|
>
|
||||||
|
<MenuItem value="">All Statuses</MenuItem>
|
||||||
|
{Object.values(PackageStatus).map((status) => (
|
||||||
|
<MenuItem key={status} value={status}>
|
||||||
|
{status}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Packages Table */}
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Title</TableCell>
|
||||||
|
<TableCell>Version</TableCell>
|
||||||
|
<TableCell>Project</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Published Date</TableCell>
|
||||||
|
<TableCell>Actions</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{packages.map((pkg) => (
|
||||||
|
<TableRow key={pkg.id} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="subtitle2">{pkg.title}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{pkg.description}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip label={pkg.version} variant="outlined" size="small" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{pkg.project?.name || 'Unknown'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusChip status={pkg.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{pkg.publishDate ? format(new Date(pkg.publishDate), 'MMM dd, yyyy HH:mm') : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Box display="flex" gap={1}>
|
||||||
|
{canEdit(pkg) && (
|
||||||
|
<Tooltip title="Edit Package">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => onEditPackage(pkg)}
|
||||||
|
>
|
||||||
|
<EditIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{canPublish(pkg) && (
|
||||||
|
<Tooltip title="Publish Package">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => onPublishPackage(pkg)}
|
||||||
|
>
|
||||||
|
<PublishIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{canEdit(pkg) && (
|
||||||
|
<Tooltip title="Delete Package">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => handleDelete(pkg.id)}
|
||||||
|
disabled={deletePackageMutation.isPending}
|
||||||
|
>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{packages.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
No packages found. Create your first package to get started.
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_, newPage) => setPage(newPage)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(e) => {
|
||||||
|
setPageSize(parseInt(e.target.value, 10));
|
||||||
|
setPage(0);
|
||||||
|
}}
|
||||||
|
rowsPerPageOptions={[5, 10, 25, 50]}
|
||||||
|
/>
|
||||||
|
</TableContainer>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
362
src/Frontend/src/components/Publishing/PublishingDashboard.tsx
Normal file
362
src/Frontend/src/components/Publishing/PublishingDashboard.tsx
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Alert,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Cancel as CancelIcon,
|
||||||
|
Refresh as RefreshIcon,
|
||||||
|
PlayArrow as PlayIcon,
|
||||||
|
Stop as StopIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import {
|
||||||
|
useActivePublications,
|
||||||
|
useCancelPublication,
|
||||||
|
useRetryPublication
|
||||||
|
} from '../../hooks/usePublications';
|
||||||
|
import { useSignalR } from '../../hooks/useSignalR';
|
||||||
|
import {
|
||||||
|
Publication,
|
||||||
|
PublishingStep,
|
||||||
|
PublishingProgressUpdate,
|
||||||
|
PublicationStatus,
|
||||||
|
StepStatus
|
||||||
|
} from '../../types';
|
||||||
|
import { ProgressBar } from '../common/ProgressBar';
|
||||||
|
import { StatusChip } from '../common/StatusChip';
|
||||||
|
import { LoadingSpinner } from '../common/LoadingSpinner';
|
||||||
|
import { ErrorDisplay } from '../common/ErrorDisplay';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
export const PublishingDashboard: React.FC = () => {
|
||||||
|
const [selectedPublication, setSelectedPublication] = useState<Publication | null>(null);
|
||||||
|
const [progressUpdates, setProgressUpdates] = useState<Map<number, PublishingProgressUpdate[]>>(new Map());
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: activePublications,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch
|
||||||
|
} = useActivePublications();
|
||||||
|
|
||||||
|
const {
|
||||||
|
subscribeToPublishingProgress,
|
||||||
|
subscribeToPublicationCompleted,
|
||||||
|
subscribeToPublicationFailed,
|
||||||
|
joinPublicationGroup,
|
||||||
|
leavePublicationGroup,
|
||||||
|
isConnected,
|
||||||
|
} = useSignalR();
|
||||||
|
|
||||||
|
const cancelMutation = useCancelPublication();
|
||||||
|
const retryMutation = useRetryPublication();
|
||||||
|
|
||||||
|
// Subscribe to real-time updates
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribeProgress = subscribeToPublishingProgress((update) => {
|
||||||
|
setProgressUpdates(prev => {
|
||||||
|
const publicationUpdates = prev.get(update.publicationId) || [];
|
||||||
|
const newUpdates = [...publicationUpdates, update];
|
||||||
|
|
||||||
|
// Keep only the last 50 updates per publication
|
||||||
|
if (newUpdates.length > 50) {
|
||||||
|
newUpdates.splice(0, newUpdates.length - 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMap = new Map(prev);
|
||||||
|
newMap.set(update.publicationId, newUpdates);
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubscribeCompleted = subscribeToPublicationCompleted((publicationId) => {
|
||||||
|
console.log(`Publication ${publicationId} completed`);
|
||||||
|
refetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
const unsubscribeFailed = subscribeToPublicationFailed(({ publicationId, error }) => {
|
||||||
|
console.error(`Publication ${publicationId} failed:`, error);
|
||||||
|
refetch();
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubscribeProgress();
|
||||||
|
unsubscribeCompleted();
|
||||||
|
unsubscribeFailed();
|
||||||
|
};
|
||||||
|
}, [subscribeToPublishingProgress, subscribeToPublicationCompleted, subscribeToPublicationFailed, refetch]);
|
||||||
|
|
||||||
|
// Join publication groups for real-time updates
|
||||||
|
useEffect(() => {
|
||||||
|
if (activePublications?.data) {
|
||||||
|
activePublications.data.forEach(publication => {
|
||||||
|
if (publication.status === PublicationStatus.InProgress) {
|
||||||
|
joinPublicationGroup(publication.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (activePublications?.data) {
|
||||||
|
activePublications.data.forEach(publication => {
|
||||||
|
leavePublicationGroup(publication.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [activePublications?.data, joinPublicationGroup, leavePublicationGroup]);
|
||||||
|
|
||||||
|
const handleCancel = async (publication: Publication) => {
|
||||||
|
if (window.confirm(`Are you sure you want to cancel the publication of "${publication.package?.title}"?`)) {
|
||||||
|
await cancelMutation.mutateAsync(publication.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = async (publication: Publication) => {
|
||||||
|
await retryMutation.mutateAsync(publication.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLatestProgressForPublication = (publicationId: number): PublishingProgressUpdate | null => {
|
||||||
|
const updates = progressUpdates.get(publicationId);
|
||||||
|
return updates && updates.length > 0 ? updates[updates.length - 1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateOverallProgress = (publication: Publication): number => {
|
||||||
|
const latestUpdate = getLatestProgressForPublication(publication.id);
|
||||||
|
if (latestUpdate) {
|
||||||
|
return latestUpdate.progressPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to calculating from steps if available
|
||||||
|
if (publication.steps && publication.steps.length > 0) {
|
||||||
|
const completedSteps = publication.steps.filter(step => step.status === StepStatus.Completed).length;
|
||||||
|
return (completedSteps / publication.steps.length) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingSpinner message="Loading active publications..." />;
|
||||||
|
if (error) return <ErrorDisplay message="Failed to load publications" onRetry={refetch} />;
|
||||||
|
|
||||||
|
const publications = activePublications?.data || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
{/* Header */}
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||||
|
<Typography variant="h4" component="h1">
|
||||||
|
Publishing Dashboard
|
||||||
|
</Typography>
|
||||||
|
<Box display="flex" gap={1}>
|
||||||
|
<Tooltip title={isConnected ? 'Connected' : 'Disconnected'}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 1,
|
||||||
|
bgcolor: isConnected ? 'success.light' : 'error.light',
|
||||||
|
color: isConnected ? 'success.contrastText' : 'error.contrastText',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: '50%',
|
||||||
|
bgcolor: isConnected ? 'success.main' : 'error.main',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{isConnected ? 'Live Updates' : 'Disconnected'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
<IconButton onClick={() => refetch()} title="Refresh">
|
||||||
|
<RefreshIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{publications.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent sx={{ textAlign: 'center', py: 6 }}>
|
||||||
|
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||||
|
No Active Publications
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
All publications are completed. Start a new publication from the Packages page.
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
{publications.map((publication) => {
|
||||||
|
const latestProgress = getLatestProgressForPublication(publication.id);
|
||||||
|
const overallProgress = calculateOverallProgress(publication);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid item xs={12} key={publication.id}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={2}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
{publication.package?.title || 'Unknown Package'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
Version: {publication.package?.version} |
|
||||||
|
Project: {publication.package?.project?.name}
|
||||||
|
</Typography>
|
||||||
|
<Box display="flex" gap={1} alignItems="center">
|
||||||
|
<StatusChip status={publication.status} />
|
||||||
|
{publication.publishedAt && (
|
||||||
|
<Chip
|
||||||
|
label={`Started ${format(new Date(publication.publishedAt), 'HH:mm:ss')}`}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box display="flex" gap={1}>
|
||||||
|
{publication.status === PublicationStatus.InProgress && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
startIcon={<CancelIcon />}
|
||||||
|
onClick={() => handleCancel(publication)}
|
||||||
|
disabled={cancelMutation.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{publication.status === PublicationStatus.Failed && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
startIcon={<PlayIcon />}
|
||||||
|
onClick={() => handleRetry(publication)}
|
||||||
|
disabled={retryMutation.isPending}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setSelectedPublication(publication)}
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box mb={2}>
|
||||||
|
<ProgressBar
|
||||||
|
progress={overallProgress}
|
||||||
|
status={latestProgress ? latestProgress.status : undefined}
|
||||||
|
stepName={latestProgress ? latestProgress.stepName : 'Initializing...'}
|
||||||
|
details={latestProgress ? latestProgress.details : undefined}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{publication.status === PublicationStatus.Failed && (
|
||||||
|
<Alert severity="error" sx={{ mt: 1 }}>
|
||||||
|
Publication failed. Check the details for more information.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Publication Details Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!selectedPublication}
|
||||||
|
onClose={() => setSelectedPublication(null)}
|
||||||
|
maxWidth="md"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{selectedPublication && (
|
||||||
|
<>
|
||||||
|
<DialogTitle>
|
||||||
|
Publication Details - {selectedPublication.package?.title}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box mb={3}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Status
|
||||||
|
</Typography>
|
||||||
|
<StatusChip status={selectedPublication.status} />
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={6}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Started
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{selectedPublication.publishedAt ?
|
||||||
|
format(new Date(selectedPublication.publishedAt), 'MMM dd, yyyy HH:mm:ss') :
|
||||||
|
'Not started'
|
||||||
|
}
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Progress Updates
|
||||||
|
</Typography>
|
||||||
|
<List sx={{ maxHeight: 300, overflow: 'auto' }}>
|
||||||
|
{progressUpdates.get(selectedPublication.id)?.map((update, index) => (
|
||||||
|
<ListItem key={index} sx={{ px: 0 }}>
|
||||||
|
<ProgressBar
|
||||||
|
progress={update.progressPercent}
|
||||||
|
status={update.status}
|
||||||
|
stepName={update.stepName}
|
||||||
|
details={update.details}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
)) || (
|
||||||
|
<ListItem sx={{ px: 0 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
No progress updates available
|
||||||
|
</Typography>
|
||||||
|
</ListItem>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setSelectedPublication(null)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
49
src/Frontend/src/components/common/ErrorDisplay.tsx
Normal file
49
src/Frontend/src/components/common/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertTitle,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Typography
|
||||||
|
} from '@mui/material';
|
||||||
|
import { Refresh as RefreshIcon } from '@mui/icons-material';
|
||||||
|
|
||||||
|
interface ErrorDisplayProps {
|
||||||
|
message?: string;
|
||||||
|
error?: Error | null;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
|
||||||
|
message = 'An error occurred',
|
||||||
|
error,
|
||||||
|
onRetry
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Box py={4}>
|
||||||
|
<Alert severity="error">
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<Typography variant="body2" gutterBottom>
|
||||||
|
{message}
|
||||||
|
</Typography>
|
||||||
|
{error && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{error.message}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{onRetry && (
|
||||||
|
<Box mt={2}>
|
||||||
|
<Button
|
||||||
|
startIcon={<RefreshIcon />}
|
||||||
|
onClick={onRetry}
|
||||||
|
variant="outlined"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
29
src/Frontend/src/components/common/LoadingSpinner.tsx
Normal file
29
src/Frontend/src/components/common/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
message?: string;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||||
|
message = 'Loading...',
|
||||||
|
size = 40
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
display="flex"
|
||||||
|
flexDirection="column"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
py={4}
|
||||||
|
>
|
||||||
|
<CircularProgress size={size} />
|
||||||
|
{message && (
|
||||||
|
<Typography variant="body2" color="text.secondary" mt={2}>
|
||||||
|
{message}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
82
src/Frontend/src/components/common/ProgressBar.tsx
Normal file
82
src/Frontend/src/components/common/ProgressBar.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
LinearProgress,
|
||||||
|
Typography,
|
||||||
|
Paper,
|
||||||
|
Chip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { StepStatus } from '../../types';
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
progress: number;
|
||||||
|
status?: StepStatus;
|
||||||
|
stepName?: string;
|
||||||
|
details?: string;
|
||||||
|
showPercentage?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||||
|
progress,
|
||||||
|
status,
|
||||||
|
stepName,
|
||||||
|
details,
|
||||||
|
showPercentage = true,
|
||||||
|
}) => {
|
||||||
|
const getProgressColor = (status?: StepStatus): 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' => {
|
||||||
|
switch (status) {
|
||||||
|
case StepStatus.Completed:
|
||||||
|
return 'success';
|
||||||
|
case StepStatus.Failed:
|
||||||
|
return 'error';
|
||||||
|
case StepStatus.InProgress:
|
||||||
|
return 'primary';
|
||||||
|
default:
|
||||||
|
return 'primary';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper elevation={1} sx={{ p: 2, mb: 1 }}>
|
||||||
|
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
|
||||||
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
|
{stepName && (
|
||||||
|
<Typography variant="subtitle2" color="text.primary">
|
||||||
|
{stepName}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{status && (
|
||||||
|
<Chip
|
||||||
|
label={status}
|
||||||
|
size="small"
|
||||||
|
color={getProgressColor(status)}
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{showPercentage && (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{Math.round(progress)}%
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<LinearProgress
|
||||||
|
variant="determinate"
|
||||||
|
value={Math.min(progress, 100)}
|
||||||
|
color={getProgressColor(status)}
|
||||||
|
sx={{
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
mb: details ? 1 : 0
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{details && (
|
||||||
|
<Typography variant="body2" color="text.secondary" mt={1}>
|
||||||
|
{details}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
85
src/Frontend/src/components/common/StatusChip.tsx
Normal file
85
src/Frontend/src/components/common/StatusChip.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Chip, ChipProps } from '@mui/material';
|
||||||
|
import {
|
||||||
|
BuildStatus,
|
||||||
|
PackageStatus,
|
||||||
|
PublicationStatus,
|
||||||
|
StepStatus
|
||||||
|
} from '../../types';
|
||||||
|
|
||||||
|
interface StatusChipProps extends Omit<ChipProps, 'color'> {
|
||||||
|
status: BuildStatus | PackageStatus | PublicationStatus | StepStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusChip: React.FC<StatusChipProps> = ({ status, ...props }) => {
|
||||||
|
const getStatusColor = (status: string): ChipProps['color'] => {
|
||||||
|
switch (status) {
|
||||||
|
case BuildStatus.Success:
|
||||||
|
case PackageStatus.Published:
|
||||||
|
case PublicationStatus.Completed:
|
||||||
|
case StepStatus.Completed:
|
||||||
|
return 'success';
|
||||||
|
|
||||||
|
case BuildStatus.Failure:
|
||||||
|
case PackageStatus.Failed:
|
||||||
|
case PublicationStatus.Failed:
|
||||||
|
case StepStatus.Failed:
|
||||||
|
return 'error';
|
||||||
|
|
||||||
|
case BuildStatus.InProgress:
|
||||||
|
case PackageStatus.Publishing:
|
||||||
|
case PublicationStatus.InProgress:
|
||||||
|
case StepStatus.InProgress:
|
||||||
|
return 'info';
|
||||||
|
|
||||||
|
case PackageStatus.Ready:
|
||||||
|
case PublicationStatus.Queued:
|
||||||
|
case StepStatus.Pending:
|
||||||
|
return 'warning';
|
||||||
|
|
||||||
|
case PackageStatus.Draft:
|
||||||
|
case PublicationStatus.Cancelled:
|
||||||
|
case StepStatus.Skipped:
|
||||||
|
return 'default';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case BuildStatus.InProgress:
|
||||||
|
case PackageStatus.Publishing:
|
||||||
|
case PublicationStatus.InProgress:
|
||||||
|
case StepStatus.InProgress:
|
||||||
|
return '🔄';
|
||||||
|
|
||||||
|
case BuildStatus.Success:
|
||||||
|
case PackageStatus.Published:
|
||||||
|
case PublicationStatus.Completed:
|
||||||
|
case StepStatus.Completed:
|
||||||
|
return '✅';
|
||||||
|
|
||||||
|
case BuildStatus.Failure:
|
||||||
|
case PackageStatus.Failed:
|
||||||
|
case PublicationStatus.Failed:
|
||||||
|
case StepStatus.Failed:
|
||||||
|
return '❌';
|
||||||
|
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const icon = getStatusIcon(status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
label={`${icon ? icon + ' ' : ''}${status}`}
|
||||||
|
color={getStatusColor(status)}
|
||||||
|
size="small"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
26
src/Frontend/src/contexts/QueryProvider.tsx
Normal file
26
src/Frontend/src/contexts/QueryProvider.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: 3,
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface QueryProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QueryProvider: React.FC<QueryProviderProps> = ({ children }) => {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
76
src/Frontend/src/hooks/usePackages.ts
Normal file
76
src/Frontend/src/hooks/usePackages.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { packageService } from '../services/packageService';
|
||||||
|
import {
|
||||||
|
Package,
|
||||||
|
CreatePackageRequest,
|
||||||
|
PackageFilter,
|
||||||
|
PaginatedResponse
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const usePackages = (
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 10,
|
||||||
|
filter?: PackageFilter
|
||||||
|
) => {
|
||||||
|
return useQuery<PaginatedResponse<Package>>({
|
||||||
|
queryKey: ['packages', page, pageSize, filter],
|
||||||
|
queryFn: () => packageService.getPackages(page, pageSize, filter),
|
||||||
|
keepPreviousData: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePackage = (id: number) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['package', id],
|
||||||
|
queryFn: () => packageService.getPackage(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCreatePackage = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (request: CreatePackageRequest) => packageService.createPackage(request),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['packages'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useUpdatePackage = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ id, request }: { id: number; request: Partial<CreatePackageRequest> }) =>
|
||||||
|
packageService.updatePackage(id, request),
|
||||||
|
onSuccess: (_, { id }) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['package', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['packages'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDeletePackage = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => packageService.deletePackage(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['packages'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePublishPackage = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ packageId, buildId }: { packageId: number; buildId: number }) =>
|
||||||
|
packageService.publishPackage(packageId, buildId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['packages'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['publications'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
56
src/Frontend/src/hooks/useProjects.ts
Normal file
56
src/Frontend/src/hooks/useProjects.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { projectService } from '../services/projectService';
|
||||||
|
import { BuildFilter } from '../types';
|
||||||
|
|
||||||
|
export const useProjects = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['projects'],
|
||||||
|
queryFn: () => projectService.getProjects(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useProject = (id: number) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['project', id],
|
||||||
|
queryFn: () => projectService.getProject(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useProjectBuilds = (
|
||||||
|
projectId: number,
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 10,
|
||||||
|
filter?: BuildFilter
|
||||||
|
) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['project-builds', projectId, page, pageSize, filter],
|
||||||
|
queryFn: () => projectService.getProjectBuilds(projectId, page, pageSize, filter),
|
||||||
|
enabled: !!projectId,
|
||||||
|
keepPreviousData: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useBuild = (buildId: number) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['build', buildId],
|
||||||
|
queryFn: () => projectService.getBuild(buildId),
|
||||||
|
enabled: !!buildId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useBuildCommits = (buildId: number) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['build-commits', buildId],
|
||||||
|
queryFn: () => projectService.getBuildCommits(buildId),
|
||||||
|
enabled: !!buildId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useProjectStatus = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['project-status'],
|
||||||
|
queryFn: () => projectService.getProjectStatus(),
|
||||||
|
refetchInterval: 30000, // Refresh every 30 seconds
|
||||||
|
});
|
||||||
|
};
|
||||||
58
src/Frontend/src/hooks/usePublications.ts
Normal file
58
src/Frontend/src/hooks/usePublications.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { publicationService } from '../services/publicationService';
|
||||||
|
|
||||||
|
export const usePublications = (page: number = 1, pageSize: number = 10) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['publications', page, pageSize],
|
||||||
|
queryFn: () => publicationService.getPublications(page, pageSize),
|
||||||
|
keepPreviousData: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePublication = (id: number) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['publication', id],
|
||||||
|
queryFn: () => publicationService.getPublication(id),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePublicationSteps = (publicationId: number) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['publication-steps', publicationId],
|
||||||
|
queryFn: () => publicationService.getPublicationSteps(publicationId),
|
||||||
|
enabled: !!publicationId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useActivePublications = () => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['active-publications'],
|
||||||
|
queryFn: () => publicationService.getActivePublications(),
|
||||||
|
refetchInterval: 5000, // Refresh every 5 seconds for active publications
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCancelPublication = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => publicationService.cancelPublication(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['publications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['active-publications'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useRetryPublication = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => publicationService.retryPublication(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['publications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['active-publications'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
88
src/Frontend/src/hooks/useSignalR.ts
Normal file
88
src/Frontend/src/hooks/useSignalR.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { useEffect, useCallback } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { signalRService } from '../services/signalRService';
|
||||||
|
import { PublishingProgressUpdate, BuildStatusUpdate } from '../types';
|
||||||
|
|
||||||
|
export const useSignalR = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const startConnection = async () => {
|
||||||
|
try {
|
||||||
|
await signalRService.start();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start SignalR connection:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
startConnection();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
signalRService.stop();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const subscribeToPublishingProgress = useCallback(
|
||||||
|
(callback: (update: PublishingProgressUpdate) => void) => {
|
||||||
|
return signalRService.subscribe<PublishingProgressUpdate>('PublishingProgress', callback);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscribeToBuildStatusUpdates = useCallback(
|
||||||
|
(callback: (update: BuildStatusUpdate) => void) => {
|
||||||
|
return signalRService.subscribe<BuildStatusUpdate>('BuildStatusUpdate', (update) => {
|
||||||
|
// Invalidate related queries when build status changes
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-status'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['project-builds'] });
|
||||||
|
callback(update);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscribeToPublicationCompleted = useCallback(
|
||||||
|
(callback: (publicationId: number) => void) => {
|
||||||
|
return signalRService.subscribe<number>('PublicationCompleted', (publicationId) => {
|
||||||
|
// Invalidate publication-related queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['publications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['active-publications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['publication', publicationId] });
|
||||||
|
callback(publicationId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
const subscribeToPublicationFailed = useCallback(
|
||||||
|
(callback: (data: { publicationId: number; error: string }) => void) => {
|
||||||
|
return signalRService.subscribe<{ publicationId: number; error: string }>('PublicationFailed', (data) => {
|
||||||
|
// Invalidate publication-related queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['publications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['active-publications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['publication', data.publicationId] });
|
||||||
|
callback(data);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
const joinPublicationGroup = useCallback(async (publicationId: number) => {
|
||||||
|
await signalRService.joinPublicationGroup(publicationId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const leavePublicationGroup = useCallback(async (publicationId: number) => {
|
||||||
|
await signalRService.leavePublicationGroup(publicationId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConnected: signalRService.isConnected,
|
||||||
|
connectionState: signalRService.connectionState,
|
||||||
|
subscribeToPublishingProgress,
|
||||||
|
subscribeToBuildStatusUpdates,
|
||||||
|
subscribeToPublicationCompleted,
|
||||||
|
subscribeToPublicationFailed,
|
||||||
|
joinPublicationGroup,
|
||||||
|
leavePublicationGroup,
|
||||||
|
};
|
||||||
|
};
|
||||||
9
src/Frontend/src/main.tsx
Normal file
9
src/Frontend/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
262
src/Frontend/src/pages/DashboardPage.tsx
Normal file
262
src/Frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Typography,
|
||||||
|
Grid,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
ListItemText,
|
||||||
|
ListItemIcon,
|
||||||
|
Chip,
|
||||||
|
Paper,
|
||||||
|
} from '@mui/material';
|
||||||
|
import {
|
||||||
|
Build as BuildIcon,
|
||||||
|
Package as PackageIcon,
|
||||||
|
Publish as PublishIcon,
|
||||||
|
CheckCircle as SuccessIcon,
|
||||||
|
Error as ErrorIcon,
|
||||||
|
Schedule as InProgressIcon,
|
||||||
|
} from '@mui/icons-material';
|
||||||
|
import { usePackages } from '../hooks/usePackages';
|
||||||
|
import { useProjectStatus } from '../hooks/useProjects';
|
||||||
|
import { useActivePublications } from '../hooks/usePublications';
|
||||||
|
import { StatusChip } from '../components/common/StatusChip';
|
||||||
|
import { LoadingSpinner } from '../components/common/LoadingSpinner';
|
||||||
|
import { ErrorDisplay } from '../components/common/ErrorDisplay';
|
||||||
|
import { PublicationStatus, PackageStatus, BuildStatus } from '../types';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
export const DashboardPage: React.FC = () => {
|
||||||
|
const { data: packagesData, isLoading: packagesLoading } = usePackages(1, 5);
|
||||||
|
const { data: projectsData, isLoading: projectsLoading } = useProjectStatus();
|
||||||
|
const { data: activePublications, isLoading: publicationsLoading } = useActivePublications();
|
||||||
|
|
||||||
|
const isLoading = packagesLoading || projectsLoading || publicationsLoading;
|
||||||
|
|
||||||
|
if (isLoading) return <LoadingSpinner message="Loading dashboard..." />;
|
||||||
|
|
||||||
|
const packages = packagesData?.data || [];
|
||||||
|
const projects = projectsData?.data || [];
|
||||||
|
const publications = activePublications?.data || [];
|
||||||
|
|
||||||
|
// Calculate statistics
|
||||||
|
const packageStats = {
|
||||||
|
total: packagesData?.totalCount || 0,
|
||||||
|
published: packages.filter(p => p.status === PackageStatus.Published).length,
|
||||||
|
failed: packages.filter(p => p.status === PackageStatus.Failed).length,
|
||||||
|
publishing: packages.filter(p => p.status === PackageStatus.Publishing).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildStats = {
|
||||||
|
success: projects.filter(p => p.status === BuildStatus.Success).length,
|
||||||
|
failure: projects.filter(p => p.status === BuildStatus.Failure).length,
|
||||||
|
inProgress: projects.filter(p => p.status === BuildStatus.InProgress).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const publicationStats = {
|
||||||
|
active: publications.filter(p => p.status === PublicationStatus.InProgress).length,
|
||||||
|
queued: publications.filter(p => p.status === PublicationStatus.Queued).length,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
Dashboard
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
<Grid container spacing={3} mb={4}>
|
||||||
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Typography color="text.secondary" gutterBottom variant="h6">
|
||||||
|
Packages
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4">
|
||||||
|
{packageStats.total}
|
||||||
|
</Typography>
|
||||||
|
<Box display="flex" gap={1} mt={1}>
|
||||||
|
<Chip label={`${packageStats.published} Published`} size="small" color="success" />
|
||||||
|
<Chip label={`${packageStats.publishing} Publishing`} size="small" color="info" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<PackageIcon sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Typography color="text.secondary" gutterBottom variant="h6">
|
||||||
|
Projects
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4">
|
||||||
|
{projects.length}
|
||||||
|
</Typography>
|
||||||
|
<Box display="flex" gap={1} mt={1}>
|
||||||
|
<Chip label={`${buildStats.success} Success`} size="small" color="success" />
|
||||||
|
<Chip label={`${buildStats.failure} Failed`} size="small" color="error" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<BuildIcon sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Typography color="text.secondary" gutterBottom variant="h6">
|
||||||
|
Active Publications
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h4">
|
||||||
|
{publicationStats.active}
|
||||||
|
</Typography>
|
||||||
|
<Box display="flex" gap={1} mt={1}>
|
||||||
|
<Chip label={`${publicationStats.queued} Queued`} size="small" color="warning" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<PublishIcon sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} sm={6} md={3}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||||
|
<Box>
|
||||||
|
<Typography color="text.secondary" gutterBottom variant="h6">
|
||||||
|
System Status
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h6" color="success.main">
|
||||||
|
All Systems Operational
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<SuccessIcon sx={{ fontSize: 40, color: 'success.main' }} />
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Recent Activity */}
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Recent Packages
|
||||||
|
</Typography>
|
||||||
|
<List>
|
||||||
|
{packages.length === 0 ? (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="No packages found"
|
||||||
|
secondary="Create your first package to get started"
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
) : (
|
||||||
|
packages.map((pkg) => (
|
||||||
|
<ListItem key={pkg.id}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<PackageIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={pkg.title}
|
||||||
|
secondary={`Version ${pkg.version} • ${pkg.project?.name}`}
|
||||||
|
/>
|
||||||
|
<StatusChip status={pkg.status} />
|
||||||
|
</ListItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Project Build Status
|
||||||
|
</Typography>
|
||||||
|
<List>
|
||||||
|
{projects.length === 0 ? (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemText
|
||||||
|
primary="No projects found"
|
||||||
|
secondary="Projects will appear here once configured"
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
) : (
|
||||||
|
projects.slice(0, 5).map((project) => (
|
||||||
|
<ListItem key={project.id}>
|
||||||
|
<ListItemIcon>
|
||||||
|
{project.status === BuildStatus.Success ? (
|
||||||
|
<SuccessIcon color="success" />
|
||||||
|
) : project.status === BuildStatus.Failure ? (
|
||||||
|
<ErrorIcon color="error" />
|
||||||
|
) : (
|
||||||
|
<InProgressIcon color="info" />
|
||||||
|
)}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={project.name}
|
||||||
|
secondary={project.description}
|
||||||
|
/>
|
||||||
|
<StatusChip status={project.status} />
|
||||||
|
</ListItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{publications.length > 0 && (
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Card>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Active Publications
|
||||||
|
</Typography>
|
||||||
|
<List>
|
||||||
|
{publications.map((publication) => (
|
||||||
|
<ListItem key={publication.id}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<PublishIcon />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={publication.package?.title}
|
||||||
|
secondary={`Version ${publication.package?.version} • Started ${
|
||||||
|
publication.publishedAt ?
|
||||||
|
format(new Date(publication.publishedAt), 'MMM dd, HH:mm') :
|
||||||
|
'Unknown'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<StatusChip status={publication.status} />
|
||||||
|
</ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
146
src/Frontend/src/pages/PackagesPage.tsx
Normal file
146
src/Frontend/src/pages/PackagesPage.tsx
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
|
DialogContent,
|
||||||
|
DialogActions,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select,
|
||||||
|
MenuItem,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { PackageList } from '../components/Packages/PackageList';
|
||||||
|
import { PackageForm } from '../components/Packages/PackageForm';
|
||||||
|
import { usePublishPackage } from '../hooks/usePackages';
|
||||||
|
import { useProjectBuilds } from '../hooks/useProjects';
|
||||||
|
import { Package, Build } from '../types';
|
||||||
|
|
||||||
|
export const PackagesPage: React.FC = () => {
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
const [editingPackage, setEditingPackage] = useState<Package | null>(null);
|
||||||
|
const [publishingPackage, setPublishingPackage] = useState<Package | null>(null);
|
||||||
|
const [selectedBuildId, setSelectedBuildId] = useState<number>(0);
|
||||||
|
|
||||||
|
const publishMutation = usePublishPackage();
|
||||||
|
const { data: buildsData } = useProjectBuilds(
|
||||||
|
publishingPackage?.projectId || 0,
|
||||||
|
1,
|
||||||
|
50,
|
||||||
|
{ status: 'Success' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCreatePackage = () => {
|
||||||
|
setShowCreateForm(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditPackage = (packageItem: Package) => {
|
||||||
|
setEditingPackage(packageItem);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublishPackage = (packageItem: Package) => {
|
||||||
|
setPublishingPackage(packageItem);
|
||||||
|
setSelectedBuildId(packageItem.sourceBuildId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseCreateForm = () => {
|
||||||
|
setShowCreateForm(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseEditForm = () => {
|
||||||
|
setEditingPackage(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmPublish = async () => {
|
||||||
|
if (publishingPackage && selectedBuildId) {
|
||||||
|
try {
|
||||||
|
await publishMutation.mutateAsync({
|
||||||
|
packageId: publishingPackage.id,
|
||||||
|
buildId: selectedBuildId,
|
||||||
|
});
|
||||||
|
setPublishingPackage(null);
|
||||||
|
setSelectedBuildId(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to publish package:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelPublish = () => {
|
||||||
|
setPublishingPackage(null);
|
||||||
|
setSelectedBuildId(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const builds = buildsData?.data || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PackageList
|
||||||
|
onCreatePackage={handleCreatePackage}
|
||||||
|
onEditPackage={handleEditPackage}
|
||||||
|
onPublishPackage={handlePublishPackage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Create Package Form */}
|
||||||
|
<PackageForm
|
||||||
|
open={showCreateForm}
|
||||||
|
onClose={handleCloseCreateForm}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Edit Package Form */}
|
||||||
|
<PackageForm
|
||||||
|
open={!!editingPackage}
|
||||||
|
onClose={handleCloseEditForm}
|
||||||
|
package={editingPackage || undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Publish Package Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={!!publishingPackage}
|
||||||
|
onClose={handleCancelPublish}
|
||||||
|
maxWidth="sm"
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<DialogTitle>
|
||||||
|
Publish Package
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body1" gutterBottom>
|
||||||
|
Are you sure you want to publish "<strong>{publishingPackage?.title}</strong>"?
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||||
|
This will start the publishing workflow and deploy the package to configured storage and help center locations.
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<FormControl fullWidth margin="normal">
|
||||||
|
<InputLabel>Source Build</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={selectedBuildId}
|
||||||
|
onChange={(e) => setSelectedBuildId(Number(e.target.value))}
|
||||||
|
label="Source Build"
|
||||||
|
>
|
||||||
|
{builds.map((build: Build) => (
|
||||||
|
<MenuItem key={build.id} value={build.id}>
|
||||||
|
{build.buildNumber} ({new Date(build.endTime).toLocaleDateString()})
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleCancelPublish}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmPublish}
|
||||||
|
variant="contained"
|
||||||
|
disabled={!selectedBuildId || publishMutation.isPending}
|
||||||
|
>
|
||||||
|
{publishMutation.isPending ? 'Publishing...' : 'Publish'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
6
src/Frontend/src/pages/PublishingPage.tsx
Normal file
6
src/Frontend/src/pages/PublishingPage.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { PublishingDashboard } from '../components/Publishing/PublishingDashboard';
|
||||||
|
|
||||||
|
export const PublishingPage: React.FC = () => {
|
||||||
|
return <PublishingDashboard />;
|
||||||
|
};
|
||||||
74
src/Frontend/src/services/api.ts
Normal file
74
src/Frontend/src/services/api.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||||
|
import { ApiResponse, PaginatedResponse } from '../types';
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private client: AxiosInstance;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setupInterceptors();
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupInterceptors() {
|
||||||
|
// Request interceptor to add auth token
|
||||||
|
this.client.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor for error handling
|
||||||
|
this.client.interceptors.response.use(
|
||||||
|
(response: AxiosResponse) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T>(url: string, params?: Record<string, any>): Promise<ApiResponse<T>> {
|
||||||
|
const response = await this.client.get(url, { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async post<T>(url: string, data?: any): Promise<ApiResponse<T>> {
|
||||||
|
const response = await this.client.post(url, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put<T>(url: string, data?: any): Promise<ApiResponse<T>> {
|
||||||
|
const response = await this.client.put(url, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete<T>(url: string): Promise<ApiResponse<T>> {
|
||||||
|
const response = await this.client.delete(url);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPaginated<T>(
|
||||||
|
url: string,
|
||||||
|
params?: Record<string, any>
|
||||||
|
): Promise<PaginatedResponse<T>> {
|
||||||
|
const response = await this.client.get(url, { params });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiClient = new ApiClient();
|
||||||
42
src/Frontend/src/services/packageService.ts
Normal file
42
src/Frontend/src/services/packageService.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { apiClient } from './api';
|
||||||
|
import {
|
||||||
|
Package,
|
||||||
|
CreatePackageRequest,
|
||||||
|
PackageFilter,
|
||||||
|
PaginatedResponse,
|
||||||
|
ApiResponse
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const packageService = {
|
||||||
|
async getPackages(
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 10,
|
||||||
|
filter?: PackageFilter
|
||||||
|
): Promise<PaginatedResponse<Package>> {
|
||||||
|
return apiClient.getPaginated('/packages', {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
...filter,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPackage(id: number): Promise<ApiResponse<Package>> {
|
||||||
|
return apiClient.get(`/packages/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createPackage(request: CreatePackageRequest): Promise<ApiResponse<Package>> {
|
||||||
|
return apiClient.post('/packages', request);
|
||||||
|
},
|
||||||
|
|
||||||
|
async updatePackage(id: number, request: Partial<CreatePackageRequest>): Promise<ApiResponse<Package>> {
|
||||||
|
return apiClient.put(`/packages/${id}`, request);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deletePackage(id: number): Promise<ApiResponse<void>> {
|
||||||
|
return apiClient.delete(`/packages/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async publishPackage(packageId: number, buildId: number): Promise<ApiResponse<void>> {
|
||||||
|
return apiClient.post(`/packages/${packageId}/publish`, { buildId });
|
||||||
|
},
|
||||||
|
};
|
||||||
44
src/Frontend/src/services/projectService.ts
Normal file
44
src/Frontend/src/services/projectService.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { apiClient } from './api';
|
||||||
|
import {
|
||||||
|
Project,
|
||||||
|
Build,
|
||||||
|
BuildCommit,
|
||||||
|
BuildFilter,
|
||||||
|
PaginatedResponse,
|
||||||
|
ApiResponse
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const projectService = {
|
||||||
|
async getProjects(): Promise<ApiResponse<Project[]>> {
|
||||||
|
return apiClient.get('/projects');
|
||||||
|
},
|
||||||
|
|
||||||
|
async getProject(id: number): Promise<ApiResponse<Project>> {
|
||||||
|
return apiClient.get(`/projects/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getProjectBuilds(
|
||||||
|
projectId: number,
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 10,
|
||||||
|
filter?: BuildFilter
|
||||||
|
): Promise<PaginatedResponse<Build>> {
|
||||||
|
return apiClient.getPaginated(`/projects/${projectId}/builds`, {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
...filter,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBuild(buildId: number): Promise<ApiResponse<Build>> {
|
||||||
|
return apiClient.get(`/builds/${buildId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBuildCommits(buildId: number): Promise<ApiResponse<BuildCommit[]>> {
|
||||||
|
return apiClient.get(`/builds/${buildId}/commits`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getProjectStatus(): Promise<ApiResponse<Project[]>> {
|
||||||
|
return apiClient.get('/projects/status');
|
||||||
|
},
|
||||||
|
};
|
||||||
36
src/Frontend/src/services/publicationService.ts
Normal file
36
src/Frontend/src/services/publicationService.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { apiClient } from './api';
|
||||||
|
import {
|
||||||
|
Publication,
|
||||||
|
PublishingStep,
|
||||||
|
PaginatedResponse,
|
||||||
|
ApiResponse
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
export const publicationService = {
|
||||||
|
async getPublications(
|
||||||
|
page: number = 1,
|
||||||
|
pageSize: number = 10
|
||||||
|
): Promise<PaginatedResponse<Publication>> {
|
||||||
|
return apiClient.getPaginated('/publications', { page, pageSize });
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPublication(id: number): Promise<ApiResponse<Publication>> {
|
||||||
|
return apiClient.get(`/publications/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPublicationSteps(publicationId: number): Promise<ApiResponse<PublishingStep[]>> {
|
||||||
|
return apiClient.get(`/publications/${publicationId}/steps`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getActivePublications(): Promise<ApiResponse<Publication[]>> {
|
||||||
|
return apiClient.get('/publications/active');
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancelPublication(id: number): Promise<ApiResponse<void>> {
|
||||||
|
return apiClient.post(`/publications/${id}/cancel`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async retryPublication(id: number): Promise<ApiResponse<void>> {
|
||||||
|
return apiClient.post(`/publications/${id}/retry`);
|
||||||
|
},
|
||||||
|
};
|
||||||
108
src/Frontend/src/services/signalRService.ts
Normal file
108
src/Frontend/src/services/signalRService.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import * as signalR from '@microsoft/signalr';
|
||||||
|
import { PublishingProgressUpdate, BuildStatusUpdate } from '../types';
|
||||||
|
|
||||||
|
export class SignalRService {
|
||||||
|
private connection: signalR.HubConnection | null = null;
|
||||||
|
private listeners: Map<string, Set<(data: any) => void>> = new Map();
|
||||||
|
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.connection?.state === signalR.HubConnectionState.Connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connection = new signalR.HubConnectionBuilder()
|
||||||
|
.withUrl('/hubs/publishing', {
|
||||||
|
accessTokenFactory: () => localStorage.getItem('authToken') || '',
|
||||||
|
})
|
||||||
|
.withAutomaticReconnect()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.setupEventHandlers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.connection.start();
|
||||||
|
console.log('SignalR connected successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('SignalR connection failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.connection) {
|
||||||
|
await this.connection.stop();
|
||||||
|
this.connection = null;
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventHandlers(): void {
|
||||||
|
if (!this.connection) return;
|
||||||
|
|
||||||
|
// Publishing progress updates
|
||||||
|
this.connection.on('PublishingProgress', (update: PublishingProgressUpdate) => {
|
||||||
|
this.notifyListeners('PublishingProgress', update);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build status updates
|
||||||
|
this.connection.on('BuildStatusUpdate', (update: BuildStatusUpdate) => {
|
||||||
|
this.notifyListeners('BuildStatusUpdate', update);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publication completed
|
||||||
|
this.connection.on('PublicationCompleted', (publicationId: number) => {
|
||||||
|
this.notifyListeners('PublicationCompleted', publicationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publication failed
|
||||||
|
this.connection.on('PublicationFailed', (data: { publicationId: number; error: string }) => {
|
||||||
|
this.notifyListeners('PublicationFailed', data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifyListeners(event: string, data: any): void {
|
||||||
|
const eventListeners = this.listeners.get(event);
|
||||||
|
if (eventListeners) {
|
||||||
|
eventListeners.forEach(callback => callback(data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe<T>(event: string, callback: (data: T) => void): () => void {
|
||||||
|
if (!this.listeners.has(event)) {
|
||||||
|
this.listeners.set(event, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventListeners = this.listeners.get(event)!;
|
||||||
|
eventListeners.add(callback);
|
||||||
|
|
||||||
|
// Return unsubscribe function
|
||||||
|
return () => {
|
||||||
|
eventListeners.delete(callback);
|
||||||
|
if (eventListeners.size === 0) {
|
||||||
|
this.listeners.delete(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async joinPublicationGroup(publicationId: number): Promise<void> {
|
||||||
|
if (this.connection?.state === signalR.HubConnectionState.Connected) {
|
||||||
|
await this.connection.invoke('JoinPublicationGroup', publicationId.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async leavePublicationGroup(publicationId: number): Promise<void> {
|
||||||
|
if (this.connection?.state === signalR.HubConnectionState.Connected) {
|
||||||
|
await this.connection.invoke('LeavePublicationGroup', publicationId.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get connectionState(): signalR.HubConnectionState {
|
||||||
|
return this.connection?.state || signalR.HubConnectionState.Disconnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnected(): boolean {
|
||||||
|
return this.connection?.state === signalR.HubConnectionState.Connected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const signalRService = new SignalRService();
|
||||||
222
src/Frontend/src/types/index.ts
Normal file
222
src/Frontend/src/types/index.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
// Core domain types based on implementation.md architecture
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
role: string;
|
||||||
|
createdAt: string;
|
||||||
|
lastLogin: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
ccNetProjectName: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Build {
|
||||||
|
id: number;
|
||||||
|
projectId: number;
|
||||||
|
buildNumber: string;
|
||||||
|
status: BuildStatus;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
logPath: string;
|
||||||
|
artifactPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum BuildStatus {
|
||||||
|
Success = 'Success',
|
||||||
|
Failure = 'Failure',
|
||||||
|
InProgress = 'InProgress',
|
||||||
|
Unknown = 'Unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildCommit {
|
||||||
|
id: number;
|
||||||
|
buildId: number;
|
||||||
|
commitHash: string;
|
||||||
|
comment: string;
|
||||||
|
user: string;
|
||||||
|
commitDate: string;
|
||||||
|
fogBugzCaseId?: string;
|
||||||
|
modifiedFiles: string[];
|
||||||
|
releaseNote?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Package {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
projectId: number;
|
||||||
|
sourceBuildId: number;
|
||||||
|
status: PackageStatus;
|
||||||
|
publishDate?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
project?: Project;
|
||||||
|
sourceBuild?: Build;
|
||||||
|
configuration?: PackageConfiguration;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PackageStatus {
|
||||||
|
Draft = 'Draft',
|
||||||
|
Ready = 'Ready',
|
||||||
|
Publishing = 'Publishing',
|
||||||
|
Published = 'Published',
|
||||||
|
Failed = 'Failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PackageConfiguration {
|
||||||
|
id: number;
|
||||||
|
packageId: number;
|
||||||
|
buildFolder: string;
|
||||||
|
zipContents: boolean;
|
||||||
|
deleteOldPublishedBuilds: boolean;
|
||||||
|
releaseNoteTemplate: string;
|
||||||
|
storageSettings: Record<string, any>;
|
||||||
|
helpCenterSettings: Record<string, any>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FogBugzCase {
|
||||||
|
id: number;
|
||||||
|
caseId: number;
|
||||||
|
title: string;
|
||||||
|
project: string;
|
||||||
|
area: string;
|
||||||
|
status: string;
|
||||||
|
lastUpdated: string;
|
||||||
|
isOpen: boolean;
|
||||||
|
events?: FogBugzEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FogBugzEvent {
|
||||||
|
id: number;
|
||||||
|
caseId: number;
|
||||||
|
eventType: string;
|
||||||
|
user: string;
|
||||||
|
comment: string;
|
||||||
|
statusString: string;
|
||||||
|
eventDate: string;
|
||||||
|
releaseNote?: string;
|
||||||
|
zendeskNumber?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Publication {
|
||||||
|
id: number;
|
||||||
|
packageId: number;
|
||||||
|
status: PublicationStatus;
|
||||||
|
publishedAt?: string;
|
||||||
|
releaseNotesPath?: string;
|
||||||
|
publicationDetails: Record<string, any>;
|
||||||
|
steps?: PublishingStep[];
|
||||||
|
package?: Package;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PublicationStatus {
|
||||||
|
Queued = 'Queued',
|
||||||
|
InProgress = 'InProgress',
|
||||||
|
Completed = 'Completed',
|
||||||
|
Failed = 'Failed',
|
||||||
|
Cancelled = 'Cancelled'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishingStep {
|
||||||
|
id: number;
|
||||||
|
publicationId: number;
|
||||||
|
stepName: string;
|
||||||
|
status: StepStatus;
|
||||||
|
details?: string;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
progressPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum StepStatus {
|
||||||
|
Pending = 'Pending',
|
||||||
|
InProgress = 'InProgress',
|
||||||
|
Completed = 'Completed',
|
||||||
|
Failed = 'Failed',
|
||||||
|
Skipped = 'Skipped'
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Response types
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
data: T;
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
errors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
totalCount: number;
|
||||||
|
pageSize: number;
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignalR Hub types
|
||||||
|
export interface PublishingProgressUpdate {
|
||||||
|
publicationId: number;
|
||||||
|
stepName: string;
|
||||||
|
progressPercent: number;
|
||||||
|
status: StepStatus;
|
||||||
|
details?: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildStatusUpdate {
|
||||||
|
projectId: number;
|
||||||
|
buildId: number;
|
||||||
|
status: BuildStatus;
|
||||||
|
buildNumber: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form types
|
||||||
|
export interface CreatePackageRequest {
|
||||||
|
title: string;
|
||||||
|
version: string;
|
||||||
|
description: string;
|
||||||
|
projectId: number;
|
||||||
|
sourceBuildId: number;
|
||||||
|
configuration: {
|
||||||
|
buildFolder: string;
|
||||||
|
zipContents: boolean;
|
||||||
|
deleteOldPublishedBuilds: boolean;
|
||||||
|
releaseNoteTemplate: string;
|
||||||
|
storageSettings: Record<string, any>;
|
||||||
|
helpCenterSettings: Record<string, any>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishPackageRequest {
|
||||||
|
packageId: number;
|
||||||
|
buildId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter and search types
|
||||||
|
export interface PackageFilter {
|
||||||
|
projectId?: number;
|
||||||
|
status?: PackageStatus;
|
||||||
|
searchTerm?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildFilter {
|
||||||
|
projectId?: number;
|
||||||
|
status?: BuildStatus;
|
||||||
|
user?: string;
|
||||||
|
searchTerm?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
}
|
||||||
36
src/Frontend/tsconfig.json
Normal file
36
src/Frontend/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
|
||||||
|
/* Path mapping */
|
||||||
|
"baseUrl": "./src",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["*"],
|
||||||
|
"@/components/*": ["components/*"],
|
||||||
|
"@/hooks/*": ["hooks/*"],
|
||||||
|
"@/services/*": ["services/*"],
|
||||||
|
"@/types/*": ["types/*"],
|
||||||
|
"@/utils/*": ["utils/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
10
src/Frontend/tsconfig.node.json
Normal file
10
src/Frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
26
src/Frontend/vite.config.ts
Normal file
26
src/Frontend/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5000',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
'/hubs': {
|
||||||
|
target: 'http://localhost:5000',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user