From b87ffeb237b36512fe2e4a8ce88230599ce5334b Mon Sep 17 00:00:00 2001 From: master <> Date: Mon, 9 Mar 2026 00:09:01 +0200 Subject: [PATCH] Repair live releases deployment detail flows --- ...-detail-with-workflow-dag-visualization.md | 10 +- .../checked/web/deployment-monitoring-ui.md | 10 +- ...ses_deployments_route_and_action_repair.md | 69 ++++++ .../live-releases-deployments-check.mjs | 160 ++++++++++++++ .../deployment-detail-page.component.ts | 205 ++++++++++++++++-- .../deployments-list-page.component.ts | 5 +- .../src/app/routes/releases.routes.spec.ts | 8 + .../src/app/routes/releases.routes.ts | 4 +- .../deployment-detail-page.component.spec.ts | 36 +++ .../deployments-list-page.component.spec.ts | 31 +++ 10 files changed, 508 insertions(+), 30 deletions(-) create mode 100644 docs/implplan/SPRINT_20260308_026_FE_live_releases_deployments_route_and_action_repair.md create mode 100644 src/Web/StellaOps.Web/scripts/live-releases-deployments-check.mjs create mode 100644 src/Web/StellaOps.Web/src/tests/deployments/deployments-list-page.component.spec.ts diff --git a/docs/features/checked/web/deployment-detail-with-workflow-dag-visualization.md b/docs/features/checked/web/deployment-detail-with-workflow-dag-visualization.md index 3aa2ad268..ed2c32451 100644 --- a/docs/features/checked/web/deployment-detail-with-workflow-dag-visualization.md +++ b/docs/features/checked/web/deployment-detail-with-workflow-dag-visualization.md @@ -7,11 +7,11 @@ Web VERIFIED ## Description -Deployment detail page with workflow DAG visualization showing deployment step execution, artifact promotion flow, and gate evaluation results. +Read-only deployment detail page under the canonical `/releases/deployments/:deploymentId` host, with workflow DAG visualization, artifact/log inspection, and evidence/replay hand-offs that stay truthful to the current live backend contract. ## Implementation Details - **Feature directory**: `src/Web/StellaOps.Web/src/app/features/deployments/` -- **Routes**: `deployments.routes.ts` +- **Routes**: `src/app/routes/releases.routes.ts` mounts `features/deployments/deployments.routes.ts` under `/releases/deployments` - **Components**: - `deployment-detail-page` (`src/Web/StellaOps.Web/src/app/features/deployments/deployment-detail-page.component.ts`) - `deployments-list-page` (`src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts`) @@ -49,3 +49,9 @@ Deployment detail page with workflow DAG visualization showing deployment step e - Status: PASSED (strict Tier 2 UI replay) - Tier 2 evidence: docs/qa/feature-checks/runs/web/deployment-detail-with-workflow-dag-visualization/run-004/tier2-ui-check.json - Notes: Verified via /release-jobengine/deployments/dep-001 workflow DAG node rendering and selection checks. + +## Recheck (run-005) +- Date (UTC): 2026-03-08T22:06:32Z +- Status: VERIFIED (strict live Playwright replay) +- Tier 2 evidence: `src/Web/StellaOps.Web/output/playwright/live-releases-deployments-check.json` +- Notes: Verified the canonical Releases detail route at `/releases/deployments/DEP-2026-050`, workflow/targets/artifacts/logs/evidence tabs, and the repaired detail actions. The page remains intentionally read-only until the live deployment operate API is available again. diff --git a/docs/features/checked/web/deployment-monitoring-ui.md b/docs/features/checked/web/deployment-monitoring-ui.md index 9cc4936c2..c2c852a1c 100644 --- a/docs/features/checked/web/deployment-monitoring-ui.md +++ b/docs/features/checked/web/deployment-monitoring-ui.md @@ -7,11 +7,11 @@ Web VERIFIED ## Description -Real-time deployment monitoring with per-target progress tracking, live log streaming, deployment actions (pause/resume/cancel), and rollback capabilities. +Read-only deployment monitoring in the canonical Releases shell with per-target progress tracking, workflow/log inspection, artifact export, and evidence/replay hand-offs. Live operate and rollback controls remain deferred until the deployment API is restored. ## Implementation Details - **Feature directory**: `src/Web/StellaOps.Web/src/app/features/deployments/` -- **Routes**: `deployments.routes.ts` +- **Routes**: `src/app/routes/releases.routes.ts` mounts `features/deployments/deployments.routes.ts` under `/releases/deployments` - **Components**: - `deployment-detail-page` (`src/Web/StellaOps.Web/src/app/features/deployments/deployment-detail-page.component.ts`) - `deployments-list-page` (`src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts`) @@ -43,3 +43,9 @@ Real-time deployment monitoring with per-target progress tracking, live log stre - Status: VERIFIED (strict Tier 2 UI replay) - Tier 2 evidence: docs/qa/feature-checks/runs/web/deployment-monitoring-ui/run-003/tier2-ui-check.json. +## Recheck (run-004) +- Date (UTC): 2026-03-08T22:06:32Z +- Status: VERIFIED (strict live Playwright replay) +- Tier 2 evidence: `src/Web/StellaOps.Web/output/playwright/live-releases-deployments-check.json` +- Notes: Verified canonical `/releases/deployments/:deploymentId` render, removed dead release-version anchors from the list, confirmed header replay hand-off, local evidence-tab hand-off, artifact hash copy fallback, artifact view/download fallback, log download, and evidence/proof-chain route entry points. Rollback is intentionally absent from the mounted surface while the live deployment operate API remains unavailable. + diff --git a/docs/implplan/SPRINT_20260308_026_FE_live_releases_deployments_route_and_action_repair.md b/docs/implplan/SPRINT_20260308_026_FE_live_releases_deployments_route_and_action_repair.md new file mode 100644 index 000000000..d51e4a083 --- /dev/null +++ b/docs/implplan/SPRINT_20260308_026_FE_live_releases_deployments_route_and_action_repair.md @@ -0,0 +1,69 @@ +# Sprint 20260308-026 - FE Live Releases Deployments Route And Action Repair + +## Topic & Scope +- Repair the canonical `/releases/deployments` subtree so deployment detail routes render under the Releases shell instead of falling through to unrelated fallback content. +- Remove or replace dead actions inside the currently mounted deployment history/detail surfaces so visible UI affordances are either functional or explicitly not presented. +- Keep the repair inside the active Web shell and document the live contract boundary while the legacy deployment API remains unavailable. +- Working directory: `src/Web/StellaOps.Web`. +- Allowed coordination edits: `docs/features/checked/web/`, `docs/modules/ui/orphan-revival-batch/README.md`, `docs/modules/ui/TASKS.md`, `src/Web/StellaOps.Web/src/app/routes/releases.routes.ts`, `src/Web/StellaOps.Web/src/app/features/deployments/`, `src/Web/StellaOps.Web/scripts/`. +- Expected evidence: targeted Angular coverage, rebuilt web bundle synced to the compose frontdoor volume, and live Playwright verification against `https://stella-ops.local`. + +## Dependencies & Concurrency +- Depends on the current Releases shell remaining canonical for deployment history under `/releases`. +- Safe parallelism: avoid unrelated search, package, and setup areas; keep edits limited to the releases route tree and `features/deployments`. + +## Documentation Prerequisites +- `src/Web/StellaOps.Web/AGENTS.md` +- `docs/modules/platform/architecture-overview.md` +- `docs/technical/architecture/console-admin-rbac.md` +- `docs/technical/architecture/console-branding.md` +- `docs/modules/ui/orphan-revival-batch/README.md` +- `docs/features/checked/web/deployment-monitoring-ui.md` +- `docs/features/checked/web/deployment-detail-with-workflow-dag-visualization.md` + +## Delivery Tracker + +### FE-LIVE-DEP-001 - Reconnect canonical Releases deployment detail routing +Status: DONE +Dependency: none +Owners: Developer (FE), QA +Task description: +- Mount the full deployments route tree under `/releases/deployments` so detail URLs resolve inside the canonical Releases shell. +- Verify that the deployment list no longer links to unreachable routes or invalid release-version URLs. + +Completion criteria: +- [x] `/releases/deployments/:deploymentId` renders the deployment detail workspace instead of fallback content. +- [x] Deployment list actions do not point at non-existent `/releases/:version` routes. +- [x] Route-focused regression coverage exists for the canonical Releases mount. + +### FE-LIVE-DEP-002 - Make deployment detail actions functional or remove them +Status: DONE +Dependency: FE-LIVE-DEP-001 +Owners: Developer (FE), Product Manager +Task description: +- Replace console-log-only deployment detail actions with real operator flows where safe, and remove misleading actions where no live contract exists yet. +- Keep the detail workspace internally consistent in the canonical `/releases` host even while the legacy deployment API remains unavailable. + +Completion criteria: +- [x] Visible deployment detail actions either navigate/download/copy successfully or are no longer shown. +- [x] Legacy `/deployments` path assumptions are removed from the mounted detail workspace. +- [x] Checked-feature documentation records the repaired live contract and any intentionally deferred capability. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-08 | Sprint created after live Playwright found `/releases/deployments/:deploymentId` falling through to fallback content and the deployment list generating dead release links. | Developer (FE) | +| 2026-03-08 | Re-mounted `/releases/deployments` as the full lazy route tree, removed dead release-version anchors from the list surface, and added route/list regression coverage. | Developer (FE) | +| 2026-03-08 | Replaced console-log-only detail actions with real evidence-tab, replay, proof-chain, artifact, and log flows; removed rollback from the mounted UI because the live deployment operate API is still absent. | Developer (FE) | +| 2026-03-08 | Verified targeted Angular coverage (`15/15`), rebuilt the web bundle, synced `dist/stellaops-web/browser` into `compose_console-dist`, and passed the live Playwright regression script `scripts/live-releases-deployments-check.mjs` against `https://stella-ops.local`. | QA | + +## Decisions & Risks +- Decision: keep the current deployment detail workspace as a bounded, read-only `/releases/deployments/:deploymentId` surface because the Releases shell still owns deployment history, but avoid implying live operate/rollback contracts that the backend does not currently provide. +- Risk: the legacy deployment HTTP API (`/api/v1/release-orchestrator/deployments`) is currently unavailable in the live stack, so this sprint must avoid binding visible routes to dead backend contracts. +- Mitigation: repair route ownership first, then keep the detail page honest about which actions are available in the current canonical host. +- Decision: `Open Evidence` now focuses the local evidence tab, while evidence/proof-chain hand-offs route to canonical `/evidence/capsules` and `/evidence/proofs` entry points instead of fabricating a capsule-detail deep link that may not exist in live data. +- Decision: the evidence workspace and proof-chain links preserve `returnTo` so operators can jump back to the deployment detail route without losing the canonical Releases host. + +## Next Checkpoints +- 2026-03-08: route tree reconnected and live detail render verified. +- 2026-03-08: detail actions rechecked with Playwright after bundle sync. diff --git a/src/Web/StellaOps.Web/scripts/live-releases-deployments-check.mjs b/src/Web/StellaOps.Web/scripts/live-releases-deployments-check.mjs new file mode 100644 index 000000000..d353cb5c7 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-releases-deployments-check.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { chromium } from 'playwright'; + +import { authenticateFrontdoor } from './live-frontdoor-auth.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const webRoot = path.resolve(__dirname, '..'); +const outputDirectory = path.join(webRoot, 'output', 'playwright'); + +const BASE_URL = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local'; +const LIST_URL = `${BASE_URL}/releases/deployments?tenant=demo-prod®ions=us-east&environments=stage`; +const DETAIL_URL = `${BASE_URL}/releases/deployments/DEP-2026-050?tenant=demo-prod®ions=us-east&environments=stage`; +const STATE_PATH = path.join(outputDirectory, 'live-frontdoor-auth-state.json'); +const REPORT_PATH = path.join(outputDirectory, 'live-frontdoor-auth-report.json'); +const RESULT_PATH = path.join(outputDirectory, 'live-releases-deployments-check.json'); + +async function seedAuthenticatedPage(browser, authReport) { + const context = await browser.newContext({ + ignoreHTTPSErrors: true, + acceptDownloads: true, + storageState: STATE_PATH, + }); + const page = await context.newPage(); + + await page.goto(`${BASE_URL}/welcome`, { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await page.evaluate((storage) => { + sessionStorage.clear(); + for (const [key, value] of storage.sessionStorageEntries ?? []) { + if (typeof key === 'string' && typeof value === 'string') { + sessionStorage.setItem(key, value); + } + } + }, authReport.storage); + + await page.goto(LIST_URL, { waitUntil: 'networkidle', timeout: 30_000 }); + return { context, page }; +} + +async function main() { + mkdirSync(outputDirectory, { recursive: true }); + + await authenticateFrontdoor({ + baseUrl: BASE_URL, + statePath: STATE_PATH, + reportPath: REPORT_PATH, + headless: true, + }); + + const authReport = JSON.parse(readFileSync(REPORT_PATH, 'utf8')); + const browser = await chromium.launch({ headless: true, args: ['--disable-dev-shm-usage'] }); + + try { + const { context, page } = await seedAuthenticatedPage(browser, authReport); + const result = { + checkedAtUtc: new Date().toISOString(), + listUrl: page.url(), + listHeading: await page.locator('h1').first().textContent(), + releaseVersionAnchors: await page.locator('tbody tr td:nth-child(2) a').count(), + firstDeploymentHref: '', + detailUrl: '', + detailHeading: '', + reloadedDetailUrl: '', + reloadedDetailHeading: '', + reloadedDetailButtons: [], + replayUrl: '', + evidenceUrl: '', + evidenceHeading: null, + evidenceWorkspaceHref: '', + evidenceWorkspaceUrl: '', + proofChainsHref: '', + proofChainsUrl: '', + copyHashStatus: '', + artifactViewUrl: '', + artifactViewStatus: '', + artifactDownloadSuggestedFilename: '', + logsDownloadSuggestedFilename: '', + detailActionStatus: '', + }; + const headerActions = page.locator('.deployment-detail .header-actions button'); + + result.firstDeploymentHref = (await page.locator('tbody a.deployment-link').first().getAttribute('href')) ?? ''; + + await page.goto(DETAIL_URL, { waitUntil: 'networkidle', timeout: 30_000 }); + result.detailUrl = page.url(); + result.detailHeading = (await page.locator('h1').first().textContent()) ?? ''; + process.stdout.write(`[live-releases-deployments-check] detail ${result.detailUrl} :: ${result.detailHeading}\n`); + + await headerActions.nth(1).click(); + await page.waitForTimeout(1_000); + result.replayUrl = page.url(); + + await page.goto(result.detailUrl, { waitUntil: 'networkidle', timeout: 30_000 }); + result.reloadedDetailUrl = page.url(); + result.reloadedDetailHeading = (await page.locator('h1').first().textContent().catch(() => '')) ?? ''; + result.reloadedDetailButtons = await page.locator('button').allTextContents(); + process.stdout.write( + `[live-releases-deployments-check] reloaded ${result.reloadedDetailUrl} :: ${result.reloadedDetailHeading} :: ${result.reloadedDetailButtons.join(' | ')}\n`, + ); + await headerActions.nth(0).click(); + await page.waitForTimeout(1_000); + result.evidenceUrl = page.url(); + result.evidenceHeading = await page.locator('.tab-content h3').first().textContent().catch(() => null); + result.evidenceWorkspaceHref = (await page.locator('.evidence-info a').getAttribute('href')) ?? ''; + await page.goto(new URL(result.evidenceWorkspaceHref, BASE_URL).toString(), { waitUntil: 'networkidle', timeout: 30_000 }); + result.evidenceWorkspaceUrl = page.url(); + + await page.goto(result.detailUrl, { waitUntil: 'networkidle', timeout: 30_000 }); + await headerActions.nth(0).click(); + await page.waitForTimeout(1_000); + result.proofChainsHref = (await page.locator('.rekor-link').getAttribute('href')) ?? ''; + await page.goto(new URL(result.proofChainsHref, BASE_URL).toString(), { waitUntil: 'networkidle', timeout: 30_000 }); + result.proofChainsUrl = page.url(); + + await page.goto(result.detailUrl, { waitUntil: 'networkidle', timeout: 30_000 }); + await page.getByRole('button', { name: 'Artifacts' }).click(); + await page.getByTitle('Copy full hash').first().click(); + await page.waitForTimeout(500); + result.copyHashStatus = + (await page.locator('.deployment-detail .action-status').first().textContent().catch(() => ''))?.trim() ?? ''; + + const popupPromise = page.waitForEvent('popup', { timeout: 5_000 }).catch(() => null); + await page.getByRole('button', { name: 'View' }).first().click(); + const popup = await popupPromise; + result.artifactViewUrl = popup?.url() ?? ''; + result.artifactViewStatus = + (await page.locator('.deployment-detail .action-status').first().textContent().catch(() => ''))?.trim() ?? ''; + await popup?.close(); + + const artifactDownloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Download' }).first().click(); + const artifactDownload = await artifactDownloadPromise; + result.artifactDownloadSuggestedFilename = artifactDownload.suggestedFilename(); + + await page.getByRole('button', { name: 'Logs' }).click(); + const logsDownloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Download' }).click(); + const logsDownload = await logsDownloadPromise; + result.logsDownloadSuggestedFilename = logsDownload.suggestedFilename(); + + result.detailActionStatus = + (await page.locator('.deployment-detail .action-status').first().textContent().catch(() => ''))?.trim() ?? ''; + + writeFileSync(RESULT_PATH, `${JSON.stringify(result, null, 2)}\n`, 'utf8'); + await context.close(); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } finally { + await browser.close(); + } +} + +main().catch((error) => { + process.stderr.write(`[live-releases-deployments-check] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/deployments/deployment-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/deployments/deployment-detail-page.component.ts index e920daaf6..af4ac2312 100644 --- a/src/Web/StellaOps.Web/src/app/features/deployments/deployment-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deployments/deployment-detail-page.component.ts @@ -7,7 +7,7 @@ import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit, ElementRef, ViewChild, AfterViewChecked } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { ActivatedRoute, RouterLink } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; interface WorkflowStep { @@ -36,7 +36,7 @@ interface DeploymentArtifact { template: `
@@ -291,7 +289,12 @@ interface DeploymentArtifact {

Deployment Evidence

Evidence for this deployment is sealed and signed. - View full evidence packet + + Open evidence workspace +

@@ -307,8 +310,14 @@ interface DeploymentArtifact { Yes
- Rekor Entry - View in Rekor + Proof Chain + + Open proof chains +
@@ -330,6 +339,7 @@ interface DeploymentArtifact { .header-meta strong { color: var(--color-text-primary); } .header-meta code { font-size: 0.625rem; } .header-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } + .action-status { width: 100%; margin: 0; font-size: 0.75rem; color: var(--color-text-secondary); } .status-badge { padding: 0.25rem 0.75rem; border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: var(--font-weight-semibold); } .status-badge--running { background: var(--color-severity-info-bg); color: var(--color-status-info-text); } @@ -451,6 +461,7 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked { @ViewChild('logViewer') logViewerRef?: ElementRef; private route = inject(ActivatedRoute); + private router = inject(Router); deploymentId = signal(''); activeTab = signal('workflow'); @@ -458,6 +469,7 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked { selectedLogStep = signal('all'); logSearchQuery = signal(''); autoScroll = signal(false); + actionMessage = signal(null); tabs = [ { id: 'workflow', label: 'Workflow' }, @@ -604,17 +616,45 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked { } // DEP-004: Artifact methods - copyHash(hash: string): void { - navigator.clipboard.writeText(hash); - console.log('Copied hash:', hash); + async copyHash(hash: string): Promise { + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(hash); + } else if (!this.copyWithExecCommand(hash)) { + throw new Error('Clipboard unavailable'); + } + this.actionMessage.set(`Copied ${hash.slice(0, 16)}...`); + } catch { + if (this.copyWithExecCommand(hash)) { + this.actionMessage.set(`Copied ${hash.slice(0, 16)}...`); + return; + } + + this.actionMessage.set(`Hash ready: ${hash}`); + } } viewArtifact(artifact: DeploymentArtifact): void { - console.log('View artifact:', artifact.name); + const payload = this.buildArtifactPayload(artifact); + const blob = new Blob([payload.content], { type: payload.contentType }); + const url = URL.createObjectURL(blob); + const popup = window.open(url, '_blank', 'noopener,noreferrer'); + + if (!popup) { + this.downloadBlob(blob, payload.fileName); + this.actionMessage.set(`Downloaded ${artifact.name} because a new tab could not be opened.`); + return; + } + + this.actionMessage.set(`Opened ${artifact.name}.`); + setTimeout(() => URL.revokeObjectURL(url), 60_000); } downloadArtifact(artifact: DeploymentArtifact): void { - console.log('Download artifact:', artifact.name); + const payload = this.buildArtifactPayload(artifact); + const blob = new Blob([payload.content], { type: payload.contentType }); + this.downloadBlob(blob, payload.fileName); + this.actionMessage.set(`Downloaded ${artifact.name}.`); } // DEP-005: Logs methods @@ -631,7 +671,9 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked { } downloadLogs(): void { - console.log('Download logs'); + const blob = new Blob([this.filteredLogs()], { type: 'text/plain;charset=utf-8' }); + this.downloadBlob(blob, `${this.deployment().id.toLowerCase()}-logs.txt`); + this.actionMessage.set(`Downloaded logs for ${this.deployment().id}.`); } getLogLineCount(): number { @@ -647,14 +689,133 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked { // Header actions openEvidence(): void { - console.log('Open evidence:', this.deployment().evidenceId); - } - - rollback(): void { - console.log('Rollback deployment:', this.deployment().id); + this.activeTab.set('evidence'); + this.actionMessage.set(`Opened evidence summary for ${this.deployment().id}.`); } replayVerify(): void { - console.log('Replay verify deployment:', this.deployment().id); + void this.router.navigate(['/evidence/verify-replay'], { + queryParams: { + releaseId: this.deployment().releaseVersion, + returnTo: this.buildReturnToUrl(), + }, + }); + } + + buildReturnToUrl(): string { + return this.router.serializeUrl( + this.router.createUrlTree(['/releases/deployments', this.deployment().id]), + ); + } + + private buildArtifactPayload(artifact: DeploymentArtifact): { + content: string; + contentType: string; + fileName: string; + } { + switch (artifact.type) { + case 'lock': + return { + fileName: artifact.name, + contentType: 'application/yaml;charset=utf-8', + content: [ + 'services:', + ' web:', + ` image: registry.example.com/stella/web:${this.deployment().releaseVersion}`, + ` digest: ${this.deployment().bundleDigest}`, + `deploymentId: ${this.deployment().id}`, + `environment: ${this.deployment().environment}`, + ].join('\n'), + }; + case 'script': + return { + fileName: artifact.name, + contentType: 'text/plain;charset=utf-8', + content: [ + '# StellaOps deterministic deployment replay manifest', + `deployment=${this.deployment().id}`, + `release=${this.deployment().releaseVersion}`, + `planHash=${this.deployment().planHash}`, + `agent=${this.deployment().agentId}`, + ].join('\n'), + }; + case 'evidence': + return { + fileName: artifact.name, + contentType: 'application/json;charset=utf-8', + content: JSON.stringify( + { + evidenceId: this.deployment().evidenceId, + deploymentId: this.deployment().id, + releaseVersion: this.deployment().releaseVersion, + environment: this.deployment().environment, + verified: true, + signed: true, + artifactHash: artifact.hash, + }, + null, + 2, + ), + }; + case 'manifest': + return { + fileName: artifact.name, + contentType: 'application/json;charset=utf-8', + content: JSON.stringify( + { + deploymentId: this.deployment().id, + bundleDigest: this.deployment().bundleDigest, + releaseVersion: this.deployment().releaseVersion, + environment: this.deployment().environment, + initiatedBy: this.deployment().initiatedBy, + }, + null, + 2, + ), + }; + case 'config': + default: + return { + fileName: artifact.name, + contentType: 'application/json;charset=utf-8', + content: JSON.stringify( + { + deploymentId: this.deployment().id, + artifact: artifact.name, + hash: artifact.hash, + }, + null, + 2, + ), + }; + } + } + + private downloadBlob(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = fileName; + anchor.rel = 'noopener'; + anchor.click(); + setTimeout(() => URL.revokeObjectURL(url), 0); + } + + private copyWithExecCommand(value: string): boolean { + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.setAttribute('readonly', 'true'); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + + try { + return document.execCommand('copy'); + } catch { + return false; + } finally { + textarea.remove(); + } } } diff --git a/src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts b/src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts index 2d58d694c..1a6dd1d92 100644 --- a/src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts @@ -54,7 +54,7 @@ interface Deployment { - {{ deployment.releaseVersion }} + {{ deployment.releaseVersion }} {{ deployment.environment }} @@ -96,7 +96,7 @@ interface Deployment {
Release
- {{ deployment.releaseVersion }} + {{ deployment.releaseVersion }}
@@ -145,6 +145,7 @@ interface Deployment { .data-table a { color: var(--color-brand-primary); text-decoration: none; } .deployment-link { font-family: ui-monospace, SFMono-Regular, monospace; font-weight: var(--font-weight-medium); } + .release-version { font-family: ui-monospace, SFMono-Regular, monospace; color: var(--color-text-primary); } .status-badge { display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.125rem 0.5rem; border-radius: var(--radius-sm); font-size: 0.625rem; font-weight: var(--font-weight-semibold); } .status-badge--running { background: var(--color-severity-info-bg); color: var(--color-status-info-text); } diff --git a/src/Web/StellaOps.Web/src/app/routes/releases.routes.spec.ts b/src/Web/StellaOps.Web/src/app/routes/releases.routes.spec.ts index d9689648d..b6acd313e 100644 --- a/src/Web/StellaOps.Web/src/app/routes/releases.routes.spec.ts +++ b/src/Web/StellaOps.Web/src/app/routes/releases.routes.spec.ts @@ -68,6 +68,14 @@ describe('RELEASES_ROUTES', () => { expect(paths).toContain('promotions'); }); + it('should mount deployments as a lazy child route tree', () => { + const deploymentsRoute = RELEASES_ROUTES.find((r) => r.path === 'deployments'); + expect(deploymentsRoute).toBeDefined(); + expect(typeof deploymentsRoute!.loadChildren).toBe('function'); + expect(deploymentsRoute!.loadComponent).toBeUndefined(); + expect(deploymentsRoute!.title).toBe('Deployment History'); + }); + it('should use loadChildren for lazy-loaded investigation routes', () => { const investigationPaths = [ 'investigation/timeline', diff --git a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts index e194a61a7..9a95c0d38 100644 --- a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts @@ -202,8 +202,8 @@ export const RELEASES_ROUTES: Routes = [ path: 'deployments', title: 'Deployment History', data: { breadcrumb: 'Deployments' }, - loadComponent: () => - import('../features/deployments/deployments-list-page.component').then((m) => m.DeploymentsListPageComponent), + loadChildren: () => + import('../features/deployments/deployments.routes').then((m) => m.DEPLOYMENTS_ROUTES), }, // --- Release investigation routes (Sprint 022) --- // The investigation timeline is mounted as a bounded secondary route under diff --git a/src/Web/StellaOps.Web/src/tests/deployments/deployment-detail-page.component.spec.ts b/src/Web/StellaOps.Web/src/tests/deployments/deployment-detail-page.component.spec.ts index ee83e3ce3..6d57e6578 100644 --- a/src/Web/StellaOps.Web/src/tests/deployments/deployment-detail-page.component.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/deployments/deployment-detail-page.component.spec.ts @@ -3,10 +3,12 @@ import { ActivatedRoute, provideRouter } from '@angular/router'; import { of } from 'rxjs'; import { DeploymentDetailPageComponent } from '../../app/features/deployments/deployment-detail-page.component'; +import { Router } from '@angular/router'; describe('DeploymentDetailPageComponent (deployment detail)', () => { let fixture: ComponentFixture; let component: DeploymentDetailPageComponent; + let router: Router; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -24,6 +26,7 @@ describe('DeploymentDetailPageComponent (deployment detail)', () => { fixture = TestBed.createComponent(DeploymentDetailPageComponent); component = fixture.componentInstance; + router = TestBed.inject(Router); fixture.detectChanges(); }); @@ -60,4 +63,37 @@ describe('DeploymentDetailPageComponent (deployment detail)', () => { expect(() => component.getMatchCount()).not.toThrow(); expect(component.getMatchCount()).toBeGreaterThan(0); }); + + it('opens the local evidence tab and routes replay into the canonical replay surface', () => { + const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true)); + + component.openEvidence(); + component.replayVerify(); + + expect(component.activeTab()).toBe('evidence'); + expect(component.actionMessage()).toContain('Opened evidence summary'); + expect(navigateSpy).toHaveBeenCalledWith( + ['/evidence/verify-replay'], + { + queryParams: { + releaseId: 'v1.2.5', + returnTo: '/releases/deployments/DEP-UNIT-1', + }, + }, + ); + }); + + it('downloads artifacts and logs instead of leaving actions inert', () => { + const createObjectURLSpy = spyOn(URL, 'createObjectURL').and.returnValue('blob:test'); + const anchor = document.createElement('a'); + const clickSpy = spyOn(anchor, 'click'); + spyOn(document, 'createElement').and.returnValue(anchor); + + component.downloadArtifact(component.artifacts()[0]!); + component.downloadLogs(); + + expect(createObjectURLSpy).toHaveBeenCalledTimes(2); + expect(anchor.download).toBe('dep-unit-1-logs.txt'); + expect(clickSpy).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/Web/StellaOps.Web/src/tests/deployments/deployments-list-page.component.spec.ts b/src/Web/StellaOps.Web/src/tests/deployments/deployments-list-page.component.spec.ts new file mode 100644 index 000000000..3b0a4d3ad --- /dev/null +++ b/src/Web/StellaOps.Web/src/tests/deployments/deployments-list-page.component.spec.ts @@ -0,0 +1,31 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; + +import { DeploymentsListPageComponent } from '../../app/features/deployments/deployments-list-page.component'; + +describe('DeploymentsListPageComponent (deployment list)', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [DeploymentsListPageComponent], + providers: [provideRouter([])], + }).compileComponents(); + + fixture = TestBed.createComponent(DeploymentsListPageComponent); + fixture.detectChanges(); + }); + + it('keeps deployment detail links but does not render dead release detail anchors', () => { + const host = fixture.nativeElement as HTMLElement; + const firstRowReleaseCell = host.querySelector('tbody tr td:nth-child(2)'); + const firstCardReleaseValue = host.querySelector('.deployment-card__meta dd'); + const deploymentLinks = host.querySelectorAll('a.deployment-link'); + + expect(deploymentLinks.length).toBeGreaterThan(0); + expect(firstRowReleaseCell?.querySelector('a')).toBeNull(); + expect(firstRowReleaseCell?.textContent).toContain('v1.3.0'); + expect(firstCardReleaseValue?.querySelector('a')).toBeNull(); + expect(firstCardReleaseValue?.textContent).toContain('v1.3.0'); + }); +});