generated from noisedestroyers/claude
[claudesquad] update from 'webui' on 22 Jul 25 13:43 EDT (paused)
This commit is contained in:
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