Compare commits

..

7 Commits

69 changed files with 7150 additions and 65 deletions

View File

@@ -213,17 +213,20 @@
},
"active": "41ed24ce492fd06e",
"lastOpenFiles": [
"src/Database/Services/PasswordHasher.cs",
"src/Database/Services/DatabaseSeeder.cs",
"src/Database/Services/DatabaseMigrator.cs",
"src/Database/Repositories/IUserRepository.cs",
"src/Database/Repositories/IPublicationRepository.cs",
"src/Database/Repositories/IProjectRepository.cs",
"src/Database/Repositories/IPackageRepository.cs",
"src/Database/Repositories/IFogBugzRepository.cs",
"src/Database/Repositories/IBuildRepository.cs",
"src/Database/Models/User.cs",
"src/Database/Models/StorageProvider.cs",
"README-Database.md",
"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",

441
README-Database.md Normal file
View File

@@ -0,0 +1,441 @@
# Database Implementation
This document describes the PostgreSQL database implementation for the Software Release Management Platform.
## Architecture Overview
The database layer uses **Entity Framework Core 8** with **PostgreSQL 15** and includes:
- **Soft Delete Pattern**: All entities support soft deletes with audit trail
- **Repository Pattern**: Clean abstraction layer for data access
- **Database Constraints**: Enforced data integrity at the database level
- **JSON Support**: PostgreSQL JSONB columns for flexible configuration storage
- **Migration Support**: Code-first database schema management
- **Connection Pooling**: Optimized for high-performance applications
## Entity Relationships
```mermaid
erDiagram
Users {
int Id PK
string Username UK
string PasswordHash
string Role
datetime CreatedAt
datetime LastLogin
bool IsDeleted
}
Projects {
int Id PK
string Name
string CCNetProjectName UK
string Status
datetime CreatedAt
bool IsDeleted
}
Builds {
int Id PK
int ProjectId FK
string BuildNumber
string Status
datetime StartTime
datetime EndTime
bool IsDeleted
}
Packages {
int Id PK
string Title
string Version
int ProjectId FK
int SourceBuildId FK
string Status
datetime PublishDate
bool IsDeleted
}
PackageConfigurations {
int Id PK
int PackageId FK
string BuildFolder
bool ZipContents
jsonb StorageSettings
jsonb HelpCenterSettings
bool IsDeleted
}
Publications {
int Id PK
int PackageId FK
string Status
datetime PublishedAt
jsonb PublicationDetails
bool IsDeleted
}
PublishingSteps {
int Id PK
int PublicationId FK
string StepName
string Status
int ProgressPercent
datetime StartedAt
datetime CompletedAt
bool IsDeleted
}
FogBugzCases {
int Id PK
int CaseId UK
string Title
string Status
datetime LastUpdated
bool IsOpen
bool IsDeleted
}
FogBugzEvents {
int Id PK
int CaseId FK
string EventType
string User
datetime EventDate
string ReleaseNote
int ZendeskNumber
bool IsDeleted
}
BuildCommits {
int Id PK
int BuildId FK
string CommitHash
string FogBugzCaseId
jsonb ModifiedFiles
datetime CommitDate
bool IsDeleted
}
StorageProviders {
int Id PK
string Name UK
string Type
jsonb Configuration
bool IsActive
bool IsDeleted
}
HelpCenterProviders {
int Id PK
string Name UK
string Type
jsonb Configuration
bool IsActive
bool IsDeleted
}
Projects ||--o{ Builds : "produces"
Projects ||--o{ Packages : "contains"
Builds ||--o{ BuildCommits : "includes"
Packages ||--o{ PackageConfigurations : "configured by"
Packages ||--o{ Publications : "published as"
Publications ||--o{ PublishingSteps : "tracked by"
FogBugzCases ||--o{ FogBugzEvents : "has history"
BuildCommits }o--|| FogBugzCases : "references"
```
## Database Schema
### Core Tables
#### Users
- **Purpose**: User authentication and authorization
- **Key Features**: Role-based access control, password hashing, audit trail
#### Projects
- **Purpose**: CruiseControl.NET project integration
- **Key Features**: Maps to CC.NET project names, tracks build history
#### Builds
- **Purpose**: Individual build records from CC.NET
- **Key Features**: Build status tracking, timing information, artifact paths
#### BuildCommits
- **Purpose**: Source code commits within builds
- **Key Features**: FogBugz case linking, modified files (JSON), commit metadata
### Package Management
#### Packages
- **Purpose**: User-defined software release configurations
- **Key Features**: Version tracking, publishing status, project association
#### PackageConfigurations
- **Purpose**: Package-specific publishing settings
- **Key Features**: Storage settings (JSON), help center settings (JSON), build options
### Publishing Workflow
#### Publications
- **Purpose**: Publishing execution records
- **Key Features**: Workflow status, publication details (JSON), timing
#### PublishingSteps
- **Purpose**: Individual workflow step tracking
- **Key Features**: Progress percentage, error handling, step ordering
### External Integration
#### FogBugzCases & FogBugzEvents
- **Purpose**: FogBugz/Manuscript integration
- **Key Features**: Case history, release note extraction, Zendesk linking
#### StorageProviders & HelpCenterProviders
- **Purpose**: External service configuration
- **Key Features**: Pluggable providers, encrypted configuration (JSON)
## Soft Delete Implementation
All entities inherit from `BaseEntity` with soft delete support:
```csharp
public abstract class BaseEntity
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public bool IsDeleted { get; set; }
public DateTime? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
public string? CreatedBy { get; set; }
public string? UpdatedBy { get; set; }
}
```
### Query Filters
Entity Framework automatically filters out soft-deleted records:
```csharp
modelBuilder.Entity<User>().HasQueryFilter(e => !e.IsDeleted);
```
### Manual Override
To include soft-deleted records:
```csharp
var allUsers = context.Users.IgnoreQueryFilters().Where(u => u.IsDeleted);
```
## Repository Pattern
### Interface Example
```csharp
public interface IUserRepository
{
Task<User?> GetByIdAsync(int id);
Task<User?> GetByUsernameAsync(string username);
Task<List<User>> GetAllAsync();
Task<User> CreateAsync(User user);
Task<User> UpdateAsync(User user);
Task DeleteAsync(int id, string deletedBy);
Task<bool> ExistsAsync(int id);
}
```
### Benefits
- Clean separation of concerns
- Testable data access layer
- Consistent error handling
- Transaction management
## Database Configuration
### Connection String
Production connection strings stored in `secrets.yaml`:
```yaml
Database:
ConnectionString: "Host=localhost;Port=5432;Database=software_release_management;Username=postgres;Password=your_secure_password"
```
### Performance Settings
```json
{
"Database": {
"CommandTimeout": 30,
"Pooling": {
"MinPoolSize": 5,
"MaxPoolSize": 100,
"ConnectionIdleLifetime": 300
},
"Retry": {
"EnableRetryOnFailure": true,
"MaxRetryCount": 5,
"MaxRetryDelay": 30
}
}
}
```
## Setup Instructions
### 1. Prerequisites
- Docker and Docker Compose
- .NET 8 SDK
- PostgreSQL client tools (optional)
### 2. Start Database
```bash
# Start PostgreSQL with Docker Compose
docker compose up postgres -d
# Optional: Start with PgAdmin
docker compose --profile dev up -d
```
### 3. Run Migrations
```bash
cd src/Database
dotnet ef migrations add InitialCreate
dotnet ef database update
```
### 4. Seed Data
```bash
# Development environment automatically seeds test data
ASPNETCORE_ENVIRONMENT=Development dotnet run
```
## Development Workflow
### Creating Migrations
```bash
# Navigate to Database project
cd src/Database
# Create new migration
dotnet ef migrations add MigrationName
# Review generated migration files
# Apply migration
dotnet ef database update
```
### Database Scripts
```bash
# Generate SQL script for production deployment
dotnet ef migrations script --output migrations.sql
# Generate script for specific migration range
dotnet ef migrations script PreviousMigration TargetMigration
```
### Data Seeding
The database automatically seeds:
- Default users (admin, user, readonly)
- Storage providers (AWS S3, Box.com, FTP)
- Help center providers (Zendesk, Salesforce)
- Test projects and packages (development only)
## Security Features
### Password Hashing
- BCrypt with work factor 12
- Automatic salt generation
- Secure password verification
### Secrets Management
- Production secrets in `secrets.yaml`
- Environment variable overrides
- Docker secrets support
### Database Security
- Connection pooling limits
- SQL injection protection (parameterized queries)
- Role-based access control
- Audit logging
## Performance Considerations
### Indexing Strategy
- Primary keys (clustered indexes)
- Foreign key relationships
- Query optimization indexes:
- `Users.Username` (unique)
- `Projects.CCNetProjectName` (unique)
- `Builds.StartTime`, `Builds.Status`
- `FogBugzCases.LastUpdated`
- `Publications.Status`, `Publications.PublishedAt`
### JSON Column Usage
PostgreSQL JSONB columns provide:
- Efficient storage and querying
- Flexible configuration schemas
- GIN index support for complex queries
### Connection Pooling
- Configured connection limits
- Idle connection cleanup
- Health check monitoring
## Monitoring and Maintenance
### Health Checks
```csharp
services.AddHealthChecks()
.AddDbContextCheck<DatabaseContext>("database");
```
### Logging
- Entity Framework query logging
- Migration execution logs
- Performance monitoring
- Error tracking
### Backup Strategy
```bash
# Database backup
pg_dump -h localhost -p 5432 -U postgres -d software_release_management > backup.sql
# Restore from backup
psql -h localhost -p 5432 -U postgres -d software_release_management < backup.sql
```
## Troubleshooting
### Common Issues
1. **Connection Issues**
```bash
# Check PostgreSQL status
docker compose logs postgres
# Test connection
psql -h localhost -p 5432 -U postgres -d software_release_management
```
2. **Migration Failures**
```bash
# Reset migrations (development only)
dotnet ef database drop --force
dotnet ef database update
```
3. **Performance Issues**
```sql
-- Check slow queries
SELECT query, mean_time, calls
FROM pg_stat_statements
ORDER BY mean_time DESC LIMIT 10;
```
### Support
- Check Entity Framework documentation
- Review PostgreSQL logs
- Monitor application performance
- Use PgAdmin for database administration
## Next Steps
- Set up monitoring and alerting
- Implement database backup automation
- Configure read replicas for scaling
- Set up connection string encryption

73
docker-compose.yml Normal file
View File

@@ -0,0 +1,73 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: srm_postgres
environment:
POSTGRES_DB: software_release_management
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
PGDATA: /var/lib/postgresql/data/pgdata
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data/pgdata
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/01-init-db.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d software_release_management"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- srm-network
# Database migration service
database-migrator:
build:
context: ./src/Database
dockerfile: Dockerfile.migrator
container_name: srm_db_migrator
depends_on:
postgres:
condition: service_healthy
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=software_release_management;Username=postgres;Password=postgres
networks:
- srm-network
restart: no
# PgAdmin for database management (optional)
pgadmin:
image: dpage/pgadmin4:latest
container_name: srm_pgadmin
environment:
PGADMIN_DEFAULT_EMAIL: admin@company.com
PGADMIN_DEFAULT_PASSWORD: admin123
PGADMIN_CONFIG_SERVER_MODE: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
ports:
- "5050:80"
volumes:
- pgadmin_data:/var/lib/pgadmin
- ./scripts/servers.json:/pgadmin4/servers.json:ro
depends_on:
- postgres
networks:
- srm-network
profiles:
- dev
restart: unless-stopped
volumes:
postgres_data:
driver: local
pgadmin_data:
driver: local
networks:
srm-network:
driver: bridge
name: software-release-management

View File

@@ -127,53 +127,79 @@ graph TB
#### 2. Project Service (`project-service`)
- **Purpose**: CruiseControl.NET integration and project management
- **Technology**: ASP.NET Core Web API
- **Integration Method**: TCP Remoting (`tcp://{server}:21234/CruiseManager.rem`)
- **Responsibilities**:
- Parse CC.NET log files
- Cache build history and status
- Connect to CC.NET via ThoughtWorks.CruiseControl.Remote library
- Parse XML build logs and extract commit information
- Regex pattern matching for FogBugz case ID extraction
- Cache build history and status with modified file tracking
- Project configuration management
- Build status monitoring
- Build status monitoring and real-time updates
#### 3. Manuscript Service (`manuscript-service`)
- **Purpose**: FogBugz/Manuscript integration
- **Technology**: ASP.NET Core Web API
- **Integration Method**: HTTP API (`http://{server}/api.asp`)
- **Authentication**: Token-based authentication system
- **Responsibilities**:
- Developer comment extraction
- Feature timing data aggregation
- Release note data compilation
- Case/bug retrieval with full event history
- Status tracking (Opened, Resolved, Closed, Reactivated)
- Release note extraction from case events
- Zendesk ticket number linking
- Event history processing and caching
- Developer comment extraction and aggregation
#### 4. Document Service (`document-service`)
- **Purpose**: PDF generation and document management
- **Technology**: ASP.NET Core + iText7 or QuestPDF
- **Technology**: ASP.NET Core + QuestPDF (modern, container-friendly)
- **Responsibilities**:
- Release note PDF generation
- Template management
- Document versioning
- Release note PDF generation with FogBugz data integration
- Template variable replacement ({{VERSION}}, {{DATE}}, {{PROJECT}})
- Document versioning and storage
- Multi-format document support (PDF primary)
- Template management and customization
#### 5. Storage Service (`storage-service`)
- **Purpose**: Cloud storage abstraction layer
- **Technology**: ASP.NET Core Web API
- **Storage Providers**:
- **AWS S3**: AWS SDK for .NET
- **AWS S3**: AWS SDK for .NET with CloudFront CDN integration
- **Box.com**: Box .NET SDK
- **FTP**: FluentFTP library
- **Pattern**: Strategy pattern for provider abstraction
- **Key Features**:
- File compression and decompression (ZIP support)
- Folder cleanup and old version management
- Upload progress tracking with real-time updates
- CDN integration for fast content delivery
- Multi-file batch upload operations
#### 6. Help Center Service (`helpcenter-service`)
- **Purpose**: Support article updates
- **Purpose**: Support article updates with template processing
- **Technology**: ASP.NET Core Web API
- **Integrations**:
- **Zendesk**: Zendesk API v2
- **Zendesk**: Zendesk API v2 with ZendeskApi_v2 library
- **Salesforce Service**: Salesforce REST API
- **Pattern**: Strategy pattern for provider abstraction
- **Key Features**:
- Template variable replacement ({{VERSION}}, {{SWURL}}, {{PDFURL}})
- Multi-locale support (default: en-us)
- Article versioning and rollback capabilities
- Automated article publishing workflows
- Link generation for software downloads and documentation
#### 7. Publishing Service (`publishing-service`)
- **Purpose**: Orchestrate complete publishing workflows
- **Technology**: ASP.NET Core + Hangfire
- **Responsibilities**:
- Coordinate document generation
- Manage cloud uploads
- Update help center articles
- Workflow status tracking
- Multi-step workflow orchestration with progress tracking
- Coordinate document generation with FogBugz data
- Manage cloud uploads with compression and cleanup
- Update help center articles with template processing
- Workflow status tracking and real-time notifications
- Error handling and retry mechanisms
- Package configuration validation
- Publishing history and audit trail
### Database Design
@@ -195,14 +221,25 @@ erDiagram
string Description
int ProjectId FK
int SourceBuildId FK
json CloudStorageConfig
json HelpCenterArticles
string Status
datetime PublishDate
datetime CreatedAt
datetime UpdatedAt
}
PackageConfigurations {
int Id PK
int PackageId FK
string BuildFolder
boolean ZipContents
boolean DeleteOldPublishedBuilds
string ReleaseNoteTemplate
json StorageSettings
json HelpCenterSettings
datetime CreatedAt
datetime UpdatedAt
}
Projects {
int Id PK
string Name
@@ -224,6 +261,41 @@ erDiagram
string ArtifactPath
}
BuildCommits {
int Id PK
int BuildId FK
string CommitHash
string Comment
string User
datetime CommitDate
string FogBugzCaseId
json ModifiedFiles
string ReleaseNote
}
FogBugzCases {
int Id PK
int CaseId
string Title
string Project
string Area
string Status
datetime LastUpdated
boolean IsOpen
}
FogBugzEvents {
int Id PK
int CaseId FK
string EventType
string User
string Comment
string StatusString
datetime EventDate
string ReleaseNote
int ZendeskNumber
}
Publications {
int Id PK
int PackageId FK
@@ -233,6 +305,17 @@ erDiagram
json PublicationDetails
}
PublishingSteps {
int Id PK
int PublicationId FK
string StepName
string Status
string Details
datetime StartedAt
datetime CompletedAt
int ProgressPercent
}
StorageProviders {
int Id PK
string Name
@@ -252,13 +335,21 @@ erDiagram
Projects ||--o{ Builds : "produces builds"
Projects ||--o{ Packages : "user creates packages for"
Builds ||--o{ Packages : "can be referenced by"
Builds ||--o{ BuildCommits : "contains commits"
Packages ||--o{ Publications : "has publications"
Packages ||--o{ PackageConfigurations : "has configuration"
Publications ||--o{ PublishingSteps : "tracked by steps"
BuildCommits ||--o| FogBugzCases : "may reference"
FogBugzCases ||--o{ FogBugzEvents : "has event history"
```
#### Core Entities
```sql
-- Packages: User-defined software release configurations
Packages (Id, Title, Version, Description, ProjectId, SourceBuildId, CloudStorageConfig, HelpCenterArticles, Status, PublishDate, CreatedAt, UpdatedAt)
Packages (Id, Title, Version, Description, ProjectId, SourceBuildId, Status, PublishDate, CreatedAt, UpdatedAt)
-- Package-specific configurations
PackageConfigurations (Id, PackageId, BuildFolder, ZipContents, DeleteOldPublishedBuilds, ReleaseNoteTemplate, StorageSettings, HelpCenterSettings, CreatedAt, UpdatedAt)
-- Projects from CruiseControl.NET
Projects (Id, Name, Description, CCNetProjectName, Status, CreatedAt, UpdatedAt)
@@ -266,9 +357,21 @@ Projects (Id, Name, Description, CCNetProjectName, Status, CreatedAt, UpdatedAt)
-- Build information
Builds (Id, ProjectId, BuildNumber, Status, StartTime, EndTime, LogPath, ArtifactPath)
-- Individual commit information from build logs
BuildCommits (Id, BuildId, CommitHash, Comment, User, CommitDate, FogBugzCaseId, ModifiedFiles, ReleaseNote)
-- FogBugz case information
FogBugzCases (Id, CaseId, Title, Project, Area, Status, LastUpdated, IsOpen)
-- FogBugz case event history
FogBugzEvents (Id, CaseId, EventType, User, Comment, StatusString, EventDate, ReleaseNote, ZendeskNumber)
-- Publishing history
Publications (Id, PackageId, Status, PublishedAt, ReleaseNotesPath, PublicationDetails)
-- Publishing workflow step tracking
PublishingSteps (Id, PublicationId, StepName, Status, Details, StartedAt, CompletedAt, ProgressPercent)
-- User management
Users (Id, Username, PasswordHash, Role, CreatedAt, LastLogin)
@@ -283,9 +386,21 @@ HelpCenterProviders (Id, Name, Type, Configuration, IsActive)
```csharp
public interface ICloudStorageProvider
{
Task<string> UploadAsync(string containerName, string fileName, Stream content);
Task<string> UploadAsync(string containerName, string fileName, Stream content, IProgress<UploadProgress> progress = null);
Task<string> UploadFileAsync(string containerName, string localFilePath, IProgress<UploadProgress> progress = null);
Task<bool> DeleteAsync(string containerName, string fileName);
Task<Stream> DownloadAsync(string containerName, string fileName);
Task<bool> DeleteFolderContentsAsync(string containerName, string folderPath, List<string> excludeFiles);
Task<List<string>> UploadFolderAsync(string containerName, string localFolderPath, bool zipContents, IProgress<UploadProgress> progress = null);
Task<string> GetPublicUrlAsync(string containerName, string fileName);
}
public class UploadProgress
{
public string FileName { get; set; }
public long BytesTransferred { get; set; }
public long TotalBytes { get; set; }
public int PercentComplete { get; set; }
}
```
@@ -293,9 +408,97 @@ public interface ICloudStorageProvider
```csharp
public interface IHelpCenterProvider
{
Task<bool> UpdateArticleAsync(string articleId, string content, string title);
Task<bool> UpdateArticleAsync(string articleId, string content, string title = null);
Task<bool> UpdateArticleWithTemplateAsync(string articleId, string templateContent, Dictionary<string, string> variables);
Task<string> CreateArticleAsync(string content, string title, string categoryId);
Task<bool> PublishArticleAsync(string articleId);
Task<ArticleInfo> GetArticleAsync(string articleId);
Task<bool> TestConnectionAsync();
}
public class ArticleInfo
{
public string Id { get; set; }
public string Title { get; set; }
public string Content { get; set; }
public string Locale { get; set; }
public DateTime LastUpdated { get; set; }
public bool IsPublished { get; set; }
}
```
### CruiseControl.NET Integration Interface
```csharp
public interface ICCNetClient
{
Task<List<ProjectStatus>> GetProjectStatusAsync();
Task<List<string>> GetBuildNamesAsync(string projectName);
Task<string> GetBuildLogAsync(string projectName, string buildName);
Task<bool> TestConnectionAsync();
}
public class ProjectStatus
{
public string Name { get; set; }
public IntegrationStatus BuildStatus { get; set; }
public string LastSuccessfulBuildLabel { get; set; }
public DateTime LastBuildDate { get; set; }
}
public class BuildCommitInfo
{
public string ProjectName { get; set; }
public string BuildNumber { get; set; }
public string Comment { get; set; }
public string User { get; set; }
public DateTime Date { get; set; }
public string FogBugzCaseId { get; set; }
public List<string> ModifiedFiles { get; set; }
}
```
### FogBugz Integration Interface
```csharp
public interface IManuscriptClient
{
Task<bool> LogOnAsync();
Task<FogBugzCase> GetCaseAsync(int caseId);
Task<List<FogBugzEvent>> GetCaseEventsAsync(int caseId);
Task<string> GetReleaseNoteAsync(int caseId);
Task<FogBugzStatus> GetCaseStatusAsync(int caseId);
Task<bool> TestConnectionAsync();
}
public class FogBugzCase
{
public int Id { get; set; }
public string Title { get; set; }
public string Project { get; set; }
public string Area { get; set; }
public string Status { get; set; }
public DateTime LastUpdated { get; set; }
public bool IsOpen { get; set; }
public List<FogBugzEvent> Events { get; set; }
}
public class FogBugzEvent
{
public string User { get; set; }
public string Comment { get; set; }
public string StatusString { get; set; }
public DateTime Timestamp { get; set; }
public string ReleaseNote { get; set; }
public int ZendeskNumber { get; set; }
public FogBugzStatus Status { get; set; }
}
public enum FogBugzStatus
{
Unknown,
Opened,
Resolved,
Closed,
Reactivated
}
```
@@ -463,13 +666,38 @@ src/
- PostgreSQL with persistent volumes
- Health checks and restart policies
## Questions for Next Phase
## External Service Integration Details
1. **CC.NET Integration**: What format are the log files? XML, plain text, or proprietary?
2. **Manuscript API**: Is there existing API documentation or will we need to reverse-engineer?
3. **Document Templates**: Do you have existing release note templates or should we create standard ones?
4. **Cloud Storage**: Any specific bucket/container naming conventions?
5. **Help Center**: Do you have existing article templates or categories to work with?
### CruiseControl.NET Integration
- **Connection Method**: TCP Remoting (`tcp://{server}:21234/CruiseManager.rem`)
- **Library**: ThoughtWorks.CruiseControl.Remote
- **Log Format**: XML with embedded modification details
- **FogBugz Case Extraction**: Regex pattern matching on commit comments
- **Supported Patterns**:
- Comments starting with case number
- "fb{number}" or "FB{number}" patterns
- "asp?{number}" patterns
- Full URL patterns with "cases/{number}"
### FogBugz/Manuscript Integration
- **Connection Method**: HTTP API (`http://{server}/api.asp`)
- **Authentication**: Token-based with session management
- **Event Processing**: Full case history with status tracking
- **Release Note Extraction**: From "ReleaseNoted" events with `<IGNORE>` filtering
- **Status Mapping**: Opened, Resolved, Closed, Reactivated
- **Zendesk Linking**: Custom field parsing for ticket numbers
### AWS S3 Integration
- **CDN**: CloudFront integration for fast content delivery
- **Features**: Folder cleanup, progress tracking, public URL generation
- **Compression**: ZIP file creation with SharpSevenZip
- **Cleanup**: Automatic deletion of old versions based on configuration
### Zendesk Integration
- **API Version**: Zendesk API v2 with ZendeskApi_v2 library
- **Template Variables**: `{{VERSION}}`, `{{SWURL}}`, `{{PDFURL}}`
- **URL Generation**: Automatic CloudFront URL generation for downloads
- **Locale Support**: Multi-locale article updates (default: en-us)
## Publishing Workflow
@@ -479,11 +707,14 @@ sequenceDiagram
participant UI as React Frontend
participant GW as API Gateway
participant PUB as Publishing Service
participant PROJ as Project Service
participant MAN as Manuscript Service
participant DOC as Document Service
participant STOR as Storage Service
participant HELP as Help Center Service
participant HF as Hangfire
participant SH as SignalR Hub
participant DB as Database
Note over User,UI: Package Management Phase
User->>UI: Create/Edit Package Configuration
@@ -493,25 +724,59 @@ sequenceDiagram
UI->>GW: GET /api/packages/{id} (Details)
Note over User,UI: Publishing Phase
User->>UI: Select Package & Initiate Publishing
UI->>GW: POST /api/publish/package/{id}
User->>UI: Select Package & Build Version
UI->>GW: POST /api/publish/package/{id}/build/{buildId}
GW->>PUB: Queue Publishing Job
PUB->>HF: Enqueue Background Job
PUB->>HF: Enqueue Multi-Step Background Job
PUB->>SH: Notify Job Started
SH->>UI: Show Publishing Progress UI
Note over HF: Background Processing Starts
Note over HF: Step 1: Validate Package Configuration
HF->>DB: Create Publication Record
HF->>PUB: Validate Package Settings
PUB->>SH: Progress Update (10%)
HF->>DOC: Generate Release Notes PDF
DOC-->>HF: PDF Generated
Note over HF: Step 2: Collect Build Data
HF->>PROJ: Get Build Commits
PROJ->>DB: Query BuildCommits for Build
PUB->>SH: Progress Update (20%)
HF->>STOR: Upload Package + PDF to Cloud
STOR-->>HF: Upload Complete
Note over HF: Step 3: Query FogBugz for Release Notes
HF->>MAN: Get Case Details for Commits
MAN->>DB: Cache FogBugz Events
PUB->>SH: Progress Update (30%)
HF->>HELP: Update Help Center Articles
HELP-->>HF: Articles Updated
Note over HF: Step 4: Generate Release Notes PDF
HF->>DOC: Generate PDF with FogBugz Data
DOC->>DB: Store Document Path
PUB->>SH: Progress Update (50%)
HF->>SH: Notify Completion
SH->>UI: Real-time Publication Status
UI->>User: Show Completion Status
Note over HF: Step 5: Compress Files (if configured)
HF->>PUB: Compress Build Artifacts
PUB->>SH: Progress Update (60%)
Note over HF: Step 6: Upload to Cloud Storage
HF->>STOR: Upload Package + PDF
STOR->>PUB: Upload Progress Events
PUB->>SH: Progress Update (75%)
Note over HF: Step 7: Clean Up Old Versions
HF->>STOR: Delete Old Published Builds
PUB->>SH: Progress Update (80%)
Note over HF: Step 8: Update Help Center Articles
HF->>HELP: Update with Template Variables
HELP->>PUB: Article Update Complete
PUB->>SH: Progress Update (90%)
Note over HF: Step 9: Update Package Status
HF->>DB: Update Package PublishDate
HF->>DB: Complete Publication Record
Note over HF: Step 10: Send Completion Notification
HF->>SH: Notify Complete (100%)
SH->>UI: Show Success with Download Links
UI->>User: Display Publication Summary
```
## Data Flow Architecture
@@ -583,21 +848,171 @@ flowchart TD
### Package Management Interface
- **Package List View**: Display all packages with columns for Title, Project, Build, Status, Publish Date
- Search and filtering capabilities
- Real-time status updates via SignalR
- Color coding for build status (Success/Failure/In Progress)
- **Package Details/Create Form**:
- Title (user input)
- Project (dropdown from available projects)
- Build (dropdown from project's successful builds)
- Publish Date (date picker)
- Help Center Articles (multi-select/tags)
- Cloud Storage Configuration
- Status tracking
- **Basic Info**: Title, Description, Version
- **Project Association**: Dropdown from available CC.NET projects
- **Build Selection**: Dropdown from successful builds with date/version info
- **Configuration Tab**:
- Build folder path
- Compression settings (ZIP contents toggle)
- Cleanup settings (delete old versions toggle)
- Release note template selection
- **Storage Tab**:
- Cloud storage provider selection
- Folder/bucket configuration
- CDN settings
- **Help Center Tab**:
- Article ID association
- Template content with variable placeholders
- Multi-locale support
- Status tracking with progress indicators
### Project Dashboard
- **Project List View**: CC.NET projects with real-time build status
- Build history and success/failure rates
- Last successful build information
- Project status monitoring
### Build Management
- **Build History View**: Detailed commit information
- Commit comments and modified files
- FogBugz case associations
- User attribution and timestamps
- Release note preview from FogBugz cases
- Search and filtering by user, date range, FogBugz status
### Publishing Dashboard
- **Active Publications View**: Real-time publishing progress
- Step-by-step progress indicators
- Detailed status for each publishing step
- Error handling and retry capabilities
- Download links upon completion
- **Publication History**: Audit trail of all publications
- Success/failure status
- Detailed logs and timing information
- Download links to generated documents and packages
### Real-time Features
- **SignalR Integration**: Live updates for:
- Build status changes
- Publishing progress (0-100%)
- Step completion notifications
- Error alerts and notifications
## Detailed Publishing Workflow Steps
Based on legacy analysis, the publishing workflow consists of these detailed steps:
### Step 1: Package Configuration Validation (10%)
- Verify package configuration exists and is complete
- Validate build folder paths and permissions
- Check storage provider credentials and connectivity
- Verify help center article IDs and templates
- Create publication record in database
### Step 2: Build Data Collection (20%)
- Query CC.NET for build information
- Parse XML build logs to extract commit data
- Apply regex patterns to identify FogBugz case references
- Store build commits with user and file modification data
- Track progress with real-time updates
### Step 3: FogBugz Data Integration (30%)
- Query FogBugz API for case details referenced in commits
- Process case event history for status and release notes
- Filter release notes (exclude `<IGNORE>` entries)
- Cache FogBugz data to reduce API calls
- Update build commits with release note content
### Step 4: Document Generation (50%)
- Generate PDF using QuestPDF with modern templates
- Integrate FogBugz release notes and case information
- Include build version, date, and project information
- Store generated document with version tracking
- Support for multiple document formats if needed
### Step 5: File Processing (60%)
- Compress build artifacts if configured (ZIP format)
- Prepare local staging directory
- Copy necessary files for upload
- Apply naming conventions based on package configuration
- Progress tracking for large file operations
### Step 6: Cloud Storage Upload (75%)
- Upload compressed package and documentation
- Generate CDN URLs for fast content delivery
- Support progress tracking with real-time updates
- Handle upload failures with retry mechanisms
- Set appropriate permissions and metadata
### Step 7: Version Cleanup (80%)
- Delete old published versions if configured
- Maintain specified number of historical versions
- Clean up temporary local files
- Update storage usage tracking
- Log cleanup operations for audit trail
### Step 8: Help Center Updates (90%)
- Process template variables in article content
- Replace `{{VERSION}}`, `{{SWURL}}`, `{{PDFURL}}` placeholders
- Generate download URLs with CloudFront integration
- Update articles via Zendesk API
- Support multi-locale article updates
### Step 9: Database Updates (95%)
- Update package publish date and version
- Complete publication record with success details
- Update package configuration if needed
- Create audit log entries
- Store download URLs and document paths
### Step 10: Notification and Completion (100%)
- Send real-time completion notification via SignalR
- Generate publication summary with download links
- Update UI with success status and metrics
- Trigger any configured post-publication webhooks
- Clean up background job resources
## Next Steps
1. Set up development environment with Docker Compose
2. Create solution structure and shared contracts
3. Implement core database models and Entity Framework setup
4. Build API Gateway with authentication
5. Develop individual services in parallel
6. Create React frontend with Package Management views and SignalR integration
7. Integration testing and deployment scripts
1. **Phase 1: Core Infrastructure**
- Set up development environment with Docker Compose
- Create solution structure with all identified services
- Implement enhanced database models with EF Core migrations
- Build API Gateway with JWT authentication and SignalR hubs
2. **Phase 2: External Service Integration**
- Implement CC.NET client with TCP remoting
- Build FogBugz client with HTTP API integration
- Create cloud storage abstraction with S3/CloudFront support
- Develop Zendesk client with template processing
3. **Phase 3: Core Services Development**
- Project Service with XML log parsing and regex case extraction
- Manuscript Service with event processing and caching
- Document Service with QuestPDF integration
- Storage Service with compression and cleanup capabilities
- Help Center Service with template variable replacement
4. **Phase 4: Publishing Workflow**
- Implement 10-step publishing workflow with Hangfire
- Build progress tracking and real-time notifications
- Create error handling and retry mechanisms
- Develop workflow orchestration and coordination
5. **Phase 5: Frontend Development**
- React frontend with advanced package management
- Real-time publishing dashboard with progress tracking
- Build history and FogBugz integration views
- Publication history and audit trail interface
6. **Phase 6: Testing and Deployment**
- Comprehensive integration testing with external services
- Performance testing for large file uploads and processing
- Security testing for credential management and API access
- Production deployment with monitoring and logging

30
scripts/init-db.sql Normal file
View File

@@ -0,0 +1,30 @@
-- Initialize PostgreSQL database for Software Release Management
-- This script runs during container initialization
-- Create additional users if needed
-- CREATE USER srm_app WITH PASSWORD 'secure_password';
-- GRANT CONNECT ON DATABASE software_release_management TO srm_app;
-- Enable UUID extension if needed in the future
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Enable PostGIS extension if geographical features are needed
-- CREATE EXTENSION IF NOT EXISTS postgis;
-- Set timezone
SET timezone = 'UTC';
-- Create schema for application (optional, using public by default)
-- CREATE SCHEMA IF NOT EXISTS srm;
-- Grant permissions
-- GRANT USAGE ON SCHEMA public TO srm_app;
-- GRANT CREATE ON SCHEMA public TO srm_app;
-- Log initialization
DO $$
BEGIN
RAISE NOTICE 'Database initialized for Software Release Management';
RAISE NOTICE 'Version: PostgreSQL %', version();
RAISE NOTICE 'Current time: %', NOW();
END $$;

23
scripts/servers.json Normal file
View File

@@ -0,0 +1,23 @@
{
"Servers": {
"1": {
"Name": "Software Release Management",
"Group": "Servers",
"Host": "postgres",
"Port": 5432,
"MaintenanceDB": "postgres",
"Username": "postgres",
"PassFile": "/tmp/pgpassfile",
"SSLMode": "prefer",
"SSLCert": "<STORAGE_DIR>/.postgresql/postgresql.crt",
"SSLKey": "<STORAGE_DIR>/.postgresql/postgresql.key",
"SSLCompression": 0,
"Timeout": 10,
"UseSSHTunnel": 0,
"TunnelHost": "",
"TunnelPort": "22",
"TunnelUsername": "",
"TunnelAuthentication": 0
}
}
}

59
secrets.yaml Normal file
View File

@@ -0,0 +1,59 @@
# Database Secrets Configuration
# This file contains sensitive configuration data and should be secured
# Use environment variables or secure key vaults in production
Database:
ConnectionString: "Host=localhost;Port=5432;Database=software_release_management;Username=postgres;Password=your_secure_password"
# External Service Credentials
StorageProviders:
AWS:
AccessKey: "YOUR_AWS_ACCESS_KEY"
SecretKey: "YOUR_AWS_SECRET_KEY"
Box:
ClientId: "YOUR_BOX_CLIENT_ID"
ClientSecret: "YOUR_BOX_CLIENT_SECRET"
AccessToken: "YOUR_BOX_ACCESS_TOKEN"
RefreshToken: "YOUR_BOX_REFRESH_TOKEN"
FTP:
Username: "YOUR_FTP_USERNAME"
Password: "YOUR_FTP_PASSWORD"
HelpCenterProviders:
Zendesk:
Username: "YOUR_ZENDESK_USERNAME"
ApiKey: "YOUR_ZENDESK_API_KEY"
Salesforce:
Username: "YOUR_SALESFORCE_USERNAME"
Password: "YOUR_SALESFORCE_PASSWORD"
ClientId: "YOUR_SALESFORCE_CLIENT_ID"
ClientSecret: "YOUR_SALESFORCE_CLIENT_SECRET"
SecurityToken: "YOUR_SALESFORCE_SECURITY_TOKEN"
# External Integration Services
ExternalServices:
CruiseControl:
ServerAddress: "ccnet.company.local"
Port: 21234
FogBugz:
ServerAddress: "fogbugz.company.local"
ApiEndpoint: "/api.asp"
AuthToken: "YOUR_FOGBUGZ_TOKEN"
# JWT Authentication
Authentication:
SecretKey: "YOUR_JWT_SECRET_KEY_AT_LEAST_32_CHARACTERS_LONG"
Issuer: "SoftwareReleaseManagement"
Audience: "SRM-API"
ExpirationMinutes: 60
RefreshTokenExpirationDays: 30
# Application Settings
Application:
DefaultAdminUsername: "admin"
DefaultAdminPassword: "ChangeThisPassword123!"
DefaultAdminEmail: "admin@company.com"

View File

@@ -0,0 +1,47 @@
namespace Database.Configuration
{
public class DatabaseConfiguration
{
public const string SectionName = "Database";
public string ConnectionString { get; set; } = string.Empty;
public bool EnableSensitiveDataLogging { get; set; } = false;
public bool EnableDetailedErrors { get; set; } = false;
public int CommandTimeout { get; set; } = 30;
public bool AutoMigrate { get; set; } = false;
public bool SeedTestData { get; set; } = false;
public string Environment { get; set; } = "Development";
public PoolingOptions Pooling { get; set; } = new();
public RetryOptions Retry { get; set; } = new();
}
public class PoolingOptions
{
public int MinPoolSize { get; set; } = 5;
public int MaxPoolSize { get; set; } = 100;
public int ConnectionIdleLifetime { get; set; } = 300; // seconds
public int ConnectionPruningInterval { get; set; } = 10; // seconds
}
public class RetryOptions
{
public bool EnableRetryOnFailure { get; set; } = true;
public int MaxRetryCount { get; set; } = 5;
public int MaxRetryDelay { get; set; } = 30; // seconds
public List<string> ErrorNumbersToAdd { get; set; } = new();
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,360 @@
using Microsoft.EntityFrameworkCore;
using Database.Models;
namespace Database
{
public class DatabaseContext : DbContext
{
public DatabaseContext(DbContextOptions<DatabaseContext> options) : base(options)
{
}
// Core Entities
public DbSet<User> Users { get; set; }
public DbSet<Package> Packages { get; set; }
public DbSet<PackageConfiguration> PackageConfigurations { get; set; }
public DbSet<Project> Projects { get; set; }
public DbSet<Build> Builds { get; set; }
public DbSet<BuildCommit> BuildCommits { get; set; }
// FogBugz Integration
public DbSet<FogBugzCase> FogBugzCases { get; set; }
public DbSet<FogBugzEvent> FogBugzEvents { get; set; }
// Publishing Workflow
public DbSet<Publication> Publications { get; set; }
public DbSet<PublishingStep> PublishingSteps { get; set; }
// Provider Configurations
public DbSet<StorageProvider> StorageProviders { get; set; }
public DbSet<HelpCenterProvider> HelpCenterProviders { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Configure soft delete filter
modelBuilder.Entity<User>().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity<Package>().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity<PackageConfiguration>().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity<Project>().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity<Build>().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity<BuildCommit>().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity<FogBugzCase>().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity<FogBugzEvent>().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity<Publication>().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity<PublishingStep>().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity<StorageProvider>().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity<HelpCenterProvider>().HasQueryFilter(e => !e.IsDeleted);
// Configure relationships
ConfigureUserRelationships(modelBuilder);
ConfigurePackageRelationships(modelBuilder);
ConfigureProjectRelationships(modelBuilder);
ConfigureBuildRelationships(modelBuilder);
ConfigureFogBugzRelationships(modelBuilder);
ConfigurePublishingRelationships(modelBuilder);
ConfigureProviderRelationships(modelBuilder);
// Configure indexes
ConfigureIndexes(modelBuilder);
// Configure database constraints
ConfigureConstraints(modelBuilder);
}
private static void ConfigureUserRelationships(ModelBuilder modelBuilder)
{
modelBuilder.Entity<User>()
.HasIndex(u => u.Username)
.IsUnique();
}
private static void ConfigurePackageRelationships(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Package>()
.HasOne(p => p.Project)
.WithMany(pr => pr.Packages)
.HasForeignKey(p => p.ProjectId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Package>()
.HasOne(p => p.SourceBuild)
.WithMany()
.HasForeignKey(p => p.SourceBuildId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<PackageConfiguration>()
.HasOne(pc => pc.Package)
.WithOne(p => p.Configuration)
.HasForeignKey<PackageConfiguration>(pc => pc.PackageId)
.OnDelete(DeleteBehavior.Cascade);
}
private static void ConfigureProjectRelationships(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Project>()
.HasIndex(p => p.CCNetProjectName)
.IsUnique();
}
private static void ConfigureBuildRelationships(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Build>()
.HasOne(b => b.Project)
.WithMany(p => p.Builds)
.HasForeignKey(b => b.ProjectId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<Build>()
.HasIndex(b => new { b.ProjectId, b.BuildNumber })
.IsUnique();
modelBuilder.Entity<BuildCommit>()
.HasOne(bc => bc.Build)
.WithMany(b => b.Commits)
.HasForeignKey(bc => bc.BuildId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<BuildCommit>()
.HasOne(bc => bc.FogBugzCase)
.WithMany()
.HasForeignKey(bc => bc.FogBugzCaseId)
.OnDelete(DeleteBehavior.SetNull);
}
private static void ConfigureFogBugzRelationships(ModelBuilder modelBuilder)
{
modelBuilder.Entity<FogBugzCase>()
.HasIndex(c => c.CaseId)
.IsUnique();
modelBuilder.Entity<FogBugzEvent>()
.HasOne(e => e.Case)
.WithMany(c => c.Events)
.HasForeignKey(e => e.CaseId)
.OnDelete(DeleteBehavior.Cascade);
}
private static void ConfigurePublishingRelationships(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Publication>()
.HasOne(pub => pub.Package)
.WithMany(p => p.Publications)
.HasForeignKey(pub => pub.PackageId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<PublishingStep>()
.HasOne(ps => ps.Publication)
.WithMany(p => p.Steps)
.HasForeignKey(ps => ps.PublicationId)
.OnDelete(DeleteBehavior.Cascade);
}
private static void ConfigureProviderRelationships(ModelBuilder modelBuilder)
{
modelBuilder.Entity<StorageProvider>()
.HasIndex(sp => sp.Name)
.IsUnique();
modelBuilder.Entity<HelpCenterProvider>()
.HasIndex(hcp => hcp.Name)
.IsUnique();
}
private static void ConfigureIndexes(ModelBuilder modelBuilder)
{
// Performance indexes
modelBuilder.Entity<Build>()
.HasIndex(b => b.Status);
modelBuilder.Entity<Build>()
.HasIndex(b => b.StartTime);
modelBuilder.Entity<BuildCommit>()
.HasIndex(bc => bc.FogBugzCaseId);
modelBuilder.Entity<BuildCommit>()
.HasIndex(bc => bc.CommitDate);
modelBuilder.Entity<FogBugzCase>()
.HasIndex(c => c.Status);
modelBuilder.Entity<FogBugzCase>()
.HasIndex(c => c.LastUpdated);
modelBuilder.Entity<FogBugzEvent>()
.HasIndex(e => e.EventDate);
modelBuilder.Entity<Publication>()
.HasIndex(p => p.Status);
modelBuilder.Entity<Publication>()
.HasIndex(p => p.PublishedAt);
modelBuilder.Entity<PublishingStep>()
.HasIndex(ps => ps.Status);
}
private static void ConfigureConstraints(ModelBuilder modelBuilder)
{
// String length constraints
modelBuilder.Entity<User>()
.Property(u => u.Username)
.HasMaxLength(100)
.IsRequired();
modelBuilder.Entity<User>()
.Property(u => u.Role)
.HasMaxLength(50)
.IsRequired();
modelBuilder.Entity<Package>()
.Property(p => p.Title)
.HasMaxLength(200)
.IsRequired();
modelBuilder.Entity<Package>()
.Property(p => p.Version)
.HasMaxLength(50)
.IsRequired();
modelBuilder.Entity<Package>()
.Property(p => p.Status)
.HasMaxLength(50)
.IsRequired();
modelBuilder.Entity<Project>()
.Property(p => p.Name)
.HasMaxLength(200)
.IsRequired();
modelBuilder.Entity<Project>()
.Property(p => p.CCNetProjectName)
.HasMaxLength(200)
.IsRequired();
modelBuilder.Entity<Build>()
.Property(b => b.BuildNumber)
.HasMaxLength(100)
.IsRequired();
modelBuilder.Entity<Build>()
.Property(b => b.Status)
.HasMaxLength(50)
.IsRequired();
modelBuilder.Entity<BuildCommit>()
.Property(bc => bc.CommitHash)
.HasMaxLength(40);
modelBuilder.Entity<BuildCommit>()
.Property(bc => bc.User)
.HasMaxLength(100);
modelBuilder.Entity<FogBugzCase>()
.Property(c => c.Title)
.HasMaxLength(500)
.IsRequired();
modelBuilder.Entity<FogBugzCase>()
.Property(c => c.Status)
.HasMaxLength(50)
.IsRequired();
modelBuilder.Entity<FogBugzEvent>()
.Property(e => e.EventType)
.HasMaxLength(100)
.IsRequired();
modelBuilder.Entity<FogBugzEvent>()
.Property(e => e.User)
.HasMaxLength(100);
modelBuilder.Entity<Publication>()
.Property(p => p.Status)
.HasMaxLength(50)
.IsRequired();
modelBuilder.Entity<PublishingStep>()
.Property(ps => ps.StepName)
.HasMaxLength(200)
.IsRequired();
modelBuilder.Entity<PublishingStep>()
.Property(ps => ps.Status)
.HasMaxLength(50)
.IsRequired();
modelBuilder.Entity<StorageProvider>()
.Property(sp => sp.Name)
.HasMaxLength(200)
.IsRequired();
modelBuilder.Entity<StorageProvider>()
.Property(sp => sp.Type)
.HasMaxLength(50)
.IsRequired();
modelBuilder.Entity<HelpCenterProvider>()
.Property(hcp => hcp.Name)
.HasMaxLength(200)
.IsRequired();
modelBuilder.Entity<HelpCenterProvider>()
.Property(hcp => hcp.Type)
.HasMaxLength(50)
.IsRequired();
// JSON column configuration
modelBuilder.Entity<PackageConfiguration>()
.Property(pc => pc.StorageSettings)
.HasColumnType("jsonb");
modelBuilder.Entity<PackageConfiguration>()
.Property(pc => pc.HelpCenterSettings)
.HasColumnType("jsonb");
modelBuilder.Entity<BuildCommit>()
.Property(bc => bc.ModifiedFiles)
.HasColumnType("jsonb");
modelBuilder.Entity<Publication>()
.Property(p => p.PublicationDetails)
.HasColumnType("jsonb");
modelBuilder.Entity<StorageProvider>()
.Property(sp => sp.Configuration)
.HasColumnType("jsonb");
modelBuilder.Entity<HelpCenterProvider>()
.Property(hcp => hcp.Configuration)
.HasColumnType("jsonb");
// Check constraints
modelBuilder.Entity<PublishingStep>()
.HasCheckConstraint("CK_PublishingStep_ProgressPercent",
"\"ProgressPercent\" >= 0 AND \"ProgressPercent\" <= 100");
modelBuilder.Entity<User>()
.HasCheckConstraint("CK_User_Role",
"\"Role\" IN ('Admin', 'User', 'ReadOnly')");
modelBuilder.Entity<Package>()
.HasCheckConstraint("CK_Package_Status",
"\"Status\" IN ('Draft', 'Ready', 'Publishing', 'Published', 'Failed')");
modelBuilder.Entity<Build>()
.HasCheckConstraint("CK_Build_Status",
"\"Status\" IN ('Success', 'Failure', 'Exception', 'Cancelled', 'Unknown')");
modelBuilder.Entity<Publication>()
.HasCheckConstraint("CK_Publication_Status",
"\"Status\" IN ('Pending', 'InProgress', 'Completed', 'Failed', 'Cancelled')");
modelBuilder.Entity<PublishingStep>()
.HasCheckConstraint("CK_PublishingStep_Status",
"\"Status\" IN ('Pending', 'InProgress', 'Completed', 'Failed', 'Skipped')");
}
}
}

View File

@@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Database.Configuration;
using Database.Services;
namespace Database.Extensions
{
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddDatabase(this IServiceCollection services, IConfiguration configuration)
{
// Bind database configuration
var dbConfig = new DatabaseConfiguration();
configuration.GetSection(DatabaseConfiguration.SectionName).Bind(dbConfig);
services.AddSingleton(dbConfig);
// Add Entity Framework DbContext
services.AddDbContext<DatabaseContext>(options =>
{
options.UseNpgsql(dbConfig.ConnectionString, npgsqlOptions =>
{
npgsqlOptions.CommandTimeout(dbConfig.CommandTimeout);
npgsqlOptions.EnableRetryOnFailure(
maxRetryCount: dbConfig.Retry.MaxRetryCount,
maxRetryDelay: TimeSpan.FromSeconds(dbConfig.Retry.MaxRetryDelay),
errorCodesToAdd: null);
});
if (dbConfig.EnableSensitiveDataLogging)
{
options.EnableSensitiveDataLogging();
}
if (dbConfig.EnableDetailedErrors)
{
options.EnableDetailedErrors();
}
// Configure connection pooling
options.UseNpgsql(dbConfig.ConnectionString, npgsqlOptions =>
{
npgsqlOptions.CommandTimeout(dbConfig.CommandTimeout);
});
});
// Add database services
services.AddScoped<IDatabaseSeeder, DatabaseSeeder>();
services.AddScoped<IDatabaseMigrator, DatabaseMigrator>();
services.AddScoped<IPasswordHasher, PasswordHasher>();
return services;
}
public static IServiceCollection AddDatabaseRepositories(this IServiceCollection services)
{
// Add repository pattern services if needed
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IPackageRepository, PackageRepository>();
services.AddScoped<IProjectRepository, ProjectRepository>();
services.AddScoped<IBuildRepository, BuildRepository>();
services.AddScoped<IFogBugzRepository, FogBugzRepository>();
services.AddScoped<IPublicationRepository, PublicationRepository>();
return services;
}
}
}

View File

@@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
namespace Database.Models
{
public abstract class BaseEntity
{
[Key]
public int Id { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public bool IsDeleted { get; set; } = false;
public DateTime? DeletedAt { get; set; }
public string? DeletedBy { get; set; }
public string? CreatedBy { get; set; }
public string? UpdatedBy { get; set; }
public void MarkAsDeleted(string deletedBy)
{
IsDeleted = true;
DeletedAt = DateTime.UtcNow;
DeletedBy = deletedBy;
}
public void UpdateTimestamp(string updatedBy)
{
UpdatedAt = DateTime.UtcNow;
UpdatedBy = updatedBy;
}
}
}

View File

@@ -0,0 +1,34 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Database.Models
{
public class Build : BaseEntity
{
public int ProjectId { get; set; }
[Required]
[MaxLength(100)]
public string BuildNumber { get; set; } = string.Empty;
[Required]
[MaxLength(50)]
public string Status { get; set; } = "Unknown"; // Success, Failure, Exception, Cancelled, Unknown
public DateTime StartTime { get; set; }
public DateTime? EndTime { get; set; }
public string? LogPath { get; set; }
public string? ArtifactPath { get; set; }
public TimeSpan? Duration => EndTime?.Subtract(StartTime);
// Navigation properties
[ForeignKey(nameof(ProjectId))]
public Project Project { get; set; } = null!;
public ICollection<BuildCommit> Commits { get; set; } = new List<BuildCommit>();
}
}

View File

@@ -0,0 +1,60 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
namespace Database.Models
{
public class BuildCommit : BaseEntity
{
public int BuildId { get; set; }
[MaxLength(40)]
public string? CommitHash { get; set; }
public string? Comment { get; set; }
[MaxLength(100)]
public string? User { get; set; }
public DateTime CommitDate { get; set; }
[MaxLength(20)]
public string? FogBugzCaseId { get; set; }
public int? FogBugzCaseIdInt => int.TryParse(FogBugzCaseId, out int result) ? result : null;
// JSON column for PostgreSQL
[Column(TypeName = "jsonb")]
public string? ModifiedFiles { get; set; }
public string? ReleaseNote { get; set; }
// Navigation properties
[ForeignKey(nameof(BuildId))]
public Build Build { get; set; } = null!;
[ForeignKey(nameof(FogBugzCaseId))]
public FogBugzCase? FogBugzCase { get; set; }
// Helper methods for ModifiedFiles JSON
public List<string> GetModifiedFiles()
{
if (string.IsNullOrEmpty(ModifiedFiles))
return new List<string>();
try
{
return JsonSerializer.Deserialize<List<string>>(ModifiedFiles) ?? new List<string>();
}
catch
{
return new List<string>();
}
}
public void SetModifiedFiles(List<string> files)
{
ModifiedFiles = JsonSerializer.Serialize(files);
}
}
}

View File

@@ -0,0 +1,69 @@
using System.ComponentModel.DataAnnotations;
namespace Database.Models
{
public class FogBugzCase : BaseEntity
{
[Required]
public int CaseId { get; set; }
[Required]
[MaxLength(500)]
public string Title { get; set; } = string.Empty;
[MaxLength(200)]
public string? Project { get; set; }
[MaxLength(200)]
public string? Area { get; set; }
[Required]
[MaxLength(50)]
public string Status { get; set; } = "Unknown";
public DateTime LastUpdated { get; set; }
public bool IsOpen { get; set; } = true;
// Navigation properties
public ICollection<FogBugzEvent> Events { get; set; } = new List<FogBugzEvent>();
// Helper methods
public string GetLatestReleaseNote()
{
return Events
.Where(e => e.EventType == "ReleaseNoted" && !string.IsNullOrEmpty(e.ReleaseNote))
.OrderByDescending(e => e.EventDate)
.FirstOrDefault()?.ReleaseNote ?? string.Empty;
}
public int? GetZendeskTicketNumber()
{
return Events
.Where(e => e.ZendeskNumber > 0)
.OrderByDescending(e => e.EventDate)
.FirstOrDefault()?.ZendeskNumber;
}
public FogBugzStatus GetMappedStatus()
{
return Status.ToLower() switch
{
var s when s.StartsWith("open") => FogBugzStatus.Opened,
var s when s.StartsWith("resolved") => FogBugzStatus.Resolved,
var s when s.StartsWith("closed") => FogBugzStatus.Closed,
var s when s.StartsWith("reactivated") => FogBugzStatus.Reactivated,
_ => FogBugzStatus.Unknown
};
}
}
public enum FogBugzStatus
{
Unknown,
Opened,
Resolved,
Closed,
Reactivated
}
}

View File

@@ -0,0 +1,55 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Database.Models
{
public class FogBugzEvent : BaseEntity
{
public int CaseId { get; set; }
[Required]
[MaxLength(100)]
public string EventType { get; set; } = string.Empty;
[MaxLength(100)]
public string? User { get; set; }
public string? Comment { get; set; }
public string? StatusString { get; set; }
public DateTime EventDate { get; set; }
public string? ReleaseNote { get; set; }
public int? ZendeskNumber { get; set; }
// Navigation properties
[ForeignKey(nameof(CaseId))]
public FogBugzCase Case { get; set; } = null!;
// Helper properties
public bool IsReleaseNoteEvent => EventType == "ReleaseNoted" && !string.IsNullOrEmpty(ReleaseNote);
public bool IsZendeskEvent => EventType == "sZendesk" && ZendeskNumber.HasValue && ZendeskNumber > 0;
public bool IsStatusChangeEvent => !string.IsNullOrEmpty(StatusString) &&
(StatusString.StartsWith("Opened") || StatusString.StartsWith("Resolved") ||
StatusString.StartsWith("Closed") || StatusString.StartsWith("Reactivated"));
public FogBugzStatus GetMappedStatus()
{
if (string.IsNullOrEmpty(StatusString))
return FogBugzStatus.Unknown;
return StatusString.ToLower() switch
{
var s when s.StartsWith("opened") => FogBugzStatus.Opened,
var s when s.StartsWith("resolved") => FogBugzStatus.Resolved,
var s when s.StartsWith("closed") => FogBugzStatus.Closed,
var s when s.StartsWith("reactivated") => FogBugzStatus.Reactivated,
_ => FogBugzStatus.Unknown
};
}
}
}

View File

@@ -0,0 +1,93 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
namespace Database.Models
{
public class HelpCenterProvider : BaseEntity
{
[Required]
[MaxLength(200)]
public string Name { get; set; } = string.Empty;
[Required]
[MaxLength(50)]
public string Type { get; set; } = string.Empty; // Zendesk, Salesforce
// JSON column for PostgreSQL
[Column(TypeName = "jsonb")]
public string? Configuration { get; set; }
public bool IsActive { get; set; } = true;
public string? Description { get; set; }
public DateTime? LastTested { get; set; }
public bool? LastTestResult { get; set; }
public string? LastTestError { get; set; }
// Helper methods for Configuration JSON
public HelpCenterProviderConfiguration GetConfiguration()
{
if (string.IsNullOrEmpty(Configuration))
return new HelpCenterProviderConfiguration();
try
{
return JsonSerializer.Deserialize<HelpCenterProviderConfiguration>(Configuration) ?? new HelpCenterProviderConfiguration();
}
catch
{
return new HelpCenterProviderConfiguration();
}
}
public void SetConfiguration(HelpCenterProviderConfiguration config)
{
Configuration = JsonSerializer.Serialize(config);
}
// Helper properties
public bool IsZendeskProvider => Type.Equals("Zendesk", StringComparison.OrdinalIgnoreCase);
public bool IsSalesforceProvider => Type.Equals("Salesforce", StringComparison.OrdinalIgnoreCase);
}
public class HelpCenterProviderConfiguration
{
// Common settings
public string? BaseUrl { get; set; }
public string? ApiVersion { get; set; }
public string? DefaultLocale { get; set; } = "en-us";
// Zendesk specific
public string? Subdomain { get; set; }
public string? Username { get; set; }
public string? ApiKey { get; set; }
public string? Token { get; set; }
// Salesforce specific
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public string? SecurityToken { get; set; }
public string? LoginUrl { get; set; }
// Template variables
public Dictionary<string, string> DefaultTemplateVariables { get; set; } = new()
{
["VERSION"] = "{{VERSION}}",
["SWURL"] = "{{SWURL}}",
["PDFURL"] = "{{PDFURL}}",
["DATE"] = "{{DATE}}",
["PROJECT"] = "{{PROJECT}}"
};
// Additional settings
public Dictionary<string, object> AdditionalSettings { get; set; } = new();
public Dictionary<string, string> Headers { get; set; } = new();
}
}

View File

@@ -0,0 +1,44 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Database.Models
{
public class Package : BaseEntity
{
[Required]
[MaxLength(200)]
public string Title { get; set; } = string.Empty;
[Required]
[MaxLength(50)]
public string Version { get; set; } = string.Empty;
public string? Description { get; set; }
public int ProjectId { get; set; }
public int? SourceBuildId { get; set; }
[Required]
[MaxLength(50)]
public string Status { get; set; } = "Draft"; // Draft, Ready, Publishing, Published, Failed
public DateTime? PublishDate { get; set; }
// Navigation properties
[ForeignKey(nameof(ProjectId))]
public Project Project { get; set; } = null!;
[ForeignKey(nameof(SourceBuildId))]
public Build? SourceBuild { get; set; }
public PackageConfiguration? Configuration { get; set; }
public ICollection<Publication> Publications { get; set; } = new List<Publication>();
// Helper properties
public bool IsPublished => Status == "Published" && PublishDate.HasValue;
public bool IsReadyToPublish => Status == "Ready" && SourceBuildId.HasValue;
}
}

View File

@@ -0,0 +1,92 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
namespace Database.Models
{
public class PackageConfiguration : BaseEntity
{
public int PackageId { get; set; }
public string? BuildFolder { get; set; }
public bool ZipContents { get; set; } = false;
public bool DeleteOldPublishedBuilds { get; set; } = false;
public string? ReleaseNoteTemplate { get; set; }
// JSON columns for PostgreSQL
[Column(TypeName = "jsonb")]
public string? StorageSettings { get; set; }
[Column(TypeName = "jsonb")]
public string? HelpCenterSettings { get; set; }
// Navigation properties
[ForeignKey(nameof(PackageId))]
public Package Package { get; set; } = null!;
// Helper methods for StorageSettings JSON
public StorageSettingsModel GetStorageSettings()
{
if (string.IsNullOrEmpty(StorageSettings))
return new StorageSettingsModel();
try
{
return JsonSerializer.Deserialize<StorageSettingsModel>(StorageSettings) ?? new StorageSettingsModel();
}
catch
{
return new StorageSettingsModel();
}
}
public void SetStorageSettings(StorageSettingsModel settings)
{
StorageSettings = JsonSerializer.Serialize(settings);
}
// Helper methods for HelpCenterSettings JSON
public HelpCenterSettingsModel GetHelpCenterSettings()
{
if (string.IsNullOrEmpty(HelpCenterSettings))
return new HelpCenterSettingsModel();
try
{
return JsonSerializer.Deserialize<HelpCenterSettingsModel>(HelpCenterSettings) ?? new HelpCenterSettingsModel();
}
catch
{
return new HelpCenterSettingsModel();
}
}
public void SetHelpCenterSettings(HelpCenterSettingsModel settings)
{
HelpCenterSettings = JsonSerializer.Serialize(settings);
}
}
public class StorageSettingsModel
{
public string? ProviderName { get; set; }
public string? ContainerName { get; set; }
public string? FolderPath { get; set; }
public bool EnableCDN { get; set; } = false;
public int? MaxVersionsToKeep { get; set; }
public Dictionary<string, object> AdditionalSettings { get; set; } = new();
}
public class HelpCenterSettingsModel
{
public string? ProviderName { get; set; }
public string? ArticleId { get; set; }
public string? ArticleTemplate { get; set; }
public string? Locale { get; set; } = "en-us";
public Dictionary<string, string> TemplateVariables { get; set; } = new();
public Dictionary<string, object> AdditionalSettings { get; set; } = new();
}
}

View File

@@ -0,0 +1,24 @@
using System.ComponentModel.DataAnnotations;
namespace Database.Models
{
public class Project : BaseEntity
{
[Required]
[MaxLength(200)]
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
[Required]
[MaxLength(200)]
public string CCNetProjectName { get; set; } = string.Empty;
[MaxLength(50)]
public string Status { get; set; } = "Active"; // Active, Inactive, Archived
// Navigation properties
public ICollection<Build> Builds { get; set; } = new List<Build>();
public ICollection<Package> Packages { get; set; } = new List<Package>();
}
}

View File

@@ -0,0 +1,75 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
namespace Database.Models
{
public class Publication : BaseEntity
{
public int PackageId { get; set; }
[Required]
[MaxLength(50)]
public string Status { get; set; } = "Pending"; // Pending, InProgress, Completed, Failed, Cancelled
public DateTime? PublishedAt { get; set; }
public string? ReleaseNotesPath { get; set; }
// JSON column for PostgreSQL
[Column(TypeName = "jsonb")]
public string? PublicationDetails { get; set; }
public string? ErrorMessage { get; set; }
public TimeSpan? Duration => PublishedAt?.Subtract(CreatedAt);
// Navigation properties
[ForeignKey(nameof(PackageId))]
public Package Package { get; set; } = null!;
public ICollection<PublishingStep> Steps { get; set; } = new List<PublishingStep>();
// Helper methods for PublicationDetails JSON
public PublicationDetailsModel GetPublicationDetails()
{
if (string.IsNullOrEmpty(PublicationDetails))
return new PublicationDetailsModel();
try
{
return JsonSerializer.Deserialize<PublicationDetailsModel>(PublicationDetails) ?? new PublicationDetailsModel();
}
catch
{
return new PublicationDetailsModel();
}
}
public void SetPublicationDetails(PublicationDetailsModel details)
{
PublicationDetails = JsonSerializer.Serialize(details);
}
// Helper properties
public bool IsCompleted => Status == "Completed" && PublishedAt.HasValue;
public bool IsFailed => Status == "Failed";
public bool IsInProgress => Status == "InProgress";
public int ProgressPercentage => Steps.Any() ?
(int)(Steps.Count(s => s.Status == "Completed") * 100.0 / Steps.Count) : 0;
}
public class PublicationDetailsModel
{
public string? BuildVersion { get; set; }
public string? ReleaseNotesUrl { get; set; }
public string? PackageUrl { get; set; }
public string? CDNUrl { get; set; }
public List<string> UploadedFiles { get; set; } = new();
public List<string> UpdatedArticles { get; set; } = new();
public Dictionary<string, object> AdditionalDetails { get; set; } = new();
}
}

View File

@@ -0,0 +1,104 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Database.Models
{
public class PublishingStep : BaseEntity
{
public int PublicationId { get; set; }
[Required]
[MaxLength(200)]
public string StepName { get; set; } = string.Empty;
[Required]
[MaxLength(50)]
public string Status { get; set; } = "Pending"; // Pending, InProgress, Completed, Failed, Skipped
public string? Details { get; set; }
public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
[Range(0, 100)]
public int ProgressPercent { get; set; } = 0;
public string? ErrorMessage { get; set; }
public int StepOrder { get; set; }
// Navigation properties
[ForeignKey(nameof(PublicationId))]
public Publication Publication { get; set; } = null!;
// Helper properties
public TimeSpan? Duration => CompletedAt?.Subtract(StartedAt ?? CreatedAt);
public bool IsCompleted => Status == "Completed" && CompletedAt.HasValue;
public bool IsFailed => Status == "Failed";
public bool IsInProgress => Status == "InProgress";
public bool IsPending => Status == "Pending";
public void Start()
{
Status = "InProgress";
StartedAt = DateTime.UtcNow;
UpdateTimestamp("System");
}
public void Complete(string? details = null)
{
Status = "Completed";
CompletedAt = DateTime.UtcNow;
ProgressPercent = 100;
if (details != null)
Details = details;
UpdateTimestamp("System");
}
public void Fail(string errorMessage, string? details = null)
{
Status = "Failed";
CompletedAt = DateTime.UtcNow;
ErrorMessage = errorMessage;
if (details != null)
Details = details;
UpdateTimestamp("System");
}
public void Skip(string reason)
{
Status = "Skipped";
CompletedAt = DateTime.UtcNow;
Details = reason;
ProgressPercent = 100;
UpdateTimestamp("System");
}
public void UpdateProgress(int percent, string? details = null)
{
ProgressPercent = Math.Max(0, Math.Min(100, percent));
if (details != null)
Details = details;
UpdateTimestamp("System");
}
}
public static class PublishingStepNames
{
public const string ValidateConfiguration = "Validate Configuration";
public const string CollectBuildData = "Collect Build Data";
public const string QueryFogBugz = "Query FogBugz Data";
public const string GenerateReleaseNotes = "Generate Release Notes";
public const string CompressFiles = "Compress Files";
public const string UploadToStorage = "Upload to Storage";
public const string CleanupOldVersions = "Cleanup Old Versions";
public const string UpdateHelpCenter = "Update Help Center";
public const string UpdatePackageStatus = "Update Package Status";
public const string SendNotification = "Send Notification";
}
}

View File

@@ -0,0 +1,90 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json;
namespace Database.Models
{
public class StorageProvider : BaseEntity
{
[Required]
[MaxLength(200)]
public string Name { get; set; } = string.Empty;
[Required]
[MaxLength(50)]
public string Type { get; set; } = string.Empty; // S3, Box, FTP
// JSON column for PostgreSQL
[Column(TypeName = "jsonb")]
public string? Configuration { get; set; }
public bool IsActive { get; set; } = true;
public string? Description { get; set; }
public DateTime? LastTested { get; set; }
public bool? LastTestResult { get; set; }
public string? LastTestError { get; set; }
// Helper methods for Configuration JSON
public StorageProviderConfiguration GetConfiguration()
{
if (string.IsNullOrEmpty(Configuration))
return new StorageProviderConfiguration();
try
{
return JsonSerializer.Deserialize<StorageProviderConfiguration>(Configuration) ?? new StorageProviderConfiguration();
}
catch
{
return new StorageProviderConfiguration();
}
}
public void SetConfiguration(StorageProviderConfiguration config)
{
Configuration = JsonSerializer.Serialize(config);
}
// Helper properties
public bool IsS3Provider => Type.Equals("S3", StringComparison.OrdinalIgnoreCase);
public bool IsBoxProvider => Type.Equals("Box", StringComparison.OrdinalIgnoreCase);
public bool IsFtpProvider => Type.Equals("FTP", StringComparison.OrdinalIgnoreCase);
}
public class StorageProviderConfiguration
{
// Common settings
public string? ConnectionString { get; set; }
public string? Region { get; set; }
public string? Bucket { get; set; }
public string? Container { get; set; }
// AWS S3 specific
public string? AccessKey { get; set; }
public string? SecretKey { get; set; }
public string? CloudFrontDomain { get; set; }
// Box specific
public string? ClientId { get; set; }
public string? ClientSecret { get; set; }
public string? AccessToken { get; set; }
public string? RefreshToken { get; set; }
// FTP specific
public string? Host { get; set; }
public int? Port { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public bool? UseSSL { get; set; }
// Additional settings
public Dictionary<string, object> AdditionalSettings { get; set; } = new();
public Dictionary<string, string> Headers { get; set; } = new();
}
}

View File

@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
namespace Database.Models
{
public class User : BaseEntity
{
[Required]
[MaxLength(100)]
public string Username { get; set; } = string.Empty;
[Required]
public string PasswordHash { get; set; } = string.Empty;
[Required]
[MaxLength(50)]
public string Role { get; set; } = "User"; // Admin, User, ReadOnly
public DateTime? LastLogin { get; set; }
public string? Email { get; set; }
public string? FirstName { get; set; }
public string? LastName { get; set; }
public bool IsActive { get; set; } = true;
public string FullName => $"{FirstName} {LastName}".Trim();
}
}

View File

@@ -0,0 +1,129 @@
using Database.Models;
using Microsoft.EntityFrameworkCore;
namespace Database.Services
{
public interface IBuildRepository
{
Task<Build?> GetByIdAsync(int id);
Task<Build?> GetByProjectAndBuildNumberAsync(int projectId, string buildNumber);
Task<List<Build>> GetByProjectIdAsync(int projectId);
Task<List<Build>> GetSuccessfulBuildsByProjectIdAsync(int projectId);
Task<List<Build>> GetRecentBuildsAsync(int count = 50);
Task<Build> CreateAsync(Build build);
Task<Build> UpdateAsync(Build build);
Task DeleteAsync(int id, string deletedBy);
Task<bool> ExistsAsync(int id);
Task<List<BuildCommit>> GetCommitsByBuildIdAsync(int buildId);
Task<BuildCommit> CreateCommitAsync(BuildCommit commit);
Task<List<BuildCommit>> CreateCommitsAsync(List<BuildCommit> commits);
}
public class BuildRepository : IBuildRepository
{
private readonly DatabaseContext _context;
public BuildRepository(DatabaseContext context)
{
_context = context;
}
public async Task<Build?> GetByIdAsync(int id)
{
return await _context.Builds
.Include(b => b.Project)
.Include(b => b.Commits)
.ThenInclude(c => c.FogBugzCase)
.FirstOrDefaultAsync(b => b.Id == id);
}
public async Task<Build?> GetByProjectAndBuildNumberAsync(int projectId, string buildNumber)
{
return await _context.Builds
.Include(b => b.Project)
.Include(b => b.Commits)
.FirstOrDefaultAsync(b => b.ProjectId == projectId && b.BuildNumber == buildNumber);
}
public async Task<List<Build>> GetByProjectIdAsync(int projectId)
{
return await _context.Builds
.Include(b => b.Project)
.Where(b => b.ProjectId == projectId)
.OrderByDescending(b => b.StartTime)
.ToListAsync();
}
public async Task<List<Build>> GetSuccessfulBuildsByProjectIdAsync(int projectId)
{
return await _context.Builds
.Include(b => b.Project)
.Where(b => b.ProjectId == projectId && b.Status == "Success")
.OrderByDescending(b => b.StartTime)
.ToListAsync();
}
public async Task<List<Build>> GetRecentBuildsAsync(int count = 50)
{
return await _context.Builds
.Include(b => b.Project)
.OrderByDescending(b => b.StartTime)
.Take(count)
.ToListAsync();
}
public async Task<Build> CreateAsync(Build build)
{
_context.Builds.Add(build);
await _context.SaveChangesAsync();
return build;
}
public async Task<Build> UpdateAsync(Build build)
{
build.UpdateTimestamp(build.UpdatedBy ?? "System");
_context.Builds.Update(build);
await _context.SaveChangesAsync();
return build;
}
public async Task DeleteAsync(int id, string deletedBy)
{
var build = await GetByIdAsync(id);
if (build != null)
{
build.MarkAsDeleted(deletedBy);
await _context.SaveChangesAsync();
}
}
public async Task<bool> ExistsAsync(int id)
{
return await _context.Builds.AnyAsync(b => b.Id == id);
}
public async Task<List<BuildCommit>> GetCommitsByBuildIdAsync(int buildId)
{
return await _context.BuildCommits
.Include(c => c.Build)
.Include(c => c.FogBugzCase)
.Where(c => c.BuildId == buildId)
.OrderByDescending(c => c.CommitDate)
.ToListAsync();
}
public async Task<BuildCommit> CreateCommitAsync(BuildCommit commit)
{
_context.BuildCommits.Add(commit);
await _context.SaveChangesAsync();
return commit;
}
public async Task<List<BuildCommit>> CreateCommitsAsync(List<BuildCommit> commits)
{
_context.BuildCommits.AddRange(commits);
await _context.SaveChangesAsync();
return commits;
}
}
}

View File

@@ -0,0 +1,158 @@
using Database.Models;
using Microsoft.EntityFrameworkCore;
namespace Database.Services
{
public interface IFogBugzRepository
{
Task<FogBugzCase?> GetCaseByIdAsync(int caseId);
Task<FogBugzCase?> GetCaseByDbIdAsync(int id);
Task<List<FogBugzCase>> GetCasesByStatusAsync(string status);
Task<List<FogBugzCase>> GetRecentCasesAsync(int count = 50);
Task<FogBugzCase> CreateOrUpdateCaseAsync(FogBugzCase fogBugzCase);
Task DeleteCaseAsync(int id, string deletedBy);
Task<bool> CaseExistsAsync(int caseId);
Task<List<FogBugzEvent>> GetEventsByCaseIdAsync(int caseId);
Task<FogBugzEvent> CreateEventAsync(FogBugzEvent fogBugzEvent);
Task<List<FogBugzEvent>> CreateEventsAsync(List<FogBugzEvent> events);
Task<List<FogBugzEvent>> GetReleaseNoteEventsAsync(int caseId);
Task<List<FogBugzEvent>> GetZendeskEventsAsync(int caseId);
}
public class FogBugzRepository : IFogBugzRepository
{
private readonly DatabaseContext _context;
public FogBugzRepository(DatabaseContext context)
{
_context = context;
}
public async Task<FogBugzCase?> GetCaseByIdAsync(int caseId)
{
return await _context.FogBugzCases
.Include(c => c.Events.OrderByDescending(e => e.EventDate))
.FirstOrDefaultAsync(c => c.CaseId == caseId);
}
public async Task<FogBugzCase?> GetCaseByDbIdAsync(int id)
{
return await _context.FogBugzCases
.Include(c => c.Events.OrderByDescending(e => e.EventDate))
.FirstOrDefaultAsync(c => c.Id == id);
}
public async Task<List<FogBugzCase>> GetCasesByStatusAsync(string status)
{
return await _context.FogBugzCases
.Where(c => c.Status == status)
.OrderByDescending(c => c.LastUpdated)
.ToListAsync();
}
public async Task<List<FogBugzCase>> GetRecentCasesAsync(int count = 50)
{
return await _context.FogBugzCases
.OrderByDescending(c => c.LastUpdated)
.Take(count)
.ToListAsync();
}
public async Task<FogBugzCase> CreateOrUpdateCaseAsync(FogBugzCase fogBugzCase)
{
var existing = await GetCaseByIdAsync(fogBugzCase.CaseId);
if (existing != null)
{
existing.Title = fogBugzCase.Title;
existing.Project = fogBugzCase.Project;
existing.Area = fogBugzCase.Area;
existing.Status = fogBugzCase.Status;
existing.LastUpdated = fogBugzCase.LastUpdated;
existing.IsOpen = fogBugzCase.IsOpen;
existing.UpdateTimestamp(fogBugzCase.UpdatedBy ?? "System");
_context.FogBugzCases.Update(existing);
await _context.SaveChangesAsync();
return existing;
}
else
{
_context.FogBugzCases.Add(fogBugzCase);
await _context.SaveChangesAsync();
return fogBugzCase;
}
}
public async Task DeleteCaseAsync(int id, string deletedBy)
{
var fogBugzCase = await GetCaseByDbIdAsync(id);
if (fogBugzCase != null)
{
fogBugzCase.MarkAsDeleted(deletedBy);
await _context.SaveChangesAsync();
}
}
public async Task<bool> CaseExistsAsync(int caseId)
{
return await _context.FogBugzCases.AnyAsync(c => c.CaseId == caseId);
}
public async Task<List<FogBugzEvent>> GetEventsByCaseIdAsync(int caseId)
{
var fogBugzCase = await GetCaseByIdAsync(caseId);
if (fogBugzCase == null)
return new List<FogBugzEvent>();
return await _context.FogBugzEvents
.Where(e => e.CaseId == fogBugzCase.Id)
.OrderByDescending(e => e.EventDate)
.ToListAsync();
}
public async Task<FogBugzEvent> CreateEventAsync(FogBugzEvent fogBugzEvent)
{
_context.FogBugzEvents.Add(fogBugzEvent);
await _context.SaveChangesAsync();
return fogBugzEvent;
}
public async Task<List<FogBugzEvent>> CreateEventsAsync(List<FogBugzEvent> events)
{
_context.FogBugzEvents.AddRange(events);
await _context.SaveChangesAsync();
return events;
}
public async Task<List<FogBugzEvent>> GetReleaseNoteEventsAsync(int caseId)
{
var fogBugzCase = await GetCaseByIdAsync(caseId);
if (fogBugzCase == null)
return new List<FogBugzEvent>();
return await _context.FogBugzEvents
.Where(e => e.CaseId == fogBugzCase.Id &&
e.EventType == "ReleaseNoted" &&
!string.IsNullOrEmpty(e.ReleaseNote))
.OrderByDescending(e => e.EventDate)
.ToListAsync();
}
public async Task<List<FogBugzEvent>> GetZendeskEventsAsync(int caseId)
{
var fogBugzCase = await GetCaseByIdAsync(caseId);
if (fogBugzCase == null)
return new List<FogBugzEvent>();
return await _context.FogBugzEvents
.Where(e => e.CaseId == fogBugzCase.Id &&
e.EventType == "sZendesk" &&
e.ZendeskNumber.HasValue &&
e.ZendeskNumber > 0)
.OrderByDescending(e => e.EventDate)
.ToListAsync();
}
}
}

View File

@@ -0,0 +1,130 @@
using Database.Models;
using Microsoft.EntityFrameworkCore;
namespace Database.Services
{
public interface IPackageRepository
{
Task<Package?> GetByIdAsync(int id);
Task<List<Package>> GetAllAsync();
Task<List<Package>> GetByProjectIdAsync(int projectId);
Task<List<Package>> GetByStatusAsync(string status);
Task<Package> CreateAsync(Package package);
Task<Package> UpdateAsync(Package package);
Task DeleteAsync(int id, string deletedBy);
Task<bool> ExistsAsync(int id);
Task<PackageConfiguration?> GetConfigurationAsync(int packageId);
Task<PackageConfiguration> SaveConfigurationAsync(PackageConfiguration config);
}
public class PackageRepository : IPackageRepository
{
private readonly DatabaseContext _context;
public PackageRepository(DatabaseContext context)
{
_context = context;
}
public async Task<Package?> GetByIdAsync(int id)
{
return await _context.Packages
.Include(p => p.Project)
.Include(p => p.SourceBuild)
.Include(p => p.Configuration)
.Include(p => p.Publications)
.FirstOrDefaultAsync(p => p.Id == id);
}
public async Task<List<Package>> GetAllAsync()
{
return await _context.Packages
.Include(p => p.Project)
.Include(p => p.SourceBuild)
.OrderByDescending(p => p.CreatedAt)
.ToListAsync();
}
public async Task<List<Package>> GetByProjectIdAsync(int projectId)
{
return await _context.Packages
.Include(p => p.Project)
.Include(p => p.SourceBuild)
.Where(p => p.ProjectId == projectId)
.OrderByDescending(p => p.CreatedAt)
.ToListAsync();
}
public async Task<List<Package>> GetByStatusAsync(string status)
{
return await _context.Packages
.Include(p => p.Project)
.Include(p => p.SourceBuild)
.Where(p => p.Status == status)
.OrderByDescending(p => p.CreatedAt)
.ToListAsync();
}
public async Task<Package> CreateAsync(Package package)
{
_context.Packages.Add(package);
await _context.SaveChangesAsync();
return package;
}
public async Task<Package> UpdateAsync(Package package)
{
package.UpdateTimestamp(package.UpdatedBy ?? "System");
_context.Packages.Update(package);
await _context.SaveChangesAsync();
return package;
}
public async Task DeleteAsync(int id, string deletedBy)
{
var package = await GetByIdAsync(id);
if (package != null)
{
package.MarkAsDeleted(deletedBy);
await _context.SaveChangesAsync();
}
}
public async Task<bool> ExistsAsync(int id)
{
return await _context.Packages.AnyAsync(p => p.Id == id);
}
public async Task<PackageConfiguration?> GetConfigurationAsync(int packageId)
{
return await _context.PackageConfigurations
.FirstOrDefaultAsync(pc => pc.PackageId == packageId);
}
public async Task<PackageConfiguration> SaveConfigurationAsync(PackageConfiguration config)
{
var existing = await GetConfigurationAsync(config.PackageId);
if (existing != null)
{
existing.BuildFolder = config.BuildFolder;
existing.ZipContents = config.ZipContents;
existing.DeleteOldPublishedBuilds = config.DeleteOldPublishedBuilds;
existing.ReleaseNoteTemplate = config.ReleaseNoteTemplate;
existing.StorageSettings = config.StorageSettings;
existing.HelpCenterSettings = config.HelpCenterSettings;
existing.UpdateTimestamp(config.UpdatedBy ?? "System");
_context.PackageConfigurations.Update(existing);
await _context.SaveChangesAsync();
return existing;
}
else
{
_context.PackageConfigurations.Add(config);
await _context.SaveChangesAsync();
return config;
}
}
}
}

View File

@@ -0,0 +1,96 @@
using Database.Models;
using Microsoft.EntityFrameworkCore;
namespace Database.Services
{
public interface IProjectRepository
{
Task<Project?> GetByIdAsync(int id);
Task<Project?> GetByCCNetNameAsync(string ccNetProjectName);
Task<List<Project>> GetAllAsync();
Task<List<Project>> GetActiveProjectsAsync();
Task<Project> CreateAsync(Project project);
Task<Project> UpdateAsync(Project project);
Task DeleteAsync(int id, string deletedBy);
Task<bool> ExistsAsync(int id);
Task<bool> CCNetNameExistsAsync(string ccNetProjectName);
}
public class ProjectRepository : IProjectRepository
{
private readonly DatabaseContext _context;
public ProjectRepository(DatabaseContext context)
{
_context = context;
}
public async Task<Project?> GetByIdAsync(int id)
{
return await _context.Projects
.Include(p => p.Builds)
.Include(p => p.Packages)
.FirstOrDefaultAsync(p => p.Id == id);
}
public async Task<Project?> GetByCCNetNameAsync(string ccNetProjectName)
{
return await _context.Projects
.Include(p => p.Builds)
.Include(p => p.Packages)
.FirstOrDefaultAsync(p => p.CCNetProjectName == ccNetProjectName);
}
public async Task<List<Project>> GetAllAsync()
{
return await _context.Projects
.Include(p => p.Builds.Take(5))
.Include(p => p.Packages.Take(5))
.OrderBy(p => p.Name)
.ToListAsync();
}
public async Task<List<Project>> GetActiveProjectsAsync()
{
return await _context.Projects
.Where(p => p.Status == "Active")
.OrderBy(p => p.Name)
.ToListAsync();
}
public async Task<Project> CreateAsync(Project project)
{
_context.Projects.Add(project);
await _context.SaveChangesAsync();
return project;
}
public async Task<Project> UpdateAsync(Project project)
{
project.UpdateTimestamp(project.UpdatedBy ?? "System");
_context.Projects.Update(project);
await _context.SaveChangesAsync();
return project;
}
public async Task DeleteAsync(int id, string deletedBy)
{
var project = await GetByIdAsync(id);
if (project != null)
{
project.MarkAsDeleted(deletedBy);
await _context.SaveChangesAsync();
}
}
public async Task<bool> ExistsAsync(int id)
{
return await _context.Projects.AnyAsync(p => p.Id == id);
}
public async Task<bool> CCNetNameExistsAsync(string ccNetProjectName)
{
return await _context.Projects.AnyAsync(p => p.CCNetProjectName == ccNetProjectName);
}
}
}

View File

@@ -0,0 +1,168 @@
using Database.Models;
using Microsoft.EntityFrameworkCore;
namespace Database.Services
{
public interface IPublicationRepository
{
Task<Publication?> GetByIdAsync(int id);
Task<List<Publication>> GetByPackageIdAsync(int packageId);
Task<List<Publication>> GetByStatusAsync(string status);
Task<List<Publication>> GetRecentPublicationsAsync(int count = 50);
Task<Publication> CreateAsync(Publication publication);
Task<Publication> UpdateAsync(Publication publication);
Task DeleteAsync(int id, string deletedBy);
Task<bool> ExistsAsync(int id);
Task<List<PublishingStep>> GetStepsByPublicationIdAsync(int publicationId);
Task<PublishingStep?> GetStepByIdAsync(int stepId);
Task<PublishingStep> CreateStepAsync(PublishingStep step);
Task<PublishingStep> UpdateStepAsync(PublishingStep step);
Task<List<PublishingStep>> CreateStepsAsync(List<PublishingStep> steps);
Task<PublishingStep?> GetCurrentStepAsync(int publicationId);
Task<List<PublishingStep>> GetCompletedStepsAsync(int publicationId);
Task<List<PublishingStep>> GetFailedStepsAsync(int publicationId);
}
public class PublicationRepository : IPublicationRepository
{
private readonly DatabaseContext _context;
public PublicationRepository(DatabaseContext context)
{
_context = context;
}
public async Task<Publication?> GetByIdAsync(int id)
{
return await _context.Publications
.Include(p => p.Package)
.ThenInclude(pkg => pkg.Project)
.Include(p => p.Steps.OrderBy(s => s.StepOrder))
.FirstOrDefaultAsync(p => p.Id == id);
}
public async Task<List<Publication>> GetByPackageIdAsync(int packageId)
{
return await _context.Publications
.Include(p => p.Package)
.Include(p => p.Steps)
.Where(p => p.PackageId == packageId)
.OrderByDescending(p => p.CreatedAt)
.ToListAsync();
}
public async Task<List<Publication>> GetByStatusAsync(string status)
{
return await _context.Publications
.Include(p => p.Package)
.ThenInclude(pkg => pkg.Project)
.Where(p => p.Status == status)
.OrderByDescending(p => p.CreatedAt)
.ToListAsync();
}
public async Task<List<Publication>> GetRecentPublicationsAsync(int count = 50)
{
return await _context.Publications
.Include(p => p.Package)
.ThenInclude(pkg => pkg.Project)
.OrderByDescending(p => p.CreatedAt)
.Take(count)
.ToListAsync();
}
public async Task<Publication> CreateAsync(Publication publication)
{
_context.Publications.Add(publication);
await _context.SaveChangesAsync();
return publication;
}
public async Task<Publication> UpdateAsync(Publication publication)
{
publication.UpdateTimestamp(publication.UpdatedBy ?? "System");
_context.Publications.Update(publication);
await _context.SaveChangesAsync();
return publication;
}
public async Task DeleteAsync(int id, string deletedBy)
{
var publication = await GetByIdAsync(id);
if (publication != null)
{
publication.MarkAsDeleted(deletedBy);
await _context.SaveChangesAsync();
}
}
public async Task<bool> ExistsAsync(int id)
{
return await _context.Publications.AnyAsync(p => p.Id == id);
}
public async Task<List<PublishingStep>> GetStepsByPublicationIdAsync(int publicationId)
{
return await _context.PublishingSteps
.Include(s => s.Publication)
.Where(s => s.PublicationId == publicationId)
.OrderBy(s => s.StepOrder)
.ToListAsync();
}
public async Task<PublishingStep?> GetStepByIdAsync(int stepId)
{
return await _context.PublishingSteps
.Include(s => s.Publication)
.FirstOrDefaultAsync(s => s.Id == stepId);
}
public async Task<PublishingStep> CreateStepAsync(PublishingStep step)
{
_context.PublishingSteps.Add(step);
await _context.SaveChangesAsync();
return step;
}
public async Task<PublishingStep> UpdateStepAsync(PublishingStep step)
{
step.UpdateTimestamp(step.UpdatedBy ?? "System");
_context.PublishingSteps.Update(step);
await _context.SaveChangesAsync();
return step;
}
public async Task<List<PublishingStep>> CreateStepsAsync(List<PublishingStep> steps)
{
_context.PublishingSteps.AddRange(steps);
await _context.SaveChangesAsync();
return steps;
}
public async Task<PublishingStep?> GetCurrentStepAsync(int publicationId)
{
return await _context.PublishingSteps
.Where(s => s.PublicationId == publicationId &&
(s.Status == "InProgress" || s.Status == "Pending"))
.OrderBy(s => s.StepOrder)
.FirstOrDefaultAsync();
}
public async Task<List<PublishingStep>> GetCompletedStepsAsync(int publicationId)
{
return await _context.PublishingSteps
.Where(s => s.PublicationId == publicationId && s.Status == "Completed")
.OrderBy(s => s.StepOrder)
.ToListAsync();
}
public async Task<List<PublishingStep>> GetFailedStepsAsync(int publicationId)
{
return await _context.PublishingSteps
.Where(s => s.PublicationId == publicationId && s.Status == "Failed")
.OrderBy(s => s.StepOrder)
.ToListAsync();
}
}
}

View File

@@ -0,0 +1,113 @@
using Database.Models;
using Microsoft.EntityFrameworkCore;
namespace Database.Services
{
public interface IUserRepository
{
Task<User?> GetByIdAsync(int id);
Task<User?> GetByUsernameAsync(string username);
Task<User?> GetByEmailAsync(string email);
Task<List<User>> GetAllAsync();
Task<List<User>> GetActiveUsersAsync();
Task<User> CreateAsync(User user);
Task<User> UpdateAsync(User user);
Task DeleteAsync(int id, string deletedBy);
Task<bool> ExistsAsync(int id);
Task<bool> UsernameExistsAsync(string username);
Task<bool> EmailExistsAsync(string email);
Task UpdateLastLoginAsync(int userId);
}
public class UserRepository : IUserRepository
{
private readonly DatabaseContext _context;
public UserRepository(DatabaseContext context)
{
_context = context;
}
public async Task<User?> GetByIdAsync(int id)
{
return await _context.Users.FindAsync(id);
}
public async Task<User?> GetByUsernameAsync(string username)
{
return await _context.Users
.FirstOrDefaultAsync(u => u.Username == username);
}
public async Task<User?> GetByEmailAsync(string email)
{
return await _context.Users
.FirstOrDefaultAsync(u => u.Email == email);
}
public async Task<List<User>> GetAllAsync()
{
return await _context.Users
.OrderBy(u => u.Username)
.ToListAsync();
}
public async Task<List<User>> GetActiveUsersAsync()
{
return await _context.Users
.Where(u => u.IsActive)
.OrderBy(u => u.Username)
.ToListAsync();
}
public async Task<User> CreateAsync(User user)
{
_context.Users.Add(user);
await _context.SaveChangesAsync();
return user;
}
public async Task<User> UpdateAsync(User user)
{
user.UpdateTimestamp(user.UpdatedBy ?? "System");
_context.Users.Update(user);
await _context.SaveChangesAsync();
return user;
}
public async Task DeleteAsync(int id, string deletedBy)
{
var user = await GetByIdAsync(id);
if (user != null)
{
user.MarkAsDeleted(deletedBy);
await _context.SaveChangesAsync();
}
}
public async Task<bool> ExistsAsync(int id)
{
return await _context.Users.AnyAsync(u => u.Id == id);
}
public async Task<bool> UsernameExistsAsync(string username)
{
return await _context.Users.AnyAsync(u => u.Username == username);
}
public async Task<bool> EmailExistsAsync(string email)
{
return await _context.Users.AnyAsync(u => u.Email == email);
}
public async Task UpdateLastLoginAsync(int userId)
{
var user = await GetByIdAsync(userId);
if (user != null)
{
user.LastLogin = DateTime.UtcNow;
await _context.SaveChangesAsync();
}
}
}
}

View File

@@ -0,0 +1,98 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Database.Configuration;
namespace Database.Services
{
public interface IDatabaseMigrator
{
Task MigrateAsync();
Task<bool> CanConnectAsync();
Task<List<string>> GetPendingMigrationsAsync();
Task<List<string>> GetAppliedMigrationsAsync();
}
public class DatabaseMigrator : IDatabaseMigrator
{
private readonly DatabaseContext _context;
private readonly DatabaseConfiguration _config;
private readonly ILogger<DatabaseMigrator> _logger;
public DatabaseMigrator(DatabaseContext context, DatabaseConfiguration config, ILogger<DatabaseMigrator> logger)
{
_context = context;
_config = config;
_logger = logger;
}
public async Task MigrateAsync()
{
try
{
_logger.LogInformation("Starting database migration...");
var pendingMigrations = await _context.Database.GetPendingMigrationsAsync();
if (pendingMigrations.Any())
{
_logger.LogInformation("Found {Count} pending migrations: {Migrations}",
pendingMigrations.Count(), string.Join(", ", pendingMigrations));
await _context.Database.MigrateAsync();
_logger.LogInformation("Database migration completed successfully");
}
else
{
_logger.LogInformation("No pending migrations found");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Database migration failed");
throw;
}
}
public async Task<bool> CanConnectAsync()
{
try
{
return await _context.Database.CanConnectAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Unable to connect to database");
return false;
}
}
public async Task<List<string>> GetPendingMigrationsAsync()
{
try
{
var pending = await _context.Database.GetPendingMigrationsAsync();
return pending.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting pending migrations");
return new List<string>();
}
}
public async Task<List<string>> GetAppliedMigrationsAsync()
{
try
{
var applied = await _context.Database.GetAppliedMigrationsAsync();
return applied.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting applied migrations");
return new List<string>();
}
}
}
}

View File

@@ -0,0 +1,312 @@
using Microsoft.Extensions.Logging;
using Database.Models;
namespace Database.Services
{
public interface IDatabaseSeeder
{
Task SeedAsync();
Task SeedUsersAsync();
Task SeedProvidersAsync();
Task SeedTestDataAsync();
}
public class DatabaseSeeder : IDatabaseSeeder
{
private readonly DatabaseContext _context;
private readonly IPasswordHasher _passwordHasher;
private readonly ILogger<DatabaseSeeder> _logger;
public DatabaseSeeder(DatabaseContext context, IPasswordHasher passwordHasher, ILogger<DatabaseSeeder> logger)
{
_context = context;
_passwordHasher = passwordHasher;
_logger = logger;
}
public async Task SeedAsync()
{
try
{
_logger.LogInformation("Starting database seeding...");
await SeedUsersAsync();
await SeedProvidersAsync();
await _context.SaveChangesAsync();
_logger.LogInformation("Database seeding completed successfully");
}
catch (Exception ex)
{
_logger.LogError(ex, "Database seeding failed");
throw;
}
}
public async Task SeedUsersAsync()
{
if (!_context.Users.Any())
{
_logger.LogInformation("Seeding default users...");
var adminUser = new User
{
Username = "admin",
PasswordHash = _passwordHasher.HashPassword("admin123"),
Role = "Admin",
Email = "admin@company.com",
FirstName = "System",
LastName = "Administrator",
IsActive = true,
CreatedBy = "System",
CreatedAt = DateTime.UtcNow
};
var testUser = new User
{
Username = "user",
PasswordHash = _passwordHasher.HashPassword("user123"),
Role = "User",
Email = "user@company.com",
FirstName = "Test",
LastName = "User",
IsActive = true,
CreatedBy = "System",
CreatedAt = DateTime.UtcNow
};
var readOnlyUser = new User
{
Username = "readonly",
PasswordHash = _passwordHasher.HashPassword("readonly123"),
Role = "ReadOnly",
Email = "readonly@company.com",
FirstName = "ReadOnly",
LastName = "User",
IsActive = true,
CreatedBy = "System",
CreatedAt = DateTime.UtcNow
};
_context.Users.AddRange(adminUser, testUser, readOnlyUser);
_logger.LogInformation("Added {Count} default users", 3);
}
}
public async Task SeedProvidersAsync()
{
await SeedStorageProvidersAsync();
await SeedHelpCenterProvidersAsync();
}
private async Task SeedStorageProvidersAsync()
{
if (!_context.StorageProviders.Any())
{
_logger.LogInformation("Seeding default storage providers...");
var s3Provider = new StorageProvider
{
Name = "AWS S3",
Type = "S3",
Description = "Amazon Web Services S3 Storage",
IsActive = true,
CreatedBy = "System",
CreatedAt = DateTime.UtcNow
};
s3Provider.SetConfiguration(new StorageProviderConfiguration
{
Region = "us-east-2",
Bucket = "software-releases",
CloudFrontDomain = "https://cdn.company.com/",
AdditionalSettings = new Dictionary<string, object>
{
["EnableCDN"] = true,
["EnableVersioning"] = true,
["DefaultExpiration"] = 365
}
});
var boxProvider = new StorageProvider
{
Name = "Box.com",
Type = "Box",
Description = "Box.com Cloud Storage",
IsActive = false,
CreatedBy = "System",
CreatedAt = DateTime.UtcNow
};
boxProvider.SetConfiguration(new StorageProviderConfiguration
{
Container = "software-releases",
AdditionalSettings = new Dictionary<string, object>
{
["EnableSharedLinks"] = true,
["DefaultPermission"] = "view"
}
});
var ftpProvider = new StorageProvider
{
Name = "FTP Server",
Type = "FTP",
Description = "Legacy FTP Server",
IsActive = false,
CreatedBy = "System",
CreatedAt = DateTime.UtcNow
};
ftpProvider.SetConfiguration(new StorageProviderConfiguration
{
Host = "ftp.company.com",
Port = 21,
UseSSL = false,
AdditionalSettings = new Dictionary<string, object>
{
["PassiveMode"] = true,
["BinaryMode"] = true
}
});
_context.StorageProviders.AddRange(s3Provider, boxProvider, ftpProvider);
_logger.LogInformation("Added {Count} storage providers", 3);
}
}
private async Task SeedHelpCenterProvidersAsync()
{
if (!_context.HelpCenterProviders.Any())
{
_logger.LogInformation("Seeding default help center providers...");
var zendeskProvider = new HelpCenterProvider
{
Name = "Zendesk",
Type = "Zendesk",
Description = "Zendesk Help Center Integration",
IsActive = true,
CreatedBy = "System",
CreatedAt = DateTime.UtcNow
};
zendeskProvider.SetConfiguration(new HelpCenterProviderConfiguration
{
Subdomain = "company",
ApiVersion = "v2",
DefaultLocale = "en-us",
DefaultTemplateVariables = new Dictionary<string, string>
{
["VERSION"] = "{{VERSION}}",
["SWURL"] = "{{SWURL}}",
["PDFURL"] = "{{PDFURL}}",
["DATE"] = "{{DATE}}",
["PROJECT"] = "{{PROJECT}}"
}
});
var salesforceProvider = new HelpCenterProvider
{
Name = "Salesforce Service",
Type = "Salesforce",
Description = "Salesforce Service Cloud Integration",
IsActive = false,
CreatedBy = "System",
CreatedAt = DateTime.UtcNow
};
salesforceProvider.SetConfiguration(new HelpCenterProviderConfiguration
{
LoginUrl = "https://login.salesforce.com",
ApiVersion = "v58.0",
DefaultLocale = "en-us"
});
_context.HelpCenterProviders.AddRange(zendeskProvider, salesforceProvider);
_logger.LogInformation("Added {Count} help center providers", 2);
}
}
public async Task SeedTestDataAsync()
{
_logger.LogInformation("Seeding test data...");
await SeedTestProjectsAsync();
await SeedTestPackagesAsync();
await _context.SaveChangesAsync();
_logger.LogInformation("Test data seeding completed");
}
private async Task SeedTestProjectsAsync()
{
if (!_context.Projects.Any())
{
var projects = new[]
{
new Project
{
Name = "DataPRO LTS",
Description = "Long Term Support version of DataPRO",
CCNetProjectName = "DATAPRO_MAINT_4_00",
Status = "Active",
CreatedBy = "System",
CreatedAt = DateTime.UtcNow
},
new Project
{
Name = "DataPRO Stable",
Description = "Stable release version of DataPRO",
CCNetProjectName = "DATAPRO_MAINT_4_04",
Status = "Active",
CreatedBy = "System",
CreatedAt = DateTime.UtcNow
}
};
_context.Projects.AddRange(projects);
}
}
private async Task SeedTestPackagesAsync()
{
if (!_context.Packages.Any() && _context.Projects.Any())
{
var ltsProject = _context.Projects.First(p => p.CCNetProjectName == "DATAPRO_MAINT_4_00");
var stableProject = _context.Projects.First(p => p.CCNetProjectName == "DATAPRO_MAINT_4_04");
var packages = new[]
{
new Package
{
Title = "DataPRO LTS Release",
Version = "4.0.1205",
Description = "Long term support release for enterprise customers",
ProjectId = ltsProject.Id,
Status = "Draft",
CreatedBy = "System",
CreatedAt = DateTime.UtcNow
},
new Package
{
Title = "DataPRO Stable Release",
Version = "4.4.305",
Description = "Latest stable release with new features",
ProjectId = stableProject.Id,
Status = "Draft",
CreatedBy = "System",
CreatedAt = DateTime.UtcNow
}
};
_context.Packages.AddRange(packages);
}
}
}
}

View File

@@ -0,0 +1,38 @@
using BCrypt.Net;
namespace Database.Services
{
public interface IPasswordHasher
{
string HashPassword(string password);
bool VerifyPassword(string password, string hashedPassword);
}
public class PasswordHasher : IPasswordHasher
{
private const int WorkFactor = 12;
public string HashPassword(string password)
{
if (string.IsNullOrEmpty(password))
throw new ArgumentException("Password cannot be null or empty", nameof(password));
return BCrypt.Net.BCrypt.HashPassword(password, WorkFactor);
}
public bool VerifyPassword(string password, string hashedPassword)
{
if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(hashedPassword))
return false;
try
{
return BCrypt.Net.BCrypt.Verify(password, hashedPassword);
}
catch
{
return false;
}
}
}
}

View File

@@ -0,0 +1,16 @@
{
"Database": {
"EnableSensitiveDataLogging": true,
"EnableDetailedErrors": true,
"SeedTestData": true,
"AutoMigrate": true
},
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft": "Information",
"Microsoft.EntityFrameworkCore": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Debug"
}
}
}

View File

@@ -0,0 +1,28 @@
{
"Database": {
"EnableSensitiveDataLogging": false,
"EnableDetailedErrors": false,
"CommandTimeout": 60,
"SeedTestData": false,
"AutoMigrate": false,
"Environment": "Production",
"Pooling": {
"MinPoolSize": 10,
"MaxPoolSize": 200,
"ConnectionIdleLifetime": 600,
"ConnectionPruningInterval": 30
},
"Retry": {
"EnableRetryOnFailure": true,
"MaxRetryCount": 3,
"MaxRetryDelay": 60
}
},
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft": "Warning",
"Microsoft.EntityFrameworkCore": "Error"
}
}
}

View File

@@ -0,0 +1,31 @@
{
"Database": {
"ConnectionString": "Host=localhost;Port=5432;Database=software_release_management;Username=postgres;Password=postgres",
"EnableSensitiveDataLogging": false,
"EnableDetailedErrors": false,
"CommandTimeout": 30,
"AutoMigrate": false,
"SeedTestData": false,
"Environment": "Development",
"Pooling": {
"MinPoolSize": 5,
"MaxPoolSize": 100,
"ConnectionIdleLifetime": 300,
"ConnectionPruningInterval": 10
},
"Retry": {
"EnableRetryOnFailure": true,
"MaxRetryCount": 5,
"MaxRetryDelay": 30,
"ErrorNumbersToAdd": []
}
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.EntityFrameworkCore": "Warning"
}
}
}

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