Repair live releases deployment detail flows
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user