generated from noisedestroyers/claude
Compare commits
9 Commits
3c35e7f60c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f49ba4d01 | |||
| fbd653e82a | |||
| 168137d463 | |||
| f8fad307ba | |||
| 1e6ed7e32d | |||
| 90fa76b864 | |||
| 85117c75a8 | |||
| 54652d79ab | |||
| 482343b6d9 |
25
.obsidian/workspace.json
vendored
25
.obsidian/workspace.json
vendored
@@ -213,17 +213,20 @@
|
||||
},
|
||||
"active": "41ed24ce492fd06e",
|
||||
"lastOpenFiles": [
|
||||
"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",
|
||||
"legacy/CCNetLogReader/CCNetControls/ManuscriptLogin.cs",
|
||||
"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",
|
||||
"claude.md",
|
||||
"implementation.md",
|
||||
"statement-of-work.md",
|
||||
|
||||
441
README-Database.md
Normal file
441
README-Database.md
Normal 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
73
docker-compose.yml
Normal 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
|
||||
@@ -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
|
||||
362
legacy.md
Normal file
362
legacy.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# Legacy System Analysis: CCNetLogReader Solution
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The existing legacy application is a comprehensive Windows Forms-based software development insight and release management platform built in C#. It successfully implements most of the functionality outlined in the statement of work, providing CruiseControl.NET integration, FogBugz/Manuscript integration, automated release note generation, and publishing workflows to cloud storage and help center platforms.
|
||||
|
||||
## Solution Architecture
|
||||
|
||||
The solution consists of 6 main projects in a modular architecture:
|
||||
|
||||
### Project Structure
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Legacy Solution Structure"
|
||||
MAIN[CCNetLogReader<br/>Main WinForms App]
|
||||
CCNET[CCNetWrapper<br/>CC.NET Data Layer]
|
||||
FOGBUGZ[FogbugzWrapper<br/>Manuscript API Client]
|
||||
CONTROLS[CCNetControls<br/>UI Components]
|
||||
EDIT[ManuscriptEdit<br/>Standalone Editor]
|
||||
ZENDESK[ZendeskTickets<br/>Ticket Utilities]
|
||||
end
|
||||
|
||||
MAIN --> CCNET
|
||||
MAIN --> FOGBUGZ
|
||||
MAIN --> CONTROLS
|
||||
EDIT --> FOGBUGZ
|
||||
|
||||
classDef main fill:#e1f5fe
|
||||
classDef wrapper fill:#f3e5f5
|
||||
classDef utility fill:#e8f5e8
|
||||
|
||||
class MAIN main
|
||||
class CCNET,FOGBUGZ wrapper
|
||||
class CONTROLS,EDIT,ZENDESK utility
|
||||
```
|
||||
|
||||
### Core Projects
|
||||
|
||||
1. **CCNetLogReader** - Primary Windows Forms application and orchestration layer
|
||||
2. **CCNetWrapper** - CruiseControl.NET integration and data models
|
||||
3. **FogbugzWrapper** - FogBugz/Manuscript API client and data structures
|
||||
4. **CCNetControls** - Custom UI controls and login dialogs
|
||||
5. **ManuscriptEdit** - Standalone manuscript editing utility
|
||||
6. **ZendeskTickets** - Zendesk ticket management utilities
|
||||
|
||||
## Data Structures and Models
|
||||
|
||||
### Configuration System
|
||||
|
||||
The application uses XML-based configuration (`Configuration.xml`) with the following key structures:
|
||||
|
||||
#### SoftwarePackage Configuration
|
||||
```csharp
|
||||
public class SoftwarePackage
|
||||
{
|
||||
public string Name { get; set; } // Package display name
|
||||
public string ProjectName { get; set; } // CC.NET project reference
|
||||
public string PublishedBuild { get; set; } // Current published version
|
||||
public DateTime PublishedDate { get; set; } // Last publish date
|
||||
public string ProjectBuildFolder { get; set; } // Build artifact location
|
||||
public long ZDArticleID { get; set; } // Zendesk article ID
|
||||
public string ZDArticleText { get; set; } // Article template with placeholders
|
||||
public bool ZipContents { get; set; } // Compress files for upload
|
||||
public bool DeleteOldPublishedBuilds { get; set; } // Cleanup old versions
|
||||
public string ReleaseNoteDocxTemplate { get; set; } // Word template path
|
||||
}
|
||||
```
|
||||
|
||||
#### External Service Configurations
|
||||
- **ZendeskConfiguration**: Locale, subdomain, username, API key
|
||||
- **S3Configuration**: AWS credentials, bucket, CloudFront domain
|
||||
|
||||
### Build and Project Data Models
|
||||
|
||||
#### ServerBuild (`ServerBuild.cs`)
|
||||
```csharp
|
||||
public class ServerBuild
|
||||
{
|
||||
public string ProjectName { get; private set; }
|
||||
public IntegrationStatus BuildStatus { get; private set; }
|
||||
public string LastSuccessfulBuildLabel { get; private set; }
|
||||
public DateTime LastBuildDate { get; private set; }
|
||||
public DateTime? PublishedDate { get; private set; }
|
||||
}
|
||||
```
|
||||
|
||||
#### BuildLog (`CCNetWrapper/BuildLog.cs`)
|
||||
```csharp
|
||||
public class BuildLog
|
||||
{
|
||||
public string ProjectName { get; set; }
|
||||
public string BuildNumber { get; set; }
|
||||
public string Comment { get; set; }
|
||||
public string ReleaseNote { get; set; }
|
||||
public List<string> ModifiedFiles { get; set; }
|
||||
public string User { get; set; }
|
||||
public DateTime Date { get; set; }
|
||||
public string Fogbugz { get; set; } // FogBugz case ID reference
|
||||
public FBEvent.FBStatuses FBStatus { get; set; } // Status from FogBugz
|
||||
}
|
||||
```
|
||||
|
||||
#### ProjectLog (`CCNetWrapper/ProjectLog.cs`)
|
||||
```csharp
|
||||
public class ProjectLog
|
||||
{
|
||||
public List<BuildLog> BuildLogs { get; set; }
|
||||
|
||||
// Integrates with FogBugz to populate status and release notes
|
||||
public void SetFBClient(FogbugzClient fogbugzClient, ref long progTotal, ref long progCurrent)
|
||||
}
|
||||
```
|
||||
|
||||
### FogBugz Integration Models
|
||||
|
||||
#### Case Management (`FogbugzWrapper/Cases.cs`)
|
||||
```csharp
|
||||
public class Case
|
||||
{
|
||||
public List<int> IxBug { get; set; } // Bug/Case ID
|
||||
public int IxProject { get; set; } // Project ID
|
||||
public bool FOpen { get; set; } // Case open status
|
||||
public string STitle { get; set; } // Case title
|
||||
public string SProject { get; set; } // Project name
|
||||
public string SStatus { get; set; } // Current status
|
||||
public DateTime DtLastUpdated { get; set; } // Last modification
|
||||
public Events Events { get; set; } // Case event history
|
||||
}
|
||||
```
|
||||
|
||||
#### Event Tracking (`FogbugzWrapper/FBEvent.cs`)
|
||||
```csharp
|
||||
public class FBEvent
|
||||
{
|
||||
public enum FBStatuses { UNKNOWN, Opened, Resolved, Closed, Reactivated }
|
||||
|
||||
public string User { get; set; }
|
||||
public string Comment { get; set; }
|
||||
public string StatusString { get; set; }
|
||||
public string TimeStamp { get; set; }
|
||||
public string ReleaseNote { get; set; } // Release note content
|
||||
public int ZendeskNumber { get; set; } // Linked Zendesk ticket
|
||||
}
|
||||
```
|
||||
|
||||
## External System Integrations
|
||||
|
||||
### CruiseControl.NET Integration
|
||||
|
||||
**Connection Method**: TCP Remoting (`tcp://{server}:21234/CruiseManager.rem`)
|
||||
|
||||
**Key Components**:
|
||||
- Uses ThoughtWorks.CruiseControl.Remote library
|
||||
- Connects via `CruiseServerClientFactory.GenerateRemotingClient()`
|
||||
- Retrieves project status and build history
|
||||
- Parses build logs for commit information
|
||||
|
||||
**Data Flow**:
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant App as CCNetLogReader
|
||||
participant CC as CruiseControl.NET
|
||||
participant Wrapper as CCNetWrapper
|
||||
|
||||
App->>CC: Connect via TCP Remoting
|
||||
CC->>App: Project List & Status
|
||||
App->>CC: Request Build History
|
||||
CC->>App: Build Log Data
|
||||
App->>Wrapper: Parse Build Logs
|
||||
Wrapper->>App: Structured BuildLog Objects
|
||||
```
|
||||
|
||||
### FogBugz/Manuscript Integration
|
||||
|
||||
**Connection Method**: HTTP API (`http://{server}/api.asp`)
|
||||
|
||||
**Authentication**: Token-based authentication system
|
||||
|
||||
**Key Capabilities**:
|
||||
- Case/bug retrieval with full event history
|
||||
- Status tracking (Opened, Resolved, Closed, Reactivated)
|
||||
- Release note extraction from case events
|
||||
- Zendesk ticket number linking
|
||||
|
||||
**API Endpoints Used**:
|
||||
- `logon` - Authentication
|
||||
- Case queries for status and release notes
|
||||
- Event history parsing for status changes
|
||||
|
||||
### Cloud Storage Integration
|
||||
|
||||
#### AWS S3 Client (`S3Client.cs`)
|
||||
```csharp
|
||||
public class s3Client
|
||||
{
|
||||
IAmazonS3 _s3;
|
||||
s3Config _config;
|
||||
|
||||
// Uses AWS SDK for .NET
|
||||
// Supports file upload with progress tracking
|
||||
// Integrates with CloudFront for CDN delivery
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration**:
|
||||
- Access Key, Secret Key, Region (us-east-2)
|
||||
- Bucket name and CloudFront domain
|
||||
- Support for file compression and cleanup
|
||||
|
||||
### Zendesk Integration
|
||||
|
||||
#### Zendesk Client (`ZendeskClient.cs`)
|
||||
```csharp
|
||||
public class ZendeskClient
|
||||
{
|
||||
ZendeskApi _zd;
|
||||
zdConfig _config;
|
||||
|
||||
// Uses ZendeskApi_v2 library
|
||||
// Supports help center article updates
|
||||
// Template-based content replacement
|
||||
}
|
||||
```
|
||||
|
||||
**Key Features**:
|
||||
- Article updating with templated content
|
||||
- Support for multiple locales (default: en-us)
|
||||
- API key authentication
|
||||
- Template variables: `{{VERSION}}`, `{{SWURL}}`, `{{PDFURL}}`
|
||||
|
||||
## Publishing Workflow
|
||||
|
||||
The legacy system implements a comprehensive publishing workflow:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
START[User Selects Package] --> CONFIG[Load Package Config]
|
||||
CONFIG --> COLLECT[Collect Build Data]
|
||||
COLLECT --> FOGBUGZ[Query FogBugz for Release Notes]
|
||||
FOGBUGZ --> GENERATE[Generate Release Notes Document]
|
||||
GENERATE --> PACKAGE[Package Files for Upload]
|
||||
PACKAGE --> UPLOAD[Upload to S3]
|
||||
UPLOAD --> ZENDESK[Update Zendesk Article]
|
||||
ZENDESK --> UPDATE[Update Package Configuration]
|
||||
UPDATE --> COMPLETE[Publishing Complete]
|
||||
|
||||
subgraph "Document Generation"
|
||||
GENERATE --> WORD[Use Word Template]
|
||||
WORD --> PDF[Convert to PDF]
|
||||
end
|
||||
|
||||
subgraph "File Processing"
|
||||
PACKAGE --> ZIP[Compress if Configured]
|
||||
ZIP --> DELETE[Delete Old Versions]
|
||||
end
|
||||
```
|
||||
|
||||
### Release Notes Generation
|
||||
|
||||
**Technology**: Microsoft Office Interop (Word automation)
|
||||
|
||||
**Process**:
|
||||
1. Load DOCX template from configured path
|
||||
2. Replace template variables with build data
|
||||
3. Include FogBugz release notes from case events
|
||||
4. Generate final document output
|
||||
|
||||
**Template Variables**:
|
||||
- Version information from build numbers
|
||||
- Release notes from FogBugz case events
|
||||
- Build dates and project information
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Software Package Configuration
|
||||
Each package supports the following options:
|
||||
|
||||
- **Project Mapping**: Links to specific CC.NET projects
|
||||
- **Version Tracking**: Maintains published version history
|
||||
- **File Processing**: Compression and cleanup options
|
||||
- **Cloud Storage**: S3 bucket and path configuration
|
||||
- **Help Center**: Zendesk article ID and template text
|
||||
- **Release Notes**: Word template path and generation settings
|
||||
|
||||
### System-Wide Settings
|
||||
- **Build Root**: Base directory for build artifacts
|
||||
- **Local Publish Root**: Staging directory for local processing
|
||||
- **External Service Credentials**: Stored in configuration XML
|
||||
|
||||
## Key Implementation Patterns
|
||||
|
||||
### Configuration Management
|
||||
- XML serialization for persistent configuration
|
||||
- Automatic deserialization on application startup
|
||||
- Support for multiple software package definitions
|
||||
|
||||
### Error Handling
|
||||
- Global exception handling in main application thread
|
||||
- User-friendly error dialogs with stack trace details
|
||||
- Logging integration for debugging
|
||||
|
||||
### UI Architecture
|
||||
- Windows Forms with custom controls
|
||||
- Data binding for project and build information
|
||||
- Progress tracking for long-running operations
|
||||
|
||||
### External Service Abstraction
|
||||
- Wrapper classes for each external integration
|
||||
- Configuration-driven service connections
|
||||
- Token and credential management
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**⚠️ Security Issues Identified**:
|
||||
|
||||
1. **Hardcoded Credentials**: API keys and credentials are stored in plaintext in source code files:
|
||||
- AWS credentials in `S3Client.cs:19-20`
|
||||
- Zendesk credentials in `ZendeskClient.cs:18-19`
|
||||
- FogBugz token in `FogbugzClient.cs:14`
|
||||
|
||||
2. **Configuration File Exposure**: `Configuration.xml` contains sensitive information in plaintext
|
||||
|
||||
3. **No Credential Rotation**: Static credentials without rotation mechanisms
|
||||
|
||||
## Recommendations for Modern Implementation
|
||||
|
||||
### Architecture Migration
|
||||
1. **Service-Oriented**: Migrate from monolithic WinForms to microservices
|
||||
2. **Web-Based UI**: Replace WinForms with modern web interface
|
||||
3. **Container-Based**: Dockerize services for deployment flexibility
|
||||
|
||||
### Security Improvements
|
||||
1. **Secrets Management**: Implement proper secrets management (Azure Key Vault, AWS Secrets Manager)
|
||||
2. **OAuth/OIDC**: Replace API keys with modern authentication
|
||||
3. **Environment-Based Config**: Use environment variables for sensitive data
|
||||
|
||||
### Technology Modernization
|
||||
1. **.NET Core/8**: Upgrade from .NET Framework
|
||||
2. **Entity Framework**: Replace manual XML configuration with database
|
||||
3. **Background Jobs**: Use Hangfire or similar for async processing
|
||||
4. **SignalR**: Add real-time updates for build status
|
||||
|
||||
## Data Migration Strategy
|
||||
|
||||
### Configuration Data
|
||||
- **Source**: `Configuration.xml` with hardcoded package definitions
|
||||
- **Target**: PostgreSQL database with `Packages`, `StorageProviders`, `HelpCenterProviders` tables
|
||||
- **Migration**: Parse XML and insert into normalized database schema
|
||||
|
||||
### Historical Data
|
||||
- **Build History**: Maintain existing CC.NET integration patterns
|
||||
- **FogBugz Data**: Preserve case linking and release note associations
|
||||
- **Publishing History**: Create audit trail for package publications
|
||||
|
||||
## Questions for Implementation Phase
|
||||
|
||||
1. **CC.NET Version**: What version of CruiseControl.NET is currently deployed?
|
||||
2. **FogBugz API**: Are there any API rate limits or authentication changes needed?
|
||||
3. **Document Templates**: Should Word template processing be migrated to a different solution?
|
||||
4. **Legacy Data**: How much historical build and publication data should be migrated?
|
||||
5. **Deployment Environment**: Will the new system run on the same infrastructure or be cloud-native?
|
||||
30
scripts/init-db.sql
Normal file
30
scripts/init-db.sql
Normal 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
23
scripts/servers.json
Normal 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
59
secrets.yaml
Normal 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"
|
||||
47
src/Database/Configuration/DatabaseConfiguration.cs
Normal file
47
src/Database/Configuration/DatabaseConfiguration.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
27
src/Database/Database.csproj
Normal file
27
src/Database/Database.csproj
Normal 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>
|
||||
360
src/Database/DatabaseContext.cs
Normal file
360
src/Database/DatabaseContext.cs
Normal 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')");
|
||||
}
|
||||
}
|
||||
}
|
||||
68
src/Database/Extensions/ServiceCollectionExtensions.cs
Normal file
68
src/Database/Extensions/ServiceCollectionExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/Database/Models/BaseEntity.cs
Normal file
37
src/Database/Models/BaseEntity.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/Database/Models/Build.cs
Normal file
34
src/Database/Models/Build.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
60
src/Database/Models/BuildCommit.cs
Normal file
60
src/Database/Models/BuildCommit.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
69
src/Database/Models/FogBugzCase.cs
Normal file
69
src/Database/Models/FogBugzCase.cs
Normal 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
|
||||
}
|
||||
}
|
||||
55
src/Database/Models/FogBugzEvent.cs
Normal file
55
src/Database/Models/FogBugzEvent.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/Database/Models/HelpCenterProvider.cs
Normal file
93
src/Database/Models/HelpCenterProvider.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
44
src/Database/Models/Package.cs
Normal file
44
src/Database/Models/Package.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
92
src/Database/Models/PackageConfiguration.cs
Normal file
92
src/Database/Models/PackageConfiguration.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
24
src/Database/Models/Project.cs
Normal file
24
src/Database/Models/Project.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
75
src/Database/Models/Publication.cs
Normal file
75
src/Database/Models/Publication.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
104
src/Database/Models/PublishingStep.cs
Normal file
104
src/Database/Models/PublishingStep.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
90
src/Database/Models/StorageProvider.cs
Normal file
90
src/Database/Models/StorageProvider.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
30
src/Database/Models/User.cs
Normal file
30
src/Database/Models/User.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
129
src/Database/Repositories/IBuildRepository.cs
Normal file
129
src/Database/Repositories/IBuildRepository.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
158
src/Database/Repositories/IFogBugzRepository.cs
Normal file
158
src/Database/Repositories/IFogBugzRepository.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
130
src/Database/Repositories/IPackageRepository.cs
Normal file
130
src/Database/Repositories/IPackageRepository.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
96
src/Database/Repositories/IProjectRepository.cs
Normal file
96
src/Database/Repositories/IProjectRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
168
src/Database/Repositories/IPublicationRepository.cs
Normal file
168
src/Database/Repositories/IPublicationRepository.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/Database/Repositories/IUserRepository.cs
Normal file
113
src/Database/Repositories/IUserRepository.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/Database/Services/DatabaseMigrator.cs
Normal file
98
src/Database/Services/DatabaseMigrator.cs
Normal 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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
312
src/Database/Services/DatabaseSeeder.cs
Normal file
312
src/Database/Services/DatabaseSeeder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/Database/Services/PasswordHasher.cs
Normal file
38
src/Database/Services/PasswordHasher.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/Database/appsettings.Development.json
Normal file
16
src/Database/appsettings.Development.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/Database/appsettings.Production.json
Normal file
28
src/Database/appsettings.Production.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
31
src/Database/appsettings.json
Normal file
31
src/Database/appsettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/Frontend/.eslintrc.cjs
Normal file
21
src/Frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,21 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'@typescript-eslint/recommended',
|
||||
'eslint:recommended',
|
||||
'@typescript-eslint/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
}
|
||||
31
src/Frontend/Dockerfile
Normal file
31
src/Frontend/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the app
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
356
src/Frontend/README.md
Normal file
356
src/Frontend/README.md
Normal file
@@ -0,0 +1,356 @@
|
||||
# Release Management Platform - Frontend
|
||||
|
||||
Modern React TypeScript frontend for the Release Management Platform, built with Material-UI and real-time SignalR integration.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dashboard**: Overview of packages, projects, and active publications
|
||||
- **Package Management**: Create, edit, and manage software packages
|
||||
- **Real-time Publishing**: Live progress tracking with WebSocket updates
|
||||
- **Project Monitoring**: Build status and history from CruiseControl.NET
|
||||
- **Responsive Design**: Works on desktop and mobile devices
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **React 18+** with TypeScript
|
||||
- **Material-UI** for component library and theming
|
||||
- **Vite** for fast development and building
|
||||
- **React Query** for server state management
|
||||
- **SignalR** for real-time updates
|
||||
- **React Hook Form** with Yup validation
|
||||
- **React Router** for navigation
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # Reusable UI components
|
||||
│ ├── Layout/ # App layout and navigation
|
||||
│ ├── Packages/ # Package management components
|
||||
│ ├── Publishing/ # Publishing dashboard components
|
||||
│ └── common/ # Shared components
|
||||
├── hooks/ # Custom React hooks
|
||||
├── services/ # API clients and services
|
||||
├── types/ # TypeScript type definitions
|
||||
├── contexts/ # React contexts and providers
|
||||
├── pages/ # Page components
|
||||
└── utils/ # Utility functions
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Development
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The application will be available at http://localhost:3000
|
||||
|
||||
### Building for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
Build the Docker image:
|
||||
```bash
|
||||
docker build -t release-management-frontend .
|
||||
```
|
||||
|
||||
Run the container:
|
||||
```bash
|
||||
docker run -p 3000:80 release-management-frontend
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The frontend is configured to proxy API requests to the backend services:
|
||||
|
||||
- `/api/*` → API Gateway on port 5000
|
||||
- `/hubs/*` → SignalR hubs on port 5000
|
||||
|
||||
For production deployment, configure the nginx.conf file or environment variables as needed.
|
||||
|
||||
## Key Components
|
||||
|
||||
### Package Management
|
||||
- **PackageList**: Displays all packages with filtering and search
|
||||
- **PackageForm**: Create and edit package configurations
|
||||
- Supports tabbed interface for basic info, configuration, storage, and help center settings
|
||||
|
||||
### Publishing Dashboard
|
||||
- **PublishingDashboard**: Real-time view of active publications
|
||||
- **ProgressBar**: Visual progress indicators for publishing steps
|
||||
- Live updates via SignalR for step completion and progress
|
||||
|
||||
### Real-time Updates
|
||||
- SignalR integration for live publishing progress
|
||||
- Build status updates from CruiseControl.NET
|
||||
- Automatic UI refresh on publication completion
|
||||
|
||||
## API Integration
|
||||
|
||||
The frontend integrates with the following backend services:
|
||||
|
||||
- **Package Service**: CRUD operations for packages
|
||||
- **Project Service**: CruiseControl.NET integration
|
||||
- **Publication Service**: Publishing workflow management
|
||||
- **SignalR Hub**: Real-time notifications
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
- Use TypeScript for type safety
|
||||
- Follow Material-UI design patterns
|
||||
- Implement proper error handling and loading states
|
||||
- Use React Query for server state management
|
||||
- Write reusable components with proper prop interfaces
|
||||
- Follow responsive design principles
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Create a `.env.local` file for local development:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=http://localhost:5000
|
||||
VITE_SIGNALR_HUB_URL=http://localhost:5000/hubs
|
||||
```
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Chrome (latest)
|
||||
- Firefox (latest)
|
||||
- Safari (latest)
|
||||
- Edge (latest)
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Follow the existing code style and patterns
|
||||
2. Write TypeScript interfaces for all data structures
|
||||
3. Include proper error handling and loading states
|
||||
4. Test responsive behavior on different screen sizes
|
||||
5. Document complex components and hooks
|
||||
|
||||
---
|
||||
|
||||
## 📝 Developer Notes for Future Development
|
||||
|
||||
### Architecture Decisions & Patterns
|
||||
|
||||
#### **State Management Strategy**
|
||||
- **React Query**: Used for all server state (packages, projects, publications)
|
||||
- **Local State**: useState/useReducer for UI-only state
|
||||
- **Context API**: Only for truly global state (currently just QueryProvider)
|
||||
- **No Redux**: Kept simple with React Query + local state
|
||||
|
||||
#### **Component Architecture**
|
||||
```
|
||||
Pages -> Layout -> Feature Components -> Common Components
|
||||
```
|
||||
- **Pages**: Route-level components, minimal logic
|
||||
- **Feature Components**: Domain-specific (Packages, Publishing)
|
||||
- **Common Components**: Reusable UI elements
|
||||
- **Layout**: Navigation and app shell
|
||||
|
||||
#### **API Integration Patterns**
|
||||
- **Services Layer**: Clean separation of API logic
|
||||
- **Custom Hooks**: Encapsulate data fetching and mutations
|
||||
- **Error Boundaries**: Not implemented yet - consider adding
|
||||
- **Optimistic Updates**: Implemented in mutation hooks
|
||||
|
||||
### 🔧 Technical Implementation Notes
|
||||
|
||||
#### **SignalR Integration**
|
||||
```typescript
|
||||
// SignalR connection lifecycle managed in useSignalR hook
|
||||
// Automatic reconnection enabled
|
||||
// Group-based subscriptions for publication updates
|
||||
```
|
||||
|
||||
**Important**: SignalR connection state should be displayed in UI (connection indicator in AppLayout)
|
||||
|
||||
#### **Form Handling**
|
||||
- **React Hook Form + Yup**: Chosen for performance and TypeScript support
|
||||
- **Tabbed Forms**: PackageForm uses Material-UI Tabs
|
||||
- **Dynamic Fields**: Build selection depends on project selection
|
||||
|
||||
#### **Real-time Updates Flow**
|
||||
```
|
||||
1. User triggers publish -> API call
|
||||
2. SignalR sends progress updates -> useSignalR hook
|
||||
3. Hook invalidates React Query cache -> UI updates
|
||||
4. Publication completes -> Final UI refresh
|
||||
```
|
||||
|
||||
### 🚨 Known Issues & TODOs
|
||||
|
||||
#### **Missing Components**
|
||||
- [ ] **Projects Page**: Currently just placeholder
|
||||
- [ ] **History Page**: Publication history view needed
|
||||
- [ ] **Settings Page**: User preferences, API configuration
|
||||
- [ ] **User Management**: Authentication UI
|
||||
- [ ] **Error Boundaries**: Global error handling
|
||||
|
||||
#### **UX Improvements Needed**
|
||||
- [ ] **Loading Skeletons**: Replace spinners with skeleton screens
|
||||
- [ ] **Toast Notifications**: Success/error feedback
|
||||
- [ ] **Keyboard Navigation**: Accessibility improvements
|
||||
- [ ] **Mobile Optimization**: Touch gestures, better responsive design
|
||||
- [ ] **Dark Mode**: Theme switcher
|
||||
|
||||
#### **Performance Optimizations**
|
||||
- [ ] **Virtualization**: For large lists (packages, publications)
|
||||
- [ ] **Code Splitting**: Route-based lazy loading
|
||||
- [ ] **Bundle Analysis**: webpack-bundle-analyzer equivalent for Vite
|
||||
- [ ] **Image Optimization**: If images are added later
|
||||
|
||||
### 🔐 Security Considerations
|
||||
|
||||
#### **Current Implementation**
|
||||
- JWT tokens stored in localStorage (not ideal for production)
|
||||
- CORS handled by backend
|
||||
- CSP headers in nginx.conf
|
||||
- Input validation with Yup schemas
|
||||
|
||||
#### **Production Security TODOs**
|
||||
- [ ] **HttpOnly Cookies**: Move JWT to secure cookies
|
||||
- [ ] **CSRF Protection**: If switching to cookies
|
||||
- [ ] **Content Security Policy**: Strengthen CSP rules
|
||||
- [ ] **Input Sanitization**: XSS prevention for rich text fields
|
||||
|
||||
### 🎨 UI/UX Design System
|
||||
|
||||
#### **Material-UI Customizations**
|
||||
```typescript
|
||||
// Theme customization in App.tsx
|
||||
// Custom shadows for cards/papers
|
||||
// Color palette matches backend architecture diagram
|
||||
```
|
||||
|
||||
#### **Component Patterns**
|
||||
- **StatusChip**: Consistent status display with icons/colors
|
||||
- **ProgressBar**: Reusable for publishing workflows
|
||||
- **LoadingSpinner**: Consistent loading states
|
||||
- **ErrorDisplay**: Standard error handling UI
|
||||
|
||||
#### **Responsive Breakpoints**
|
||||
```typescript
|
||||
// Material-UI defaults:
|
||||
xs: 0px, sm: 600px, md: 900px, lg: 1200px, xl: 1536px
|
||||
// Drawer collapses below lg (1200px)
|
||||
```
|
||||
|
||||
### 🧪 Testing Strategy (Not Implemented)
|
||||
|
||||
#### **Recommended Testing Approach**
|
||||
```bash
|
||||
# Unit Tests: React Testing Library + Jest
|
||||
npm install --save-dev @testing-library/react @testing-library/jest-dom
|
||||
|
||||
# E2E Tests: Playwright or Cypress
|
||||
npm install --save-dev playwright
|
||||
|
||||
# Component Testing: Storybook
|
||||
npm install --save-dev @storybook/react
|
||||
```
|
||||
|
||||
#### **Test Priorities**
|
||||
1. **Critical User Flows**: Package creation, publishing workflow
|
||||
2. **Real-time Features**: SignalR connection handling
|
||||
3. **Form Validation**: Package form edge cases
|
||||
4. **API Error Handling**: Network failure scenarios
|
||||
|
||||
### 🚀 Deployment Notes
|
||||
|
||||
#### **Environment Configuration**
|
||||
```bash
|
||||
# Development
|
||||
VITE_API_BASE_URL=http://localhost:5000
|
||||
|
||||
# Production
|
||||
VITE_API_BASE_URL=https://api.yourdomain.com
|
||||
```
|
||||
|
||||
#### **Docker Multi-stage Build**
|
||||
- Stage 1: Node.js build environment
|
||||
- Stage 2: Nginx serving static files
|
||||
- nginx.conf proxies API requests to backend
|
||||
|
||||
#### **Monitoring & Observability**
|
||||
- [ ] **Error Tracking**: Sentry or similar
|
||||
- [ ] **Analytics**: User behavior tracking
|
||||
- [ ] **Performance**: Core Web Vitals monitoring
|
||||
- [ ] **Logging**: Frontend error logging
|
||||
|
||||
### 🔄 Integration with Backend Services
|
||||
|
||||
#### **Expected API Endpoints**
|
||||
```typescript
|
||||
// Based on implementation.md architecture:
|
||||
GET /api/packages # List packages
|
||||
POST /api/packages # Create package
|
||||
GET /api/packages/{id} # Get package details
|
||||
PUT /api/packages/{id} # Update package
|
||||
DELETE /api/packages/{id} # Delete package
|
||||
POST /api/packages/{id}/publish # Start publishing
|
||||
|
||||
GET /api/projects # List projects
|
||||
GET /api/projects/{id}/builds # Get project builds
|
||||
GET /api/builds/{id}/commits # Get build commits
|
||||
|
||||
GET /api/publications # List publications
|
||||
GET /api/publications/active # Active publications
|
||||
POST /api/publications/{id}/cancel # Cancel publication
|
||||
```
|
||||
|
||||
#### **SignalR Hub Events**
|
||||
```typescript
|
||||
// Expected events from backend SignalR hub:
|
||||
'PublishingProgress' // Step progress updates
|
||||
'BuildStatusUpdate' # Project build status changes
|
||||
'PublicationCompleted' // Publication finished successfully
|
||||
'PublicationFailed' // Publication failed with error
|
||||
```
|
||||
|
||||
### 💡 Future Enhancement Ideas
|
||||
|
||||
#### **Advanced Features**
|
||||
- **Bulk Operations**: Select multiple packages for batch publishing
|
||||
- **Publishing Templates**: Reusable configuration templates
|
||||
- **Workflow Visualization**: Visual pipeline representation
|
||||
- **Audit Trail**: Detailed action history with user attribution
|
||||
- **Notifications**: Email/Slack integration for publish completion
|
||||
- **Rollback**: Revert to previous package versions
|
||||
|
||||
#### **Developer Experience**
|
||||
- **Hot Reload**: Already implemented with Vite
|
||||
- **TypeScript Strict Mode**: Enable stricter type checking
|
||||
- **ESLint Rules**: Add more React-specific linting rules
|
||||
- **Prettier**: Code formatting consistency
|
||||
- **Husky**: Pre-commit hooks for quality checks
|
||||
|
||||
### 🤝 Team Collaboration Notes
|
||||
|
||||
#### **Code Review Checklist**
|
||||
- [ ] TypeScript interfaces defined for new data structures
|
||||
- [ ] Loading and error states handled
|
||||
- [ ] Mobile responsiveness tested
|
||||
- [ ] SignalR subscriptions properly cleaned up
|
||||
- [ ] React Query cache invalidation logic correct
|
||||
- [ ] Form validation covers edge cases
|
||||
|
||||
#### **Naming Conventions**
|
||||
- **Components**: PascalCase (PackageList.tsx)
|
||||
- **Hooks**: camelCase with 'use' prefix (usePackages.ts)
|
||||
- **Types**: PascalCase interfaces/enums (Package, PackageStatus)
|
||||
- **Files**: PascalCase for components, camelCase for utilities
|
||||
|
||||
This frontend implementation provides a solid foundation for the Release Management Platform. The architecture is scalable and the patterns established should guide future development efforts.
|
||||
21
src/Frontend/index.html
Normal file
21
src/Frontend/index.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Release Management Platform</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/icon?family=Material+Icons"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
src/Frontend/nginx.conf
Normal file
50
src/Frontend/nginx.conf
Normal file
@@ -0,0 +1,50 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
# Serve static assets with caching
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://api-gateway:80/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# SignalR hub proxy
|
||||
location /hubs/ {
|
||||
proxy_pass http://api-gateway:80/hubs/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Handle client-side routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||
}
|
||||
43
src/Frontend/package.json
Normal file
43
src/Frontend/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "release-management-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@mui/material": "^5.15.1",
|
||||
"@mui/icons-material": "^5.15.1",
|
||||
"@mui/x-data-grid": "^6.18.2",
|
||||
"@mui/x-date-pickers": "^6.18.2",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@microsoft/signalr": "^8.0.0",
|
||||
"@tanstack/react-query": "^5.8.4",
|
||||
"@tanstack/react-query-devtools": "^5.8.4",
|
||||
"axios": "^1.6.2",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"date-fns": "^2.30.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"@hookform/resolvers": "^3.3.2",
|
||||
"yup": "^1.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react": "^4.1.1",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
75
src/Frontend/src/App.tsx
Normal file
75
src/Frontend/src/App.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ThemeProvider, createTheme, CssBaseline } from '@mui/material';
|
||||
import { LocalizationProvider } from '@mui/x-date-pickers';
|
||||
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
|
||||
import { QueryProvider } from './contexts/QueryProvider';
|
||||
import { AppLayout } from './components/Layout/AppLayout';
|
||||
import { DashboardPage } from './pages/DashboardPage';
|
||||
import { PackagesPage } from './pages/PackagesPage';
|
||||
import { PublishingPage } from './pages/PublishingPage';
|
||||
|
||||
// Create Material-UI theme
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#1976d2',
|
||||
},
|
||||
secondary: {
|
||||
main: '#dc004e',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||
h4: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
h6: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const App: React.FC = () => {
|
||||
return (
|
||||
<QueryProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<CssBaseline />
|
||||
<Router>
|
||||
<AppLayout>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/packages" element={<PackagesPage />} />
|
||||
<Route path="/publishing" element={<PublishingPage />} />
|
||||
<Route path="/projects" element={<div>Projects page - Coming soon</div>} />
|
||||
<Route path="/history" element={<div>History page - Coming soon</div>} />
|
||||
<Route path="/settings" element={<div>Settings page - Coming soon</div>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AppLayout>
|
||||
</Router>
|
||||
</LocalizationProvider>
|
||||
</ThemeProvider>
|
||||
</QueryProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
205
src/Frontend/src/components/Layout/AppLayout.tsx
Normal file
205
src/Frontend/src/components/Layout/AppLayout.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
AppBar,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Drawer,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListItemButton,
|
||||
Box,
|
||||
IconButton,
|
||||
Badge,
|
||||
Tooltip,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
Dashboard as DashboardIcon,
|
||||
Inventory as PackagesIcon,
|
||||
Build as BuildIcon,
|
||||
Publish as PublishIcon,
|
||||
History as HistoryIcon,
|
||||
Settings as SettingsIcon,
|
||||
Notifications as NotificationsIcon,
|
||||
AccountCircle as AccountIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useSignalR } from '../../hooks/useSignalR';
|
||||
import { useActivePublications } from '../../hooks/usePublications';
|
||||
|
||||
const DRAWER_WIDTH = 240;
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const AppLayout: React.FC<AppLayoutProps> = ({ children }) => {
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { isConnected } = useSignalR();
|
||||
const { data: activePublications } = useActivePublications();
|
||||
|
||||
const menuItems = [
|
||||
{ text: 'Dashboard', icon: <DashboardIcon />, path: '/' },
|
||||
{ text: 'Packages', icon: <PackagesIcon />, path: '/packages' },
|
||||
{ text: 'Projects', icon: <BuildIcon />, path: '/projects' },
|
||||
{ text: 'Publishing', icon: <PublishIcon />, path: '/publishing' },
|
||||
{ text: 'History', icon: <HistoryIcon />, path: '/history' },
|
||||
];
|
||||
|
||||
const activePublicationCount = activePublications?.data?.length || 0;
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setDrawerOpen(!drawerOpen);
|
||||
};
|
||||
|
||||
const handleNavigation = (path: string) => {
|
||||
navigate(path);
|
||||
if (window.innerWidth < 900) {
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const drawer = (
|
||||
<Box sx={{ overflow: 'auto' }}>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" noWrap component="div">
|
||||
Release Management
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<List>
|
||||
{menuItems.map((item) => (
|
||||
<ListItem key={item.text} disablePadding>
|
||||
<ListItemButton
|
||||
selected={location.pathname === item.path}
|
||||
onClick={() => handleNavigation(item.path)}
|
||||
>
|
||||
<ListItemIcon>
|
||||
{item.text === 'Publishing' ? (
|
||||
<Badge badgeContent={activePublicationCount} color="secondary">
|
||||
{item.icon}
|
||||
</Badge>
|
||||
) : (
|
||||
item.icon
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={() => handleNavigation('/settings')}>
|
||||
<ListItemIcon>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Settings" />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||
bgcolor: 'primary.main',
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
edge="start"
|
||||
onClick={handleDrawerToggle}
|
||||
sx={{ mr: 2, display: { lg: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||
Release Management Platform
|
||||
</Typography>
|
||||
|
||||
<Tooltip title={isConnected ? 'Connected' : 'Disconnected'}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: '50%',
|
||||
bgcolor: isConnected ? 'success.main' : 'error.main',
|
||||
mr: 2,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Notifications">
|
||||
<IconButton color="inherit">
|
||||
<Badge badgeContent={activePublicationCount} color="secondary">
|
||||
<NotificationsIcon />
|
||||
</Badge>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Account">
|
||||
<IconButton color="inherit">
|
||||
<AccountIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={drawerOpen}
|
||||
onClose={handleDrawerToggle}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{
|
||||
display: { xs: 'block', lg: 'none' },
|
||||
'& .MuiDrawer-paper': {
|
||||
boxSizing: 'border-box',
|
||||
width: DRAWER_WIDTH,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: 'none', lg: 'block' },
|
||||
'& .MuiDrawer-paper': {
|
||||
boxSizing: 'border-box',
|
||||
width: DRAWER_WIDTH,
|
||||
},
|
||||
}}
|
||||
open
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
p: 3,
|
||||
width: { lg: `calc(100% - ${DRAWER_WIDTH}px)` },
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
411
src/Frontend/src/components/Packages/PackageForm.tsx
Normal file
411
src/Frontend/src/components/Packages/PackageForm.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
TextField,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Box,
|
||||
Tabs,
|
||||
Tab,
|
||||
Typography,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
Autocomplete,
|
||||
} from '@mui/material';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as yup from 'yup';
|
||||
import {
|
||||
Package,
|
||||
CreatePackageRequest,
|
||||
Build
|
||||
} from '../../types';
|
||||
import { useProjects, useProjectBuilds } from '../../hooks/useProjects';
|
||||
import { useCreatePackage, useUpdatePackage } from '../../hooks/usePackages';
|
||||
import { LoadingSpinner } from '../common/LoadingSpinner';
|
||||
|
||||
interface PackageFormProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
package?: Package;
|
||||
}
|
||||
|
||||
interface TabPanelProps {
|
||||
children?: React.ReactNode;
|
||||
index: number;
|
||||
value: number;
|
||||
}
|
||||
|
||||
const TabPanel: React.FC<TabPanelProps> = ({ children, value, index, ...other }) => (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`package-tabpanel-${index}`}
|
||||
aria-labelledby={`package-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const schema = yup.object({
|
||||
title: yup.string().required('Title is required'),
|
||||
version: yup.string().required('Version is required'),
|
||||
description: yup.string().required('Description is required'),
|
||||
projectId: yup.number().required('Project is required'),
|
||||
sourceBuildId: yup.number().required('Source build is required'),
|
||||
configuration: yup.object({
|
||||
buildFolder: yup.string().required('Build folder is required'),
|
||||
zipContents: yup.boolean().required(),
|
||||
deleteOldPublishedBuilds: yup.boolean().required(),
|
||||
releaseNoteTemplate: yup.string().required('Release note template is required'),
|
||||
storageSettings: yup.object().required(),
|
||||
helpCenterSettings: yup.object().required(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const PackageForm: React.FC<PackageFormProps> = ({ open, onClose, package: pkg }) => {
|
||||
const [currentTab, setCurrentTab] = React.useState(0);
|
||||
const [selectedProjectId, setSelectedProjectId] = React.useState<number | null>(null);
|
||||
|
||||
const { data: projectsData } = useProjects();
|
||||
const { data: buildsData } = useProjectBuilds(
|
||||
selectedProjectId || 0,
|
||||
1,
|
||||
50,
|
||||
{ status: 'Success' }
|
||||
);
|
||||
|
||||
const createMutation = useCreatePackage();
|
||||
const updateMutation = useUpdatePackage();
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors, isValid },
|
||||
} = useForm<CreatePackageRequest>({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
title: '',
|
||||
version: '',
|
||||
description: '',
|
||||
projectId: 0,
|
||||
sourceBuildId: 0,
|
||||
configuration: {
|
||||
buildFolder: '',
|
||||
zipContents: true,
|
||||
deleteOldPublishedBuilds: true,
|
||||
releaseNoteTemplate: '',
|
||||
storageSettings: {},
|
||||
helpCenterSettings: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const watchedProjectId = watch('projectId');
|
||||
|
||||
useEffect(() => {
|
||||
if (watchedProjectId) {
|
||||
setSelectedProjectId(watchedProjectId);
|
||||
}
|
||||
}, [watchedProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pkg) {
|
||||
reset({
|
||||
title: pkg.title,
|
||||
version: pkg.version,
|
||||
description: pkg.description,
|
||||
projectId: pkg.projectId,
|
||||
sourceBuildId: pkg.sourceBuildId,
|
||||
configuration: pkg.configuration || {
|
||||
buildFolder: '',
|
||||
zipContents: true,
|
||||
deleteOldPublishedBuilds: true,
|
||||
releaseNoteTemplate: '',
|
||||
storageSettings: {},
|
||||
helpCenterSettings: {},
|
||||
},
|
||||
});
|
||||
setSelectedProjectId(pkg.projectId);
|
||||
} else {
|
||||
reset({
|
||||
title: '',
|
||||
version: '',
|
||||
description: '',
|
||||
projectId: 0,
|
||||
sourceBuildId: 0,
|
||||
configuration: {
|
||||
buildFolder: '',
|
||||
zipContents: true,
|
||||
deleteOldPublishedBuilds: true,
|
||||
releaseNoteTemplate: '',
|
||||
storageSettings: {},
|
||||
helpCenterSettings: {},
|
||||
},
|
||||
});
|
||||
setSelectedProjectId(null);
|
||||
}
|
||||
}, [pkg, reset]);
|
||||
|
||||
const onSubmit = async (data: CreatePackageRequest) => {
|
||||
try {
|
||||
if (pkg) {
|
||||
await updateMutation.mutateAsync({ id: pkg.id, request: data });
|
||||
} else {
|
||||
await createMutation.mutateAsync(data);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to save package:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentTab(0);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const projects = projectsData?.data || [];
|
||||
const builds = buildsData?.data || [];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogTitle>
|
||||
{pkg ? 'Edit Package' : 'Create Package'}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<Tabs value={currentTab} onChange={(_, newValue) => setCurrentTab(newValue)}>
|
||||
<Tab label="Basic Info" />
|
||||
<Tab label="Configuration" />
|
||||
<Tab label="Storage" />
|
||||
<Tab label="Help Center" />
|
||||
</Tabs>
|
||||
|
||||
{/* Basic Info Tab */}
|
||||
<TabPanel value={currentTab} index={0}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller
|
||||
name="title"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Title"
|
||||
fullWidth
|
||||
error={!!errors.title}
|
||||
helperText={errors.title?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller
|
||||
name="version"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Version"
|
||||
fullWidth
|
||||
error={!!errors.version}
|
||||
helperText={errors.version?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Description"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
error={!!errors.description}
|
||||
helperText={errors.description?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller
|
||||
name="projectId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl fullWidth error={!!errors.projectId}>
|
||||
<InputLabel>Project</InputLabel>
|
||||
<Select
|
||||
{...field}
|
||||
label="Project"
|
||||
>
|
||||
{projects.map((project) => (
|
||||
<MenuItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Controller
|
||||
name="sourceBuildId"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Autocomplete
|
||||
options={builds}
|
||||
getOptionLabel={(build: Build) =>
|
||||
`${build.buildNumber} (${new Date(build.endTime).toLocaleDateString()})`
|
||||
}
|
||||
value={builds.find(b => b.id === field.value) || null}
|
||||
onChange={(_, value) => field.onChange(value?.id || 0)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label="Source Build"
|
||||
error={!!errors.sourceBuildId}
|
||||
helperText={errors.sourceBuildId?.message}
|
||||
/>
|
||||
)}
|
||||
disabled={!selectedProjectId}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
{/* Configuration Tab */}
|
||||
<TabPanel value={currentTab} index={1}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Controller
|
||||
name="configuration.buildFolder"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Build Folder Path"
|
||||
fullWidth
|
||||
error={!!errors.configuration?.buildFolder}
|
||||
helperText={errors.configuration?.buildFolder?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Controller
|
||||
name="configuration.zipContents"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch {...field} checked={field.value} />}
|
||||
label="ZIP Contents"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Controller
|
||||
name="configuration.deleteOldPublishedBuilds"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch {...field} checked={field.value} />}
|
||||
label="Delete Old Published Builds"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Controller
|
||||
name="configuration.releaseNoteTemplate"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label="Release Note Template"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
error={!!errors.configuration?.releaseNoteTemplate}
|
||||
helperText={errors.configuration?.releaseNoteTemplate?.message}
|
||||
placeholder="Template variables: {{VERSION}}, {{DATE}}, {{PROJECT}}"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
{/* Storage Tab */}
|
||||
<TabPanel value={currentTab} index={2}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Storage Configuration
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Configure cloud storage settings for package deployment.
|
||||
</Typography>
|
||||
{/* Storage configuration fields would go here */}
|
||||
<TextField
|
||||
label="Storage Provider"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
placeholder="AWS S3, Box.com, FTP"
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
{/* Help Center Tab */}
|
||||
<TabPanel value={currentTab} index={3}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Help Center Configuration
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Configure automatic help center article updates.
|
||||
</Typography>
|
||||
{/* Help center configuration fields would go here */}
|
||||
<TextField
|
||||
label="Article ID"
|
||||
fullWidth
|
||||
margin="normal"
|
||||
/>
|
||||
</TabPanel>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
disabled={!isValid || createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending || updateMutation.isPending ? (
|
||||
<LoadingSpinner size={20} />
|
||||
) : pkg ? (
|
||||
'Update'
|
||||
) : (
|
||||
'Create'
|
||||
)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
270
src/Frontend/src/components/Packages/PackageList.tsx
Normal file
270
src/Frontend/src/components/Packages/PackageList.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Grid,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Chip,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TablePagination,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
Publish as PublishIcon,
|
||||
Search as SearchIcon,
|
||||
FilterList as FilterIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { usePackages, useDeletePackage } from '../../hooks/usePackages';
|
||||
import { useProjects } from '../../hooks/useProjects';
|
||||
import { Package, PackageStatus, PackageFilter } from '../../types';
|
||||
import { StatusChip } from '../common/StatusChip';
|
||||
import { LoadingSpinner } from '../common/LoadingSpinner';
|
||||
import { ErrorDisplay } from '../common/ErrorDisplay';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface PackageListProps {
|
||||
onCreatePackage: () => void;
|
||||
onEditPackage: (packageItem: Package) => void;
|
||||
onPublishPackage: (packageItem: Package) => void;
|
||||
}
|
||||
|
||||
export const PackageList: React.FC<PackageListProps> = ({
|
||||
onCreatePackage,
|
||||
onEditPackage,
|
||||
onPublishPackage,
|
||||
}) => {
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [filter, setFilter] = useState<PackageFilter>({});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const { data: packagesData, isLoading, error, refetch } = usePackages(
|
||||
page + 1,
|
||||
pageSize,
|
||||
filter
|
||||
);
|
||||
const { data: projectsData } = useProjects();
|
||||
const deletePackageMutation = useDeletePackage();
|
||||
|
||||
const handleFilterChange = (field: keyof PackageFilter, value: any) => {
|
||||
setFilter(prev => ({ ...prev, [field]: value }));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (window.confirm('Are you sure you want to delete this package?')) {
|
||||
await deletePackageMutation.mutateAsync(id);
|
||||
}
|
||||
};
|
||||
|
||||
const canPublish = (pkg: Package): boolean => {
|
||||
return pkg.status === PackageStatus.Ready || pkg.status === PackageStatus.Failed;
|
||||
};
|
||||
|
||||
const canEdit = (pkg: Package): boolean => {
|
||||
return pkg.status !== PackageStatus.Publishing;
|
||||
};
|
||||
|
||||
if (isLoading) return <LoadingSpinner message="Loading packages..." />;
|
||||
if (error) return <ErrorDisplay message="Failed to load packages" onRetry={refetch} />;
|
||||
|
||||
const packages = packagesData?.data || [];
|
||||
const totalCount = packagesData?.totalCount || 0;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header */}
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Packages
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={onCreatePackage}
|
||||
>
|
||||
Create Package
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* Filters */}
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" gap={2} mb={showFilters ? 2 : 0}>
|
||||
<TextField
|
||||
placeholder="Search packages..."
|
||||
variant="outlined"
|
||||
size="small"
|
||||
value={filter.searchTerm || ''}
|
||||
onChange={(e) => handleFilterChange('searchTerm', e.target.value)}
|
||||
InputProps={{
|
||||
startAdornment: <SearchIcon sx={{ mr: 1, color: 'text.secondary' }} />,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<FilterIcon />}
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
{showFilters ? 'Hide Filters' : 'Show Filters'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{showFilters && (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Project</InputLabel>
|
||||
<Select
|
||||
value={filter.projectId || ''}
|
||||
onChange={(e) => handleFilterChange('projectId', e.target.value || undefined)}
|
||||
label="Project"
|
||||
>
|
||||
<MenuItem value="">All Projects</MenuItem>
|
||||
{projectsData?.data?.map((project) => (
|
||||
<MenuItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={filter.status || ''}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
|
||||
label="Status"
|
||||
>
|
||||
<MenuItem value="">All Statuses</MenuItem>
|
||||
{Object.values(PackageStatus).map((status) => (
|
||||
<MenuItem key={status} value={status}>
|
||||
{status}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Packages Table */}
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Title</TableCell>
|
||||
<TableCell>Version</TableCell>
|
||||
<TableCell>Project</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Published Date</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{packages.map((pkg) => (
|
||||
<TableRow key={pkg.id} hover>
|
||||
<TableCell>
|
||||
<Box>
|
||||
<Typography variant="subtitle2">{pkg.title}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{pkg.description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={pkg.version} variant="outlined" size="small" />
|
||||
</TableCell>
|
||||
<TableCell>{pkg.project?.name || 'Unknown'}</TableCell>
|
||||
<TableCell>
|
||||
<StatusChip status={pkg.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{pkg.publishDate ? format(new Date(pkg.publishDate), 'MMM dd, yyyy HH:mm') : '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box display="flex" gap={1}>
|
||||
{canEdit(pkg) && (
|
||||
<Tooltip title="Edit Package">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onEditPackage(pkg)}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canPublish(pkg) && (
|
||||
<Tooltip title="Publish Package">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => onPublishPackage(pkg)}
|
||||
>
|
||||
<PublishIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canEdit(pkg) && (
|
||||
<Tooltip title="Delete Package">
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDelete(pkg.id)}
|
||||
disabled={deletePackageMutation.isPending}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{packages.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No packages found. Create your first package to get started.
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
onPageChange={(_, newPage) => setPage(newPage)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(e) => {
|
||||
setPageSize(parseInt(e.target.value, 10));
|
||||
setPage(0);
|
||||
}}
|
||||
rowsPerPageOptions={[5, 10, 25, 50]}
|
||||
/>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
362
src/Frontend/src/components/Publishing/PublishingDashboard.tsx
Normal file
362
src/Frontend/src/components/Publishing/PublishingDashboard.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
List,
|
||||
ListItem,
|
||||
Button,
|
||||
Chip,
|
||||
Alert,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Cancel as CancelIcon,
|
||||
Refresh as RefreshIcon,
|
||||
PlayArrow as PlayIcon,
|
||||
Stop as StopIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
useActivePublications,
|
||||
useCancelPublication,
|
||||
useRetryPublication
|
||||
} from '../../hooks/usePublications';
|
||||
import { useSignalR } from '../../hooks/useSignalR';
|
||||
import {
|
||||
Publication,
|
||||
PublishingStep,
|
||||
PublishingProgressUpdate,
|
||||
PublicationStatus,
|
||||
StepStatus
|
||||
} from '../../types';
|
||||
import { ProgressBar } from '../common/ProgressBar';
|
||||
import { StatusChip } from '../common/StatusChip';
|
||||
import { LoadingSpinner } from '../common/LoadingSpinner';
|
||||
import { ErrorDisplay } from '../common/ErrorDisplay';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export const PublishingDashboard: React.FC = () => {
|
||||
const [selectedPublication, setSelectedPublication] = useState<Publication | null>(null);
|
||||
const [progressUpdates, setProgressUpdates] = useState<Map<number, PublishingProgressUpdate[]>>(new Map());
|
||||
|
||||
const {
|
||||
data: activePublications,
|
||||
isLoading,
|
||||
error,
|
||||
refetch
|
||||
} = useActivePublications();
|
||||
|
||||
const {
|
||||
subscribeToPublishingProgress,
|
||||
subscribeToPublicationCompleted,
|
||||
subscribeToPublicationFailed,
|
||||
joinPublicationGroup,
|
||||
leavePublicationGroup,
|
||||
isConnected,
|
||||
} = useSignalR();
|
||||
|
||||
const cancelMutation = useCancelPublication();
|
||||
const retryMutation = useRetryPublication();
|
||||
|
||||
// Subscribe to real-time updates
|
||||
useEffect(() => {
|
||||
const unsubscribeProgress = subscribeToPublishingProgress((update) => {
|
||||
setProgressUpdates(prev => {
|
||||
const publicationUpdates = prev.get(update.publicationId) || [];
|
||||
const newUpdates = [...publicationUpdates, update];
|
||||
|
||||
// Keep only the last 50 updates per publication
|
||||
if (newUpdates.length > 50) {
|
||||
newUpdates.splice(0, newUpdates.length - 50);
|
||||
}
|
||||
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(update.publicationId, newUpdates);
|
||||
return newMap;
|
||||
});
|
||||
});
|
||||
|
||||
const unsubscribeCompleted = subscribeToPublicationCompleted((publicationId) => {
|
||||
console.log(`Publication ${publicationId} completed`);
|
||||
refetch();
|
||||
});
|
||||
|
||||
const unsubscribeFailed = subscribeToPublicationFailed(({ publicationId, error }) => {
|
||||
console.error(`Publication ${publicationId} failed:`, error);
|
||||
refetch();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeProgress();
|
||||
unsubscribeCompleted();
|
||||
unsubscribeFailed();
|
||||
};
|
||||
}, [subscribeToPublishingProgress, subscribeToPublicationCompleted, subscribeToPublicationFailed, refetch]);
|
||||
|
||||
// Join publication groups for real-time updates
|
||||
useEffect(() => {
|
||||
if (activePublications?.data) {
|
||||
activePublications.data.forEach(publication => {
|
||||
if (publication.status === PublicationStatus.InProgress) {
|
||||
joinPublicationGroup(publication.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (activePublications?.data) {
|
||||
activePublications.data.forEach(publication => {
|
||||
leavePublicationGroup(publication.id);
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [activePublications?.data, joinPublicationGroup, leavePublicationGroup]);
|
||||
|
||||
const handleCancel = async (publication: Publication) => {
|
||||
if (window.confirm(`Are you sure you want to cancel the publication of "${publication.package?.title}"?`)) {
|
||||
await cancelMutation.mutateAsync(publication.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = async (publication: Publication) => {
|
||||
await retryMutation.mutateAsync(publication.id);
|
||||
};
|
||||
|
||||
const getLatestProgressForPublication = (publicationId: number): PublishingProgressUpdate | null => {
|
||||
const updates = progressUpdates.get(publicationId);
|
||||
return updates && updates.length > 0 ? updates[updates.length - 1] : null;
|
||||
};
|
||||
|
||||
const calculateOverallProgress = (publication: Publication): number => {
|
||||
const latestUpdate = getLatestProgressForPublication(publication.id);
|
||||
if (latestUpdate) {
|
||||
return latestUpdate.progressPercent;
|
||||
}
|
||||
|
||||
// Fallback to calculating from steps if available
|
||||
if (publication.steps && publication.steps.length > 0) {
|
||||
const completedSteps = publication.steps.filter(step => step.status === StepStatus.Completed).length;
|
||||
return (completedSteps / publication.steps.length) * 100;
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
if (isLoading) return <LoadingSpinner message="Loading active publications..." />;
|
||||
if (error) return <ErrorDisplay message="Failed to load publications" onRetry={refetch} />;
|
||||
|
||||
const publications = activePublications?.data || [];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Header */}
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
|
||||
<Typography variant="h4" component="h1">
|
||||
Publishing Dashboard
|
||||
</Typography>
|
||||
<Box display="flex" gap={1}>
|
||||
<Tooltip title={isConnected ? 'Connected' : 'Disconnected'}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderRadius: 1,
|
||||
bgcolor: isConnected ? 'success.light' : 'error.light',
|
||||
color: isConnected ? 'success.contrastText' : 'error.contrastText',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: isConnected ? 'success.main' : 'error.main',
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2">
|
||||
{isConnected ? 'Live Updates' : 'Disconnected'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<IconButton onClick={() => refetch()} title="Refresh">
|
||||
<RefreshIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{publications.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center', py: 6 }}>
|
||||
<Typography variant="h6" color="text.secondary" gutterBottom>
|
||||
No Active Publications
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
All publications are completed. Start a new publication from the Packages page.
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
{publications.map((publication) => {
|
||||
const latestProgress = getLatestProgressForPublication(publication.id);
|
||||
const overallProgress = calculateOverallProgress(publication);
|
||||
|
||||
return (
|
||||
<Grid item xs={12} key={publication.id}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="flex-start" mb={2}>
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{publication.package?.title || 'Unknown Package'}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
Version: {publication.package?.version} |
|
||||
Project: {publication.package?.project?.name}
|
||||
</Typography>
|
||||
<Box display="flex" gap={1} alignItems="center">
|
||||
<StatusChip status={publication.status} />
|
||||
{publication.publishedAt && (
|
||||
<Chip
|
||||
label={`Started ${format(new Date(publication.publishedAt), 'HH:mm:ss')}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box display="flex" gap={1}>
|
||||
{publication.status === PublicationStatus.InProgress && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
size="small"
|
||||
startIcon={<CancelIcon />}
|
||||
onClick={() => handleCancel(publication)}
|
||||
disabled={cancelMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{publication.status === PublicationStatus.Failed && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
size="small"
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={() => handleRetry(publication)}
|
||||
disabled={retryMutation.isPending}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => setSelectedPublication(publication)}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box mb={2}>
|
||||
<ProgressBar
|
||||
progress={overallProgress}
|
||||
status={latestProgress ? latestProgress.status : undefined}
|
||||
stepName={latestProgress ? latestProgress.stepName : 'Initializing...'}
|
||||
details={latestProgress ? latestProgress.details : undefined}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{publication.status === PublicationStatus.Failed && (
|
||||
<Alert severity="error" sx={{ mt: 1 }}>
|
||||
Publication failed. Check the details for more information.
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{/* Publication Details Dialog */}
|
||||
<Dialog
|
||||
open={!!selectedPublication}
|
||||
onClose={() => setSelectedPublication(null)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
{selectedPublication && (
|
||||
<>
|
||||
<DialogTitle>
|
||||
Publication Details - {selectedPublication.package?.title}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box mb={3}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Status
|
||||
</Typography>
|
||||
<StatusChip status={selectedPublication.status} />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Started
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
{selectedPublication.publishedAt ?
|
||||
format(new Date(selectedPublication.publishedAt), 'MMM dd, yyyy HH:mm:ss') :
|
||||
'Not started'
|
||||
}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Progress Updates
|
||||
</Typography>
|
||||
<List sx={{ maxHeight: 300, overflow: 'auto' }}>
|
||||
{progressUpdates.get(selectedPublication.id)?.map((update, index) => (
|
||||
<ListItem key={index} sx={{ px: 0 }}>
|
||||
<ProgressBar
|
||||
progress={update.progressPercent}
|
||||
status={update.status}
|
||||
stepName={update.stepName}
|
||||
details={update.details}
|
||||
/>
|
||||
</ListItem>
|
||||
)) || (
|
||||
<ListItem sx={{ px: 0 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No progress updates available
|
||||
</Typography>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setSelectedPublication(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
49
src/Frontend/src/components/common/ErrorDisplay.tsx
Normal file
49
src/Frontend/src/components/common/ErrorDisplay.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
Box,
|
||||
Button,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { Refresh as RefreshIcon } from '@mui/icons-material';
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
message?: string;
|
||||
error?: Error | null;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({
|
||||
message = 'An error occurred',
|
||||
error,
|
||||
onRetry
|
||||
}) => {
|
||||
return (
|
||||
<Box py={4}>
|
||||
<Alert severity="error">
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<Typography variant="body2" gutterBottom>
|
||||
{message}
|
||||
</Typography>
|
||||
{error && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{error.message}
|
||||
</Typography>
|
||||
)}
|
||||
{onRetry && (
|
||||
<Box mt={2}>
|
||||
<Button
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={onRetry}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
Try Again
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
29
src/Frontend/src/components/common/LoadingSpinner.tsx
Normal file
29
src/Frontend/src/components/common/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
message?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
message = 'Loading...',
|
||||
size = 40
|
||||
}) => {
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
py={4}
|
||||
>
|
||||
<CircularProgress size={size} />
|
||||
{message && (
|
||||
<Typography variant="body2" color="text.secondary" mt={2}>
|
||||
{message}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
82
src/Frontend/src/components/common/ProgressBar.tsx
Normal file
82
src/Frontend/src/components/common/ProgressBar.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
Paper,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import { StepStatus } from '../../types';
|
||||
|
||||
interface ProgressBarProps {
|
||||
progress: number;
|
||||
status?: StepStatus;
|
||||
stepName?: string;
|
||||
details?: string;
|
||||
showPercentage?: boolean;
|
||||
}
|
||||
|
||||
export const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||
progress,
|
||||
status,
|
||||
stepName,
|
||||
details,
|
||||
showPercentage = true,
|
||||
}) => {
|
||||
const getProgressColor = (status?: StepStatus): 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' => {
|
||||
switch (status) {
|
||||
case StepStatus.Completed:
|
||||
return 'success';
|
||||
case StepStatus.Failed:
|
||||
return 'error';
|
||||
case StepStatus.InProgress:
|
||||
return 'primary';
|
||||
default:
|
||||
return 'primary';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper elevation={1} sx={{ p: 2, mb: 1 }}>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" mb={1}>
|
||||
<Box display="flex" alignItems="center" gap={1}>
|
||||
{stepName && (
|
||||
<Typography variant="subtitle2" color="text.primary">
|
||||
{stepName}
|
||||
</Typography>
|
||||
)}
|
||||
{status && (
|
||||
<Chip
|
||||
label={status}
|
||||
size="small"
|
||||
color={getProgressColor(status)}
|
||||
variant="outlined"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
{showPercentage && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{Math.round(progress)}%
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={Math.min(progress, 100)}
|
||||
color={getProgressColor(status)}
|
||||
sx={{
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
mb: details ? 1 : 0
|
||||
}}
|
||||
/>
|
||||
|
||||
{details && (
|
||||
<Typography variant="body2" color="text.secondary" mt={1}>
|
||||
{details}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
85
src/Frontend/src/components/common/StatusChip.tsx
Normal file
85
src/Frontend/src/components/common/StatusChip.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from 'react';
|
||||
import { Chip, ChipProps } from '@mui/material';
|
||||
import {
|
||||
BuildStatus,
|
||||
PackageStatus,
|
||||
PublicationStatus,
|
||||
StepStatus
|
||||
} from '../../types';
|
||||
|
||||
interface StatusChipProps extends Omit<ChipProps, 'color'> {
|
||||
status: BuildStatus | PackageStatus | PublicationStatus | StepStatus;
|
||||
}
|
||||
|
||||
export const StatusChip: React.FC<StatusChipProps> = ({ status, ...props }) => {
|
||||
const getStatusColor = (status: string): ChipProps['color'] => {
|
||||
switch (status) {
|
||||
case BuildStatus.Success:
|
||||
case PackageStatus.Published:
|
||||
case PublicationStatus.Completed:
|
||||
case StepStatus.Completed:
|
||||
return 'success';
|
||||
|
||||
case BuildStatus.Failure:
|
||||
case PackageStatus.Failed:
|
||||
case PublicationStatus.Failed:
|
||||
case StepStatus.Failed:
|
||||
return 'error';
|
||||
|
||||
case BuildStatus.InProgress:
|
||||
case PackageStatus.Publishing:
|
||||
case PublicationStatus.InProgress:
|
||||
case StepStatus.InProgress:
|
||||
return 'info';
|
||||
|
||||
case PackageStatus.Ready:
|
||||
case PublicationStatus.Queued:
|
||||
case StepStatus.Pending:
|
||||
return 'warning';
|
||||
|
||||
case PackageStatus.Draft:
|
||||
case PublicationStatus.Cancelled:
|
||||
case StepStatus.Skipped:
|
||||
return 'default';
|
||||
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case BuildStatus.InProgress:
|
||||
case PackageStatus.Publishing:
|
||||
case PublicationStatus.InProgress:
|
||||
case StepStatus.InProgress:
|
||||
return '🔄';
|
||||
|
||||
case BuildStatus.Success:
|
||||
case PackageStatus.Published:
|
||||
case PublicationStatus.Completed:
|
||||
case StepStatus.Completed:
|
||||
return '✅';
|
||||
|
||||
case BuildStatus.Failure:
|
||||
case PackageStatus.Failed:
|
||||
case PublicationStatus.Failed:
|
||||
case StepStatus.Failed:
|
||||
return '❌';
|
||||
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const icon = getStatusIcon(status);
|
||||
|
||||
return (
|
||||
<Chip
|
||||
label={`${icon ? icon + ' ' : ''}${status}`}
|
||||
color={getStatusColor(status)}
|
||||
size="small"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
26
src/Frontend/src/contexts/QueryProvider.tsx
Normal file
26
src/Frontend/src/contexts/QueryProvider.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 3,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
interface QueryProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const QueryProvider: React.FC<QueryProviderProps> = ({ children }) => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
76
src/Frontend/src/hooks/usePackages.ts
Normal file
76
src/Frontend/src/hooks/usePackages.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { packageService } from '../services/packageService';
|
||||
import {
|
||||
Package,
|
||||
CreatePackageRequest,
|
||||
PackageFilter,
|
||||
PaginatedResponse
|
||||
} from '../types';
|
||||
|
||||
export const usePackages = (
|
||||
page: number = 1,
|
||||
pageSize: number = 10,
|
||||
filter?: PackageFilter
|
||||
) => {
|
||||
return useQuery<PaginatedResponse<Package>>({
|
||||
queryKey: ['packages', page, pageSize, filter],
|
||||
queryFn: () => packageService.getPackages(page, pageSize, filter),
|
||||
keepPreviousData: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const usePackage = (id: number) => {
|
||||
return useQuery({
|
||||
queryKey: ['package', id],
|
||||
queryFn: () => packageService.getPackage(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreatePackage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (request: CreatePackageRequest) => packageService.createPackage(request),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['packages'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdatePackage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, request }: { id: number; request: Partial<CreatePackageRequest> }) =>
|
||||
packageService.updatePackage(id, request),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['package', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['packages'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeletePackage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => packageService.deletePackage(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['packages'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePublishPackage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ packageId, buildId }: { packageId: number; buildId: number }) =>
|
||||
packageService.publishPackage(packageId, buildId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['packages'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['publications'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
56
src/Frontend/src/hooks/useProjects.ts
Normal file
56
src/Frontend/src/hooks/useProjects.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { projectService } from '../services/projectService';
|
||||
import { BuildFilter } from '../types';
|
||||
|
||||
export const useProjects = () => {
|
||||
return useQuery({
|
||||
queryKey: ['projects'],
|
||||
queryFn: () => projectService.getProjects(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useProject = (id: number) => {
|
||||
return useQuery({
|
||||
queryKey: ['project', id],
|
||||
queryFn: () => projectService.getProject(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useProjectBuilds = (
|
||||
projectId: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 10,
|
||||
filter?: BuildFilter
|
||||
) => {
|
||||
return useQuery({
|
||||
queryKey: ['project-builds', projectId, page, pageSize, filter],
|
||||
queryFn: () => projectService.getProjectBuilds(projectId, page, pageSize, filter),
|
||||
enabled: !!projectId,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const useBuild = (buildId: number) => {
|
||||
return useQuery({
|
||||
queryKey: ['build', buildId],
|
||||
queryFn: () => projectService.getBuild(buildId),
|
||||
enabled: !!buildId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useBuildCommits = (buildId: number) => {
|
||||
return useQuery({
|
||||
queryKey: ['build-commits', buildId],
|
||||
queryFn: () => projectService.getBuildCommits(buildId),
|
||||
enabled: !!buildId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useProjectStatus = () => {
|
||||
return useQuery({
|
||||
queryKey: ['project-status'],
|
||||
queryFn: () => projectService.getProjectStatus(),
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
});
|
||||
};
|
||||
58
src/Frontend/src/hooks/usePublications.ts
Normal file
58
src/Frontend/src/hooks/usePublications.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { publicationService } from '../services/publicationService';
|
||||
|
||||
export const usePublications = (page: number = 1, pageSize: number = 10) => {
|
||||
return useQuery({
|
||||
queryKey: ['publications', page, pageSize],
|
||||
queryFn: () => publicationService.getPublications(page, pageSize),
|
||||
keepPreviousData: true,
|
||||
});
|
||||
};
|
||||
|
||||
export const usePublication = (id: number) => {
|
||||
return useQuery({
|
||||
queryKey: ['publication', id],
|
||||
queryFn: () => publicationService.getPublication(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const usePublicationSteps = (publicationId: number) => {
|
||||
return useQuery({
|
||||
queryKey: ['publication-steps', publicationId],
|
||||
queryFn: () => publicationService.getPublicationSteps(publicationId),
|
||||
enabled: !!publicationId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useActivePublications = () => {
|
||||
return useQuery({
|
||||
queryKey: ['active-publications'],
|
||||
queryFn: () => publicationService.getActivePublications(),
|
||||
refetchInterval: 5000, // Refresh every 5 seconds for active publications
|
||||
});
|
||||
};
|
||||
|
||||
export const useCancelPublication = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => publicationService.cancelPublication(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['publications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['active-publications'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRetryPublication = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => publicationService.retryPublication(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['publications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['active-publications'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
88
src/Frontend/src/hooks/useSignalR.ts
Normal file
88
src/Frontend/src/hooks/useSignalR.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { signalRService } from '../services/signalRService';
|
||||
import { PublishingProgressUpdate, BuildStatusUpdate } from '../types';
|
||||
|
||||
export const useSignalR = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
const startConnection = async () => {
|
||||
try {
|
||||
await signalRService.start();
|
||||
} catch (error) {
|
||||
console.error('Failed to start SignalR connection:', error);
|
||||
}
|
||||
};
|
||||
|
||||
startConnection();
|
||||
|
||||
return () => {
|
||||
signalRService.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const subscribeToPublishingProgress = useCallback(
|
||||
(callback: (update: PublishingProgressUpdate) => void) => {
|
||||
return signalRService.subscribe<PublishingProgressUpdate>('PublishingProgress', callback);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const subscribeToBuildStatusUpdates = useCallback(
|
||||
(callback: (update: BuildStatusUpdate) => void) => {
|
||||
return signalRService.subscribe<BuildStatusUpdate>('BuildStatusUpdate', (update) => {
|
||||
// Invalidate related queries when build status changes
|
||||
queryClient.invalidateQueries({ queryKey: ['project-status'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['project-builds'] });
|
||||
callback(update);
|
||||
});
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const subscribeToPublicationCompleted = useCallback(
|
||||
(callback: (publicationId: number) => void) => {
|
||||
return signalRService.subscribe<number>('PublicationCompleted', (publicationId) => {
|
||||
// Invalidate publication-related queries
|
||||
queryClient.invalidateQueries({ queryKey: ['publications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['active-publications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['publication', publicationId] });
|
||||
callback(publicationId);
|
||||
});
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const subscribeToPublicationFailed = useCallback(
|
||||
(callback: (data: { publicationId: number; error: string }) => void) => {
|
||||
return signalRService.subscribe<{ publicationId: number; error: string }>('PublicationFailed', (data) => {
|
||||
// Invalidate publication-related queries
|
||||
queryClient.invalidateQueries({ queryKey: ['publications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['active-publications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['publication', data.publicationId] });
|
||||
callback(data);
|
||||
});
|
||||
},
|
||||
[queryClient]
|
||||
);
|
||||
|
||||
const joinPublicationGroup = useCallback(async (publicationId: number) => {
|
||||
await signalRService.joinPublicationGroup(publicationId);
|
||||
}, []);
|
||||
|
||||
const leavePublicationGroup = useCallback(async (publicationId: number) => {
|
||||
await signalRService.leavePublicationGroup(publicationId);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isConnected: signalRService.isConnected,
|
||||
connectionState: signalRService.connectionState,
|
||||
subscribeToPublishingProgress,
|
||||
subscribeToBuildStatusUpdates,
|
||||
subscribeToPublicationCompleted,
|
||||
subscribeToPublicationFailed,
|
||||
joinPublicationGroup,
|
||||
leavePublicationGroup,
|
||||
};
|
||||
};
|
||||
9
src/Frontend/src/main.tsx
Normal file
9
src/Frontend/src/main.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
262
src/Frontend/src/pages/DashboardPage.tsx
Normal file
262
src/Frontend/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Grid,
|
||||
Card,
|
||||
CardContent,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
ListItemIcon,
|
||||
Chip,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Build as BuildIcon,
|
||||
Package as PackageIcon,
|
||||
Publish as PublishIcon,
|
||||
CheckCircle as SuccessIcon,
|
||||
Error as ErrorIcon,
|
||||
Schedule as InProgressIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { usePackages } from '../hooks/usePackages';
|
||||
import { useProjectStatus } from '../hooks/useProjects';
|
||||
import { useActivePublications } from '../hooks/usePublications';
|
||||
import { StatusChip } from '../components/common/StatusChip';
|
||||
import { LoadingSpinner } from '../components/common/LoadingSpinner';
|
||||
import { ErrorDisplay } from '../components/common/ErrorDisplay';
|
||||
import { PublicationStatus, PackageStatus, BuildStatus } from '../types';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
export const DashboardPage: React.FC = () => {
|
||||
const { data: packagesData, isLoading: packagesLoading } = usePackages(1, 5);
|
||||
const { data: projectsData, isLoading: projectsLoading } = useProjectStatus();
|
||||
const { data: activePublications, isLoading: publicationsLoading } = useActivePublications();
|
||||
|
||||
const isLoading = packagesLoading || projectsLoading || publicationsLoading;
|
||||
|
||||
if (isLoading) return <LoadingSpinner message="Loading dashboard..." />;
|
||||
|
||||
const packages = packagesData?.data || [];
|
||||
const projects = projectsData?.data || [];
|
||||
const publications = activePublications?.data || [];
|
||||
|
||||
// Calculate statistics
|
||||
const packageStats = {
|
||||
total: packagesData?.totalCount || 0,
|
||||
published: packages.filter(p => p.status === PackageStatus.Published).length,
|
||||
failed: packages.filter(p => p.status === PackageStatus.Failed).length,
|
||||
publishing: packages.filter(p => p.status === PackageStatus.Publishing).length,
|
||||
};
|
||||
|
||||
const buildStats = {
|
||||
success: projects.filter(p => p.status === BuildStatus.Success).length,
|
||||
failure: projects.filter(p => p.status === BuildStatus.Failure).length,
|
||||
inProgress: projects.filter(p => p.status === BuildStatus.InProgress).length,
|
||||
};
|
||||
|
||||
const publicationStats = {
|
||||
active: publications.filter(p => p.status === PublicationStatus.InProgress).length,
|
||||
queued: publications.filter(p => p.status === PublicationStatus.Queued).length,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
Dashboard
|
||||
</Typography>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<Grid container spacing={3} mb={4}>
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography color="text.secondary" gutterBottom variant="h6">
|
||||
Packages
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{packageStats.total}
|
||||
</Typography>
|
||||
<Box display="flex" gap={1} mt={1}>
|
||||
<Chip label={`${packageStats.published} Published`} size="small" color="success" />
|
||||
<Chip label={`${packageStats.publishing} Publishing`} size="small" color="info" />
|
||||
</Box>
|
||||
</Box>
|
||||
<PackageIcon sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography color="text.secondary" gutterBottom variant="h6">
|
||||
Projects
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{projects.length}
|
||||
</Typography>
|
||||
<Box display="flex" gap={1} mt={1}>
|
||||
<Chip label={`${buildStats.success} Success`} size="small" color="success" />
|
||||
<Chip label={`${buildStats.failure} Failed`} size="small" color="error" />
|
||||
</Box>
|
||||
</Box>
|
||||
<BuildIcon sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography color="text.secondary" gutterBottom variant="h6">
|
||||
Active Publications
|
||||
</Typography>
|
||||
<Typography variant="h4">
|
||||
{publicationStats.active}
|
||||
</Typography>
|
||||
<Box display="flex" gap={1} mt={1}>
|
||||
<Chip label={`${publicationStats.queued} Queued`} size="small" color="warning" />
|
||||
</Box>
|
||||
</Box>
|
||||
<PublishIcon sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} sm={6} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||
<Box>
|
||||
<Typography color="text.secondary" gutterBottom variant="h6">
|
||||
System Status
|
||||
</Typography>
|
||||
<Typography variant="h6" color="success.main">
|
||||
All Systems Operational
|
||||
</Typography>
|
||||
</Box>
|
||||
<SuccessIcon sx={{ fontSize: 40, color: 'success.main' }} />
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Recent Packages
|
||||
</Typography>
|
||||
<List>
|
||||
{packages.length === 0 ? (
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="No packages found"
|
||||
secondary="Create your first package to get started"
|
||||
/>
|
||||
</ListItem>
|
||||
) : (
|
||||
packages.map((pkg) => (
|
||||
<ListItem key={pkg.id}>
|
||||
<ListItemIcon>
|
||||
<PackageIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={pkg.title}
|
||||
secondary={`Version ${pkg.version} • ${pkg.project?.name}`}
|
||||
/>
|
||||
<StatusChip status={pkg.status} />
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Project Build Status
|
||||
</Typography>
|
||||
<List>
|
||||
{projects.length === 0 ? (
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="No projects found"
|
||||
secondary="Projects will appear here once configured"
|
||||
/>
|
||||
</ListItem>
|
||||
) : (
|
||||
projects.slice(0, 5).map((project) => (
|
||||
<ListItem key={project.id}>
|
||||
<ListItemIcon>
|
||||
{project.status === BuildStatus.Success ? (
|
||||
<SuccessIcon color="success" />
|
||||
) : project.status === BuildStatus.Failure ? (
|
||||
<ErrorIcon color="error" />
|
||||
) : (
|
||||
<InProgressIcon color="info" />
|
||||
)}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={project.name}
|
||||
secondary={project.description}
|
||||
/>
|
||||
<StatusChip status={project.status} />
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{publications.length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Active Publications
|
||||
</Typography>
|
||||
<List>
|
||||
{publications.map((publication) => (
|
||||
<ListItem key={publication.id}>
|
||||
<ListItemIcon>
|
||||
<PublishIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={publication.package?.title}
|
||||
secondary={`Version ${publication.package?.version} • Started ${
|
||||
publication.publishedAt ?
|
||||
format(new Date(publication.publishedAt), 'MMM dd, HH:mm') :
|
||||
'Unknown'
|
||||
}`}
|
||||
/>
|
||||
<StatusChip status={publication.status} />
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
146
src/Frontend/src/pages/PackagesPage.tsx
Normal file
146
src/Frontend/src/pages/PackagesPage.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Typography,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
} from '@mui/material';
|
||||
import { PackageList } from '../components/Packages/PackageList';
|
||||
import { PackageForm } from '../components/Packages/PackageForm';
|
||||
import { usePublishPackage } from '../hooks/usePackages';
|
||||
import { useProjectBuilds } from '../hooks/useProjects';
|
||||
import { Package, Build } from '../types';
|
||||
|
||||
export const PackagesPage: React.FC = () => {
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [editingPackage, setEditingPackage] = useState<Package | null>(null);
|
||||
const [publishingPackage, setPublishingPackage] = useState<Package | null>(null);
|
||||
const [selectedBuildId, setSelectedBuildId] = useState<number>(0);
|
||||
|
||||
const publishMutation = usePublishPackage();
|
||||
const { data: buildsData } = useProjectBuilds(
|
||||
publishingPackage?.projectId || 0,
|
||||
1,
|
||||
50,
|
||||
{ status: 'Success' }
|
||||
);
|
||||
|
||||
const handleCreatePackage = () => {
|
||||
setShowCreateForm(true);
|
||||
};
|
||||
|
||||
const handleEditPackage = (packageItem: Package) => {
|
||||
setEditingPackage(packageItem);
|
||||
};
|
||||
|
||||
const handlePublishPackage = (packageItem: Package) => {
|
||||
setPublishingPackage(packageItem);
|
||||
setSelectedBuildId(packageItem.sourceBuildId);
|
||||
};
|
||||
|
||||
const handleCloseCreateForm = () => {
|
||||
setShowCreateForm(false);
|
||||
};
|
||||
|
||||
const handleCloseEditForm = () => {
|
||||
setEditingPackage(null);
|
||||
};
|
||||
|
||||
const handleConfirmPublish = async () => {
|
||||
if (publishingPackage && selectedBuildId) {
|
||||
try {
|
||||
await publishMutation.mutateAsync({
|
||||
packageId: publishingPackage.id,
|
||||
buildId: selectedBuildId,
|
||||
});
|
||||
setPublishingPackage(null);
|
||||
setSelectedBuildId(0);
|
||||
} catch (error) {
|
||||
console.error('Failed to publish package:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelPublish = () => {
|
||||
setPublishingPackage(null);
|
||||
setSelectedBuildId(0);
|
||||
};
|
||||
|
||||
const builds = buildsData?.data || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PackageList
|
||||
onCreatePackage={handleCreatePackage}
|
||||
onEditPackage={handleEditPackage}
|
||||
onPublishPackage={handlePublishPackage}
|
||||
/>
|
||||
|
||||
{/* Create Package Form */}
|
||||
<PackageForm
|
||||
open={showCreateForm}
|
||||
onClose={handleCloseCreateForm}
|
||||
/>
|
||||
|
||||
{/* Edit Package Form */}
|
||||
<PackageForm
|
||||
open={!!editingPackage}
|
||||
onClose={handleCloseEditForm}
|
||||
package={editingPackage || undefined}
|
||||
/>
|
||||
|
||||
{/* Publish Package Dialog */}
|
||||
<Dialog
|
||||
open={!!publishingPackage}
|
||||
onClose={handleCancelPublish}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
Publish Package
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body1" gutterBottom>
|
||||
Are you sure you want to publish "<strong>{publishingPackage?.title}</strong>"?
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" gutterBottom>
|
||||
This will start the publishing workflow and deploy the package to configured storage and help center locations.
|
||||
</Typography>
|
||||
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>Source Build</InputLabel>
|
||||
<Select
|
||||
value={selectedBuildId}
|
||||
onChange={(e) => setSelectedBuildId(Number(e.target.value))}
|
||||
label="Source Build"
|
||||
>
|
||||
{builds.map((build: Build) => (
|
||||
<MenuItem key={build.id} value={build.id}>
|
||||
{build.buildNumber} ({new Date(build.endTime).toLocaleDateString()})
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCancelPublish}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmPublish}
|
||||
variant="contained"
|
||||
disabled={!selectedBuildId || publishMutation.isPending}
|
||||
>
|
||||
{publishMutation.isPending ? 'Publishing...' : 'Publish'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
6
src/Frontend/src/pages/PublishingPage.tsx
Normal file
6
src/Frontend/src/pages/PublishingPage.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import { PublishingDashboard } from '../components/Publishing/PublishingDashboard';
|
||||
|
||||
export const PublishingPage: React.FC = () => {
|
||||
return <PublishingDashboard />;
|
||||
};
|
||||
74
src/Frontend/src/services/api.ts
Normal file
74
src/Frontend/src/services/api.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||
import { ApiResponse, PaginatedResponse } from '../types';
|
||||
|
||||
class ApiClient {
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
}
|
||||
|
||||
private setupInterceptors() {
|
||||
// Request interceptor to add auth token
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
this.client.interceptors.response.use(
|
||||
(response: AxiosResponse) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('authToken');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async get<T>(url: string, params?: Record<string, any>): Promise<ApiResponse<T>> {
|
||||
const response = await this.client.get(url, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async post<T>(url: string, data?: any): Promise<ApiResponse<T>> {
|
||||
const response = await this.client.post(url, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async put<T>(url: string, data?: any): Promise<ApiResponse<T>> {
|
||||
const response = await this.client.put(url, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async delete<T>(url: string): Promise<ApiResponse<T>> {
|
||||
const response = await this.client.delete(url);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async getPaginated<T>(
|
||||
url: string,
|
||||
params?: Record<string, any>
|
||||
): Promise<PaginatedResponse<T>> {
|
||||
const response = await this.client.get(url, { params });
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
42
src/Frontend/src/services/packageService.ts
Normal file
42
src/Frontend/src/services/packageService.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { apiClient } from './api';
|
||||
import {
|
||||
Package,
|
||||
CreatePackageRequest,
|
||||
PackageFilter,
|
||||
PaginatedResponse,
|
||||
ApiResponse
|
||||
} from '../types';
|
||||
|
||||
export const packageService = {
|
||||
async getPackages(
|
||||
page: number = 1,
|
||||
pageSize: number = 10,
|
||||
filter?: PackageFilter
|
||||
): Promise<PaginatedResponse<Package>> {
|
||||
return apiClient.getPaginated('/packages', {
|
||||
page,
|
||||
pageSize,
|
||||
...filter,
|
||||
});
|
||||
},
|
||||
|
||||
async getPackage(id: number): Promise<ApiResponse<Package>> {
|
||||
return apiClient.get(`/packages/${id}`);
|
||||
},
|
||||
|
||||
async createPackage(request: CreatePackageRequest): Promise<ApiResponse<Package>> {
|
||||
return apiClient.post('/packages', request);
|
||||
},
|
||||
|
||||
async updatePackage(id: number, request: Partial<CreatePackageRequest>): Promise<ApiResponse<Package>> {
|
||||
return apiClient.put(`/packages/${id}`, request);
|
||||
},
|
||||
|
||||
async deletePackage(id: number): Promise<ApiResponse<void>> {
|
||||
return apiClient.delete(`/packages/${id}`);
|
||||
},
|
||||
|
||||
async publishPackage(packageId: number, buildId: number): Promise<ApiResponse<void>> {
|
||||
return apiClient.post(`/packages/${packageId}/publish`, { buildId });
|
||||
},
|
||||
};
|
||||
44
src/Frontend/src/services/projectService.ts
Normal file
44
src/Frontend/src/services/projectService.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { apiClient } from './api';
|
||||
import {
|
||||
Project,
|
||||
Build,
|
||||
BuildCommit,
|
||||
BuildFilter,
|
||||
PaginatedResponse,
|
||||
ApiResponse
|
||||
} from '../types';
|
||||
|
||||
export const projectService = {
|
||||
async getProjects(): Promise<ApiResponse<Project[]>> {
|
||||
return apiClient.get('/projects');
|
||||
},
|
||||
|
||||
async getProject(id: number): Promise<ApiResponse<Project>> {
|
||||
return apiClient.get(`/projects/${id}`);
|
||||
},
|
||||
|
||||
async getProjectBuilds(
|
||||
projectId: number,
|
||||
page: number = 1,
|
||||
pageSize: number = 10,
|
||||
filter?: BuildFilter
|
||||
): Promise<PaginatedResponse<Build>> {
|
||||
return apiClient.getPaginated(`/projects/${projectId}/builds`, {
|
||||
page,
|
||||
pageSize,
|
||||
...filter,
|
||||
});
|
||||
},
|
||||
|
||||
async getBuild(buildId: number): Promise<ApiResponse<Build>> {
|
||||
return apiClient.get(`/builds/${buildId}`);
|
||||
},
|
||||
|
||||
async getBuildCommits(buildId: number): Promise<ApiResponse<BuildCommit[]>> {
|
||||
return apiClient.get(`/builds/${buildId}/commits`);
|
||||
},
|
||||
|
||||
async getProjectStatus(): Promise<ApiResponse<Project[]>> {
|
||||
return apiClient.get('/projects/status');
|
||||
},
|
||||
};
|
||||
36
src/Frontend/src/services/publicationService.ts
Normal file
36
src/Frontend/src/services/publicationService.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { apiClient } from './api';
|
||||
import {
|
||||
Publication,
|
||||
PublishingStep,
|
||||
PaginatedResponse,
|
||||
ApiResponse
|
||||
} from '../types';
|
||||
|
||||
export const publicationService = {
|
||||
async getPublications(
|
||||
page: number = 1,
|
||||
pageSize: number = 10
|
||||
): Promise<PaginatedResponse<Publication>> {
|
||||
return apiClient.getPaginated('/publications', { page, pageSize });
|
||||
},
|
||||
|
||||
async getPublication(id: number): Promise<ApiResponse<Publication>> {
|
||||
return apiClient.get(`/publications/${id}`);
|
||||
},
|
||||
|
||||
async getPublicationSteps(publicationId: number): Promise<ApiResponse<PublishingStep[]>> {
|
||||
return apiClient.get(`/publications/${publicationId}/steps`);
|
||||
},
|
||||
|
||||
async getActivePublications(): Promise<ApiResponse<Publication[]>> {
|
||||
return apiClient.get('/publications/active');
|
||||
},
|
||||
|
||||
async cancelPublication(id: number): Promise<ApiResponse<void>> {
|
||||
return apiClient.post(`/publications/${id}/cancel`);
|
||||
},
|
||||
|
||||
async retryPublication(id: number): Promise<ApiResponse<void>> {
|
||||
return apiClient.post(`/publications/${id}/retry`);
|
||||
},
|
||||
};
|
||||
108
src/Frontend/src/services/signalRService.ts
Normal file
108
src/Frontend/src/services/signalRService.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
import { PublishingProgressUpdate, BuildStatusUpdate } from '../types';
|
||||
|
||||
export class SignalRService {
|
||||
private connection: signalR.HubConnection | null = null;
|
||||
private listeners: Map<string, Set<(data: any) => void>> = new Map();
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.connection?.state === signalR.HubConnectionState.Connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl('/hubs/publishing', {
|
||||
accessTokenFactory: () => localStorage.getItem('authToken') || '',
|
||||
})
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
this.setupEventHandlers();
|
||||
|
||||
try {
|
||||
await this.connection.start();
|
||||
console.log('SignalR connected successfully');
|
||||
} catch (error) {
|
||||
console.error('SignalR connection failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.connection) {
|
||||
await this.connection.stop();
|
||||
this.connection = null;
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventHandlers(): void {
|
||||
if (!this.connection) return;
|
||||
|
||||
// Publishing progress updates
|
||||
this.connection.on('PublishingProgress', (update: PublishingProgressUpdate) => {
|
||||
this.notifyListeners('PublishingProgress', update);
|
||||
});
|
||||
|
||||
// Build status updates
|
||||
this.connection.on('BuildStatusUpdate', (update: BuildStatusUpdate) => {
|
||||
this.notifyListeners('BuildStatusUpdate', update);
|
||||
});
|
||||
|
||||
// Publication completed
|
||||
this.connection.on('PublicationCompleted', (publicationId: number) => {
|
||||
this.notifyListeners('PublicationCompleted', publicationId);
|
||||
});
|
||||
|
||||
// Publication failed
|
||||
this.connection.on('PublicationFailed', (data: { publicationId: number; error: string }) => {
|
||||
this.notifyListeners('PublicationFailed', data);
|
||||
});
|
||||
}
|
||||
|
||||
private notifyListeners(event: string, data: any): void {
|
||||
const eventListeners = this.listeners.get(event);
|
||||
if (eventListeners) {
|
||||
eventListeners.forEach(callback => callback(data));
|
||||
}
|
||||
}
|
||||
|
||||
subscribe<T>(event: string, callback: (data: T) => void): () => void {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, new Set());
|
||||
}
|
||||
|
||||
const eventListeners = this.listeners.get(event)!;
|
||||
eventListeners.add(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
eventListeners.delete(callback);
|
||||
if (eventListeners.size === 0) {
|
||||
this.listeners.delete(event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async joinPublicationGroup(publicationId: number): Promise<void> {
|
||||
if (this.connection?.state === signalR.HubConnectionState.Connected) {
|
||||
await this.connection.invoke('JoinPublicationGroup', publicationId.toString());
|
||||
}
|
||||
}
|
||||
|
||||
async leavePublicationGroup(publicationId: number): Promise<void> {
|
||||
if (this.connection?.state === signalR.HubConnectionState.Connected) {
|
||||
await this.connection.invoke('LeavePublicationGroup', publicationId.toString());
|
||||
}
|
||||
}
|
||||
|
||||
get connectionState(): signalR.HubConnectionState {
|
||||
return this.connection?.state || signalR.HubConnectionState.Disconnected;
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.connection?.state === signalR.HubConnectionState.Connected;
|
||||
}
|
||||
}
|
||||
|
||||
export const signalRService = new SignalRService();
|
||||
222
src/Frontend/src/types/index.ts
Normal file
222
src/Frontend/src/types/index.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
// Core domain types based on implementation.md architecture
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
lastLogin: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
ccNetProjectName: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Build {
|
||||
id: number;
|
||||
projectId: number;
|
||||
buildNumber: string;
|
||||
status: BuildStatus;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
logPath: string;
|
||||
artifactPath: string;
|
||||
}
|
||||
|
||||
export enum BuildStatus {
|
||||
Success = 'Success',
|
||||
Failure = 'Failure',
|
||||
InProgress = 'InProgress',
|
||||
Unknown = 'Unknown'
|
||||
}
|
||||
|
||||
export interface BuildCommit {
|
||||
id: number;
|
||||
buildId: number;
|
||||
commitHash: string;
|
||||
comment: string;
|
||||
user: string;
|
||||
commitDate: string;
|
||||
fogBugzCaseId?: string;
|
||||
modifiedFiles: string[];
|
||||
releaseNote?: string;
|
||||
}
|
||||
|
||||
export interface Package {
|
||||
id: number;
|
||||
title: string;
|
||||
version: string;
|
||||
description: string;
|
||||
projectId: number;
|
||||
sourceBuildId: number;
|
||||
status: PackageStatus;
|
||||
publishDate?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
project?: Project;
|
||||
sourceBuild?: Build;
|
||||
configuration?: PackageConfiguration;
|
||||
}
|
||||
|
||||
export enum PackageStatus {
|
||||
Draft = 'Draft',
|
||||
Ready = 'Ready',
|
||||
Publishing = 'Publishing',
|
||||
Published = 'Published',
|
||||
Failed = 'Failed'
|
||||
}
|
||||
|
||||
export interface PackageConfiguration {
|
||||
id: number;
|
||||
packageId: number;
|
||||
buildFolder: string;
|
||||
zipContents: boolean;
|
||||
deleteOldPublishedBuilds: boolean;
|
||||
releaseNoteTemplate: string;
|
||||
storageSettings: Record<string, any>;
|
||||
helpCenterSettings: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FogBugzCase {
|
||||
id: number;
|
||||
caseId: number;
|
||||
title: string;
|
||||
project: string;
|
||||
area: string;
|
||||
status: string;
|
||||
lastUpdated: string;
|
||||
isOpen: boolean;
|
||||
events?: FogBugzEvent[];
|
||||
}
|
||||
|
||||
export interface FogBugzEvent {
|
||||
id: number;
|
||||
caseId: number;
|
||||
eventType: string;
|
||||
user: string;
|
||||
comment: string;
|
||||
statusString: string;
|
||||
eventDate: string;
|
||||
releaseNote?: string;
|
||||
zendeskNumber?: number;
|
||||
}
|
||||
|
||||
export interface Publication {
|
||||
id: number;
|
||||
packageId: number;
|
||||
status: PublicationStatus;
|
||||
publishedAt?: string;
|
||||
releaseNotesPath?: string;
|
||||
publicationDetails: Record<string, any>;
|
||||
steps?: PublishingStep[];
|
||||
package?: Package;
|
||||
}
|
||||
|
||||
export enum PublicationStatus {
|
||||
Queued = 'Queued',
|
||||
InProgress = 'InProgress',
|
||||
Completed = 'Completed',
|
||||
Failed = 'Failed',
|
||||
Cancelled = 'Cancelled'
|
||||
}
|
||||
|
||||
export interface PublishingStep {
|
||||
id: number;
|
||||
publicationId: number;
|
||||
stepName: string;
|
||||
status: StepStatus;
|
||||
details?: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
progressPercent: number;
|
||||
}
|
||||
|
||||
export enum StepStatus {
|
||||
Pending = 'Pending',
|
||||
InProgress = 'InProgress',
|
||||
Completed = 'Completed',
|
||||
Failed = 'Failed',
|
||||
Skipped = 'Skipped'
|
||||
}
|
||||
|
||||
// API Response types
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
success: boolean;
|
||||
message?: string;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
totalCount: number;
|
||||
pageSize: number;
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// SignalR Hub types
|
||||
export interface PublishingProgressUpdate {
|
||||
publicationId: number;
|
||||
stepName: string;
|
||||
progressPercent: number;
|
||||
status: StepStatus;
|
||||
details?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface BuildStatusUpdate {
|
||||
projectId: number;
|
||||
buildId: number;
|
||||
status: BuildStatus;
|
||||
buildNumber: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// Form types
|
||||
export interface CreatePackageRequest {
|
||||
title: string;
|
||||
version: string;
|
||||
description: string;
|
||||
projectId: number;
|
||||
sourceBuildId: number;
|
||||
configuration: {
|
||||
buildFolder: string;
|
||||
zipContents: boolean;
|
||||
deleteOldPublishedBuilds: boolean;
|
||||
releaseNoteTemplate: string;
|
||||
storageSettings: Record<string, any>;
|
||||
helpCenterSettings: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PublishPackageRequest {
|
||||
packageId: number;
|
||||
buildId: number;
|
||||
}
|
||||
|
||||
// Filter and search types
|
||||
export interface PackageFilter {
|
||||
projectId?: number;
|
||||
status?: PackageStatus;
|
||||
searchTerm?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
export interface BuildFilter {
|
||||
projectId?: number;
|
||||
status?: BuildStatus;
|
||||
user?: string;
|
||||
searchTerm?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
36
src/Frontend/tsconfig.json
Normal file
36
src/Frontend/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path mapping */
|
||||
"baseUrl": "./src",
|
||||
"paths": {
|
||||
"@/*": ["*"],
|
||||
"@/components/*": ["components/*"],
|
||||
"@/hooks/*": ["hooks/*"],
|
||||
"@/services/*": ["services/*"],
|
||||
"@/types/*": ["types/*"],
|
||||
"@/utils/*": ["utils/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
src/Frontend/tsconfig.node.json
Normal file
10
src/Frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
26
src/Frontend/vite.config.ts
Normal file
26
src/Frontend/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/hubs': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user