From 1e6ed7e32d1e9b9d03d840745d9fbe6f8c506767 Mon Sep 17 00:00:00 2001 From: noisedestroyers Date: Tue, 22 Jul 2025 13:43:41 -0400 Subject: [PATCH] [claudesquad] update from 'webui' on 22 Jul 25 13:43 EDT (paused) --- src/Frontend/.eslintrc.cjs | 21 + src/Frontend/Dockerfile | 31 ++ src/Frontend/README.md | 356 +++++++++++++++ src/Frontend/index.html | 21 + src/Frontend/nginx.conf | 50 +++ src/Frontend/package.json | 43 ++ src/Frontend/src/App.tsx | 75 ++++ .../src/components/Layout/AppLayout.tsx | 205 +++++++++ .../src/components/Packages/PackageForm.tsx | 411 ++++++++++++++++++ .../src/components/Packages/PackageList.tsx | 270 ++++++++++++ .../Publishing/PublishingDashboard.tsx | 362 +++++++++++++++ .../src/components/common/ErrorDisplay.tsx | 49 +++ .../src/components/common/LoadingSpinner.tsx | 29 ++ .../src/components/common/ProgressBar.tsx | 82 ++++ .../src/components/common/StatusChip.tsx | 85 ++++ src/Frontend/src/contexts/QueryProvider.tsx | 26 ++ src/Frontend/src/hooks/usePackages.ts | 76 ++++ src/Frontend/src/hooks/useProjects.ts | 56 +++ src/Frontend/src/hooks/usePublications.ts | 58 +++ src/Frontend/src/hooks/useSignalR.ts | 88 ++++ src/Frontend/src/main.tsx | 9 + src/Frontend/src/pages/DashboardPage.tsx | 262 +++++++++++ src/Frontend/src/pages/PackagesPage.tsx | 146 +++++++ src/Frontend/src/pages/PublishingPage.tsx | 6 + src/Frontend/src/services/api.ts | 74 ++++ src/Frontend/src/services/packageService.ts | 42 ++ src/Frontend/src/services/projectService.ts | 44 ++ .../src/services/publicationService.ts | 36 ++ src/Frontend/src/services/signalRService.ts | 108 +++++ src/Frontend/src/types/index.ts | 222 ++++++++++ src/Frontend/tsconfig.json | 36 ++ src/Frontend/tsconfig.node.json | 10 + src/Frontend/vite.config.ts | 26 ++ 33 files changed, 3415 insertions(+) create mode 100644 src/Frontend/.eslintrc.cjs create mode 100644 src/Frontend/Dockerfile create mode 100644 src/Frontend/README.md create mode 100644 src/Frontend/index.html create mode 100644 src/Frontend/nginx.conf create mode 100644 src/Frontend/package.json create mode 100644 src/Frontend/src/App.tsx create mode 100644 src/Frontend/src/components/Layout/AppLayout.tsx create mode 100644 src/Frontend/src/components/Packages/PackageForm.tsx create mode 100644 src/Frontend/src/components/Packages/PackageList.tsx create mode 100644 src/Frontend/src/components/Publishing/PublishingDashboard.tsx create mode 100644 src/Frontend/src/components/common/ErrorDisplay.tsx create mode 100644 src/Frontend/src/components/common/LoadingSpinner.tsx create mode 100644 src/Frontend/src/components/common/ProgressBar.tsx create mode 100644 src/Frontend/src/components/common/StatusChip.tsx create mode 100644 src/Frontend/src/contexts/QueryProvider.tsx create mode 100644 src/Frontend/src/hooks/usePackages.ts create mode 100644 src/Frontend/src/hooks/useProjects.ts create mode 100644 src/Frontend/src/hooks/usePublications.ts create mode 100644 src/Frontend/src/hooks/useSignalR.ts create mode 100644 src/Frontend/src/main.tsx create mode 100644 src/Frontend/src/pages/DashboardPage.tsx create mode 100644 src/Frontend/src/pages/PackagesPage.tsx create mode 100644 src/Frontend/src/pages/PublishingPage.tsx create mode 100644 src/Frontend/src/services/api.ts create mode 100644 src/Frontend/src/services/packageService.ts create mode 100644 src/Frontend/src/services/projectService.ts create mode 100644 src/Frontend/src/services/publicationService.ts create mode 100644 src/Frontend/src/services/signalRService.ts create mode 100644 src/Frontend/src/types/index.ts create mode 100644 src/Frontend/tsconfig.json create mode 100644 src/Frontend/tsconfig.node.json create mode 100644 src/Frontend/vite.config.ts diff --git a/src/Frontend/.eslintrc.cjs b/src/Frontend/.eslintrc.cjs new file mode 100644 index 0000000..39aef40 --- /dev/null +++ b/src/Frontend/.eslintrc.cjs @@ -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', + }, +} \ No newline at end of file diff --git a/src/Frontend/Dockerfile b/src/Frontend/Dockerfile new file mode 100644 index 0000000..57be778 --- /dev/null +++ b/src/Frontend/Dockerfile @@ -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;"] \ No newline at end of file diff --git a/src/Frontend/README.md b/src/Frontend/README.md new file mode 100644 index 0000000..d97c271 --- /dev/null +++ b/src/Frontend/README.md @@ -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. \ No newline at end of file diff --git a/src/Frontend/index.html b/src/Frontend/index.html new file mode 100644 index 0000000..eafe862 --- /dev/null +++ b/src/Frontend/index.html @@ -0,0 +1,21 @@ + + + + + + + Release Management Platform + + + + +
+ + + \ No newline at end of file diff --git a/src/Frontend/nginx.conf b/src/Frontend/nginx.conf new file mode 100644 index 0000000..827a483 --- /dev/null +++ b/src/Frontend/nginx.conf @@ -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; +} \ No newline at end of file diff --git a/src/Frontend/package.json b/src/Frontend/package.json new file mode 100644 index 0000000..d9ba37e --- /dev/null +++ b/src/Frontend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/src/Frontend/src/App.tsx b/src/Frontend/src/App.tsx new file mode 100644 index 0000000..c52fd9c --- /dev/null +++ b/src/Frontend/src/App.tsx @@ -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 ( + + + + + + + + } /> + } /> + } /> + Projects page - Coming soon} /> + History page - Coming soon} /> + Settings page - Coming soon} /> + } /> + + + + + + + ); +}; + +export default App; \ No newline at end of file diff --git a/src/Frontend/src/components/Layout/AppLayout.tsx b/src/Frontend/src/components/Layout/AppLayout.tsx new file mode 100644 index 0000000..925d78f --- /dev/null +++ b/src/Frontend/src/components/Layout/AppLayout.tsx @@ -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 = ({ children }) => { + const [drawerOpen, setDrawerOpen] = useState(false); + const navigate = useNavigate(); + const location = useLocation(); + const { isConnected } = useSignalR(); + const { data: activePublications } = useActivePublications(); + + const menuItems = [ + { text: 'Dashboard', icon: , path: '/' }, + { text: 'Packages', icon: , path: '/packages' }, + { text: 'Projects', icon: , path: '/projects' }, + { text: 'Publishing', icon: , path: '/publishing' }, + { text: 'History', icon: , 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 = ( + + + + Release Management + + + + + {menuItems.map((item) => ( + + handleNavigation(item.path)} + > + + {item.text === 'Publishing' ? ( + + {item.icon} + + ) : ( + item.icon + )} + + + + + ))} + + + + + handleNavigation('/settings')}> + + + + + + + + + ); + + return ( + + theme.zIndex.drawer + 1, + bgcolor: 'primary.main', + }} + > + + + + + + + Release Management Platform + + + + + + + + + + + + + + + + + + + + + + + + {drawer} + + + + {drawer} + + + + + {children} + + + ); +}; \ No newline at end of file diff --git a/src/Frontend/src/components/Packages/PackageForm.tsx b/src/Frontend/src/components/Packages/PackageForm.tsx new file mode 100644 index 0000000..002d915 --- /dev/null +++ b/src/Frontend/src/components/Packages/PackageForm.tsx @@ -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 = ({ children, value, index, ...other }) => ( + +); + +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 = ({ open, onClose, package: pkg }) => { + const [currentTab, setCurrentTab] = React.useState(0); + const [selectedProjectId, setSelectedProjectId] = React.useState(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({ + 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 ( + +
+ + {pkg ? 'Edit Package' : 'Create Package'} + + + + setCurrentTab(newValue)}> + + + + + + + {/* Basic Info Tab */} + + + + ( + + )} + /> + + + ( + + )} + /> + + + ( + + )} + /> + + + ( + + Project + + + )} + /> + + + ( + + `${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) => ( + + )} + disabled={!selectedProjectId} + /> + )} + /> + + + + + {/* Configuration Tab */} + + + + ( + + )} + /> + + + ( + } + label="ZIP Contents" + /> + )} + /> + + + ( + } + label="Delete Old Published Builds" + /> + )} + /> + + + ( + + )} + /> + + + + + {/* Storage Tab */} + + + Storage Configuration + + + Configure cloud storage settings for package deployment. + + {/* Storage configuration fields would go here */} + + + + {/* Help Center Tab */} + + + Help Center Configuration + + + Configure automatic help center article updates. + + {/* Help center configuration fields would go here */} + + + + + + + + +
+
+ ); +}; \ No newline at end of file diff --git a/src/Frontend/src/components/Packages/PackageList.tsx b/src/Frontend/src/components/Packages/PackageList.tsx new file mode 100644 index 0000000..29083b2 --- /dev/null +++ b/src/Frontend/src/components/Packages/PackageList.tsx @@ -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 = ({ + onCreatePackage, + onEditPackage, + onPublishPackage, +}) => { + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(10); + const [filter, setFilter] = useState({}); + 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 ; + if (error) return ; + + const packages = packagesData?.data || []; + const totalCount = packagesData?.totalCount || 0; + + return ( + + {/* Header */} + + + Packages + + + + + {/* Filters */} + + + + handleFilterChange('searchTerm', e.target.value)} + InputProps={{ + startAdornment: , + }} + /> + + + + {showFilters && ( + + + + Project + + + + + + Status + + + + + )} + + + + {/* Packages Table */} + + + + + Title + Version + Project + Status + Published Date + Actions + + + + {packages.map((pkg) => ( + + + + {pkg.title} + + {pkg.description} + + + + + + + {pkg.project?.name || 'Unknown'} + + + + + {pkg.publishDate ? format(new Date(pkg.publishDate), 'MMM dd, yyyy HH:mm') : '-'} + + + + {canEdit(pkg) && ( + + onEditPackage(pkg)} + > + + + + )} + {canPublish(pkg) && ( + + onPublishPackage(pkg)} + > + + + + )} + {canEdit(pkg) && ( + + handleDelete(pkg.id)} + disabled={deletePackageMutation.isPending} + > + + + + )} + + + + ))} + {packages.length === 0 && ( + + + + No packages found. Create your first package to get started. + + + + )} + +
+ setPage(newPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => { + setPageSize(parseInt(e.target.value, 10)); + setPage(0); + }} + rowsPerPageOptions={[5, 10, 25, 50]} + /> +
+
+ ); +}; \ No newline at end of file diff --git a/src/Frontend/src/components/Publishing/PublishingDashboard.tsx b/src/Frontend/src/components/Publishing/PublishingDashboard.tsx new file mode 100644 index 0000000..5b64a3d --- /dev/null +++ b/src/Frontend/src/components/Publishing/PublishingDashboard.tsx @@ -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(null); + const [progressUpdates, setProgressUpdates] = useState>(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 ; + if (error) return ; + + const publications = activePublications?.data || []; + + return ( + + {/* Header */} + + + Publishing Dashboard + + + + + + + {isConnected ? 'Live Updates' : 'Disconnected'} + + + + refetch()} title="Refresh"> + + + + + + {publications.length === 0 ? ( + + + + No Active Publications + + + All publications are completed. Start a new publication from the Packages page. + + + + ) : ( + + {publications.map((publication) => { + const latestProgress = getLatestProgressForPublication(publication.id); + const overallProgress = calculateOverallProgress(publication); + + return ( + + + + + + + {publication.package?.title || 'Unknown Package'} + + + Version: {publication.package?.version} | + Project: {publication.package?.project?.name} + + + + {publication.publishedAt && ( + + )} + + + + {publication.status === PublicationStatus.InProgress && ( + + )} + {publication.status === PublicationStatus.Failed && ( + + )} + + + + + + + + + {publication.status === PublicationStatus.Failed && ( + + Publication failed. Check the details for more information. + + )} + + + + ); + })} + + )} + + {/* Publication Details Dialog */} + setSelectedPublication(null)} + maxWidth="md" + fullWidth + > + {selectedPublication && ( + <> + + Publication Details - {selectedPublication.package?.title} + + + + + + + Status + + + + + + Started + + + {selectedPublication.publishedAt ? + format(new Date(selectedPublication.publishedAt), 'MMM dd, yyyy HH:mm:ss') : + 'Not started' + } + + + + + + + Progress Updates + + + {progressUpdates.get(selectedPublication.id)?.map((update, index) => ( + + + + )) || ( + + + No progress updates available + + + )} + + + + + + + )} + + + ); +}; \ No newline at end of file diff --git a/src/Frontend/src/components/common/ErrorDisplay.tsx b/src/Frontend/src/components/common/ErrorDisplay.tsx new file mode 100644 index 0000000..89af6b2 --- /dev/null +++ b/src/Frontend/src/components/common/ErrorDisplay.tsx @@ -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 = ({ + message = 'An error occurred', + error, + onRetry +}) => { + return ( + + + Error + + {message} + + {error && ( + + {error.message} + + )} + {onRetry && ( + + + + )} + + + ); +}; \ No newline at end of file diff --git a/src/Frontend/src/components/common/LoadingSpinner.tsx b/src/Frontend/src/components/common/LoadingSpinner.tsx new file mode 100644 index 0000000..29fb511 --- /dev/null +++ b/src/Frontend/src/components/common/LoadingSpinner.tsx @@ -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 = ({ + message = 'Loading...', + size = 40 +}) => { + return ( + + + {message && ( + + {message} + + )} + + ); +}; \ No newline at end of file diff --git a/src/Frontend/src/components/common/ProgressBar.tsx b/src/Frontend/src/components/common/ProgressBar.tsx new file mode 100644 index 0000000..a4bb41d --- /dev/null +++ b/src/Frontend/src/components/common/ProgressBar.tsx @@ -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 = ({ + 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 ( + + + + {stepName && ( + + {stepName} + + )} + {status && ( + + )} + + {showPercentage && ( + + {Math.round(progress)}% + + )} + + + + + {details && ( + + {details} + + )} + + ); +}; \ No newline at end of file diff --git a/src/Frontend/src/components/common/StatusChip.tsx b/src/Frontend/src/components/common/StatusChip.tsx new file mode 100644 index 0000000..d4f6fd1 --- /dev/null +++ b/src/Frontend/src/components/common/StatusChip.tsx @@ -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 { + status: BuildStatus | PackageStatus | PublicationStatus | StepStatus; +} + +export const StatusChip: React.FC = ({ 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 ( + + ); +}; \ No newline at end of file diff --git a/src/Frontend/src/contexts/QueryProvider.tsx b/src/Frontend/src/contexts/QueryProvider.tsx new file mode 100644 index 0000000..e7b727d --- /dev/null +++ b/src/Frontend/src/contexts/QueryProvider.tsx @@ -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 = ({ children }) => { + return ( + + {children} + + + ); +}; \ No newline at end of file diff --git a/src/Frontend/src/hooks/usePackages.ts b/src/Frontend/src/hooks/usePackages.ts new file mode 100644 index 0000000..6b5c54f --- /dev/null +++ b/src/Frontend/src/hooks/usePackages.ts @@ -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>({ + 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 }) => + 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'] }); + }, + }); +}; \ No newline at end of file diff --git a/src/Frontend/src/hooks/useProjects.ts b/src/Frontend/src/hooks/useProjects.ts new file mode 100644 index 0000000..bc91188 --- /dev/null +++ b/src/Frontend/src/hooks/useProjects.ts @@ -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 + }); +}; \ No newline at end of file diff --git a/src/Frontend/src/hooks/usePublications.ts b/src/Frontend/src/hooks/usePublications.ts new file mode 100644 index 0000000..f5ba272 --- /dev/null +++ b/src/Frontend/src/hooks/usePublications.ts @@ -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'] }); + }, + }); +}; \ No newline at end of file diff --git a/src/Frontend/src/hooks/useSignalR.ts b/src/Frontend/src/hooks/useSignalR.ts new file mode 100644 index 0000000..1a05029 --- /dev/null +++ b/src/Frontend/src/hooks/useSignalR.ts @@ -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('PublishingProgress', callback); + }, + [] + ); + + const subscribeToBuildStatusUpdates = useCallback( + (callback: (update: BuildStatusUpdate) => void) => { + return signalRService.subscribe('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('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, + }; +}; \ No newline at end of file diff --git a/src/Frontend/src/main.tsx b/src/Frontend/src/main.tsx new file mode 100644 index 0000000..c28b964 --- /dev/null +++ b/src/Frontend/src/main.tsx @@ -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( + + + , +) \ No newline at end of file diff --git a/src/Frontend/src/pages/DashboardPage.tsx b/src/Frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..b7b83a7 --- /dev/null +++ b/src/Frontend/src/pages/DashboardPage.tsx @@ -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 ; + + 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 ( + + + Dashboard + + + {/* Statistics Cards */} + + + + + + + + Packages + + + {packageStats.total} + + + + + + + + + + + + + + + + + + + Projects + + + {projects.length} + + + + + + + + + + + + + + + + + + + Active Publications + + + {publicationStats.active} + + + + + + + + + + + + + + + + + + System Status + + + All Systems Operational + + + + + + + + + + {/* Recent Activity */} + + + + + + Recent Packages + + + {packages.length === 0 ? ( + + + + ) : ( + packages.map((pkg) => ( + + + + + + + + )) + )} + + + + + + + + + + Project Build Status + + + {projects.length === 0 ? ( + + + + ) : ( + projects.slice(0, 5).map((project) => ( + + + {project.status === BuildStatus.Success ? ( + + ) : project.status === BuildStatus.Failure ? ( + + ) : ( + + )} + + + + + )) + )} + + + + + + {publications.length > 0 && ( + + + + + Active Publications + + + {publications.map((publication) => ( + + + + + + + + ))} + + + + + )} + + + ); +}; \ No newline at end of file diff --git a/src/Frontend/src/pages/PackagesPage.tsx b/src/Frontend/src/pages/PackagesPage.tsx new file mode 100644 index 0000000..8072244 --- /dev/null +++ b/src/Frontend/src/pages/PackagesPage.tsx @@ -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(null); + const [publishingPackage, setPublishingPackage] = useState(null); + const [selectedBuildId, setSelectedBuildId] = useState(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 ( + <> + + + {/* Create Package Form */} + + + {/* Edit Package Form */} + + + {/* Publish Package Dialog */} + + + Publish Package + + + + Are you sure you want to publish "{publishingPackage?.title}"? + + + This will start the publishing workflow and deploy the package to configured storage and help center locations. + + + + Source Build + + + + + + + + + + ); +}; \ No newline at end of file diff --git a/src/Frontend/src/pages/PublishingPage.tsx b/src/Frontend/src/pages/PublishingPage.tsx new file mode 100644 index 0000000..5c4c1de --- /dev/null +++ b/src/Frontend/src/pages/PublishingPage.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { PublishingDashboard } from '../components/Publishing/PublishingDashboard'; + +export const PublishingPage: React.FC = () => { + return ; +}; \ No newline at end of file diff --git a/src/Frontend/src/services/api.ts b/src/Frontend/src/services/api.ts new file mode 100644 index 0000000..e7e6648 --- /dev/null +++ b/src/Frontend/src/services/api.ts @@ -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(url: string, params?: Record): Promise> { + const response = await this.client.get(url, { params }); + return response.data; + } + + async post(url: string, data?: any): Promise> { + const response = await this.client.post(url, data); + return response.data; + } + + async put(url: string, data?: any): Promise> { + const response = await this.client.put(url, data); + return response.data; + } + + async delete(url: string): Promise> { + const response = await this.client.delete(url); + return response.data; + } + + async getPaginated( + url: string, + params?: Record + ): Promise> { + const response = await this.client.get(url, { params }); + return response.data; + } +} + +export const apiClient = new ApiClient(); \ No newline at end of file diff --git a/src/Frontend/src/services/packageService.ts b/src/Frontend/src/services/packageService.ts new file mode 100644 index 0000000..369f42f --- /dev/null +++ b/src/Frontend/src/services/packageService.ts @@ -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> { + return apiClient.getPaginated('/packages', { + page, + pageSize, + ...filter, + }); + }, + + async getPackage(id: number): Promise> { + return apiClient.get(`/packages/${id}`); + }, + + async createPackage(request: CreatePackageRequest): Promise> { + return apiClient.post('/packages', request); + }, + + async updatePackage(id: number, request: Partial): Promise> { + return apiClient.put(`/packages/${id}`, request); + }, + + async deletePackage(id: number): Promise> { + return apiClient.delete(`/packages/${id}`); + }, + + async publishPackage(packageId: number, buildId: number): Promise> { + return apiClient.post(`/packages/${packageId}/publish`, { buildId }); + }, +}; \ No newline at end of file diff --git a/src/Frontend/src/services/projectService.ts b/src/Frontend/src/services/projectService.ts new file mode 100644 index 0000000..229a989 --- /dev/null +++ b/src/Frontend/src/services/projectService.ts @@ -0,0 +1,44 @@ +import { apiClient } from './api'; +import { + Project, + Build, + BuildCommit, + BuildFilter, + PaginatedResponse, + ApiResponse +} from '../types'; + +export const projectService = { + async getProjects(): Promise> { + return apiClient.get('/projects'); + }, + + async getProject(id: number): Promise> { + return apiClient.get(`/projects/${id}`); + }, + + async getProjectBuilds( + projectId: number, + page: number = 1, + pageSize: number = 10, + filter?: BuildFilter + ): Promise> { + return apiClient.getPaginated(`/projects/${projectId}/builds`, { + page, + pageSize, + ...filter, + }); + }, + + async getBuild(buildId: number): Promise> { + return apiClient.get(`/builds/${buildId}`); + }, + + async getBuildCommits(buildId: number): Promise> { + return apiClient.get(`/builds/${buildId}/commits`); + }, + + async getProjectStatus(): Promise> { + return apiClient.get('/projects/status'); + }, +}; \ No newline at end of file diff --git a/src/Frontend/src/services/publicationService.ts b/src/Frontend/src/services/publicationService.ts new file mode 100644 index 0000000..e3771b0 --- /dev/null +++ b/src/Frontend/src/services/publicationService.ts @@ -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> { + return apiClient.getPaginated('/publications', { page, pageSize }); + }, + + async getPublication(id: number): Promise> { + return apiClient.get(`/publications/${id}`); + }, + + async getPublicationSteps(publicationId: number): Promise> { + return apiClient.get(`/publications/${publicationId}/steps`); + }, + + async getActivePublications(): Promise> { + return apiClient.get('/publications/active'); + }, + + async cancelPublication(id: number): Promise> { + return apiClient.post(`/publications/${id}/cancel`); + }, + + async retryPublication(id: number): Promise> { + return apiClient.post(`/publications/${id}/retry`); + }, +}; \ No newline at end of file diff --git a/src/Frontend/src/services/signalRService.ts b/src/Frontend/src/services/signalRService.ts new file mode 100644 index 0000000..229a7b9 --- /dev/null +++ b/src/Frontend/src/services/signalRService.ts @@ -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 void>> = new Map(); + + async start(): Promise { + 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 { + 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(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 { + if (this.connection?.state === signalR.HubConnectionState.Connected) { + await this.connection.invoke('JoinPublicationGroup', publicationId.toString()); + } + } + + async leavePublicationGroup(publicationId: number): Promise { + 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(); \ No newline at end of file diff --git a/src/Frontend/src/types/index.ts b/src/Frontend/src/types/index.ts new file mode 100644 index 0000000..8bc6d59 --- /dev/null +++ b/src/Frontend/src/types/index.ts @@ -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; + helpCenterSettings: Record; + 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; + 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 { + data: T; + success: boolean; + message?: string; + errors?: string[]; +} + +export interface PaginatedResponse { + 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; + helpCenterSettings: Record; + }; +} + +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; +} \ No newline at end of file diff --git a/src/Frontend/tsconfig.json b/src/Frontend/tsconfig.json new file mode 100644 index 0000000..597bb76 --- /dev/null +++ b/src/Frontend/tsconfig.json @@ -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" }] +} \ No newline at end of file diff --git a/src/Frontend/tsconfig.node.json b/src/Frontend/tsconfig.node.json new file mode 100644 index 0000000..c0febd3 --- /dev/null +++ b/src/Frontend/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/src/Frontend/vite.config.ts b/src/Frontend/vite.config.ts new file mode 100644 index 0000000..80dc218 --- /dev/null +++ b/src/Frontend/vite.config.ts @@ -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', + }, +}) \ No newline at end of file