diff --git a/devops/docker/Dockerfile.console b/devops/docker/Dockerfile.console index f785099a2..83c15253f 100644 --- a/devops/docker/Dockerfile.console +++ b/devops/docker/Dockerfile.console @@ -49,6 +49,65 @@ server { # --- API reverse proxy (eliminates CORS for same-origin requests) --- + # Release Orchestrator (JobEngine service) — must precede the /api/ catch-all + location /api/v1/release-orchestrator/ { + set \$orchestrator_upstream http://orchestrator.stella-ops.local; + proxy_pass \$orchestrator_upstream; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + } + + # Release Control bundles (JobEngine service) + location /api/v1/release-control/ { + set \$orchestrator_upstream http://orchestrator.stella-ops.local; + proxy_pass \$orchestrator_upstream; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + } + + # Release v2 read-model (JobEngine service) — rewrites /api/v2/releases/ to orchestrator + location /api/v2/releases/ { + set \$orchestrator_upstream http://orchestrator.stella-ops.local; + proxy_pass \$orchestrator_upstream; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + } + + # Legacy v1 releases (JobEngine service) + location /api/v1/releases/ { + set \$orchestrator_upstream http://orchestrator.stella-ops.local; + proxy_pass \$orchestrator_upstream; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + } + + # Registry image search (Scanner service) + location /api/v1/registries/ { + set \$scanner_upstream http://scanner.stella-ops.local; + proxy_pass \$scanner_upstream; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + } + + # Release v2 security endpoints (Platform proxies these) + location /api/v2/security/ { + proxy_pass http://platform.stella-ops.local/api/v2/security/; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + } + # Platform API (direct /api/ prefix for clients using environment.apiBaseUrl) location /api/ { proxy_pass http://platform.stella-ops.local/api/; @@ -227,6 +286,17 @@ server { proxy_set_header X-Forwarded-Proto \$scheme; } + # Orchestrator service (key: orchestrator, strips /orchestrator/ prefix) + location /orchestrator/ { + set \$orchestrator_upstream http://orchestrator.stella-ops.local; + rewrite ^/orchestrator/(.*)\$ /\$1 break; + proxy_pass \$orchestrator_upstream; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto \$scheme; + } + # Environment settings — SPA fetches /platform/envsettings.json at startup. # sub_filter rewrites Docker-internal hostnames to relative paths so the # browser routes all API calls through this nginx reverse proxy (CORS fix). diff --git a/docs/implplan/SPRINT_20260323_001_BE_release_api_proxy_and_endpoints.md b/docs/implplan/SPRINT_20260323_001_BE_release_api_proxy_and_endpoints.md new file mode 100644 index 000000000..9b6eef03e --- /dev/null +++ b/docs/implplan/SPRINT_20260323_001_BE_release_api_proxy_and_endpoints.md @@ -0,0 +1,237 @@ +# Sprint 20260323-001 — Release API Proxy Fix + Missing Backend Endpoints + +## Topic & Scope +- Fix nginx proxy routes so the UI's `/api/v2/releases` calls reach the JobEngine backend. +- Implement 41 missing backend endpoints across 5 service areas. +- Working directories: `devops/docker/`, `src/JobEngine/`, `src/Platform/`, `src/EvidenceLocker/` +- Expected evidence: all release CRUD flows work end-to-end via Playwright. + +## Dependencies & Concurrency +- TASK-001 (proxy fix) is prerequisite for all UI testing. +- TASK-002 through TASK-006 can run in parallel after TASK-001. + +## Documentation Prerequisites +- `docs/modules/release-orchestrator/deployment/overview.md` +- `docs/modules/release-orchestrator/deployment/strategies.md` +- `src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Endpoints/ReleaseEndpoints.cs` +- `src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Endpoints/ApprovalEndpoints.cs` + +## Delivery Tracker + +### TASK-001 - Fix nginx proxy routes for release APIs +Status: TODO +Dependency: none +Owners: DevOps / FE + +Task description: +The console nginx (`devops/docker/Dockerfile.console`) reverse-proxies API calls. The UI calls: +- `/api/v2/releases/*` → should reach JobEngine at `/api/v1/release-orchestrator/releases/*` +- `/api/v1/release-control/bundles/*` → should reach JobEngine bundle endpoints +- `/api/v1/registries/images/search` → should reach Scanner service +- `/api/v2/releases/approvals` → should reach JobEngine approval endpoints +- `/api/v2/releases/activity` → should reach JobEngine release events +- `/api/v2/releases/runs/*` → should reach JobEngine run workbench + +Options: +A) Add nginx `location` blocks in `Dockerfile.console` to proxy these paths to the correct upstream services +B) Add proxy routes in the Platform service (which already proxies many paths) +C) Update the UI API clients to call the correct backend URLs directly (e.g., change `/api/v2/releases` to `/api/v1/release-orchestrator/releases`) + +Recommended: Option C (most reliable, no proxy chain) + Option A as fallback. + +Implementation for Option C: +- Update `src/Web/StellaOps.Web/src/app/core/api/release-management.client.ts` — change base URLs +- Update `release.store.ts` — ensure `loadReleases` calls the correct endpoint +- Update `release-detail.component.ts` — fix all `/api/v2/releases/` calls to `/api/v1/release-orchestrator/releases/` +- Update approval client — fix `/api/v2/releases/approvals` to `/api/v1/release-orchestrator/approvals` + +For nginx (Option A fallback), add to `Dockerfile.console`: +```nginx +location /api/v2/releases/ { + set $orchestrator_upstream http://orchestrator.stella-ops.local; + rewrite ^/api/v2/releases/(.*) /api/v1/release-orchestrator/releases/$1 break; + proxy_pass $orchestrator_upstream; +} + +location /api/v1/release-control/ { + set $orchestrator_upstream http://orchestrator.stella-ops.local; + rewrite ^/api/v1/release-control/(.*) /api/v1/release-control/$1 break; + proxy_pass $orchestrator_upstream; +} + +location /api/v1/registries/ { + set $scanner_upstream http://scanner.stella-ops.local; + rewrite ^/api/v1/registries/(.*) /api/v1/registries/$1 break; + proxy_pass $scanner_upstream; +} +``` + +Completion criteria: +- [ ] `/api/v2/releases` returns data from JobEngine (not 404) +- [ ] `/api/v1/registries/images/search?q=nginx` returns results from Scanner +- [ ] `/api/v1/release-control/bundles` POST creates a bundle in JobEngine +- [ ] UI pipeline page shows real releases (if any exist) +- [ ] UI version create wizard can search registry images + +### TASK-002 - Deployment monitoring endpoints (11 endpoints) +Status: TODO +Dependency: TASK-001 +Owners: BE (JobEngine) + +Task description: +Add deployment monitoring endpoints to JobEngine's `ReleaseEndpoints.cs` or new `DeploymentEndpoints.cs`: + +``` +GET /api/v1/release-orchestrator/deployments — list deployments +GET /api/v1/release-orchestrator/deployments/{id} — get deployment detail +GET /api/v1/release-orchestrator/deployments/{id}/logs — get deployment logs +GET /api/v1/release-orchestrator/deployments/{id}/targets/{targetId}/logs — target logs +GET /api/v1/release-orchestrator/deployments/{id}/events — deployment events +GET /api/v1/release-orchestrator/deployments/{id}/metrics — deployment metrics +POST /api/v1/release-orchestrator/deployments/{id}/pause — pause deployment +POST /api/v1/release-orchestrator/deployments/{id}/resume — resume deployment +POST /api/v1/release-orchestrator/deployments/{id}/cancel — cancel deployment +POST /api/v1/release-orchestrator/deployments/{id}/rollback — rollback deployment +POST /api/v1/release-orchestrator/deployments/{id}/targets/{targetId}/retry — retry target +``` + +Each endpoint needs: +- Request/response models in contracts +- Handler method with auth policy (ReleaseRead for GET, ReleaseWrite for POST) +- Minimal implementation (can return mock/empty data initially, wired to real service later) + +Completion criteria: +- [ ] All 11 endpoints return 200/201 (not 404) +- [ ] GET /deployments returns a list (even if empty) +- [ ] POST /pause returns success +- [ ] Auth policies enforced + +### TASK-003 - Evidence management endpoints (6 endpoints) +Status: TODO +Dependency: TASK-001 +Owners: BE (EvidenceLocker or Attestor) + +Task description: +Add evidence lifecycle endpoints — either in EvidenceLocker service or as a new endpoints file in JobEngine: + +``` +GET /api/v1/release-orchestrator/evidence — list evidence packets +GET /api/v1/release-orchestrator/evidence/{id} — get evidence detail +POST /api/v1/release-orchestrator/evidence/{id}/verify — verify evidence integrity +GET /api/v1/release-orchestrator/evidence/{id}/export — export evidence bundle +GET /api/v1/release-orchestrator/evidence/{id}/raw — download raw evidence +GET /api/v1/release-orchestrator/evidence/{id}/timeline — evidence timeline +``` + +Completion criteria: +- [ ] All 6 endpoints return valid responses +- [ ] Verify endpoint checks hash integrity +- [ ] Export returns downloadable content-type + +### TASK-004 - Environment/Target management endpoints (15 endpoints) +Status: TODO +Dependency: TASK-001 +Owners: BE (Platform) + +Task description: +Add environment and target management endpoints to Platform service. These may already partially exist in the topology/environment subsystem — check before implementing. + +``` +GET /api/v1/release-orchestrator/environments — list environments +GET /api/v1/release-orchestrator/environments/{id} — get environment +POST /api/v1/release-orchestrator/environments — create environment +PUT /api/v1/release-orchestrator/environments/{id} — update environment +DELETE /api/v1/release-orchestrator/environments/{id} — delete environment +PUT /api/v1/release-orchestrator/environments/{id}/settings — update settings +GET /api/v1/release-orchestrator/environments/{id}/targets — list targets +POST /api/v1/release-orchestrator/environments/{id}/targets — add target +PUT /api/v1/release-orchestrator/environments/{id}/targets/{tid} — update target +DELETE /api/v1/release-orchestrator/environments/{id}/targets/{tid} — remove target +POST /api/v1/release-orchestrator/environments/{id}/targets/{tid}/health-check — check health +GET /api/v1/release-orchestrator/environments/{id}/freeze-windows — list freeze windows +POST /api/v1/release-orchestrator/environments/{id}/freeze-windows — create freeze window +PUT /api/v1/release-orchestrator/environments/{id}/freeze-windows/{wid} — update +DELETE /api/v1/release-orchestrator/environments/{id}/freeze-windows/{wid} — delete +``` + +NOTE: The Platform service already has environment data via PlatformContextStore. These endpoints may be aliases or extensions of existing topology endpoints. Check `src/Platform/` for existing environment CRUD before implementing new ones. + +Completion criteria: +- [ ] Environment CRUD works +- [ ] Target CRUD within environments works +- [ ] Health check returns target status +- [ ] Freeze window CRUD works + +### TASK-005 - Release dashboard endpoint (3 endpoints) +Status: TODO +Dependency: TASK-001 +Owners: BE (JobEngine) + +Task description: +Add dashboard summary and promotion decision endpoints: + +``` +GET /api/v1/release-orchestrator/dashboard — aggregated dashboard data +POST /api/v1/release-orchestrator/promotions/{id}/approve — approve promotion +POST /api/v1/release-orchestrator/promotions/{id}/reject — reject promotion +``` + +Dashboard endpoint should return: release counts by status, deployment health, pending approvals, gate summary. + +Completion criteria: +- [ ] Dashboard returns aggregated stats +- [ ] Approve/reject promotion updates approval status + +### TASK-006 - Registry image search in Scanner service +Status: TODO +Dependency: none +Owners: BE (Scanner) + +Task description: +Verify/implement registry image search endpoints in the Scanner service: + +``` +GET /api/v1/registries/images/search?q={query} — search images by name +GET /api/v1/registries/images/digests?repository={repo} — get image digests +``` + +These are called by Create Version and Create Hotfix wizards. If the Scanner service doesn't have these endpoints, add them. The response format expected by the UI: + +```json +// Search response +[ + { + "name": "nginx", + "repository": "library/nginx", + "tags": ["latest", "1.25", "1.25-alpine"], + "digests": [ + { "tag": "latest", "digest": "sha256:abc123...", "pushedAt": "2026-03-20T10:00:00Z" } + ], + "lastPushed": "2026-03-20T10:00:00Z" + } +] +``` + +If no real registry is connected, return mock data so the wizard flow can be tested. + +Completion criteria: +- [ ] Search returns image results for common queries (nginx, redis, postgres) +- [ ] Digests returns tags + SHA digests for a given repository +- [ ] Create Version wizard can search and select images + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-23 | Sprint created. TASK-001 is the critical path. | Planning | + +## Decisions & Risks +- TASK-001 (proxy fix) is the blocker — without it, no UI endpoint reaches the backend. +- Option C (fix UI API URLs) is preferred over nginx rewrites because it eliminates a proxy hop. +- Environment endpoints (TASK-004) may overlap with existing Platform topology — investigate before duplicating. +- Evidence endpoints (TASK-003) may belong in EvidenceLocker rather than JobEngine — architecture decision needed. +- Registry search (TASK-006) needs a connected registry or mock data for testing. + +## Next Checkpoints +- After TASK-001: re-run Playwright E2E tests to verify data flows +- After TASK-002 + TASK-006: version/hotfix create → list → view flow should work end-to-end +- After all tasks: full CRUD across all release control pages diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts index 62e91d473..5c6b60db8 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-deployment/create-deployment.component.ts @@ -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.'; + } } diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-version/create-version.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-version/create-version.component.ts index 8b8ad052c..9038848e5 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-version/create-version.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-version/create-version.component.ts @@ -75,6 +75,15 @@ import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api'; + + Promotion Lane * + + Dev → Stage → Prod (standard) + Stage → Prod (skip dev) + + The promotion path this version will follow + + Description @@ -220,6 +229,7 @@ import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api'; Name{{ form.name }} Version{{ form.version }} + Promotion Lane{{ form.promotionLane === 'dev-stage-prod' ? 'Dev → Stage → Prod' : 'Stage → Prod' }} Description{{ form.description || 'none' }} @@ -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', }); }, diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts index 1e41ae413..7baf570f9 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts @@ -143,7 +143,14 @@ interface ReloadOptions { template: ` @if (loading() && !release()) { Loading release workbench... } - @if (error()) { {{ error() }} } + @if (error()) { + + + Version not found + {{ error() }} + Back to Versions + + } @if (release()) { @@ -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))); diff --git a/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts index c138f0632..d252b7e99 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts @@ -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(MOCK_RELEASES); + readonly releases = computed(() => { + 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 = { + pass: 'pass', warn: 'warn', block: 'block', + pending: 'warn', unknown: 'warn', + }; + return map[gate] ?? 'warn'; + } + + private mapRiskTier(tier: string): PipelineRelease['riskTier'] { + const map: Record = { + 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 = { + verified: 'verified', partial: 'partial', missing: 'missing', + replay_mismatch: 'partial', unknown: 'missing', + }; + return map[posture] ?? 'missing'; + } } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannels.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannels.cs index 2e94f7dd7..76dbfbbc4 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannels.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeChannels.cs @@ -211,9 +211,11 @@ internal static class ElkEdgeChannels if (sinkBandsByEdgeId.ContainsKey(sorted[index].Id)) { var sourceNode = positionedNodes[sorted[index].SourceNodeId]; - var isGatewaySource = string.Equals(sourceNode.Kind, "Decision", StringComparison.OrdinalIgnoreCase) - || string.Equals(sourceNode.Kind, "Fork", StringComparison.OrdinalIgnoreCase); - if (isGatewaySource) + var hasOtherForwardEdge = sorted.Length > 1 + || forwardEdgesBySource.TryGetValue(sorted[index].SourceNodeId, out var allSourceEdges) + && allSourceEdges.Any(e => !sinkBandsByEdgeId.ContainsKey(e.Id)); + var isNonChainSource = !string.Equals(sourceNode.Kind, "Task", StringComparison.OrdinalIgnoreCase); + if (hasOtherForwardEdge && isNonChainSource) { sinkBand = (-1, 0, 0d, double.NaN); } diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs index c52582a01..349a546a5 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouter.cs @@ -80,10 +80,44 @@ internal static class ElkEdgeRouter targetPoint = new ElkPoint { X = adjustedX, Y = targetNode.Y }; } + string? multiSideOverride = null; + if (string.IsNullOrWhiteSpace(edge.TargetPortId) + && direction == ElkLayoutDirection.LeftToRight + && (string.Equals(targetNode.Kind, "End", StringComparison.OrdinalIgnoreCase) + || string.Equals(targetNode.Kind, "Join", StringComparison.OrdinalIgnoreCase))) + { + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var verticalOffset = sourceCenterY - targetCenterY; + var threshold = targetNode.Height * 0.4d; + if (verticalOffset < -threshold) + { + targetPoint = new ElkPoint { X = targetNode.X + (targetNode.Width / 2d), Y = targetNode.Y }; + multiSideOverride = "NORTH"; + } + else if (verticalOffset > threshold) + { + targetPoint = new ElkPoint { X = targetNode.X + (targetNode.Width / 2d), Y = targetNode.Y + targetNode.Height }; + multiSideOverride = "SOUTH"; + } + } + var bendPoints = direction == ElkLayoutDirection.LeftToRight ? ElkEdgeRouterBendPoints.BuildHorizontalBendPoints(sourceNode, targetNode, sourcePoint, targetPoint, graphBounds, channel, layerBoundariesByNodeId) : ElkEdgeRouterBendPoints.BuildVerticalBendPoints(sourceNode, targetNode, sourcePoint, targetPoint, graphBounds, channel, layerBoundariesByNodeId); + if (multiSideOverride is not null) + { + var approachList = bendPoints is List list ? list : new List(bendPoints); + var approachOffset = 24d; + var approachX = targetNode.X + (targetNode.Width / 2d); + var approachY = multiSideOverride == "NORTH" + ? targetNode.Y - approachOffset + : targetNode.Y + targetNode.Height + approachOffset; + approachList.Add(new ElkPoint { X = approachX, Y = approachY }); + bendPoints = approachList; + } + var routedKind = channel.RouteMode == EdgeRouteMode.BackwardOuter ? $"backward|usc={channel.UseSourceCollector}|sox={channel.SharedOuterX:F0}" : edge.Kind; @@ -192,10 +226,17 @@ internal static class ElkEdgeRouter { if (positionedNodes.TryGetValue(dummyId, out var dummyPos)) { + var centerY = dummyPos.Y + (dummyPos.Height / 2d); + if (channel.RouteMode == EdgeRouteMode.Direct + && (centerY > graphBounds.MaxY + 8d || centerY < graphBounds.MinY - 8d)) + { + continue; + } + bendPoints.Add(new ElkPoint { X = dummyPos.X + (dummyPos.Width / 2d), - Y = dummyPos.Y + (dummyPos.Height / 2d), + Y = centerY, }); } } @@ -213,6 +254,32 @@ internal static class ElkEdgeRouter var targetAnchor = ElkEdgeRouterAnchors.ComputeSmartAnchor(targetNode, bendPoints.Count > 0 ? bendPoints[^1] : null, false, targetEntryY, targetGroup?.Length ?? 1, direction); + if (direction == ElkLayoutDirection.LeftToRight + && (string.Equals(targetNode.Kind, "End", StringComparison.OrdinalIgnoreCase) + || string.Equals(targetNode.Kind, "Join", StringComparison.OrdinalIgnoreCase))) + { + var sourceCenterY = sourceNode.Y + (sourceNode.Height / 2d); + var targetCenterY = targetNode.Y + (targetNode.Height / 2d); + var verticalOffset = sourceCenterY - targetCenterY; + var sideThreshold = targetNode.Height * 0.4d; + if (verticalOffset < -sideThreshold) + { + targetAnchor = ElkEdgeRouterAnchors.ResolvePreferredAnchorPoint( + targetNode, sourceNode.X + sourceNode.Width, targetNode.Y - 256d, "NORTH", direction); + var approachOffset = 24d; + var approachX = targetNode.X + (targetNode.Width / 2d); + bendPoints.Add(new ElkPoint { X = approachX, Y = targetNode.Y - approachOffset }); + } + else if (verticalOffset > sideThreshold) + { + targetAnchor = ElkEdgeRouterAnchors.ResolvePreferredAnchorPoint( + targetNode, sourceNode.X + sourceNode.Width, targetNode.Y + targetNode.Height + 256d, "SOUTH", direction); + var approachOffset = 24d; + var approachX = targetNode.X + (targetNode.Width / 2d); + bendPoints.Add(new ElkPoint { X = approachX, Y = targetNode.Y + targetNode.Height + approachOffset }); + } + } + reconstructed[edge.Id] = new ElkRoutedEdge { Id = edge.Id, diff --git a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterBendPoints.cs b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterBendPoints.cs index 7c8343714..0522da137 100644 --- a/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterBendPoints.cs +++ b/src/__Libraries/StellaOps.ElkSharp/ElkEdgeRouterBendPoints.cs @@ -222,8 +222,8 @@ internal static class ElkEdgeRouterBendPoints var targetApproachX = Math.Max(sourceExitX + 24d, targetBoundary.MinX - 32d); var outerY = graphBounds.MaxY + 32d + ElkEdgeChannelBands.ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex)); - var horizontalSpan = targetApproachX - sourceExitX; - if (horizontalSpan > 200d) + var corridorSpan = targetApproachX - sourceExitX; + if (corridorSpan > 200d) { return ElkLayoutHelpers.NormalizeBendPoints( new ElkPoint { X = targetApproachX, Y = startPoint.Y }, @@ -256,8 +256,8 @@ internal static class ElkEdgeRouterBendPoints ? graphBounds.MinY - 56d - ElkEdgeChannelBands.ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex), 36d, 28d) : channel.PreferredOuterY + ElkEdgeChannelBands.ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex), 28d, 24d); - var topHorizontalSpan = targetApproachX - sourceExitX; - if (topHorizontalSpan > 200d) + var topCorridorSpan = targetApproachX - sourceExitX; + if (topCorridorSpan > 200d) { return ElkLayoutHelpers.NormalizeBendPoints( new ElkPoint { X = targetApproachX, Y = startPoint.Y }, diff --git a/src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.sln b/src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.sln new file mode 100644 index 000000000..3a53a5377 --- /dev/null +++ b/src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StellaOps.ElkSharp", "StellaOps.ElkSharp.csproj", "{C14F76B1-AFCA-BAF5-D682-EFA322DF1D6E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C14F76B1-AFCA-BAF5-D682-EFA322DF1D6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C14F76B1-AFCA-BAF5-D682-EFA322DF1D6E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C14F76B1-AFCA-BAF5-D682-EFA322DF1D6E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C14F76B1-AFCA-BAF5-D682-EFA322DF1D6E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {4342DA81-8C0D-4763-BDF9-77353C422BA9} + EndGlobalSection +EndGlobal
Loading release workbench...
{{ error() }}