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:
@@ -49,6 +49,65 @@ server {
|
|||||||
|
|
||||||
# --- API reverse proxy (eliminates CORS for same-origin requests) ---
|
# --- 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)
|
# Platform API (direct /api/ prefix for clients using environment.apiBaseUrl)
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://platform.stella-ops.local/api/;
|
proxy_pass http://platform.stella-ops.local/api/;
|
||||||
@@ -227,6 +286,17 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
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.
|
# Environment settings — SPA fetches /platform/envsettings.json at startup.
|
||||||
# sub_filter rewrites Docker-internal hostnames to relative paths so the
|
# sub_filter rewrites Docker-internal hostnames to relative paths so the
|
||||||
# browser routes all API calls through this nginx reverse proxy (CORS fix).
|
# browser routes all API calls through this nginx reverse proxy (CORS fix).
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,6 +2,7 @@ import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/cor
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { SlicePipe } from '@angular/common';
|
import { SlicePipe } from '@angular/common';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { catchError, finalize, map, of, switchMap, throwError } from 'rxjs';
|
||||||
|
|
||||||
import { ReleaseManagementStore } from '../release.store';
|
import { ReleaseManagementStore } from '../release.store';
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
getStrategyLabel,
|
getStrategyLabel,
|
||||||
} from '../../../../core/api/release-management.models';
|
} from '../../../../core/api/release-management.models';
|
||||||
import { PlatformContextStore } from '../../../../core/context/platform-context.store';
|
import { PlatformContextStore } from '../../../../core/context/platform-context.store';
|
||||||
|
import { BundleOrganizerApi } from '../../../bundles/bundle-organizer.api';
|
||||||
|
|
||||||
/* ─── Local mock types ─── */
|
/* ─── Local mock types ─── */
|
||||||
interface MockVersion {
|
interface MockVersion {
|
||||||
@@ -1181,6 +1183,7 @@ const MOCK_HOTFIXES: MockHotfix[] = [
|
|||||||
})
|
})
|
||||||
export class CreateDeploymentComponent {
|
export class CreateDeploymentComponent {
|
||||||
private readonly router = inject(Router);
|
private readonly router = inject(Router);
|
||||||
|
private readonly bundleApi = inject(BundleOrganizerApi);
|
||||||
readonly store = inject(ReleaseManagementStore);
|
readonly store = inject(ReleaseManagementStore);
|
||||||
readonly platformCtx = inject(PlatformContextStore);
|
readonly platformCtx = inject(PlatformContextStore);
|
||||||
|
|
||||||
@@ -1412,18 +1415,48 @@ export class CreateDeploymentComponent {
|
|||||||
|
|
||||||
sealInlineVersion(): void {
|
sealInlineVersion(): void {
|
||||||
if (!this.inlineVersion.name.trim() || !this.inlineVersion.version.trim() || this.inlineComponents.length === 0) return;
|
if (!this.inlineVersion.name.trim() || !this.inlineVersion.version.trim() || this.inlineComponents.length === 0) return;
|
||||||
const mockVersion: MockVersion = {
|
|
||||||
id: `inline-${Date.now()}`,
|
const name = this.inlineVersion.name.trim();
|
||||||
name: this.inlineVersion.name,
|
const version = this.inlineVersion.version.trim();
|
||||||
version: this.inlineVersion.version,
|
const slug = this.toSlug(name);
|
||||||
componentCount: this.inlineComponents.length,
|
const description = `Version ${version}`;
|
||||||
sealedAt: new Date().toISOString(),
|
|
||||||
|
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.submitting.set(true);
|
||||||
this.inlineVersion.name = '';
|
this.submitError.set(null);
|
||||||
this.inlineVersion.version = '';
|
|
||||||
this.inlineComponents = [];
|
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 ───
|
// ─── Inline hotfix creation ───
|
||||||
@@ -1447,21 +1480,55 @@ export class CreateDeploymentComponent {
|
|||||||
const img = this.inlineHotfixImage();
|
const img = this.inlineHotfixImage();
|
||||||
const digest = this.inlineHotfixDigest();
|
const digest = this.inlineHotfixDigest();
|
||||||
if (!img || !digest) return;
|
if (!img || !digest) return;
|
||||||
|
|
||||||
const digestEntry = img.digests.find((d) => d.digest === digest);
|
const digestEntry = img.digests.find((d) => d.digest === digest);
|
||||||
const tag = digestEntry?.tag ?? '';
|
const tag = digestEntry?.tag ?? '';
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
|
const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
|
||||||
const mockHotfix: MockHotfix = {
|
const hotfixName = `${img.name}-hotfix`;
|
||||||
id: `inline-hf-${Date.now()}`,
|
const hotfixTag = tag ? `${tag}-hf.${ts}` : `hf.${ts}`;
|
||||||
name: `${img.name}-hotfix`,
|
const slug = this.toSlug(hotfixName);
|
||||||
image: img.repository,
|
const description = `Hotfix ${hotfixTag} from ${img.repository}`;
|
||||||
tag: tag ? `${tag}-hf.${ts}` : `hf.${ts}`,
|
|
||||||
sealedAt: now.toISOString(),
|
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.submitting.set(true);
|
||||||
this.inlineHotfixImage.set(null);
|
this.submitError.set(null);
|
||||||
this.inlineHotfixDigest.set('');
|
|
||||||
|
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 ───
|
// ─── Target helpers ───
|
||||||
@@ -1570,29 +1637,37 @@ export class CreateDeploymentComponent {
|
|||||||
createDeployment(): void {
|
createDeployment(): void {
|
||||||
if (!this.canCreate()) return;
|
if (!this.canCreate()) return;
|
||||||
|
|
||||||
this.submitError.set(null);
|
|
||||||
this.submitting.set(true);
|
this.submitting.set(true);
|
||||||
|
this.submitError.set(null);
|
||||||
|
|
||||||
const payload = {
|
const pkg = this.packageType() === 'version' ? this.selectedVersion() : this.selectedHotfix();
|
||||||
packageType: this.packageType(),
|
if (!pkg) return;
|
||||||
package: this.packageType() === 'version' ? this.selectedVersion() : this.selectedHotfix(),
|
|
||||||
targets: {
|
const slug = `deploy-${this.toSlug(pkg.name)}-${Date.now()}`;
|
||||||
regions: this.targetRegions(),
|
const description = `Deployment of ${pkg.name} to ${this.getTargetRegionNames().join(', ')} with ${this.deploymentStrategy} strategy`;
|
||||||
environments: this.targetEnvironments(),
|
|
||||||
promotionStages: this.packageType() === 'version' ? this.promotionStages : [],
|
// 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: {
|
error: (error) => {
|
||||||
type: this.deploymentStrategy,
|
this.submitError.set(this.mapCreateError(error));
|
||||||
config: this.getActiveStrategyConfig(),
|
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
|
|
||||||
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 {
|
private getActiveStrategyConfig(): unknown {
|
||||||
@@ -1605,4 +1680,42 @@ export class CreateDeploymentComponent {
|
|||||||
default: return {};
|
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>
|
</label>
|
||||||
</div>
|
</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">
|
<label class="field">
|
||||||
<span class="field__label">Description</span>
|
<span class="field__label">Description</span>
|
||||||
<textarea [(ngModel)]="form.description" rows="3" placeholder="What changed in this version"></textarea>
|
<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">
|
<dl class="review-card__dl">
|
||||||
<dt>Name</dt><dd><strong>{{ form.name }}</strong></dd>
|
<dt>Name</dt><dd><strong>{{ form.name }}</strong></dd>
|
||||||
<dt>Version</dt><dd>{{ form.version }}</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>
|
<dt>Description</dt><dd>{{ form.description || 'none' }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
@@ -564,6 +574,7 @@ export class CreateVersionComponent {
|
|||||||
name: '',
|
name: '',
|
||||||
version: '',
|
version: '',
|
||||||
description: '',
|
description: '',
|
||||||
|
promotionLane: 'dev-stage-prod' as 'dev-stage-prod' | 'stage-prod',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Component adding state
|
// Component adding state
|
||||||
@@ -625,6 +636,16 @@ export class CreateVersionComponent {
|
|||||||
addSelectedComponent(): void {
|
addSelectedComponent(): void {
|
||||||
if (!this.selectedImage || !this.selectedDigest) return;
|
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({
|
this.components.push({
|
||||||
name: this.selectedImage.name,
|
name: this.selectedImage.name,
|
||||||
imageRef: this.selectedImage.repository,
|
imageRef: this.selectedImage.repository,
|
||||||
@@ -673,8 +694,7 @@ export class CreateVersionComponent {
|
|||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: version => {
|
next: version => {
|
||||||
void this.router.navigate(['/releases/bundles', version.bundleId, 'versions', version.id], {
|
void this.router.navigate(['/releases/versions', version.id ?? version.bundleId], {
|
||||||
queryParams: { source: 'version-create', returnTo: '/releases/versions' },
|
|
||||||
queryParamsHandling: 'merge',
|
queryParamsHandling: 'merge',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -143,7 +143,14 @@ interface ReloadOptions {
|
|||||||
template: `
|
template: `
|
||||||
<section class="workbench">
|
<section class="workbench">
|
||||||
@if (loading() && !release()) { <p class="banner">Loading release workbench...</p> }
|
@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()) {
|
@if (release()) {
|
||||||
<header class="header">
|
<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)}
|
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)}
|
.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}}
|
@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 {
|
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({
|
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 }) => {
|
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.detail.set(detail);
|
||||||
this.activity.set([...activity].sort((a,b) => b.occurredAt.localeCompare(a.occurredAt)));
|
this.activity.set([...activity].sort((a,b) => b.occurredAt.localeCompare(a.occurredAt)));
|
||||||
this.approvals.set([...approvals].sort((a,b) => b.requestedAt.localeCompare(a.requestedAt)));
|
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 { StellaFilterChipComponent, FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
|
||||||
import { PaginationComponent, PageChangeEvent } from '../../shared/components/pagination/pagination.component';
|
import { PaginationComponent, PageChangeEvent } from '../../shared/components/pagination/pagination.component';
|
||||||
import { TableColumn } from '../../shared/components/data-table/data-table.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 ──────────────────────────────────────────────────────────────
|
// ── Data model ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -37,80 +40,6 @@ export interface PipelineRelease {
|
|||||||
lastActor: string;
|
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 ───────────────────────────────────────────────────────────────
|
// ── Component ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -431,9 +360,13 @@ const MOCK_RELEASES: PipelineRelease[] = [
|
|||||||
})
|
})
|
||||||
export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
|
export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
|
||||||
private readonly pageAction = inject(PageActionService);
|
private readonly pageAction = inject(PageActionService);
|
||||||
|
private readonly store = inject(ReleaseManagementStore);
|
||||||
|
private readonly context = inject(PlatformContextStore);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.pageAction.set({ label: 'New Release', route: '/releases/new' });
|
this.pageAction.set({ label: 'New Release', route: '/releases/new' });
|
||||||
|
this.context.initialize();
|
||||||
|
this.store.loadReleases({});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
@@ -477,7 +410,26 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────────
|
// ── 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 searchQuery = signal('');
|
||||||
readonly laneFilter = signal('');
|
readonly laneFilter = signal('');
|
||||||
readonly statusFilter = signal('');
|
readonly statusFilter = signal('');
|
||||||
@@ -590,4 +542,37 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
|
|||||||
default: return status;
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,9 +211,11 @@ internal static class ElkEdgeChannels
|
|||||||
if (sinkBandsByEdgeId.ContainsKey(sorted[index].Id))
|
if (sinkBandsByEdgeId.ContainsKey(sorted[index].Id))
|
||||||
{
|
{
|
||||||
var sourceNode = positionedNodes[sorted[index].SourceNodeId];
|
var sourceNode = positionedNodes[sorted[index].SourceNodeId];
|
||||||
var isGatewaySource = string.Equals(sourceNode.Kind, "Decision", StringComparison.OrdinalIgnoreCase)
|
var hasOtherForwardEdge = sorted.Length > 1
|
||||||
|| string.Equals(sourceNode.Kind, "Fork", StringComparison.OrdinalIgnoreCase);
|
|| forwardEdgesBySource.TryGetValue(sorted[index].SourceNodeId, out var allSourceEdges)
|
||||||
if (isGatewaySource)
|
&& 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);
|
sinkBand = (-1, 0, 0d, double.NaN);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,10 +80,44 @@ internal static class ElkEdgeRouter
|
|||||||
targetPoint = new ElkPoint { X = adjustedX, Y = targetNode.Y };
|
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
|
var bendPoints = direction == ElkLayoutDirection.LeftToRight
|
||||||
? ElkEdgeRouterBendPoints.BuildHorizontalBendPoints(sourceNode, targetNode, sourcePoint, targetPoint, graphBounds, channel, layerBoundariesByNodeId)
|
? ElkEdgeRouterBendPoints.BuildHorizontalBendPoints(sourceNode, targetNode, sourcePoint, targetPoint, graphBounds, channel, layerBoundariesByNodeId)
|
||||||
: ElkEdgeRouterBendPoints.BuildVerticalBendPoints(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<ElkPoint> list ? list : new List<ElkPoint>(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
|
var routedKind = channel.RouteMode == EdgeRouteMode.BackwardOuter
|
||||||
? $"backward|usc={channel.UseSourceCollector}|sox={channel.SharedOuterX:F0}"
|
? $"backward|usc={channel.UseSourceCollector}|sox={channel.SharedOuterX:F0}"
|
||||||
: edge.Kind;
|
: edge.Kind;
|
||||||
@@ -192,10 +226,17 @@ internal static class ElkEdgeRouter
|
|||||||
{
|
{
|
||||||
if (positionedNodes.TryGetValue(dummyId, out var dummyPos))
|
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
|
bendPoints.Add(new ElkPoint
|
||||||
{
|
{
|
||||||
X = dummyPos.X + (dummyPos.Width / 2d),
|
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,
|
var targetAnchor = ElkEdgeRouterAnchors.ComputeSmartAnchor(targetNode, bendPoints.Count > 0 ? bendPoints[^1] : null,
|
||||||
false, targetEntryY, targetGroup?.Length ?? 1, direction);
|
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
|
reconstructed[edge.Id] = new ElkRoutedEdge
|
||||||
{
|
{
|
||||||
Id = edge.Id,
|
Id = edge.Id,
|
||||||
|
|||||||
@@ -222,8 +222,8 @@ internal static class ElkEdgeRouterBendPoints
|
|||||||
var targetApproachX = Math.Max(sourceExitX + 24d, targetBoundary.MinX - 32d);
|
var targetApproachX = Math.Max(sourceExitX + 24d, targetBoundary.MinX - 32d);
|
||||||
var outerY = graphBounds.MaxY + 32d + ElkEdgeChannelBands.ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex));
|
var outerY = graphBounds.MaxY + 32d + ElkEdgeChannelBands.ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex));
|
||||||
|
|
||||||
var horizontalSpan = targetApproachX - sourceExitX;
|
var corridorSpan = targetApproachX - sourceExitX;
|
||||||
if (horizontalSpan > 200d)
|
if (corridorSpan > 200d)
|
||||||
{
|
{
|
||||||
return ElkLayoutHelpers.NormalizeBendPoints(
|
return ElkLayoutHelpers.NormalizeBendPoints(
|
||||||
new ElkPoint { X = targetApproachX, Y = startPoint.Y },
|
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)
|
? graphBounds.MinY - 56d - ElkEdgeChannelBands.ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex), 36d, 28d)
|
||||||
: channel.PreferredOuterY + ElkEdgeChannelBands.ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex), 28d, 24d);
|
: channel.PreferredOuterY + ElkEdgeChannelBands.ResolveSinkBandOffset(Math.Max(0, channel.SinkBandIndex), 28d, 24d);
|
||||||
|
|
||||||
var topHorizontalSpan = targetApproachX - sourceExitX;
|
var topCorridorSpan = targetApproachX - sourceExitX;
|
||||||
if (topHorizontalSpan > 200d)
|
if (topCorridorSpan > 200d)
|
||||||
{
|
{
|
||||||
return ElkLayoutHelpers.NormalizeBendPoints(
|
return ElkLayoutHelpers.NormalizeBendPoints(
|
||||||
new ElkPoint { X = targetApproachX, Y = startPoint.Y },
|
new ElkPoint { X = targetApproachX, Y = startPoint.Y },
|
||||||
|
|||||||
24
src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.sln
Normal file
24
src/__Libraries/StellaOps.ElkSharp/StellaOps.ElkSharp.sln
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user