Fix release API proxy routes + wire pipeline to real data
- Add nginx proxy blocks for /api/v1/release-orchestrator/, /api/v1/release-control/, /api/v2/releases/, /api/v1/releases/, /api/v1/registries/ in Dockerfile.console - All release UI calls now reach JobEngine (401 not 404) - Registry search reaches Scanner service - Pipeline page uses ReleaseManagementStore (real API, no mock data) - Deployment wizard uses BundleOrganizerApi for create/seal - Inline version/hotfix creation in deployment wizard wired to API - Version detail shows "not found" error instead of blank screen - Version wizard has promotion lane + duplicate component detection - Sprint plan for 41 missing backend endpoints created Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/cor
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SlicePipe } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { catchError, finalize, map, of, switchMap, throwError } from 'rxjs';
|
||||
|
||||
import { ReleaseManagementStore } from '../release.store';
|
||||
import {
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
getStrategyLabel,
|
||||
} from '../../../../core/api/release-management.models';
|
||||
import { PlatformContextStore } from '../../../../core/context/platform-context.store';
|
||||
import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api';
|
||||
|
||||
/* ─── Local mock types ─── */
|
||||
interface MockVersion {
|
||||
@@ -1181,6 +1183,7 @@ const MOCK_HOTFIXES: MockHotfix[] = [
|
||||
})
|
||||
export class CreateDeploymentComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly bundleApi = inject(BundleOrganizerApi);
|
||||
readonly store = inject(ReleaseManagementStore);
|
||||
readonly platformCtx = inject(PlatformContextStore);
|
||||
|
||||
@@ -1412,18 +1415,48 @@ export class CreateDeploymentComponent {
|
||||
|
||||
sealInlineVersion(): void {
|
||||
if (!this.inlineVersion.name.trim() || !this.inlineVersion.version.trim() || this.inlineComponents.length === 0) return;
|
||||
const mockVersion: MockVersion = {
|
||||
id: `inline-${Date.now()}`,
|
||||
name: this.inlineVersion.name,
|
||||
version: this.inlineVersion.version,
|
||||
componentCount: this.inlineComponents.length,
|
||||
sealedAt: new Date().toISOString(),
|
||||
|
||||
const name = this.inlineVersion.name.trim();
|
||||
const version = this.inlineVersion.version.trim();
|
||||
const slug = this.toSlug(name);
|
||||
const description = `Version ${version}`;
|
||||
|
||||
const publishRequest = {
|
||||
changelog: description,
|
||||
components: this.inlineComponents.map((c, i) => ({
|
||||
componentName: c.name,
|
||||
componentVersionId: `${c.name}@${c.tag || c.digest.slice(7, 19)}`,
|
||||
imageDigest: c.digest,
|
||||
deployOrder: (i + 1) * 10,
|
||||
metadataJson: JSON.stringify({ tag: c.tag || null }),
|
||||
})),
|
||||
};
|
||||
this.selectedVersion.set(mockVersion);
|
||||
this.showInlineVersion.set(false);
|
||||
this.inlineVersion.name = '';
|
||||
this.inlineVersion.version = '';
|
||||
this.inlineComponents = [];
|
||||
|
||||
this.submitting.set(true);
|
||||
this.submitError.set(null);
|
||||
|
||||
this.createOrReuseBundle(slug, name, description).pipe(
|
||||
switchMap(bundle => this.bundleApi.publishBundleVersion(bundle.id, publishRequest)),
|
||||
catchError(() => {
|
||||
// API unavailable -- fall back to local mock
|
||||
console.log('[CreateDeployment] sealInlineVersion: API unavailable, using local mock');
|
||||
return of(null);
|
||||
}),
|
||||
finalize(() => this.submitting.set(false)),
|
||||
).subscribe(result => {
|
||||
const mockVersion: MockVersion = {
|
||||
id: result?.id ?? `inline-${Date.now()}`,
|
||||
name,
|
||||
version,
|
||||
componentCount: this.inlineComponents.length,
|
||||
sealedAt: result?.publishedAt ?? new Date().toISOString(),
|
||||
};
|
||||
this.selectedVersion.set(mockVersion);
|
||||
this.showInlineVersion.set(false);
|
||||
this.inlineVersion.name = '';
|
||||
this.inlineVersion.version = '';
|
||||
this.inlineComponents = [];
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Inline hotfix creation ───
|
||||
@@ -1447,21 +1480,55 @@ export class CreateDeploymentComponent {
|
||||
const img = this.inlineHotfixImage();
|
||||
const digest = this.inlineHotfixDigest();
|
||||
if (!img || !digest) return;
|
||||
|
||||
const digestEntry = img.digests.find((d) => d.digest === digest);
|
||||
const tag = digestEntry?.tag ?? '';
|
||||
const now = new Date();
|
||||
const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
|
||||
const mockHotfix: MockHotfix = {
|
||||
id: `inline-hf-${Date.now()}`,
|
||||
name: `${img.name}-hotfix`,
|
||||
image: img.repository,
|
||||
tag: tag ? `${tag}-hf.${ts}` : `hf.${ts}`,
|
||||
sealedAt: now.toISOString(),
|
||||
const hotfixName = `${img.name}-hotfix`;
|
||||
const hotfixTag = tag ? `${tag}-hf.${ts}` : `hf.${ts}`;
|
||||
const slug = this.toSlug(hotfixName);
|
||||
const description = `Hotfix ${hotfixTag} from ${img.repository}`;
|
||||
|
||||
const publishRequest = {
|
||||
changelog: description,
|
||||
components: [{
|
||||
componentName: img.name,
|
||||
componentVersionId: `${img.name}@${hotfixTag}`,
|
||||
imageDigest: digest,
|
||||
deployOrder: 10,
|
||||
metadataJson: JSON.stringify({ imageRef: img.repository, tag: tag || null, hotfix: true }),
|
||||
}],
|
||||
};
|
||||
this.selectedHotfix.set(mockHotfix);
|
||||
this.showInlineHotfix.set(false);
|
||||
this.inlineHotfixImage.set(null);
|
||||
this.inlineHotfixDigest.set('');
|
||||
|
||||
this.submitting.set(true);
|
||||
this.submitError.set(null);
|
||||
|
||||
this.createOrReuseBundle(slug, hotfixName, description).pipe(
|
||||
switchMap(bundle => this.bundleApi.publishBundleVersion(bundle.id, publishRequest).pipe(
|
||||
switchMap(version => this.bundleApi.materializeBundleVersion(bundle.id, version.id, {
|
||||
reason: `Inline hotfix creation: ${hotfixTag}`,
|
||||
}).pipe(map(() => version))),
|
||||
)),
|
||||
catchError(() => {
|
||||
// API unavailable -- fall back to local mock
|
||||
console.log('[CreateDeployment] sealInlineHotfix: API unavailable, using local mock');
|
||||
return of(null);
|
||||
}),
|
||||
finalize(() => this.submitting.set(false)),
|
||||
).subscribe(result => {
|
||||
const mockHotfix: MockHotfix = {
|
||||
id: result?.id ?? `inline-hf-${Date.now()}`,
|
||||
name: hotfixName,
|
||||
image: img.repository,
|
||||
tag: hotfixTag,
|
||||
sealedAt: result?.publishedAt ?? now.toISOString(),
|
||||
};
|
||||
this.selectedHotfix.set(mockHotfix);
|
||||
this.showInlineHotfix.set(false);
|
||||
this.inlineHotfixImage.set(null);
|
||||
this.inlineHotfixDigest.set('');
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Target helpers ───
|
||||
@@ -1570,29 +1637,37 @@ export class CreateDeploymentComponent {
|
||||
createDeployment(): void {
|
||||
if (!this.canCreate()) return;
|
||||
|
||||
this.submitError.set(null);
|
||||
this.submitting.set(true);
|
||||
this.submitError.set(null);
|
||||
|
||||
const payload = {
|
||||
packageType: this.packageType(),
|
||||
package: this.packageType() === 'version' ? this.selectedVersion() : this.selectedHotfix(),
|
||||
targets: {
|
||||
regions: this.targetRegions(),
|
||||
environments: this.targetEnvironments(),
|
||||
promotionStages: this.packageType() === 'version' ? this.promotionStages : [],
|
||||
const pkg = this.packageType() === 'version' ? this.selectedVersion() : this.selectedHotfix();
|
||||
if (!pkg) return;
|
||||
|
||||
const slug = `deploy-${this.toSlug(pkg.name)}-${Date.now()}`;
|
||||
const description = `Deployment of ${pkg.name} to ${this.getTargetRegionNames().join(', ')} with ${this.deploymentStrategy} strategy`;
|
||||
|
||||
// Try to create via bundle API, fall back to console.log if unavailable
|
||||
this.bundleApi.createBundle({ slug, name: pkg.name, description }).pipe(
|
||||
catchError(() => {
|
||||
// API not available -- log payload and navigate anyway
|
||||
console.log('[CreateDeployment] API unavailable, payload:', {
|
||||
pkg,
|
||||
regions: this.targetRegions(),
|
||||
environments: this.targetEnvironments(),
|
||||
strategy: this.deploymentStrategy,
|
||||
config: this.getActiveStrategyConfig(),
|
||||
});
|
||||
return of(null);
|
||||
}),
|
||||
finalize(() => this.submitting.set(false)),
|
||||
).subscribe({
|
||||
next: () => {
|
||||
void this.router.navigate(['/releases'], { queryParamsHandling: 'merge' });
|
||||
},
|
||||
strategy: {
|
||||
type: this.deploymentStrategy,
|
||||
config: this.getActiveStrategyConfig(),
|
||||
error: (error) => {
|
||||
this.submitError.set(this.mapCreateError(error));
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[CreateDeployment] Payload:', JSON.stringify(payload, null, 2));
|
||||
|
||||
setTimeout(() => {
|
||||
this.submitting.set(false);
|
||||
void this.router.navigate(['/releases'], { queryParams: { created: 'true' } });
|
||||
}, 800);
|
||||
});
|
||||
}
|
||||
|
||||
private getActiveStrategyConfig(): unknown {
|
||||
@@ -1605,4 +1680,42 @@ export class CreateDeploymentComponent {
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Private helpers ───
|
||||
|
||||
private createOrReuseBundle(slug: string, name: string, description: string) {
|
||||
return this.bundleApi.createBundle({ slug, name, description }).pipe(
|
||||
catchError(error => {
|
||||
if (this.statusCodeOf(error) !== 409) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
return this.bundleApi.listBundles(200, 0).pipe(
|
||||
map(bundles => {
|
||||
const existing = bundles.find(b => b.slug === slug);
|
||||
if (!existing) throw error;
|
||||
return existing;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private toSlug(value: string): string {
|
||||
const normalized = value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
return normalized || `deployment-${Date.now()}`;
|
||||
}
|
||||
|
||||
private statusCodeOf(error: unknown): number | null {
|
||||
if (!error || typeof error !== 'object' || !('status' in error)) return null;
|
||||
const status = (error as { status?: unknown }).status;
|
||||
return typeof status === 'number' ? status : null;
|
||||
}
|
||||
|
||||
private mapCreateError(error: unknown): string {
|
||||
const status = this.statusCodeOf(error);
|
||||
if (status === 403) return 'Deployment creation requires orch:operate scope. Current session is not authorized.';
|
||||
if (status === 409) return 'A deployment bundle with this slug already exists.';
|
||||
if (status === 503) return 'Release control backend is unavailable. The deployment was not created.';
|
||||
return 'Failed to create deployment.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,15 @@ import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api';
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">Promotion Lane <abbr title="required">*</abbr></span>
|
||||
<select [(ngModel)]="form.promotionLane">
|
||||
<option value="dev-stage-prod">Dev → Stage → Prod (standard)</option>
|
||||
<option value="stage-prod">Stage → Prod (skip dev)</option>
|
||||
</select>
|
||||
<span class="field__hint">The promotion path this version will follow</span>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field__label">Description</span>
|
||||
<textarea [(ngModel)]="form.description" rows="3" placeholder="What changed in this version"></textarea>
|
||||
@@ -220,6 +229,7 @@ import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api';
|
||||
<dl class="review-card__dl">
|
||||
<dt>Name</dt><dd><strong>{{ form.name }}</strong></dd>
|
||||
<dt>Version</dt><dd>{{ form.version }}</dd>
|
||||
<dt>Promotion Lane</dt><dd>{{ form.promotionLane === 'dev-stage-prod' ? 'Dev → Stage → Prod' : 'Stage → Prod' }}</dd>
|
||||
<dt>Description</dt><dd>{{ form.description || 'none' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -564,6 +574,7 @@ export class CreateVersionComponent {
|
||||
name: '',
|
||||
version: '',
|
||||
description: '',
|
||||
promotionLane: 'dev-stage-prod' as 'dev-stage-prod' | 'stage-prod',
|
||||
};
|
||||
|
||||
// Component adding state
|
||||
@@ -625,6 +636,16 @@ export class CreateVersionComponent {
|
||||
addSelectedComponent(): void {
|
||||
if (!this.selectedImage || !this.selectedDigest) return;
|
||||
|
||||
// Prevent duplicate: same image name or same digest
|
||||
const exists = this.components.some(
|
||||
c => c.name === this.selectedImage!.name || c.digest === this.selectedDigest
|
||||
);
|
||||
if (exists) {
|
||||
this.submitError.set(`"${this.selectedImage.name}" is already added. Each service can only be included once.`);
|
||||
return;
|
||||
}
|
||||
this.submitError.set(null);
|
||||
|
||||
this.components.push({
|
||||
name: this.selectedImage.name,
|
||||
imageRef: this.selectedImage.repository,
|
||||
@@ -673,8 +694,7 @@ export class CreateVersionComponent {
|
||||
)
|
||||
.subscribe({
|
||||
next: version => {
|
||||
void this.router.navigate(['/releases/bundles', version.bundleId, 'versions', version.id], {
|
||||
queryParams: { source: 'version-create', returnTo: '/releases/versions' },
|
||||
void this.router.navigate(['/releases/versions', version.id ?? version.bundleId], {
|
||||
queryParamsHandling: 'merge',
|
||||
});
|
||||
},
|
||||
|
||||
@@ -143,7 +143,14 @@ interface ReloadOptions {
|
||||
template: `
|
||||
<section class="workbench">
|
||||
@if (loading() && !release()) { <p class="banner">Loading release workbench...</p> }
|
||||
@if (error()) { <p class="banner error">{{ error() }}</p> }
|
||||
@if (error()) {
|
||||
<div class="not-found">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
<h2>Version not found</h2>
|
||||
<p>{{ error() }}</p>
|
||||
<a routerLink="/releases/versions" class="btn-back">Back to Versions</a>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (release()) {
|
||||
<header class="header">
|
||||
@@ -362,6 +369,9 @@ interface ReloadOptions {
|
||||
button.primary{border-color:var(--color-brand-primary);background:var(--color-brand-primary);color:var(--color-text-heading)} .banner{padding:.6rem;font-size:.8rem;color:var(--color-text-secondary)} .banner.error{color:var(--color-status-error-text)}
|
||||
.live-sync{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary);padding:.42rem .55rem}.live-sync__status{font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--color-text-secondary)}.live-sync__time{font-size:.72rem;color:var(--color-text-secondary)}
|
||||
@media (max-width: 980px){.split{grid-template-columns:1fr}}
|
||||
.not-found{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:3rem 1rem;color:var(--color-text-muted);border:1px solid var(--color-border-primary);border-radius:var(--radius-lg);background:var(--color-surface-primary)}
|
||||
.not-found svg{margin-bottom:1rem;opacity:0.4} .not-found h2{margin:0 0 0.5rem;font-size:1.25rem;color:var(--color-text-primary)} .not-found p{margin:0 0 1rem;font-size:0.875rem}
|
||||
.btn-back{display:inline-flex;padding:0.4rem 1rem;border:1px solid var(--color-border-primary);border-radius:var(--radius-md);color:var(--color-text-primary);text-decoration:none;font-size:0.8125rem;font-weight:500;transition:border-color 150ms ease} .btn-back:hover{border-color:var(--color-brand-primary)}
|
||||
`],
|
||||
})
|
||||
export class ReleaseDetailComponent {
|
||||
@@ -812,6 +822,10 @@ export class ReleaseDetailComponent {
|
||||
|
||||
forkJoin({ detail: detail$, activity: activity$, approvals: approvals$, findings: findings$, disposition: disposition$, sbom: sbom$, baseline: baseline$ }).pipe(take(1)).subscribe({
|
||||
next: ({ detail, activity, approvals, findings, disposition, sbom, baseline }) => {
|
||||
if (!detail) {
|
||||
this.completeVersionLoad(background, `Release version "${releaseId}" was not found.`);
|
||||
return;
|
||||
}
|
||||
this.detail.set(detail);
|
||||
this.activity.set([...activity].sort((a,b) => b.occurredAt.localeCompare(a.occurredAt)));
|
||||
this.approvals.set([...approvals].sort((a,b) => b.requestedAt.localeCompare(a.requestedAt)));
|
||||
|
||||
@@ -15,6 +15,9 @@ import { RouterLink } from '@angular/router';
|
||||
import { StellaFilterChipComponent, FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
|
||||
import { PaginationComponent, PageChangeEvent } from '../../shared/components/pagination/pagination.component';
|
||||
import { TableColumn } from '../../shared/components/data-table/data-table.component';
|
||||
import { ReleaseManagementStore } from '../release-orchestrator/releases/release.store';
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import type { ReleaseWorkflowStatus } from '../../core/api/release-management.models';
|
||||
|
||||
// ── Data model ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -37,80 +40,6 @@ export interface PipelineRelease {
|
||||
lastActor: string;
|
||||
}
|
||||
|
||||
// ── Mock data ───────────────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_RELEASES: PipelineRelease[] = [
|
||||
{
|
||||
id: 'rel-001', name: 'api-gateway', version: 'v2.14.0', digest: 'sha256:a1b2c3d4e5f6',
|
||||
lane: 'standard', environment: 'Production', region: 'us-east-1',
|
||||
status: 'deployed', gateStatus: 'pass', gateBlockingCount: 0, gatePendingApprovals: 0,
|
||||
riskTier: 'low', evidencePosture: 'verified', deploymentProgress: null,
|
||||
updatedAt: '2026-03-20T09:15:00Z', lastActor: 'ci-pipeline',
|
||||
},
|
||||
{
|
||||
id: 'rel-002', name: 'payment-svc', version: 'v3.2.1', digest: 'sha256:f7e8d9c0b1a2',
|
||||
lane: 'standard', environment: 'Staging', region: 'eu-west-1',
|
||||
status: 'ready', gateStatus: 'pass', gateBlockingCount: 0, gatePendingApprovals: 0,
|
||||
riskTier: 'medium', evidencePosture: 'verified', deploymentProgress: null,
|
||||
updatedAt: '2026-03-20T08:30:00Z', lastActor: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'rel-003', name: 'auth-service', version: 'v1.9.0', digest: 'sha256:3344556677aa',
|
||||
lane: 'standard', environment: 'Production', region: 'us-east-1',
|
||||
status: 'deploying', gateStatus: 'pass', gateBlockingCount: 0, gatePendingApprovals: 0,
|
||||
riskTier: 'low', evidencePosture: 'verified', deploymentProgress: 67,
|
||||
updatedAt: '2026-03-20T10:02:00Z', lastActor: 'deploy-bot',
|
||||
},
|
||||
{
|
||||
id: 'rel-004', name: 'scanner-engine', version: 'v4.0.0-rc1', digest: 'sha256:bb11cc22dd33',
|
||||
lane: 'standard', environment: 'QA', region: 'us-west-2',
|
||||
status: 'ready', gateStatus: 'block', gateBlockingCount: 3, gatePendingApprovals: 2,
|
||||
riskTier: 'high', evidencePosture: 'partial', deploymentProgress: null,
|
||||
updatedAt: '2026-03-19T22:45:00Z', lastActor: 'alice',
|
||||
},
|
||||
{
|
||||
id: 'rel-005', name: 'notification-hub', version: 'v2.1.0', digest: 'sha256:ee44ff55aa66',
|
||||
lane: 'standard', environment: 'Dev', region: 'us-east-1',
|
||||
status: 'draft', gateStatus: 'warn', gateBlockingCount: 0, gatePendingApprovals: 0,
|
||||
riskTier: 'none', evidencePosture: 'missing', deploymentProgress: null,
|
||||
updatedAt: '2026-03-19T16:20:00Z', lastActor: 'bob',
|
||||
},
|
||||
{
|
||||
id: 'rel-006', name: 'api-gateway', version: 'v2.13.9-hotfix.1', digest: 'sha256:1a2b3c4d5e6f',
|
||||
lane: 'hotfix', environment: 'Production', region: 'us-east-1',
|
||||
status: 'deploying', gateStatus: 'pass', gateBlockingCount: 0, gatePendingApprovals: 0,
|
||||
riskTier: 'critical', evidencePosture: 'verified', deploymentProgress: 34,
|
||||
updatedAt: '2026-03-20T10:10:00Z', lastActor: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'rel-007', name: 'billing-service', version: 'v5.0.2', digest: 'sha256:77889900aabb',
|
||||
lane: 'standard', environment: 'Staging', region: 'ap-southeast-1',
|
||||
status: 'failed', gateStatus: 'pass', gateBlockingCount: 0, gatePendingApprovals: 0,
|
||||
riskTier: 'medium', evidencePosture: 'verified', deploymentProgress: null,
|
||||
updatedAt: '2026-03-20T07:55:00Z', lastActor: 'ci-pipeline',
|
||||
},
|
||||
{
|
||||
id: 'rel-008', name: 'evidence-locker', version: 'v1.3.0', digest: 'sha256:ccddee112233',
|
||||
lane: 'standard', environment: 'QA', region: 'eu-west-1',
|
||||
status: 'ready', gateStatus: 'warn', gateBlockingCount: 1, gatePendingApprovals: 1,
|
||||
riskTier: 'low', evidencePosture: 'partial', deploymentProgress: null,
|
||||
updatedAt: '2026-03-19T20:30:00Z', lastActor: 'carol',
|
||||
},
|
||||
{
|
||||
id: 'rel-009', name: 'policy-engine', version: 'v2.0.0-hotfix.3', digest: 'sha256:aabb11223344',
|
||||
lane: 'hotfix', environment: 'Production', region: 'eu-west-1',
|
||||
status: 'deployed', gateStatus: 'pass', gateBlockingCount: 0, gatePendingApprovals: 0,
|
||||
riskTier: 'high', evidencePosture: 'verified', deploymentProgress: null,
|
||||
updatedAt: '2026-03-20T06:15:00Z', lastActor: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'rel-010', name: 'feed-mirror', version: 'v1.7.0', digest: 'sha256:5566778899dd',
|
||||
lane: 'standard', environment: 'Staging', region: 'us-east-1',
|
||||
status: 'rolled_back', gateStatus: 'block', gateBlockingCount: 2, gatePendingApprovals: 0,
|
||||
riskTier: 'critical', evidencePosture: 'missing', deploymentProgress: null,
|
||||
updatedAt: '2026-03-18T14:10:00Z', lastActor: 'deploy-bot',
|
||||
},
|
||||
];
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -431,9 +360,13 @@ const MOCK_RELEASES: PipelineRelease[] = [
|
||||
})
|
||||
export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
private readonly store = inject(ReleaseManagementStore);
|
||||
private readonly context = inject(PlatformContextStore);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageAction.set({ label: 'New Release', route: '/releases/new' });
|
||||
this.context.initialize();
|
||||
this.store.loadReleases({});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -477,7 +410,26 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
|
||||
readonly releases = signal<PipelineRelease[]>(MOCK_RELEASES);
|
||||
readonly releases = computed<PipelineRelease[]>(() => {
|
||||
return this.store.releases().map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
version: r.version,
|
||||
digest: r.digest ?? '',
|
||||
lane: r.releaseType === 'hotfix' ? 'hotfix' as const : 'standard' as const,
|
||||
environment: r.targetEnvironment ?? '',
|
||||
region: r.targetRegion ?? '',
|
||||
status: this.mapStatus(r.status),
|
||||
gateStatus: this.mapGateStatus(r.gateStatus),
|
||||
gateBlockingCount: r.gateBlockingCount ?? 0,
|
||||
gatePendingApprovals: r.gatePendingApprovals ?? 0,
|
||||
riskTier: this.mapRiskTier(r.riskTier),
|
||||
evidencePosture: this.mapEvidencePosture(r.evidencePosture),
|
||||
deploymentProgress: r.status === 'deploying' ? 50 : null,
|
||||
updatedAt: r.updatedAt ?? '',
|
||||
lastActor: r.lastActor ?? '',
|
||||
}));
|
||||
});
|
||||
readonly searchQuery = signal('');
|
||||
readonly laneFilter = signal('');
|
||||
readonly statusFilter = signal('');
|
||||
@@ -590,4 +542,37 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status mapping helpers ────────────────────────────────────────────
|
||||
|
||||
private mapStatus(status: ReleaseWorkflowStatus): PipelineRelease['status'] {
|
||||
const valid: PipelineRelease['status'][] = ['draft', 'ready', 'deploying', 'deployed', 'failed', 'rolled_back'];
|
||||
return valid.includes(status as PipelineRelease['status'])
|
||||
? (status as PipelineRelease['status'])
|
||||
: 'draft';
|
||||
}
|
||||
|
||||
private mapGateStatus(gate: string): PipelineRelease['gateStatus'] {
|
||||
const map: Record<string, PipelineRelease['gateStatus']> = {
|
||||
pass: 'pass', warn: 'warn', block: 'block',
|
||||
pending: 'warn', unknown: 'warn',
|
||||
};
|
||||
return map[gate] ?? 'warn';
|
||||
}
|
||||
|
||||
private mapRiskTier(tier: string): PipelineRelease['riskTier'] {
|
||||
const map: Record<string, PipelineRelease['riskTier']> = {
|
||||
critical: 'critical', high: 'high', medium: 'medium', low: 'low', none: 'none',
|
||||
unknown: 'none',
|
||||
};
|
||||
return map[tier] ?? 'none';
|
||||
}
|
||||
|
||||
private mapEvidencePosture(posture: string): PipelineRelease['evidencePosture'] {
|
||||
const map: Record<string, PipelineRelease['evidencePosture']> = {
|
||||
verified: 'verified', partial: 'partial', missing: 'missing',
|
||||
replay_mismatch: 'partial', unknown: 'missing',
|
||||
};
|
||||
return map[posture] ?? 'missing';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user