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:
master
2026-03-23 15:38:16 +02:00
parent 66d84fb17a
commit d3353e9d16
10 changed files with 658 additions and 126 deletions

View File

@@ -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.';
}
}

View File

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

View File

@@ -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)));

View File

@@ -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';
}
}