Repair live releases deployment detail flows

This commit is contained in:
master
2026-03-09 00:09:01 +02:00
parent faf6278941
commit b87ffeb237
10 changed files with 508 additions and 30 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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&regions=us-east&environments=stage`;
const DETAIL_URL = `${BASE_URL}/releases/deployments/DEP-2026-050?tenant=demo-prod&regions=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);
});

View File

@@ -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: `
<div class="deployment-detail">
<header class="page-header">
<a routerLink="/deployments" class="back-link">← Back to Deployments</a>
<a routerLink="/releases/deployments" class="back-link">← Back to Deployments</a>
<div class="header-main">
<div class="header-title-row">
<h1 class="page-title">{{ deployment().id }}</h1>
@@ -55,15 +55,13 @@ interface DeploymentArtifact {
<button type="button" class="btn btn--secondary" (click)="openEvidence()">
Open Evidence
</button>
@if (deployment().status === 'success') {
<button type="button" class="btn btn--secondary" (click)="rollback()">
Rollback
</button>
}
<button type="button" class="btn btn--secondary" (click)="replayVerify()">
Replay Verify
</button>
</div>
@if (actionMessage()) {
<p class="action-status" role="status">{{ actionMessage() }}</p>
}
</header>
<!-- Tabs -->
@@ -291,7 +289,12 @@ interface DeploymentArtifact {
<h3>Deployment Evidence</h3>
<p class="evidence-info">
Evidence for this deployment is sealed and signed.
<a [routerLink]="['/evidence', deployment().evidenceId]">View full evidence packet</a>
<a
[routerLink]="['/evidence/capsules']"
[queryParams]="{ evidenceId: deployment().evidenceId, returnTo: buildReturnToUrl() }"
>
Open evidence workspace
</a>
</p>
<div class="evidence-summary">
<div class="evidence-item">
@@ -307,8 +310,14 @@ interface DeploymentArtifact {
<span class="evidence-badge evidence-badge--success">Yes</span>
</div>
<div class="evidence-item">
<span class="evidence-label">Rekor Entry</span>
<a href="#" class="rekor-link">View in Rekor</a>
<span class="evidence-label">Proof Chain</span>
<a
[routerLink]="['/evidence/proofs']"
[queryParams]="{ evidenceId: deployment().evidenceId, returnTo: buildReturnToUrl() }"
class="rekor-link"
>
Open proof chains
</a>
</div>
</div>
</section>
@@ -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<HTMLDivElement>;
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<string | null>(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<void> {
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();
}
}
}

View File

@@ -54,7 +54,7 @@ interface Deployment {
</a>
</td>
<td>
<a [routerLink]="['/releases', deployment.releaseVersion]">{{ deployment.releaseVersion }}</a>
<span class="release-version">{{ deployment.releaseVersion }}</span>
</td>
<td>{{ deployment.environment }}</td>
<td>
@@ -96,7 +96,7 @@ interface Deployment {
<div>
<dt>Release</dt>
<dd>
<a [routerLink]="['/releases', deployment.releaseVersion]">{{ deployment.releaseVersion }}</a>
<span class="release-version">{{ deployment.releaseVersion }}</span>
</dd>
</div>
<div>
@@ -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); }

View File

@@ -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',

View File

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

View File

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

View File

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