autocomit
This commit is contained in:
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
+60
@@ -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 (< 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 (> 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>
|
||||||
+144
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user