diff --git a/src/Web/StellaOps.Web/e2e/environment-detail.e2e.spec.ts b/src/Web/StellaOps.Web/e2e/environment-detail.e2e.spec.ts new file mode 100644 index 000000000..b2ea6f518 --- /dev/null +++ b/src/Web/StellaOps.Web/e2e/environment-detail.e2e.spec.ts @@ -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'); + }); +}); diff --git a/src/Web/StellaOps.Web/e2e/environments-command.e2e.spec.ts b/src/Web/StellaOps.Web/e2e/environments-command.e2e.spec.ts new file mode 100644 index 000000000..40b0aae99 --- /dev/null +++ b/src/Web/StellaOps.Web/e2e/environments-command.e2e.spec.ts @@ -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 = { + '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'); + }); +}); diff --git a/src/Web/StellaOps.Web/e2e/fixtures/live-auth.fixture.ts b/src/Web/StellaOps.Web/e2e/fixtures/live-auth.fixture.ts new file mode 100644 index 000000000..92b78a77a --- /dev/null +++ b/src/Web/StellaOps.Web/e2e/fixtures/live-auth.fixture.ts @@ -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 }; diff --git a/src/Web/StellaOps.Web/e2e/navigation-reorganization.e2e.spec.ts b/src/Web/StellaOps.Web/e2e/navigation-reorganization.e2e.spec.ts new file mode 100644 index 000000000..1a7cb1388 --- /dev/null +++ b/src/Web/StellaOps.Web/e2e/navigation-reorganization.e2e.spec.ts @@ -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); + }); +}); diff --git a/src/Web/StellaOps.Web/e2e/release-workflow.e2e.spec.ts b/src/Web/StellaOps.Web/e2e/release-workflow.e2e.spec.ts new file mode 100644 index 000000000..545937f10 --- /dev/null +++ b/src/Web/StellaOps.Web/e2e/release-workflow.e2e.spec.ts @@ -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'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/core/api/approval.client.ts b/src/Web/StellaOps.Web/src/app/core/api/approval.client.ts index 4aceb93ab..db86f15a7 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/approval.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/approval.client.ts @@ -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 { const params: Record = {}; @@ -78,20 +78,16 @@ export class ApprovalHttpClient implements ApprovalApi { } approve(id: string, comment: string): Observable { - return this.http.post(`${this.detailBaseUrl}/${id}/decision`, { - action: 'approve', + return this.http.post(`${this.detailBaseUrl}/${id}/approve`, { comment, - actor: 'ui-operator', }).pipe( map(row => this.mapV2ApprovalDetail(row)) ); } reject(id: string, comment: string): Observable { - return this.http.post(`${this.detailBaseUrl}/${id}/decision`, { - action: 'reject', + return this.http.post(`${this.detailBaseUrl}/${id}/reject`, { comment, - actor: 'ui-operator', }).pipe( map(row => this.mapV2ApprovalDetail(row)) ); diff --git a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts index 5199e5816..835201cd2 100644 --- a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts +++ b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts @@ -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)', - }, ], }, ]; diff --git a/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts b/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts index c85780d9b..bd050ab80 100644 --- a/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts @@ -92,6 +92,9 @@ interface PendingAction {

Dashboard

{{ tenantLabel() }}

+ @if (!contextReady()) { @@ -161,11 +164,14 @@ interface PendingAction { All environments -
+
@if (showPipelineLeftArrow()) { +
@if (showPipelineRightArrow()) { +
- - } `, @@ -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 { diff --git a/src/Web/StellaOps.Web/src/app/features/docs/docs-viewer.component.ts b/src/Web/StellaOps.Web/src/app/features/docs/docs-viewer.component.ts index b8a790b8f..92240d766 100644 --- a/src/Web/StellaOps.Web/src/app/features/docs/docs-viewer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/docs/docs-viewer.component.ts @@ -56,7 +56,7 @@ interface TocHeading { @@ -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); diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-audit/evidence-audit-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-audit/evidence-audit-overview.component.ts index 34c9db71e..fee43ded6 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-audit/evidence-audit-overview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-audit/evidence-audit-overview.component.ts @@ -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' }, diff --git a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-dashboard.component.ts index 478972b47..5d69d1d8e 100644 --- a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-dashboard.component.ts @@ -19,9 +19,6 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
- - ← Back to Jobs & Queues -

Scheduled Jobs

Execution queues, quotas, dead-letter recovery, and scheduler handoffs.

@@ -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; diff --git a/src/Web/StellaOps.Web/src/app/features/mission-control/mission-activity-page.component.ts b/src/Web/StellaOps.Web/src/app/features/mission-control/mission-activity-page.component.ts index bc74db5be..1c17204b3 100644 --- a/src/Web/StellaOps.Web/src/app/features/mission-control/mission-activity-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/mission-control/mission-activity-page.component.ts @@ -15,9 +15,9 @@ import { RouterLink } from '@angular/router';

Evidence

diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.html b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.html index d7707f3f4..efdeb8b22 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.html +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.html @@ -8,7 +8,7 @@
- +
Run Doctor Audit Log diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.scss b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.scss index 3c397ca9f..0752b0fb1 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.scss @@ -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 { diff --git a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-promotion-paths-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-promotion-paths-page.component.ts index 1eaf3948f..c19a04423 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-promotion-paths-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-promotion-paths-page.component.ts @@ -53,7 +53,7 @@ interface PromotionRule { diff --git a/src/Web/StellaOps.Web/src/app/features/releases/release-ops-overview-page.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/release-ops-overview-page.component.ts index cb4b2f6e3..cb4f8f170 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/release-ops-overview-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/release-ops-overview-page.component.ts @@ -15,7 +15,7 @@ import { RouterLink } from '@angular/router';
Release Versions - Release Runs + Deployments Approvals Queue Hotfixes Promotions diff --git a/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts index 74bbbfc38..dc1bb1a0c 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts @@ -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: `
-
-

Deployments

-

Deployment runs, approvals, and promotion activity.

-
- - - @if (showPendingLane()) { -
-
-

- - {{ pendingApprovals().length }} Pending Approval{{ pendingApprovals().length === 1 ? '' : 's' }} -

- -
-
- @for (apr of pendingApprovals().slice(0, 5); track apr.id) { -
-
-
{{ apr.releaseName }}@if (apr.releaseVersion && apr.releaseVersion !== apr.releaseName) {{{ apr.releaseVersion }}}
- {{ apr.urgency }} -
-
- {{ apr.sourceEnvironment }} - - {{ apr.targetEnvironment }} -
-
- by {{ apr.requestedBy }} - {{ apr.gatesPassed ? 'Gates OK' : 'Gates fail' }} - {{ timeRemaining(apr.expiresAt) }} -
-
- - - -
-
- } +
+
+
+

Deployments

+

Deployment pipeline and approval queue.

+
- } +
- + - - @if (showRejectDlg()) { -
`, 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(null); readonly rows = signal([]); - 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; @ViewChild('approveConfirm') approveConfirmRef!: ConfirmDialogComponent; readonly pendingApprovals = signal([]); + 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([]); 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); diff --git a/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts index 353d3056b..69c73066b 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/releases-unified-page.component.ts @@ -1,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: `
-
+
-

Release Control

-

{{ activeSubtitle() }}

+

Releases

+

Version catalog and release plans.

-
+
- + - @if (activeTab() === 'releases') { - -
-
@@ -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(null); diff --git a/src/Web/StellaOps.Web/src/app/features/topology/environments-command.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/environments-command.component.ts new file mode 100644 index 000000000..8b5e6dec4 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/topology/environments-command.component.ts @@ -0,0 +1,1023 @@ +import { + Component, OnInit, OnDestroy, signal, computed, inject, ChangeDetectionStrategy, +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, RouterLink } from '@angular/router'; +import { HttpClient } from '@angular/common/http'; +import { catchError, of, forkJoin, interval, Subject, takeUntil, take } from 'rxjs'; + +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; +import { TopologyGraphComponent } from './topology-graph.component'; +import { TopologyLayoutService } from './topology-layout.service'; +import { + TopologyLayoutResponse, + TopologyPositionedNode, + TopologyRoutedEdge, +} from './topology-layout.models'; +import { TopologyTarget, TopologyHost, PlatformListResponse } from './topology.models'; +import { PlatformContextStore } from '../../core/context/platform-context.store'; +import { HttpParams } from '@angular/common/http'; + +interface ReleaseActivity { + activityId: string; + releaseId: string; + releaseName: string; + version?: string; + status: string; + eventType: string; + occurredAt: string; +} + +const PENDING_STATUSES = new Set([ + 'pending', 'promoting', 'awaiting_approval', 'gates_running', + 'deploying', 'draft', 'ready', +]); + +// ── Interfaces ────────────────────────────────────────────────────── + +interface GateResult { + gateName: string; + status: string; + message?: string; + checkedAt?: string; + durationMs?: number; +} + +interface ReadinessReport { + targetId: string; + targetName?: string; + environmentId: string; + isReady: boolean; + gates: GateResult[]; + evaluatedAt: string; +} + +interface Environment { + environmentId: string; + displayName: string; + regionId?: string; + environmentType?: string; +} + +// ── Mock data ─────────────────────────────────────────────────────── + +const REGIONS = [ + { regionId: 'apac', displayName: 'Asia-Pacific' }, + { regionId: 'eu-west', displayName: 'EU West' }, + { regionId: 'us-east', displayName: 'US East' }, + { regionId: 'us-west', displayName: 'US West' }, +]; + +const MOCK_ENVS: Environment[] = [ + { 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: 'prod-eu-west', displayName: 'EU West DR', regionId: 'eu-west', environmentType: 'dr' }, + { environmentId: 'apac-prod', displayName: 'APAC Production', regionId: 'apac', environmentType: 'production' }, +]; + +function g(name: string, status: string, msg?: string, dur?: number): GateResult { + return { gateName: name, status, message: msg, + checkedAt: new Date(Date.now() - Math.random() * 120_000).toISOString(), + durationMs: dur ?? Math.round(30 + Math.random() * 400) }; +} +function ok(): GateResult[] { + return [g('agent_bound','pass','Agent heartbeat OK'), g('docker_version_ok','pass','Docker 24.0.9'), + g('docker_ping_ok','pass','Daemon reachable'), g('registry_pull_ok','pass','Pull test OK'), + g('vault_reachable','pass','Vault unsealed'), g('consul_reachable','pass','Consul leader elected'), + g('connectivity_ok','pass','All required gates pass')]; +} +function t(id: string, name: string, env: string, ready: boolean, gates?: GateResult[]): ReadinessReport { + return { targetId: id, targetName: name, environmentId: env, isReady: ready, + gates: gates ?? ok(), evaluatedAt: new Date(Date.now() - Math.round(Math.random()*300_000)).toISOString() }; +} + +const MOCK_REPORTS: ReadinessReport[] = [ + t('tgt-eu-p-a1','eu-prod-app-01','eu-prod',true), t('tgt-eu-p-a2','eu-prod-app-02','eu-prod',true), + t('tgt-eu-p-w1','eu-prod-worker-01','eu-prod',false,[ + g('agent_bound','pass','Agent heartbeat OK'),g('docker_version_ok','pass','Docker 24.0.9'), + g('docker_ping_ok','pass','Daemon reachable'),g('registry_pull_ok','pass','Pull test OK'), + g('vault_reachable','fail','Vault sealed — manual unseal required',5100), + g('consul_reachable','pass','Consul leader elected'),g('connectivity_ok','fail','Required gate vault_reachable failed')]), + t('tgt-eu-s-a1','eu-stage-app-01','eu-stage',true), + t('tgt-eu-s-a2','eu-stage-app-02','eu-stage',false,[ + g('agent_bound','pass','Agent heartbeat OK'),g('docker_version_ok','pass','Docker 24.0.9'), + g('docker_ping_ok','pass','Daemon reachable'), + g('registry_pull_ok','fail','Connection refused: registry.internal:5000',3200), + g('vault_reachable','pass','Vault unsealed'),g('consul_reachable','pass','Consul leader elected'), + g('connectivity_ok','fail','Required gate registry_pull_ok failed')]), + t('tgt-eu-s-w1','eu-stage-worker-01','eu-stage',true,[ + g('agent_bound','pass','Agent heartbeat OK'),g('docker_version_ok','pass','Docker 24.0.9'), + g('docker_ping_ok','pass','Daemon reachable'),g('registry_pull_ok','pass','Pull test OK'), + g('vault_reachable','skip','No vault binding'),g('consul_reachable','skip','No consul binding'), + g('connectivity_ok','pass','All required gates pass')]), + t('tgt-us-p-a1','us-prod-app-01','us-prod',true), t('tgt-us-p-a2','us-prod-app-02','us-prod',true), + t('tgt-us-p-w1','us-prod-worker-01','us-prod',true), + t('tgt-us-u-a1','us-uat-app-01','us-uat',true), + t('tgt-us-u-w1','us-uat-worker-01','us-uat',false,[ + g('agent_bound','pending','Awaiting agent registration'),g('docker_version_ok','pending','Blocked by agent_bound'), + g('docker_ping_ok','pending','Blocked by agent_bound'),g('registry_pull_ok','pending','Blocked by agent_bound'), + g('vault_reachable','pending','Blocked by agent_bound'),g('consul_reachable','pending','Blocked by agent_bound'), + g('connectivity_ok','fail','Required gate agent_bound is pending')]), + t('tgt-uw-p-a1','usw-prod-app-01','prod-us-west',true), t('tgt-uw-p-w1','usw-prod-worker-01','prod-us-west',true), + t('tgt-ed-a1','eudr-app-01','prod-eu-west',true), + t('tgt-ed-w1','eudr-worker-01','prod-eu-west',false,[ + g('agent_bound','pass','Agent heartbeat OK'), + g('docker_version_ok','fail','Docker 19.03.15 — minimum 20.10 required',120), + g('docker_ping_ok','pass','Daemon reachable'),g('registry_pull_ok','pass','Pull test OK'), + g('vault_reachable','pass','Vault unsealed'),g('consul_reachable','pass','Consul leader elected'), + g('connectivity_ok','fail','Required gate docker_version_ok failed')]), + t('tgt-ap-a1','apac-prod-app-01','apac-prod',true), t('tgt-ap-a2','apac-prod-app-02','apac-prod',true), + t('tgt-ap-w1','apac-prod-worker-01','apac-prod',false,[ + g('agent_bound','pass','Agent heartbeat OK'),g('docker_version_ok','pass','Docker 25.0.3'), + g('docker_ping_ok','pass','Daemon reachable'),g('registry_pull_ok','pass','Pull test OK'), + g('vault_reachable','pass','Vault unsealed'), + g('consul_reachable','fail','No Consul leader — cluster partitioned',8200), + g('connectivity_ok','fail','Required gate consul_reachable failed')]), +]; + +const REMEDIATION: Record = { + agent_bound: 'Register an agent via Ops → Agent Fleet.', + docker_version_ok: 'Upgrade Docker on the host to 20.10+.', + docker_ping_ok: 'Check Docker daemon is running and accessible.', + registry_pull_ok: 'Verify registry connectivity and credentials.', + vault_reachable: 'Unseal Vault or check network path.', + consul_reachable: 'Check Consul cluster health and partitions.', + connectivity_ok: 'Fix the upstream gate failures listed above.', +}; + +// Mock topology layout for when API returns empty +function buildMockLayout(envs: Environment[], reports: ReadinessReport[]): TopologyLayoutResponse { + const readinessMap = new Map(); + for (const env of envs) { + const er = reports.filter(r => r.environmentId === env.environmentId); + readinessMap.set(env.environmentId, er.length > 0 && er.every(r => r.isReady) ? 'healthy' : er.some(r => !r.isReady) ? 'degraded' : 'unknown'); + } + + const nodes: TopologyPositionedNode[] = []; + const edges: TopologyRoutedEdge[] = []; + + // Region boxes + const regionLayout: Record = { + 'eu-west': { x: 0, y: 0, w: 520, h: 230 }, + 'us-east': { x: 560, y: 0, w: 460, h: 150 }, + 'us-west': { x: 560, y: 190, w: 250, h: 130 }, + 'apac': { x: 0, y: 270, w: 300, h: 130 }, + }; + + for (const r of REGIONS) { + const box = regionLayout[r.regionId]; + if (!box) continue; + nodes.push({ + id: `region-${r.regionId}`, label: r.displayName, kind: 'region', parentNodeId: null, + x: box.x, y: box.y, width: box.w, height: box.h, + hostCount: 0, targetCount: 0, isFrozen: false, promotionPathCount: 0, + deployingCount: 0, pendingCount: 0, failedCount: 0, totalDeployments: 0, + }); + } + + // Environment nodes + const envPositions: Record = { + 'eu-stage': { x: 30, y: 60 }, + 'eu-prod': { x: 260, y: 60 }, + 'prod-eu-west': { x: 260, y: 150 }, + 'us-uat': { x: 590, y: 55 }, + 'us-prod': { x: 800, y: 55 }, + 'prod-us-west': { x: 590, y: 235 }, + 'apac-prod': { x: 30, y: 315 }, + }; + + for (const env of envs) { + const pos = envPositions[env.environmentId]; + if (!pos) continue; + const er = reports.filter(r => r.environmentId === env.environmentId); + nodes.push({ + id: `env-${env.environmentId}`, label: env.displayName, kind: 'environment', + parentNodeId: `region-${env.regionId}`, + x: pos.x, y: pos.y, width: 180, height: 55, + environmentId: env.environmentId, regionId: env.regionId, + environmentType: env.environmentType, + healthStatus: readinessMap.get(env.environmentId) ?? 'unknown', + hostCount: er.length, targetCount: er.length, + isFrozen: false, promotionPathCount: 0, + deployingCount: 0, pendingCount: 0, failedCount: 0, totalDeployments: 0, + }); + } + + // Promotion path edges + const paths: Array<{ from: string; to: string; mode: string }> = [ + { from: 'eu-stage', to: 'eu-prod', mode: 'auto' }, + { from: 'eu-prod', to: 'prod-eu-west', mode: 'manual' }, + { from: 'us-uat', to: 'us-prod', mode: 'auto' }, + { from: 'us-prod', to: 'prod-us-west', mode: 'manual' }, + ]; + + for (const p of paths) { + const src = envPositions[p.from]; + const dst = envPositions[p.to]; + if (!src || !dst) continue; + edges.push({ + id: `path-${p.from}-${p.to}`, + sourceNodeId: `env-${p.from}`, targetNodeId: `env-${p.to}`, + kind: 'promotion', label: p.mode === 'auto' ? 'auto-promote' : 'manual', + pathMode: p.mode, status: 'active', requiredApprovals: p.mode === 'manual' ? 1 : 0, + sections: [{ startPoint: { x: src.x + 180, y: src.y + 27 }, endPoint: { x: dst.x, y: dst.y + 27 }, bendPoints: [] }], + }); + } + + return { + nodes, edges, + metadata: { regionCount: 4, environmentCount: envs.length, promotionPathCount: paths.length, canvasWidth: 1060, canvasHeight: 420 }, + }; +} + +// ── Component ─────────────────────────────────────────────────────── + +@Component({ + selector: 'app-environments-command', + standalone: true, + imports: [CommonModule, LoadingStateComponent, RouterLink, TopologyGraphComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + +
+
+
+ + +
+ +
+ + + +
+
+ +
+ @if (usingMock()) { Demo Data } + @if (statusFilter() !== 'all') { + + } + + +
+
+ + + @if (filtered().length > 0 || view() === 'topology') { +
+
{{ grouped().length }}Environments
+
{{ filtered().length }}Targets
+
{{ readyCnt() }}Ready
+
{{ notReadyCnt() }}Not Ready
+
{{ failedGateCnt() }}Failed Gates
+
+ } + + + @if (view() === 'command') { + + @if (loading() && filtered().length === 0) { + + } + + @if (!loading() && filtered().length === 0) { +
+

No environments match the current filters.

+ @if (activeFilterCount() > 0) { + + } +
+ } + + @for (grp of grouped(); track grp.envId) { +
+ + +
+
+

{{ grp.envName }}

+ @if (grp.regionName) { {{ grp.regionName }} } + @if (grp.envType) { {{ grp.envType }} } +
+
+ + {{ grp.readyCnt }}/{{ grp.reports.length }} ready + +
+
+ + +
+
+ Target + @for (gn of gateNames; track gn) { + {{ fmtGate(gn) }} + } + Ready +
+ @for (rpt of grp.reports; track rpt.targetId) { +
+ {{ rpt.targetName || rpt.targetId.substring(0,12) }} + @for (gn of gateNames; track gn) { + {{ gIcon(rpt,gn) }} + } + + {{ rpt.isReady ? '✓' : '✗' }} + +
+ } +
+ + + @if (grp.blockers.length > 0) { +
+ @for (b of grp.blockers; track b.gate + b.target) { +
+ + {{ fmtGate(b.gate) }} + {{ b.msg }} + on {{ b.target }} +
+ } + @if (grp.blockers.length > 0) { +
+ @for (hint of grp.hints; track hint) { → {{ hint }} } +
+ } +
+ } + + +
+ @if (grp.allReady) { + + Deploy + + } + + View Detail → +
+
+ } + } + + + @if (view() === 'topology') { +
+
+ @if (topoLoading()) { +
Loading topology...
+ } @else { + + } +
+ + @if (topoSelectedNode(); as node) { + + } + + @if (topoSelectedEdge(); as edge) { + + } +
+ } + + @if (lastRefresh()) { +

Last refresh: {{ lastRefresh() }}

+ } +
+ `, + styles: [` + .env-cmd { display: grid; gap: 0.65rem; } + + /* ── Toolbar (single row: toggle + status + actions) ── */ + .toolbar { + display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; flex-wrap: wrap; + } + .toolbar__left { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } + .toolbar__right { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; } + .status-chips { display: flex; gap: 0.25rem; align-items: center; } + .mock-badge { + padding: 0.15rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.62rem; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.04em; + background: var(--color-status-warning-bg, #fef3cd); color: var(--color-status-warning-text, #856404); + } + + /* ── View toggle ── */ + .view-toggle { + display: inline-flex; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); + overflow: hidden; background: var(--color-surface-secondary); + } + .toggle-btn { + display: inline-flex; align-items: center; gap: 0.3rem; + padding: 0.35rem 0.7rem; border: none; background: transparent; + font-size: 0.76rem; font-weight: 500; color: var(--color-text-muted); cursor: pointer; + transition: all 150ms ease; + } + .toggle-btn:hover:not(.toggle-btn--active) { color: var(--color-text-primary); background: var(--color-surface-tertiary, rgba(0,0,0,0.04)); } + .toggle-btn--active { + background: var(--color-surface-primary); color: var(--color-text-primary); font-weight: 600; + box-shadow: 0 1px 2px rgba(0,0,0,0.06); + } + .toggle-btn svg { flex-shrink: 0; } + + /* ── 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 ease; + } + .btn--sm { padding: 0.28rem 0.6rem; } + .btn--xs { padding: 0.2rem 0.4rem; font-size: 0.68rem; } + .btn--primary { background: var(--color-brand, #3b82f6); color: #fff; } + .btn--primary:hover:not(:disabled) { filter: brightness(1.1); } + .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); } + .btn--ghost { background: transparent; color: var(--color-text-muted); } + .btn--ghost:hover:not(:disabled) { color: var(--color-text-primary); background: var(--color-surface-secondary); } + .btn--success { background: var(--color-status-success, #22c55e); color: #fff; } + .btn--success:hover:not(:disabled) { filter: brightness(1.1); } + .btn:disabled { opacity: 0.5; cursor: not-allowed; } + + .chip { + padding: 0.18rem 0.45rem; border-radius: var(--radius-sm); font-size: 0.68rem; + border: 1px solid var(--color-border-primary); background: var(--color-surface-secondary); + color: var(--color-text-muted); cursor: pointer; transition: all 120ms ease; + } + .chip:hover { border-color: var(--color-border-emphasis); color: var(--color-text-primary); } + .chip--on { background: var(--color-brand-soft, rgba(59,130,246,0.12)); border-color: var(--color-brand, #3b82f6); color: var(--color-brand, #3b82f6); font-weight: 600; } + .chip--ok.chip--on { background: rgba(34,197,94,0.1); border-color: var(--color-status-success, #22c55e); color: var(--color-status-success, #22c55e); } + .chip--err.chip--on { background: rgba(239,68,68,0.1); border-color: var(--color-status-error, #ef4444); color: var(--color-status-error, #ef4444); } + + /* ── Summary ── */ + .summary { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.4rem; } + .sc { + display: flex; flex-direction: column; align-items: center; padding: 0.45rem 0.4rem; + border-radius: var(--radius-md); border: 1px solid var(--color-border-primary); background: var(--color-surface-primary); + } + .sc__v { font-size: 1.15rem; font-weight: 700; line-height: 1; color: var(--color-text-heading); } + .sc__l { font-size: 0.62rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; margin-top: 0.1rem; } + .sc--ok .sc__v { color: var(--color-status-success, #22c55e); } + .sc--err .sc__v { color: var(--color-status-error, #ef4444); } + + .empty { + 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; + display: flex; flex-direction: column; align-items: center; gap: 0.5rem; + } + + /* ── Environment card ── */ + .env-card { + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); + background: var(--color-surface-primary); padding: 0.55rem; display: grid; gap: 0.35rem; + } + .env-card--ok { border-left: 3px solid var(--color-status-success, #22c55e); } + .env-card--bad { border-left: 3px solid var(--color-status-error, #ef4444); } + .env-card__head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.3rem; } + .env-card__id { display: flex; align-items: center; gap: 0.4rem; } + .env-card__id h3 { margin: 0; font-size: 0.88rem; font-weight: 600; color: var(--color-card-heading); } + .env-link { color: var(--color-text-link, var(--color-brand, #3b82f6)); text-decoration: none; cursor: pointer; } + .env-link:hover { text-decoration: underline; filter: brightness(1.15); } + .tag { font-size: 0.6rem; padding: 0.1rem 0.3rem; border-radius: var(--radius-sm); background: var(--color-surface-secondary); color: var(--color-text-muted); } + .tag--type { text-transform: capitalize; } + .env-card__score { font-size: 0.72rem; font-weight: 600; } + .score--ok { color: var(--color-status-success, #22c55e); } + .score--bad { color: var(--color-status-error, #ef4444); } + .env-card__actions { display: flex; gap: 0.3rem; align-items: center; padding-top: 0.2rem; border-top: 1px solid var(--color-border-primary); } + + /* ── Gate grid ── */ + .gg { font-size: 0.74rem; overflow-x: auto; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); } + .gg__hdr { + display: grid; grid-template-columns: 150px repeat(7, 1fr) 44px; + padding: 0.3rem 0; font-weight: 600; color: var(--color-text-muted); font-size: 0.62rem; + text-transform: uppercase; letter-spacing: 0.04em; background: var(--color-surface-primary); + } + .gg__row { + display: grid; grid-template-columns: 150px repeat(7, 1fr) 44px; + padding: 0.25rem 0; border-top: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); transition: background 120ms ease; + } + .gg__row:nth-child(even) { background: var(--color-surface-primary); } + .gg__row:hover { background: var(--color-brand-soft); } + .gg__row--bad { border-left: 2px solid var(--color-status-error, #ef4444); } + .gg__c { padding: 0.15rem 0.35rem; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .gg__c--name { text-align: left; color: var(--color-text-primary); font-weight: 500; } + .gg__c--rdy { font-weight: 600; font-size: 0.7rem; } + .gc--pass { color: var(--color-status-success, #22c55e); } .gc--fail { color: var(--color-status-error, #ef4444); } + .gc--skip { color: var(--color-text-muted); } .gc--pend { color: var(--color-status-warning, #f59e0b); } + .rdy-y { color: var(--color-status-success, #22c55e); } .rdy-n { color: var(--color-status-error, #ef4444); } + + /* ── Blockers ── */ + .blockers { padding: 0.35rem 0.45rem; background: var(--color-status-error-bg, rgba(239,68,68,0.06)); border-radius: var(--radius-sm); display: grid; gap: 0.2rem; } + .blocker { display: flex; align-items: center; gap: 0.3rem; font-size: 0.72rem; } + .blocker__icon { color: var(--color-status-error, #ef4444); } + .blocker__gate { font-weight: 600; color: var(--color-text-primary); } + .blocker__msg { color: var(--color-text-muted); flex: 1; } + .blocker__target { font-size: 0.66rem; color: var(--color-text-muted); } + .blocker-hints { padding-top: 0.15rem; border-top: 1px solid var(--color-border-primary); display: grid; gap: 0.1rem; } + .hint { font-size: 0.68rem; color: var(--color-text-secondary); } + + /* ── Topology view ── */ + .topo-pane { display: grid; grid-template-columns: 1fr; min-height: 420px; position: relative; gap: 0.5rem; } + .topo-pane--drawer { grid-template-columns: 1fr 320px; } + .topo-graph { position: relative; min-height: 400px; } + .topo-loading { display: flex; align-items: center; justify-content: center; gap: 0.5rem; height: 100%; color: var(--color-text-secondary); font-size: 0.8rem; } + .spinner { width: 20px; height: 20px; border: 2px solid var(--color-border-primary); border-top-color: var(--color-brand, #3b82f6); border-radius: 50%; animation: spin 0.8s linear infinite; } + @keyframes spin { to { transform: rotate(360deg); } } + + /* ── Topology drawer ── */ + .topo-drawer { + border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); + background: var(--color-surface-primary); overflow-y: auto; max-height: 100%; + } + .td__hdr { + display: flex; justify-content: space-between; align-items: center; padding: 0.4rem 0.55rem; + border-bottom: 1px solid var(--color-border-primary); position: sticky; top: 0; + background: var(--color-surface-primary); z-index: 1; + } + .td__hdr h3 { margin: 0; font-size: 0.82rem; font-weight: 600; } + .td__close { border: none; background: none; color: var(--color-text-secondary); font-size: 1.1rem; cursor: pointer; padding: 0 0.2rem; } + .td__close:hover { color: var(--color-text-primary); } + .td__body { padding: 0.45rem 0.55rem; display: grid; gap: 0.4rem; } + .td__dl { display: grid; grid-template-columns: auto 1fr; gap: 0.15rem 0.5rem; margin: 0; font-size: 0.76rem; } + .td__dl dt { color: var(--color-text-muted); font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.03em; } + .td__dl dd { margin: 0; } + .td__readiness { font-size: 0.76rem; padding: 0.25rem 0.4rem; background: var(--color-surface-secondary); border-radius: var(--radius-sm); display: flex; justify-content: space-between; align-items: center; } + .td__readiness-label { font-size: 0.66rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.03em; } + .td__gates { font-size: 0.72rem; padding: 0.25rem 0.35rem; background: var(--color-surface-secondary); border-radius: var(--radius-sm); border: 1px solid var(--color-border-primary); } + .td__gates-l { display: block; font-size: 0.62rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 0.1rem; } + .td__actions { display: flex; gap: 0.3rem; padding-top: 0.2rem; border-top: 1px solid var(--color-border-primary); } + .frozen-badge { background: var(--color-status-error-bg); border: 1px solid var(--color-status-error-border, var(--color-status-error, #ef4444)); color: var(--color-status-error, #ef4444); font-weight: 700; font-size: 0.68rem; text-align: center; padding: 0.15rem; border-radius: var(--radius-sm); } + + /* ── Drawer tables (hosts, targets, deployments) ── */ + .td__section { margin: 0; font-size: 0.68rem; font-weight: 600; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; padding-top: 0.25rem; border-top: 1px solid var(--color-border-primary); } + .td__section-hdr { display: flex; justify-content: space-between; align-items: center; padding-top: 0.25rem; border-top: 1px solid var(--color-border-primary); } + .td__table { width: 100%; border-collapse: collapse; font-size: 0.7rem; } + .td__table th { text-align: left; font-size: 0.62rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; padding: 0.15rem 0.25rem; border-bottom: 1px solid var(--color-border-primary); } + .td__table td { padding: 0.15rem 0.25rem; border-bottom: 1px solid var(--color-border-primary); } + .td__table tr:last-child td { border-bottom: none; } + .td__link { color: var(--color-text-link, var(--color-brand, #3b82f6)); text-decoration: none; font-weight: 500; } + .td__link:hover { text-decoration: underline; } + .td__event { font-size: 0.62rem; color: var(--color-text-secondary); } + .td__date { font-size: 0.62rem; color: var(--color-text-secondary); white-space: nowrap; } + .td__empty { margin: 0; color: var(--color-text-muted); font-size: 0.72rem; } + .mono { font-family: var(--font-mono, monospace); font-size: 0.62rem; } + .seg-control { display: inline-flex; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); overflow: hidden; background: var(--color-surface-secondary); height: 22px; } + .seg-btn { display: inline-flex; align-items: center; justify-content: center; height: 100%; padding: 0 0.4rem; border: none; background: transparent; color: var(--color-text-muted); font-size: 0.6rem; font-family: inherit; font-weight: 500; cursor: pointer; white-space: nowrap; transition: background 150ms, color 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; } + .release-status { font-size: 0.62rem; font-weight: 500; } + .release-status--deployed, .release-status--succeeded { color: var(--color-status-success, #22c55e); } + .release-status--pending, .release-status--promoting, .release-status--awaiting_approval { color: var(--color-status-warning, #f59e0b); } + .release-status--failed, .release-status--cancelled, .release-status--rejected { color: var(--color-status-error, #ef4444); } + .type-production { color: var(--color-status-error-text, #ef4444); font-weight: 500; } + .type-staging { color: var(--color-status-warning-text, #f59e0b); font-weight: 500; } + .type-uat { color: var(--color-status-warning-text, #f59e0b); font-weight: 500; } + .type-dr { color: var(--color-text-secondary); font-weight: 500; } + .health-healthy { color: var(--color-status-success, #22c55e); font-weight: 500; } + .health-degraded { color: var(--color-status-warning, #f59e0b); font-weight: 500; } + .health-unhealthy { color: var(--color-status-error, #ef4444); font-weight: 500; } + + @media (max-width: 960px) { + .topo-pane--drawer { grid-template-columns: 1fr; } + .topo-drawer { max-height: 280px; } + .summary { grid-template-columns: repeat(3, 1fr); } + } + + .ts { font-size: 0.68rem; color: var(--color-text-muted); margin: 0; } + `] +}) +export class EnvironmentsCommandComponent implements OnInit, OnDestroy { + private readonly http = inject(HttpClient); + private readonly route = inject(ActivatedRoute); + private readonly layoutService = inject(TopologyLayoutService); + readonly context = inject(PlatformContextStore); + private readonly destroy$ = new Subject(); + + // ── View state ── + readonly view = signal<'command' | 'topology'>('command'); + readonly loading = signal(false); + readonly usingMock = signal(false); + readonly lastRefresh = signal(null); + + // ── Data ── + readonly allEnvs = signal([]); + readonly reports = signal([]); + + // ── Topology ── + readonly topoLayout = signal(null); + readonly topoLoading = signal(false); + readonly topoSelectedNode = signal(null); + readonly topoSelectedEdge = signal(null); + + // ── Topology drawer detail ── + readonly drawerTargets = signal([]); + readonly drawerHosts = signal([]); + readonly drawerReleases = signal([]); + readonly drawerLoading = signal(false); + readonly drawerShowDone = signal(false); + + readonly drawerFilteredReleases = computed(() => { + const all = this.drawerReleases(); + const showDone = this.drawerShowDone(); + return all.filter(r => showDone ? !PENDING_STATUSES.has(r.status) : PENDING_STATUSES.has(r.status)); + }); + + // ── Filters (status only — region comes from global PlatformContextStore) ── + readonly statusFilter = signal<'all' | 'ready' | 'not-ready'>('all'); + + // ── Action state ── + readonly validatingAll = signal(false); + readonly validatingEnvs = signal>(new Set()); + + readonly gateNames = ['agent_bound','docker_version_ok','docker_ping_ok','registry_pull_ok','vault_reachable','consul_reachable','connectivity_ok']; + + // ── Computed ── + + readonly filtered = computed(() => { + let data = this.reports(); + const status = this.statusFilter(); + // Region filtering is handled by the global PlatformContextStore — + // the API call already scopes by selected regions, and the context + // bar renders region chips. No need to duplicate here. + const ctxEnvs = this.context.selectedEnvironments(); + if (ctxEnvs.length > 0) { + const envSet = new Set(ctxEnvs); + data = data.filter(r => envSet.has(r.environmentId)); + } + if (status === 'ready') data = data.filter(r => r.isReady); + if (status === 'not-ready') data = data.filter(r => !r.isReady); + return data; + }); + + readonly readyCnt = computed(() => this.filtered().filter(r => r.isReady).length); + readonly notReadyCnt = computed(() => this.filtered().filter(r => !r.isReady).length); + readonly failedGateCnt = computed(() => { let c = 0; for (const r of this.filtered()) for (const g of r.gates) if (g.status === 'fail') c++; return c; }); + + readonly grouped = computed(() => { + const envLookup = new Map(this.allEnvs().map(e => [e.environmentId, e])); + const regionLookup = new Map(REGIONS.map(r => [r.regionId, r.displayName])); + const map = new Map(); + + for (const rpt of this.filtered()) { + if (!map.has(rpt.environmentId)) { + const env = envLookup.get(rpt.environmentId); + map.set(rpt.environmentId, { + envId: rpt.environmentId, + envName: env?.displayName || rpt.environmentId, + regionName: env?.regionId ? (regionLookup.get(env.regionId) || env.regionId) : '', + envType: env?.environmentType || '', + reports: [], readyCnt: 0, notReadyCnt: 0, allReady: true, + blockers: [], hints: [], + }); + } + const grp = map.get(rpt.environmentId)!; + grp.reports.push(rpt); + if (rpt.isReady) grp.readyCnt++; else grp.notReadyCnt++; + if (!rpt.isReady) grp.allReady = false; + } + + // Compute blockers and hints + for (const grp of map.values()) { + const hintSet = new Set(); + for (const rpt of grp.reports) { + for (const gate of rpt.gates) { + if (gate.status === 'fail' || gate.status === 'pending') { + grp.blockers.push({ gate: gate.gateName, msg: gate.message || gate.status, target: rpt.targetName || rpt.targetId.substring(0, 12) }); + const h = REMEDIATION[gate.gateName]; + if (h && !hintSet.has(h)) { hintSet.add(h); grp.hints.push(h); } + } + } + } + } + + // Sort: not-ready first, then by name + return [...map.values()].sort((a, b) => { + if (a.allReady !== b.allReady) return a.allReady ? 1 : -1; + return a.envName.localeCompare(b.envName); + }); + }); + + // ── Lifecycle ── + + ngOnInit(): void { + this.context.initialize(); + const qp = this.route.snapshot.queryParamMap; + const viewParam = qp.get('view'); + if (viewParam === 'topology') this.view.set('topology'); + + this.refresh(); + interval(30000).pipe(takeUntil(this.destroy$)).subscribe(() => this.refresh()); + } + + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); } + + // ── Data loading ── + + refresh(): void { + this.loading.set(true); + this.http.get<{ items: Environment[] }>('/api/v2/topology/environments') + .pipe(catchError(() => of({ items: [] as Environment[] }))) + .subscribe(envResp => { + if (envResp.items.length === 0) { this.loadMock(); return; } + this.usingMock.set(false); + this.allEnvs.set(envResp.items); + const requests = envResp.items.map(env => + this.http.get<{ items: ReadinessReport[] }>(`/api/v1/environments/${env.environmentId}/readiness`) + .pipe(catchError(() => of({ items: [] as ReadinessReport[] }))) + ); + if (requests.length === 0) { this.loading.set(false); return; } + forkJoin(requests).subscribe(results => { + const all = results.flatMap(r => r.items); + if (all.length === 0) { this.loadMock(); return; } + this.reports.set(all); + this.lastRefresh.set(new Date().toLocaleTimeString()); + this.loading.set(false); + }); + }); + this.loadTopology(); + } + + private loadMock(): void { + this.usingMock.set(true); + this.allEnvs.set(MOCK_ENVS); + this.reports.set(MOCK_REPORTS); + this.topoLayout.set(buildMockLayout(MOCK_ENVS, MOCK_REPORTS)); + this.lastRefresh.set(new Date().toLocaleTimeString()); + this.loading.set(false); + this.topoLoading.set(false); + } + + private loadTopology(): void { + this.topoLoading.set(true); + const regions = this.context.selectedRegions(); + this.layoutService.getLayout({ + region: regions.length > 0 ? regions.join(',') : undefined, + }).pipe(take(1), catchError(() => of(null))).subscribe(layout => { + if (layout) { this.topoLayout.set(layout); this.topoLoading.set(false); } + // If null and mock is loaded, mock layout is already set + else if (!this.usingMock()) { this.topoLoading.set(false); } + }); + } + + // ── Actions ── + + validateAll(): void { + this.validatingAll.set(true); + const ids = this.filtered().map(r => r.targetId); + const reqs = ids.map(id => this.http.post(`/api/v1/targets/${id}/validate`, {}).pipe(catchError(() => of(null)))); + if (reqs.length === 0) { this.validatingAll.set(false); return; } + forkJoin(reqs).subscribe(results => { + this.merge(results.filter((r): r is ReadinessReport => r !== null)); + this.validatingAll.set(false); + }); + } + + validateEnv(envId: string): void { + const s = new Set(this.validatingEnvs()); s.add(envId); this.validatingEnvs.set(s); + const ids = this.reports().filter(r => r.environmentId === envId).map(r => r.targetId); + const reqs = ids.map(id => this.http.post(`/api/v1/targets/${id}/validate`, {}).pipe(catchError(() => of(null)))); + if (reqs.length === 0) { s.delete(envId); this.validatingEnvs.set(new Set(s)); return; } + forkJoin(reqs).subscribe(results => { + this.merge(results.filter((r): r is ReadinessReport => r !== null)); + const u = new Set(this.validatingEnvs()); u.delete(envId); this.validatingEnvs.set(u); + }); + } + + private merge(results: ReadinessReport[]): void { + if (!results.length) return; + const cur = [...this.reports()]; + for (const r of results) { const i = cur.findIndex(x => x.targetId === r.targetId); if (i >= 0) cur[i] = r; else cur.push(r); } + this.reports.set(cur); + this.lastRefresh.set(new Date().toLocaleTimeString()); + } + + // ── Topology interactions ── + + onTopoNodeSelected(node: TopologyPositionedNode): void { + this.topoSelectedNode.set(node); + this.topoSelectedEdge.set(null); + this.resetDrawer(); + if (node.kind === 'environment' && node.environmentId) { + this.loadDrawerDetail(node.environmentId); + } + } + onTopoEdgeSelected(edge: TopologyRoutedEdge): void { this.topoSelectedEdge.set(edge); this.topoSelectedNode.set(null); this.resetDrawer(); } + clearTopoSelection(): void { this.topoSelectedNode.set(null); this.topoSelectedEdge.set(null); this.resetDrawer(); } + + private resetDrawer(): void { + this.drawerTargets.set([]); + this.drawerHosts.set([]); + this.drawerReleases.set([]); + this.drawerShowDone.set(false); + } + + private loadDrawerDetail(environmentId: string): void { + this.drawerLoading.set(true); + this.layoutService.getTargets(environmentId).pipe(take(1), catchError(() => of([]))).subscribe(t => this.drawerTargets.set(t)); + this.layoutService.getHosts(environmentId).pipe(take(1), catchError(() => of([]))).subscribe(h => this.drawerHosts.set(h)); + const params = new HttpParams().set('environment', environmentId).set('limit', '20'); + this.http.get>('/api/v2/releases/activity', { params }) + .pipe(take(1), catchError(() => of({ items: [] as ReleaseActivity[] }))) + .subscribe(r => { this.drawerReleases.set(r?.items ?? []); this.drawerLoading.set(false); }); + } + + getTopoNodeLabel(id: string): string { return this.topoLayout()?.nodes.find(n => n.id === id)?.label ?? id; } + getRegionEnvCount(regionId: string): number { return this.topoLayout()?.nodes.filter(n => n.parentNodeId === regionId && n.kind === 'environment').length ?? 0; } + getEnvReports(envId: string): ReadinessReport[] { return this.reports().filter(r => r.environmentId === envId); } + getEnvReadinessLabel(envId: string): string { + const er = this.getEnvReports(envId); + if (er.length === 0) return ''; + return `${er.filter(r => r.isReady).length}/${er.length} ready`; + } + isEnvAllReady(envId: string): boolean { const er = this.getEnvReports(envId); return er.length > 0 && er.every(r => r.isReady); } + + // ── Helpers ── + + gs(rpt: ReadinessReport, gate: string): string { return rpt.gates.find(x => x.gateName === gate)?.status || ''; } + gIcon(rpt: ReadinessReport, gate: string): string { + const s = this.gs(rpt, gate); + return s === 'pass' ? '✓' : s === 'fail' ? '✗' : s === 'pending' ? '●' : '—'; + } + gTooltip(rpt: ReadinessReport, gate: string): string { + const x = rpt.gates.find(y => y.gateName === gate); + if (!x) return ''; + const p = [this.fmtGate(gate), x.status.toUpperCase()]; + if (x.message) p.push(x.message); + if (x.durationMs != null) p.push(`${x.durationMs}ms`); + return p.join(' · '); + } + fmtGate(gate: string): string { return gate.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).replace(' Ok', ''); } + + fmtDate(iso: string | null | undefined): string { + if (!iso) return '—'; + const d = new Date(iso); + const diff = Date.now() - d.getTime(); + if (diff < 60_000) return 'just now'; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + if (diff < 604_800_000) return `${Math.floor(diff / 86_400_000)}d ago`; + return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/topology/readiness-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/readiness-dashboard.component.ts deleted file mode 100644 index 3e7f46d44..000000000 --- a/src/Web/StellaOps.Web/src/app/features/topology/readiness-dashboard.component.ts +++ /dev/null @@ -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: ` -
-
-

Topology Readiness

-

Gate status for all targets across environments and regions

- -
- - @if (loading() && reports().length === 0) { - - } - - @if (!loading() && reports().length === 0) { -
-

No readiness data available. Run validation on targets to see results.

-
- } - - @for (group of groupedReports(); track group.environmentId) { -
-

{{ group.environmentName }}

-
-
- Target - @for (gate of gateNames; track gate) { - {{ formatGateName(gate) }} - } - Ready -
- @for (report of group.reports; track report.targetId) { -
- {{ getTargetName(report.targetId) }} - @for (gate of gateNames; track gate) { - - {{ getGateIcon(report, gate) }} - - } - - {{ report.isReady ? 'Yes' : 'No' }} - -
- } -
-
- } - - @if (lastRefresh()) { -

Last refresh: {{ lastRefresh() }}

- } -
- `, - 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([]); - readonly targets = signal>(new Map()); - readonly environments = signal>(new Map()); - readonly lastRefresh = signal(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(); - - 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(); - 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', ''); - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts index a126098a3..cf5ebc7d0 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts @@ -1,6 +1,6 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, RouterLink } from '@angular/router'; import { catchError, forkJoin, of, take } from 'rxjs'; import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; @@ -10,373 +10,487 @@ import { } from '../../shared/components/stella-quick-links/stella-quick-links.component'; import { PlatformContextStore } from '../../core/context/platform-context.store'; import { TopologyDataService } from './topology-data.service'; +import { TopologySetupClient } from '../../core/api/topology-setup.client'; import { summarizeEnvironmentScope } from './environment-scope-summary'; import { EvidenceCapsuleRow, PlatformListResponse, + ReadinessGateResult, + ReadinessReport, ReleaseActivityRow, SecurityFindingRow, TopologyAgent, TopologyEnvironment, + TopologyHost, + TopologyPromotionPath, TopologyTarget, } from './topology.models'; import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; +import { MetricCardComponent } from '../../shared/ui/metric-card/metric-card.component'; +import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component'; +import { StatGroupComponent } from '../../shared/components/stat-card/stat-card.component'; +import { RelativeTimePipe, DurationPipe } from '../../shared/pipes/format.pipes'; -type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'security' | 'evidence' | 'data-quality'; +type EnvironmentTab = 'overview' | 'targets' | 'readiness' | 'deployments' | 'agents' | 'security' | 'evidence' | 'drift' | 'data-quality'; -const ENV_DETAIL_TABS: StellaPageTab[] = [ - { id: 'overview', label: 'Overview', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' }, - { id: 'targets', label: 'Targets', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01' }, - { id: 'deployments', label: 'Runs', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, - { id: 'agents', label: 'Agents', icon: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|||M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|||M20 8v6|||M23 11h-6' }, - { id: 'security', label: 'Security', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, - { id: 'evidence', label: 'Evidence', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, - { id: 'data-quality', label: 'Data Quality', icon: 'M18 20V10|||M12 20V4|||M6 20v-6' }, -]; +const GATE_NAMES = ['agent_bound', 'docker_version_ok', 'docker_ping_ok', 'registry_pull_ok', 'vault_reachable', 'consul_reachable', 'connectivity_ok']; + +function fmtGateName(gate: string): string { + return gate.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).replace(' Ok', ''); +} + +function gateStatusType(s: string): 'success' | 'error' | 'warning' | 'neutral' { + switch (s) { case 'pass': return 'success'; case 'fail': return 'error'; case 'pending': return 'warning'; default: return 'neutral'; } +} + +function healthToStatus(h: string): 'success' | 'warning' | 'error' | 'neutral' { + const l = h?.trim().toLowerCase() ?? ''; + if (l === 'healthy') return 'success'; + if (l === 'degraded') return 'warning'; + if (l === 'unhealthy' || l === 'offline' || l === 'unknown') return 'error'; + return 'neutral'; +} + +function severityToStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { + const l = s?.toLowerCase() ?? ''; + if (l === 'critical' || l === 'high') return 'error'; + if (l === 'medium') return 'warning'; + if (l === 'low') return 'info'; + return 'neutral'; +} @Component({ selector: 'app-topology-environment-detail-page', standalone: true, - imports: [LoadingStateComponent, StellaQuickLinksComponent, StellaPageTabsComponent], + imports: [ + LoadingStateComponent, StellaQuickLinksComponent, StellaPageTabsComponent, + MetricCardComponent, StatusBadgeComponent, StatGroupComponent, + RelativeTimePipe, DurationPipe, RouterLink, + ], template: ` -
-
-
-

{{ environmentLabel() }}

-

{{ regionLabel() }} · {{ environmentTypeLabel() }}

+
+ + +
+ ← Environments +
+
+

{{ environmentLabel() }}

+ + @if (currentRelease()) { {{ currentRelease() }} } + @if (isFrozen()) { } +
+

{{ regionLabel() }} · {{ environmentTypeLabel() }}

+ @if (promotionLine()) {

{{ promotionLine() }}

}
-
- Deploy {{ deployHealth() }} - Targets {{ targetRows().length }} - Agents {{ agentRows().length }} +
+ + Deploy
- @if (error()) { - - } + @if (error()) { } @if (loading()) { } @else { @switch (activeTab()) { - @case ('overview') { -
-
-

Overview

-

Targets healthy {{ healthyTargets() }} · degraded {{ degradedTargets() }} · unhealthy {{ unhealthyTargets() }}

-

Findings requiring action {{ blockingFindings() }}

-

Capsules stale {{ staleCapsules() }}

-
-
-

Environment Signals

- -
-
-
-

Top Blockers

-
    - @for (blocker of blockers(); track blocker) { -
  • {{ blocker }}
  • - } @empty { -
  • No active blockers for this environment.
  • + + @case ('overview') { +
    +
    + + + + + + + +
    +

    Top Blockers

    + @if (blockers().length === 0) { +

    No active blockers for this environment.

    + } @else { +
    + @for (b of blockers(); track b) { +
    + + {{ b.text }} +
    + } +
    + } +
    + + @if (readinessReports().length > 0) { +
    +
    +

    Readiness Snapshot

    + View full → +
    +
    + {{ readyTargetsCnt() }} pass + {{ failingTargetsCnt() }} fail + {{ pendingTargetsCnt() }} pending +
    +
    } -
-
+
+ +
+
+
+ {{ healthyTargets() }}/{{ targetRows().length }} + healthy +
+
+ +
+

Quick Stats

+
+
{{ agentRows().length }}Agents
+
{{ capsuleRows().length }}Evidence
+
{{ staleCapsules() }}Stale
+
+
+ +
+ +
+
+
} + @case ('targets') { -
-

Targets

+
- - - - - - - - - - + - @for (target of targetRows(); track target.targetId) { + @for (t of targetRows(); track t.targetId) { - - - - - - + + + + + + } @empty { - + }
TargetRuntimeHostAgentStatusLast Sync
TargetTypeHostAgentStatusLast Sync
{{ target.name }}{{ target.targetType }}{{ target.hostId }}{{ target.agentId }}{{ target.healthStatus }}{{ target.lastSyncAt ?? '-' }}{{ t.name }}{{ t.targetType }}{{ hostName(t.hostId) }}{{ agentName(t.agentId) }}{{ t.lastSyncAt | relativeTime }}
No targets in this environment scope.
No targets in this environment.
} + + @case ('readiness') { + + + + + + +
+
+

Gate Status

+ +
+
+ + + + + @for (gn of gateNames; track gn) { } + + + + + + @for (rpt of readinessReports(); track rpt.targetId) { + + + @for (gn of gateNames; track gn) { + + } + + + + } @empty { + + } + +
Target{{ fmtGate(gn) }}ReadyActions
{{ targetName(rpt.targetId) }} + + + +
No readiness data. Run validation to check targets.
+
+
+ } + + @case ('deployments') { -
-

Runs

+
- - - - - - - - + @for (run of runRows(); track run.activityId) { - - - + + + + } @empty { - + }
ReleaseStatusCorrelationOccurred
ReleaseStatusDurationOccurred
{{ run.releaseName }}{{ run.status }}{{ run.correlationKey }}{{ run.occurredAt }}{{ run.durationMs ? (run.durationMs | duration) : '—' }}{{ run.occurredAt | relativeTime }}View →
No run activity in this scope.
No deployment runs in this scope.
} + @case ('agents') { -
-

Agents

+
- - - - - - - - - + - @for (agent of agentRows(); track agent.agentId) { + @for (a of agentRows(); track a.agentId) { - - - - - + + + + + } @empty { - + }
AgentStatusCapabilitiesAssigned TargetsHeartbeat
AgentStatusCapabilitiesTargetsHeartbeat
{{ agent.agentName }}{{ agent.status }}{{ agent.capabilities.join(', ') || '-' }}{{ agent.assignedTargetCount }}{{ agent.lastHeartbeatAt ?? '-' }}{{ a.agentName }}{{ a.capabilities.join(', ') || '—' }}{{ a.assignedTargetCount }}{{ a.lastHeartbeatAt | relativeTime }}
No agents in this environment scope.
No agents in this environment.
} + @case ('security') { -
-

Security

+
- - - - - - - + - @for (finding of findingRows(); track finding.findingId) { + @for (f of findingRows(); track f.findingId) { - - - + + + + + + } @empty { - + }
CVESeverityDisposition
CVESeverityCVSSReachableDisposition
{{ finding.cveId }}{{ finding.severity }}{{ finding.effectiveDisposition }}{{ f.cveId }}{{ f.cvss ?? '—' }} + @if (f.reachable != null) { + + } @else { — } + {{ f.effectiveDisposition }}View →
No active findings in this scope.
No active findings in this scope.
} + @case ('evidence') { -
-

Evidence

- - - - - - - - - - @for (capsule of capsuleRows(); track capsule.capsuleId) { - - - - - - } @empty { - - } - -
CapsuleStatusUpdated
{{ capsule.capsuleId }}{{ capsule.status }}{{ capsule.updatedAt }}
No decision capsules in this scope.
+ @if (capsuleRows().length === 0) { +

No decision capsules in this scope.

+ } @else { +
+ @for (c of capsuleRows(); track c.capsuleId) { +
+
+ @if (c.contentTypes?.length) { + {{ c.contentTypes!.join(', ') }} + } +
+ @if (c.signatureStatus === 'signed' || c.signatureStatus === 'valid') { + + } + @if (c.signatureStatus === 'verified') { + + } +
+
+
+ {{ c.capsuleId.substring(0, 12) }}... + {{ c.updatedAt | relativeTime }} +
+
+ +
+
+ } +
+ } + } + + + @case ('drift') { +
+ @if (driftDetected()) { +
+
+ +

Some targets have diverged from expected release state. {{ driftedTargetCount() }} target(s) running a different version.

+
+
+ } @else { +
+ +

All targets are running the expected release version.

+
+ }
} + @case ('data-quality') { -
-

Data Quality

-
    -
  • Context region: {{ regionLabel() }}
  • -
  • Topology targets covered: {{ targetRows().length }}
  • -
  • Agent heartbeat warnings: {{ degradedAgents() }}
  • -
  • Stale decision capsules: {{ staleCapsules() }}
  • -
  • Action-required findings: {{ blockingFindings() }}
  • -
-
+ + + + + + } } } `, styles: [` - .environment-detail { - display: grid; - gap: 0.75rem; + .env-detail { display: grid; gap: 0.75rem; } + + /* ── Header ── */ + .hdr { display: grid; gap: 0.25rem; padding: 0.6rem 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); } + .hdr__back { font-size: 0.72rem; color: var(--color-text-link, var(--color-brand, #3b82f6)); text-decoration: none; } + .hdr__back:hover { text-decoration: underline; } + .hdr__main { display: grid; gap: 0.15rem; } + .hdr__title-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } + .hdr__title-row h1 { margin: 0; font-size: 1.2rem; font-weight: 700; } + .hdr__sub { margin: 0; color: var(--color-text-secondary); font-size: 0.78rem; } + .hdr__promo { margin: 0; color: var(--color-text-muted); font-size: 0.72rem; font-style: italic; } + .hdr__actions { display: flex; gap: 0.35rem; align-items: center; justify-content: flex-end; } + .chip--release { font-size: 0.66rem; padding: 0.1rem 0.35rem; border-radius: var(--radius-sm); background: var(--color-surface-secondary); color: var(--color-text-secondary); font-family: var(--font-mono, monospace); } + + /* ── 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 ease; } + .btn--sm { padding: 0.28rem 0.6rem; } + .btn--xs { padding: 0.2rem 0.4rem; font-size: 0.68rem; } + .btn--primary { background: var(--color-brand, #3b82f6); color: #fff; } + .btn--primary:hover:not(:disabled) { filter: brightness(1.1); } + .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); } + .btn:disabled { opacity: 0.5; cursor: not-allowed; } + + .link { color: var(--color-text-link, var(--color-brand, #3b82f6)); text-decoration: none; font-size: 0.72rem; font-weight: 500; } + .link:hover { text-decoration: underline; } + .mono { font-family: var(--font-mono, monospace); font-size: 0.72rem; } + + /* ── Panels ── */ + .panel { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); padding: 0.7rem; display: grid; gap: 0.4rem; } + .panel h2 { margin: 0; font-size: 0.88rem; font-weight: 600; } + .panel__hdr { display: flex; justify-content: space-between; align-items: center; } + .panel__link { font-size: 0.72rem; color: var(--color-text-link, var(--color-brand, #3b82f6)); cursor: pointer; text-decoration: none; } + .panel__link:hover { text-decoration: underline; } + .panel__ok { margin: 0; color: var(--color-status-success, #22c55e); font-size: 0.76rem; } + .banner { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.5rem 0.65rem; font-size: 0.78rem; } + .banner--error { color: var(--color-status-error-text, #ef4444); background: var(--color-status-error-bg, rgba(239,68,68,0.06)); } + .muted { color: var(--color-text-muted); font-size: 0.74rem; } + + /* ── Overview 2-column layout ── */ + .overview-layout { display: grid; grid-template-columns: 2fr 1fr; gap: 0.65rem; } + .overview-main { display: grid; gap: 0.65rem; } + .overview-side { display: grid; gap: 0.65rem; align-content: start; } + + /* ── Blocker list ── */ + .blocker-list { display: grid; gap: 0.3rem; } + .blocker-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.76rem; } + + /* ── Readiness mini ── */ + .readiness-mini { display: flex; gap: 0.75rem; font-size: 0.78rem; font-weight: 600; } + .rm--ok { color: var(--color-status-success, #22c55e); } + .rm--fail { color: var(--color-status-error, #ef4444); } + .rm--pend { color: var(--color-status-warning, #f59e0b); } + + /* ── Health circle ── */ + .health-circle-panel { display: flex; justify-content: center; padding: 1rem; } + .health-circle { + width: 80px; height: 80px; border-radius: 50%; + display: flex; flex-direction: column; align-items: center; justify-content: center; + border: 3px solid var(--color-border-primary); background: var(--color-surface-secondary); } + .health-circle--healthy { border-color: var(--color-status-success, #22c55e); background: rgba(34,197,94,0.06); } + .health-circle--degraded { border-color: var(--color-status-warning, #f59e0b); background: rgba(245,158,11,0.06); } + .health-circle--unhealthy { border-color: var(--color-status-error, #ef4444); background: rgba(239,68,68,0.06); } + .health-circle__num { font-size: 1.1rem; font-weight: 700; line-height: 1; } + .health-circle__label { font-size: 0.6rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; } - .hero { - display: flex; - justify-content: space-between; - gap: 1rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - padding: 0.8rem; - } + /* ── Quick stats ── */ + .quick-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.35rem; text-align: center; } + .qs { display: flex; flex-direction: column; align-items: center; } + .qs__v { font-size: 1rem; font-weight: 700; color: var(--color-text-heading); } + .qs__l { font-size: 0.6rem; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.04em; } - .hero h1 { - margin: 0; - font-size: 1.3rem; - } + /* ── Gate grid ── */ + .gg-wrap { overflow-x: auto; } + .th-gate, .td-gate { text-align: center; font-size: 0.68rem; } + .th-gate { font-size: 0.6rem; text-transform: uppercase; letter-spacing: 0.03em; } - .hero p { - margin: 0.25rem 0 0; - color: var(--color-text-secondary); - font-size: 0.8rem; - } + /* ── Evidence grid ── */ + .evidence-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 0.5rem; } + .ev-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); padding: 0.55rem; display: grid; gap: 0.3rem; } + .ev-card__hdr { display: flex; justify-content: space-between; align-items: center; } + .ev-type { font-size: 0.62rem; padding: 0.1rem 0.3rem; border-radius: var(--radius-sm); background: var(--color-surface-secondary); color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.03em; } + .ev-badges { display: flex; gap: 0.2rem; } + .ev-card__body { display: grid; gap: 0.1rem; } + .ev-card__id { font-family: var(--font-mono, monospace); font-size: 0.72rem; color: var(--color-text-primary); } + .ev-card__date { font-size: 0.66rem; color: var(--color-text-muted); } + .ev-card__foot { display: flex; gap: 0.3rem; } - .hero__stats { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 0.35rem; - } + /* ── Drift ── */ + .drift-alert { padding: 0.5rem; background: var(--color-status-warning-bg, rgba(245,158,11,0.08)); border-radius: var(--radius-sm); } + .drift-alert__msg { display: flex; align-items: flex-start; gap: 0.5rem; } + .drift-alert__msg p { margin: 0; font-size: 0.76rem; color: var(--color-text-secondary); } + .drift-ok { display: flex; align-items: center; gap: 0.5rem; } + .drift-ok p { margin: 0; font-size: 0.76rem; color: var(--color-text-secondary); } - .hero__stats span { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-full); - background: var(--color-surface-secondary); - color: var(--color-text-secondary); - font-size: 0.7rem; - padding: 0.1rem 0.45rem; - white-space: nowrap; - } - - - - .banner { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - color: var(--color-text-secondary); - padding: 0.7rem; - font-size: 0.78rem; - } - - .banner--error { - color: var(--color-status-error-text); - } - - .grid { - display: grid; - gap: 0.6rem; - } - - .grid--two { - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - } - - .card { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - padding: 0.7rem; - display: grid; - gap: 0.4rem; - } - - .card h2 { - margin: 0; - font-size: 0.95rem; - } - - .card p { - margin: 0; - color: var(--color-text-secondary); - font-size: 0.76rem; - } - - .actions { - display: flex; - flex-wrap: wrap; - gap: 0.45rem; - } - - .actions a { - color: var(--color-text-link); - font-size: 0.74rem; - text-decoration: none; - } - - /* Table styling provided by global .stella-table class */ - - .list { - margin: 0; - padding-left: 1rem; - display: grid; - gap: 0.2rem; - color: var(--color-text-secondary); - font-size: 0.75rem; - } - - .muted { - color: var(--color-text-secondary); - font-size: 0.74rem; + @media (max-width: 1024px) { + .overview-layout { grid-template-columns: 1fr; } } `], changeDetection: ChangeDetectionStrategy.OnPush, }) export class TopologyEnvironmentDetailPageComponent { private readonly topologyApi = inject(TopologyDataService); + private readonly topologySetup = inject(TopologySetupClient); private readonly http = inject(HttpClient); private readonly route = inject(ActivatedRoute); readonly context = inject(PlatformContextStore); - readonly ENV_DETAIL_TABS = ENV_DETAIL_TABS; + readonly gateNames = GATE_NAMES; readonly activeTab = signal('overview'); readonly loading = signal(false); @@ -384,151 +498,246 @@ export class TopologyEnvironmentDetailPageComponent { readonly environmentId = signal(''); readonly environmentLabel = signal('Environment'); - readonly regionLabel = signal('unknown-region'); - readonly environmentTypeLabel = signal('unknown-type'); + readonly regionLabel = signal(''); + readonly environmentTypeLabel = signal(''); readonly targetRows = signal([]); + readonly hostRows = signal([]); readonly agentRows = signal([]); readonly runRows = signal([]); readonly findingRows = signal([]); readonly capsuleRows = signal([]); + readonly readinessReports = signal([]); + readonly promotionPaths = signal([]); + readonly allEnvironments = signal([]); + readonly isFrozen = signal(false); + readonly currentRelease = signal(null); + readonly validating = signal>(new Set()); + readonly validatingAll = signal(false); - readonly envQuickLinks = computed(() => [ - { label: 'Environment', route: `/setup/topology/environments/${this.environmentId()}`, description: 'Environment posture and configuration' }, - { label: 'Targets', route: '/setup/topology/targets', description: 'Deployment targets in this environment' }, - { label: 'Agents', route: '/setup/topology/agents', description: 'Agent fleet and connectivity' }, - { label: 'Runs', route: '/releases/runs', description: 'Deployment runs for this environment' }, - ]); + // ── Lookup maps ── + readonly hostNameMap = computed(() => new Map(this.hostRows().map(h => [h.hostId, h.hostName]))); + readonly agentNameMap = computed(() => new Map(this.agentRows().map(a => [a.agentId, a.agentName]))); + readonly targetNameMap = computed(() => new Map(this.targetRows().map(t => [t.targetId, t.name]))); + readonly envNameMap = computed(() => new Map(this.allEnvironments().map(e => [e.environmentId, e.displayName]))); - readonly healthyTargets = computed(() => - this.targetRows().filter((item) => item.healthStatus.trim().toLowerCase() === 'healthy').length, - ); - readonly degradedTargets = computed(() => - this.targetRows().filter((item) => item.healthStatus.trim().toLowerCase() === 'degraded').length, - ); - readonly unhealthyTargets = computed(() => - this.targetRows().filter((item) => { - const status = item.healthStatus.trim().toLowerCase(); - return status === 'unhealthy' || status === 'offline' || status === 'unknown'; - }).length, - ); - - readonly blockingFindings = computed( - () => this.findingRows().filter((item) => item.effectiveDisposition.trim().toLowerCase() === 'action_required').length, - ); - readonly staleCapsules = computed(() => - this.capsuleRows().filter((item) => item.status.trim().toLowerCase().includes('stale')).length, - ); - readonly degradedAgents = computed( - () => this.agentRows().filter((item) => item.status.trim().toLowerCase() !== 'active').length, - ); + // ── Health computeds ── + readonly healthyTargets = computed(() => this.targetRows().filter(t => t.healthStatus.trim().toLowerCase() === 'healthy').length); + readonly degradedTargets = computed(() => this.targetRows().filter(t => t.healthStatus.trim().toLowerCase() === 'degraded').length); + readonly unhealthyTargets = computed(() => this.targetRows().filter(t => { const s = t.healthStatus.trim().toLowerCase(); return s === 'unhealthy' || s === 'offline' || s === 'unknown'; }).length); + readonly blockingFindings = computed(() => this.findingRows().filter(f => f.effectiveDisposition.trim().toLowerCase() === 'action_required').length); + readonly staleCapsules = computed(() => this.capsuleRows().filter(c => c.status.trim().toLowerCase().includes('stale')).length); + readonly degradedAgents = computed(() => this.agentRows().filter(a => a.status.trim().toLowerCase() !== 'active').length); readonly deployHealth = computed(() => { - if (this.unhealthyTargets() > 0) { - return 'UNHEALTHY'; - } - if (this.degradedTargets() > 0 || this.degradedAgents() > 0) { - return 'DEGRADED'; - } + if (this.unhealthyTargets() > 0) return 'UNHEALTHY'; + if (this.degradedTargets() > 0 || this.degradedAgents() > 0) return 'DEGRADED'; return 'HEALTHY'; }); - readonly blockers = computed(() => { - const blockers: string[] = []; - if (this.unhealthyTargets() > 0) { - blockers.push('Unhealthy topology targets require runtime remediation.'); - } - if (this.blockingFindings() > 0) { - blockers.push('Action-required findings still block promotion.'); - } - if (this.staleCapsules() > 0) { - blockers.push('Decision capsule freshness is stale.'); - } - if (this.degradedAgents() > 0) { - blockers.push('Agent fleet for this environment has degraded heartbeat status.'); - } - return blockers; + readonly deployHealthStatus = computed((): 'success' | 'warning' | 'error' => { + const h = this.deployHealth(); + return h === 'HEALTHY' ? 'success' : h === 'DEGRADED' ? 'warning' : 'error'; }); + // ── Readiness computeds ── + readonly readyTargetsCnt = computed(() => this.readinessReports().filter(r => r.isReady).length); + readonly failingTargetsCnt = computed(() => this.readinessReports().filter(r => !r.isReady && r.gates.some(g => g.status === 'fail')).length); + readonly pendingTargetsCnt = computed(() => this.readinessReports().filter(r => !r.isReady && r.gates.some(g => g.status === 'pending') && !r.gates.some(g => g.status === 'fail')).length); + + // ── Drift ── + readonly driftDetected = computed(() => { + const targets = this.targetRows(); + if (targets.length < 2) return false; + const versions = new Set(targets.map(t => t.releaseVersionId).filter(Boolean)); + return versions.size > 1; + }); + readonly driftedTargetCount = computed(() => { + const targets = this.targetRows(); + const versions = targets.map(t => t.releaseVersionId).filter(Boolean); + if (versions.length === 0) return 0; + const counts = new Map(); + for (const v of versions) { counts.set(v, (counts.get(v) ?? 0) + 1); } + let maxCount = 0; for (const c of counts.values()) if (c > maxCount) maxCount = c; + return targets.length - maxCount; + }); + + // ── Promotion context ── + readonly promotionLine = computed(() => { + const envId = this.environmentId(); + const paths = this.promotionPaths(); + const names = this.envNameMap(); + if (paths.length === 0) return ''; + const upstream = paths.filter(p => p.targetEnvironmentId === envId).map(p => names.get(p.sourceEnvironmentId) ?? p.sourceEnvironmentId); + const downstream = paths.filter(p => p.sourceEnvironmentId === envId).map(p => names.get(p.targetEnvironmentId) ?? p.targetEnvironmentId); + const parts: string[] = []; + if (upstream.length) parts.push(upstream.join(', ') + ' →'); + parts.push(`[${this.environmentLabel()}]`); + if (downstream.length) parts.push('→ ' + downstream.join(', ')); + return parts.join(' '); + }); + + // ── Blockers ── + readonly blockers = computed(() => { + const items: { severity: 'error' | 'warning'; text: string }[] = []; + if (this.unhealthyTargets() > 0) items.push({ severity: 'error', text: `${this.unhealthyTargets()} unhealthy target(s) require runtime remediation.` }); + if (this.blockingFindings() > 0) items.push({ severity: 'error', text: `${this.blockingFindings()} finding(s) blocking promotion.` }); + if (this.failingTargetsCnt() > 0) items.push({ severity: 'error', text: `${this.failingTargetsCnt()} target(s) failing readiness gates.` }); + if (this.staleCapsules() > 0) items.push({ severity: 'warning', text: `${this.staleCapsules()} stale decision capsule(s).` }); + if (this.degradedAgents() > 0) items.push({ severity: 'warning', text: `${this.degradedAgents()} agent(s) with degraded heartbeat.` }); + return items; + }); + + // ── Tabs (dynamic status dots + badges) ── + readonly tabDefs = computed((): StellaPageTab[] => [ + { id: 'overview', label: 'Overview', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0', status: this.deployHealthStatus() === 'success' ? 'ok' : this.deployHealthStatus() === 'warning' ? 'warn' : 'error' }, + { id: 'targets', label: 'Targets', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01', badge: this.targetRows().length || undefined }, + { id: 'readiness', label: 'Readiness', 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', status: this.failingTargetsCnt() > 0 ? 'error' : this.pendingTargetsCnt() > 0 ? 'warn' : 'ok', badge: this.failingTargetsCnt() || undefined }, + { id: 'deployments', label: 'Runs', icon: 'M22 12h-4l-3 9L9 3l-3 9H2', badge: this.runRows().length || undefined }, + { id: 'agents', label: 'Agents', icon: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|||M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|||M20 8v6|||M23 11h-6', status: this.degradedAgents() > 0 ? 'warn' : 'ok' }, + { id: 'security', label: 'Security', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', status: this.blockingFindings() > 0 ? 'error' : 'ok', badge: this.blockingFindings() || undefined }, + { id: 'evidence', label: 'Evidence', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8', status: this.staleCapsules() > 0 ? 'warn' : 'ok' }, + { id: 'drift', label: 'Drift', icon: 'M16 3h5v5|||M4 20L21 3|||M21 16v5h-5|||M15 15l6 6|||M4 4l5 5', status: this.driftDetected() ? 'warn' : 'ok' }, + { id: 'data-quality', label: 'Data Quality', icon: 'M18 20V10|||M12 20V4|||M6 20v-6' }, + ]); + + readonly envQuickLinks = computed(() => [ + { label: 'Targets', route: '/environments/targets', description: 'Deployment targets' }, + { label: 'Agents', route: '/ops/operations/agents', description: 'Agent fleet' }, + { label: 'Runs', route: '/releases/deployments', description: 'Deployment runs' }, + ]); + + // ── Helpers ── + hostName(id: string): string { return this.hostNameMap().get(id) ?? id.substring(0, 8) + '...'; } + agentName(id: string): string { return this.agentNameMap().get(id) ?? id.substring(0, 8) + '...'; } + targetName(id: string): string { return this.targetNameMap().get(id) ?? id.substring(0, 12); } + healthToStatus(h: string): 'success' | 'warning' | 'error' | 'neutral' { return healthToStatus(h); } + severityStatus(s: string): 'error' | 'warning' | 'info' | 'neutral' { return severityToStatus(s); } + fmtGate(g: string): string { return fmtGateName(g); } + + gateStatus(rpt: ReadinessReport, gateName: string): 'success' | 'error' | 'warning' | 'neutral' { + const gate = rpt.gates.find(x => x.gateName === gateName); + return gate ? gateStatusType(gate.status) : 'neutral'; + } + + gateLabel(rpt: ReadinessReport, gateName: string): string { + const gate = rpt.gates.find(x => x.gateName === gateName); + if (!gate) return '—'; + switch (gate.status) { case 'pass': return '✓'; case 'fail': return '✗'; case 'pending': return '●'; default: return '—'; } + } + + runStatusType(status: string): 'success' | 'warning' | 'error' | 'neutral' { + const l = status.toLowerCase(); + if (l === 'deployed' || l === 'succeeded' || l === 'completed') return 'success'; + if (l === 'failed' || l === 'cancelled' || l === 'rejected') return 'error'; + if (l === 'pending' || l === 'promoting' || l === 'deploying' || l === 'awaiting_approval') return 'warning'; + return 'neutral'; + } + + // ── Lifecycle ── + constructor() { this.context.initialize(); - - this.route.paramMap.subscribe((params) => { - const environmentId = params.get('environmentId') ?? ''; - this.environmentId.set(environmentId); - if (environmentId) { - this.load(environmentId); - } + this.route.paramMap.subscribe(params => { + const id = params.get('environmentId') ?? ''; + this.environmentId.set(id); + if (id) this.load(id); }); } + refresh(): void { const id = this.environmentId(); if (id) this.load(id); } + private load(environmentId: string): void { this.loading.set(true); this.error.set(null); - const envFilter = [environmentId]; const params = new HttpParams().set('limit', '100').set('offset', '0').set('environment', environmentId); forkJoin({ - environmentRows: this.topologyApi - .list('/api/v2/topology/environments', this.context, { - environmentOverride: envFilter, - }) - .pipe(catchError(() => of([]))), - targets: this.topologyApi - .list('/api/v2/topology/targets', this.context, { environmentOverride: envFilter }) - .pipe(catchError(() => of([]))), - agents: this.topologyApi - .list('/api/v2/topology/agents', this.context, { environmentOverride: envFilter }) - .pipe(catchError(() => of([]))), - runs: this.http - .get>('/api/v2/releases/activity', { params }) - .pipe( - take(1), - catchError(() => of({ items: [] })), - ), - findings: this.http - .get>('/api/v2/security/findings', { params }) - .pipe( - take(1), - catchError(() => of({ items: [] })), - ), - capsules: this.http - .get>('/api/v2/evidence/packs', { params }) - .pipe( - take(1), - catchError(() => of({ items: [] })), - ), + environmentRows: this.topologyApi.list('/api/v2/topology/environments', this.context, { environmentOverride: envFilter }).pipe(catchError(() => of([]))), + targets: this.topologyApi.list('/api/v2/topology/targets', this.context, { environmentOverride: envFilter }).pipe(catchError(() => of([]))), + hosts: this.topologyApi.list('/api/v2/topology/hosts', this.context, { environmentOverride: envFilter }).pipe(catchError(() => of([]))), + agents: this.topologyApi.list('/api/v2/topology/agents', this.context, { environmentOverride: envFilter }).pipe(catchError(() => of([]))), + runs: this.http.get>('/api/v2/releases/activity', { params }).pipe(take(1), catchError(() => of({ items: [] }))), + findings: this.http.get>('/api/v2/security/findings', { params }).pipe(take(1), catchError(() => of({ items: [] }))), + capsules: this.http.get>('/api/v2/evidence/packs', { params }).pipe(take(1), catchError(() => of({ items: [] }))), + readiness: this.topologySetup.getEnvironmentReadiness(environmentId).pipe(catchError(() => of({ items: [] as ReadinessReport[] }))), + promotionPaths: this.topologyApi.list('/api/v2/topology/promotion-paths', this.context, { environmentOverride: envFilter }).pipe(catchError(() => of([]))), + allEnvs: this.topologyApi.list('/api/v2/topology/environments', this.context).pipe(catchError(() => of([]))), }).subscribe({ - next: ({ environmentRows, targets, agents, runs, findings, capsules }) => { - const scopeSummary = summarizeEnvironmentScope( - environmentRows, - environmentId, - this.context.regionSummary(), - ); - - this.environmentLabel.set(scopeSummary.environmentLabel); - this.regionLabel.set(scopeSummary.regionLabel); - this.environmentTypeLabel.set(scopeSummary.environmentTypeLabel); + next: ({ environmentRows, targets, hosts, agents, runs, findings, capsules, readiness, promotionPaths, allEnvs }) => { + // Try direct match first (more reliable than scope summary when API returns multiple envs) + const directMatch = environmentRows.find(e => e.environmentId === environmentId); + if (directMatch) { + this.environmentLabel.set(directMatch.displayName || environmentId); + this.regionLabel.set(directMatch.regionId || ''); + this.environmentTypeLabel.set(directMatch.environmentType || ''); + } else { + const scope = summarizeEnvironmentScope(environmentRows, environmentId, this.context.regionSummary()); + this.environmentLabel.set(scope.environmentLabel); + this.regionLabel.set(scope.regionLabel); + this.environmentTypeLabel.set(scope.environmentTypeLabel); + } this.targetRows.set(targets); + this.hostRows.set(hosts); this.agentRows.set(agents); this.runRows.set(runs?.items ?? []); this.findingRows.set(findings?.items ?? []); this.capsuleRows.set(capsules?.items ?? []); + this.readinessReports.set(readiness?.items ?? []); + this.promotionPaths.set(promotionPaths); + this.allEnvironments.set(allEnvs); + + // Derive current release from most common releaseVersionId across targets + const versions = targets.map(t => t.releaseVersionId).filter(Boolean); + if (versions.length > 0) { + const counts = new Map(); + for (const v of versions) counts.set(v, (counts.get(v) ?? 0) + 1); + let best = ''; let bestCount = 0; + for (const [v, c] of counts) if (c > bestCount) { best = v; bestCount = c; } + this.currentRelease.set(best.substring(0, 12)); + } + this.loading.set(false); }, error: (err: unknown) => { - this.error.set(err instanceof Error ? err.message : 'Failed to load topology environment detail.'); - this.targetRows.set([]); - this.agentRows.set([]); - this.runRows.set([]); - this.findingRows.set([]); - this.capsuleRows.set([]); + this.error.set(err instanceof Error ? err.message : 'Failed to load environment detail.'); this.loading.set(false); }, }); } + + // ── Actions ── + + validateTarget(targetId: string): void { + const s = new Set(this.validating()); s.add(targetId); this.validating.set(s); + this.topologySetup.validateTarget(targetId).pipe(catchError(() => of(null))).subscribe(result => { + if (result) { + const cur = [...this.readinessReports()]; + const idx = cur.findIndex(r => r.targetId === targetId); + if (idx >= 0) cur[idx] = result as ReadinessReport; else cur.push(result as ReadinessReport); + this.readinessReports.set(cur); + } + const u = new Set(this.validating()); u.delete(targetId); this.validating.set(u); + }); + } + + validateAll(): void { + this.validatingAll.set(true); + const ids = this.readinessReports().map(r => r.targetId); + if (ids.length === 0) { this.validatingAll.set(false); return; } + const reqs = ids.map(id => this.topologySetup.validateTarget(id).pipe(catchError(() => of(null)))); + forkJoin(reqs).subscribe(results => { + const cur = [...this.readinessReports()]; + for (const r of results) { + if (!r) continue; + const rpt = r as ReadinessReport; + const idx = cur.findIndex(x => x.targetId === rpt.targetId); + if (idx >= 0) cur[idx] = rpt; else cur.push(rpt); + } + this.readinessReports.set(cur); + this.validatingAll.set(false); + }); + } } - - diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology.models.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology.models.ts index daafdfdd9..179c413f8 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology.models.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology.models.ts @@ -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; } diff --git a/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts index 977e134a1..797ad90ad 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts @@ -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: `
@@ -95,7 +99,14 @@ import { SearchAssistantHostComponent } from '../search-assistant-host/search-as + + + + + + +
`, styles: [` diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts index 2148a7dcd..571ed88ff 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts @@ -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(() => { const orderedGroups = new Map(); - 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: diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-nav-item.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-nav-item.component.ts index ddc7d83ad..13b7401ad 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-nav-item.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-nav-item.component.ts @@ -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" > @switch (icon) { diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts index be6a726a5..0b63fe0f2 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts @@ -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, diff --git a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts index b229f196e..a19f072a6 100644 --- a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts @@ -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', }, ]; diff --git a/src/Web/StellaOps.Web/src/app/routes/topology.routes.ts b/src/Web/StellaOps.Web/src/app/routes/topology.routes.ts index 3b678976c..169b223eb 100644 --- a/src/Web/StellaOps.Web/src/app/routes/topology.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/topology.routes.ts @@ -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' }, ], }, ]; diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.ts index 36ccbf0f6..541ddc2c0 100644 --- a/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.ts @@ -209,9 +209,14 @@ function toDateKey(iso: string, locale?: string): string { [class]="'timeline__marker--' + (event.eventKind || 'neutral')" aria-hidden="true" > - @if (event.icon) { - {{ event.icon }} - } +
@@ -280,7 +285,10 @@ function toDateKey(iso: string, locale?: string): string {
- +

{{ emptyMessage() }}

@@ -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); } diff --git a/src/Web/StellaOps.Web/src/styles.scss b/src/Web/StellaOps.Web/src/styles.scss index cd1fab759..34f984058 100644 --- a/src/Web/StellaOps.Web/src/styles.scss +++ b/src/Web/StellaOps.Web/src/styles.scss @@ -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; } +} diff --git a/src/Web/StellaOps.Web/src/tests/navigation/nav-model.spec.ts b/src/Web/StellaOps.Web/src/tests/navigation/nav-model.spec.ts index 142362b9d..af556df42 100644 --- a/src/Web/StellaOps.Web/src/tests/navigation/nav-model.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/navigation/nav-model.spec.ts @@ -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 = { - 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(); 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); + } } }); }); diff --git a/src/Web/StellaOps.Web/src/tests/navigation/nav-route-integrity.spec.ts b/src/Web/StellaOps.Web/src/tests/navigation/nav-route-integrity.spec.ts index 622896396..e1a24fd70 100644 --- a/src/Web/StellaOps.Web/src/tests/navigation/nav-route-integrity.spec.ts +++ b/src/Web/StellaOps.Web/src/tests/navigation/nav-route-integrity.spec.ts @@ -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([ - '/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(); - 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(); + 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(); + 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(); + 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(); 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); } }