Compare commits

...

2 Commits

34 changed files with 3427 additions and 10 deletions

View File

@@ -213,17 +213,19 @@
},
"active": "41ed24ce492fd06e",
"lastOpenFiles": [
"src/Frontend/src/components/common/StatusChip.tsx",
"src/Frontend/src/components/common/ProgressBar.tsx",
"src/Frontend/src/components/common/LoadingSpinner.tsx",
"src/Frontend/src/components/common/ErrorDisplay.tsx",
"src/Frontend/src/components/Publishing/PublishingDashboard.tsx",
"src/Frontend/src/components/Packages/PackageList.tsx",
"src/Frontend/src/components/Packages/PackageForm.tsx",
"src/Frontend/src/components/Layout/AppLayout.tsx",
"src/Frontend/src/types/index.ts",
"src/Frontend/src/services/signalRService.ts",
"src/Frontend/src/services/publicationService.ts",
"src/Frontend/README.md",
"legacy.md",
"legacy/CCNetLogReader/CCNetControls/obj/Debug/TempPE",
"legacy/CCNetLogReader/CCNetControls/obj/Debug/DesignTimeResolveAssemblyReferencesInput.cache",
"legacy/CCNetLogReader/CCNetControls/obj/Debug/CCNetControls.csproj.FileListAbsolute.txt",
"legacy/CCNetLogReader/CCNetControls/obj/Debug",
"legacy/CCNetLogReader/CCNetControls/bin/Debug",
"legacy/CCNetLogReader/CCNetControls/Properties/AssemblyInfo.cs",
"legacy/CCNetLogReader/CCNetControls/obj",
"legacy/CCNetLogReader/CCNetControls/bin",
"legacy/CCNetLogReader/CCNetControls/Properties",
"legacy/CCNetLogReader/CCNetControls/ManuscriptLogin.resx",
"claude.md",
"implementation.md",
"statement-of-work.md",

View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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}
/>
);
};

View 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>
);
};

View 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'] });
},
});
};

View 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
});
};

View 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'] });
},
});
};

View 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,
};
};

View 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>,
)

View 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>
);
};

View 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>
</>
);
};

View File

@@ -0,0 +1,6 @@
import React from 'react';
import { PublishingDashboard } from '../components/Publishing/PublishingDashboard';
export const PublishingPage: React.FC = () => {
return <PublishingDashboard />;
};

View 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();

View 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 });
},
};

View 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');
},
};

View 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`);
},
};

View 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();

View 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;
}

View 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" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View 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',
},
})