From b6bf113b99de302776320356d70269f556860477 Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 31 Mar 2026 23:52:32 +0300 Subject: [PATCH] feat(web): harden split release promotion handoff Signed-off-by: master <> --- .../SPRINT_20260322_001_FE_wizard_split.md | 67 ---- .../S00_endpoint_contract_ledger_v1.md | 3 +- .../approval-detail-page.component.ts | 8 +- .../bundle-version-detail.component.ts | 23 +- .../promotions/create-promotion.component.ts | 54 ++- .../release-flow-launchpad.component.ts | 312 ++++++++++++++++++ .../releases/releases.routes.ts | 8 +- .../releases/release-detail-page.component.ts | 43 ++- .../releases-unified-page.component.ts | 31 +- .../environments-command.component.ts | 4 +- .../src/app/routes/releases.routes.ts | 10 +- .../routes/route-surface-ownership.spec.ts | 66 ++-- .../stella-helper-tips.config.ts | 167 +++++++--- .../approval-detail-page.component.spec.ts | 49 ++- ...e-version-detail-promotion-handoff.spec.ts | 65 ++++ .../release-control-routes.spec.ts | 20 +- .../release-flow-launchpad.component.spec.ts | 48 +++ ...ease-detail-page-promotion-handoff.spec.ts | 107 ++++++ .../release-promotions-cutover.spec.ts | 59 ++++ 19 files changed, 953 insertions(+), 191 deletions(-) delete mode 100644 docs/implplan/SPRINT_20260322_001_FE_wizard_split.md create mode 100644 src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-flow-launchpad.component.ts create mode 100644 src/Web/StellaOps.Web/src/tests/release-control/bundle-version-detail-promotion-handoff.spec.ts create mode 100644 src/Web/StellaOps.Web/src/tests/release-control/release-flow-launchpad.component.spec.ts create mode 100644 src/Web/StellaOps.Web/src/tests/releases/release-detail-page-promotion-handoff.spec.ts diff --git a/docs/implplan/SPRINT_20260322_001_FE_wizard_split.md b/docs/implplan/SPRINT_20260322_001_FE_wizard_split.md deleted file mode 100644 index 54da573e3..000000000 --- a/docs/implplan/SPRINT_20260322_001_FE_wizard_split.md +++ /dev/null @@ -1,67 +0,0 @@ -# Sprint 20260322-001 — Split Create Wizard into Version / Hotfix / Release - -## Topic & Scope -- Split the monolithic "Create Release" wizard into 3 distinct wizards matching DevOps concepts. -- **Version**: artifact definition (name, version, images, scripts). No deployment info. -- **Hotfix**: single emergency package (one image + tag). Minimal. -- **Release**: deployment plan. Picks a Version or Hotfix, then configures WHERE (regions, envs) and HOW (stages, strategy). If hotfix → no stages, just target env. If version → requires promotion stages. -- If Version/Hotfix doesn't exist during Release creation → inline creation within the same page. -- Working directory: `src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/` - -## Dependencies & Concurrency -- Tasks are sequential (shared component first, then 3 wizards, then routes). - -## Delivery Tracker - -### TASK-001 - Create Version wizard -Status: TODO -Owners: FE -Task description: -- New component: `create-version.component.ts` -- Steps: 1) Name + Version + Description 2) Components (images + scripts) with autocomplete 3) Review & Seal -- Autocomplete: name defaults to last used or generic, version auto-increments -- Component search uses existing registry API -- No regions, no stages, no strategy, no deployment config -- Route: `/releases/versions/new` - -### TASK-002 - Create Hotfix wizard -Status: TODO -Owners: FE -Task description: -- New component: `create-hotfix.component.ts` -- Single step or 2 steps: 1) Pick one Docker image + tag 2) Confirm -- No name (derives from image), no version (uses digest) -- Minimal, fast-track flow -- Route: `/releases/hotfixes/new` - -### TASK-003 - Create Release wizard -Status: TODO -Owners: FE -Task description: -- New component: `create-release.component.ts` (replaces old wizard) -- Steps: 1) Pick Version or Hotfix (with inline create option) 2) Target (regions, envs, stages) 3) Strategy config 4) Review & Create -- If Version selected → stages required (Dev → Stage → Prod) -- If Hotfix selected → just target env, no stages -- Inline create: if version/hotfix doesn't exist, expand an inline creation form -- Route: `/releases/new` - -### TASK-004 - Update routes and navigation -Status: TODO -Owners: FE -Task description: -- `/releases/versions/new` → CreateVersionComponent -- `/releases/hotfixes/new` → CreateHotfixComponent -- `/releases/new` → CreateReleaseComponent -- Update sidebar "New Version" page action to point to `/releases/versions/new` -- Update pipeline page "New Release" to point to `/releases/new` -- Remove old `create-release.component.ts` or rename - -## Execution Log -| Date (UTC) | Update | Owner | -| --- | --- | --- | -| 2026-03-22 | Sprint created. | Planning | - -## Decisions & Risks -- Old create-release component will be replaced, not refactored (too intertwined). -- Inline version/hotfix creation within release wizard is complex — may use dialog or expandable section. -- Custom scripts support deferred to follow-up sprint. diff --git a/docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v1.md b/docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v1.md index 647bf92d1..e38c87c97 100644 --- a/docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v1.md +++ b/docs/modules/ui/v2-rewire/S00_endpoint_contract_ledger_v1.md @@ -14,6 +14,7 @@ Sprint: `20260218_005`, task `R0-06` - Backend administration adapters now cover Pack-21 A0-A7 (`/api/v1/administration/{summary,identity-access,tenant-branding,notifications,usage-limits,policy-governance,trust-signing,system}`), so `S00-T05-ADM-01` is reclassified to `EXISTS_COMPAT`. - Trust owner mutation routes for keys/issuers/certificates/transparency log are implemented under `/api/v1/administration/trust-signing/*` with `platform.trust.write` / `platform.trust.admin`, backed by Platform DB migration `046_TrustSigningAdministration.sql`. - Readiness reconciliation is recorded in `S16_release_readiness_package.md`. +- Frontend release creation was re-aligned on 2026-03-31: `/releases/new` is now a split-flow handoff page, `/releases/promotions/create` is the canonical target/gate/approval flow, and `/releases/deployments/new` remains only as a compatibility redirect into promotions. ## Status class definitions @@ -29,7 +30,7 @@ Sprint: `20260218_005`, task `R0-06` | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | Dashboard | Dashboard v3 mission board | `source-of-truth.md 3.2`, `authority-matrix.md A: Dashboard`, `pack-16.md` | `/` (control-plane/dashboard variants) | `GET /api/v1/dashboard/summary`; existing promotion, approval, and scan summary endpoints | `EXISTS_COMPAT` | `Web` (composition) + `ReleaseOrchestrator`, `Policy`, `Scanner` | No new scopes; requires existing viewer scopes | Implemented in Platform pack adapters with deterministic data-confidence, CritR env breakdown, B/I/R coverage, and top-driver fields consumed by dashboard v3 cards | Route finalized to `/api/v1/dashboard/summary`; validated by `PackAdapterEndpointsTests` | `S00-T05-DASH-01` | | Release Control | Bundle catalog/detail/builder | `source-of-truth.md 3.1`, `authority-matrix.md A: bundles`, `pack-12.md` | `/release-control/bundles/*` | `GET /api/v1/release-control/bundles`; `GET /api/v1/release-control/bundles/{bundleId}`; `GET /api/v1/release-control/bundles/{bundleId}/versions`; `GET /api/v1/release-control/bundles/{bundleId}/versions/{versionId}`; `POST /api/v1/release-control/bundles`; `POST /api/v1/release-control/bundles/{bundleId}/versions`; `POST /api/v1/release-control/bundles/{bundleId}/versions/{versionId}/materialize` | `EXISTS_COMPAT` | `Platform` (`StellaOps.Platform.WebService`) | `orch:read` (read routes), `orch:operate` (create/publish/materialize) | Implemented with Postgres-backed lifecycle tables (`release.control_bundles*`) plus deterministic list ordering and idempotent materialization key handling | Collision with Evidence bundle export routes resolved by dedicated `/api/v1/release-control/*` namespace; frontend bundle surfaces are now API-bound (see sprint `20260219_003` RC3-06) | `S00-T05-RC-01` | -| Release Control | Promotions list/create/detail | `source-of-truth.md 3.1`, `authority-matrix.md A: releases`, `pack-13.md` | `/release-control/promotions/*` | `GET /api/release-jobengine/approvals` (list); `GET /api/release-jobengine/approvals/{id}` (detail); `GET /api/release-jobengine/releases/{releaseId}/available-environments` (target preflight); `GET /api/release-jobengine/releases/{releaseId}/promotion-preview` (gate preflight); `POST /api/release-jobengine/releases/{releaseId}/promote` (create); `POST /api/release-jobengine/approvals/{id}/approve`; `POST /api/release-jobengine/approvals/{id}/reject` | `EXISTS_COMPAT` | `ReleaseOrchestrator` | Existing `orch:read` / `orch:operate` | Legacy promotion/approval payloads are enriched with manifest digest, risk snapshot, hybrid reachability coverage, ops confidence, and decision digest via `ApprovalEndpoints.WithDerivedSignals` | Contract fields verified by `ReleaseControlV2EndpointsTests`; Pack 13 digest-first promotion cards no longer depend on frontend-only gap placeholders | `S00-T05-RC-02` | +| Release Control | Promotions list/create/detail | `source-of-truth.md 3.1`, `authority-matrix.md A: releases`, `pack-13.md` | `/releases/promotions/*` | `GET /api/release-jobengine/approvals` (list); `GET /api/release-jobengine/approvals/{id}` (detail); `GET /api/release-jobengine/releases/{releaseId}/available-environments` (target preflight); `GET /api/release-jobengine/releases/{releaseId}/promotion-preview` (gate preflight); `POST /api/release-jobengine/releases/{releaseId}/promote` (create); `POST /api/release-jobengine/approvals/{id}/approve`; `POST /api/release-jobengine/approvals/{id}/reject` | `EXISTS_COMPAT` | `ReleaseOrchestrator` | Existing `orch:read` / `orch:operate` | Legacy promotion/approval payloads are enriched with manifest digest, risk snapshot, hybrid reachability coverage, ops confidence, and decision digest via `ApprovalEndpoints.WithDerivedSignals` | Contract fields verified by `ReleaseControlV2EndpointsTests`; `/releases/new` now hands users into this surface and `/releases/deployments/new` is retained only as a compatibility redirect. Bundle/version/hotfix context may be preserved on the handoff page, but the FE no longer aliases those identities into the promotion API's `releaseId` parameter. | `S00-T05-RC-02` | | Release Control | Run timeline, checkpoints, rollback | `source-of-truth.md 3.1`, `authority-matrix.md A: run timeline`, `pack-14.md` | `/deployments/*` and run views | `GET /api/v1/runs/{id}` (run detail); `GET /api/v1/runs/{id}/steps` (step list); `GET /api/v1/runs/{id}/steps/{stepId}` (step detail + logs); `POST /api/v1/runs/{id}/rollback` (trigger rollback) | `EXISTS_COMPAT` | `ReleaseOrchestrator` | Existing `orch:read` / `orch:operate` | Implemented v2 run contracts include ordered checkpoints plus explicit evidence-thread and log-artifact links; rollback returns deterministic accepted payload with guard state | `/api/v1/runs/*` and `/v1/runs/*` compatibility routes are live and test-backed; policy-coupled rollback guard hardening remains future work | `S00-T05-RUN-01` | | Approvals | Approvals v2 tabs and decision packet | `source-of-truth.md 3.3`, `authority-matrix.md A: approvals`, `pack-17.md` | `/approvals/*` | `GET /api/v1/approvals` (queue); `GET /api/v1/approvals/{id}` (detail); `GET /api/v1/approvals/{id}/gates` (gate trace); `GET /api/v1/approvals/{id}/evidence` (evidence packet); `GET /api/v1/approvals/{id}/security-snapshot` (security tab data); `GET /api/v1/approvals/{id}/ops-health` (ops/data tab); `POST /api/v1/approvals/{id}/decision` (approve/reject/defer/escalate) | `EXISTS_COMPAT` | `Policy` + `ReleaseOrchestrator` | Existing policy reviewer / approver scopes | v2 approvals adapter routes now return deterministic decision-packet shapes containing digest, gate trace, security snapshot (risk + B/I/R), and ops/data confidence payloads | Deterministic ordering and contract fields are verified in `ReleaseControlV2EndpointsTests` (queue determinism, gate ordering, decision mutation, not-found behavior) | `S00-T05-APR-01` | | Environment | Environment detail standard tabs | `source-of-truth.md 3.1 and 3.6`, `authority-matrix.md A: env detail`, `pack-18.md` | `/environments/*` | `GET /api/v1/environments/{id}` (detail); `GET /api/v1/environments/{id}/deployments` (deployment history); `GET /api/v1/environments/{id}/security-snapshot` (security state); `GET /api/v1/environments/{id}/evidence` (evidence summary); `GET /api/v1/environments/{id}/ops-health` (data confidence) | `EXISTS_COMPAT` | `ReleaseOrchestrator` | Existing `orch:read` | Pack-18 environment tab contracts are implemented with standardized header fields (manifest digest, risk snapshot, B/I/R coverage, ops confidence) and deterministic deployment ordering | Environment adapters are live under `/api/v1/environments/*` and validated in `ReleaseControlV2EndpointsTests` | `S00-T05-ENV-01` | diff --git a/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts index c367f8586..94acfeca8 100644 --- a/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts @@ -33,6 +33,7 @@ const APPROVAL_DETAIL_TABS: StellaPageTab[] = [ interface ApprovalDetailState { id: string; + releaseId: string; bundleVersion: string; bundleDigest: string; sourceEnvironment: string; @@ -1548,6 +1549,7 @@ export class ApprovalDetailPageComponent implements OnInit { readonly approval = signal({ id: 'apr-001', + releaseId: 'rel-001', bundleVersion: 'Platform Bundle 1.3.0-rc1', bundleDigest: 'sha256:7aa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9', sourceEnvironment: 'EU-West/stage', @@ -1737,6 +1739,10 @@ export class ApprovalDetailPageComponent implements OnInit { this.route.queryParamMap.subscribe((queryParamMap) => { this.scopeQueryParams.set(this.mapQueryParams(queryParamMap)); + const releaseId = queryParamMap.get('releaseId')?.trim(); + if (releaseId) { + this.approval.update((state) => ({ ...state, releaseId })); + } }); } @@ -1818,7 +1824,7 @@ export class ApprovalDetailPageComponent implements OnInit { return this.handoffQueryParams({ approvalId: approval.id, - releaseId: approval.bundleVersion, + releaseId: approval.releaseId, environment: approval.targetEnvironment, artifact: approval.bundleDigest, returnTo, diff --git a/src/Web/StellaOps.Web/src/app/features/bundles/bundle-version-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/bundles/bundle-version-detail.component.ts index 147e4acfa..2eb56e0f8 100644 --- a/src/Web/StellaOps.Web/src/app/features/bundles/bundle-version-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/bundles/bundle-version-detail.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { BundleOrganizerApi, @@ -73,14 +73,14 @@ const BUNDLE_VERSION_TABS: readonly StellaPageTab[] = [

Release definition sealed successfully

Sealing locks the release definition — its components, contract inputs, and policy pin are now immutable. - To deploy this release, request a promotion to the target environment. Promotion enters the deployment - workflow where policy gates, approvals, and materialization checks are evaluated before any changes reach - an environment. + Continue through the promotion handoff to reach targeting, gate preview, approvals, and launch. + The current promotion API still requires a release id, so Stella preserves this bundle/version context + without coercing it into that slot.

- Request Promotion + Open Promotion Handoff View all promotions Back to versions
@@ -160,7 +160,9 @@ const BUNDLE_VERSION_TABS: readonly StellaPageTab[] = [

{{ materializeError }}

} View all releases - Create promotion from this version + + Open promotion handoff for this version + } @@ -435,6 +437,11 @@ export class BundleVersionDetailComponent implements OnInit { readonly targetEnvironment = signal(''); readonly materializeMessage = signal(null); readonly materializeError = signal(null); + readonly promotionHandoffQueryParams = computed>(() => ({ + bundleId: this.bundleId(), + versionId: this.versionId(), + returnTo: `/releases/bundles/${this.bundleId()}/versions/${this.versionId()}`, + })); ngOnInit(): void { this.bundleId.set(this.route.snapshot.params['bundleId'] ?? ''); diff --git a/src/Web/StellaOps.Web/src/app/features/promotions/create-promotion.component.ts b/src/Web/StellaOps.Web/src/app/features/promotions/create-promotion.component.ts index 5e6bdb1c5..215e6b711 100644 --- a/src/Web/StellaOps.Web/src/app/features/promotions/create-promotion.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/promotions/create-promotion.component.ts @@ -47,6 +47,15 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6; } + @if (targetEnvironmentHint()) { +
+ Target environment handoff detected for {{ targetEnvironmentHint() }}. + @if (!releaseId().trim()) { + Enter a release identity to load the compatible promotion path. + } +
+ } +
@for (step of steps; track step.number) {
(1); readonly releaseId = signal(''); readonly targetEnvironmentId = signal(''); + readonly preferredTargetEnvironmentId = signal(''); readonly urgency = signal('normal'); readonly justification = signal(''); readonly scheduledTime = signal(''); @@ -556,8 +566,17 @@ export class CreatePromotionComponent implements OnInit { ]; readonly selectedEnvironmentLabel = computed(() => { - const env = this.environments().find((item) => item.id === this.targetEnvironmentId()); - return env ? `${env.name} (${env.tier})` : '-'; + const targetEnvironmentId = this.targetEnvironmentId().trim(); + if (!targetEnvironmentId) { + return '-'; + } + + const env = this.environments().find((item) => item.id === targetEnvironmentId); + return env ? `${env.name} (${env.tier})` : targetEnvironmentId; + }); + + readonly targetEnvironmentHint = computed(() => { + return this.targetEnvironmentId().trim() || this.preferredTargetEnvironmentId().trim(); }); readonly materializationState = computed(() => { @@ -596,13 +615,21 @@ export class CreatePromotionComponent implements OnInit { ngOnInit(): void { const releaseId = this.route.snapshot.queryParamMap.get('releaseId')?.trim() ?? ''; - const targetEnvironmentId = this.route.snapshot.queryParamMap.get('targetEnvironmentId')?.trim() ?? ''; + const targetEnvironmentId = + this.route.snapshot.queryParamMap.get('targetEnvironmentId')?.trim() ?? + this.route.snapshot.queryParamMap.get('environment')?.trim() ?? + ''; const returnTo = this.route.snapshot.queryParamMap.get('returnTo')?.trim() ?? ''; if (returnTo.length > 0) { this.returnTo.set(returnTo); } + if (targetEnvironmentId.length > 0) { + this.targetEnvironmentId.set(targetEnvironmentId); + this.preferredTargetEnvironmentId.set(targetEnvironmentId); + } + if (!releaseId) { return; } @@ -656,6 +683,11 @@ export class CreatePromotionComponent implements OnInit { return; } + const targetEnvironmentCandidate = + preferredTargetEnvironmentId?.trim() || + this.preferredTargetEnvironmentId().trim() || + this.targetEnvironmentId().trim(); + this.loadingEnvironments.set(true); this.error.set(null); @@ -674,16 +706,28 @@ export class CreatePromotionComponent implements OnInit { this.promoteActiveStep(2); } - if (preferredTargetEnvironmentId && items.some((item) => item.id === preferredTargetEnvironmentId)) { - this.targetEnvironmentId.set(preferredTargetEnvironmentId); + if ( + targetEnvironmentCandidate.length > 0 && + items.some((item) => item.id === targetEnvironmentCandidate) + ) { + this.targetEnvironmentId.set(targetEnvironmentCandidate); + this.preferredTargetEnvironmentId.set(targetEnvironmentCandidate); this.promoteActiveStep(4); this.loadPreview(); + return; + } + + if (targetEnvironmentCandidate.length > 0 && items.length > 0) { + this.targetEnvironmentId.set(''); + this.preview.set(null); + this.error.set('Requested environment is not available for this release.'); } }); } onTargetEnvironmentChange(value: string): void { this.targetEnvironmentId.set(value); + this.preferredTargetEnvironmentId.set(value); this.preview.set(null); if (value) { this.loadPreview(); diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-flow-launchpad.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-flow-launchpad.component.ts new file mode 100644 index 000000000..73bda4403 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-flow-launchpad.component.ts @@ -0,0 +1,312 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, +} from '@angular/core'; +import { ActivatedRoute, RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-release-flow-launchpad', + standalone: true, + imports: [CommonModule, RouterLink], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+

Split release flow

+

Start Release Flow

+

+ Stella keeps release definition separate from promotion. Create a version or hotfix first, + then request promotion for target selection, gate preview, approvals, and launch. +

+
+ + @if (contextItems().length > 0) { +
+ @for (item of contextItems(); track item.label) { +
+ {{ item.label }} + {{ item.value }} +
+ } +
+ } + +
+
+

Canonical path

+

Request Promotion

+

+ Open the promotion wizard to choose a target environment, preview gates, collect + approvals, and launch the release. +

+ @if (releaseId()) { +

+ Carrying forward release identity {{ releaseId() }} into the current API. +

+ } @else if (hasCompatibilityIdentity()) { +

+ Current promotion API still requires a release id. Bundle/version context is preserved + here for handoff but is not injected into the releaseId slot. +

+ } @else { +

+ No release identity was supplied. You can still open the promotion wizard and enter it there. +

+ } + + Open Promotion Flow + +
+ +
+

Definition

+

Create Version

+

+ Build a standard release definition from immutable artifact identity and component selection. +

+ Create Version +
+ +
+

Fast track

+

Create Hotfix

+

+ Capture a single emergency package for the hotfix lane without inventing deployment state. +

+ Create Hotfix +
+
+ +
+ Why this changed +

+ The old combined release and deployment wizard implied backend support for stages and + strategy that the current contract does not persist. Promotions now own target selection + and approvals directly. +

+
+
+ `, + styles: [ + ` + .release-start { + display: grid; + gap: 1rem; + max-width: 980px; + padding: 1.5rem; + } + + .release-start__header h1 { + margin: 0.15rem 0 0.35rem; + font-size: 1.7rem; + } + + .release-start__eyebrow, + .flow-card__eyebrow { + margin: 0; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-secondary, #4b5563); + } + + .release-start__subtitle { + margin: 0; + max-width: 72ch; + color: var(--color-text-secondary, #4b5563); + line-height: 1.5; + } + + .release-start__context { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + } + + .context-chip { + display: grid; + gap: 0.12rem; + min-width: 11rem; + padding: 0.65rem 0.8rem; + border: 1px solid var(--color-border-primary, #d1d5db); + border-radius: var(--radius-md, 10px); + background: var(--color-surface-secondary, #f8fafc); + } + + .context-chip__label { + font-size: 0.68rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-secondary, #4b5563); + } + + .context-chip__value { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .release-start__grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 0.85rem; + } + + .flow-card { + display: grid; + gap: 0.7rem; + padding: 1rem; + border: 1px solid var(--color-border-primary, #d1d5db); + border-radius: var(--radius-lg, 14px); + background: var(--color-surface-primary, #ffffff); + } + + .flow-card--primary { + border-color: var(--color-brand, #1d4ed8); + background: + linear-gradient(135deg, rgba(29, 78, 216, 0.08), transparent 48%), + var(--color-surface-primary, #ffffff); + } + + .flow-card h2 { + margin: 0; + font-size: 1.15rem; + } + + .flow-card p { + margin: 0; + color: var(--color-text-secondary, #4b5563); + line-height: 1.5; + } + + .flow-card__hint code { + font-size: 0.82rem; + } + + .flow-card__action { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.5rem; + padding: 0.55rem 0.95rem; + border: 1px solid var(--color-border-primary, #d1d5db); + border-radius: 999px; + font-weight: 600; + text-decoration: none; + color: var(--color-text-primary, #111827); + background: var(--color-surface-secondary, #f8fafc); + } + + .flow-card__action--primary { + border-color: var(--color-brand, #1d4ed8); + background: var(--color-brand, #1d4ed8); + color: #fff; + } + + .release-start__note { + display: grid; + gap: 0.35rem; + padding: 0.9rem 1rem; + border-radius: var(--radius-md, 10px); + border: 1px solid var(--color-border-primary, #d1d5db); + background: var(--color-surface-secondary, #f8fafc); + } + + .release-start__note p { + margin: 0; + color: var(--color-text-secondary, #4b5563); + line-height: 1.5; + } + + @media (max-width: 640px) { + .release-start { + padding: 1rem; + } + } + `, + ], +}) +export class ReleaseFlowLaunchpadComponent { + private readonly route = inject(ActivatedRoute); + + readonly releaseId = signal(this.readFirstQueryParam(['releaseId'])); + readonly versionId = signal(this.readFirstQueryParam(['versionId'])); + readonly bundleId = signal(this.readFirstQueryParam(['bundleId'])); + readonly hotfixId = signal(this.readFirstQueryParam(['hotfixId'])); + readonly targetEnvironmentId = signal( + this.readFirstQueryParam(['targetEnvironmentId', 'environment']), + ); + readonly returnTo = signal(this.readFirstQueryParam(['returnTo'])); + readonly hasCompatibilityIdentity = computed(() => + Boolean(this.versionId() || this.bundleId() || this.hotfixId()), + ); + + readonly contextItems = computed(() => { + const items: Array<{ label: string; value: string }> = []; + + if (this.releaseId()) { + items.push({ label: 'Release', value: this.releaseId() }); + } + + if (this.versionId()) { + items.push({ label: 'Version', value: this.versionId() }); + } + + if (this.bundleId()) { + items.push({ label: 'Bundle', value: this.bundleId() }); + } + + if (this.hotfixId()) { + items.push({ label: 'Hotfix', value: this.hotfixId() }); + } + + if (this.targetEnvironmentId()) { + items.push({ label: 'Target', value: this.targetEnvironmentId() }); + } + + if (this.returnTo()) { + items.push({ label: 'Return To', value: this.returnTo() }); + } + + return items; + }); + + readonly promotionQueryParams = computed>(() => { + const queryParams: Record = {}; + + if (this.releaseId()) { + queryParams['releaseId'] = this.releaseId(); + } + + if (this.targetEnvironmentId()) { + queryParams['targetEnvironmentId'] = this.targetEnvironmentId(); + } + + if (this.returnTo()) { + queryParams['returnTo'] = this.returnTo(); + } + + return queryParams; + }); + + private readFirstQueryParam(names: readonly string[]): string { + for (const name of names) { + const value = this.route.snapshot.queryParamMap.get(name)?.trim(); + if (value) { + return value; + } + } + + return ''; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/releases.routes.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/releases.routes.ts index 088af3158..39f70edac 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/releases.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/releases.routes.ts @@ -15,8 +15,8 @@ export const RELEASE_ROUTES: Routes = [ { path: 'new', loadComponent: () => - import('./create-deployment/create-deployment.component').then( - (m) => m.CreateDeploymentComponent + import('./release-flow-launchpad.component').then( + (m) => m.ReleaseFlowLaunchpadComponent ), }, { @@ -43,8 +43,8 @@ export const RELEASE_ROUTES: Routes = [ { path: 'create-deployment', loadComponent: () => - import('./create-deployment/create-deployment.component').then( - (m) => m.CreateDeploymentComponent + import('../../promotions/create-promotion.component').then( + (m) => m.CreatePromotionComponent ), }, { diff --git a/src/Web/StellaOps.Web/src/app/features/releases/release-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/release-detail-page.component.ts index 793f3b205..d26a5cea7 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/release-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/release-detail-page.component.ts @@ -93,13 +93,13 @@ const TABS: StellaPageTab[] = [
@if (store.canDeploy()) { - + } @if (store.showPromote()) { Promote @@ -477,6 +477,35 @@ export class ReleaseDetailPageComponent { private _activityFetched = false; readonly release = this.store.selectedRelease; + readonly promotionRequestQueryParams = computed(() => { + const id = this.releaseId(); + if (!id) { + return {}; + } + + return { + releaseId: id, + returnTo: this.detailReturnTo(), + }; + }); + readonly deploymentRequestQueryParams = computed(() => { + const id = this.releaseId(); + if (!id) { + return {}; + } + + const queryParams: Record = { + releaseId: id, + returnTo: this.detailReturnTo(), + }; + + const targetEnvironment = this.release()?.targetEnvironment?.trim(); + if (targetEnvironment) { + queryParams['targetEnvironmentId'] = targetEnvironment; + } + + return queryParams; + }); readonly releaseActivity = computed(() => { const id = this.releaseId(); @@ -540,7 +569,9 @@ export class ReleaseDetailPageComponent { onDeploy(): void { const id = this.releaseId(); if (id) { - void this.router.navigate(['/releases/deployments/new'], { queryParams: { releaseId: id } }); + void this.router.navigate(['/releases/promotions/create'], { + queryParams: this.deploymentRequestQueryParams(), + }); } } @@ -563,6 +594,10 @@ export class ReleaseDetailPageComponent { }); } + private detailReturnTo(): string { + return `/releases/detail/${this.releaseId()}/${this.activeTab()}`; + } + private loadActivity(): void { this.activityLoading.set(true); const params = new HttpParams().set('limit', '200').set('offset', '0'); 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 69c73066b..8c64f198a 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 @@ -1,11 +1,11 @@ /** * Releases Unified Page — Dual-Panel Layout * - * Left panel: Versions (sealed artifact catalog) - * Right panel: Releases (plans for deployment, filtered by selected version) + * Left panel: release identities prepared for promotion + * Right panel: managed release state filtered by selected identity * - * Clicking a version row filters the releases panel. "+ Release" on a version - * row navigates to create a release from that version. + * Clicking a version row filters the releases panel. "Request Promotion" on a + * version row opens the canonical promotion wizard for that identity. */ import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, ViewChild, effect, inject, signal, computed } from '@angular/core'; @@ -56,7 +56,7 @@ export interface PipelineRelease {

Releases

-

Version catalog and release plans.

+

Release definitions, gate posture, and promotion handoffs.

@@ -93,7 +93,7 @@ export interface PipelineRelease { } @else if (pagedVersions().length === 0) {

No versions match the current filters.

-

Create a new version to start building releases.

+

Create a new version to start a promotion path.

} @else {
@@ -111,7 +111,12 @@ export interface PipelineRelease { {{ v.updatedAt | relativeTime }}
- + Release + Request Promotion
} @@ -159,10 +164,10 @@ export interface PipelineRelease {
@if (selectedVersionId()) {

No releases for this version yet.

-

Click "+ Release" on the version to create one.

+

Use "Request Promotion" on the version to open the canonical handoff.

} @else {

No releases match the current filters.

-

Create a release from a version in the left panel.

+

Create a version or hotfix first, then request promotion from this workspace.

}
} @else { @@ -201,7 +206,7 @@ export interface PipelineRelease {
@if (r.status === 'ready' && r.gateStatus === 'pass') { - + Request Promotion } @if (r.gatePendingApprovals > 0) { @@ -210,7 +215,7 @@ export interface PipelineRelease { Review } @if (r.status === 'deployed' && r.gateStatus === 'pass') { - Promote + Promote }
@@ -511,6 +516,10 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy { } } + promotionQueryParams(release: PipelineRelease): Record { + return { releaseId: release.id }; + } + 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'; diff --git a/src/Web/StellaOps.Web/src/app/features/topology/environments-command.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/environments-command.component.ts index 8b5e6dec4..f9f71ee00 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/environments-command.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/environments-command.component.ts @@ -377,8 +377,8 @@ function buildMockLayout(envs: Environment[], reports: ReadinessReport[]): Topol
@if (grp.allReady) { - - Deploy + + Request Promotion }