autocomit

This commit is contained in:
2026-02-08 18:31:27 +01:00
parent 8fef802597
commit 6b588005c9
18 changed files with 1585 additions and 4 deletions
@@ -34,6 +34,9 @@
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"> <column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/> <constraints nullable="false"/>
</column> </column>
<column name="updated_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
</createTable> </createTable>
<createIndex tableName="deployment" indexName="idx_deployment_application"> <createIndex tableName="deployment" indexName="idx_deployment_application">
+4 -2
View File
@@ -11,7 +11,8 @@ services:
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data #- postgres_data:/var/lib/postgresql/data
- /srv/ldpv2_db:/var/lib/postgresql/data
networks: networks:
- ldpv2-network - ldpv2-network
healthcheck: healthcheck:
@@ -52,7 +53,8 @@ services:
restart: unless-stopped restart: unless-stopped
# Optional: Mount logs directory for easier debugging # Optional: Mount logs directory for easier debugging
volumes: volumes:
- app_logs:/var/log/supervisor #- app_logs:/var/log/supervisor
- /srv/ldpv2_logs:/var/log/supervisor
volumes: volumes:
postgres_data: postgres_data:
+30
View File
@@ -67,6 +67,16 @@ export const routes: Routes = [
path: ':id/edit', path: ':id/edit',
loadComponent: () => import('./features/applications/application-form/application-form.component') loadComponent: () => import('./features/applications/application-form/application-form.component')
.then(m => m.ApplicationFormComponent) .then(m => m.ApplicationFormComponent)
},
{
path: ':applicationId/versions/new',
loadComponent: () => import('./features/versions/version-form/version-form.component')
.then(m => m.VersionFormComponent)
},
{
path: ':applicationId/versions/:versionId/edit',
loadComponent: () => import('./features/versions/version-form/version-form.component')
.then(m => m.VersionFormComponent)
} }
] ]
}, },
@@ -95,6 +105,26 @@ export const routes: Routes = [
} }
] ]
}, },
{
path: 'deployments',
children: [
{
path: '',
loadComponent: () => import('./features/deployments/deployment-list/deployment-list.component')
.then(m => m.DeploymentListComponent)
},
{
path: 'new',
loadComponent: () => import('./features/deployments/deployment-form/deployment-form.component')
.then(m => m.DeploymentFormComponent)
},
{
path: 'current',
loadComponent: () => import('./features/deployments/deployment-dashboard/deployment-dashboard.component')
.then(m => m.DeploymentDashboardComponent)
}
]
},
{ {
path: 'persons', path: 'persons',
children: [ children: [
@@ -0,0 +1,60 @@
<div class="container">
<div class="header">
<h1>Current Deployment State</h1>
<button (click)="recordDeployment()" class="btn-primary">Record Deployment</button>
</div>
<div *ngIf="loading" class="loading">Loading deployment state...</div>
<div *ngIf="error" class="error">{{ error }}</div>
<div *ngIf="!loading && getApplicationIds().length > 0" class="matrix-container">
<div class="legend">
<span class="legend-item">
<span class="legend-color cell-recent"></span> Recent (&lt; 30 days)
</span>
<span class="legend-item">
<span class="legend-color cell-moderate"></span> Moderate (30-90 days)
</span>
<span class="legend-item">
<span class="legend-color cell-old"></span> Old (&gt; 90 days)
</span>
<span class="legend-item">
<span class="legend-color cell-empty"></span> Not deployed
</span>
</div>
<div class="matrix-scroll">
<table class="deployment-matrix">
<thead>
<tr>
<th class="app-header">Application</th>
<th *ngFor="let envId of environmentIds" class="env-header">
{{ environmentNames.get(envId) }}
</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let appId of getApplicationIds()">
<td class="app-name">{{ matrix[appId].applicationName }}</td>
<td *ngFor="let envId of environmentIds"
[class]="getCellClass(getDeploymentForCell(appId, envId))"
class="deployment-cell">
<div *ngIf="getDeploymentForCell(appId, envId) as deployment" class="cell-content">
<div class="version">{{ deployment.version.versionIdentifier }}</div>
<div class="date">{{ deployment.deploymentDate | date:'shortDate' }}</div>
<div class="days-ago">{{ getDaysAgo(deployment.deploymentDate) }}d ago</div>
</div>
<div *ngIf="!getDeploymentForCell(appId, envId)" class="cell-content empty">
<span>-</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div *ngIf="!loading && getApplicationIds().length === 0" class="empty">
No deployments recorded yet.
</div>
</div>
@@ -0,0 +1,144 @@
.container {
max-width: 1600px;
margin: 0 auto;
padding: 2rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
h1 { margin: 0; }
}
.btn-primary {
background-color: #3f51b5;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
&:hover { background-color: #303f9f; }
}
.loading, .error, .empty {
text-align: center;
padding: 2rem;
color: #666;
}
.error { color: #f44336; }
.legend {
display: flex;
gap: 1.5rem;
margin-bottom: 1rem;
padding: 1rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.legend-color {
width: 20px;
height: 20px;
border-radius: 4px;
border: 1px solid #ddd;
}
}
.matrix-scroll {
overflow-x: auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.deployment-matrix {
width: 100%;
border-collapse: collapse;
min-width: 800px;
th, td {
padding: 1rem;
border: 1px solid #ddd;
}
thead {
background-color: #f5f5f5;
position: sticky;
top: 0;
z-index: 10;
th {
font-weight: 600;
text-align: center;
}
}
.app-header {
text-align: left;
min-width: 200px;
}
.app-name {
font-weight: 600;
background-color: #fafafa;
position: sticky;
left: 0;
z-index: 5;
}
.deployment-cell {
text-align: center;
min-width: 150px;
&.cell-recent {
background-color: #e8f5e9;
}
&.cell-moderate {
background-color: #fff9c4;
}
&.cell-old {
background-color: #ffebee;
}
&.cell-empty {
background-color: #f5f5f5;
}
}
.cell-content {
.version {
font-weight: 600;
color: #333;
margin-bottom: 0.25rem;
}
.date {
font-size: 0.875rem;
color: #666;
}
.days-ago {
font-size: 0.75rem;
color: #999;
margin-top: 0.25rem;
}
&.empty span {
color: #999;
}
}
}
@@ -0,0 +1,106 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { DeploymentService } from '../deployment.service';
import { CurrentDeploymentState } from '../../../shared/models/deployment.model';
interface DeploymentMatrix {
[applicationId: string]: {
applicationName: string;
environments: {
[environmentId: string]: CurrentDeploymentState;
};
};
}
@Component({
selector: 'app-deployment-dashboard',
standalone: true,
imports: [CommonModule],
templateUrl: './deployment-dashboard.component.html',
styleUrls: ['./deployment-dashboard.component.scss']
})
export class DeploymentDashboardComponent implements OnInit {
deploymentStates: CurrentDeploymentState[] = [];
matrix: DeploymentMatrix = {};
environmentIds: string[] = [];
environmentNames: Map<string, string> = new Map();
loading = false;
error = '';
constructor(
private deploymentService: DeploymentService,
private router: Router
) {}
ngOnInit(): void {
this.loadCurrentState();
}
loadCurrentState(): void {
this.loading = true;
this.deploymentService.getCurrentState().subscribe({
next: (states) => {
this.deploymentStates = states;
this.buildMatrix();
this.loading = false;
},
error: (err) => {
this.error = 'Failed to load deployment state';
this.loading = false;
}
});
}
buildMatrix(): void {
this.matrix = {};
const envSet = new Set<string>();
this.deploymentStates.forEach(state => {
const appId = state.application.id;
const envId = state.environment.id;
if (!this.matrix[appId]) {
this.matrix[appId] = {
applicationName: state.application.name,
environments: {}
};
}
this.matrix[appId].environments[envId] = state;
envSet.add(envId);
this.environmentNames.set(envId, state.environment.name);
});
this.environmentIds = Array.from(envSet).sort();
}
getApplicationIds(): string[] {
return Object.keys(this.matrix);
}
getDeploymentForCell(appId: string, envId: string): CurrentDeploymentState | undefined {
return this.matrix[appId]?.environments[envId];
}
getCellClass(deployment?: CurrentDeploymentState): string {
if (!deployment) return 'cell-empty';
const daysAgo = this.getDaysAgo(deployment.deploymentDate);
if (daysAgo < 30) return 'cell-recent';
if (daysAgo < 90) return 'cell-moderate';
return 'cell-old';
}
getDaysAgo(date: Date): number {
const now = new Date();
const deployDate = new Date(date);
const diff = now.getTime() - deployDate.getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
recordDeployment(): void {
this.router.navigate(['/deployments/new']);
}
}
@@ -0,0 +1,100 @@
<div class="container">
<h1>Record New Deployment</h1>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="applicationId">Application *</label>
<select
id="applicationId"
formControlName="applicationId"
[class.error]="form.get('applicationId')?.invalid && form.get('applicationId')?.touched">
<option value="">Select an application</option>
<option *ngFor="let app of applications" [value]="app.id">
{{ app.name }}
</option>
</select>
<div class="error-message" *ngIf="form.get('applicationId')?.invalid && form.get('applicationId')?.touched">
Application is required
</div>
</div>
<div class="form-group">
<label for="versionId">Version *</label>
<select
id="versionId"
formControlName="versionId"
[disabled]="!form.value.applicationId"
[class.error]="form.get('versionId')?.invalid && form.get('versionId')?.touched">
<option value="">{{ form.value.applicationId ? 'Select a version' : 'Select application first' }}</option>
<option *ngFor="let version of versions" [value]="version.id">
{{ version.versionIdentifier }} (Released: {{ version.releaseDate | date:'shortDate' }})
</option>
</select>
<div class="error-message" *ngIf="form.get('versionId')?.invalid && form.get('versionId')?.touched">
Version is required
</div>
</div>
<div class="form-group">
<label for="environmentId">Environment *</label>
<select
id="environmentId"
formControlName="environmentId"
[class.error]="form.get('environmentId')?.invalid && form.get('environmentId')?.touched">
<option value="">Select an environment</option>
<option *ngFor="let env of environments" [value]="env.id">
{{ env.name }} {{ env.isProduction ? '(PRODUCTION)' : '' }}
</option>
</select>
<div class="error-message" *ngIf="form.get('environmentId')?.invalid && form.get('environmentId')?.touched">
Environment is required
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="deploymentDate">Deployment Date & Time *</label>
<input
id="deploymentDate"
type="datetime-local"
formControlName="deploymentDate"
[class.error]="form.get('deploymentDate')?.invalid && form.get('deploymentDate')?.touched"
/>
<div class="error-message" *ngIf="form.get('deploymentDate')?.invalid && form.get('deploymentDate')?.touched">
Deployment date is required
</div>
</div>
<div class="form-group">
<label for="deployedBy">Deployed By</label>
<input
id="deployedBy"
type="text"
formControlName="deployedBy"
placeholder="Username or system"
/>
</div>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea
id="notes"
formControlName="notes"
rows="4"
placeholder="Any additional notes about this deployment..."
></textarea>
</div>
<div class="error-message" *ngIf="error">
{{ error }}
</div>
<div class="form-actions">
<button type="button" (click)="cancel()" class="btn-secondary">Cancel</button>
<button type="submit" [disabled]="form.invalid || loading" class="btn-primary">
{{ loading ? 'Recording...' : 'Record Deployment' }}
</button>
</div>
</form>
</div>
@@ -0,0 +1,92 @@
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
h1 { margin-bottom: 2rem; }
}
form {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 1.5rem;
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #555;
}
input,
select,
textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
font-family: inherit;
&:focus {
outline: none;
border-color: #3f51b5;
}
&:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
&.error { border-color: #f44336; }
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
}
.btn-primary, .btn-secondary {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.btn-primary {
background-color: #3f51b5;
color: white;
&:hover:not(:disabled) { background-color: #303f9f; }
&:disabled {
background-color: #ccc;
cursor: not-allowed;
}
}
.btn-secondary {
background-color: #f5f5f5;
color: #333;
&:hover { background-color: #e0e0e0; }
}
.error-message {
color: #f44336;
font-size: 0.875rem;
margin-top: 0.25rem;
}
@@ -0,0 +1,134 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { DeploymentService } from '../deployment.service';
import { ApplicationService } from '../../applications/application.service';
import { EnvironmentService } from '../../environments/environment.service';
import { VersionService } from '../../versions/version.service';
import { AuthService } from '../../../core/auth/auth.service';
import { Application } from '../../../shared/models/application.model';
import { Environment } from '../../../shared/models/environment.model';
import { Version } from '../../../shared/models/version.model';
import { Page } from '../../../shared/models/environment.model';
@Component({
selector: 'app-deployment-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './deployment-form.component.html',
styleUrls: ['./deployment-form.component.scss']
})
export class DeploymentFormComponent implements OnInit {
form: FormGroup;
loading = false;
error = '';
applications: Application[] = [];
environments: Environment[] = [];
versions: Version[] = [];
constructor(
private fb: FormBuilder,
private deploymentService: DeploymentService,
private applicationService: ApplicationService,
private environmentService: EnvironmentService,
private versionService: VersionService,
private authService: AuthService,
private router: Router
) {
const now = new Date();
const currentDateTime = now.toISOString().slice(0, 16);
const currentUser = this.authService.getCurrentUser();
this.form = this.fb.group({
applicationId: ['', [Validators.required]],
versionId: ['', [Validators.required]],
environmentId: ['', [Validators.required]],
deploymentDate: [currentDateTime, [Validators.required]],
deployedBy: [currentUser?.username || '', []],
notes: ['']
});
}
ngOnInit(): void {
this.loadApplications();
this.loadEnvironments();
this.form.get('applicationId')?.valueChanges.subscribe(appId => {
if (appId) {
this.loadVersionsForApplication(appId);
} else {
this.versions = [];
this.form.patchValue({ versionId: '' });
}
});
}
loadApplications(): void {
this.applicationService.getApplications({}, 0, 100).subscribe({
next: (data: Page<Application>) => {
this.applications = data.content;
},
error: (err) => {
this.error = 'Failed to load applications';
}
});
}
loadEnvironments(): void {
this.environmentService.getEnvironments(0, 100).subscribe({
next: (data: Page<Environment>) => {
this.environments = data.content;
},
error: (err) => {
this.error = 'Failed to load environments';
}
});
}
loadVersionsForApplication(applicationId: string): void {
this.versionService.getVersions(applicationId, 0, 100).subscribe({
next: (data: Page<Version>) => {
this.versions = data.content;
},
error: (err) => {
this.error = 'Failed to load versions';
}
});
}
onSubmit(): void {
if (this.form.valid) {
const deploymentDate = new Date(this.form.value.deploymentDate);
const now = new Date();
if (deploymentDate > now) {
this.error = 'Deployment date cannot be in the future';
return;
}
this.loading = true;
this.error = '';
const formData = {
...this.form.value,
deploymentDate: deploymentDate.toISOString()
};
this.deploymentService.recordDeployment(formData).subscribe({
next: () => {
this.router.navigate(['/deployments']);
},
error: (err) => {
this.error = err.error?.message || 'Failed to record deployment';
this.loading = false;
}
});
}
}
cancel(): void {
this.router.navigate(['/deployments']);
}
}
@@ -0,0 +1,89 @@
<div class="container">
<div class="header">
<h1>Deployment History</h1>
<button (click)="recordNew()" class="btn-primary">Record New Deployment</button>
</div>
<div class="filters">
<div class="filter-row">
<div class="filter-group">
<label>Application:</label>
<select [(ngModel)]="selectedApplicationId" (ngModelChange)="onFilterChange()" class="filter-select">
<option value="">All Applications</option>
<option *ngFor="let app of applications" [value]="app.id">{{ app.name }}</option>
</select>
</div>
<div class="filter-group">
<label>Environment:</label>
<select [(ngModel)]="selectedEnvironmentId" (ngModelChange)="onFilterChange()" class="filter-select">
<option value="">All Environments</option>
<option *ngFor="let env of environments" [value]="env.id">{{ env.name }}</option>
</select>
</div>
<div class="filter-group">
<label>From Date:</label>
<input type="date" [(ngModel)]="dateFrom" (ngModelChange)="onFilterChange()" class="filter-input">
</div>
<div class="filter-group">
<label>To Date:</label>
<input type="date" [(ngModel)]="dateTo" (ngModelChange)="onFilterChange()" class="filter-input">
</div>
</div>
</div>
<div *ngIf="loading" class="loading">Loading...</div>
<div *ngIf="error" class="error">{{ error }}</div>
<div *ngIf="!loading && deployments.length > 0" class="table-container">
<table>
<thead>
<tr>
<th>Application</th>
<th>Version</th>
<th>Environment</th>
<th>Deployment Date</th>
<th>Deployed By</th>
<th>Notes</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let deployment of deployments">
<td><strong>{{ deployment.application.name }}</strong></td>
<td>{{ deployment.version.versionIdentifier }}</td>
<td>
<span
class="env-badge"
[class.env-prod]="deployment.environment.isProduction"
[class.env-non-prod]="!deployment.environment.isProduction">
{{ deployment.environment.name }}
</span>
</td>
<td>
{{ deployment.deploymentDate | date:'medium' }}
<br>
<small class="time-ago">{{ getDaysAgo(deployment.deploymentDate) }} days ago</small>
</td>
<td>{{ deployment.deployedBy || '-' }}</td>
<td>{{ deployment.notes || '-' }}</td>
<td>
<button (click)="viewDetails(deployment.id)" class="btn-sm">Details</button>
</td>
</tr>
</tbody>
</table>
</div>
<div *ngIf="!loading && deployments.length === 0" class="empty">
No deployments found matching your criteria.
</div>
<div *ngIf="totalPages > 1" class="pagination">
<button (click)="previousPage()" [disabled]="page === 0">Previous</button>
<span>Page {{ page + 1 }} of {{ totalPages }} ({{ totalElements }} total)</span>
<button (click)="nextPage()" [disabled]="page >= totalPages - 1">Next</button>
</div>
</div>
@@ -0,0 +1,153 @@
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
h1 { margin: 0; }
}
.filters {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.filter-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.filter-group {
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #555;
}
.filter-select,
.filter-input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
&:focus {
outline: none;
border-color: #3f51b5;
}
}
}
.btn-primary {
background-color: #3f51b5;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
&:hover { background-color: #303f9f; }
}
.loading, .error, .empty {
text-align: center;
padding: 2rem;
color: #666;
}
.error { color: #f44336; }
.table-container {
overflow-x: auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
table {
width: 100%;
border-collapse: collapse;
th, td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f5f5f5;
font-weight: 600;
}
tbody tr:hover { background-color: #f9f9f9; }
}
.env-badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.875rem;
font-weight: 500;
&.env-prod {
background-color: #ffebee;
color: #c62828;
}
&.env-non-prod {
background-color: #e3f2fd;
color: #1565c0;
}
}
.time-ago {
color: #999;
font-size: 0.75rem;
}
.btn-sm {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
background-color: #2196f3;
color: white;
font-size: 0.875rem;
&:hover { background-color: #1976d2; }
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 2rem;
button {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
&:hover:not(:disabled) { background-color: #f5f5f5; }
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
span { color: #666; }
}
@@ -0,0 +1,133 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { DeploymentService } from '../deployment.service';
import { ApplicationService } from '../../applications/application.service';
import { EnvironmentService } from '../../environments/environment.service';
import { Deployment } from '../../../shared/models/deployment.model';
import { Application } from '../../../shared/models/application.model';
import { Environment } from '../../../shared/models/environment.model';
import { Page } from '../../../shared/models/environment.model';
@Component({
selector: 'app-deployment-list',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './deployment-list.component.html',
styleUrls: ['./deployment-list.component.scss']
})
export class DeploymentListComponent implements OnInit {
deployments: Deployment[] = [];
applications: Application[] = [];
environments: Environment[] = [];
loading = false;
error = '';
page = 0;
size = 20;
totalElements = 0;
totalPages = 0;
selectedApplicationId = '';
selectedEnvironmentId = '';
dateFrom = '';
dateTo = '';
constructor(
private deploymentService: DeploymentService,
private applicationService: ApplicationService,
private environmentService: EnvironmentService,
private router: Router
) {}
ngOnInit(): void {
this.loadApplications();
this.loadEnvironments();
this.loadDeployments();
}
loadApplications(): void {
this.applicationService.getApplications({}, 0, 100).subscribe({
next: (data: Page<Application>) => {
this.applications = data.content;
},
error: (err) => console.error('Failed to load applications', err)
});
}
loadEnvironments(): void {
this.environmentService.getEnvironments(0, 100).subscribe({
next: (data: Page<Environment>) => {
this.environments = data.content;
},
error: (err) => console.error('Failed to load environments', err)
});
}
loadDeployments(): void {
this.loading = true;
const filters: any = {};
if (this.selectedApplicationId) {
filters.applicationId = this.selectedApplicationId;
}
if (this.selectedEnvironmentId) {
filters.environmentId = this.selectedEnvironmentId;
}
if (this.dateFrom) {
filters.dateFrom = new Date(this.dateFrom);
}
if (this.dateTo) {
filters.dateTo = new Date(this.dateTo);
}
this.deploymentService.getDeployments(filters, this.page, this.size).subscribe({
next: (data: Page<Deployment>) => {
this.deployments = data.content;
this.totalElements = data.totalElements;
this.totalPages = data.totalPages;
this.loading = false;
},
error: (err) => {
this.error = 'Failed to load deployments';
this.loading = false;
}
});
}
onFilterChange(): void {
this.page = 0;
this.loadDeployments();
}
recordNew(): void {
this.router.navigate(['/deployments/new']);
}
viewDetails(id: string): void {
this.router.navigate(['/deployments', id]);
}
nextPage(): void {
if (this.page < this.totalPages - 1) {
this.page++;
this.loadDeployments();
}
}
previousPage(): void {
if (this.page > 0) {
this.page--;
this.loadDeployments();
}
}
getDaysAgo(date: Date): number {
const now = new Date();
const deployDate = new Date(date);
const diff = now.getTime() - deployDate.getTime();
return Math.floor(diff / (1000 * 60 * 60 * 24));
}
}
@@ -0,0 +1,70 @@
<div class="container">
<h1>{{ isEditMode ? 'Edit Version' : 'Add New Version' }}</h1>
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="versionIdentifier">Version Identifier *</label>
<input
id="versionIdentifier"
type="text"
formControlName="versionIdentifier"
placeholder="e.g., 1.2.0, 2024.Q1"
[class.error]="form.get('versionIdentifier')?.invalid && form.get('versionIdentifier')?.touched"
/>
<div class="error-message" *ngIf="form.get('versionIdentifier')?.invalid && form.get('versionIdentifier')?.touched">
<span *ngIf="form.get('versionIdentifier')?.errors?.['required']">Version identifier is required</span>
<span *ngIf="form.get('versionIdentifier')?.errors?.['maxlength']">Max 100 characters</span>
</div>
</div>
<div class="form-group">
<label for="externalReference">External Reference</label>
<input
id="externalReference"
type="url"
formControlName="externalReference"
placeholder="e.g., https://github.com/org/repo/releases/tag/v1.2.0"
[class.error]="form.get('externalReference')?.invalid && form.get('externalReference')?.touched"
/>
<small>Git tag, JIRA release link, etc.</small>
<div class="error-message" *ngIf="form.get('externalReference')?.invalid && form.get('externalReference')?.touched">
Max 500 characters
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="releaseDate">Release Date *</label>
<input
id="releaseDate"
type="date"
formControlName="releaseDate"
[class.error]="form.get('releaseDate')?.invalid && form.get('releaseDate')?.touched"
/>
<div class="error-message" *ngIf="form.get('releaseDate')?.invalid && form.get('releaseDate')?.touched">
Release date is required
</div>
</div>
<div class="form-group">
<label for="endOfLifeDate">End of Life Date</label>
<input
id="endOfLifeDate"
type="date"
formControlName="endOfLifeDate"
/>
</div>
</div>
<div class="error-message" *ngIf="error">
{{ error }}
</div>
<div class="form-actions">
<button type="button" (click)="cancel()" class="btn-secondary">Cancel</button>
<button type="submit" [disabled]="form.invalid || loading" class="btn-primary">
{{ loading ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
@@ -0,0 +1,91 @@
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
h1 { margin-bottom: 2rem; }
}
form {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-group {
margin-bottom: 1.5rem;
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #555;
}
input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
&:focus {
outline: none;
border-color: #3f51b5;
}
&.error { border-color: #f44336; }
}
small {
display: block;
margin-top: 0.25rem;
color: #666;
font-size: 0.875rem;
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 2rem;
}
.btn-primary, .btn-secondary {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.btn-primary {
background-color: #3f51b5;
color: white;
&:hover:not(:disabled) { background-color: #303f9f; }
&:disabled {
background-color: #ccc;
cursor: not-allowed;
}
}
.btn-secondary {
background-color: #f5f5f5;
color: #333;
&:hover { background-color: #e0e0e0; }
}
.error-message {
color: #f44336;
font-size: 0.875rem;
margin-top: 0.25rem;
}
@@ -0,0 +1,128 @@
import { Component, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { Router, ActivatedRoute } from '@angular/router';
import { VersionService } from '../version.service';
@Component({
selector: 'app-version-form',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './version-form.component.html',
styleUrls: ['./version-form.component.scss']
})
export class VersionFormComponent implements OnInit {
form: FormGroup;
loading = false;
error = '';
isEditMode = false;
applicationId!: string;
versionId?: string;
constructor(
private fb: FormBuilder,
private versionService: VersionService,
private router: Router,
private route: ActivatedRoute
) {
this.form = this.fb.group({
versionIdentifier: ['', [Validators.required, Validators.maxLength(100)]],
externalReference: ['', [Validators.maxLength(500)]],
releaseDate: ['', [Validators.required]],
endOfLifeDate: ['']
});
}
ngOnInit(): void {
this.applicationId = this.route.snapshot.paramMap.get('applicationId')!;
this.versionId = this.route.snapshot.paramMap.get('versionId') || undefined;
this.isEditMode = !!this.versionId;
if (this.isEditMode && this.versionId) {
this.loadVersion();
} else {
// Default release date to today
const today = new Date().toISOString().split('T')[0];
this.form.patchValue({ releaseDate: today });
}
}
loadVersion(): void {
if (!this.versionId) return;
this.loading = true;
this.versionService.getVersion(this.applicationId, this.versionId).subscribe({
next: (version) => {
this.form.patchValue({
versionIdentifier: version.versionIdentifier,
externalReference: version.externalReference,
releaseDate: this.formatDateForInput(version.releaseDate),
endOfLifeDate: version.endOfLifeDate ? this.formatDateForInput(version.endOfLifeDate) : ''
});
this.loading = false;
},
error: (err) => {
this.error = 'Failed to load version';
this.loading = false;
}
});
}
formatDateForInput(date: Date): string {
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
onSubmit(): void {
if (this.form.valid) {
const releaseDate = new Date(this.form.value.releaseDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (releaseDate > today) {
this.error = 'Release date cannot be in the future';
return;
}
if (this.form.value.endOfLifeDate) {
const eolDate = new Date(this.form.value.endOfLifeDate);
if (eolDate <= releaseDate) {
this.error = 'End of life date must be after release date';
return;
}
}
this.loading = true;
this.error = '';
const formData = { ...this.form.value };
if (!formData.endOfLifeDate) {
formData.endOfLifeDate = null;
}
if (!formData.externalReference) {
formData.externalReference = null;
}
const request$ = this.isEditMode && this.versionId
? this.versionService.updateVersion(this.applicationId, this.versionId, formData)
: this.versionService.createVersion(this.applicationId, formData);
request$.subscribe({
next: () => {
this.router.navigate(['/applications', this.applicationId]);
},
error: (err) => {
this.error = err.error?.message || 'Failed to save version';
this.loading = false;
}
});
}
}
cancel(): void {
this.router.navigate(['/applications', this.applicationId]);
}
}
@@ -0,0 +1,53 @@
<div class="version-list-container">
<div class="header">
<h2>Versions for {{ applicationName }}</h2>
<button (click)="addVersion()" class="btn-primary">Add New Version</button>
</div>
<div *ngIf="loading" class="loading">Loading versions...</div>
<div *ngIf="error" class="error">{{ error }}</div>
<div *ngIf="!loading && versions.length > 0" class="table-container">
<table>
<thead>
<tr>
<th>Version</th>
<th>Release Date</th>
<th>End of Life</th>
<th>External Reference</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr *ngFor="let version of versions">
<td><strong>{{ version.versionIdentifier }}</strong></td>
<td>{{ version.releaseDate | date:'mediumDate' }}</td>
<td>{{ version.endOfLifeDate ? (version.endOfLifeDate | date:'mediumDate') : '-' }}</td>
<td>
<a *ngIf="version.externalReference"
[href]="version.externalReference"
target="_blank"
class="external-link">
View Reference
</a>
<span *ngIf="!version.externalReference">-</span>
</td>
<td class="actions">
<button (click)="editVersion(version.id)" class="btn-sm">Edit</button>
<button (click)="deleteVersion(version.id)" class="btn-sm btn-danger">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
<div *ngIf="!loading && versions.length === 0" class="empty">
No versions found. Click "Add New Version" to create one.
</div>
<div *ngIf="totalPages > 1" class="pagination">
<button (click)="previousPage()" [disabled]="page === 0">Previous</button>
<span>Page {{ page + 1 }} of {{ totalPages }}</span>
<button (click)="nextPage()" [disabled]="page >= totalPages - 1">Next</button>
</div>
</div>
@@ -0,0 +1,106 @@
.version-list-container {
padding: 1.5rem;
background: white;
border-radius: 8px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
h2 { margin: 0; }
}
.btn-primary {
background-color: #3f51b5;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 4px;
cursor: pointer;
&:hover { background-color: #303f9f; }
}
.loading, .error, .empty {
text-align: center;
padding: 2rem;
color: #666;
}
.error { color: #f44336; }
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
th, td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f5f5f5;
font-weight: 600;
}
tbody tr:hover { background-color: #f9f9f9; }
}
.external-link {
color: #2196f3;
text-decoration: none;
&:hover { text-decoration: underline; }
}
.actions {
display: flex;
gap: 0.5rem;
}
.btn-sm {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
background-color: #2196f3;
color: white;
font-size: 0.875rem;
&:hover { background-color: #1976d2; }
&.btn-danger {
background-color: #f44336;
&:hover { background-color: #d32f2f; }
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
margin-top: 1.5rem;
button {
padding: 0.5rem 1rem;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
&:hover:not(:disabled) { background-color: #f5f5f5; }
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
@@ -0,0 +1,87 @@
import { Component, Input, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { VersionService } from '../version.service';
import { Version } from '../../../shared/models/version.model';
import { Page } from '../../../shared/models/environment.model';
@Component({
selector: 'app-version-list',
standalone: true,
imports: [CommonModule],
templateUrl: './version-list.component.html',
styleUrls: ['./version-list.component.scss']
})
export class VersionListComponent implements OnInit {
@Input() applicationId!: string;
@Input() applicationName!: string;
versions: Version[] = [];
loading = false;
error = '';
page = 0;
size = 20;
totalPages = 0;
constructor(
private versionService: VersionService,
private router: Router
) {}
ngOnInit(): void {
if (this.applicationId) {
this.loadVersions();
}
}
loadVersions(): void {
this.loading = true;
this.versionService.getVersions(this.applicationId, this.page, this.size).subscribe({
next: (data: Page<Version>) => {
this.versions = data.content;
this.totalPages = data.totalPages;
this.loading = false;
},
error: (err) => {
this.error = 'Failed to load versions';
this.loading = false;
}
});
}
addVersion(): void {
this.router.navigate(['/applications', this.applicationId, 'versions', 'new']);
}
editVersion(versionId: string): void {
this.router.navigate(['/applications', this.applicationId, 'versions', versionId, 'edit']);
}
deleteVersion(versionId: string): void {
if (confirm('Are you sure you want to delete this version?')) {
this.versionService.deleteVersion(this.applicationId, versionId).subscribe({
next: () => {
this.loadVersions();
},
error: (err) => {
this.error = 'Failed to delete version';
}
});
}
}
nextPage(): void {
if (this.page < this.totalPages - 1) {
this.page++;
this.loadVersions();
}
}
previousPage(): void {
if (this.page > 0) {
this.page--;
this.loadVersions();
}
}
}