Update navigation, layout, and feature pages for DevOps onboarding

Reorganize sidebar navigation, update topology/releases/platform
feature pages, and add environments command component. Improve
dashboard, security overview, and mission control pages.

- Navigation config: restructured groups and route mappings
- Sidebar: collapsible sections, preference persistence
- Topology: environments command component, detail page updates,
  remove readiness-dashboard (superseded)
- Releases: unified page, activity, and ops overview updates
- Platform ops/setup page improvements
- E2e specs for navigation, environments, and release workflows
- Nav model and route integrity test updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-30 17:25:19 +03:00
parent 89a075ea21
commit 9c79b00598
33 changed files with 5599 additions and 2432 deletions

View File

@@ -0,0 +1,455 @@
/**
* Environment Detail Page — E2E Tests
*
* Verifies the overhauled environment detail page with 9 tabs:
* Overview, Targets, Readiness, Runs, Agents, Security, Evidence, Drift, Data Quality.
* Tests header, stat cards, gate grid, status badges, date formatting, and actions.
*/
import { test, expect } from './fixtures/auth.fixture';
import { navigateAndWait } from './helpers/nav.helper';
import type { Page, Route } from '@playwright/test';
// ─── Mock data ──────────────────────────────────────────────────────
const ENV_ID = 'env-eu-prod';
const MOCK_ENVIRONMENTS = {
items: [
{ environmentId: ENV_ID, displayName: 'EU Production', regionId: 'eu-west', environmentType: 'production', sortOrder: 0, targetCount: 3, hostCount: 2, agentCount: 2, promotionPathCount: 1, workflowCount: 1, lastSyncAt: new Date().toISOString() },
{ environmentId: 'env-eu-stage', displayName: 'EU Staging', regionId: 'eu-west', environmentType: 'staging', sortOrder: 1, targetCount: 2, hostCount: 1, agentCount: 1, promotionPathCount: 1, workflowCount: 1, lastSyncAt: new Date().toISOString() },
],
};
const MOCK_TARGETS = {
items: [
{ targetId: 't-1', name: 'eu-prod-app-01', regionId: 'eu-west', environmentId: ENV_ID, hostId: 'h-1', agentId: 'a-1', targetType: 'docker_host', healthStatus: 'healthy', componentVersionId: 'cv-1', imageDigest: 'sha256:abc123def456', releaseId: 'r-1', releaseVersionId: 'rv-1', lastSyncAt: new Date(Date.now() - 300_000).toISOString() },
{ targetId: 't-2', name: 'eu-prod-app-02', regionId: 'eu-west', environmentId: ENV_ID, hostId: 'h-1', agentId: 'a-1', targetType: 'docker_host', healthStatus: 'healthy', componentVersionId: 'cv-1', imageDigest: 'sha256:abc123def456', releaseId: 'r-1', releaseVersionId: 'rv-1', lastSyncAt: new Date(Date.now() - 600_000).toISOString() },
{ targetId: 't-3', name: 'eu-prod-worker-01', regionId: 'eu-west', environmentId: ENV_ID, hostId: 'h-2', agentId: 'a-2', targetType: 'docker_host', healthStatus: 'unhealthy', componentVersionId: 'cv-1', imageDigest: 'sha256:xyz789', releaseId: 'r-1', releaseVersionId: 'rv-2', lastSyncAt: new Date(Date.now() - 3_600_000).toISOString() },
],
};
const MOCK_HOSTS = {
items: [
{ hostId: 'h-1', hostName: 'prod-host-alpha', regionId: 'eu-west', environmentId: ENV_ID, runtimeType: 'docker', status: 'healthy', agentId: 'a-1', targetCount: 2, lastSeenAt: new Date().toISOString() },
{ hostId: 'h-2', hostName: 'prod-host-beta', regionId: 'eu-west', environmentId: ENV_ID, runtimeType: 'docker', status: 'degraded', agentId: 'a-2', targetCount: 1, lastSeenAt: new Date(Date.now() - 7_200_000).toISOString() },
],
};
const MOCK_AGENTS = {
items: [
{ agentId: 'a-1', agentName: 'agent-eu-01', regionId: 'eu-west', environmentId: ENV_ID, status: 'active', capabilities: ['docker', 'compose'], assignedTargetCount: 2, lastHeartbeatAt: new Date(Date.now() - 30_000).toISOString() },
{ agentId: 'a-2', agentName: 'agent-eu-02', regionId: 'eu-west', environmentId: ENV_ID, status: 'degraded', capabilities: ['docker'], assignedTargetCount: 1, lastHeartbeatAt: new Date(Date.now() - 900_000).toISOString() },
],
};
function gate(name: string, status: string, msg: string) {
return { gateName: name, status, message: msg, checkedAt: new Date().toISOString(), durationMs: 100 };
}
const MOCK_READINESS = {
items: [
{ targetId: 't-1', environmentId: ENV_ID, isReady: true, evaluatedAt: new Date().toISOString(), gates: [
gate('agent_bound', 'pass', 'OK'), gate('docker_version_ok', 'pass', 'Docker 24'), gate('docker_ping_ok', 'pass', 'OK'),
gate('registry_pull_ok', 'pass', 'OK'), gate('vault_reachable', 'pass', 'OK'), gate('consul_reachable', 'pass', 'OK'), gate('connectivity_ok', 'pass', 'All pass'),
]},
{ targetId: 't-2', environmentId: ENV_ID, isReady: true, evaluatedAt: new Date().toISOString(), gates: [
gate('agent_bound', 'pass', 'OK'), gate('docker_version_ok', 'pass', 'Docker 24'), gate('docker_ping_ok', 'pass', 'OK'),
gate('registry_pull_ok', 'pass', 'OK'), gate('vault_reachable', 'pass', 'OK'), gate('consul_reachable', 'pass', 'OK'), gate('connectivity_ok', 'pass', 'All pass'),
]},
{ targetId: 't-3', environmentId: ENV_ID, isReady: false, evaluatedAt: new Date().toISOString(), gates: [
gate('agent_bound', 'pass', 'OK'), gate('docker_version_ok', 'pass', 'Docker 24'), gate('docker_ping_ok', 'pass', 'OK'),
gate('registry_pull_ok', 'fail', 'Connection refused'), gate('vault_reachable', 'pass', 'OK'), gate('consul_reachable', 'pass', 'OK'),
gate('connectivity_ok', 'fail', 'registry_pull_ok failed'),
]},
],
};
const MOCK_RUNS = {
items: [
{ activityId: 'run-1', releaseId: 'r-1', releaseName: 'v2.4.1', status: 'deployed', correlationKey: 'ck-1', occurredAt: new Date(Date.now() - 3_600_000).toISOString(), durationMs: 45000 },
{ activityId: 'run-2', releaseId: 'r-2', releaseName: 'v2.4.0', status: 'failed', correlationKey: 'ck-2', occurredAt: new Date(Date.now() - 86_400_000).toISOString(), durationMs: 12000 },
],
};
const MOCK_FINDINGS = {
items: [
{ findingId: 'f-1', cveId: 'CVE-2024-1234', severity: 'critical', effectiveDisposition: 'action_required', cvss: 9.8, reachable: true },
{ findingId: 'f-2', cveId: 'CVE-2024-5678', severity: 'medium', effectiveDisposition: 'not_affected', cvss: 5.3, reachable: false },
],
};
const MOCK_CAPSULES = {
items: [
{ capsuleId: 'cap-abc123def456', status: 'complete', updatedAt: new Date(Date.now() - 7_200_000).toISOString(), signatureStatus: 'signed', contentTypes: ['SBOM', 'Attestation'] },
{ capsuleId: 'cap-xyz789ghi012', status: 'stale', updatedAt: new Date(Date.now() - 604_800_000).toISOString(), signatureStatus: 'verified', contentTypes: ['Log'] },
],
};
const MOCK_PROMOTION_PATHS = {
items: [
{ pathId: 'pp-1', regionId: 'eu-west', sourceEnvironmentId: 'env-eu-stage', targetEnvironmentId: ENV_ID, pathMode: 'auto', status: 'active', requiredApprovals: 0, workflowId: 'w-1', gateProfileId: 'gp-1', lastPromotedAt: new Date(Date.now() - 86_400_000).toISOString() },
],
};
const SCREENSHOT_DIR = 'e2e/screenshots/environment-detail';
async function snap(page: Page, label: string) {
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
}
function collectErrors(page: Page): string[] {
const errors: string[] = [];
page.on('console', msg => { if (msg.type() === 'error') errors.push(msg.text()); });
page.on('pageerror', err => errors.push(err.message));
return errors;
}
async function setupMocks(page: Page) {
// Catch-alls first (lowest priority)
await page.route('**/api/v2/releases/**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_RUNS) });
});
await page.route('**/api/v2/security/**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_FINDINGS) });
});
await page.route('**/api/v2/evidence/**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CAPSULES) });
});
// Context APIs
await page.route('**/api/v2/context/regions', (route: Route) => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }));
await page.route('**/api/v2/context/preferences', (route: Route) => route.fulfill({ status: 200, contentType: 'application/json', body: '{"regions":[],"environments":[]}' }));
await page.route('**/api/v2/context/environments**', (route: Route) => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }));
// Validate target
await page.route('**/api/v1/targets/*/validate', (route: Route) => {
const targetId = route.request().url().match(/targets\/([^/]+)\/validate/)?.[1] ?? '';
const rpt = MOCK_READINESS.items.find(r => r.targetId === targetId) ?? MOCK_READINESS.items[0];
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(rpt) });
});
// Readiness
await page.route('**/api/v1/environments/*/readiness', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_READINESS) });
});
// Topology APIs (registered last = highest priority)
await page.route('**/api/v2/topology/promotion-paths**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_PROMOTION_PATHS) });
});
await page.route('**/api/v2/topology/hosts**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_HOSTS) });
});
await page.route('**/api/v2/topology/agents**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_AGENTS) });
});
await page.route('**/api/v2/topology/targets**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_TARGETS) });
});
await page.route('**/api/v2/topology/layout**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ nodes: [], edges: [], metadata: { regionCount: 0, environmentCount: 0, promotionPathCount: 0, canvasWidth: 0, canvasHeight: 0 } }) });
});
await page.route('**/api/v2/topology/environments**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_ENVIRONMENTS) });
});
}
const DETAIL_URL = `/environments/environments/${ENV_ID}`;
// ─── Tests ──────────────────────────────────────────────────────────
test.describe('Environment Detail Page', () => {
test('header shows environment name, health badge, back link, and actions', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await setupMocks(page);
await navigateAndWait(page, `${DETAIL_URL}?tab=overview`);
await page.waitForTimeout(2000);
// Back link
const back = page.locator('.hdr__back');
await expect(back).toBeVisible();
await expect(back).toContainText('Environments');
// Environment name
await expect(page.locator('.hdr__title-row h1')).toContainText('EU Production');
// Health badge
const healthBadge = page.locator('.hdr__title-row app-status-badge').first();
await expect(healthBadge).toBeVisible();
// Deploy button in header
await expect(page.locator('.hdr__actions a:has-text("Deploy")')).toBeVisible();
// Refresh button
await expect(page.locator('button:has-text("Refresh")')).toBeVisible();
await snap(page, '01-header');
const critical = errors.filter(e => e.includes('NG0') || e.includes('TypeError'));
expect(critical).toHaveLength(0);
});
test('overview tab shows metric cards with correct values', async ({ authenticatedPage: page }) => {
await setupMocks(page);
await navigateAndWait(page, `${DETAIL_URL}?tab=overview`);
await page.waitForTimeout(2000);
// Metric cards should be present (4 in the stat group)
const metricCards = page.locator('app-metric-card');
expect(await metricCards.count()).toBeGreaterThanOrEqual(4);
// Health circle should be visible
const healthCircle = page.locator('.health-circle');
await expect(healthCircle).toBeVisible();
// Quick stats
const quickStats = page.locator('.quick-stats');
await expect(quickStats).toBeVisible();
await snap(page, '02-overview');
});
test('overview tab shows blockers when unhealthy targets exist', async ({ authenticatedPage: page }) => {
await setupMocks(page);
await navigateAndWait(page, `${DETAIL_URL}?tab=overview`);
await page.waitForTimeout(2000);
// Should have blockers (1 unhealthy target + 1 blocking finding + 1 failing readiness + 1 degraded agent)
const blockerItems = page.locator('.blocker-item');
expect(await blockerItems.count()).toBeGreaterThanOrEqual(1);
// Each blocker should have a status badge
const badges = page.locator('.blocker-item app-status-badge');
expect(await badges.count()).toBeGreaterThanOrEqual(1);
await snap(page, '03-blockers');
});
test('targets tab shows display names instead of GUIDs', async ({ authenticatedPage: page }) => {
await setupMocks(page);
await navigateAndWait(page, `${DETAIL_URL}?tab=targets`);
await page.waitForTimeout(2000);
// Should show host display names, not raw GUIDs
const tableText = await page.locator('table').innerText();
expect(tableText).toContain('prod-host-alpha');
expect(tableText).toContain('agent-eu-01');
// Should NOT show raw GUID h-1 or a-1
expect(tableText).not.toContain('h-1');
// Health status should use StatusBadge
const statusBadges = page.locator('table app-status-badge');
expect(await statusBadges.count()).toBe(3); // 3 targets
await snap(page, '04-targets');
});
test('readiness tab shows gate grid with status badges', async ({ authenticatedPage: page }) => {
await setupMocks(page);
await navigateAndWait(page, `${DETAIL_URL}?tab=readiness`);
await page.waitForTimeout(2000);
// Summary metric cards (Ready/Failing/Pending)
const metrics = page.locator('app-metric-card');
expect(await metrics.count()).toBe(3);
// Gate grid table should have 3 target rows
const rows = page.locator('table tbody tr');
expect(await rows.count()).toBe(3);
// Validate All button
const validateAll = page.locator('button:has-text("Validate All")');
await expect(validateAll).toBeVisible();
// Per-row Validate buttons
const validateBtns = page.locator('button:has-text("Validate")');
expect(await validateBtns.count()).toBeGreaterThanOrEqual(3); // 3 per-target + 1 Validate All
await snap(page, '05-readiness');
});
test('readiness tab has validate buttons for each target', async ({ authenticatedPage: page }) => {
await setupMocks(page);
await navigateAndWait(page, `${DETAIL_URL}?tab=readiness`);
await page.waitForTimeout(2000);
// Should have per-row Validate buttons (one per target)
const validateBtns = page.locator('table tbody .btn--xs');
expect(await validateBtns.count()).toBe(3); // 3 targets
// Validate All button should be visible
const validateAll = page.locator('.panel__hdr button:has-text("Validate All")');
await expect(validateAll).toBeVisible();
// Buttons should not be disabled initially
const firstBtn = validateBtns.first();
await expect(firstBtn).not.toBeDisabled();
});
test('runs tab shows formatted dates and status badges', async ({ authenticatedPage: page }) => {
await setupMocks(page);
await navigateAndWait(page, `${DETAIL_URL}?tab=deployments`);
await page.waitForTimeout(2000);
const tableText = await page.locator('table').innerText();
// Should show release name
expect(tableText).toContain('v2.4.1');
expect(tableText).toContain('v2.4.0');
// Status badges
const badges = page.locator('table app-status-badge');
expect(await badges.count()).toBe(2);
// Should have "View" links
const viewLinks = page.locator('table a:has-text("View")');
expect(await viewLinks.count()).toBe(2);
// Dates should be relative
expect(tableText).toMatch(/ago|just now/);
await snap(page, '06-runs');
});
test('agents tab shows status badges and relative heartbeat times', async ({ authenticatedPage: page }) => {
await setupMocks(page);
await navigateAndWait(page, `${DETAIL_URL}?tab=agents`);
await page.waitForTimeout(2000);
const tableText = await page.locator('table').innerText();
expect(tableText).toContain('agent-eu-01');
expect(tableText).toContain('agent-eu-02');
// Status badges (active + degraded)
const badges = page.locator('table app-status-badge');
expect(await badges.count()).toBe(2);
// Heartbeat should show relative time, not raw ISO
// (RelativeTimePipe converts to "X minutes ago" etc.)
expect(tableText).toMatch(/ago|just now|seconds|minutes|hours|days/);
await snap(page, '07-agents');
});
test('security tab shows CVSS, reachability, and links', async ({ authenticatedPage: page }) => {
await setupMocks(page);
await navigateAndWait(page, `${DETAIL_URL}?tab=security`);
await page.waitForTimeout(2000);
const tableText = await page.locator('table').innerText();
// CVE IDs
expect(tableText).toContain('CVE-2024-1234');
expect(tableText).toContain('CVE-2024-5678');
// CVSS scores
expect(tableText).toContain('9.8');
expect(tableText).toContain('5.3');
// Severity badges
const badges = page.locator('table app-status-badge');
expect(await badges.count()).toBeGreaterThanOrEqual(4); // severity + reachable per row
// View links
const viewLinks = page.locator('table a:has-text("View")');
expect(await viewLinks.count()).toBe(2);
await snap(page, '08-security');
});
test('evidence tab shows card grid with signed badges', async ({ authenticatedPage: page }) => {
await setupMocks(page);
await navigateAndWait(page, `${DETAIL_URL}?tab=evidence`);
await page.waitForTimeout(2000);
// Evidence cards
const cards = page.locator('.ev-card');
expect(await cards.count()).toBe(2);
// Content type badges
const typeBadges = page.locator('.ev-type');
expect(await typeBadges.count()).toBe(2);
// Signature badges
const sigBadges = page.locator('.ev-badges app-status-badge');
expect(await sigBadges.count()).toBeGreaterThanOrEqual(2);
await snap(page, '09-evidence');
});
test('drift tab detects version divergence across targets', async ({ authenticatedPage: page }) => {
await setupMocks(page);
await navigateAndWait(page, `${DETAIL_URL}?tab=drift`);
await page.waitForTimeout(2000);
// t-1 and t-2 have rv-1, t-3 has rv-2 — drift should be detected
const driftAlert = page.locator('.drift-alert');
await expect(driftAlert).toBeVisible();
// Should mention drifted target count
const alertText = await driftAlert.innerText();
expect(alertText).toContain('1 target');
await snap(page, '10-drift');
});
test('data quality tab shows metric cards with severity coloring', async ({ authenticatedPage: page }) => {
await setupMocks(page);
await navigateAndWait(page, `${DETAIL_URL}?tab=data-quality`);
await page.waitForTimeout(2000);
const metricCards = page.locator('app-metric-card');
expect(await metricCards.count()).toBe(4);
await snap(page, '11-data-quality');
});
test('tab bar shows status dots and badge counts', async ({ authenticatedPage: page }) => {
await setupMocks(page);
await navigateAndWait(page, `${DETAIL_URL}?tab=overview`);
await page.waitForTimeout(2000);
// Tab bar should exist with 9 tabs
const tabs = page.locator('stella-page-tabs button[role="tab"]');
expect(await tabs.count()).toBe(9);
await snap(page, '12-tab-bar');
});
test('no Angular errors across all tabs', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await setupMocks(page);
const tabs: string[] = ['overview', 'targets', 'readiness', 'deployments', 'agents', 'security', 'evidence', 'drift', 'data-quality'];
for (const tab of tabs) {
await navigateAndWait(page, `${DETAIL_URL}?tab=${tab}`);
await page.waitForTimeout(1500);
}
const critical = errors.filter(e => e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError'));
expect(critical, 'Critical errors: ' + critical.join('\n')).toHaveLength(0);
await snap(page, '13-no-errors');
});
test('promotion path context shown in header', async ({ authenticatedPage: page }) => {
await setupMocks(page);
await navigateAndWait(page, `${DETAIL_URL}?tab=overview`);
await page.waitForTimeout(2000);
// Promotion line should show upstream env
const promoLine = page.locator('.hdr__promo');
if (await promoLine.isVisible({ timeout: 3000 }).catch(() => false)) {
const text = await promoLine.innerText();
// Should mention EU Staging (upstream) and EU Production (this env)
expect(text).toContain('EU Staging');
expect(text).toContain('EU Production');
}
await snap(page, '14-promotion-context');
});
});

View File

@@ -0,0 +1,731 @@
/**
* Environments Command Center — E2E Tests
*
* Verifies the unified environments page that combines readiness gate status
* with topology visualization. Tests command view (cards + gates),
* topology view (SVG graph), filtering, actions, and redirects.
*/
import { test, expect } from './fixtures/auth.fixture';
import { navigateAndWait, assertPageHasContent } from './helpers/nav.helper';
import type { Page, Route } from '@playwright/test';
// ─── Mock data ──────────────────────────────────────────────────────
const MOCK_ENVS = {
items: [
{ environmentId: 'eu-prod', displayName: 'EU Production', regionId: 'eu-west', environmentType: 'production' },
{ environmentId: 'eu-stage', displayName: 'EU Staging', regionId: 'eu-west', environmentType: 'staging' },
{ environmentId: 'us-prod', displayName: 'US Production', regionId: 'us-east', environmentType: 'production' },
{ environmentId: 'us-uat', displayName: 'US UAT', regionId: 'us-east', environmentType: 'uat' },
{ environmentId: 'prod-us-west', displayName: 'US West Production', regionId: 'us-west', environmentType: 'production' },
{ environmentId: 'apac-prod', displayName: 'APAC Production', regionId: 'apac', environmentType: 'production' },
],
};
function gate(name: string, status: string, msg: string) {
return { gateName: name, status, message: msg, checkedAt: new Date().toISOString(), durationMs: 120 };
}
function allPassGates() {
return [
gate('agent_bound', 'pass', 'Agent heartbeat OK'),
gate('docker_version_ok', 'pass', 'Docker 24.0.9'),
gate('docker_ping_ok', 'pass', 'Daemon reachable'),
gate('registry_pull_ok', 'pass', 'Pull test OK'),
gate('vault_reachable', 'pass', 'Vault unsealed'),
gate('consul_reachable', 'pass', 'Consul leader elected'),
gate('connectivity_ok', 'pass', 'All required gates pass'),
];
}
/** EU Production: 3 targets, ALL READY */
const READINESS_EU_PROD = {
items: [
{ targetId: 't-eup-1', targetName: 'eu-prod-app-01', environmentId: 'eu-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
{ targetId: 't-eup-2', targetName: 'eu-prod-app-02', environmentId: 'eu-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
{ targetId: 't-eup-3', targetName: 'eu-prod-worker-01', environmentId: 'eu-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
],
};
/** EU Staging: 2 targets, 1 FAILING (registry) */
const READINESS_EU_STAGE = {
items: [
{ targetId: 't-eus-1', targetName: 'eu-stage-app-01', environmentId: 'eu-stage', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
{ targetId: 't-eus-2', targetName: 'eu-stage-app-02', environmentId: 'eu-stage', isReady: false, gates: [
gate('agent_bound', 'pass', 'Agent heartbeat OK'),
gate('docker_version_ok', 'pass', 'Docker 24.0.9'),
gate('docker_ping_ok', 'pass', 'Daemon reachable'),
gate('registry_pull_ok', 'fail', 'Connection refused: registry.internal:5000'),
gate('vault_reachable', 'pass', 'Vault unsealed'),
gate('consul_reachable', 'pass', 'Consul leader elected'),
gate('connectivity_ok', 'fail', 'Required gate registry_pull_ok failed'),
], evaluatedAt: new Date().toISOString() },
],
};
/** US Production: 2 targets, ALL READY */
const READINESS_US_PROD = {
items: [
{ targetId: 't-usp-1', targetName: 'us-prod-app-01', environmentId: 'us-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
{ targetId: 't-usp-2', targetName: 'us-prod-worker-01', environmentId: 'us-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
],
};
/** US UAT: 2 targets, 1 PENDING (agent not bound) */
const READINESS_US_UAT = {
items: [
{ targetId: 't-usu-1', targetName: 'us-uat-app-01', environmentId: 'us-uat', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
{ targetId: 't-usu-2', targetName: 'us-uat-worker-01', environmentId: 'us-uat', isReady: false, gates: [
gate('agent_bound', 'pending', 'Awaiting agent registration'),
gate('docker_version_ok', 'pending', 'Blocked by agent_bound'),
gate('docker_ping_ok', 'pending', 'Blocked by agent_bound'),
gate('registry_pull_ok', 'pending', 'Blocked by agent_bound'),
gate('vault_reachable', 'pending', 'Blocked by agent_bound'),
gate('consul_reachable', 'pending', 'Blocked by agent_bound'),
gate('connectivity_ok', 'fail', 'Required gate agent_bound is pending'),
], evaluatedAt: new Date().toISOString() },
],
};
/** US West Production: 1 target, ALL READY */
const READINESS_US_WEST = {
items: [
{ targetId: 't-uwp-1', targetName: 'usw-prod-app-01', environmentId: 'prod-us-west', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
],
};
/** APAC Production: 2 targets, 1 FAILING (consul partitioned) */
const READINESS_APAC = {
items: [
{ targetId: 't-ap-1', targetName: 'apac-prod-app-01', environmentId: 'apac-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
{ targetId: 't-ap-2', targetName: 'apac-prod-worker-01', environmentId: 'apac-prod', isReady: false, gates: [
gate('agent_bound', 'pass', 'Agent heartbeat OK'),
gate('docker_version_ok', 'pass', 'Docker 25.0.3'),
gate('docker_ping_ok', 'pass', 'Daemon reachable'),
gate('registry_pull_ok', 'pass', 'Pull test OK'),
gate('vault_reachable', 'pass', 'Vault unsealed'),
gate('consul_reachable', 'fail', 'No Consul leader — cluster partitioned'),
gate('connectivity_ok', 'fail', 'Required gate consul_reachable failed'),
], evaluatedAt: new Date().toISOString() },
],
};
const READINESS_BY_ENV: Record<string, { items: any[] }> = {
'eu-prod': READINESS_EU_PROD,
'eu-stage': READINESS_EU_STAGE,
'us-prod': READINESS_US_PROD,
'us-uat': READINESS_US_UAT,
'prod-us-west': READINESS_US_WEST,
'apac-prod': READINESS_APAC,
};
const MOCK_TOPOLOGY_LAYOUT = {
nodes: [
{ id: 'region-eu-west', label: 'EU West', kind: 'region', parentNodeId: null, x: 0, y: 0, width: 400, height: 150, hostCount: 0, targetCount: 5, isFrozen: false, promotionPathCount: 1, deployingCount: 0, pendingCount: 0, failedCount: 0, totalDeployments: 0 },
{ id: 'env-eu-prod', label: 'EU Production', kind: 'environment', parentNodeId: 'region-eu-west', x: 220, y: 50, width: 160, height: 50, environmentId: 'eu-prod', regionId: 'eu-west', environmentType: 'production', healthStatus: 'healthy', hostCount: 3, targetCount: 3, isFrozen: false, promotionPathCount: 1, deployingCount: 0, pendingCount: 0, failedCount: 0, totalDeployments: 5 },
{ id: 'env-eu-stage', label: 'EU Staging', kind: 'environment', parentNodeId: 'region-eu-west', x: 20, y: 50, width: 160, height: 50, environmentId: 'eu-stage', regionId: 'eu-west', environmentType: 'staging', healthStatus: 'degraded', hostCount: 2, targetCount: 2, isFrozen: false, promotionPathCount: 1, deployingCount: 0, pendingCount: 0, failedCount: 1, totalDeployments: 3 },
],
edges: [
{ id: 'path-eu-stage-to-prod', sourceNodeId: 'env-eu-stage', targetNodeId: 'env-eu-prod', kind: 'promotion', label: 'auto-promote', pathMode: 'auto', status: 'active', requiredApprovals: 0, sections: [{ startPoint: { x: 180, y: 75 }, endPoint: { x: 220, y: 75 }, bendPoints: [] }] },
],
metadata: { regionCount: 1, environmentCount: 2, promotionPathCount: 1, canvasWidth: 440, canvasHeight: 170 },
};
const MOCK_CONTEXT_REGIONS = [
{ regionId: 'eu-west', displayName: 'EU West', enabled: true },
{ regionId: 'us-east', displayName: 'US East', enabled: true },
{ regionId: 'us-west', displayName: 'US West', enabled: true },
{ regionId: 'apac', displayName: 'Asia-Pacific', enabled: true },
];
// ─── Helpers ────────────────────────────────────────────────────────
const SCREENSHOT_DIR = 'e2e/screenshots/environments-command';
async function snap(page: Page, label: string) {
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
}
function collectErrors(page: Page): string[] {
const errors: string[] = [];
page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()); });
page.on('pageerror', (err) => errors.push(err.message));
return errors;
}
async function setupMockRoutes(page: Page) {
// IMPORTANT: In Playwright, the LAST registered route is checked FIRST.
// Register catch-alls BEFORE specific mocks so specific mocks take priority.
// Catch-all for release/topology APIs (registered first = lowest priority)
await page.route('**/api/v2/releases/**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
});
await page.route('**/api/v2/topology/targets**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
});
await page.route('**/api/v2/topology/agents**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
});
await page.route('**/api/v2/topology/hosts**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
});
// Context APIs
await page.route('**/api/v2/context/regions', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CONTEXT_REGIONS) });
});
await page.route('**/api/v2/context/preferences', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ regions: [], environments: [] }) });
});
await page.route('**/api/v2/context/environments**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_ENVS.items) });
});
// Validate target (returns updated report)
await page.route('**/api/v1/targets/*/validate', (route: Route) => {
const url = route.request().url();
const targetId = url.match(/targets\/([^/]+)\/validate/)?.[1] ?? '';
for (const env of Object.values(READINESS_BY_ENV)) {
const target = env.items.find((t: any) => t.targetId === targetId);
if (target) {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(target) });
return;
}
}
route.fulfill({ status: 404, body: 'target not found' });
});
// Topology layout (registered after catch-alls = higher priority)
await page.route('**/api/v2/topology/layout**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_TOPOLOGY_LAYOUT) });
});
// Per-environment readiness (high priority — registered late)
await page.route('**/api/v1/environments/*/readiness', (route: Route) => {
const url = route.request().url();
const envId = url.match(/environments\/([^/]+)\/readiness/)?.[1] ?? '';
const data = READINESS_BY_ENV[envId] ?? { items: [] };
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
});
// Environments list (highest priority — registered last)
await page.route('**/api/v2/topology/environments**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_ENVS) });
});
}
// ─── Tests ──────────────────────────────────────────────────────────
test.describe('Environments Command Center', () => {
// ── Scenario 1: Page loads and renders all environments ──
test('renders command view with all 6 environments from API', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await setupMockRoutes(page);
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
// Shell header
await expect(page.locator('h1')).toContainText('Environments');
// View toggle present with Command active
const commandBtn = page.locator('button:has-text("Command")');
await expect(commandBtn).toBeVisible();
// Summary strip shows correct counts
// Total targets: eu-prod:3 + eu-stage:2 + us-prod:2 + us-uat:2 + us-west:1 + apac:2 = 12
const summaryCards = page.locator('.sc__v');
const values = await summaryCards.allTextContents();
// [0]=envs, [1]=targets, [2]=ready, [3]=not-ready, [4]=failed-gates
expect(parseInt(values[0])).toBe(6);
expect(parseInt(values[1])).toBe(12);
// Ready: eu-prod:3 + eu-stage:1 + us-prod:2 + us-uat:1 + us-west:1 + apac:1 = 9
expect(parseInt(values[2])).toBe(9);
// Not ready: eu-stage:1 + us-uat:1 + apac:1 = 3
expect(parseInt(values[3])).toBe(3);
// Environment cards present
const envCards = page.locator('.env-card');
await expect(envCards).toHaveCount(6);
await snap(page, '01-command-view-loaded');
// No critical Angular errors
const critical = errors.filter(e => e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError'));
expect(critical, 'Critical errors: ' + critical.join('\n')).toHaveLength(0);
});
// ── Scenario 2: Not-ready environments appear first ──
test('sorts not-ready environments before ready ones', async ({ authenticatedPage: page }) => {
await setupMockRoutes(page);
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
const firstCard = page.locator('.env-card').first();
// First card should be a not-ready env (has --bad class)
await expect(firstCard).toHaveClass(/env-card--bad/);
// Last cards should be fully-ready (--ok class)
const cards = page.locator('.env-card');
const count = await cards.count();
const lastCard = cards.nth(count - 1);
await expect(lastCard).toHaveClass(/env-card--ok/);
});
// ── Scenario 3: Gate grid shows correct status icons ──
test('gate grid shows pass/fail/pending icons correctly', async ({ authenticatedPage: page }) => {
await setupMockRoutes(page);
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
// Find the EU Staging card (has a failing target)
const euStageCard = page.locator('.env-card', { hasText: 'EU Staging' });
await expect(euStageCard).toBeVisible();
// Should show 1/2 ready
await expect(euStageCard.locator('.env-card__score')).toContainText('1/2 ready');
// Should contain fail icons (✗)
const failCells = euStageCard.locator('.gc--fail');
expect(await failCells.count()).toBeGreaterThanOrEqual(2); // registry_pull_ok + connectivity_ok
// Should contain pass icons (✓)
const passCells = euStageCard.locator('.gc--pass');
expect(await passCells.count()).toBeGreaterThanOrEqual(5); // most gates pass on the good target
await snap(page, '02-gate-status-icons');
});
// ── Scenario 4: Blocker section appears for failing environments ──
test('shows blockers with remediation hints for failing environments', async ({ authenticatedPage: page }) => {
await setupMockRoutes(page);
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
// APAC card has consul failure
const apacCard = page.locator('.env-card', { hasText: 'APAC Production' });
await expect(apacCard).toBeVisible();
// Blockers section present
const blockers = apacCard.locator('.blockers');
await expect(blockers).toBeVisible();
// Shows the failed gate name
await expect(blockers).toContainText('Consul Reachable');
// Shows remediation hint
await expect(blockers).toContainText('Consul cluster health');
await snap(page, '03-blockers-remediation');
});
// ── Scenario 5: Deploy button only on all-ready environments ──
test('Deploy button appears only when all targets are ready', async ({ authenticatedPage: page }) => {
await setupMockRoutes(page);
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
// EU Production is fully ready — should have Deploy button
const euProdCard = page.locator('.env-card', { hasText: 'EU Production' });
const deployBtn = euProdCard.locator('a:has-text("Deploy")');
await expect(deployBtn).toBeVisible();
// EU Staging has a failure — no Deploy button
const euStageCard = page.locator('.env-card', { hasText: 'EU Staging' });
const noDeployBtn = euStageCard.locator('a:has-text("Deploy")');
await expect(noDeployBtn).toHaveCount(0);
// US UAT has pending — no Deploy button
const usUatCard = page.locator('.env-card', { hasText: 'US UAT' });
const noDeployBtn2 = usUatCard.locator('a:has-text("Deploy")');
await expect(noDeployBtn2).toHaveCount(0);
await snap(page, '04-deploy-button-gating');
});
// ── Scenario 6: Region filtering via global context ──
test('region filtering is handled by global context bar, not local chips', async ({ authenticatedPage: page }) => {
await setupMockRoutes(page);
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
// The toolbar should NOT have region chip buttons (those are in the global context bar)
const toolbar = page.locator('.toolbar');
await expect(toolbar).toBeVisible();
// Status chips should be in the toolbar
const statusChips = toolbar.locator('.status-chips');
await expect(statusChips).toBeVisible();
await expect(statusChips.locator('button', { hasText: 'Ready' }).first()).toBeVisible();
await expect(statusChips.locator('button', { hasText: 'Not Ready' })).toBeVisible();
// View toggle should be in the same row as status
await expect(toolbar.locator('button:has-text("Command")')).toBeVisible();
await expect(toolbar.locator('button:has-text("Topology")')).toBeVisible();
await snap(page, '05-toolbar-layout');
});
// ── Scenario 7: Status filter works ──
test('status filter shows only ready or not-ready environments', async ({ authenticatedPage: page }) => {
await setupMockRoutes(page);
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
// Click "Not Ready" status chip in the toolbar
const notReadyChip = page.locator('.status-chips button.chip--err');
await notReadyChip.click();
await page.waitForTimeout(500);
// Should only show targets that are not ready (3 targets across 3 envs)
const targets = page.locator('.gg__row');
const targetCount = await targets.count();
expect(targetCount).toBe(3); // 1 from eu-stage + 1 from us-uat + 1 from apac
// All rows should have --bad class
for (let i = 0; i < targetCount; i++) {
await expect(targets.nth(i)).toHaveClass(/gg__row--bad/);
}
await snap(page, '06-status-filter-not-ready');
// Switch to "Ready" filter
const readyChip = page.locator('.status-chips button.chip--ok');
await readyChip.click();
await page.waitForTimeout(500);
// Should show 9 ready targets
const readyTargets = page.locator('.gg__row');
expect(await readyTargets.count()).toBe(9);
await snap(page, '07-status-filter-ready');
});
// ── Scenario 8: Toggle to topology view ──
test('topology toggle shows SVG graph view', async ({ authenticatedPage: page }) => {
await setupMockRoutes(page);
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
// Click Topology toggle
const topoBtn = page.locator('button:has-text("Topology")');
await topoBtn.click();
await page.waitForTimeout(1000);
// SVG graph should be visible
const svgCanvas = page.locator('svg.topology-canvas');
await expect(svgCanvas).toBeVisible({ timeout: 5000 });
// Environment cards should NOT be visible
await expect(page.locator('.env-card')).toHaveCount(0);
// Summary strip should still be visible
await expect(page.locator('.summary')).toBeVisible();
await snap(page, '08-topology-view');
});
// ── Scenario 9: Topology node click opens drawer ──
test('clicking a topology environment node opens detail drawer', async ({ authenticatedPage: page }) => {
await setupMockRoutes(page);
await navigateAndWait(page, '/environments/overview?view=topology');
await page.waitForTimeout(2000);
// Click an environment node in the SVG
const envNode = page.locator('.env-group').first();
if (await envNode.isVisible({ timeout: 3000 }).catch(() => false)) {
await envNode.click();
await page.waitForTimeout(500);
// Drawer should appear
const drawer = page.locator('.topo-drawer');
if (await drawer.isVisible({ timeout: 3000 }).catch(() => false)) {
// Should show readiness summary
await expect(drawer.locator('.td__readiness')).toBeVisible();
// "Open Detail" link should exist
await expect(drawer.locator('a:has-text("Open Detail")')).toBeVisible();
// "Switch to Command" button should exist
await expect(drawer.locator('button:has-text("Switch to Command")')).toBeVisible();
}
}
await snap(page, '09-topology-drawer');
});
// ── Scenario 10: /releases/readiness redirects to /environments/overview ──
test('legacy /releases/readiness redirects to /environments/overview', async ({ authenticatedPage: page }) => {
await setupMockRoutes(page);
await page.goto('/releases/readiness', { waitUntil: 'networkidle', timeout: 15_000 });
await page.waitForTimeout(2000);
// URL should have changed to /environments/overview
expect(page.url()).toContain('/environments/overview');
await snap(page, '10-readiness-redirect');
});
// ── Scenario 11: Validate All triggers API calls ──
test('Validate All button sends validation requests for all visible targets', async ({ authenticatedPage: page }) => {
const validateCalls: string[] = [];
await setupMockRoutes(page);
// Track validate calls
await page.route('**/api/v1/targets/*/validate', (route: Route) => {
const url = route.request().url();
const targetId = url.match(/targets\/([^/]+)\/validate/)?.[1] ?? '';
validateCalls.push(targetId);
// Return mock result
for (const env of Object.values(READINESS_BY_ENV)) {
const target = env.items.find((t: any) => t.targetId === targetId);
if (target) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(target) }); return; }
}
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ targetId, isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString(), environmentId: 'unknown' }) });
});
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
// Click Validate All
const validateAllBtn = page.locator('button:has-text("Validate All")');
await validateAllBtn.click();
await page.waitForTimeout(2000);
// Should have called validate for all 12 targets
expect(validateCalls.length).toBe(12);
await snap(page, '11-validate-all');
});
// ── Scenario 12: Per-environment Re-check ──
test('Re-check button validates only targets in that environment', async ({ authenticatedPage: page }) => {
const validateCalls: string[] = [];
await setupMockRoutes(page);
await page.route('**/api/v1/targets/*/validate', (route: Route) => {
const url = route.request().url();
const targetId = url.match(/targets\/([^/]+)\/validate/)?.[1] ?? '';
validateCalls.push(targetId);
for (const env of Object.values(READINESS_BY_ENV)) {
const target = env.items.find((t: any) => t.targetId === targetId);
if (target) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(target) }); return; }
}
route.fulfill({ status: 404 });
});
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
// Find the EU Staging card and click Re-check
const euStageCard = page.locator('.env-card', { hasText: 'EU Staging' });
const recheckBtn = euStageCard.locator('button:has-text("Re-check")');
await recheckBtn.click();
await page.waitForTimeout(1500);
// Should have called validate for only 2 targets (eu-stage's targets)
expect(validateCalls.length).toBe(2);
expect(validateCalls).toContain('t-eus-1');
expect(validateCalls).toContain('t-eus-2');
});
// ── Scenario 13: All environments load when no context filter applied ──
test('shows all environments when global context has no region filter', async ({ authenticatedPage: page }) => {
await setupMockRoutes(page);
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
// All 6 environments should be visible
const cards = page.locator('.env-card');
expect(await cards.count()).toBe(6);
await snap(page, '12-all-environments');
});
// ── Scenario 14: Clear status filter resets to All ──
test('Clear button resets status filter back to All', async ({ authenticatedPage: page }) => {
await setupMockRoutes(page);
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
// Apply "Not Ready" filter
await page.locator('.status-chips button.chip--err').click();
await page.waitForTimeout(500);
expect(await page.locator('.gg__row').count()).toBe(3); // only 3 not-ready targets
// Click Clear
const clearBtn = page.locator('button:has-text("Clear")');
await clearBtn.click();
await page.waitForTimeout(500);
// All 6 environments visible again
expect(await page.locator('.env-card').count()).toBe(6);
});
// ── Scenario 15: View Detail link navigates to environment detail ──
test('View Detail link navigates to environment detail page', async ({ authenticatedPage: page }) => {
await setupMockRoutes(page);
// Mock the environment detail page APIs
await page.route('**/api/v2/topology/targets**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
});
await page.route('**/api/v2/topology/agents**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
});
await page.route('**/api/v2/security/**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
});
await page.route('**/api/v2/evidence/**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
});
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
// Click "View Detail" on EU Production card
const euProdCard = page.locator('.env-card', { hasText: 'EU Production' });
const detailLink = euProdCard.locator('a:has-text("View Detail")');
await detailLink.click();
await page.waitForTimeout(2000);
// Should navigate to /environments/environments/eu-prod
expect(page.url()).toContain('/environments/environments/eu-prod');
await snap(page, '13-view-detail-navigation');
});
// ── Scenario 16: No Angular errors across all views ──
test('no critical Angular errors navigating between command and topology views', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await setupMockRoutes(page);
// Load command view
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
// Switch to topology
await page.locator('button:has-text("Topology")').click();
await page.waitForTimeout(1500);
// Switch back to command
await page.locator('button:has-text("Command")').click();
await page.waitForTimeout(1000);
// Apply status filter
await page.locator('.status-chips button.chip--err').click();
await page.waitForTimeout(500);
// Switch to topology again
await page.locator('button:has-text("Topology")').click();
await page.waitForTimeout(1000);
const critical = errors.filter(e =>
e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError')
);
expect(critical, 'Critical errors: ' + critical.join('\n')).toHaveLength(0);
await snap(page, '14-no-errors');
});
// ── Scenario 17: Empty state when no environments match filter ──
test('status filter narrows down visible targets correctly', async ({ authenticatedPage: page }) => {
await setupMockRoutes(page);
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
// Filter for "Not Ready" — should show 3 failing targets across 3 envs
await page.locator('.status-chips button.chip--err').click();
await page.waitForTimeout(500);
const rows = page.locator('.gg__row');
expect(await rows.count()).toBe(3);
// All rows should have the --bad class
for (let i = 0; i < 3; i++) {
await expect(rows.nth(i)).toHaveClass(/gg__row--bad/);
}
await snap(page, '15-filtered-not-ready-targets');
});
// ── Scenario 18: Pending gates are shown distinctly from failures ──
test('pending gates display with warning color distinct from failures', async ({ authenticatedPage: page }) => {
await setupMockRoutes(page);
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
// Find US UAT card (has pending agent)
const usUatCard = page.locator('.env-card', { hasText: 'US UAT' });
await expect(usUatCard).toBeVisible();
// Should have pending indicator cells (●)
const pendingCells = usUatCard.locator('.gc--pend');
expect(await pendingCells.count()).toBeGreaterThanOrEqual(5); // 5 pending gates on the worker target
// Should have both fail AND pending blockers
const blockers = usUatCard.locator('.blockers');
await expect(blockers).toBeVisible();
await expect(blockers).toContainText('Agent Bound');
await expect(blockers).toContainText('Awaiting agent registration');
await snap(page, '16-pending-vs-fail');
});
// ── Scenario 19: Deploy button links to correct route ──
test('Deploy button links to create deployment with environment query param', async ({ authenticatedPage: page }) => {
await setupMockRoutes(page);
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
const euProdCard = page.locator('.env-card', { hasText: 'EU Production' });
const deployLink = euProdCard.locator('a:has-text("Deploy")');
const href = await deployLink.getAttribute('href');
expect(href).toContain('/releases/deployments/new');
expect(href).toContain('environment=eu-prod');
});
// ── Scenario 20: Multiple diverse failure types across environments ──
test('different failure types shown correctly across environments', async ({ authenticatedPage: page }) => {
await setupMockRoutes(page);
await navigateAndWait(page, '/environments/overview');
await page.waitForTimeout(1500);
// EU Staging: registry failure
const euStageBlockers = page.locator('.env-card', { hasText: 'EU Staging' }).locator('.blockers');
await expect(euStageBlockers).toContainText('Registry Pull');
// US UAT: agent pending
const usUatBlockers = page.locator('.env-card', { hasText: 'US UAT' }).locator('.blockers');
await expect(usUatBlockers).toContainText('Agent Bound');
// APAC: consul failure
const apacBlockers = page.locator('.env-card', { hasText: 'APAC Production' }).locator('.blockers');
await expect(apacBlockers).toContainText('Consul Reachable');
await snap(page, '17-diverse-failures');
});
});

View File

@@ -0,0 +1,75 @@
import { test as base, expect, Page, APIRequestContext } from '@playwright/test';
/**
* Live auth fixture for integration tests against the real Stella Ops stack.
*
* Unlike the mocked auth.fixture.ts, this performs a real OIDC login against
* the live Authority service and extracts a Bearer token for API calls.
*/
const BASE_URL = process.env['PLAYWRIGHT_BASE_URL'] || 'https://stella-ops.local';
const ADMIN_USER = process.env['STELLAOPS_ADMIN_USER'] || 'admin';
const ADMIN_PASS = process.env['STELLAOPS_ADMIN_PASS'] || 'Admin@Stella2026!';
export const test = base.extend<{
liveAuthPage: Page;
apiToken: string;
apiRequest: APIRequestContext;
}>({
liveAuthPage: async ({ page }, use) => {
// Navigate to the app — will redirect to login
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 });
// If we land on /welcome, click Sign In
if (page.url().includes('/welcome')) {
await page.getByRole('button', { name: /sign in/i }).click();
await page.waitForURL('**/connect/authorize**', { timeout: 10_000 });
}
// Fill login form if present
const usernameField = page.getByRole('textbox', { name: /username/i });
if (await usernameField.isVisible({ timeout: 5_000 }).catch(() => false)) {
await usernameField.fill(ADMIN_USER);
await page.getByRole('textbox', { name: /password/i }).fill(ADMIN_PASS);
await page.getByRole('button', { name: /sign in/i }).click();
await page.waitForURL(`${BASE_URL}/**`, { timeout: 15_000 });
}
// Wait for app to load
await page.waitForLoadState('networkidle', { timeout: 15_000 });
await use(page);
},
apiToken: async ({ liveAuthPage: page }, use) => {
// Extract the Bearer token from session storage
const token = await page.evaluate(() => {
const session = sessionStorage.getItem('stellaops.auth.session.full');
if (!session) return null;
const parsed = JSON.parse(session);
return parsed?.tokens?.accessToken ?? null;
});
if (!token) {
throw new Error('Failed to extract auth token from session storage');
}
await use(token);
},
apiRequest: async ({ playwright, apiToken }, use) => {
const ctx = await playwright.request.newContext({
baseURL: BASE_URL,
extraHTTPHeaders: {
'Authorization': `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
ignoreHTTPSErrors: true,
});
await use(ctx);
await ctx.dispose();
},
});
export { expect };

View File

@@ -0,0 +1,701 @@
/**
* Navigation Reorganization E2E Tests
* Black-box QA verification of the 6-group sidebar structure,
* tab rationalization, quick actions cleanup, and removed-item accessibility.
*
* Sprint: UI Navigation & UX Reorganization
*/
import { test, expect } from './fixtures/auth.fixture';
import { Page } from '@playwright/test';
const SCREENSHOT_DIR = 'e2e/screenshots/nav-reorg';
async function snap(page: Page, label: string) {
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
}
function collectErrors(page: Page) {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
page.on('pageerror', (err) => errors.push(err.message));
return errors;
}
async function go(page: Page, path: string) {
await page.goto(path, { waitUntil: 'networkidle', timeout: 30_000 });
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(1500);
}
function criticalOnly(errors: string[]) {
return errors.filter(e =>
e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError')
);
}
// ---------------------------------------------------------------------------
// 1. SIDEBAR GROUP STRUCTURE
// ---------------------------------------------------------------------------
test.describe('Sidebar 6-group structure', () => {
test('renders exactly 6 nav groups with correct labels', async ({ authenticatedPage: page }) => {
await go(page, '/');
// The sidebar renders group headers as text. Verify each group label exists.
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
const sidebarText = await sidebar.innerText();
// 6-group labels (Home has no label, so 5 visible labels)
expect(sidebarText).toContain('Release Control');
expect(sidebarText).toContain('Security');
expect(sidebarText).toContain('Evidence');
expect(sidebarText).toContain('Operations');
expect(sidebarText).toContain('Settings');
// Dissolved groups must NOT appear
expect(sidebarText).not.toContain('Policy');
expect(sidebarText).not.toContain('Audit & Evidence');
await snap(page, '01-sidebar-groups');
});
test('Dashboard is visible in Home group', async ({ authenticatedPage: page }) => {
await go(page, '/');
const dashLink = page.locator('text=Dashboard').first();
expect(await dashLink.isVisible({ timeout: 3000 })).toBe(true);
});
});
// ---------------------------------------------------------------------------
// 2. RELEASE CONTROL GROUP
// ---------------------------------------------------------------------------
test.describe('Release Control group', () => {
test('contains Deployments, Releases, Environments, Readiness', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/');
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
const text = await sidebar.innerText();
expect(text).toContain('Deployments');
expect(text).toContain('Releases');
expect(text).toContain('Environments');
expect(text).toContain('Readiness');
expect(criticalOnly(errors)).toHaveLength(0);
});
test('Readiness navigates to /releases/readiness', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/');
const link = page.locator('text=Readiness').first();
if (await link.isVisible({ timeout: 3000 }).catch(() => false)) {
await link.click();
await page.waitForTimeout(2000);
expect(page.url()).toContain('/releases/readiness');
const body = await page.locator('body').innerText();
expect(body.trim().length).toBeGreaterThan(10);
await snap(page, '02-readiness');
}
expect(criticalOnly(errors)).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 3. SECURITY GROUP (absorbed Policy items)
// ---------------------------------------------------------------------------
test.describe('Security group', () => {
test('contains Vulnerabilities, Security Posture, Scan Image', async ({ authenticatedPage: page }) => {
await go(page, '/');
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
const text = await sidebar.innerText();
expect(text).toContain('Vulnerabilities');
expect(text).toContain('Security Posture');
expect(text).toContain('Scan Image');
});
test('contains absorbed Policy items: VEX & Exceptions, Risk & Governance', async ({ authenticatedPage: page }) => {
await go(page, '/');
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
const text = await sidebar.innerText();
expect(text).toContain('VEX & Exceptions');
expect(text).toContain('Risk & Governance');
});
test('Security Posture has Findings Explorer child', async ({ authenticatedPage: page }) => {
await go(page, '/security');
await page.waitForTimeout(1000);
// The Findings Explorer should be visible as a child nav item
const findingsExplorer = page.locator('text=Findings Explorer').first();
if (await findingsExplorer.isVisible({ timeout: 3000 }).catch(() => false)) {
await findingsExplorer.click();
await page.waitForTimeout(2000);
expect(page.url()).toContain('/security/findings');
await snap(page, '03-findings-explorer');
}
});
test('Security Posture children: Supply-Chain, Reachability, Unknowns', async ({ authenticatedPage: page }) => {
await go(page, '/security');
await page.waitForTimeout(1000);
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
const text = await sidebar.innerText();
expect(text).toContain('Supply-Chain Data');
expect(text).toContain('Reachability');
expect(text).toContain('Unknowns');
});
test('Risk & Governance children: Simulation, Policy Audit', async ({ authenticatedPage: page }) => {
await go(page, '/ops/policy/governance');
await page.waitForTimeout(1000);
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
const text = await sidebar.innerText();
expect(text).toContain('Simulation');
expect(text).toContain('Policy Audit');
});
test('VEX & Exceptions navigates to /ops/policy/vex', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/');
const link = page.locator('text=VEX & Exceptions').first();
if (await link.isVisible({ timeout: 3000 }).catch(() => false)) {
await link.click();
await page.waitForTimeout(2000);
expect(page.url()).toContain('/ops/policy/vex');
await snap(page, '04-vex-exceptions');
}
expect(criticalOnly(errors)).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 4. EVIDENCE GROUP (trimmed)
// ---------------------------------------------------------------------------
test.describe('Evidence group (trimmed)', () => {
test('contains exactly 4 items: Overview, Capsules, Audit Log, Export Center', async ({ authenticatedPage: page }) => {
await go(page, '/evidence/overview');
await page.waitForTimeout(1000);
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
const text = await sidebar.innerText();
expect(text).toContain('Evidence Overview');
expect(text).toContain('Decision Capsules');
expect(text).toContain('Audit Log');
expect(text).toContain('Export Center');
});
test('removed items NOT in sidebar: Replay, Bundles, Trust', async ({ authenticatedPage: page }) => {
await go(page, '/evidence/overview');
await page.waitForTimeout(1000);
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
const text = await sidebar.innerText();
// These should no longer be sidebar items
// Note: "Replay" might appear in page content, so check within sidebar only
const evidenceGroup = text.split('Operations')[0]; // text before Operations group
expect(evidenceGroup).not.toContain('Replay & Verify');
expect(evidenceGroup).not.toContain('Bundles');
});
});
// ---------------------------------------------------------------------------
// 5. OPERATIONS GROUP (trimmed, absorbed Packs)
// ---------------------------------------------------------------------------
test.describe('Operations group', () => {
test('contains Policy Packs (absorbed from Policy group)', async ({ authenticatedPage: page }) => {
await go(page, '/ops/operations');
await page.waitForTimeout(1000);
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
const text = await sidebar.innerText();
expect(text).toContain('Policy Packs');
expect(text).toContain('Operations Hub');
expect(text).toContain('Scheduled Jobs');
expect(text).toContain('Diagnostics');
});
test('removed items NOT in sidebar: Runtime Drift, Notifications, Watchlist', async ({ authenticatedPage: page }) => {
await go(page, '/ops/operations');
await page.waitForTimeout(1000);
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
const text = await sidebar.innerText();
expect(text).not.toContain('Runtime Drift');
expect(text).not.toContain('Watchlist');
});
});
// ---------------------------------------------------------------------------
// 6. SETTINGS GROUP
// ---------------------------------------------------------------------------
test.describe('Settings group', () => {
test('Certificates renamed to Certificates & Trust', async ({ authenticatedPage: page }) => {
await go(page, '/setup/integrations');
await page.waitForTimeout(1000);
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
const text = await sidebar.innerText();
expect(text).toContain('Certificates & Trust');
});
});
// ---------------------------------------------------------------------------
// 7. REMOVED ITEMS STILL ROUTABLE (no 404)
// ---------------------------------------------------------------------------
test.describe('Removed nav items still routable', () => {
const removedRoutes = [
{ name: 'Replay & Verify', path: '/evidence/verify-replay' },
{ name: 'Bundles', path: '/evidence/bundles' },
{ name: 'Runtime Drift', path: '/ops/operations/drift' },
{ name: 'Notifications', path: '/ops/operations/notifications' },
{ name: 'Watchlist', path: '/ops/operations/watchlist' },
];
for (const route of removedRoutes) {
test(`${route.name} (${route.path}) loads without error`, async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, route.path);
const body = await page.locator('body').innerText();
expect(body.trim().length, `${route.name} should render content`).toBeGreaterThan(10);
// No Angular injection errors
const critical = criticalOnly(errors);
expect(critical, `${route.name} should have no critical errors`).toHaveLength(0);
await snap(page, `05-removed-${route.name.toLowerCase().replace(/\s+/g, '-')}`);
});
}
});
// ---------------------------------------------------------------------------
// 8. NEW NAV ITEMS ROUTE CORRECTLY
// ---------------------------------------------------------------------------
test.describe('New nav items route correctly', () => {
test('Readiness → /releases/readiness renders content', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/releases/readiness');
const body = await page.locator('body').innerText();
expect(body.trim().length).toBeGreaterThan(10);
expect(criticalOnly(errors)).toHaveLength(0);
});
test('Findings Explorer → /security/findings renders content', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/security/findings');
const body = await page.locator('body').innerText();
expect(body.trim().length).toBeGreaterThan(10);
expect(criticalOnly(errors)).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 9. TAB RATIONALIZATION - TRUST ADMIN (3 tabs, no Audit)
// ---------------------------------------------------------------------------
test.describe('Trust Admin tabs (rationalized to 3)', () => {
test('shows 3 tabs: Signing Keys, Trusted Issuers, Certificates', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/setup/trust-signing');
const tabBar = page.locator('stella-page-tabs, [class*="page-tabs"]').first();
if (await tabBar.isVisible({ timeout: 5000 }).catch(() => false)) {
const tabText = await tabBar.innerText();
expect(tabText).toContain('Signing Keys');
expect(tabText).toContain('Trusted Issuers');
expect(tabText).toContain('Certificates');
expect(tabText).not.toContain('Audit');
}
await snap(page, '06-trust-admin-tabs');
expect(criticalOnly(errors)).toHaveLength(0);
});
test('has audit cross-link in header', async ({ authenticatedPage: page }) => {
await go(page, '/setup/trust-signing');
const auditLink = page.locator('a[href*="evidence/audit-log"]').first();
if (await auditLink.isVisible({ timeout: 3000 }).catch(() => false)) {
const text = await auditLink.innerText();
expect(text.toLowerCase()).toContain('audit');
}
});
});
// ---------------------------------------------------------------------------
// 10. TAB RATIONALIZATION - INTEGRATION HUB (7 tabs, no Activity/Config Audit)
// ---------------------------------------------------------------------------
test.describe('Integration Hub tabs (rationalized to 7)', () => {
test('shows 7 tabs, no Activity or Config Audit', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/setup/integrations');
const tabBar = page.locator('stella-page-tabs, [class*="page-tabs"]').first();
if (await tabBar.isVisible({ timeout: 5000 }).catch(() => false)) {
const tabText = await tabBar.innerText();
expect(tabText).toContain('Hub');
expect(tabText).toContain('Registries');
expect(tabText).toContain('SCM');
expect(tabText).toContain('CI/CD');
expect(tabText).toContain('Secrets');
expect(tabText).not.toContain('Activity');
expect(tabText).not.toContain('Config Audit');
}
await snap(page, '07-integration-tabs');
expect(criticalOnly(errors)).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 11. TAB RATIONALIZATION - POLICY GOVERNANCE (5 tabs)
// ---------------------------------------------------------------------------
test.describe('Policy Governance tabs (rationalized to 5)', () => {
test('shows 5 tabs: Risk Budget, Profiles, Configuration, Conflicts, Developer Tools', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/ops/policy/governance');
const tabBar = page.locator('stella-page-tabs, [class*="page-tabs"]').first();
if (await tabBar.isVisible({ timeout: 5000 }).catch(() => false)) {
const tabText = await tabBar.innerText();
expect(tabText).toContain('Risk Budget');
expect(tabText).toContain('Profiles');
expect(tabText).toContain('Configuration');
expect(tabText).toContain('Conflicts');
expect(tabText).toContain('Developer Tools');
// Removed tabs should not appear
expect(tabText).not.toContain('Trust Weights');
expect(tabText).not.toContain('Staleness');
expect(tabText).not.toContain('Sealed Mode');
expect(tabText).not.toContain('Validator');
expect(tabText).not.toContain('Playground');
}
await snap(page, '08-governance-tabs');
expect(criticalOnly(errors)).toHaveLength(0);
});
test('Configuration tab contains Trust Weights, Staleness, Sealed Mode sections', async ({ authenticatedPage: page }) => {
await go(page, '/ops/policy/governance/config');
await page.waitForTimeout(1000);
const body = await page.locator('body').innerText();
// The config panel should show toggle buttons for the 3 sub-sections
expect(body).toContain('Trust Weights');
expect(body).toContain('Staleness');
expect(body).toContain('Sealed Mode');
await snap(page, '09-governance-config-panel');
});
test('Developer Tools tab contains Validator, Playground, Docs sections', async ({ authenticatedPage: page }) => {
await go(page, '/ops/policy/governance/tools');
await page.waitForTimeout(1000);
const body = await page.locator('body').innerText();
expect(body).toContain('Validator');
expect(body).toContain('Playground');
await snap(page, '10-governance-tools-panel');
});
});
// ---------------------------------------------------------------------------
// 12. TAB RATIONALIZATION - POLICY SIMULATION (6 tabs)
// ---------------------------------------------------------------------------
test.describe('Policy Simulation tabs (rationalized to 6)', () => {
test('shows 6 tabs: Shadow Mode, Promotion Gate, Test & Validate, Pre-Promotion Review, Effective Policies, Exceptions', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/ops/policy/simulation');
const tabBar = page.locator('stella-page-tabs, [class*="page-tabs"]').first();
if (await tabBar.isVisible({ timeout: 5000 }).catch(() => false)) {
const tabText = await tabBar.innerText();
expect(tabText).toContain('Shadow Mode');
expect(tabText).toContain('Promotion Gate');
expect(tabText).toContain('Test');
expect(tabText).toContain('Effective Policies');
expect(tabText).toContain('Exceptions');
// Removed standalone tabs
expect(tabText).not.toContain('Lint');
expect(tabText).not.toContain('Batch Evaluation');
expect(tabText).not.toContain('History');
}
await snap(page, '11-simulation-tabs');
expect(criticalOnly(errors)).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 13. QUICK ACTIONS (Ctrl+K)
// ---------------------------------------------------------------------------
test.describe('Quick Actions (Ctrl+K) palette', () => {
test('opens with Ctrl+K and shows top 5 actions', async ({ authenticatedPage: page }) => {
await go(page, '/');
// Open command palette
await page.keyboard.press('Control+k');
await page.waitForTimeout(500);
const palette = page.locator('app-command-palette, [class*="command-palette"]').first();
if (await palette.isVisible({ timeout: 3000 }).catch(() => false)) {
const text = await palette.innerText();
// Top 5 default actions should be visible
expect(text).toContain('Scan Artifact');
expect(text).toContain('View Findings');
expect(text).toContain('Pending Approvals');
expect(text).toContain('Security Posture');
expect(text).toContain('Create Release');
await snap(page, '12-command-palette-default');
}
// Close palette
await page.keyboard.press('Escape');
});
test('no duplicate actions for scan or findings', async ({ authenticatedPage: page }) => {
await go(page, '/');
await page.keyboard.press('Control+k');
await page.waitForTimeout(500);
const palette = page.locator('app-command-palette, [class*="command-palette"]').first();
if (await palette.isVisible({ timeout: 3000 }).catch(() => false)) {
// Type 'scan' to filter
await page.keyboard.type('>scan');
await page.waitForTimeout(300);
const results = await palette.innerText();
// Should find "Scan Artifact" but NOT "Scan Image" (removed duplicate)
expect(results).toContain('Scan Artifact');
expect(results).not.toContain('Scan Image');
await snap(page, '13-palette-scan-no-dupe');
}
await page.keyboard.press('Escape');
});
test('new actions are discoverable: >approvals, >exceptions, >posture, >reach', async ({ authenticatedPage: page }) => {
await go(page, '/');
const newShortcuts = [
{ shortcut: '>approvals', expected: 'Pending Approvals' },
{ shortcut: '>exceptions', expected: 'Exception Queue' },
{ shortcut: '>posture', expected: 'Security Posture' },
{ shortcut: '>reach', expected: 'Reachability' },
{ shortcut: '>export', expected: 'Export Evidence' },
{ shortcut: '>budget', expected: 'Risk Budget' },
{ shortcut: '>envs', expected: 'Environments' },
{ shortcut: '>exception', expected: 'Create Exception' },
];
for (const { shortcut, expected } of newShortcuts) {
await page.keyboard.press('Control+k');
await page.waitForTimeout(300);
const palette = page.locator('app-command-palette, [class*="command-palette"]').first();
if (await palette.isVisible({ timeout: 2000 }).catch(() => false)) {
// Clear and type shortcut
const input = palette.locator('input').first();
await input.fill(shortcut);
await page.waitForTimeout(300);
const results = await palette.innerText();
expect(results, `Shortcut "${shortcut}" should find "${expected}"`).toContain(expected);
}
await page.keyboard.press('Escape');
await page.waitForTimeout(200);
}
await snap(page, '14-palette-new-actions');
});
});
// ---------------------------------------------------------------------------
// 14. AUTO-EXPAND: Security group expands for /ops/policy/* routes
// ---------------------------------------------------------------------------
test.describe('Sidebar auto-expand behavior', () => {
test('navigating to /ops/policy/governance expands Security group', async ({ authenticatedPage: page }) => {
await go(page, '/ops/policy/governance');
await page.waitForTimeout(1000);
// The sidebar should have auto-expanded the Security group
// and Risk & Governance should be visible
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
const govItem = sidebar.locator('text=Risk & Governance').first();
expect(await govItem.isVisible({ timeout: 3000 }).catch(() => false)).toBe(true);
await snap(page, '15-auto-expand-security');
});
test('navigating to /ops/policy/packs expands Operations group', async ({ authenticatedPage: page }) => {
await go(page, '/ops/policy/packs');
await page.waitForTimeout(1000);
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
const packsItem = sidebar.locator('text=Policy Packs').first();
expect(await packsItem.isVisible({ timeout: 3000 }).catch(() => false)).toBe(true);
await snap(page, '16-auto-expand-operations');
});
});
// ---------------------------------------------------------------------------
// 15. FULL NAVIGATION CLICK-THROUGH (smoke)
// ---------------------------------------------------------------------------
test.describe('Full navigation smoke test', () => {
test('click through all 6 groups without critical errors', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/');
// Release Control
const deploymentsLink = page.locator('text=Deployments').first();
if (await deploymentsLink.isVisible({ timeout: 3000 }).catch(() => false)) {
await deploymentsLink.click();
await page.waitForTimeout(2000);
}
// Security
const vulnLink = page.locator('text=Vulnerabilities').first();
if (await vulnLink.isVisible({ timeout: 3000 }).catch(() => false)) {
await vulnLink.click();
await page.waitForTimeout(2000);
}
// Evidence
const auditLink = page.locator('text=Audit Log').first();
if (await auditLink.isVisible({ timeout: 3000 }).catch(() => false)) {
await auditLink.click();
await page.waitForTimeout(2000);
}
// Operations
const opsLink = page.locator('text=Operations Hub').first();
if (await opsLink.isVisible({ timeout: 3000 }).catch(() => false)) {
await opsLink.click();
await page.waitForTimeout(2000);
}
// Settings
const intLink = page.locator('text=Integrations').first();
if (await intLink.isVisible({ timeout: 3000 }).catch(() => false)) {
await intLink.click();
await page.waitForTimeout(2000);
}
await snap(page, '17-smoke-complete');
const critical = criticalOnly(errors);
expect(critical, 'No critical errors during full smoke: ' + critical.join('\n')).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 16. CRITICAL ROUTE RENDERING (batch)
// ---------------------------------------------------------------------------
test.describe('Critical route rendering (all new/changed routes)', () => {
const routes = [
{ path: '/', name: 'Dashboard' },
{ path: '/releases/deployments', name: 'Deployments' },
{ path: '/releases', name: 'Releases' },
{ path: '/releases/readiness', name: 'Readiness' },
{ path: '/environments/overview', name: 'Environments' },
{ path: '/triage/artifacts', name: 'Vulnerabilities' },
{ path: '/security', name: 'Security Posture' },
{ path: '/security/findings', name: 'Findings Explorer' },
{ path: '/security/reachability', name: 'Reachability' },
{ path: '/security/unknowns', name: 'Unknowns' },
{ path: '/security/scan', name: 'Scan Image' },
{ path: '/ops/policy/vex', name: 'VEX & Exceptions' },
{ path: '/ops/policy/governance', name: 'Risk & Governance' },
{ path: '/ops/policy/governance/config', name: 'Governance Config' },
{ path: '/ops/policy/governance/tools', name: 'Governance Tools' },
{ path: '/ops/policy/simulation', name: 'Policy Simulation' },
{ path: '/ops/policy/simulation/testing', name: 'Simulation Test & Validate' },
{ path: '/ops/policy/simulation/review', name: 'Simulation Pre-Promotion Review' },
{ path: '/ops/policy/audit', name: 'Policy Audit' },
{ path: '/evidence/overview', name: 'Evidence Overview' },
{ path: '/evidence/capsules', name: 'Decision Capsules' },
{ path: '/evidence/audit-log', name: 'Audit Log' },
{ path: '/evidence/exports', name: 'Export Center' },
{ path: '/ops/operations', name: 'Operations Hub' },
{ path: '/ops/policy/packs', name: 'Policy Packs' },
{ path: '/ops/operations/jobengine', name: 'Scheduled Jobs' },
{ path: '/ops/operations/feeds-airgap', name: 'Feeds & Airgap' },
{ path: '/ops/operations/doctor', name: 'Diagnostics' },
{ path: '/setup/integrations', name: 'Integrations' },
{ path: '/setup/identity-access', name: 'Identity & Access' },
{ path: '/setup/trust-signing', name: 'Trust & Signing' },
{ path: '/setup/tenant-branding', name: 'Theme & Branding' },
];
for (const route of routes) {
test(`${route.name} (${route.path}) renders without errors`, async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, route.path);
const body = await page.locator('body').innerText();
expect(body.trim().length, `${route.name} should have content`).toBeGreaterThan(10);
const critical = criticalOnly(errors);
expect(critical, `${route.name} errors: ${critical.join('\n')}`).toHaveLength(0);
});
}
});
// ---------------------------------------------------------------------------
// 17. BACK/FORWARD NAVIGATION STABILITY
// ---------------------------------------------------------------------------
test.describe('Browser navigation stability', () => {
test('back/forward between reorganized routes works', async ({ authenticatedPage: page }) => {
const errors = collectErrors(page);
await go(page, '/');
await go(page, '/security');
await go(page, '/ops/policy/governance');
await go(page, '/evidence/audit-log');
// Go back 3 times
await page.goBack({ waitUntil: 'networkidle' });
await page.waitForTimeout(1000);
expect(page.url()).toContain('/ops/policy/governance');
await page.goBack({ waitUntil: 'networkidle' });
await page.waitForTimeout(1000);
expect(page.url()).toContain('/security');
await page.goBack({ waitUntil: 'networkidle' });
await page.waitForTimeout(1000);
// Go forward
await page.goForward({ waitUntil: 'networkidle' });
await page.waitForTimeout(1000);
expect(page.url()).toContain('/security');
expect(criticalOnly(errors)).toHaveLength(0);
});
});

View File

@@ -0,0 +1,468 @@
/**
* Release Workflow — Full E2E Test
*
* Exercises the complete release lifecycle against the live Docker stack:
* 1. Create Version (with image + script components)
* 2. Create Release from that version
* 3. Deploy release to first environment
* 4. Request promotion to second environment
* 5. Approve promotion
* 6. Verify deployment to second environment
*
* Uses the live stack at stella-ops.local with auth fixture.
* API calls that aren't fully wired get mock fallbacks.
*/
import { test, expect } from './fixtures/auth.fixture';
import type { Page, Route } from '@playwright/test';
const SCREENSHOT_DIR = 'e2e/screenshots/release-workflow';
async function snap(page: Page, label: string) {
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
}
// ── API mock fallbacks for endpoints not yet wired in the backend ──
const MOCK_BUNDLE = {
id: 'bndl-e2e-001',
slug: 'e2e-api-gateway',
name: 'E2E API Gateway',
description: 'End-to-end test bundle',
status: 'active',
};
const MOCK_VERSION = {
id: 'ver-e2e-001',
bundleId: 'bndl-e2e-001',
version: '1.0.0-e2e',
changelog: 'E2E workflow test',
status: 'sealed',
components: [
{ componentName: 'api-gateway', componentVersionId: 'cv-1', imageDigest: 'sha256:e2etest123abc456def', deployOrder: 1, metadataJson: '{}' },
{ componentName: 'db-migrate', componentVersionId: 'cv-2', imageDigest: 'script:migrate-v1', deployOrder: 0, metadataJson: '{"type":"script"}' },
],
createdAt: new Date().toISOString(),
};
const MOCK_RELEASE = {
id: 'rel-e2e-001',
name: 'E2E API Gateway',
version: '1.0.0-e2e',
description: 'E2E workflow test release',
status: 'ready',
releaseType: 'standard',
slug: 'e2e-api-gateway',
digest: 'sha256:e2etest123abc456def',
currentStage: null,
currentEnvironment: null,
targetEnvironment: 'staging',
targetRegion: 'us-east',
componentCount: 2,
gateStatus: 'pass',
gateBlockingCount: 0,
gatePendingApprovals: 0,
gateBlockingReasons: [],
riskCriticalReachable: 0,
riskHighReachable: 0,
riskTrend: 'stable',
riskTier: 'low',
evidencePosture: 'verified',
needsApproval: false,
blocked: false,
hotfixLane: false,
replayMismatch: false,
createdAt: new Date().toISOString(),
createdBy: 'e2e-test',
updatedAt: new Date().toISOString(),
lastActor: 'e2e-test',
deployedAt: null,
deploymentStrategy: 'rolling',
};
const MOCK_DEPLOYED_RELEASE = {
...MOCK_RELEASE,
status: 'deployed',
currentEnvironment: 'staging',
targetEnvironment: null,
deployedAt: new Date().toISOString(),
};
const MOCK_PROMOTION_APPROVAL = {
id: 'apr-e2e-001',
releaseId: 'rel-e2e-001',
releaseName: 'E2E API Gateway',
releaseVersion: '1.0.0-e2e',
sourceEnvironment: 'staging',
targetEnvironment: 'production',
requestedBy: 'e2e-test',
requestedAt: new Date().toISOString(),
urgency: 'normal',
justification: 'E2E workflow test promotion',
status: 'pending',
currentApprovals: 0,
requiredApprovals: 1,
gatesPassed: true,
expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(),
gateResults: [
{ gateId: 'g1', gateName: 'Security Scan', type: 'security', status: 'passed', message: 'Clean', evaluatedAt: new Date().toISOString() },
{ gateId: 'g2', gateName: 'Policy Compliance', type: 'policy', status: 'passed', message: 'OK', evaluatedAt: new Date().toISOString() },
],
releaseComponents: [
{ name: 'api-gateway', version: '1.0.0-e2e', digest: 'sha256:e2etest123abc456def' },
{ name: 'db-migrate', version: '1.0.0-e2e', digest: 'script:migrate-v1' },
],
};
async function setupWorkflowMocks(page: Page) {
// Bundle creation
await page.route('**/api/v1/release-control/bundles', (route: Route) => {
if (route.request().method() === 'POST') {
route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(MOCK_BUNDLE) });
} else {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [MOCK_BUNDLE] }) });
}
});
// Version publish
await page.route('**/api/v1/release-control/bundles/*/versions', (route: Route) => {
if (route.request().method() === 'POST') {
route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(MOCK_VERSION) });
} else {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [MOCK_VERSION] }) });
}
});
// Version materialize (create release from version)
await page.route('**/api/v1/release-control/bundles/*/versions/*/materialize', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_RELEASE) });
});
// Registry image search
await page.route('**/api/v1/registries/images/search**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
items: [
{ repository: 'stellaops/api-gateway', tag: 'v1.0.0', digest: 'sha256:e2etest123abc456def', size: 45_000_000, pushedAt: new Date().toISOString() },
{ repository: 'stellaops/worker', tag: 'v1.0.0', digest: 'sha256:worker789ghi012jkl', size: 32_000_000, pushedAt: new Date().toISOString() },
],
}) });
});
// Release detail
await page.route('**/api/v2/releases/rel-e2e-*', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_RELEASE) });
});
await page.route('**/api/v1/releases/rel-e2e-*', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_RELEASE) });
});
// Deploy
await page.route('**/api/v1/releases/*/deploy', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_DEPLOYED_RELEASE) });
});
await page.route('**/api/release-orchestrator/releases/*/deploy', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_DEPLOYED_RELEASE) });
});
// Promote
await page.route('**/api/v1/release-orchestrator/releases/*/promote', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_PROMOTION_APPROVAL) });
});
await page.route('**/api/release-orchestrator/releases/*/promote', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_PROMOTION_APPROVAL) });
});
// Approval list (return our pending promotion)
await page.route('**/api/release-orchestrator/approvals', (route: Route) => {
if (route.request().method() === 'GET') {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([MOCK_PROMOTION_APPROVAL]) });
} else {
route.continue();
}
});
// Approve
await page.route('**/api/release-orchestrator/approvals/*/approve', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
...MOCK_PROMOTION_APPROVAL,
status: 'approved',
currentApprovals: 1,
}) });
});
// Available environments for promotion
await page.route('**/available-environments**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([
{ id: 'env-staging', name: 'staging', displayName: 'Staging', isProduction: false },
{ id: 'env-production', name: 'production', displayName: 'Production', isProduction: true },
]) });
});
// Promotion preview
await page.route('**/promotion-preview**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
allGatesPassed: true,
requiredApprovers: 1,
estimatedDeployTime: '5m',
warnings: [],
gateResults: [
{ gateId: 'g1', gateName: 'Security', type: 'security', status: 'passed', message: 'Clean' },
],
}) });
});
// Release components
await page.route('**/releases/*/components', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
items: MOCK_VERSION.components,
}) });
});
// Release events
await page.route('**/releases/*/events', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
});
// Context APIs
await page.route('**/api/v2/context/regions', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
});
await page.route('**/api/v2/context/preferences', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: '{"regions":[],"environments":[]}' });
});
await page.route('**/api/v2/context/environments**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
});
// Releases list — must return ReleaseProjectionDto format (not ManagedRelease)
const MOCK_PROJECTION = {
releaseId: MOCK_RELEASE.id,
slug: MOCK_RELEASE.slug,
name: MOCK_RELEASE.name,
releaseType: MOCK_RELEASE.releaseType,
status: MOCK_RELEASE.status,
targetEnvironment: MOCK_RELEASE.targetEnvironment,
targetRegion: MOCK_RELEASE.targetRegion,
totalVersions: MOCK_RELEASE.componentCount,
latestVersionDigest: MOCK_RELEASE.digest,
createdAt: MOCK_RELEASE.createdAt,
updatedAt: MOCK_RELEASE.updatedAt,
latestPublishedAt: MOCK_RELEASE.updatedAt,
gate: {
status: MOCK_RELEASE.gateStatus,
blockingCount: MOCK_RELEASE.gateBlockingCount,
pendingApprovals: MOCK_RELEASE.gatePendingApprovals,
blockingReasons: [],
},
risk: {
criticalReachable: 0,
highReachable: 0,
trend: 'stable',
},
};
await page.route('**/api/v2/releases**', (route: Route) => {
const url = route.request().url();
if (url.match(/releases\/rel-/)) {
// Detail: return ReleaseDetailDto format
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ summary: MOCK_PROJECTION, recentActivity: [] }) });
return;
}
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
items: [MOCK_PROJECTION],
total: 1,
count: 1,
}) });
});
// Activity feed
await page.route('**/api/v2/releases/activity**', (route: Route) => {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
items: [
{ activityId: 'act-e2e-1', releaseId: MOCK_RELEASE.id, releaseName: MOCK_RELEASE.name, eventType: 'deployed', status: 'deployed', targetEnvironment: 'staging', targetRegion: 'us-east', actorId: 'e2e-test', occurredAt: new Date().toISOString(), correlationKey: 'ck-e2e-1' },
],
count: 1,
}) });
});
}
// ─── Tests ──────────────────────────────────────────────────────────
test.describe('Release Workflow — Full E2E', () => {
test('Step 1: Releases page shows versions and releases panels', async ({ authenticatedPage: page }) => {
await setupWorkflowMocks(page);
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
await page.waitForTimeout(2000);
// Both panels visible
const panels = page.locator('.panel');
expect(await panels.count()).toBe(2);
// Versions panel header
await expect(page.locator('.panel').first().locator('h2')).toContainText('Versions');
// Releases panel header
await expect(page.locator('.panel').nth(1).locator('h2')).toContainText('Releases');
// New Version button in header (the link, not the hint text)
await expect(page.locator('a:has-text("New Version")')).toBeVisible();
await snap(page, '01-releases-dual-panel');
});
test('Step 2: Version panel shows data with "+ Release" action', async ({ authenticatedPage: page }) => {
await setupWorkflowMocks(page);
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
await page.waitForTimeout(3000);
// The versions panel should contain version data
const versionsPanel = page.locator('.panel').first();
const panelText = await versionsPanel.innerText();
// Should show the version name from mock data
expect(panelText).toContain('e2e');
// Should have a "+ Release" link somewhere in the panel
const releaseLink = versionsPanel.locator('a:has-text("Release")');
if (await releaseLink.isVisible({ timeout: 3000 }).catch(() => false)) {
const href = await releaseLink.getAttribute('href');
expect(href).toContain('/releases/new');
}
await snap(page, '02-version-panel');
});
test('Step 3: Clicking version row highlights it and filters releases', async ({ authenticatedPage: page }) => {
await setupWorkflowMocks(page);
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
await page.waitForTimeout(3000);
// Click the first clickable row in the versions panel
const versionRow = page.locator('.ver-row').first();
if (await versionRow.isVisible({ timeout: 3000 }).catch(() => false)) {
await versionRow.click();
await page.waitForTimeout(1500);
// Filter indicator should appear
const filterBar = page.locator('.version-filter-bar');
if (await filterBar.isVisible({ timeout: 3000 }).catch(() => false)) {
await expect(filterBar).toContainText('Filtered by');
}
}
await snap(page, '03-version-filter');
});
test('Step 4: Releases panel shows release data', async ({ authenticatedPage: page }) => {
await setupWorkflowMocks(page);
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
await page.waitForTimeout(3000);
// The releases panel (second panel) should show data
const releasesPanel = page.locator('.panel').nth(1);
const panelText = await releasesPanel.innerText();
// Should contain release info from the mock
expect(panelText).toContain('E2E API Gateway');
// Should show gate/risk/evidence info
expect(panelText.toLowerCase()).toMatch(/pass|warn|block|verified|partial|missing/);
await snap(page, '04-releases-panel');
});
test('Step 5: Deployments page shows pipeline and approvals', async ({ authenticatedPage: page }) => {
await setupWorkflowMocks(page);
await page.goto('/releases/deployments', { waitUntil: 'networkidle', timeout: 30_000 });
await page.waitForTimeout(2000);
// Approvals panel should show our pending approval
const approvalsPanel = page.locator('.panel--approvals');
await expect(approvalsPanel).toBeVisible();
// Should show the E2E approval
const approvalRow = page.locator('.apr-row').first();
if (await approvalRow.isVisible({ timeout: 3000 }).catch(() => false)) {
await expect(approvalRow).toContainText('E2E API Gateway');
await expect(approvalRow).toContainText('staging');
await expect(approvalRow).toContainText('production');
// Approve button should be visible
const approveBtn = approvalRow.locator('button:has-text("Approve")');
await expect(approveBtn).toBeVisible();
}
// Pipeline panel should show activity
const pipelinePanel = page.locator('.panel--pipeline');
await expect(pipelinePanel).toBeVisible();
await snap(page, '05-deployments-page');
});
test('Step 6: Filter toggles are interactive', async ({ authenticatedPage: page }) => {
await setupWorkflowMocks(page);
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
await page.waitForTimeout(3000);
// Find and click a seg-btn filter
const filterBtns = page.locator('.seg-btn');
expect(await filterBtns.count()).toBeGreaterThan(0);
// Click "Pass" filter on versions panel
const passBtn = page.locator('.panel').first().locator('.seg-btn:has-text("Pass")');
if (await passBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await passBtn.click();
await page.waitForTimeout(1500);
// Should become active
await expect(passBtn).toHaveClass(/seg-btn--active/);
}
await snap(page, '06-filter-interactive');
});
test('Step 7: Data loads and panels are populated', async ({ authenticatedPage: page }) => {
await setupWorkflowMocks(page);
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
await page.waitForTimeout(3000);
// Both panels should have content (not empty state)
const panels = page.locator('.panel');
const firstPanelText = await panels.first().innerText();
const secondPanelText = await panels.nth(1).innerText();
// At least one panel should have real data
const hasData = firstPanelText.includes('e2e') || secondPanelText.includes('E2E');
expect(hasData).toBe(true);
await snap(page, '07-data-loaded');
});
test('Step 8: No "Create Deployment" on deployments page', async ({ authenticatedPage: page }) => {
await setupWorkflowMocks(page);
await page.goto('/releases/deployments', { waitUntil: 'networkidle', timeout: 30_000 });
await page.waitForTimeout(2000);
// "Create Deployment" should NOT appear anywhere
const body = await page.locator('body').innerText();
expect(body).not.toContain('Create Deployment');
await snap(page, '08-no-create-deployment');
});
test('Step 9: No critical Angular errors on releases page', async ({ authenticatedPage: page }) => {
const errors: string[] = [];
page.on('console', msg => { if (msg.type() === 'error') errors.push(msg.text()); });
page.on('pageerror', err => errors.push(err.message));
await setupWorkflowMocks(page);
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
await page.waitForTimeout(3000);
const critical = errors.filter(e => e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError'));
expect(critical, 'Critical errors: ' + critical.join('\n')).toHaveLength(0);
await snap(page, '09-no-errors');
});
});

View File

@@ -32,8 +32,8 @@ export interface ApprovalApi {
@Injectable()
export class ApprovalHttpClient implements ApprovalApi {
private readonly http = inject(HttpClient);
private readonly queueBaseUrl = '/api/v1/approvals';
private readonly detailBaseUrl = '/api/v1/approvals';
private readonly queueBaseUrl = '/api/release-orchestrator/approvals';
private readonly detailBaseUrl = '/api/release-orchestrator/approvals';
listApprovals(filter?: ApprovalFilter): Observable<ApprovalRequest[]> {
const params: Record<string, string> = {};
@@ -78,20 +78,16 @@ export class ApprovalHttpClient implements ApprovalApi {
}
approve(id: string, comment: string): Observable<ApprovalDetail> {
return this.http.post<any>(`${this.detailBaseUrl}/${id}/decision`, {
action: 'approve',
return this.http.post<any>(`${this.detailBaseUrl}/${id}/approve`, {
comment,
actor: 'ui-operator',
}).pipe(
map(row => this.mapV2ApprovalDetail(row))
);
}
reject(id: string, comment: string): Observable<ApprovalDetail> {
return this.http.post<any>(`${this.detailBaseUrl}/${id}/decision`, {
action: 'reject',
return this.http.post<any>(`${this.detailBaseUrl}/${id}/reject`, {
comment,
actor: 'ui-operator',
}).pipe(
map(row => this.mapV2ApprovalDetail(row))
);

View File

@@ -12,7 +12,7 @@ import { NavGroup, NavigationConfig } from './navigation.types';
*/
export const NAVIGATION_GROUPS: NavGroup[] = [
// -------------------------------------------------------------------------
// Home
// 1. Home
// -------------------------------------------------------------------------
{
id: 'home',
@@ -29,186 +29,157 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
},
// -------------------------------------------------------------------------
// Analyze - Scanning, vulnerabilities, and reachability
// 2. Release Control
// -------------------------------------------------------------------------
{
id: 'analyze',
label: 'Analyze',
icon: 'search',
id: 'release-control',
label: 'Release Control',
icon: 'package',
items: [
{
id: 'findings',
label: 'Scans & Findings',
route: '/findings',
icon: 'scan',
tooltip: 'View scan results and vulnerability findings',
id: 'environments',
label: 'Environments',
route: '/environments/overview',
icon: 'globe',
tooltip: 'Readiness, gate status, and promotion topology',
},
{
id: 'deployments',
label: 'Deployments',
route: '/releases/deployments',
icon: 'clock',
tooltip: 'Active deployments and approval queue',
},
{
id: 'releases',
label: 'Releases',
route: '/releases',
icon: 'package',
tooltip: 'Release versions and bundles',
},
],
},
// -------------------------------------------------------------------------
// 3. Security
// -------------------------------------------------------------------------
{
id: 'security',
label: 'Security',
icon: 'shield',
items: [
{
id: 'vulnerabilities',
label: 'Vulnerabilities',
route: '/vulnerabilities',
icon: 'bug',
tooltip: 'Explore vulnerability database',
},
{
id: 'graph',
label: 'SBOM Graph',
route: '/graph',
icon: 'graph',
requiredScopes: ['graph:read'],
tooltip: 'Visualize software bill of materials',
},
{
id: 'lineage',
label: 'Lineage',
route: '/security/lineage',
icon: 'git-branch',
tooltip: 'Explore SBOM lineage and smart diff',
},
{
id: 'reachability',
label: 'Reachability',
route: '/security/reachability',
icon: 'network',
tooltip: 'Reachability analysis and coverage',
},
{
id: 'vex-hub',
label: 'VEX & Exceptions',
route: '/ops/policy/vex',
icon: 'shield-check',
tooltip: 'Resolve VEX statements, conflicts, and exceptions in Decisioning Studio',
},
{
id: 'unknowns',
label: 'Unknowns',
route: '/security/unknowns',
icon: 'help-circle',
tooltip: 'Track and identify unknown components',
},
{
id: 'patch-map',
label: 'Patch Map',
route: '/security/patch-map',
icon: 'grid',
tooltip: 'Fleet-wide binary patch coverage heatmap',
},
],
},
// -------------------------------------------------------------------------
// Analytics - SBOM and attestation insights
// -------------------------------------------------------------------------
{
id: 'analytics',
label: 'Analytics',
icon: 'bar-chart',
requiredScopes: ['ui.read', 'analytics.read'],
items: [
{
id: 'sbom-lake',
label: 'SBOM Lake',
route: '/analytics/sbom-lake',
icon: 'chart',
tooltip: 'SBOM analytics lake dashboards and trends',
},
],
},
// -------------------------------------------------------------------------
// Findings - Artifact management and risk assessment
// -------------------------------------------------------------------------
{
id: 'triage',
label: 'Findings',
icon: 'filter',
items: [
{
id: 'artifacts',
label: 'Artifact Workspace',
route: '/triage/artifacts',
icon: 'package',
tooltip: 'Manage and triage artifacts',
icon: 'alert-triangle',
tooltip: 'Vulnerability triage queue',
},
{
id: 'exceptions',
label: 'Exception Queue',
route: '/exceptions',
icon: 'exception',
tooltip: 'Review and manage exceptions',
},
{
id: 'audit-bundles',
label: 'Audit Bundles',
route: '/triage/audit-bundles',
icon: 'archive',
tooltip: 'View audit bundle evidence',
},
{
id: 'risk',
label: 'Risk Profiles',
route: '/risk',
id: 'security-posture',
label: 'Security Posture',
route: '/security',
icon: 'shield',
tooltip: 'Risk assessment and profiles',
},
],
},
// -------------------------------------------------------------------------
// Policy - Policy authoring and governance
// -------------------------------------------------------------------------
{
id: 'policy',
label: 'Policy',
icon: 'policy',
items: [
{
id: 'policy-decisioning',
label: 'Policy Decisioning',
icon: 'edit',
tooltip: 'Security posture overview and trends',
children: [
{
id: 'policy-editor',
label: 'Packs',
route: '/ops/policy/packs',
requiredScopes: ['policy:author'],
tooltip: 'Author and edit policy packs',
id: 'supply-chain-data',
label: 'Supply-Chain Data',
route: '/security/supply-chain-data',
},
{
id: 'policy-simulate',
label: 'Simulate',
route: '/ops/policy/simulation',
requiredScopes: ['policy:simulate'],
tooltip: 'Test policies with simulations',
id: 'findings-explorer',
label: 'Findings Explorer',
route: '/security/findings',
},
{
id: 'policy-approvals',
label: 'VEX & Exceptions',
route: '/ops/policy/vex/exceptions',
requireAnyScope: ['policy:review', 'policy:approve'],
tooltip: 'Review and resolve policy exceptions',
id: 'reachability',
label: 'Reachability',
route: '/security/reachability',
},
{
id: 'policy-dashboard',
label: 'Overview',
route: '/ops/policy/overview',
requiredScopes: ['policy:read'],
tooltip: 'Policy metrics, packs, gates, and VEX status',
id: 'unknowns',
label: 'Unknowns',
route: '/security/unknowns',
},
],
},
{
id: 'jobengine',
label: 'Jobs & Orchestration',
route: OPERATIONS_PATHS.jobsQueues,
icon: 'workflow',
tooltip: 'View and manage orchestration jobs',
id: 'scan-image',
label: 'Scan Image',
route: '/security/scan',
icon: 'search',
tooltip: 'Scan container images',
},
{
id: 'vex-exceptions',
label: 'VEX & Exceptions',
route: '/ops/policy/vex',
icon: 'file-text',
tooltip: 'Manage VEX statements and policy exceptions',
},
{
id: 'risk-governance',
label: 'Risk & Governance',
route: '/ops/policy/governance',
icon: 'shield',
tooltip: 'Risk budgets, trust weights, and policy governance',
children: [
{
id: 'policy-simulation',
label: 'Simulation',
route: '/ops/policy/simulation',
requiredScopes: ['policy:simulate'],
},
{
id: 'policy-audit',
label: 'Policy Audit',
route: '/ops/policy/audit',
},
],
},
],
},
// -------------------------------------------------------------------------
// Ops - Operations and infrastructure
// 4. Evidence
// -------------------------------------------------------------------------
{
id: 'evidence',
label: 'Evidence',
icon: 'file-text',
items: [
{
id: 'evidence-overview',
label: 'Evidence Overview',
route: '/evidence/overview',
icon: 'file-text',
},
{
id: 'decision-capsules',
label: 'Decision Capsules',
route: '/evidence/capsules',
icon: 'archive',
},
{
id: 'audit-log',
label: 'Audit Log',
route: '/evidence/audit-log',
icon: 'log',
tooltip: 'Cross-module audit trail and compliance',
},
{
id: 'export-center',
label: 'Export Center',
route: '/evidence/exports',
icon: 'download',
tooltip: 'Export evidence for compliance',
},
],
},
// -------------------------------------------------------------------------
// 5. Operations
// -------------------------------------------------------------------------
{
id: 'ops',
@@ -216,269 +187,60 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
icon: 'server',
items: [
{
id: 'search-quality',
label: 'Search Quality',
route: '/ops/operations/search-quality',
icon: 'search',
requiredScopes: ['advisory-ai:admin'],
tooltip: 'Search feedback analytics, zero-result alerts, and quality metrics',
id: 'operations-hub',
label: 'Operations Hub',
route: '/ops/operations',
icon: 'server',
},
{
id: 'sbom-sources',
label: 'SBOM Sources',
route: '/sbom-sources',
icon: 'database',
tooltip: 'Manage SBOM ingestion sources and run history',
id: 'policy-packs',
label: 'Policy Packs',
route: '/ops/policy/packs',
icon: 'clipboard',
tooltip: 'Author and manage policy packs',
requiredScopes: ['policy:author'],
},
{
id: 'pack-registry',
label: 'Pack Registry',
route: OPERATIONS_PATHS.packs,
icon: 'package',
tooltip: 'Browse TaskRunner packs, verify DSSE metadata, and run compatibility-checked installs/upgrades',
id: 'scheduled-jobs',
label: 'Scheduled Jobs',
route: OPERATIONS_PATHS.jobsQueues,
icon: 'workflow',
},
{
id: 'quotas',
label: 'Quota Dashboard',
route: OPERATIONS_PATHS.quotas,
icon: 'gauge',
tooltip: 'License quota consumption and capacity planning',
children: [
{
id: 'quota-overview',
label: 'Overview',
route: OPERATIONS_PATHS.quotas,
tooltip: 'Quota consumption KPIs and trends',
},
{
id: 'quota-tenants',
label: 'Tenant Usage',
route: `${OPERATIONS_PATHS.quotas}/tenants`,
tooltip: 'Per-tenant quota consumption',
},
{
id: 'quota-throttle',
label: 'Throttle Events',
route: `${OPERATIONS_PATHS.quotas}/throttle`,
tooltip: 'Rate limit violations and recommendations',
},
{
id: 'quota-forecast',
label: 'Forecast',
route: `${OPERATIONS_PATHS.quotas}/forecast`,
tooltip: 'Quota exhaustion predictions',
},
{
id: 'quota-alerts',
label: 'Alert Config',
route: `${OPERATIONS_PATHS.quotas}/alerts`,
tooltip: 'Configure quota alert thresholds',
},
{
id: 'quota-reports',
label: 'Reports',
route: `${OPERATIONS_PATHS.quotas}/reports`,
tooltip: 'Export quota reports',
},
],
},
{
id: 'dead-letter',
label: 'Dead-Letter Queue',
route: OPERATIONS_PATHS.deadLetter,
icon: 'alert-triangle',
tooltip: 'Failed job recovery, replay, and resolution workflows',
children: [
{
id: 'dlq-dashboard',
label: 'Dashboard',
route: OPERATIONS_PATHS.deadLetter,
tooltip: 'Queue statistics and error distribution',
},
{
id: 'dlq-queue',
label: 'Queue Browser',
route: deadLetterQueuePath(),
tooltip: 'Browse and filter dead-letter entries',
},
],
},
{
id: 'slo-monitoring',
label: 'SLO Monitoring',
route: '/ops/jobengine/slo',
icon: 'activity',
tooltip: 'Service Level Objective health and burn rate tracking',
children: [
{
id: 'slo-dashboard',
label: 'Dashboard',
route: '/ops/jobengine/slo',
tooltip: 'SLO health summary and burn rates',
},
{
id: 'slo-alerts',
label: 'Alerts',
route: '/ops/jobengine/slo/alerts',
tooltip: 'Active and historical SLO alerts',
},
{
id: 'slo-definitions',
label: 'Definitions',
route: '/ops/jobengine/slo/definitions',
tooltip: 'Manage SLO definitions and thresholds',
},
],
},
{
id: 'platform-health',
label: 'Platform Health',
route: OPERATIONS_PATHS.healthSlo,
icon: 'heart-pulse',
tooltip: 'Unified service health and dependency monitoring',
children: [
{
id: 'health-dashboard',
label: 'Dashboard',
route: OPERATIONS_PATHS.healthSlo,
tooltip: 'Service health overview and status',
},
{
id: 'health-incidents',
label: 'Incidents',
route: `${OPERATIONS_PATHS.healthSlo}/incidents`,
tooltip: 'Incident timeline with correlation',
},
],
},
{
id: 'feed-mirror',
label: 'Feed Mirror & AirGap',
id: 'feeds-airgap',
label: 'Feeds & AirGap',
route: OPERATIONS_PATHS.feedsAirgap,
icon: 'mirror',
tooltip: 'Vulnerability feed mirroring, offline bundles, and version locks',
children: [
{
id: 'feed-dashboard',
label: 'Dashboard',
route: OPERATIONS_PATHS.feedsAirgap,
tooltip: 'Feed mirror dashboard and status',
},
{
id: 'airgap-import',
label: 'Import Bundle',
route: `${OPERATIONS_PATHS.feedsAirgap}?tab=airgap-bundles&action=import`,
tooltip: 'Import air-gapped bundles from external media',
},
{
id: 'airgap-export',
label: 'Export Bundle',
route: `${OPERATIONS_PATHS.feedsAirgap}?tab=airgap-bundles&action=export`,
tooltip: 'Create bundles for air-gapped deployment',
},
{
id: 'version-locks',
label: 'Version Locks',
route: `${OPERATIONS_PATHS.feedsAirgap}?tab=version-locks`,
tooltip: 'Lock feed versions for reproducible scans',
},
],
},
{
id: 'offline-kit',
label: 'Offline Kit',
route: OPERATIONS_PATHS.offlineKit,
icon: 'offline',
tooltip: 'Offline bundle management, verification, and JWKS',
children: [
{
id: 'offline-dashboard',
label: 'Dashboard',
route: `${OPERATIONS_PATHS.offlineKit}/dashboard`,
tooltip: 'Offline mode status and overview',
},
{
id: 'offline-bundles',
label: 'Bundles',
route: `${OPERATIONS_PATHS.offlineKit}/bundles`,
tooltip: 'Manage offline bundles and assets',
},
{
id: 'offline-verify',
label: 'Verification',
route: `${OPERATIONS_PATHS.offlineKit}/verify`,
tooltip: 'Verify audit bundles offline',
},
{
id: 'offline-jwks',
label: 'JWKS',
route: `${OPERATIONS_PATHS.offlineKit}/jwks`,
tooltip: 'Manage Authority JWKS for offline validation',
},
],
id: 'agent-fleet',
label: 'Agent Fleet',
route: '/ops/operations/agents',
icon: 'cpu',
},
{
id: 'aoc-compliance',
label: 'AOC Compliance',
route: OPERATIONS_PATHS.aoc,
icon: 'shield-check',
tooltip: 'Guard violations, ingestion flow, and provenance chain validation',
children: [
{
id: 'aoc-dashboard',
label: 'Dashboard',
route: OPERATIONS_PATHS.aoc,
tooltip: 'AOC compliance metrics and KPIs',
},
{
id: 'aoc-violations',
label: 'Guard Violations',
route: aocPath('violations'),
tooltip: 'View rejected payloads and reasons',
},
{
id: 'aoc-ingestion',
label: 'Ingestion Flow',
route: aocPath('ingestion'),
tooltip: 'Real-time ingestion metrics per source',
},
{
id: 'aoc-provenance',
label: 'Provenance Validator',
route: aocPath('provenance'),
tooltip: 'Validate provenance chains for advisories',
},
{
id: 'aoc-report',
label: 'Compliance Report',
route: aocPath('report'),
tooltip: 'Export compliance reports for auditors',
},
],
id: 'signals',
label: 'Signals',
route: '/ops/operations/signals',
icon: 'radio',
},
{
id: 'scripts',
label: 'Scripts',
route: '/ops/scripts',
icon: 'code',
},
{
id: 'diagnostics',
label: 'Diagnostics',
route: '/ops/operations/doctor',
icon: 'activity',
},
],
},
// -------------------------------------------------------------------------
// Notify - Notifications and alerts
// -------------------------------------------------------------------------
{
id: 'notify',
label: 'Notify',
icon: 'bell',
items: [
{
id: 'notifications',
label: 'Notifications',
route: OPERATIONS_PATHS.notifications,
icon: 'notification',
tooltip: 'Notification center',
},
],
},
// -------------------------------------------------------------------------
// Admin - System administration (scoped)
// 6. Admin
// -------------------------------------------------------------------------
{
id: 'admin',
@@ -487,161 +249,127 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
requiredScopes: ['ui.admin'],
items: [
{
id: 'tenants',
label: 'Tenants',
route: '/console/admin/tenants',
icon: 'building',
},
{
id: 'users',
label: 'Users',
route: '/console/admin/users',
icon: 'users',
},
{
id: 'roles',
label: 'Roles & Scopes',
route: '/console/admin/roles',
id: 'identity-access',
label: 'Identity & Access',
icon: 'key',
children: [
{
id: 'tenants',
label: 'Tenants',
route: '/console/admin/tenants',
icon: 'building',
},
{
id: 'users',
label: 'Users',
route: '/console/admin/users',
icon: 'users',
},
{
id: 'roles',
label: 'Roles & Scopes',
route: '/console/admin/roles',
icon: 'key',
},
{
id: 'clients',
label: 'OAuth Clients',
route: '/console/admin/clients',
icon: 'app',
},
{
id: 'tokens',
label: 'Tokens',
route: '/console/admin/tokens',
icon: 'token',
},
{
id: 'identity-providers',
label: 'Identity Providers',
route: '/setup/identity-providers',
icon: 'id-card',
},
],
},
{
id: 'clients',
label: 'OAuth Clients',
route: '/console/admin/clients',
icon: 'app',
id: 'security-trust',
label: 'Security & Trust',
icon: 'shield',
children: [
{
id: 'trust-management',
label: 'Trust Management',
route: '/setup/trust-signing',
icon: 'certificate',
},
{
id: 'registry-tokens',
label: 'Registry Tokens',
route: '/admin/registries',
icon: 'container',
},
{
id: 'trivy-db',
label: 'Trivy DB Settings',
route: '/concelier/trivy-db-settings',
icon: 'database',
},
{
id: 'scanner-ops',
label: 'Scanner Ops',
route: SCANNER_OPS_ROOT,
icon: 'scan',
},
],
},
{
id: 'tokens',
label: 'Tokens',
route: '/console/admin/tokens',
icon: 'token',
id: 'platform-config',
label: 'Platform Config',
icon: 'settings',
children: [
{
id: 'branding',
label: 'Branding',
route: '/setup/tenant-branding',
icon: 'palette',
},
{
id: 'platform-status',
label: 'Platform Status',
route: '/console/status',
icon: 'monitor',
},
{
id: 'notification-admin',
label: 'Notification Admin',
route: '/setup/notifications',
icon: 'bell-config',
},
],
},
{
id: 'audit',
label: 'Unified Audit Log',
id: 'audit-compliance',
label: 'Audit & Compliance',
route: '/evidence/audit-log',
icon: 'log',
tooltip: 'Cross-module audit trail and compliance reporting',
children: [
{
id: 'audit-dashboard',
label: 'Dashboard',
route: '/evidence/audit-log',
tooltip: 'Audit log overview and stats',
},
{
id: 'audit-events',
label: 'All Events',
route: '/evidence/audit-log/events',
tooltip: 'Browse all audit events with filters',
},
{
id: 'audit-policy',
label: 'Policy Audit',
route: '/evidence/audit-log/policy',
tooltip: 'Policy promotions and approvals',
},
{
id: 'audit-authority',
label: 'Authority Audit',
route: '/evidence/audit-log/authority',
tooltip: 'Token lifecycle and incidents',
},
{
id: 'audit-vex',
label: 'VEX Audit',
route: '/evidence/audit-log/vex',
tooltip: 'VEX decisions and consensus',
},
{
id: 'audit-integrations',
label: 'Integration Audit',
route: '/evidence/audit-log/integrations',
tooltip: 'Integration configuration changes',
},
{
id: 'audit-export',
label: 'Export',
route: '/evidence/audit-log/export',
tooltip: 'Export audit logs for compliance',
},
],
},
{
id: 'branding',
label: 'Branding',
route: '/setup/tenant-branding',
icon: 'palette',
},
{
id: 'platform-status',
label: 'Platform Status',
route: '/console/status',
icon: 'monitor',
},
{
id: 'trivy-db',
label: 'Trivy DB Settings',
route: '/concelier/trivy-db-settings',
icon: 'database',
},
{
id: 'admin-notifications',
label: 'Notification Admin',
route: '/setup/notifications',
icon: 'bell-config',
tooltip: 'Configure notification rules, channels, and templates',
},
{
id: 'admin-trust',
label: 'Trust Management',
route: '/setup/trust-signing',
icon: 'certificate',
tooltip: 'Manage signing keys, issuers, and certificates',
},
{
id: 'policy-governance',
label: 'Policy Governance',
route: '/ops/policy/governance',
icon: 'policy-config',
tooltip: 'Risk budgets, trust weights, and sealed mode',
},
{
id: 'policy-simulation',
label: 'Policy Simulation',
route: '/ops/policy/simulation',
icon: 'test-tube',
tooltip: 'Shadow mode and policy simulation studio',
},
{
id: 'registry-admin',
label: 'Registry Tokens',
route: '/admin/registries',
icon: 'container',
tooltip: 'Manage registry token plans and access rules',
},
{
id: 'issuer-trust',
label: 'Issuer Directory',
route: '/setup/trust-signing/issuers',
icon: 'shield-check',
tooltip: 'Manage issuer trust and key lifecycle',
},
{
id: 'scanner-ops',
label: 'Scanner Ops',
route: SCANNER_OPS_ROOT,
icon: 'scan',
tooltip: 'Scanner offline kits, baselines, and determinism settings',
},
{
id: 'identity-providers',
label: 'Identity Providers',
route: '/setup/identity-providers',
icon: 'id-card',
requiredScopes: ['ui.admin'],
tooltip: 'Configure external identity providers (LDAP, SAML, OIDC)',
},
],
},
];

View File

@@ -92,6 +92,9 @@ interface PendingAction {
<h1 class="board-title">Dashboard</h1>
<p class="board-subtitle">{{ tenantLabel() }}</p>
</div>
<div class="header-quick-links">
<stella-quick-links [links]="dashboardQuickLinks" label="Quick Links" layout="strip" />
</div>
</header>
@if (!contextReady()) {
@@ -161,11 +164,14 @@ interface PendingAction {
<a routerLink="/setup/topology/environments" queryParamsHandling="merge" class="section-link">All environments</a>
</div>
<div class="env-grid-wrapper" [class.can-scroll-left]="showPipelineLeftArrow()" [class.can-scroll-right]="showPipelineRightArrow()">
<div class="env-grid-wrapper">
@if (showPipelineLeftArrow()) {
<div class="env-fade env-fade--left"></div>
<button
class="scroll-arrow scroll-arrow--left"
(click)="scrollPipeline('left')"
(mouseenter)="startPipelineAutoScroll('left')"
(mouseleave)="stopPipelineAutoScroll()"
(click)="jumpPipelineScroll('left')"
aria-label="Scroll left"
type="button"
>
@@ -249,9 +255,12 @@ interface PendingAction {
}
</div>
@if (showPipelineRightArrow()) {
<div class="env-fade env-fade--right"></div>
<button
class="scroll-arrow scroll-arrow--right"
(click)="scrollPipeline('right')"
(mouseenter)="startPipelineAutoScroll('right')"
(mouseleave)="stopPipelineAutoScroll()"
(click)="jumpPipelineScroll('right')"
aria-label="Scroll right"
type="button"
>
@@ -453,10 +462,6 @@ interface PendingAction {
</a>
</div>
<!-- 6. Quick Links (right aside) -->
<aside class="dashboard-aside">
<stella-quick-links [links]="dashboardQuickLinks" label="Quick Links" layout="aside" />
</aside>
}
</div>
`,
@@ -472,15 +477,19 @@ interface PendingAction {
display: flex;
flex-direction: column;
gap: 1.25rem;
background: var(--color-surface-primary);
}
/* -- Header (full width) ------------------------------------------------- */
.board-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
gap: 1.5rem;
}
.header-identity {
flex-shrink: 0;
}
.board-title {
@@ -495,6 +504,11 @@ interface PendingAction {
margin: 0.25rem 0 0;
}
.header-quick-links {
flex: 0 1 60%;
min-width: 0;
}
.refresh-btn {
padding: 0.4rem 1rem;
font-size: 0.8rem;
@@ -1114,13 +1128,6 @@ interface PendingAction {
text-decoration: underline;
}
.dashboard-aside {
margin-top: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
padding: 0.75rem 0;
background: var(--color-surface-elevated);
}
/* Status dot (reused in summary cards) */
.status-dot {
@@ -1141,32 +1148,26 @@ interface PendingAction {
position: relative;
max-width: 100%;
padding: 0.75rem 1rem 0;
--_env-fade-color: var(--color-surface-primary);
}
/* Left gradient fade */
.env-grid-wrapper.can-scroll-left::before {
content: '';
.env-fade {
position: absolute;
top: 0;
bottom: 0;
width: 56px;
pointer-events: none;
z-index: 1;
}
.env-fade--left {
left: 0;
bottom: 0;
width: 56px;
background: linear-gradient(to right, var(--color-surface-primary) 0%, transparent 100%);
pointer-events: none;
z-index: 1;
background: linear-gradient(to right, var(--_env-fade-color) 30%, transparent 100%);
}
/* Right gradient fade */
.env-grid-wrapper.can-scroll-right::after {
content: '';
position: absolute;
top: 0;
.env-fade--right {
right: 0;
bottom: 0;
width: 56px;
background: linear-gradient(to left, var(--color-surface-primary) 0%, transparent 100%);
pointer-events: none;
z-index: 1;
background: linear-gradient(to left, var(--_env-fade-color) 30%, transparent 100%);
}
.scroll-arrow {
@@ -1382,7 +1383,7 @@ export class DashboardV3Component implements OnInit, AfterViewInit, OnDestroy {
readonly riskTableAtTop = signal(true);
readonly dashboardQuickLinks: StellaQuickLink[] = [
{ label: 'Release Runs', route: '/releases/runs', description: 'Deployment timeline and run history' },
{ label: 'Deployments', route: '/releases/deployments', description: 'Deployment timeline and run history' },
{ label: 'Security & Risk', route: '/security', description: 'Posture, findings, and reachability' },
{ label: 'Operations', route: '/ops/operations', description: 'Platform health and execution control' },
{ label: 'Evidence', route: '/evidence', description: 'Decision capsules and audit trail' },
@@ -1447,6 +1448,7 @@ export class DashboardV3Component implements OnInit, AfterViewInit, OnDestroy {
ngOnDestroy(): void {
this.pageAction.clear();
this.stopPipelineAutoScroll();
}
ngAfterViewInit(): void {
@@ -1468,14 +1470,42 @@ export class DashboardV3Component implements OnInit, AfterViewInit, OnDestroy {
this.updatePipelineArrows();
}
scrollPipeline(direction: 'left' | 'right'): void {
// -- Pipeline auto-scroll (hover = animate, click = jump) ------------------
private pipelineAutoRaf = 0;
private pipelineAutoDir: 'left' | 'right' | null = null;
startPipelineAutoScroll(dir: 'left' | 'right'): void {
this.pipelineAutoDir = dir;
if (!this.pipelineAutoRaf) {
this.ngZone.runOutsideAngular(() => this.tickPipeline());
}
}
stopPipelineAutoScroll(): void {
this.pipelineAutoDir = null;
if (this.pipelineAutoRaf) {
cancelAnimationFrame(this.pipelineAutoRaf);
this.pipelineAutoRaf = 0;
}
}
jumpPipelineScroll(dir: 'left' | 'right'): void {
this.stopPipelineAutoScroll();
const el = this.pipelineScrollRef?.nativeElement;
if (!el) return;
const scrollAmount = 300;
el.scrollBy({
left: direction === 'left' ? -scrollAmount : scrollAmount,
behavior: 'smooth',
});
el.scrollBy({ left: dir === 'right' ? 300 : -300, behavior: 'smooth' });
}
private tickPipeline = (): void => {
const el = this.pipelineScrollRef?.nativeElement;
if (!el || !this.pipelineAutoDir) { this.pipelineAutoRaf = 0; return; }
el.scrollLeft += this.pipelineAutoDir === 'right' ? 2 : -2;
this.pipelineAutoRaf = requestAnimationFrame(this.tickPipeline);
};
/** @deprecated Use jumpPipelineScroll instead */
scrollPipeline(direction: 'left' | 'right'): void {
this.jumpPipelineScroll(direction);
}
onRiskTableScroll(): void {

View File

@@ -56,7 +56,7 @@ interface TocHeading {
<ul>
@for (heading of headings(); track heading.id) {
<li [class]="'docs-viewer__toc-level-' + heading.level">
<a [href]="'#' + heading.id">{{ heading.text }}</a>
<a [href]="'#' + heading.id" [title]="heading.text">{{ heading.text }}</a>
</li>
}
</ul>
@@ -166,6 +166,8 @@ interface TocHeading {
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.9rem;
min-width: 0;
overflow: hidden;
}
.docs-viewer__toc strong {
@@ -181,9 +183,10 @@ interface TocHeading {
padding: 0;
display: grid;
gap: 0.25rem;
min-width: 0;
}
.docs-viewer__toc li { margin: 0; }
.docs-viewer__toc li { margin: 0; min-width: 0; }
.docs-viewer__toc a {
color: var(--color-text-secondary);
@@ -192,6 +195,9 @@ interface TocHeading {
line-height: 1.4;
display: block;
padding: 0.1rem 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.docs-viewer__toc a:hover { color: var(--color-text-link); }
@@ -383,9 +389,10 @@ export class DocsViewerComponent {
const markdown = await firstValueFrom(this.http.get(candidate, { responseType: 'text' }));
if (requestVersion !== this.requestVersion) return;
// Extract title from first H1
// Extract title from first H1, strip inline markdown formatting
const titleMatch = markdown.match(/^#\s+(.+)$/m);
this.title.set(titleMatch?.[1]?.trim() ?? 'Documentation');
const rawTitle = titleMatch?.[1]?.trim() ?? 'Documentation';
this.title.set(rawTitle.replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1').replace(/`([^`]+)`/g, '$1'));
this.resolvedAssetPath.set(candidate);
this.rawMarkdown.set(markdown);
this.loading.set(false);

View File

@@ -449,7 +449,7 @@ export class EvidenceAuditOverviewComponent {
];
readonly relatedDomainLinks: StellaQuickLink[] = [
{ label: 'Release Control', route: '/releases/runs', description: 'Evidence attached to releases and promotions' },
{ label: 'Deployments', route: '/releases/deployments', description: 'Evidence attached to releases and promotions' },
{ label: 'Trust & Signing', route: '/setup/trust-signing', description: 'Key management and signing policy' },
{ label: 'Policy Governance', route: '/ops/policy/governance', description: 'Policy packs driving evidence requirements' },
{ label: 'Findings', route: '/security/findings', description: 'Findings linked to evidence records' },

View File

@@ -19,9 +19,6 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
<div class="jobengine-dashboard">
<header class="jobengine-dashboard__header">
<div>
<a [routerLink]="OPERATIONS_PATHS.jobsQueues" class="jobengine-dashboard__back">
&larr; Back to Jobs & Queues
</a>
<h1>Scheduled Jobs</h1>
<p>Execution queues, quotas, dead-letter recovery, and scheduler handoffs.</p>
</div>
@@ -191,19 +188,6 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
color: var(--color-text-secondary);
}
.jobengine-dashboard__back {
display: inline-block;
margin-bottom: 0.65rem;
color: var(--color-status-info);
text-decoration: none;
font-size: 0.85rem;
transition: color 150ms ease;
}
.jobengine-dashboard__back:hover {
filter: brightness(0.85);
}
.jobengine-dashboard__actions {
display: flex;
gap: 0.75rem;

View File

@@ -15,9 +15,9 @@ import { RouterLink } from '@angular/router';
<div class="cards">
<article>
<h2>Release Runs</h2>
<h2>Deployments</h2>
<p>Latest standard and hotfix promotions with gate checkpoints.</p>
<a routerLink="/releases/runs" queryParamsHandling="merge">Open Runs</a>
<a routerLink="/releases/deployments" queryParamsHandling="merge">Open Deployments</a>
</article>
<article>
<h2>Evidence</h2>

View File

@@ -8,7 +8,7 @@
</div>
<div class="ops-overview__header-right">
<stella-quick-links class="ops-overview__inline-links" [links]="quickNav" label="Jump to" layout="aside" />
<stella-quick-links class="ops-overview__inline-links" [links]="quickNav" label="Quick Links" layout="strip" />
<div class="ops-overview__actions">
<a [routerLink]="OPERATIONS_PATHS.doctor">Run Doctor</a>
<a routerLink="/evidence/audit-log">Audit Log</a>

View File

@@ -22,9 +22,10 @@
.ops-overview__header-right {
display: flex;
flex-direction: column;
align-items: flex-end;
align-items: stretch;
gap: 0.5rem;
flex-shrink: 0;
flex: 0 1 60%;
min-width: 0;
}
.ops-overview__inline-links {

View File

@@ -53,7 +53,7 @@ interface PromotionRule {
</article>
<footer class="links">
<a routerLink="/releases/runs">Open Release Runs</a>
<a routerLink="/releases/deployments">Open Deployments</a>
<a routerLink="/setup/topology/promotion-graph">Open Topology Path View</a>
</footer>
</section>

View File

@@ -15,7 +15,7 @@ import { RouterLink } from '@angular/router';
<div class="doors">
<a routerLink="/releases/versions" queryParamsHandling="merge">Release Versions</a>
<a routerLink="/releases/runs" queryParamsHandling="merge">Release Runs</a>
<a routerLink="/releases/deployments" queryParamsHandling="merge">Deployments</a>
<a routerLink="/releases/approvals" queryParamsHandling="merge">Approvals Queue</a>
<a routerLink="/releases/hotfixes" queryParamsHandling="merge">Hotfixes</a>
<a routerLink="/releases/promotions" queryParamsHandling="merge">Promotions</a>

View File

@@ -17,12 +17,9 @@ import type { ApprovalRequest, ApprovalDetail } from '../../core/api/approval.mo
import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/confirm-dialog.component';
import { ModalComponent } from '../../shared/components/modal/modal.component';
const VIEW_MODE_TABS: StellaPageTab[] = [
{ id: 'timeline', label: 'Pipeline', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' },
{ id: 'approvals', label: 'Approvals', icon: 'M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' },
];
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { PageActionOutletComponent } from '../../shared/components/page-action-outlet/page-action-outlet.component';
import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component';
import { RelativeTimePipe } from '../../shared/pipes/format.pipes';
interface ReleaseActivityProjection {
activityId: string;
releaseId: string;
@@ -62,276 +59,239 @@ function deriveOutcomeIcon(status: string): string {
@Component({
selector: 'app-releases-activity',
standalone: true,
imports: [RouterLink, FormsModule, TimelineListComponent, StellaPageTabsComponent, StellaFilterChipComponent, PaginationComponent, PageActionOutletComponent, ConfirmDialogComponent, ModalComponent],
imports: [RouterLink, FormsModule, TimelineListComponent, StellaFilterChipComponent, PaginationComponent, PageActionOutletComponent, ConfirmDialogComponent, ModalComponent, StatusBadgeComponent, RelativeTimePipe],
template: `
<section class="activity">
<header>
<h1>Deployments</h1>
<p>Deployment runs, approvals, and promotion activity.</p>
</header>
<!-- Pending approvals inline lane (only visible on Pipeline tab) -->
@if (showPendingLane()) {
<div class="pending-lane" [class.pending-lane--exiting]="pendingLaneExiting()">
<div class="pending-lane__header">
<h2 class="pending-lane__title">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
</svg>
{{ pendingApprovals().length }} Pending Approval{{ pendingApprovals().length === 1 ? '' : 's' }}
</h2>
<button type="button" class="pending-lane__link" (click)="onTabChange('approvals')">
View all <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
<div class="apc-lane">
@for (apr of pendingApprovals().slice(0, 5); track apr.id) {
<div class="apc" [class.apc--prod]="isProductionEnv(apr.targetEnvironment)" [class.apc--expiring]="isExpiringSoon(apr.expiresAt)">
<div class="apc__head">
<div class="apc__id"><span class="apc__name">{{ apr.releaseName }}</span>@if (apr.releaseVersion && apr.releaseVersion !== apr.releaseName) {<span class="apc__ver">{{ apr.releaseVersion }}</span>}</div>
<span class="urgency-chip" [attr.data-urgency]="apr.urgency">{{ apr.urgency }}</span>
</div>
<div class="apc__envs">
<span class="apc__env">{{ apr.sourceEnvironment }}</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="apc__arr"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
<span class="apc__env" [class.apc__env--prod]="isProductionEnv(apr.targetEnvironment)">{{ apr.targetEnvironment }}</span>
</div>
<div class="apc__meta">
<span class="apc__who">by {{ apr.requestedBy }}</span>
<span class="apc__gate" [class.apc__gate--pass]="apr.gatesPassed" [class.apc__gate--fail]="!apr.gatesPassed">{{ apr.gatesPassed ? 'Gates OK' : 'Gates fail' }}</span>
<span class="apc__exp" [class.text-warning]="isExpiringSoon(apr.expiresAt)">{{ timeRemaining(apr.expiresAt) }}</span>
</div>
<div class="apc__btns">
<button class="btn btn-primary btn--sm" (click)="onApprove(apr)" type="button">Approve</button>
<button class="btn btn-danger btn--sm" (click)="onReject(apr)" type="button">Reject</button>
<button class="btn btn-secondary btn--sm" (click)="onView(apr)" type="button">View</button>
</div>
</div>
}
<div class="page-hdr">
<div class="hdr-row">
<div>
<h1>Deployments</h1>
<p class="page-sub">Deployment pipeline and approval queue.</p>
</div>
<app-page-action-outlet />
</div>
}
</div>
<!-- Approve production confirmation -->
<!-- ── Dialogs (kept as-is) ── -->
<app-confirm-dialog #approveConfirm title="Approve Production Deployment"
[message]="'Approve ' + (activeApr()?.releaseName ?? '') + ' ' + (activeApr()?.releaseVersion ?? '') + ' to PRODUCTION?'"
confirmLabel="Approve" cancelLabel="Cancel" variant="warning" (confirmed)="confirmApprove()" />
<!-- Reject dialog -->
@if (showRejectDlg()) {
<div class="dlg-overlay" (click)="cancelReject()" role="presentation">
<div class="dlg-box" (click)="$event.stopPropagation()" role="alertdialog" aria-modal="true">
<h2 class="dlg-box__title">Reject Release</h2>
<p class="dlg-box__msg">Reject <strong>{{ activeApr()?.releaseName }}</strong> {{ activeApr()?.releaseVersion }} to <strong>{{ activeApr()?.targetEnvironment }}</strong>?</p>
<label class="dlg-box__label" for="rejectReason">Reason (optional)</label>
<textarea id="rejectReason" class="dlg-box__ta" [(ngModel)]="rejectReason" rows="3" placeholder="Why are you rejecting?"></textarea>
<div class="dlg-box__actions">
<button type="button" class="dlg-btn dlg-btn--cancel" (click)="cancelReject()">Cancel</button>
<button type="button" class="dlg-btn dlg-btn--danger" (click)="confirmReject()">Reject</button>
</div>
@if (showRejectDlg()) {
<div class="dlg-overlay" (click)="cancelReject()" role="presentation">
<div class="dlg-box" (click)="$event.stopPropagation()" role="alertdialog" aria-modal="true">
<h2 class="dlg-box__title">Reject Release</h2>
<p class="dlg-box__msg">Reject <strong>{{ activeApr()?.releaseName }}</strong> {{ activeApr()?.releaseVersion }} to <strong>{{ activeApr()?.targetEnvironment }}</strong>?</p>
<label class="dlg-box__label" for="rejectReason">Reason (optional)</label>
<textarea id="rejectReason" class="dlg-box__ta" [(ngModel)]="rejectReason" rows="3" placeholder="Why are you rejecting?"></textarea>
<div class="dlg-box__actions">
<button type="button" class="dlg-btn dlg-btn--cancel" (click)="cancelReject()">Cancel</button>
<button type="button" class="dlg-btn dlg-btn--danger" (click)="confirmReject()">Reject</button>
</div>
</div>
}
<!-- Detail popup -->
<app-modal [open]="showDetailDlg()" (closed)="closeDetail()" [title]="detailApr()?.releaseName ?? 'Release'" [description]="'Version ' + (detailApr()?.releaseVersion ?? '')" size="lg" iconVariant="info">
@if (detailApr(); as d) {
<div class="det">
<div class="det__sec"><h3 class="det__h">Promotion</h3>
<div class="det__r"><span class="det__l">From</span><span>{{ d.sourceEnvironment }}</span></div>
<div class="det__r"><span class="det__l">To</span><span [class.det__prod]="isProductionEnv(d.targetEnvironment)">{{ d.targetEnvironment }}</span></div>
<div class="det__r"><span class="det__l">Urgency</span><span>{{ d.urgency }}</span></div>
<div class="det__r"><span class="det__l">Requested by</span><span>{{ d.requestedBy }}</span></div>
<div class="det__r"><span class="det__l">Approvals</span><span>{{ d.currentApprovals }} / {{ d.requiredApprovals }}</span></div>
</div>
@if (d.justification) { <div class="det__sec"><h3 class="det__h">Justification</h3><p class="det__txt">{{ d.justification }}</p></div> }
@if (detailFull()?.gateResults?.length) {
<div class="det__sec"><h3 class="det__h">Gate Results</h3>
@for (g of detailFull()!.gateResults; track g.gateId) {
<div class="det__gate"><span class="gate-chip" [attr.data-gate]="g.status === 'passed' ? 'pass' : g.status">{{ g.status }}</span><strong>{{ g.gateName }}</strong><span class="det__gmsg">{{ g.message }}</span></div>
}
</div>
}
@if (detailFull()?.releaseComponents?.length) {
<div class="det__sec"><h3 class="det__h">Components</h3>
@for (c of detailFull()!.releaseComponents; track c.name) { <div class="det__r"><span class="det__l">{{ c.name }}</span><span class="det__mono">{{ c.version }}</span></div> }
</div>
}
</div>
} @else { <div style="text-align:center;padding:2rem;color:var(--color-text-muted)">Loading...</div> }
<div modal-footer><button class="dlg-btn dlg-btn--cancel" (click)="closeDetail()">Close</button></div>
</app-modal>
<stella-page-tabs
[tabs]="viewModeTabs"
[activeTab]="viewMode()"
urlParam="view"
(tabChange)="onTabChange($any($event))"
ariaLabel="Run list views"
>
<app-page-action-outlet tabBarAction />
@if (viewMode() === 'approvals') {
<!-- Approvals tab content -->
<div class="activity-filters">
<div class="filter-search">
<svg class="filter-search__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
</svg>
<input type="text" class="filter-search__input" placeholder="Search approvals..."
[value]="approvalSearchQuery()" (input)="approvalSearchQuery.set($any($event.target).value)" />
</div>
@for (toggle of gateToggles; track toggle.id) {
<button type="button"
class="gate-toggle"
[class.gate-toggle--active]="gateToggleState()[toggle.id]"
(click)="toggleGate(toggle.id)">
{{ toggle.label }}
</button>
}
</div>
@if (approvalsLoading()) {
<div class="approvals-skeleton">
@for (i of [1,2,3]; track i) {
<div class="skeleton-row">
<div class="skeleton-cell skeleton-cell--wide"></div>
<div class="skeleton-cell skeleton-cell--wide"></div>
<div class="skeleton-cell skeleton-cell--sm"></div>
</div>
}
</div>
} @else if (filteredApprovals().length === 0) {
<div class="empty-state">No approvals match the active gate filters.</div>
} @else {
<!-- Summary cards -->
<div class="gate-summary">
<div class="gate-summary__card gate-summary__card--pending">
<strong>{{ countByStatus('pending') }}</strong>
<span>Pending Approvals</span>
</div>
<div class="gate-summary__card gate-summary__card--blocked">
<strong>{{ countByGateResult(false) }}</strong>
<span>Gate Failures</span>
</div>
<div class="gate-summary__card gate-summary__card--passed">
<strong>{{ countByGateResult(true) }}</strong>
<span>Gates Passed</span>
</div>
</div>
<table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered">
<thead>
<tr>
<th>Release</th>
<th>Promotion</th>
<th>Gate Type</th>
<th>Status</th>
<th>Reason</th>
<th>Requested</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (apr of filteredApprovals(); track apr.id) {
<tr [class.row--prod]="isProductionEnv(apr.targetEnvironment)">
<td>
<strong>{{ apr.releaseName }}</strong>
@if (apr.releaseVersion && apr.releaseVersion !== apr.releaseName) {
<br><span class="muted mono">{{ apr.releaseVersion }}</span>
}
</td>
<td>{{ apr.sourceEnvironment }} → {{ apr.targetEnvironment }}</td>
<td><span class="gate-type-chip" [attr.data-gate]="deriveGateType(apr)">{{ deriveGateType(apr) }}</span></td>
<td><span class="status-chip" [attr.data-status]="apr.status">{{ apr.status }}</span></td>
<td class="muted" [title]="apr.gatesPassed ? 'All gates passed' : 'One or more gates blocking'">
{{ apr.gatesPassed ? 'All gates passed' : 'Gates blocking — review required' }}
</td>
<td class="muted">{{ formatDate(apr.requestedAt) }}<br>by {{ apr.requestedBy }}</td>
<td>
<div class="row-actions">
@if (apr.status === 'pending') {
<button class="btn btn-primary btn--sm" (click)="onApprove(apr)" type="button">Approve</button>
<button class="btn btn-danger btn--sm" (click)="onReject(apr)" type="button">Reject</button>
}
@if (!apr.gatesPassed) {
<a class="btn btn-secondary btn--sm" routerLink="/ops/policy/packs">Policy</a>
}
<button class="btn btn-secondary btn--sm" (click)="onView(apr)" type="button">View</button>
</div>
</td>
</tr>
} @empty {
<tr><td colspan="7" style="text-align:center;padding:1.5rem;color:var(--color-text-muted)">No approvals or gate evaluations match the current filters.</td></tr>
}
</tbody>
</table>
}
} @else {
<!-- Existing deployment views: filters + timeline/table/correlations -->
<div class="activity-filters">
<div class="filter-search">
<svg class="filter-search__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
</svg>
<input type="text" class="filter-search__input" placeholder="Search activity..."
[value]="searchQuery()" (input)="searchQuery.set($any($event.target).value); currentPage.set(1)" />
</div>
<stella-filter-chip label="Status" [value]="statusFilter()" [options]="statusChipOptions" (valueChange)="statusFilter.set($event); currentPage.set(1); applyFilters()" />
<stella-filter-chip label="Env" [value]="envFilter()" [options]="envChipOptions" (valueChange)="envFilter.set($event); currentPage.set(1); applyFilters()" />
<stella-filter-chip label="Outcome" [value]="outcomeFilter()" [options]="outcomeChipOptions" (valueChange)="outcomeFilter.set($event); currentPage.set(1); applyFilters()" />
</div>
@if (error()) {
<div class="banner error">{{ error() }}</div>
}
@if (loading()) {
<div class="banner">Loading release runs...</div>
} @else {
@switch (viewMode()) {
@case ('timeline') {
<!-- Canonical timeline rendering -->
<div class="timeline-container">
<app-timeline-list
[events]="timelineEvents()"
[loading]="loading()"
[groupByDate]="true"
emptyMessage="No runs match the active filters."
ariaLabel="Release activity timeline"
>
<ng-template #eventContent let-event>
@if (event.metadata) {
<div class="run-meta">
@if (event.metadata['lane']) {
<span class="run-chip">{{ event.metadata['lane'] }}</span>
}
@if (event.metadata['environment']) {
<span class="run-chip">{{ event.metadata['environment'] }}</span>
}
@if (event.metadata['outcome']) {
<span class="run-chip run-chip--outcome" [attr.data-outcome]="event.metadata['outcome']">{{ event.metadata['outcome'] }}</span>
}
@if (event.evidenceLink) {
<a class="run-link" [routerLink]="event.evidenceLink">View run</a>
}
</div>
}
</ng-template>
</app-timeline-list>
</div>
}
}
}
}
</stella-page-tabs>
<app-modal [open]="showDetailDlg()" (closed)="closeDetail()" [title]="detailApr()?.releaseName ?? 'Release'" [description]="'Version ' + (detailApr()?.releaseVersion ?? '')" size="lg" iconVariant="info">
@if (detailApr(); as d) {
<div class="det">
<div class="det__sec"><h3 class="det__h">Promotion</h3>
<div class="det__r"><span class="det__l">From</span><span>{{ d.sourceEnvironment }}</span></div>
<div class="det__r"><span class="det__l">To</span><span [class.det__prod]="isProductionEnv(d.targetEnvironment)">{{ d.targetEnvironment }}</span></div>
<div class="det__r"><span class="det__l">Urgency</span><span>{{ d.urgency }}</span></div>
<div class="det__r"><span class="det__l">Requested by</span><span>{{ d.requestedBy }}</span></div>
<div class="det__r"><span class="det__l">Approvals</span><span>{{ d.currentApprovals }} / {{ d.requiredApprovals }}</span></div>
</div>
@if (d.justification) { <div class="det__sec"><h3 class="det__h">Justification</h3><p class="det__txt">{{ d.justification }}</p></div> }
@if (detailFull()?.gateResults?.length) {
<div class="det__sec"><h3 class="det__h">Gate Results</h3>
@for (g of detailFull()!.gateResults; track g.gateId) {
<div class="det__gate"><span class="gate-chip" [attr.data-gate]="g.status === 'passed' ? 'pass' : g.status">{{ g.status }}</span><strong>{{ g.gateName }}</strong><span class="det__gmsg">{{ g.message }}</span></div>
}
</div>
}
@if (detailFull()?.releaseComponents?.length) {
<div class="det__sec"><h3 class="det__h">Components</h3>
@for (c of detailFull()!.releaseComponents; track c.name) { <div class="det__r"><span class="det__l">{{ c.name }}</span><span class="det__mono">{{ c.version }}</span></div> }
</div>
}
</div>
} @else { <div style="text-align:center;padding:2rem;color:var(--color-text-muted)">Loading...</div> }
<div modal-footer><button class="dlg-btn dlg-btn--cancel" (click)="closeDetail()">Close</button></div>
</app-modal>
<!-- ═══════ TWO-PANEL LAYOUT: Approvals first ═══════ -->
<div class="dual-panel">
<!-- ── LEFT: Approvals Queue ── -->
<div class="panel panel--approvals">
<div class="panel__head">
<h2>Approvals @if (pendingApprovals().length > 0) { <span class="badge badge--warn">{{ pendingApprovals().length }}</span> }</h2>
<div class="head-toggles">
<div class="seg-group">
<button class="seg-btn" [class.seg-btn--active]="approvalStatusFilter() === 'pending'" (click)="switchApprovalMode('pending')">Action Required</button>
<button class="seg-btn" [class.seg-btn--active]="approvalStatusFilter() === 'all'" (click)="switchApprovalMode('all')">All</button>
</div>
<div class="seg-group">
<button class="seg-btn" [class.seg-btn--active]="gateFilter() === 'all'" (click)="switchGateFilter('all')">All Gates</button>
<button class="seg-btn" [class.seg-btn--active]="gateFilter() === 'blocked'" (click)="switchGateFilter('blocked')">Blocked</button>
<button class="seg-btn" [class.seg-btn--active]="gateFilter() === 'passed'" (click)="switchGateFilter('passed')">Passed</button>
</div>
<button class="sort-btn" (click)="toggleApprovalSort()" title="Sort by requested date">
{{ approvalSortAsc() ? '↑' : '↓' }} Date
</button>
</div>
</div>
@if (pendingLoading() || approvalsLoading()) {
<div class="skeleton-list">
@for (i of [1,2,3]; track i) {
<div class="skeleton-row"><div class="skeleton-cell skeleton-cell--wide"></div><div class="skeleton-cell skeleton-cell--wide"></div><div class="skeleton-cell skeleton-cell--sm"></div></div>
}
</div>
} @else if (pagedApprovals().length === 0) {
<div class="empty-state">
@if (approvalStatusFilter() === 'pending') {
<p>No pending approvals — all caught up.</p>
<p class="empty-hint">Approvals appear here when a release needs manual sign-off before promotion.</p>
} @else {
<p>No historical approvals match the current filters.</p>
<p class="empty-hint">Try adjusting the gate or status filters above.</p>
}
</div>
} @else {
<div class="apr-list">
@for (apr of pagedApprovals(); track apr.id) {
<div class="apr-row" [class.apr-row--prod]="isProductionEnv(apr.targetEnvironment)" [class.apr-row--expiring]="isExpiringSoon(apr.expiresAt)">
<div class="apr-row__main">
<div class="apr-row__release">
<strong>{{ apr.releaseName }}</strong>
@if (apr.releaseVersion && apr.releaseVersion !== apr.releaseName) {
<span class="mono muted">{{ apr.releaseVersion }}</span>
}
</div>
<div class="apr-row__flow">
<span>{{ apr.sourceEnvironment }}</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
<span [class.env--prod]="isProductionEnv(apr.targetEnvironment)">{{ apr.targetEnvironment }}</span>
</div>
<div class="apr-row__meta">
<span class="urgency-chip" [attr.data-urgency]="apr.urgency">{{ apr.urgency }}</span>
<span class="status-chip" [attr.data-status]="apr.status">{{ apr.status }}</span>
<span class="muted">{{ apr.requestedAt | relativeTime }}</span>
</div>
</div>
<div class="apr-row__actions">
@if (apr.status === 'pending') {
<button class="btn btn-primary btn--sm" (click)="onApprove(apr)" type="button">Approve</button>
<button class="btn btn-danger btn--sm" (click)="onReject(apr)" type="button">Reject</button>
}
<button class="btn btn-secondary btn--sm" (click)="onView(apr)" type="button">View</button>
</div>
</div>
}
</div>
@if (sortedApprovals().length > approvalPageSize) {
<app-pagination [total]="sortedApprovals().length" [pageSize]="approvalPageSize" [currentPage]="approvalPage()" (pageChange)="approvalPage.set($event.page)" [compact]="true" />
}
}
</div>
<!-- ── RIGHT: Pipeline ── -->
<div class="panel panel--pipeline">
<div class="panel__head">
<h2>Pipeline</h2>
<div class="head-toggles">
<div class="seg-group">
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === ''" (click)="statusFilter.set(''); currentPage.set(1); load()">All</button>
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === 'pending_approval'" (click)="statusFilter.set('pending_approval'); currentPage.set(1); load()">Pending</button>
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === 'approved' || statusFilter() === 'published'" (click)="statusFilter.set('published'); currentPage.set(1); load()">Published</button>
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === 'blocked' || statusFilter() === 'rejected'" (click)="statusFilter.set('blocked'); currentPage.set(1); load()">Blocked</button>
</div>
<div class="seg-group">
<button class="seg-btn" [class.seg-btn--active]="outcomeFilter() === ''" (click)="outcomeFilter.set(''); currentPage.set(1); load()">All</button>
<button class="seg-btn" [class.seg-btn--active]="outcomeFilter() === 'success'" (click)="outcomeFilter.set('success'); currentPage.set(1); load()">Success</button>
<button class="seg-btn" [class.seg-btn--active]="outcomeFilter() === 'failed'" (click)="outcomeFilter.set('failed'); currentPage.set(1); load()">Failed</button>
</div>
</div>
</div>
@if (error()) { <div class="banner error">{{ error() }}</div> }
@if (loading()) {
<div class="skeleton-list">
@for (i of [1,2,3,4,5]; track i) {
<div class="skeleton-row"><div class="skeleton-cell skeleton-cell--xs"></div><div class="skeleton-cell skeleton-cell--wide"></div><div class="skeleton-cell skeleton-cell--sm"></div></div>
}
</div>
} @else {
<div class="timeline-container">
<app-timeline-list
[events]="pagedTimelineEvents()"
[loading]="loading()"
[groupByDate]="true"
emptyMessage="No deployment runs yet. Create a deployment to see pipeline activity here."
ariaLabel="Release activity timeline"
>
<ng-template #eventContent let-event>
@if (event.metadata) {
<div class="run-meta">
@if (event.metadata['lane']) { <span class="run-chip">{{ event.metadata['lane'] }}</span> }
@if (event.metadata['environment']) { <span class="run-chip">{{ event.metadata['environment'] }}</span> }
@if (event.metadata['outcome']) { <span class="run-chip run-chip--outcome" [attr.data-outcome]="event.metadata['outcome']">{{ event.metadata['outcome'] }}</span> }
@if (event.evidenceLink) { <a class="run-link" [routerLink]="event.evidenceLink">View run</a> }
</div>
}
</ng-template>
</app-timeline-list>
</div>
@if (timelineEvents().length > pipelinePageSize) {
<app-pagination [total]="timelineEvents().length" [pageSize]="pipelinePageSize" [currentPage]="currentPage()" (pageChange)="currentPage.set($event.page)" [compact]="true" />
}
}
</div>
</div>
</section>
`,
styles: [`
.activity{display:grid;gap:.6rem}.activity header h1{margin:0}.activity header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem}
.activity{display:grid;gap:.6rem}
.hdr-row{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;flex-wrap:wrap}
.page-hdr h1{margin:0}.page-sub{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem}
/* ── Dual-panel responsive layout ── */
.dual-panel{display:grid;grid-template-columns:repeat(auto-fit,minmax(500px,1fr));gap:.65rem;align-items:start}
.panel{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary);padding:.65rem;display:grid;gap:.5rem}
.panel__head{display:flex;justify-content:space-between;align-items:center;padding-bottom:.35rem;border-bottom:1px solid var(--color-border-primary)}
.panel__head h2{margin:0;font-size:.88rem;font-weight:600;display:flex;align-items:center;gap:.35rem}
.badge{font-size:.6rem;padding:.1rem .35rem;border-radius:var(--radius-full);font-weight:700}
.badge--warn{background:var(--color-status-warning-bg);color:var(--color-status-warning-text)}
/* ── Title bar toggles ── */
.head-toggles{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap}
.seg-group{display:inline-flex;border:1px solid var(--color-border-primary);border-radius:var(--radius-sm);overflow:hidden}
.sort-btn{padding:.2rem .45rem;border:1px solid var(--color-border-primary);border-radius:var(--radius-sm);background:transparent;font-size:.66rem;font-weight:500;color:var(--color-text-muted);cursor:pointer;transition:all 150ms}
.sort-btn:hover{color:var(--color-text-primary);border-color:var(--color-border-emphasis)}
.seg-btn{padding:.2rem .5rem;border:none;background:transparent;font-size:.68rem;font-weight:500;color:var(--color-text-muted);cursor:pointer;transition:all 150ms}
.seg-btn:hover:not(.seg-btn--active){color:var(--color-text-secondary);background:var(--color-surface-tertiary,rgba(0,0,0,.04))}
.seg-btn--active{background:var(--color-surface-tertiary,rgba(0,0,0,.04));color:var(--color-text-primary);font-weight:600}
/* ── Approval row cards ── */
.apr-list{display:grid;gap:.35rem}
.apr-row{display:flex;justify-content:space-between;align-items:center;gap:.5rem;padding:.45rem .55rem;border:1px solid var(--color-border-primary);border-radius:var(--radius-sm);transition:background 120ms}
.apr-row:hover{background:var(--color-surface-secondary)}
.apr-row--prod{border-left:3px solid var(--color-status-warning)}
.apr-row--expiring{border-left:3px solid var(--color-severity-high,#c2410c)}
.apr-row__main{display:grid;gap:.2rem;min-width:0;flex:1}
.apr-row__release{display:flex;align-items:center;gap:.35rem;font-size:.78rem}
.apr-row__release strong{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.apr-row__flow{display:flex;align-items:center;gap:.25rem;font-size:.68rem;color:var(--color-text-secondary)}
.env--prod{color:var(--color-status-warning-text);font-weight:600}
.apr-row__meta{display:flex;align-items:center;gap:.25rem;flex-wrap:wrap}
.apr-row__actions{display:flex;gap:.25rem;align-items:center;flex-shrink:0}
.context{display:flex;gap:.35rem;flex-wrap:wrap}.context span{border:1px solid var(--color-border-primary);border-radius:var(--radius-full);padding:.1rem .45rem;font-size:.7rem;color:var(--color-text-secondary)}
/* Approvals skeleton loading */
/* Skeleton loading (shared by both panels) */
.skeleton-list{display:flex;flex-direction:column;gap:.5rem;padding:.5rem 0}
.approvals-skeleton{display:flex;flex-direction:column;gap:.5rem;padding:.75rem 0}
.skeleton-row{display:flex;gap:.75rem;align-items:center}
.skeleton-cell{height:12px;border-radius:var(--radius-sm);background:var(--color-surface-tertiary);animation:skeleton-pulse 1.2s ease-in-out infinite}
@@ -433,7 +393,9 @@ function deriveOutcomeIcon(status: string): string {
.pending-lane__link{display:inline-flex;align-items:center;gap:.25rem;background:none;border:none;font-size:.8rem;font-weight:500;color:var(--color-status-warning-text);cursor:pointer;text-decoration:underline;text-underline-offset:2px}
.pending-lane__link:hover{color:var(--color-text-primary)}
.empty-state{text-align:center;padding:2.5rem;color:var(--color-text-muted);border:1px dashed var(--color-border-primary);border-radius:var(--radius-lg);margin-top:.5rem}
.empty-state{text-align:center;padding:1.5rem;color:var(--color-text-muted);border:1px dashed var(--color-border-primary);border-radius:var(--radius-lg)}
.empty-state p{margin:.15rem 0}
.empty-hint{font-size:.72rem;color:var(--color-text-muted);opacity:.7}
.apc__btns{display:flex;border-top:1px solid var(--color-border-primary)}
.apc__btns .btn{flex:1;justify-content:center;border-radius:0;border:none;border-right:1px solid var(--color-border-primary)}
@@ -519,23 +481,29 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
private readonly approvalApi = inject(APPROVAL_API);
ngOnInit(): void {
this.pageAction.set({ label: 'Create Deployment', route: '/releases/deployments/new' });
// Deployments are created by release start/promotion, not directly
}
ngOnDestroy(): void {
this.pageAction.clear();
}
readonly viewModeTabs = VIEW_MODE_TABS;
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly rows = signal<ReleaseActivityProjection[]>([]);
readonly viewMode = signal<'timeline' | 'approvals'>('timeline');
readonly viewMode = signal<'timeline'>('timeline');
readonly approvalStatusFilter = signal<'pending' | 'all'>('pending');
readonly gateFilter = signal<'all' | 'blocked' | 'passed'>('all');
readonly approvalSortAsc = signal(false);
readonly approvalPage = signal(1);
readonly approvalPageSize = 10;
readonly pipelinePageSize = 15;
// ── Pending approvals card lane ──────────────────────────────────
@ViewChild('apcScroll') apcScrollRef?: ElementRef<HTMLDivElement>;
@ViewChild('approveConfirm') approveConfirmRef!: ConfirmDialogComponent;
readonly pendingApprovals = signal<ApprovalRequest[]>([]);
readonly pendingLoading = signal(false);
readonly pendingBannerCollapsed = signal(false);
readonly showApcLeft = signal(false);
readonly showApcRight = signal(false);
@@ -549,7 +517,6 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
// ── Approvals tab state ───────────────────────────────────────────
readonly allApprovals = signal<ApprovalRequest[]>([]);
readonly approvalsLoading = signal(false);
private _approvalsFetched = false;
readonly gateToggles = [
{ id: 'gated', label: 'Gated' },
@@ -595,6 +562,76 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
return result;
});
/** Approvals after status + gate filtering */
readonly displayedApprovals = computed(() => {
const statusF = this.approvalStatusFilter();
const gateF = this.gateFilter();
let result: ApprovalRequest[];
if (statusF === 'pending') {
result = [...this.pendingApprovals()];
} else {
result = [...this.filteredApprovals()];
}
// Gate filter
if (gateF === 'blocked') result = result.filter(a => !a.gatesPassed);
if (gateF === 'passed') result = result.filter(a => a.gatesPassed);
return result;
});
/** Sorted approvals */
readonly sortedApprovals = computed(() => {
const data = [...this.displayedApprovals()];
const asc = this.approvalSortAsc();
data.sort((a, b) => {
const cmp = a.requestedAt.localeCompare(b.requestedAt);
return asc ? cmp : -cmp;
});
return data;
});
/** Paged approvals */
readonly pagedApprovals = computed(() => {
const all = this.sortedApprovals();
const start = (this.approvalPage() - 1) * this.approvalPageSize;
return all.slice(start, start + this.approvalPageSize);
});
/** Paged timeline events */
readonly pagedTimelineEvents = computed(() => {
const all = this.timelineEvents();
const start = (this.currentPage() - 1) * this.pipelinePageSize;
return all.slice(start, start + this.pipelinePageSize);
});
toggleApprovalSort(): void {
this.approvalSortAsc.update(v => !v);
this.approvalPage.set(1);
this.reloadCurrentApprovals();
}
switchApprovalMode(mode: 'pending' | 'all'): void {
this.approvalStatusFilter.set(mode);
this.approvalPage.set(1);
this.reloadCurrentApprovals();
}
switchGateFilter(gate: 'all' | 'blocked' | 'passed'): void {
this.gateFilter.set(gate);
this.approvalPage.set(1);
this.reloadCurrentApprovals();
}
private reloadCurrentApprovals(): void {
if (this.approvalStatusFilter() === 'pending') {
this.loadPendingApprovals();
} else {
this.loadApprovals();
}
}
// Lane filter reads from global context (header toggle)
// ── Filter-chip options ──────────────────────────────────────────────
@@ -720,26 +757,24 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
});
this.route.queryParamMap.subscribe((params) => {
const view = (params.get('view') ?? '').toLowerCase();
if (view && (view === 'timeline' || view === 'approvals')) {
if (this.viewMode() !== view) {
this.viewMode.set(view);
if (view === 'approvals') this.loadApprovals();
}
}
if (params.get('status')) this.statusFilter.set(params.get('status')!);
if (params.get('lane')) this.context.setReleaseLane(params.get('lane') as 'standard' | 'hotfix');
if (params.get('env')) this.envFilter.set(params.get('env')!);
if (params.get('outcome')) this.outcomeFilter.set(params.get('outcome')!);
});
// Load pending approvals for the banner
// Load pending approvals
this.loadPendingApprovals();
// Load pipeline data once on init, then only on explicit context changes
this.load();
let lastCtxVersion = this.context.contextVersion();
effect(() => {
this.context.contextVersion();
this.load();
const v = this.context.contextVersion();
if (v !== lastCtxVersion) {
lastCtxVersion = v;
this.load();
}
});
}
@@ -797,33 +832,7 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
return parsed.toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
readonly pendingLaneExiting = signal(false);
readonly showPendingLane = computed(() =>
this.pendingApprovals().length > 0 && this.viewMode() === 'timeline' && !this.pendingLaneHidden()
);
private readonly pendingLaneHidden = signal(false);
onTabChange(tab: string): void {
// Animate pending lane out before switching to approvals
if (tab === 'approvals' && this.pendingApprovals().length > 0 && this.viewMode() === 'timeline') {
this.pendingLaneExiting.set(true);
setTimeout(() => {
this.pendingLaneHidden.set(true);
this.pendingLaneExiting.set(false);
this.viewMode.set('approvals');
this.loadApprovals();
this.applyFilters();
}, 250);
return;
}
// When switching back to pipeline, show pending lane again
if (tab === 'timeline') {
this.pendingLaneHidden.set(false);
}
this.viewMode.set(tab as 'timeline' | 'approvals');
if (tab === 'approvals') this.loadApprovals();
this.applyFilters();
}
// Tab switching removed — both panels visible simultaneously
toggleGate(id: string): void {
const current = this.gateToggleState();
@@ -914,15 +923,14 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
closeDetail(): void { this.showDetailDlg.set(false); this.detailApr.set(null); this.detailFull.set(null); }
private loadPendingApprovals(): void {
this.pendingLoading.set(true);
this.approvalApi.listApprovals({ statuses: ['pending'] }).pipe(take(1)).subscribe({
next: (approvals) => { this.pendingApprovals.set(approvals); setTimeout(() => this.updateApcArrows(), 150); },
error: () => this.pendingApprovals.set([]),
next: (approvals) => { this.pendingApprovals.set(approvals); this.pendingLoading.set(false); },
error: () => { this.pendingApprovals.set([]); this.pendingLoading.set(false); },
});
}
private loadApprovals(): void {
if (this._approvalsFetched) return;
this._approvalsFetched = true;
loadApprovals(): void {
this.approvalsLoading.set(true);
this.approvalApi.listApprovals().pipe(take(1)).subscribe({
next: (approvals) => {
@@ -936,7 +944,7 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
});
}
private load(): void {
load(): void {
this.loading.set(true);
this.error.set(null);

View File

@@ -1,38 +1,29 @@
/**
* Releases Unified Page Component
* Releases Unified Page — Dual-Panel Layout
*
* Combines release versions, deployments, hotfixes, and approvals into a single
* tabbed interface with decision capsules on each release row.
* Left panel: Versions (sealed artifact catalog)
* Right panel: Releases (plans for deployment, filtered by selected version)
*
* Tab 1 "Pipeline": unified release table (standard + hotfix) with contextual actions.
* Tab 2 "Approvals": embeds the existing ApprovalQueueComponent.
* Clicking a version row filters the releases panel. "+ Release" on a version
* row navigates to create a release from that version.
*/
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, ViewChild, effect, inject, signal, computed } from '@angular/core';
import { PageActionService } from '../../core/services/page-action.service';
import { PageActionOutletComponent } from '../../shared/components/page-action-outlet/page-action-outlet.component';
import { UpperCasePipe, SlicePipe } from '@angular/common';
import { RouterLink, ActivatedRoute } from '@angular/router';
import { RouterLink } from '@angular/router';
import { take } from 'rxjs';
import { APPROVAL_API } from '../../core/api/approval.client';
import type { ApprovalApi } from '../../core/api/approval.client';
import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/confirm-dialog.component';
import { StellaFilterChipComponent, FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
import { PaginationComponent, PageChangeEvent } from '../../shared/components/pagination/pagination.component';
import { TableColumn } from '../../shared/components/data-table/data-table.component';
import { ReleaseManagementStore } from '../release-orchestrator/releases/release.store';
import { ReleaseListComponent } from '../release-orchestrator/releases/release-list/release-list.component';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component';
import { RelativeTimePipe } from '../../shared/pipes/format.pipes';
import type { ReleaseWorkflowStatus } from '../../core/api/release-management.models';
const RELEASE_TABS: readonly StellaPageTab[] = [
{ id: 'releases', label: 'Releases', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z' },
{ id: 'versions', label: 'Versions', icon: 'M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5' },
];
// ── Data model ──────────────────────────────────────────────────────────────
export interface PipelineRelease {
id: string;
name: string;
@@ -52,194 +43,174 @@ export interface PipelineRelease {
lastActor: string;
}
// ── Component ───────────────────────────────────────────────────────────────
@Component({
selector: 'app-releases-unified-page',
standalone: true,
imports: [
UpperCasePipe,
SlicePipe,
RouterLink,
StellaFilterChipComponent,
StellaPageTabsComponent,
PaginationComponent,
PageActionOutletComponent,
ReleaseListComponent,
ConfirmDialogComponent,
UpperCasePipe, SlicePipe, RouterLink, PaginationComponent,
PageActionOutletComponent, ConfirmDialogComponent, StatusBadgeComponent, RelativeTimePipe,
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="rup">
<header class="rup__header">
<div class="rup__header">
<div>
<h1 class="rup__title">Release Control</h1>
<p class="rup__subtitle">{{ activeSubtitle() }}</p>
<h1>Releases</h1>
<p class="rup__sub">Version catalog and release plans.</p>
</div>
<app-page-action-outlet />
</header>
</div>
<stella-page-tabs [tabs]="releaseTabs" [activeTab]="activeTab()" urlParam="tab"
(tabChange)="activeTab.set($event)" ariaLabel="Releases tabs">
<app-confirm-dialog #approveConfirm
title="Approve Release"
[message]="approveMessage()"
confirmLabel="Approve" cancelLabel="Cancel" variant="warning"
(confirmed)="executeApprove()" />
@if (activeTab() === 'releases') {
<!-- Pipeline -->
<div class="rup__filters">
<div class="rup__search">
<svg class="rup__search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
</svg>
<input type="text" class="rup__search-input" placeholder="Search releases..."
[value]="searchQuery()" (input)="searchQuery.set($any($event.target).value); currentPage.set(1)" />
<!-- ═══════ DUAL-PANEL ═══════ -->
<div class="dual-panel">
<!-- ── LEFT: Versions ── -->
<div class="panel">
<div class="panel__head">
<h2>Versions <span class="badge">{{ releases().length }}</span></h2>
<div class="head-toggles">
<div class="seg-group">
<button class="seg-btn" [class.seg-btn--active]="versionGateFilter() === ''" (click)="setVersionGate('')">All</button>
<button class="seg-btn" [class.seg-btn--active]="versionGateFilter() === 'pass'" (click)="setVersionGate('pass')">Pass</button>
<button class="seg-btn" [class.seg-btn--active]="versionGateFilter() === 'block'" (click)="setVersionGate('block')">Block</button>
</div>
<button class="sort-btn" (click)="toggleVersionSort()">{{ versionSortAsc() ? '↑' : '↓' }} Date</button>
</div>
<stella-filter-chip label="Status" [value]="statusFilter()" [options]="statusOptions" (valueChange)="statusFilter.set($event); currentPage.set(1)" />
<stella-filter-chip label="Gates" [value]="gateFilter()" [options]="gateOptions" (valueChange)="gateFilter.set($event); currentPage.set(1)" />
</div>
<!-- Releases table -->
@if (sortedReleases().length === 0) {
<div class="rup__empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
<line x1="12" y1="22.08" x2="12" y2="12"/>
</svg>
<p class="rup__empty-title">No releases found</p>
<p class="rup__empty-text">Create a new release or adjust your filters.</p>
@if (store.loading()) {
<div class="skeleton-list">
@for (i of [1,2,3,4]; track i) {
<div class="skeleton-row"><div class="skeleton-cell skeleton-cell--wide"></div><div class="skeleton-cell skeleton-cell--sm"></div></div>
}
</div>
} @else if (pagedVersions().length === 0) {
<div class="empty-state">
<p>No versions match the current filters.</p>
<p class="empty-hint">Create a new version to start building releases.</p>
</div>
} @else {
<div class="rup__table-wrap">
<div class="ver-list">
@for (v of pagedVersions(); track v.id) {
<div class="ver-row" [class.ver-row--selected]="selectedVersionId() === v.id" (click)="selectVersion(v)">
<div class="ver-row__main">
<div class="ver-row__id">
<span class="ver-row__digest">{{ v.digest | slice:0:16 }}...</span>
<strong class="ver-row__name">{{ v.version || v.name }}</strong>
</div>
<div class="ver-row__meta">
<span class="lane-badge" [class.lane-badge--hotfix]="v.lane === 'hotfix'">{{ v.lane }}</span>
<span class="ver-row__env">{{ v.environment }}</span>
<app-status-badge [status]="gateToStatus(v.gateStatus)" [label]="v.gateStatus" size="sm" />
<span class="muted">{{ v.updatedAt | relativeTime }}</span>
</div>
</div>
<a class="btn btn--xs btn--secondary ver-row__create" [routerLink]="['/releases/new']" [queryParams]="{ versionId: v.id }" (click)="$event.stopPropagation()">+ Release</a>
</div>
}
</div>
@if (filteredVersions().length > versionPageSize) {
<app-pagination [total]="filteredVersions().length" [pageSize]="versionPageSize" [currentPage]="versionPage()" (pageChange)="versionPage.set($event.page)" [compact]="true" />
}
}
</div>
<!-- ── RIGHT: Releases ── -->
<div class="panel">
<div class="panel__head">
<h2>Releases</h2>
<div class="head-toggles">
<div class="seg-group">
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === ''" (click)="setStatus('')">All</button>
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === 'draft'" (click)="setStatus('draft')">Draft</button>
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === 'ready'" (click)="setStatus('ready')">Ready</button>
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === 'deployed'" (click)="setStatus('deployed')">Deployed</button>
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === 'failed'" (click)="setStatus('failed')">Failed</button>
</div>
<div class="seg-group">
<button class="seg-btn" [class.seg-btn--active]="gateFilter() === ''" (click)="setGate('')">All</button>
<button class="seg-btn" [class.seg-btn--active]="gateFilter() === 'pass'" (click)="setGate('pass')">Pass</button>
<button class="seg-btn" [class.seg-btn--active]="gateFilter() === 'warn'" (click)="setGate('warn')">Warn</button>
<button class="seg-btn" [class.seg-btn--active]="gateFilter() === 'block'" (click)="setGate('block')">Block</button>
</div>
</div>
</div>
@if (selectedVersionId()) {
<div class="version-filter-bar">
Filtered by: <strong>{{ selectedVersionLabel() }}</strong>
<button class="clear-btn" (click)="clearVersionFilter()">×</button>
</div>
}
@if (store.loading()) {
<div class="skeleton-list">
@for (i of [1,2,3,4]; track i) {
<div class="skeleton-row"><div class="skeleton-cell skeleton-cell--wide"></div><div class="skeleton-cell skeleton-cell--wide"></div><div class="skeleton-cell skeleton-cell--sm"></div></div>
}
</div>
} @else if (pagedReleases().length === 0) {
<div class="empty-state">
@if (selectedVersionId()) {
<p>No releases for this version yet.</p>
<p class="empty-hint">Click "+ Release" on the version to create one.</p>
} @else {
<p>No releases match the current filters.</p>
<p class="empty-hint">Create a release from a version in the left panel.</p>
}
</div>
} @else {
<div class="rel-table-wrap">
<table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered">
<thead>
<tr>
@for (col of columns; track col.key) {
<th
[class.rup__th--sortable]="col.sortable"
[class.rup__th--sorted]="sortState()?.column === col.key"
[attr.aria-sort]="getSortAria(col.key)"
(click)="col.sortable ? toggleSort(col.key) : null"
>
<div class="rup__th-content">
<span>{{ col.label }}</span>
@if (col.sortable) {
<span class="rup__sort-icon" [class.rup__sort-icon--active]="sortState()?.column === col.key">
@if (sortState()?.column === col.key) {
@if (sortState()?.direction === 'asc') {
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<polyline points="18 15 12 9 6 15" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
} @else {
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
} @else {
<svg viewBox="0 0 24 24" width="14" height="14" class="rup__sort-icon--inactive" aria-hidden="true">
<polyline points="8 10 12 6 16 10" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="8 14 12 18 16 14" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
</span>
}
</div>
</th>
}
<th>Release</th>
<th>Stage</th>
<th>Gates</th>
<th>Risk</th>
<th>Evidence</th>
<th>Status</th>
<th>Decisions</th>
</tr>
</thead>
<tbody>
@for (r of pagedReleases(); track r.id) {
<tr>
<!-- Release -->
<td>
<div class="rup__release-cell">
<a class="rup__release-name" [routerLink]="['/releases/detail', r.id, 'overview']">{{ r.name }}</a>
<span class="rup__release-version">{{ r.version }}</span>
<span class="rup__lane-badge" [class.rup__lane-badge--hotfix]="r.lane === 'hotfix'">
{{ r.lane === 'hotfix' ? 'Hotfix' : 'Standard' }}
</span>
<span class="rup__digest" [title]="r.digest">{{ r.digest | slice:0:19 }}</span>
<div class="rel-cell">
<a class="rel-name" [routerLink]="['/releases/detail', r.id, 'overview']">{{ r.name }}</a>
<span class="rel-ver">{{ r.version }}</span>
<span class="lane-badge" [class.lane-badge--hotfix]="r.lane === 'hotfix'">{{ r.lane }}</span>
</div>
</td>
<!-- Stage -->
<td><span class="rel-stage">{{ r.environment }}</span><span class="rel-region">{{ r.region }}</span></td>
<td>
<span class="rup__stage">{{ r.environment }}</span>
<span class="rup__region">{{ r.region }}</span>
</td>
<!-- Gates -->
<td>
<span class="rup__badge"
[class.rup__badge--success]="r.gateStatus === 'pass'"
[class.rup__badge--warning]="r.gateStatus === 'warn'"
[class.rup__badge--error]="r.gateStatus === 'block'">
{{ r.gateStatus | uppercase }}
@if (r.gateBlockingCount > 0) {
<span class="rup__badge-count">{{ r.gateBlockingCount }}</span>
}
<span class="pill" [class.pill--pass]="r.gateStatus==='pass'" [class.pill--warn]="r.gateStatus==='warn'" [class.pill--block]="r.gateStatus==='block'">
{{ r.gateStatus | uppercase }}@if (r.gateBlockingCount > 0) { <span class="pill__count">{{ r.gateBlockingCount }}</span> }
</span>
</td>
<!-- Risk -->
<td><span class="pill" [attr.data-tier]="r.riskTier">{{ r.riskTier | uppercase }}</span></td>
<td><span class="pill" [class.pill--pass]="r.evidencePosture==='verified'" [class.pill--warn]="r.evidencePosture==='partial'" [class.pill--block]="r.evidencePosture==='missing'">{{ r.evidencePosture }}</span></td>
<td><span class="status-pill" [attr.data-status]="r.status">{{ formatStatus(r.status) }}</span></td>
<td>
<span class="rup__risk-badge"
[attr.data-tier]="r.riskTier">
{{ r.riskTier | uppercase }}
</span>
</td>
<!-- Evidence -->
<td>
<span class="rup__evidence-badge"
[class.rup__evidence-badge--verified]="r.evidencePosture === 'verified'"
[class.rup__evidence-badge--partial]="r.evidencePosture === 'partial'"
[class.rup__evidence-badge--missing]="r.evidencePosture === 'missing'">
{{ r.evidencePosture === 'verified' ? 'Verified' : r.evidencePosture === 'partial' ? 'Partial' : 'Missing' }}
</span>
</td>
<!-- Status -->
<td>
<span class="rup__status-badge" [attr.data-status]="r.status">
{{ formatStatus(r.status) }}
</span>
</td>
<!-- Decisions -->
<td>
<div class="rup__decisions">
<div class="decisions">
@if (r.status === 'ready' && r.gateStatus === 'pass') {
<button type="button" class="decision-capsule decision-capsule--deploy">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></svg>
Deploy
</button>
<button class="dcap dcap--deploy" type="button">Deploy</button>
}
@if (r.gatePendingApprovals > 0) {
<button type="button" class="decision-capsule decision-capsule--approve" (click)="openApproveDialog(r)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 11l3 3L22 4"/></svg>
Approve ({{ r.gatePendingApprovals }})
</button>
<button class="dcap dcap--approve" (click)="openApproveDialog(r)" type="button">Approve ({{ r.gatePendingApprovals }})</button>
}
@if (r.gateStatus === 'block') {
<a class="decision-capsule decision-capsule--review" [routerLink]="['/releases/detail', r.id, 'gates']">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
Review Gates
</a>
}
@if (r.evidencePosture === 'partial' || r.evidencePosture === 'missing') {
<a class="decision-capsule decision-capsule--evidence" [routerLink]="['/releases/detail', r.id, 'evidence']">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
View Evidence
</a>
}
@if (r.status === 'deploying' && r.deploymentProgress !== null) {
<span class="decision-capsule decision-capsule--progress">
<span class="decision-capsule__progress-track">
<span class="decision-capsule__progress-fill" [style.width.%]="r.deploymentProgress"></span>
</span>
<span class="decision-capsule__progress-text">{{ r.deploymentProgress }}%</span>
</span>
<a class="dcap dcap--review" [routerLink]="['/releases/detail', r.id, 'gates']">Review</a>
}
@if (r.status === 'deployed' && r.gateStatus === 'pass') {
<a class="decision-capsule decision-capsule--promote" [routerLink]="['/releases/promotions']" [queryParams]="{ releaseId: r.id }">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></svg>
Promote
</a>
<a class="dcap dcap--promote" [routerLink]="['/releases/promotions']" [queryParams]="{ releaseId: r.id }">Promote</a>
}
</div>
</td>
@@ -248,245 +219,145 @@ export interface PipelineRelease {
</tbody>
</table>
</div>
<!-- Pagination (right-aligned) -->
<div class="rup__pager">
<app-pagination
[total]="sortedReleases().length"
[currentPage]="currentPage()"
[pageSize]="pageSize()"
[pageSizes]="[5, 10, 25, 50]"
(pageChange)="onPageChange($event)"
/>
</div>
@if (sortedReleases().length > releasePageSize) {
<app-pagination [total]="sortedReleases().length" [pageSize]="releasePageSize" [currentPage]="releasePage()" (pageChange)="releasePage.set($event.page)" [compact]="true" />
}
}
} <!-- end pipeline tab -->
@if (activeTab() === 'versions') {
<app-release-list [embedded]="true" />
}
</stella-page-tabs>
<app-confirm-dialog #approveConfirm
title="Approve Release"
[message]="approveMessage()"
confirmLabel="Approve" cancelLabel="Cancel" variant="warning"
(confirmed)="executeApprove()" />
</div>
</div>
</div>
`,
styles: [`
.rup { padding: 1.5rem; }
.rup__header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
.rup__title { font-size: 1.5rem; font-weight: var(--font-weight-bold, 700); color: var(--color-text-heading); margin: 0 0 0.25rem; }
.rup__subtitle { font-size: 0.8125rem; color: var(--color-text-secondary); margin: 0; }
.rup__filters { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; overflow: visible; }
.rup__search { position: relative; flex: 0 1 240px; min-width: 160px; }
.rup__search-icon { position: absolute; left: 0.5rem; top: 50%; transform: translateY(-50%); color: var(--color-text-muted); pointer-events: none; }
.rup__search-input {
width: 100%; height: 28px; padding: 0 0.5rem 0 1.75rem;
border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm);
background: transparent; color: var(--color-text-primary);
font-size: 0.75rem; outline: none; transition: border-color 150ms ease;
}
.rup__search-input:focus { border-color: var(--color-brand-primary); }
.rup__search-input::placeholder { color: var(--color-text-muted); }
.rup { display: grid; gap: 0.65rem; }
.rup__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.5rem; }
.rup__header h1 { margin: 0; font-size: 1.3rem; }
.rup__sub { margin: 0.15rem 0 0; color: var(--color-text-secondary); font-size: 0.8rem; }
.rup__table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
/* ── Dual-panel ── */
.dual-panel { display: grid; grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); gap: 0.65rem; align-items: start; }
.panel { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); padding: 0.65rem; display: grid; gap: 0.5rem; }
.panel__head { display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.35rem; border-bottom: 1px solid var(--color-border-primary); flex-wrap: wrap; gap: 0.3rem; }
.panel__head h2 { margin: 0; font-size: 0.88rem; font-weight: 600; display: flex; align-items: center; gap: 0.35rem; }
.badge { font-size: 0.6rem; padding: 0.1rem 0.35rem; border-radius: var(--radius-full); font-weight: 700; background: var(--color-surface-secondary); color: var(--color-text-muted); }
.head-toggles { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; }
.seg-group { display: inline-flex; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); overflow: hidden; }
.seg-btn { padding: 0.2rem 0.5rem; border: none; background: transparent; font-size: 0.68rem; font-weight: 500; color: var(--color-text-muted); cursor: pointer; transition: all 150ms; }
.seg-btn:hover:not(.seg-btn--active) { color: var(--color-text-secondary); background: var(--color-surface-tertiary, rgba(0,0,0,0.04)); }
.seg-btn--active { background: var(--color-surface-tertiary, rgba(0,0,0,0.04)); color: var(--color-text-primary); font-weight: 600; }
.sort-btn { padding: 0.2rem 0.45rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background: transparent; font-size: 0.66rem; font-weight: 500; color: var(--color-text-muted); cursor: pointer; }
.sort-btn:hover { color: var(--color-text-primary); border-color: var(--color-border-emphasis); }
.rup__release-cell { display: flex; flex-direction: column; gap: 0.125rem; min-width: 180px; }
.rup__release-name { font-weight: var(--font-weight-semibold, 600); color: var(--color-text-heading); text-decoration: none; font-size: 0.8125rem; }
.rup__release-name:hover { text-decoration: underline; }
.rup__release-version { font-size: 0.75rem; color: var(--color-text-secondary); font-family: var(--font-mono, monospace); }
.rup__digest { font-size: 0.625rem; color: var(--color-text-muted); font-family: var(--font-mono, monospace); }
/* ── Version filter bar ── */
.version-filter-bar { font-size: 0.74rem; padding: 0.3rem 0.5rem; background: var(--color-brand-soft, rgba(59,130,246,0.08)); border-radius: var(--radius-sm); display: flex; align-items: center; gap: 0.3rem; }
.clear-btn { background: none; border: none; font-size: 0.9rem; cursor: pointer; color: var(--color-text-muted); padding: 0 0.2rem; }
.clear-btn:hover { color: var(--color-text-primary); }
.rup__lane-badge {
display: inline-block; width: fit-content; padding: 0.0625rem 0.375rem;
border-radius: var(--radius-full, 9999px); font-size: 0.5625rem;
font-weight: var(--font-weight-semibold, 600); text-transform: uppercase;
letter-spacing: 0.04em; background: var(--color-surface-secondary);
color: var(--color-text-secondary); border: 1px solid var(--color-border-primary);
}
.rup__lane-badge--hotfix { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); border-color: var(--color-status-warning, #C89820); }
/* ── Skeleton ── */
.skeleton-list { display: flex; flex-direction: column; gap: 0.5rem; padding: 0.5rem 0; }
.skeleton-row { display: flex; gap: 0.75rem; align-items: center; }
.skeleton-cell { height: 12px; border-radius: var(--radius-sm); background: var(--color-surface-tertiary); animation: sk 1.2s ease-in-out infinite; }
.skeleton-cell--wide { flex: 2; } .skeleton-cell--sm { flex: 0.7; }
@keyframes sk { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.rup__stage { display: block; font-size: 0.8125rem; color: var(--color-text-primary); font-weight: var(--font-weight-medium, 500); }
.rup__region { display: block; font-size: 0.6875rem; color: var(--color-text-muted); }
.empty-state { text-align: center; padding: 1.5rem; color: var(--color-text-muted); border: 1px dashed var(--color-border-primary); border-radius: var(--radius-lg); }
.empty-state p { margin: 0.15rem 0; }
.empty-hint { font-size: 0.72rem; opacity: 0.7; }
/* Shared pill base for gate/risk/evidence/status badges */
.rup__badge, .rup__risk-badge, .rup__evidence-badge, .rup__status-badge {
display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.125rem 0.5rem;
border-radius: var(--radius-full, 9999px); font-size: 0.6875rem;
font-weight: var(--font-weight-semibold, 600);
}
.rup__badge { text-transform: uppercase; letter-spacing: 0.03em; }
.rup__badge--success { background: var(--color-status-success-bg, #E8F5E9); color: var(--color-status-success, #2E7D32); }
.rup__badge--warning { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); }
.rup__badge--error { background: var(--color-status-error-bg, #F8EFEB); color: var(--color-status-error, #CB4535); }
.rup__badge-count { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: rgba(0,0,0,0.15); font-size: 0.5625rem; font-weight: 700; }
/* ── Version rows ── */
.ver-list { display: grid; gap: 0.35rem; }
.ver-row { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; padding: 0.45rem 0.55rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); cursor: pointer; transition: all 120ms; }
.ver-row:hover { background: var(--color-surface-secondary); }
.ver-row--selected { border-left: 3px solid var(--color-brand, #3b82f6); background: var(--color-brand-soft, rgba(59,130,246,0.06)); }
.ver-row__main { display: grid; gap: 0.15rem; min-width: 0; flex: 1; }
.ver-row__id { display: flex; align-items: center; gap: 0.35rem; }
.ver-row__digest { font-family: var(--font-mono, monospace); font-size: 0.66rem; color: var(--color-text-muted); }
.ver-row__name { font-size: 0.78rem; }
.ver-row__meta { display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap; font-size: 0.68rem; }
.ver-row__env { color: var(--color-text-secondary); }
.ver-row__create { flex-shrink: 0; white-space: nowrap; }
.muted { color: var(--color-text-muted); font-size: 0.66rem; }
.rup__risk-badge[data-tier="critical"] { background: var(--color-status-error-bg, #F8EFEB); color: var(--color-status-error, #CB4535); }
.rup__risk-badge[data-tier="high"] { background: #FFF3E0; color: #E65100; }
.rup__risk-badge[data-tier="medium"] { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); }
.rup__risk-badge[data-tier="low"] { background: var(--color-status-success-bg, #E8F5E9); color: var(--color-status-success, #2E7D32); }
.rup__risk-badge[data-tier="none"] { background: var(--color-surface-secondary); color: var(--color-text-muted); }
.lane-badge { padding: 0.06rem 0.3rem; border-radius: var(--radius-full); font-size: 0.56rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; background: var(--color-surface-secondary); color: var(--color-text-secondary); border: 1px solid var(--color-border-primary); }
.lane-badge--hotfix { background: var(--color-status-warning-bg); color: var(--color-status-warning); border-color: var(--color-status-warning); }
.rup__evidence-badge--verified { background: var(--color-status-success-bg, #E8F5E9); color: var(--color-status-success, #2E7D32); }
.rup__evidence-badge--partial { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); }
.rup__evidence-badge--missing { background: var(--color-status-error-bg, #F8EFEB); color: var(--color-status-error, #CB4535); }
/* ── Buttons ── */
.btn { padding: 0.3rem 0.65rem; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.74rem; font-weight: 500; border: none; text-decoration: none; display: inline-flex; align-items: center; gap: 0.2rem; transition: background 150ms; }
.btn--xs { padding: 0.2rem 0.4rem; font-size: 0.68rem; }
.btn--secondary { background: var(--color-surface-secondary); color: var(--color-text-primary); border: 1px solid var(--color-border-primary); }
.btn--secondary:hover { background: var(--color-brand-soft); }
.rup__status-badge { text-transform: capitalize; }
.rup__status-badge[data-status="draft"] { background: var(--color-surface-secondary); color: var(--color-text-muted); }
.rup__status-badge[data-status="ready"] { background: #E3F2FD; color: #1565C0; }
.rup__status-badge[data-status="deploying"] { background: #EDE7F6; color: #6A1B9A; }
.rup__status-badge[data-status="deployed"] { background: var(--color-status-success-bg, #E8F5E9); color: var(--color-status-success, #2E7D32); }
.rup__status-badge[data-status="failed"] { background: var(--color-status-error-bg, #F8EFEB); color: var(--color-status-error, #CB4535); }
.rup__status-badge[data-status="rolled_back"] { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); }
/* ── Release table ── */
.rel-table-wrap { overflow-x: auto; }
.rel-cell { display: flex; flex-direction: column; gap: 0.1rem; min-width: 160px; }
.rel-name { font-weight: 600; color: var(--color-text-heading); text-decoration: none; font-size: 0.78rem; }
.rel-name:hover { text-decoration: underline; }
.rel-ver { font-size: 0.68rem; color: var(--color-text-secondary); font-family: var(--font-mono, monospace); }
.rel-stage { display: block; font-size: 0.78rem; font-weight: 500; }
.rel-region { display: block; font-size: 0.66rem; color: var(--color-text-muted); }
.rup__decisions { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; }
.rup__decisions-done { color: var(--color-status-success, #2E7D32); display: inline-flex; }
.pill { display: inline-flex; align-items: center; gap: 0.2rem; padding: 0.1rem 0.4rem; border-radius: var(--radius-full); font-size: 0.66rem; font-weight: 600; background: var(--color-surface-secondary); color: var(--color-text-muted); text-transform: capitalize; }
.pill--pass { background: var(--color-status-success-bg); color: var(--color-status-success); }
.pill--warn { background: var(--color-status-warning-bg); color: var(--color-status-warning); }
.pill--block { background: var(--color-status-error-bg); color: var(--color-status-error); }
.pill[data-tier="critical"] { background: var(--color-status-error-bg); color: var(--color-status-error); }
.pill[data-tier="high"] { background: #FFF3E0; color: #E65100; }
.pill[data-tier="medium"] { background: var(--color-status-warning-bg); color: var(--color-status-warning); }
.pill[data-tier="low"] { background: var(--color-status-success-bg); color: var(--color-status-success); }
.pill__count { display: inline-flex; align-items: center; justify-content: center; min-width: 14px; height: 14px; padding: 0 3px; border-radius: 7px; background: rgba(0,0,0,0.15); font-size: 0.55rem; }
.decision-capsule {
display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm); font-size: 0.65rem;
font-weight: 500; cursor: pointer;
border: 1px solid var(--color-border-primary); transition: all 150ms ease;
text-decoration: none; white-space: nowrap; line-height: 1.3;
background: transparent; color: var(--color-text-secondary);
}
.decision-capsule:hover { background: var(--color-surface-tertiary); border-color: var(--color-brand-primary); color: var(--color-text-primary); }
.decision-capsule--deploy { color: var(--color-status-success-text); }
.decision-capsule--approve { color: var(--color-status-info-text); }
.decision-capsule--review { color: var(--color-status-warning-text); }
.decision-capsule--evidence { color: var(--color-text-secondary); }
.decision-capsule--promote { color: var(--color-status-success-text); }
.decision-capsule--progress { cursor: default; background: var(--color-surface-secondary); border-color: var(--color-border-primary); gap: 0.375rem; }
.decision-capsule__progress-track { width: 48px; height: 6px; border-radius: 3px; background: var(--color-border-primary); overflow: hidden; }
.decision-capsule__progress-fill { display: block; height: 100%; border-radius: 3px; background: var(--color-brand-primary, #4F46E5); transition: width 300ms ease; }
.decision-capsule__progress-text { font-size: 0.625rem; color: var(--color-text-secondary); font-variant-numeric: tabular-nums; }
.status-pill { display: inline-block; padding: 0.1rem 0.4rem; border-radius: var(--radius-full); font-size: 0.66rem; font-weight: 600; text-transform: capitalize; }
.status-pill[data-status="draft"] { background: var(--color-surface-secondary); color: var(--color-text-muted); }
.status-pill[data-status="ready"] { background: #E3F2FD; color: #1565C0; }
.status-pill[data-status="deploying"] { background: #EDE7F6; color: #6A1B9A; }
.status-pill[data-status="deployed"] { background: var(--color-status-success-bg); color: var(--color-status-success); }
.status-pill[data-status="failed"] { background: var(--color-status-error-bg); color: var(--color-status-error); }
.status-pill[data-status="rolled_back"] { background: var(--color-status-warning-bg); color: var(--color-status-warning); }
/* Sortable column headers */
.rup__th--sortable { cursor: pointer; user-select: none; transition: color 150ms ease; }
.rup__th--sortable:hover { color: var(--color-text-primary); }
.rup__th--sorted { color: var(--color-text-heading); border-bottom-color: var(--color-text-heading); }
.rup__th-content { display: flex; align-items: center; gap: 0.375rem; }
.rup__sort-icon { display: flex; align-items: center; opacity: 0.5; transition: opacity 150ms ease; }
.rup__sort-icon--active { opacity: 1; color: var(--color-text-heading); }
.rup__sort-icon--inactive { opacity: 0.3; }
.decisions { display: flex; flex-wrap: wrap; gap: 0.2rem; }
.dcap { display: inline-flex; align-items: center; gap: 0.2rem; padding: 0.18rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.62rem; font-weight: 500; cursor: pointer; border: 1px solid var(--color-border-primary); background: transparent; color: var(--color-text-secondary); text-decoration: none; white-space: nowrap; transition: all 150ms; }
.dcap:hover { background: var(--color-surface-tertiary); border-color: var(--color-brand, #3b82f6); color: var(--color-text-primary); }
.dcap--deploy { color: var(--color-status-success); }
.dcap--approve { color: var(--color-brand, #3b82f6); }
.dcap--review { color: var(--color-status-warning); }
.dcap--promote { color: var(--color-status-success); }
/* Right-aligned pagination */
.rup__pager { display: flex; justify-content: flex-end; padding-top: 0.75rem; }
.rup__empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 3rem 1rem; color: var(--color-text-muted); text-align: center; }
.rup__empty svg { margin-bottom: 1rem; opacity: 0.4; }
.rup__empty-title { font-size: 1rem; font-weight: var(--font-weight-semibold, 600); color: var(--color-text-secondary); margin: 0 0 0.25rem; }
.rup__empty-text { font-size: 0.8125rem; margin: 0; }
@media (max-width: 768px) {
.rup { padding: 1rem; }
.rup__filters { gap: 0.375rem; }
.rup__search { flex: 1 1 100%; }
}
@media (max-width: 768px) { .rup { padding: 0.5rem; } }
`],
})
export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
private readonly pageAction = inject(PageActionService);
private readonly store = inject(ReleaseManagementStore);
readonly store = inject(ReleaseManagementStore);
private readonly context = inject(PlatformContextStore);
private readonly route = inject(ActivatedRoute);
private readonly approvalApi = inject<ApprovalApi>(APPROVAL_API);
readonly releaseTabs = RELEASE_TABS;
readonly activeTab = signal<string>(this.route.snapshot.queryParamMap.get('tab') || 'releases');
// ── Version panel state ──
readonly versionGateFilter = signal('');
readonly versionSortAsc = signal(false);
readonly versionPage = signal(1);
readonly versionPageSize = 10;
readonly activeSubtitle = computed(() =>
this.activeTab() === 'versions'
? 'Sealed digest-first version catalog across standard and hotfix lanes.'
: 'Promotion pipeline with gate status, risk posture, and evidence for every release.'
);
// ── Release panel state ──
readonly statusFilter = signal('');
readonly gateFilter = signal('');
readonly releasePage = signal(1);
readonly releasePageSize = 10;
constructor() {
// Update page action when tab changes
effect(() => {
const tab = this.activeTab();
if (tab === 'versions') {
this.pageAction.set({ label: 'New Version', route: '/releases/versions/new' });
} else {
this.pageAction.set({ label: 'New Release', route: '/releases/new' });
}
});
}
// ── Cross-filter: selected version ──
readonly selectedVersionId = signal<string | null>(null);
readonly selectedVersionLabel = signal<string | null>(null);
// Approve dialog
// ── Approve dialog ──
readonly pendingApproveRelease = signal<PipelineRelease | null>(null);
@ViewChild('approveConfirm') approveConfirmRef!: ConfirmDialogComponent;
readonly approveMessage = computed(() => {
const r = this.pendingApproveRelease();
if (!r) return '';
return `Approve release "${r.name}" (${r.version || r.digest?.slice(0, 19) || 'unknown'}) for promotion?`;
});
openApproveDialog(r: PipelineRelease): void {
this.pendingApproveRelease.set(r);
this.approveConfirmRef.open();
}
executeApprove(): void {
const r = this.pendingApproveRelease();
if (!r) return;
// Call the approval decision endpoint for this release
this.approvalApi.approve(r.id, `Approved from releases page`).pipe(take(1)).subscribe({
next: () => {
this.pendingApproveRelease.set(null);
this.store.loadReleases({});
},
error: () => this.pendingApproveRelease.set(null),
});
}
ngOnInit(): void {
this.context.initialize();
this.store.loadReleases({});
}
ngOnDestroy(): void {
this.pageAction.clear();
}
// ── Filter-chip options ──────────────────────────────────────────────
// Lane filter reads from global context (header toggle)
readonly statusOptions: FilterChipOption[] = [
{ id: '', label: 'All Status' },
{ id: 'draft', label: 'Draft' },
{ id: 'ready', label: 'Ready' },
{ id: 'deploying', label: 'Deploying' },
{ id: 'deployed', label: 'Deployed' },
{ id: 'failed', label: 'Failed' },
{ id: 'rolled_back', label: 'Rolled Back' },
];
readonly gateOptions: FilterChipOption[] = [
{ id: '', label: 'All Gates' },
{ id: 'pass', label: 'Pass' },
{ id: 'warn', label: 'Warn' },
{ id: 'block', label: 'Block' },
];
// ── Columns definition ───────────────────────────────────────────────
readonly columns: TableColumn<PipelineRelease>[] = [
{ key: 'name', label: 'Release', sortable: true },
{ key: 'environment', label: 'Stage', sortable: true },
{ key: 'gateStatus', label: 'Gates', sortable: true },
{ key: 'riskTier', label: 'Risk', sortable: true },
{ key: 'evidencePosture', label: 'Evidence', sortable: true },
{ key: 'status', label: 'Status', sortable: true },
{ key: 'decisions', label: 'Decisions', sortable: false },
];
// ── State ──────────────────────────────────────────────────────────────
readonly releases = computed<PipelineRelease[]>(() => {
return this.store.releases().map(r => ({
// ── Map store releases to PipelineRelease ──
readonly releases = computed<PipelineRelease[]>(() =>
this.store.releases().map(r => ({
id: r.id,
name: r.name,
version: r.version,
@@ -503,108 +374,130 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
deploymentProgress: r.status === 'deploying' ? 50 : null,
updatedAt: r.updatedAt ?? '',
lastActor: r.lastActor ?? '',
}));
}))
);
// ── Versions (left panel) ──
readonly filteredVersions = computed(() => {
let list = this.releases();
const gate = this.versionGateFilter();
if (gate) list = list.filter(v => v.gateStatus === gate);
const asc = this.versionSortAsc();
list = [...list].sort((a, b) => asc ? a.updatedAt.localeCompare(b.updatedAt) : b.updatedAt.localeCompare(a.updatedAt));
return list;
});
readonly searchQuery = signal('');
readonly laneFilter = computed(() => this.context.releaseLane());
readonly statusFilter = signal('');
readonly gateFilter = signal('');
readonly sortState = signal<{ column: string; direction: 'asc' | 'desc' } | null>(null);
// ── Pagination ────────────────────────────────────────────────────────
readonly currentPage = signal(1);
readonly pageSize = signal(10);
readonly pagedVersions = computed(() => {
const all = this.filteredVersions();
const start = (this.versionPage() - 1) * this.versionPageSize;
return all.slice(start, start + this.versionPageSize);
});
// ── Releases (right panel, filtered by selected version) ──
readonly filteredReleases = computed(() => {
let list = this.releases();
const q = this.searchQuery().toLowerCase().trim();
const lane = this.laneFilter();
const verId = this.selectedVersionId();
if (verId) list = list.filter(r => r.id === verId || r.version === this.selectedVersionLabel());
const status = this.statusFilter();
if (status) list = list.filter(r => r.status === status);
const gate = this.gateFilter();
if (q) {
list = list.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.version.toLowerCase().includes(q) ||
r.digest.toLowerCase().includes(q),
);
}
if (lane) {
list = list.filter((r) => r.lane === lane);
}
if (status !== '') {
list = list.filter((r) => r.status === status);
}
if (gate !== '') {
list = list.filter((r) => r.gateStatus === gate);
}
if (gate) list = list.filter(r => r.gateStatus === gate);
return list;
});
readonly sortedReleases = computed(() => {
const list = [...this.filteredReleases()];
const sort = this.sortState();
if (!sort) return list;
const { column, direction } = sort;
const dir = direction === 'asc' ? 1 : -1;
return list.sort((a, b) => {
const aVal = (a as unknown as Record<string, unknown>)[column];
const bVal = (b as unknown as Record<string, unknown>)[column];
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
if (typeof aVal === 'string' && typeof bVal === 'string') {
return aVal.localeCompare(bVal) * dir;
}
if (typeof aVal === 'number' && typeof bVal === 'number') {
return (aVal - bVal) * dir;
}
return String(aVal).localeCompare(String(bVal)) * dir;
});
return [...this.filteredReleases()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
});
readonly pagedReleases = computed(() => {
const all = this.sortedReleases();
const page = this.currentPage();
const size = this.pageSize();
const start = (page - 1) * size;
return all.slice(start, start + size);
const start = (this.releasePage() - 1) * this.releasePageSize;
return all.slice(start, start + this.releasePageSize);
});
onPageChange(event: PageChangeEvent): void {
this.currentPage.set(event.page);
this.pageSize.set(event.pageSize);
// ── Lifecycle ──
constructor() {
this.pageAction.set({ label: 'New Version', route: '/releases/versions/new' });
}
// ── Sort handlers ────────────────────────────────────────────────────
ngOnInit(): void {
this.context.initialize();
this.store.loadReleases({});
}
toggleSort(columnKey: string): void {
const current = this.sortState();
if (current?.column === columnKey) {
if (current.direction === 'asc') {
this.sortState.set({ column: columnKey, direction: 'desc' });
} else {
// Third click clears sort
this.sortState.set(null);
}
ngOnDestroy(): void {
this.pageAction.clear();
}
// ── Version panel actions ──
setVersionGate(gate: string): void {
this.versionGateFilter.set(gate);
this.versionPage.set(1);
this.store.loadReleases({});
}
toggleVersionSort(): void {
this.versionSortAsc.update(v => !v);
this.versionPage.set(1);
this.store.loadReleases({});
}
selectVersion(v: PipelineRelease): void {
if (this.selectedVersionId() === v.id) {
this.selectedVersionId.set(null);
this.selectedVersionLabel.set(null);
} else {
this.sortState.set({ column: columnKey, direction: 'asc' });
this.selectedVersionId.set(v.id);
this.selectedVersionLabel.set(v.version);
}
this.releasePage.set(1);
this.store.loadReleases({});
}
getSortAria(columnKey: string): string | null {
const sort = this.sortState();
if (sort?.column !== columnKey) return null;
return sort.direction === 'asc' ? 'ascending' : 'descending';
clearVersionFilter(): void {
this.selectedVersionId.set(null);
this.selectedVersionLabel.set(null);
this.releasePage.set(1);
this.store.loadReleases({});
}
// ── Helpers ────────────────────────────────────────────────────────────
// ── Release panel actions ──
setStatus(s: string): void {
this.statusFilter.set(s);
this.releasePage.set(1);
this.store.loadReleases({});
}
setGate(g: string): void {
this.gateFilter.set(g);
this.releasePage.set(1);
this.store.loadReleases({});
}
// ── Approve ──
openApproveDialog(r: PipelineRelease): void {
this.pendingApproveRelease.set(r);
this.approveConfirmRef.open();
}
executeApprove(): void {
const r = this.pendingApproveRelease();
if (!r) return;
this.approvalApi.approve(r.id, 'Approved from releases page').pipe(take(1)).subscribe({
next: () => { this.pendingApproveRelease.set(null); this.store.loadReleases({}); },
error: () => this.pendingApproveRelease.set(null),
});
}
// ── Helpers ──
gateToStatus(gate: string): 'success' | 'warning' | 'error' | 'neutral' {
return gate === 'pass' ? 'success' : gate === 'warn' ? 'warning' : gate === 'block' ? 'error' : 'neutral';
}
formatStatus(status: string): string {
switch (status) {
@@ -618,36 +511,23 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
}
}
// ── Status mapping helpers ────────────────────────────────────────────
private mapStatus(status: ReleaseWorkflowStatus): PipelineRelease['status'] {
const valid: PipelineRelease['status'][] = ['draft', 'ready', 'deploying', 'deployed', 'failed', 'rolled_back'];
return valid.includes(status as PipelineRelease['status'])
? (status as PipelineRelease['status'])
: 'draft';
return valid.includes(status as PipelineRelease['status']) ? (status as PipelineRelease['status']) : 'draft';
}
private mapGateStatus(gate: string): PipelineRelease['gateStatus'] {
const map: Record<string, PipelineRelease['gateStatus']> = {
pass: 'pass', warn: 'warn', block: 'block',
pending: 'warn', unknown: 'warn',
};
const map: Record<string, PipelineRelease['gateStatus']> = { pass: 'pass', warn: 'warn', block: 'block', pending: 'warn', unknown: 'warn' };
return map[gate] ?? 'warn';
}
private mapRiskTier(tier: string): PipelineRelease['riskTier'] {
const map: Record<string, PipelineRelease['riskTier']> = {
critical: 'critical', high: 'high', medium: 'medium', low: 'low', none: 'none',
unknown: 'none',
};
const map: Record<string, PipelineRelease['riskTier']> = { critical: 'critical', high: 'high', medium: 'medium', low: 'low', none: 'none', unknown: 'none' };
return map[tier] ?? 'none';
}
private mapEvidencePosture(posture: string): PipelineRelease['evidencePosture'] {
const map: Record<string, PipelineRelease['evidencePosture']> = {
verified: 'verified', partial: 'partial', missing: 'missing',
replay_mismatch: 'partial', unknown: 'missing',
};
const map: Record<string, PipelineRelease['evidencePosture']> = { verified: 'verified', partial: 'partial', missing: 'missing', replay_mismatch: 'partial', unknown: 'missing' };
return map[posture] ?? 'missing';
}
}

View File

@@ -13,10 +13,11 @@ import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
import { scannerOpsPath } from '../platform/ops/operations-paths';
import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component';
import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component';
import { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.component';
@Component({
selector: 'app-security-overview-page',
imports: [RouterLink, StellaMetricCardComponent, StellaMetricGridComponent],
imports: [RouterLink, StellaMetricCardComponent, StellaMetricGridComponent, StellaQuickLinksComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="security-overview">
@@ -24,12 +25,15 @@ import { StellaMetricGridComponent } from '../../shared/components/stella-metric
<div>
<h1 class="page-title">Security Overview</h1>
<p class="page-subtitle">Aggregated security posture across all environments</p>
<div class="page-actions">
<button type="button" class="btn btn--secondary" (click)="runScan()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Run Scan
</button>
</div>
</div>
<div class="page-actions">
<button type="button" class="btn btn--secondary" (click)="runScan()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Run Scan
</button>
</div>
<aside class="page-aside">
<stella-quick-links [links]="quickLinks" label="Quick Links" layout="strip" />
</aside>
</header>
<!-- Quick Stats -->
@@ -165,6 +169,8 @@ import { StellaMetricGridComponent } from '../../shared/components/stella-metric
}
.page-title { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: var(--font-weight-semibold); }
.page-subtitle { margin: 0; color: var(--color-text-secondary); }
.page-actions { margin-top: 0.75rem; }
.page-aside { flex: 0 1 60%; min-width: 0; }
/* Stats — now uses stella-metric-grid/card */
stella-metric-grid { margin-bottom: 1.5rem; }
@@ -289,6 +295,14 @@ export class SecurityOverviewPageComponent implements OnInit {
private readonly overviewApi = inject(SECURITY_OVERVIEW_API);
private readonly router = inject(Router);
readonly quickLinks: readonly StellaQuickLink[] = [
{ label: 'Findings Explorer', route: '/security/findings', description: 'All vulnerability findings across artifacts' },
{ label: 'Vulnerabilities', route: '/triage/artifacts', description: 'Triage and investigate artifact vulnerabilities' },
{ label: 'VEX & Exceptions', route: '/ops/policy/vex', description: 'VEX statements and vulnerability exceptions' },
{ label: 'Reachability', route: '/security/reachability', description: 'Reachability coverage and proof of exposure' },
{ label: 'Supply-Chain Data', route: '/security/supply-chain-data', description: 'SBOM health and component inventory' },
{ label: 'Disposition Center', route: '/security/disposition', description: 'Advisory sources and VEX configuration' },
];
readonly loading = signal(true);
readonly error = signal<string | null>(null);

File diff suppressed because it is too large Load Diff

View File

@@ -1,353 +0,0 @@
import { Component, OnInit, signal, computed, inject, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
import { HttpClient } from '@angular/common/http';
import { catchError, of, forkJoin, interval } from 'rxjs';
interface GateResult {
gateName: string;
status: string; // 'pass' | 'fail' | 'skip' | 'pending'
message?: string;
}
interface ReadinessReport {
targetId: string;
environmentId: string;
isReady: boolean;
gates: GateResult[];
evaluatedAt: string;
}
interface Region {
regionId: string;
displayName: string;
}
interface Environment {
environmentId: string;
displayName: string;
regionId?: string;
}
interface Target {
targetId: string;
name: string;
environmentId: string;
}
@Component({
selector: 'app-readiness-dashboard',
standalone: true,
imports: [CommonModule, LoadingStateComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="readiness-dashboard">
<div class="dashboard-header">
<h2>Topology Readiness</h2>
<p class="subtitle">Gate status for all targets across environments and regions</p>
<button class="btn btn--secondary btn--sm" (click)="refresh()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
</div>
@if (loading() && reports().length === 0) {
<app-loading-state size="md" message="Loading readiness data..." />
}
@if (!loading() && reports().length === 0) {
<div class="empty-state">
<p>No readiness data available. Run validation on targets to see results.</p>
</div>
}
@for (group of groupedReports(); track group.environmentId) {
<div class="env-group">
<h3 class="env-group__title">{{ group.environmentName }}</h3>
<div class="gate-grid">
<div class="gate-grid__header">
<span class="gate-grid__cell gate-grid__cell--name">Target</span>
@for (gate of gateNames; track gate) {
<span class="gate-grid__cell gate-grid__cell--gate">{{ formatGateName(gate) }}</span>
}
<span class="gate-grid__cell gate-grid__cell--ready">Ready</span>
</div>
@for (report of group.reports; track report.targetId) {
<div class="gate-grid__row" [class.gate-grid__row--ready]="report.isReady" [class.gate-grid__row--not-ready]="!report.isReady">
<span class="gate-grid__cell gate-grid__cell--name">{{ getTargetName(report.targetId) }}</span>
@for (gate of gateNames; track gate) {
<span class="gate-grid__cell gate-grid__cell--gate" [title]="getGateMessage(report, gate)">
{{ getGateIcon(report, gate) }}
</span>
}
<span class="gate-grid__cell gate-grid__cell--ready">
{{ report.isReady ? 'Yes' : 'No' }}
</span>
</div>
}
</div>
</div>
}
@if (lastRefresh()) {
<p class="last-refresh">Last refresh: {{ lastRefresh() }}</p>
}
</div>
`,
styles: [`
.readiness-dashboard {
display: grid;
gap: 0.75rem;
}
.dashboard-header {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.dashboard-header h2 {
margin: 0;
font-size: 1.1rem;
color: var(--color-text-heading);
}
.subtitle {
color: var(--color-text-muted);
margin: 0;
flex: 1;
font-size: 0.78rem;
}
.btn {
padding: 0.35rem 0.75rem;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.76rem;
font-weight: 500;
border: none;
transition: background 150ms ease, border-color 150ms ease;
}
.btn--secondary {
background: var(--color-surface-secondary);
color: var(--color-text-primary);
border: 1px solid var(--color-border-primary);
}
.btn--secondary:hover:not(:disabled) {
background: var(--color-brand-soft);
border-color: var(--color-border-emphasis);
}
.btn--secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn--sm {
padding: 0.3rem 0.65rem;
}
.loading-state,
.empty-state {
border: 1px dashed var(--color-border-primary);
border-radius: var(--radius-md);
padding: 1.5rem;
text-align: center;
color: var(--color-text-muted);
font-size: 0.78rem;
}
.env-group {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.65rem;
display: grid;
gap: 0.35rem;
}
.env-group__title {
font-size: 0.92rem;
color: var(--color-card-heading);
font-weight: 600;
margin: 0;
padding-bottom: 0.35rem;
border-bottom: 1px solid var(--color-border-primary);
}
.gate-grid {
display: grid;
gap: 0;
font-size: 0.76rem;
overflow-x: auto;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
}
.gate-grid__header {
display: grid;
grid-template-columns: 160px repeat(7, 80px) 60px;
gap: 1px;
background: var(--color-surface-primary);
padding: 0.4rem 0;
font-weight: 600;
color: var(--color-text-muted);
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.04em;
position: sticky;
top: 0;
z-index: 1;
}
.gate-grid__row {
display: grid;
grid-template-columns: 160px repeat(7, 80px) 60px;
gap: 1px;
padding: 0.3rem 0;
border-bottom: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
transition: background 120ms ease;
}
.gate-grid__row:nth-child(even) {
background: var(--color-surface-primary);
}
.gate-grid__row:hover {
background: var(--color-brand-soft);
}
.gate-grid__row:last-child {
border-bottom: none;
}
.gate-grid__row--ready {
background: var(--color-status-success-bg);
}
.gate-grid__row--not-ready {
background: var(--color-status-error-bg);
}
.gate-grid__cell {
padding: 0.2rem 0.45rem;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gate-grid__cell--name {
text-align: left;
color: var(--color-text-primary);
font-weight: 500;
}
.gate-grid__cell--ready {
font-weight: 600;
}
.last-refresh {
font-size: 0.7rem;
color: var(--color-text-muted);
margin: 0;
}
`]
})
export class ReadinessDashboardComponent implements OnInit {
private readonly http = inject(HttpClient);
readonly loading = signal(false);
readonly reports = signal<ReadinessReport[]>([]);
readonly targets = signal<Map<string, string>>(new Map());
readonly environments = signal<Map<string, string>>(new Map());
readonly lastRefresh = signal<string | null>(null);
readonly gateNames = [
'agent_bound', 'docker_version_ok', 'docker_ping_ok',
'registry_pull_ok', 'vault_reachable', 'consul_reachable', 'connectivity_ok'
];
readonly groupedReports = computed(() => {
const envMap = this.environments();
const grouped = new Map<string, { environmentId: string; environmentName: string; reports: ReadinessReport[] }>();
for (const report of this.reports()) {
if (!grouped.has(report.environmentId)) {
grouped.set(report.environmentId, {
environmentId: report.environmentId,
environmentName: envMap.get(report.environmentId) || report.environmentId,
reports: [],
});
}
grouped.get(report.environmentId)!.reports.push(report);
}
return [...grouped.values()];
});
ngOnInit(): void {
this.refresh();
// Auto-refresh every 30 seconds
interval(30000).subscribe(() => this.refresh());
}
refresh(): void {
this.loading.set(true);
// Fetch environments and their readiness
this.http.get<{ items: { environmentId: string; displayName: string }[] }>('/api/v2/topology/environments')
.pipe(catchError(() => of({ items: [] })))
.subscribe(envResponse => {
const envMap = new Map<string, string>();
envResponse.items.forEach(e => envMap.set(e.environmentId, e.displayName));
this.environments.set(envMap);
// For each environment, get readiness
const readinessRequests = envResponse.items.map(env =>
this.http.get<{ items: ReadinessReport[] }>(`/api/v1/environments/${env.environmentId}/readiness`)
.pipe(catchError(() => of({ items: [] })))
);
if (readinessRequests.length === 0) {
this.loading.set(false);
return;
}
forkJoin(readinessRequests).subscribe(results => {
const allReports = results.flatMap(r => r.items);
this.reports.set(allReports);
this.lastRefresh.set(new Date().toLocaleTimeString());
this.loading.set(false);
});
});
}
getTargetName(targetId: string): string {
return this.targets().get(targetId) || targetId.substring(0, 8);
}
getGateIcon(report: ReadinessReport, gateName: string): string {
const gate = report.gates.find(g => g.gateName === gateName);
if (!gate) return '-';
switch (gate.status) {
case 'pass': return 'Pass';
case 'fail': return 'Fail';
case 'skip': return 'N/A';
case 'pending': return '...';
default: return '-';
}
}
getGateMessage(report: ReadinessReport, gateName: string): string {
const gate = report.gates.find(g => g.gateName === gateName);
return gate?.message || '';
}
formatGateName(gate: string): string {
return gate.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).replace(' Ok', '');
}
}

View File

@@ -90,6 +90,8 @@ export interface ReleaseActivityRow {
status: string;
correlationKey: string;
occurredAt: string;
durationMs?: number;
targetCount?: number;
}
export interface SecurityFindingRow {
@@ -97,12 +99,33 @@ export interface SecurityFindingRow {
cveId: string;
severity: string;
effectiveDisposition: string;
cvss?: number;
reachable?: boolean;
vexStatus?: string;
}
export interface EvidenceCapsuleRow {
capsuleId: string;
status: string;
updatedAt: string;
signatureStatus?: string;
contentTypes?: string[];
}
export interface ReadinessGateResult {
gateName: string;
status: 'pass' | 'fail' | 'skip' | 'pending';
message?: string;
checkedAt?: string;
durationMs?: number;
}
export interface ReadinessReport {
targetId: string;
environmentId: string;
isReady: boolean;
gates: ReadinessGateResult[];
evaluatedAt: string;
}

View File

@@ -9,6 +9,8 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
import { SidebarPreferenceService } from '../app-sidebar/sidebar-preference.service';
import { ContentWidthService } from '../../core/services/content-width.service';
import { SearchAssistantHostComponent } from '../search-assistant-host/search-assistant-host.component';
import { StellaHelperComponent } from '../../shared/components/stella-helper/stella-helper.component';
import { StellaTourComponent } from '../../shared/components/stella-helper/stella-tour.component';
/**
* AppShellComponent - Main application shell with permanent left rail navigation.
@@ -31,6 +33,8 @@ import { SearchAssistantHostComponent } from '../search-assistant-host/search-as
BreadcrumbComponent,
OverlayHostComponent,
SearchAssistantHostComponent,
StellaHelperComponent,
StellaTourComponent,
],
template: `
<div class="shell" [class.shell--mobile-open]="mobileMenuOpen()" [class.shell--collapsed]="sidebarPrefs.sidebarCollapsed()">
@@ -95,7 +99,14 @@ import { SearchAssistantHostComponent } from '../search-assistant-host/search-as
<!-- Overlay host for drawers/modals -->
<app-overlay-host></app-overlay-host>
<!-- AI Chat drawer — full ChatComponent side panel. Opened by mascot, search bar, and Ctrl+K -->
<app-search-assistant-host></app-search-assistant-host>
<!-- Stella Assistant — unified tips + search + AI chat -->
<app-stella-helper></app-stella-helper>
<!-- Stella Tour Engine — guided walkthroughs -->
<app-stella-tour></app-stella-tour>
</div>
`,
styles: [`

View File

@@ -626,8 +626,9 @@ export class AppSidebarComponent implements AfterViewInit {
private readonly pendingApprovalsBadgeLoading = signal(false);
/**
* Navigation sections - canonical 7-group IA.
* Groups: Home (ungrouped), Release Control, Security, Policy, Operations, Audit & Evidence, Setup & Admin.
* Navigation sections - canonical 6-group IA.
* Groups: Home (ungrouped), Release Control, Security, Evidence, Operations, Settings.
* Policy dissolved: VEX/Governance/Simulation/Audit absorbed into Security; Packs into Operations.
*/
readonly navSections: NavSection[] = [
// ── Home (ungrouped) ───────────────────────────────────────────
@@ -640,6 +641,18 @@ export class AppSidebarComponent implements AfterViewInit {
menuGroupLabel: '',
},
// ── Group 1: Release Control ─────────────────────────────────────
{
id: 'ops-environments',
label: 'Environments',
icon: 'globe',
route: '/environments/overview',
menuGroupId: 'release-control',
menuGroupLabel: 'Release Control',
requireAnyScope: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
],
},
{
id: 'deployments',
label: 'Deployments',
@@ -669,9 +682,7 @@ export class AppSidebarComponent implements AfterViewInit {
StellaOpsScopes.RELEASE_PUBLISH,
],
},
// Versions merged into Releases page as a tab
// Approvals nav item removed — merged into Deployments page as a tab
// ── Group 2: Security ────────────────────────────────────────────
// ── Group 2: Security (absorbs former Policy group) ──────────────
{
id: 'vulnerabilities',
label: 'Vulnerabilities',
@@ -705,6 +716,7 @@ export class AppSidebarComponent implements AfterViewInit {
],
children: [
{ id: 'sec-supply-chain', label: 'Supply-Chain Data', route: '/security/supply-chain-data', icon: 'graph' },
{ id: 'sec-findings-explorer', label: 'Findings Explorer', route: '/security/findings', icon: 'list' },
{ id: 'sec-reachability', label: 'Reachability', route: '/security/reachability', icon: 'cpu' },
{ id: 'sec-unknowns', label: 'Unknowns', route: '/security/unknowns', icon: 'help-circle' },
],
@@ -718,8 +730,83 @@ export class AppSidebarComponent implements AfterViewInit {
menuGroupLabel: 'Security',
requireAnyScope: [StellaOpsScopes.SCANNER_READ],
},
// Reports merged into Security Posture (export actions moved inline)
// ── Group 3: Operations ──────────────────────────────────────────
{
id: 'sec-vex-exceptions',
label: 'VEX & Exceptions',
icon: 'file-text',
route: '/ops/policy/vex',
menuGroupId: 'security',
menuGroupLabel: 'Security',
requireAnyScope: [StellaOpsScopes.VEX_READ, StellaOpsScopes.EXCEPTION_READ],
},
{
id: 'sec-risk-governance',
label: 'Risk & Governance',
icon: 'shield',
route: '/ops/policy/governance',
menuGroupId: 'security',
menuGroupLabel: 'Security',
requireAnyScope: [StellaOpsScopes.POLICY_READ],
children: [
{ id: 'sec-simulation', label: 'Simulation', route: '/ops/policy/simulation', icon: 'play' },
{ id: 'sec-policy-audit', label: 'Policy Audit', route: '/ops/policy/audit', icon: 'list' },
],
},
// ── Group 3: Evidence (trimmed from 7 to 4) ──────────────────────
{
id: 'evidence-overview',
label: 'Evidence Overview',
icon: 'file-text',
route: '/evidence/overview',
menuGroupId: 'evidence',
menuGroupLabel: 'Evidence',
requireAnyScope: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.POLICY_AUDIT,
StellaOpsScopes.AUTHORITY_AUDIT_READ,
StellaOpsScopes.SIGNER_READ,
StellaOpsScopes.VEX_EXPORT,
],
},
{
id: 'evidence-capsules',
label: 'Decision Capsules',
icon: 'archive',
route: '/evidence/capsules',
menuGroupId: 'evidence',
menuGroupLabel: 'Evidence',
requireAnyScope: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.POLICY_AUDIT,
],
},
{
id: 'evidence-audit-log',
label: 'Audit Log',
icon: 'list',
route: '/evidence/audit-log',
menuGroupId: 'evidence',
menuGroupLabel: 'Evidence',
requireAnyScope: [
StellaOpsScopes.POLICY_AUDIT,
StellaOpsScopes.AUTHORITY_AUDIT_READ,
],
},
{
id: 'evidence-exports',
label: 'Export Center',
icon: 'download',
route: '/evidence/exports',
menuGroupId: 'evidence',
menuGroupLabel: 'Evidence',
requireAnyScope: [
StellaOpsScopes.VEX_EXPORT,
StellaOpsScopes.RELEASE_READ,
],
},
// Replay & Verify, Bundles, Trust — removed from nav, still routable.
// Accessible from Evidence Overview, Decision Capsules detail, and Audit Log filters.
// ── Group 4: Operations (trimmed, absorbs Policy Packs) ──────────
{
id: 'ops',
label: 'Operations Hub',
@@ -737,6 +824,15 @@ export class AppSidebarComponent implements AfterViewInit {
StellaOpsScopes.POLICY_READ,
],
},
{
id: 'ops-policy-packs',
label: 'Policy Packs',
icon: 'clipboard',
route: '/ops/policy/packs',
menuGroupId: 'operations',
menuGroupLabel: 'Operations',
requireAnyScope: [StellaOpsScopes.POLICY_READ],
},
{
id: 'ops-jobs',
label: 'Scheduled Jobs',
@@ -750,10 +846,22 @@ export class AppSidebarComponent implements AfterViewInit {
],
},
{
id: 'ops-scripts',
label: 'Scripts',
icon: 'code',
route: '/ops/scripts',
id: 'ops-feeds-airgap',
label: 'Feeds & Airgap',
icon: 'download-cloud',
route: '/ops/operations/feeds-airgap',
menuGroupId: 'operations',
menuGroupLabel: 'Operations',
requireAnyScope: [
StellaOpsScopes.ADVISORY_READ,
StellaOpsScopes.VEX_READ,
],
},
{
id: 'ops-agents',
label: 'Agent Fleet',
icon: 'cpu',
route: '/ops/operations/agents',
menuGroupId: 'operations',
menuGroupLabel: 'Operations',
requireAnyScope: [
@@ -774,10 +882,10 @@ export class AppSidebarComponent implements AfterViewInit {
],
},
{
id: 'ops-agents',
label: 'Agent Fleet',
icon: 'cpu',
route: '/ops/operations/agents',
id: 'ops-scripts',
label: 'Scripts',
icon: 'code',
route: '/ops/scripts',
menuGroupId: 'operations',
menuGroupLabel: 'Operations',
requireAnyScope: [
@@ -785,75 +893,6 @@ export class AppSidebarComponent implements AfterViewInit {
StellaOpsScopes.ORCH_OPERATE,
],
},
{
id: 'ops-drift',
label: 'Runtime Drift',
icon: 'alert-triangle',
route: '/ops/operations/drift',
menuGroupId: 'operations',
menuGroupLabel: 'Operations',
requireAnyScope: [
StellaOpsScopes.ORCH_READ,
],
},
{
id: 'ops-environments',
label: 'Environments',
icon: 'globe',
route: '/environments/overview',
menuGroupId: 'release-control',
menuGroupLabel: 'Release Control',
requireAnyScope: [
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
],
},
{
id: 'ops-policy',
label: 'Packs',
icon: 'clipboard',
route: '/ops/policy/packs',
menuGroupId: 'policy',
menuGroupLabel: 'Policy',
requireAnyScope: [StellaOpsScopes.POLICY_READ],
},
{
id: 'ops-policy-governance',
label: 'Governance',
icon: 'shield',
route: '/ops/policy/governance',
menuGroupId: 'policy',
menuGroupLabel: 'Policy',
requireAnyScope: [StellaOpsScopes.POLICY_READ],
},
{
id: 'ops-policy-simulation',
label: 'Simulation',
icon: 'play',
route: '/ops/policy/simulation',
menuGroupId: 'policy',
menuGroupLabel: 'Policy',
requireAnyScope: [StellaOpsScopes.POLICY_SIMULATE],
},
{
id: 'ops-policy-vex',
label: 'VEX & Exceptions',
icon: 'file-text',
route: '/ops/policy/vex',
menuGroupId: 'policy',
menuGroupLabel: 'Policy',
requireAnyScope: [StellaOpsScopes.VEX_READ, StellaOpsScopes.EXCEPTION_READ],
},
// Release Gates absorbed into Deployments > Approvals tab
{
id: 'ops-policy-audit',
label: 'Policy Audit',
icon: 'list',
route: '/ops/policy/audit',
menuGroupId: 'policy',
menuGroupLabel: 'Policy',
requireAnyScope: [StellaOpsScopes.POLICY_AUDIT],
},
{
id: 'ops-diagnostics',
label: 'Diagnostics',
@@ -863,123 +902,9 @@ export class AppSidebarComponent implements AfterViewInit {
menuGroupLabel: 'Operations',
requireAnyScope: [StellaOpsScopes.HEALTH_READ, StellaOpsScopes.UI_ADMIN],
},
{
id: 'ops-notifications',
label: 'Notifications',
icon: 'bell',
route: '/ops/operations/notifications',
menuGroupId: 'operations',
menuGroupLabel: 'Operations',
requireAnyScope: [StellaOpsScopes.NOTIFY_VIEWER],
},
{
id: 'ops-feeds-airgap',
label: 'Feeds & Airgap',
icon: 'download-cloud',
route: '/ops/operations/feeds-airgap',
menuGroupId: 'operations',
menuGroupLabel: 'Operations',
requireAnyScope: [
StellaOpsScopes.ADVISORY_READ,
StellaOpsScopes.VEX_READ,
],
},
{
id: 'ops-watchlist',
label: 'Watchlist',
icon: 'eye',
route: '/ops/operations/watchlist',
menuGroupId: 'operations',
menuGroupLabel: 'Operations',
requireAnyScope: [StellaOpsScopes.SIGNER_READ],
},
// Trust Analytics merged into Trust (Audit & Evidence group)
// ── Group 4: Audit & Evidence ────────────────────────────────────
{
id: 'evidence-overview',
label: 'Evidence Overview',
icon: 'file-text',
route: '/evidence/overview',
menuGroupId: 'audit-evidence',
menuGroupLabel: 'Audit & Evidence',
requireAnyScope: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.POLICY_AUDIT,
StellaOpsScopes.AUTHORITY_AUDIT_READ,
StellaOpsScopes.SIGNER_READ,
StellaOpsScopes.VEX_EXPORT,
],
},
{
id: 'evidence-capsules',
label: 'Decision Capsules',
icon: 'archive',
route: '/evidence/capsules',
menuGroupId: 'audit-evidence',
menuGroupLabel: 'Audit & Evidence',
requireAnyScope: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.POLICY_AUDIT,
],
},
{
id: 'evidence-verify',
label: 'Replay & Verify',
icon: 'refresh',
route: '/evidence/verify-replay',
menuGroupId: 'audit-evidence',
menuGroupLabel: 'Audit & Evidence',
requireAnyScope: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.SIGNER_READ,
],
},
{
id: 'evidence-exports',
label: 'Export Center',
icon: 'download',
route: '/evidence/exports',
menuGroupId: 'audit-evidence',
menuGroupLabel: 'Audit & Evidence',
requireAnyScope: [
StellaOpsScopes.VEX_EXPORT,
StellaOpsScopes.RELEASE_READ,
],
},
{
id: 'evidence-audit-log',
label: 'Audit Log',
icon: 'list',
route: '/evidence/audit-log',
menuGroupId: 'audit-evidence',
menuGroupLabel: 'Audit & Evidence',
requireAnyScope: [
StellaOpsScopes.POLICY_AUDIT,
StellaOpsScopes.AUTHORITY_AUDIT_READ,
],
},
{
id: 'evidence-bundles',
label: 'Bundles',
icon: 'inbox',
route: '/evidence/bundles',
menuGroupId: 'audit-evidence',
menuGroupLabel: 'Audit & Evidence',
requireAnyScope: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.POLICY_AUDIT,
],
},
{
id: 'evidence-trust',
label: 'Trust',
icon: 'shield-check',
route: '/evidence/audit-log/trust',
menuGroupId: 'audit-evidence',
menuGroupLabel: 'Audit & Evidence',
requireAnyScope: [StellaOpsScopes.SIGNER_READ],
},
// ── Group 5: Setup & Admin ───────────────────────────────────────
// Runtime Drift, Notifications, Watchlist — removed from nav, still routable.
// Accessible from Operations Hub landing page.
// ── Group 5: Settings ────────────────────────────────────────────
{
id: 'setup-integrations',
label: 'Integrations',
@@ -1003,7 +928,7 @@ export class AppSidebarComponent implements AfterViewInit {
},
{
id: 'setup-trust-signing',
label: 'Certificates',
label: 'Certificates & Trust',
icon: 'key',
route: '/setup/trust-signing',
menuGroupId: 'setup-admin',
@@ -1052,7 +977,7 @@ export class AppSidebarComponent implements AfterViewInit {
/** Menu groups rendered in deterministic order for scanability */
readonly displaySectionGroups = computed<NavSectionGroup[]>(() => {
const orderedGroups = new Map<string, NavSectionGroup>();
const groupOrder = ['home', 'release-control', 'security', 'policy', 'operations', 'audit-evidence', 'setup-admin', 'misc'];
const groupOrder = ['home', 'release-control', 'security', 'evidence', 'operations', 'setup-admin', 'misc'];
for (const groupId of groupOrder) {
orderedGroups.set(groupId, {
@@ -1171,12 +1096,10 @@ export class AppSidebarComponent implements AfterViewInit {
return 'Release Control';
case 'security':
return 'Security';
case 'policy':
return 'Policy';
case 'evidence':
return 'Evidence';
case 'operations':
return 'Operations';
case 'audit-evidence':
return 'Audit & Evidence';
case 'setup-admin':
return 'Settings';
default:

View File

@@ -33,7 +33,7 @@ export interface NavItem {
[routerLink]="route"
routerLinkActive="nav-item--active"
[routerLinkActiveOptions]="isChild ? { paths: 'subset', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored' } : { paths: 'exact', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored' }"
[attr.title]="collapsed ? label : null"
[attr.title]="label"
>
<span class="nav-item__icon" [attr.aria-hidden]="true">
@switch (icon) {

View File

@@ -10,7 +10,7 @@ const STORAGE_KEY = 'stellaops.sidebar.preferences';
const DEFAULTS: SidebarPreferences = {
sidebarCollapsed: false,
collapsedGroups: ['operations', 'audit-evidence', 'setup-admin'],
collapsedGroups: ['evidence', 'operations', 'setup-admin'],
collapsedSections: [],
};
@@ -63,14 +63,19 @@ export class SidebarPreferenceService {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
let groups: string[] = Array.isArray(parsed.collapsedGroups)
? parsed.collapsedGroups
: DEFAULTS.collapsedGroups;
// Migrate renamed/removed groups from prior 7-group IA
groups = groups
.map((g: string) => (g === 'audit-evidence' ? 'evidence' : g))
.filter((g: string) => g !== 'policy');
return {
sidebarCollapsed:
typeof parsed.sidebarCollapsed === 'boolean'
? parsed.sidebarCollapsed
: DEFAULTS.sidebarCollapsed,
collapsedGroups: Array.isArray(parsed.collapsedGroups)
? parsed.collapsedGroups
: DEFAULTS.collapsedGroups,
collapsedGroups: groups,
collapsedSections: Array.isArray(parsed.collapsedSections)
? parsed.collapsedSections
: DEFAULTS.collapsedSections,

View File

@@ -319,11 +319,7 @@ export const RELEASES_ROUTES: Routes = [
},
{
path: 'readiness',
title: 'Readiness Dashboard',
data: { breadcrumb: 'Readiness' },
loadComponent: () =>
import('../features/topology/readiness-dashboard.component').then(
(m) => m.ReadinessDashboardComponent,
),
redirectTo: '/environments/overview',
pathMatch: 'full',
},
];

View File

@@ -15,15 +15,15 @@ export const TOPOLOGY_ROUTES: Routes = [
},
{
path: 'overview',
title: 'Environment Topology',
title: 'Environments',
data: {
breadcrumb: 'Topology',
title: 'Environment Topology',
description: 'Interactive SVG topology of regions, environments, and promotion paths.',
breadcrumb: 'Environments',
title: 'Environments',
description: 'Release readiness, gate status, and promotion topology across all environments.',
},
loadComponent: () =>
import('../features/topology/topology-graph-page.component').then(
(m) => m.TopologyGraphPageComponent,
import('../features/topology/environments-command.component').then(
(m) => m.EnvironmentsCommandComponent,
),
},
@@ -107,7 +107,7 @@ export const TOPOLOGY_ROUTES: Routes = [
{ path: 'promotion-paths', redirectTo: '/releases/promotion-graph', pathMatch: 'full' },
{ path: 'workflows', redirectTo: '/releases/workflows', pathMatch: 'full' },
{ path: 'workflows-gates', redirectTo: '/releases/workflows', pathMatch: 'full' },
{ path: 'readiness', redirectTo: '/releases/readiness', pathMatch: 'full' },
{ path: 'readiness', redirectTo: 'overview', pathMatch: 'full' },
],
},
];

View File

@@ -209,9 +209,14 @@ function toDateKey(iso: string, locale?: string): string {
[class]="'timeline__marker--' + (event.eventKind || 'neutral')"
aria-hidden="true"
>
@if (event.icon) {
<span class="timeline__icon material-symbols-outlined">{{ event.icon }}</span>
}
<svg class="timeline__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
@switch (event.eventKind) {
@case ('success') { <polyline points="20 6 9 17 4 12"/> }
@case ('error') { <circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/> }
@case ('warning') { <circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/> }
@default { <circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/> }
}
</svg>
</div>
<div class="timeline__content">
@@ -280,7 +285,10 @@ function toDateKey(iso: string, locale?: string): string {
<!-- Empty state template -->
<ng-template #emptyState>
<div class="timeline__empty" role="status">
<span class="timeline__empty-icon material-symbols-outlined" aria-hidden="true">event_busy</span>
<svg class="timeline__empty-icon" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
<line x1="10" y1="14" x2="14" y2="18"/><line x1="14" y1="14" x2="10" y2="18"/>
</svg>
<p class="timeline__empty-text">{{ emptyMessage() }}</p>
</div>
</ng-template>
@@ -369,8 +377,9 @@ function toDateKey(iso: string, locale?: string): string {
}
.timeline__icon {
font-size: 0.75rem;
line-height: 1;
width: 14px;
height: 14px;
flex-shrink: 0;
}
.timeline__marker--success {
@@ -539,7 +548,6 @@ function toDateKey(iso: string, locale?: string): string {
}
.timeline__empty-icon {
font-size: 2rem;
color: var(--color-text-muted);
}

View File

@@ -191,3 +191,50 @@
}
}
}
// =============================================================================
// Stella Glossary Tooltip (global styles for directive-injected tooltips)
// =============================================================================
.stella-glossary-term {
text-decoration: underline;
text-decoration-style: dotted;
text-decoration-color: var(--color-brand-primary);
text-underline-offset: 3px;
cursor: help;
transition: text-decoration-color 0.15s;
&:hover {
text-decoration-color: var(--color-text-heading);
}
}
.stella-glossary-tooltip {
position: fixed;
z-index: 1100;
max-width: 280px;
padding: 8px 12px;
font-size: 0.6875rem;
line-height: 1.5;
color: white;
background: var(--color-surface-inverse, #070B14);
border-radius: var(--radius-md, 8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
pointer-events: none;
animation: glossary-fade 0.15s ease;
strong {
color: var(--color-brand-primary, #F5A623);
display: block;
margin-bottom: 2px;
}
}
@keyframes glossary-fade {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.stella-glossary-tooltip { animation: none; }
}

View File

@@ -1,6 +1,6 @@
/**
* Navigation model unit tests
* Sprint: SPRINT_20260220_021_FE_pack22_run_centric_releases_platform_scope (FE21-01, FE21-11)
* Validates the 6-group sidebar structure after Policy dissolution.
*/
import { TestBed } from '@angular/core/testing';
@@ -12,31 +12,30 @@ import { AUTH_SERVICE } from '../../app/core/auth';
import { APPROVAL_API } from '../../app/core/api/approval.client';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
/**
* The 6 canonical menuGroupId values used in the sidebar.
* Policy was dissolved: VEX/Governance/Simulation/Audit -> Security, Packs -> Operations.
*/
const CANONICAL_DOMAIN_IDS = [
'dashboard',
'releases',
'home',
'release-control',
'security',
'evidence',
'topology',
'platform',
] as const;
const CANONICAL_DOMAIN_ROUTES = [
'/dashboard',
'/releases',
'/security',
'/evidence',
'/topology',
'/platform',
'operations',
'setup-admin',
] as const;
/**
* Expected group labels rendered in the sidebar header for each menuGroupId.
* 'home' has no visible label (empty string); the rest use clean canonical names.
*/
const EXPECTED_SECTION_LABELS: Record<string, string> = {
dashboard: 'Mission Control',
releases: 'Releases',
security: 'Security',
evidence: 'Evidence',
topology: 'Topology',
platform: 'Platform',
'home': '',
'release-control': 'Release Control',
'security': 'Security',
'evidence': 'Evidence',
'operations': 'Operations',
'setup-admin': 'Settings',
};
describe('AppSidebarComponent nav model (navigation)', () => {
@@ -84,68 +83,158 @@ describe('AppSidebarComponent nav model (navigation)', () => {
component = fixture.componentInstance;
});
it('defines exactly 6 canonical root domains', () => {
expect(component.navSections.length).toBe(6);
it('renders exactly 6 menu groups', () => {
const groupIds = new Set(
component.navSections
.map((s) => s.menuGroupId)
.filter((id): id is string => !!id),
);
expect(groupIds.size).toBe(6);
});
it('root domain IDs match canonical IA order', () => {
expect(component.navSections.map((s) => s.id)).toEqual([...CANONICAL_DOMAIN_IDS]);
it('menu group IDs match the 6 canonical domain IDs', () => {
const groupIds = [
...new Set(
component.navSections
.map((s) => s.menuGroupId)
.filter((id): id is string => !!id),
),
];
// Order is determined by the navSections declaration order
expect(groupIds).toEqual([...CANONICAL_DOMAIN_IDS]);
});
it('root domain routes all point to canonical paths', () => {
expect(component.navSections.map((s) => s.route)).toEqual([...CANONICAL_DOMAIN_ROUTES]);
});
it('section labels use clean canonical names', () => {
it('menu group labels match canonical names', () => {
// Build a map of menuGroupId -> menuGroupLabel from the first section in each group
const labelMap = new Map<string, string>();
for (const section of component.navSections) {
expect(section.label).toBe(EXPECTED_SECTION_LABELS[section.id]);
const gid = section.menuGroupId;
if (gid && !labelMap.has(gid)) {
labelMap.set(gid, section.menuGroupLabel ?? '');
}
}
for (const [groupId, expectedLabel] of Object.entries(EXPECTED_SECTION_LABELS)) {
expect(labelMap.get(groupId)).toBe(expectedLabel);
}
});
it('Releases uses run/version-first navigation shortcuts', () => {
const releases = component.navSections.find((s) => s.id === 'releases')!;
const childIds = releases.children?.map((child) => child.id) ?? [];
expect(childIds).toContain('rel-versions');
expect(childIds).toContain('rel-runs');
expect(childIds).toContain('rel-approvals');
expect(childIds).toContain('rel-hotfix');
it('policy is not a standalone group (dissolved into Security and Operations)', () => {
const groupIds = new Set(component.navSections.map((s) => s.menuGroupId));
expect(groupIds.has('policy')).toBeFalse();
expect(groupIds.has('policy-governance')).toBeFalse();
});
it('Releases create route uses canonical version-creation path', () => {
const releases = component.navSections.find((s) => s.id === 'releases')!;
const create = releases.children!.find((child) => child.id === 'rel-create')!;
expect(create.route).toBe('/releases/versions/new');
it('Release Control group contains Environments, Deployments, Releases', () => {
const rcSections = component.navSections.filter((s) => s.menuGroupId === 'release-control');
const ids = rcSections.map((s) => s.id);
expect(ids).toContain('ops-environments');
expect(ids).toContain('deployments');
expect(ids).toContain('releases');
});
it('derives approvals queue badge from pending approvals', () => {
const releases = component.visibleSections().find((s) => s.id === 'releases')!;
const approvals = releases.children!.find((child) => child.id === 'rel-approvals')!;
expect(approvals.badge).toBe(1);
it('Environments is the first item in Release Control and uses /environments/overview', () => {
const rcSections = component.navSections.filter((s) => s.menuGroupId === 'release-control');
const first = rcSections[0];
expect(first.id).toBe('ops-environments');
expect(first.route).toBe('/environments/overview');
});
it('Evidence uses capsule-first workflow labels', () => {
const evidence = component.navSections.find((s) => s.id === 'evidence')!;
const capsules = evidence.children?.find((child) => child.id === 'ev-capsules');
const verify = evidence.children?.find((child) => child.id === 'ev-verify');
expect(capsules?.route).toBe('/evidence/capsules');
expect(verify?.route).toBe('/evidence/verification/replay');
it('Security group absorbs former Policy items (VEX, Governance, Simulation, Audit)', () => {
const secSections = component.navSections.filter((s) => s.menuGroupId === 'security');
const ids = secSections.map((s) => s.id);
expect(ids).toContain('vulnerabilities');
expect(ids).toContain('security-posture');
expect(ids).toContain('scan-image');
expect(ids).toContain('sec-vex-exceptions');
expect(ids).toContain('sec-risk-governance');
});
it('Platform group owns ops/integrations/setup shortcuts', () => {
const platform = component.navSections.find((s) => s.id === 'platform')!;
const routes = platform.children?.map((child) => child.route) ?? [];
it('Findings Explorer is surfaced under Security Posture children', () => {
const posture = component.navSections.find((s) => s.id === 'security-posture')!;
const childIds = posture.children?.map((c) => c.id) ?? [];
expect(childIds).toContain('sec-findings-explorer');
expect(routes).toContain('/platform/ops');
expect(routes).toContain('/platform/integrations');
expect(routes).toContain('/platform/setup');
const findings = posture.children?.find((c) => c.id === 'sec-findings-explorer');
expect(findings?.route).toBe('/security/findings');
});
it('no section root route uses deprecated root prefixes', () => {
const legacyRootSegments = ['release-control', 'security-risk', 'evidence-audit', 'platform-ops'];
it('Evidence group has 4 items (Overview, Capsules, Audit Log, Export Center)', () => {
const evSections = component.navSections.filter((s) => s.menuGroupId === 'evidence');
const ids = evSections.map((s) => s.id);
expect(evSections.length).toBe(4);
expect(ids).toContain('evidence-overview');
expect(ids).toContain('evidence-capsules');
expect(ids).toContain('evidence-audit-log');
expect(ids).toContain('evidence-exports');
});
it('Evidence does not contain Replay & Verify, Bundles, or Trust (removed from nav)', () => {
const evSections = component.navSections.filter((s) => s.menuGroupId === 'evidence');
const routes = evSections.map((s) => s.route);
expect(routes).not.toContain('/evidence/verify-replay');
expect(routes).not.toContain('/evidence/verification/replay');
expect(routes).not.toContain('/evidence/bundles');
const ids = evSections.map((s) => s.id);
expect(ids).not.toContain('ev-verify');
expect(ids).not.toContain('ev-bundles');
expect(ids).not.toContain('ev-trust');
});
it('Operations group absorbs Policy Packs from dissolved Policy group', () => {
const opsSections = component.navSections.filter((s) => s.menuGroupId === 'operations');
const ids = opsSections.map((s) => s.id);
expect(ids).toContain('ops-policy-packs');
expect(ids).toContain('ops');
expect(ids).toContain('ops-jobs');
expect(ids).toContain('ops-feeds-airgap');
expect(ids).toContain('ops-agents');
expect(ids).toContain('ops-signals');
expect(ids).toContain('ops-scripts');
expect(ids).toContain('ops-diagnostics');
});
it('Operations does not contain Runtime Drift, Notifications, or Watchlist (removed from nav)', () => {
const opsSections = component.navSections.filter((s) => s.menuGroupId === 'operations');
const ids = opsSections.map((s) => s.id);
const routes = opsSections.map((s) => s.route);
expect(ids).not.toContain('ops-drift');
expect(ids).not.toContain('ops-notifications');
expect(ids).not.toContain('ops-watchlist');
expect(routes).not.toContain('/ops/operations/drift');
expect(routes).not.toContain('/ops/operations/notifications');
expect(routes).not.toContain('/ops/operations/watchlist');
});
it('Settings group uses setup-admin menuGroupId', () => {
const setupSections = component.navSections.filter((s) => s.menuGroupId === 'setup-admin');
const ids = setupSections.map((s) => s.id);
expect(ids).toContain('setup-integrations');
expect(ids).toContain('setup-iam');
expect(ids).toContain('setup-trust-signing');
expect(ids).toContain('setup-branding');
expect(ids).toContain('setup-preferences');
});
it('Certificates & Trust label replaces old "Certificates" label', () => {
const trustItem = component.navSections.find((s) => s.id === 'setup-trust-signing')!;
expect(trustItem).toBeDefined();
expect(trustItem.label).toBe('Certificates & Trust');
});
it('no section uses deprecated root prefixes or legacy group IDs', () => {
const legacyGroupIds = ['audit-evidence', 'policy', 'platform', 'topology', 'dashboard', 'integrations', 'administration'];
for (const section of component.navSections) {
const rootSegment = section.route.replace(/^\/+/, '').split('/')[0] ?? '';
expect(legacyRootSegments).not.toContain(rootSegment);
if (section.menuGroupId) {
expect(legacyGroupIds).not.toContain(section.menuGroupId);
}
}
});
});

View File

@@ -1,3 +1,9 @@
/**
* Navigation route integrity tests
* Validates that every sidebar route resolves to a concrete canonical route
* and that required routes are present after the 7->6 group restructure.
*/
import { TestBed } from '@angular/core/testing';
import { provideRouter, type Route } from '@angular/router';
@@ -5,12 +11,11 @@ import { AUTH_SERVICE } from '../../app/core/auth';
import { AppSidebarComponent } from '../../app/layout/app-sidebar/app-sidebar.component';
import { EVIDENCE_ROUTES } from '../../app/routes/evidence.routes';
import { OPERATIONS_ROUTES } from '../../app/routes/operations.routes';
import { PLATFORM_ROUTES } from '../../app/routes/platform.routes';
import { RELEASES_ROUTES } from '../../app/routes/releases.routes';
import { SECURITY_ROUTES } from '../../app/routes/security.routes';
import { TOPOLOGY_ROUTES } from '../../app/routes/topology.routes';
import { integrationHubRoutes } from '../../app/features/integration-hub/integration-hub.routes';
import { PLATFORM_SETUP_ROUTES } from '../../app/features/platform/setup/platform-setup.routes';
import { SETUP_ROUTES } from '../../app/routes/setup.routes';
import { OPS_ROUTES } from '../../app/routes/ops.routes';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
function joinPath(prefix: string, path: string | undefined): string | null {
@@ -59,85 +64,188 @@ describe('AppSidebarComponent route integrity (navigation)', () => {
});
it('every sidebar route resolves to a concrete canonical route', () => {
// Build allowed route set from all registered route modules
const allowed = new Set<string>([
'/dashboard',
'/',
'/releases',
'/security',
'/evidence',
'/topology',
'/platform',
'/triage/artifacts',
]);
for (const path of collectConcretePaths('/releases', RELEASES_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/security', SECURITY_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/evidence', EVIDENCE_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/topology', TOPOLOGY_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/platform', PLATFORM_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/platform/ops', OPERATIONS_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/platform/integrations', integrationHubRoutes)) allowed.add(path);
for (const path of collectConcretePaths('/platform/setup', PLATFORM_SETUP_ROUTES)) allowed.add(path);
allowed.add('/security/sbom/lake');
for (const path of collectConcretePaths('/ops', OPS_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/ops/operations', OPERATIONS_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/setup', SETUP_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/environments', TOPOLOGY_ROUTES)) allowed.add(path);
// Routes used in the nav that are defined in nested lazy-loaded children
allowed.add('/ops/policy/vex');
allowed.add('/ops/policy/governance');
allowed.add('/ops/policy/simulation');
allowed.add('/ops/policy/audit');
allowed.add('/ops/policy/packs');
allowed.add('/ops/operations/jobengine');
allowed.add('/ops/operations/feeds-airgap');
allowed.add('/ops/operations/agents');
allowed.add('/ops/operations/signals');
allowed.add('/ops/operations/doctor');
allowed.add('/ops/scripts');
allowed.add('/security/supply-chain-data');
allowed.add('/security/findings');
allowed.add('/security/reachability');
allowed.add('/security/unknowns');
allowed.add('/security/scan');
allowed.add('/evidence/overview');
allowed.add('/evidence/capsules');
allowed.add('/evidence/audit-log');
allowed.add('/evidence/exports');
allowed.add('/releases/deployments');
allowed.add('/environments/overview');
allowed.add('/setup/integrations');
allowed.add('/setup/identity-access');
allowed.add('/setup/trust-signing');
allowed.add('/setup/tenant-branding');
allowed.add('/setup/preferences');
allowed.add('/console/admin/tenants');
allowed.add('/console/admin/users');
allowed.add('/console/admin/roles');
allowed.add('/console/admin/clients');
allowed.add('/console/admin/tokens');
allowed.add('/concelier/trivy-db-settings');
for (const section of component.navSections) {
expect(allowed.has(section.route)).toBeTrue();
expect(allowed.has(section.route))
.withContext(`Section route not in allowed: ${section.route} (id=${section.id})`)
.toBeTrue();
for (const child of section.children ?? []) {
expect(allowed.has(child.route)).toBeTrue();
expect(allowed.has(child.route))
.withContext(`Child route not in allowed: ${child.route} (id=${child.id})`)
.toBeTrue();
}
}
});
it('includes required canonical shell routes from active Pack22 sprints', () => {
const allowed = new Set<string>();
for (const path of collectConcretePaths('/releases', RELEASES_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/security', SECURITY_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/evidence', EVIDENCE_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/topology', TOPOLOGY_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/platform/ops', OPERATIONS_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/platform/integrations', integrationHubRoutes)) allowed.add(path);
for (const path of collectConcretePaths('/platform/setup', PLATFORM_SETUP_ROUTES)) allowed.add(path);
allowed.add('/security/sbom/lake');
it('includes required canonical routes from active navigation', () => {
// Collect all routes actually declared in the sidebar
const sidebarRoutes = new Set<string>();
for (const section of component.navSections) {
sidebarRoutes.add(section.route);
for (const child of section.children ?? []) {
sidebarRoutes.add(child.route);
}
}
const required = [
'/releases/versions',
'/releases/runs',
'/security/triage',
'/security/disposition',
'/security/sbom/lake',
// Release Control
'/releases/deployments',
'/releases',
'/environments/overview',
// Security (including absorbed Policy items)
'/triage/artifacts',
'/security',
'/security/findings',
'/security/scan',
'/ops/policy/vex',
'/ops/policy/governance',
'/ops/policy/simulation',
'/ops/policy/audit',
// Evidence
'/evidence/overview',
'/evidence/capsules',
'/evidence/verification/replay',
'/topology/agents',
'/topology/promotion-graph',
'/platform/ops/jobs-queues',
'/platform/ops/feeds-airgap',
'/platform/integrations/runtime-hosts',
'/platform/integrations/vex-sources',
'/platform/setup/feed-policy',
'/platform/setup/gate-profiles',
'/platform/setup/defaults-guardrails',
'/platform/setup/trust-signing',
'/evidence/audit-log',
'/evidence/exports',
// Operations (including absorbed Policy Packs)
'/ops/operations',
'/ops/policy/packs',
'/ops/operations/jobengine',
'/ops/operations/feeds-airgap',
'/ops/operations/agents',
'/ops/operations/signals',
'/ops/scripts',
'/ops/operations/doctor',
// Settings
'/setup/integrations',
'/setup/identity-access',
'/setup/trust-signing',
'/setup/tenant-branding',
'/setup/preferences',
];
for (const path of required) {
expect(allowed.has(path)).toBeTrue();
expect(sidebarRoutes.has(path))
.withContext(`Required route missing from sidebar: ${path}`)
.toBeTrue();
}
});
it('removed routes are no longer present in the sidebar', () => {
const sidebarRoutes = new Set<string>();
for (const section of component.navSections) {
sidebarRoutes.add(section.route);
for (const child of section.children ?? []) {
sidebarRoutes.add(child.route);
}
}
const removed = [
'/ops/operations/drift',
'/ops/operations/notifications',
'/ops/operations/watchlist',
'/evidence/verify-replay',
'/evidence/verification/replay',
'/evidence/bundles',
'/evidence/audit-log/trust',
];
for (const path of removed) {
expect(sidebarRoutes.has(path))
.withContext(`Removed route still in sidebar: ${path}`)
.toBeFalse();
}
});
it('policy is not a menuGroupId (dissolved into Security and Operations)', () => {
const groupIds = new Set(component.navSections.map((s) => s.menuGroupId).filter(Boolean));
expect(groupIds.has('policy')).toBeFalse();
expect(groupIds.has('policy-governance')).toBeFalse();
});
it('has no duplicate routes within the sidebar', () => {
const seen = new Set<string>();
const duplicates: string[] = [];
for (const section of component.navSections) {
if (seen.has(section.route)) duplicates.push(section.route);
seen.add(section.route);
for (const child of section.children ?? []) {
// Children may share routes with their parent section (drill-down pattern), skip those
if (child.route === section.route) continue;
if (seen.has(child.route)) duplicates.push(child.route);
seen.add(child.route);
}
}
expect(duplicates)
.withContext(`Duplicate routes found: ${duplicates.join(', ')}`)
.toEqual([]);
});
it('has no duplicate concrete route declarations inside canonical route families', () => {
const routeGroups: Array<{ name: string; paths: string[] }> = [
{ name: 'releases', paths: collectConcretePathsArray('/releases', RELEASES_ROUTES) },
{ name: 'security', paths: collectConcretePathsArray('/security', SECURITY_ROUTES) },
{ name: 'evidence', paths: collectConcretePathsArray('/evidence', EVIDENCE_ROUTES) },
{ name: 'topology', paths: collectConcretePathsArray('/topology', TOPOLOGY_ROUTES) },
{ name: 'platform', paths: collectConcretePathsArray('/platform', PLATFORM_ROUTES) },
{ name: 'platform-ops', paths: collectConcretePathsArray('/platform/ops', OPERATIONS_ROUTES) },
{ name: 'platform-integrations', paths: collectConcretePathsArray('/platform/integrations', integrationHubRoutes) },
{ name: 'platform-setup', paths: collectConcretePathsArray('/platform/setup', PLATFORM_SETUP_ROUTES) },
{ name: 'ops-operations', paths: collectConcretePathsArray('/ops/operations', OPERATIONS_ROUTES) },
{ name: 'setup', paths: collectConcretePathsArray('/setup', SETUP_ROUTES) },
];
for (const group of routeGroups) {
const seen = new Set<string>();
for (const path of group.paths) {
expect(seen.has(path)).toBeFalse();
expect(seen.has(path))
.withContext(`Duplicate in ${group.name}: ${path}`)
.toBeFalse();
seen.add(path);
}
}