Update navigation, layout, and feature pages for DevOps onboarding
Reorganize sidebar navigation, update topology/releases/platform feature pages, and add environments command component. Improve dashboard, security overview, and mission control pages. - Navigation config: restructured groups and route mappings - Sidebar: collapsible sections, preference persistence - Topology: environments command component, detail page updates, remove readiness-dashboard (superseded) - Releases: unified page, activity, and ops overview updates - Platform ops/setup page improvements - E2e specs for navigation, environments, and release workflows - Nav model and route integrity test updates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
455
src/Web/StellaOps.Web/e2e/environment-detail.e2e.spec.ts
Normal file
455
src/Web/StellaOps.Web/e2e/environment-detail.e2e.spec.ts
Normal file
@@ -0,0 +1,455 @@
|
||||
/**
|
||||
* Environment Detail Page — E2E Tests
|
||||
*
|
||||
* Verifies the overhauled environment detail page with 9 tabs:
|
||||
* Overview, Targets, Readiness, Runs, Agents, Security, Evidence, Drift, Data Quality.
|
||||
* Tests header, stat cards, gate grid, status badges, date formatting, and actions.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
import { navigateAndWait } from './helpers/nav.helper';
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
|
||||
// ─── Mock data ──────────────────────────────────────────────────────
|
||||
|
||||
const ENV_ID = 'env-eu-prod';
|
||||
|
||||
const MOCK_ENVIRONMENTS = {
|
||||
items: [
|
||||
{ environmentId: ENV_ID, displayName: 'EU Production', regionId: 'eu-west', environmentType: 'production', sortOrder: 0, targetCount: 3, hostCount: 2, agentCount: 2, promotionPathCount: 1, workflowCount: 1, lastSyncAt: new Date().toISOString() },
|
||||
{ environmentId: 'env-eu-stage', displayName: 'EU Staging', regionId: 'eu-west', environmentType: 'staging', sortOrder: 1, targetCount: 2, hostCount: 1, agentCount: 1, promotionPathCount: 1, workflowCount: 1, lastSyncAt: new Date().toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_TARGETS = {
|
||||
items: [
|
||||
{ targetId: 't-1', name: 'eu-prod-app-01', regionId: 'eu-west', environmentId: ENV_ID, hostId: 'h-1', agentId: 'a-1', targetType: 'docker_host', healthStatus: 'healthy', componentVersionId: 'cv-1', imageDigest: 'sha256:abc123def456', releaseId: 'r-1', releaseVersionId: 'rv-1', lastSyncAt: new Date(Date.now() - 300_000).toISOString() },
|
||||
{ targetId: 't-2', name: 'eu-prod-app-02', regionId: 'eu-west', environmentId: ENV_ID, hostId: 'h-1', agentId: 'a-1', targetType: 'docker_host', healthStatus: 'healthy', componentVersionId: 'cv-1', imageDigest: 'sha256:abc123def456', releaseId: 'r-1', releaseVersionId: 'rv-1', lastSyncAt: new Date(Date.now() - 600_000).toISOString() },
|
||||
{ targetId: 't-3', name: 'eu-prod-worker-01', regionId: 'eu-west', environmentId: ENV_ID, hostId: 'h-2', agentId: 'a-2', targetType: 'docker_host', healthStatus: 'unhealthy', componentVersionId: 'cv-1', imageDigest: 'sha256:xyz789', releaseId: 'r-1', releaseVersionId: 'rv-2', lastSyncAt: new Date(Date.now() - 3_600_000).toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_HOSTS = {
|
||||
items: [
|
||||
{ hostId: 'h-1', hostName: 'prod-host-alpha', regionId: 'eu-west', environmentId: ENV_ID, runtimeType: 'docker', status: 'healthy', agentId: 'a-1', targetCount: 2, lastSeenAt: new Date().toISOString() },
|
||||
{ hostId: 'h-2', hostName: 'prod-host-beta', regionId: 'eu-west', environmentId: ENV_ID, runtimeType: 'docker', status: 'degraded', agentId: 'a-2', targetCount: 1, lastSeenAt: new Date(Date.now() - 7_200_000).toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_AGENTS = {
|
||||
items: [
|
||||
{ agentId: 'a-1', agentName: 'agent-eu-01', regionId: 'eu-west', environmentId: ENV_ID, status: 'active', capabilities: ['docker', 'compose'], assignedTargetCount: 2, lastHeartbeatAt: new Date(Date.now() - 30_000).toISOString() },
|
||||
{ agentId: 'a-2', agentName: 'agent-eu-02', regionId: 'eu-west', environmentId: ENV_ID, status: 'degraded', capabilities: ['docker'], assignedTargetCount: 1, lastHeartbeatAt: new Date(Date.now() - 900_000).toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
function gate(name: string, status: string, msg: string) {
|
||||
return { gateName: name, status, message: msg, checkedAt: new Date().toISOString(), durationMs: 100 };
|
||||
}
|
||||
|
||||
const MOCK_READINESS = {
|
||||
items: [
|
||||
{ targetId: 't-1', environmentId: ENV_ID, isReady: true, evaluatedAt: new Date().toISOString(), gates: [
|
||||
gate('agent_bound', 'pass', 'OK'), gate('docker_version_ok', 'pass', 'Docker 24'), gate('docker_ping_ok', 'pass', 'OK'),
|
||||
gate('registry_pull_ok', 'pass', 'OK'), gate('vault_reachable', 'pass', 'OK'), gate('consul_reachable', 'pass', 'OK'), gate('connectivity_ok', 'pass', 'All pass'),
|
||||
]},
|
||||
{ targetId: 't-2', environmentId: ENV_ID, isReady: true, evaluatedAt: new Date().toISOString(), gates: [
|
||||
gate('agent_bound', 'pass', 'OK'), gate('docker_version_ok', 'pass', 'Docker 24'), gate('docker_ping_ok', 'pass', 'OK'),
|
||||
gate('registry_pull_ok', 'pass', 'OK'), gate('vault_reachable', 'pass', 'OK'), gate('consul_reachable', 'pass', 'OK'), gate('connectivity_ok', 'pass', 'All pass'),
|
||||
]},
|
||||
{ targetId: 't-3', environmentId: ENV_ID, isReady: false, evaluatedAt: new Date().toISOString(), gates: [
|
||||
gate('agent_bound', 'pass', 'OK'), gate('docker_version_ok', 'pass', 'Docker 24'), gate('docker_ping_ok', 'pass', 'OK'),
|
||||
gate('registry_pull_ok', 'fail', 'Connection refused'), gate('vault_reachable', 'pass', 'OK'), gate('consul_reachable', 'pass', 'OK'),
|
||||
gate('connectivity_ok', 'fail', 'registry_pull_ok failed'),
|
||||
]},
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_RUNS = {
|
||||
items: [
|
||||
{ activityId: 'run-1', releaseId: 'r-1', releaseName: 'v2.4.1', status: 'deployed', correlationKey: 'ck-1', occurredAt: new Date(Date.now() - 3_600_000).toISOString(), durationMs: 45000 },
|
||||
{ activityId: 'run-2', releaseId: 'r-2', releaseName: 'v2.4.0', status: 'failed', correlationKey: 'ck-2', occurredAt: new Date(Date.now() - 86_400_000).toISOString(), durationMs: 12000 },
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_FINDINGS = {
|
||||
items: [
|
||||
{ findingId: 'f-1', cveId: 'CVE-2024-1234', severity: 'critical', effectiveDisposition: 'action_required', cvss: 9.8, reachable: true },
|
||||
{ findingId: 'f-2', cveId: 'CVE-2024-5678', severity: 'medium', effectiveDisposition: 'not_affected', cvss: 5.3, reachable: false },
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_CAPSULES = {
|
||||
items: [
|
||||
{ capsuleId: 'cap-abc123def456', status: 'complete', updatedAt: new Date(Date.now() - 7_200_000).toISOString(), signatureStatus: 'signed', contentTypes: ['SBOM', 'Attestation'] },
|
||||
{ capsuleId: 'cap-xyz789ghi012', status: 'stale', updatedAt: new Date(Date.now() - 604_800_000).toISOString(), signatureStatus: 'verified', contentTypes: ['Log'] },
|
||||
],
|
||||
};
|
||||
|
||||
const MOCK_PROMOTION_PATHS = {
|
||||
items: [
|
||||
{ pathId: 'pp-1', regionId: 'eu-west', sourceEnvironmentId: 'env-eu-stage', targetEnvironmentId: ENV_ID, pathMode: 'auto', status: 'active', requiredApprovals: 0, workflowId: 'w-1', gateProfileId: 'gp-1', lastPromotedAt: new Date(Date.now() - 86_400_000).toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
const SCREENSHOT_DIR = 'e2e/screenshots/environment-detail';
|
||||
|
||||
async function snap(page: Page, label: string) {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
function collectErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on('console', msg => { if (msg.type() === 'error') errors.push(msg.text()); });
|
||||
page.on('pageerror', err => errors.push(err.message));
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function setupMocks(page: Page) {
|
||||
// Catch-alls first (lowest priority)
|
||||
await page.route('**/api/v2/releases/**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_RUNS) });
|
||||
});
|
||||
await page.route('**/api/v2/security/**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_FINDINGS) });
|
||||
});
|
||||
await page.route('**/api/v2/evidence/**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CAPSULES) });
|
||||
});
|
||||
|
||||
// Context APIs
|
||||
await page.route('**/api/v2/context/regions', (route: Route) => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }));
|
||||
await page.route('**/api/v2/context/preferences', (route: Route) => route.fulfill({ status: 200, contentType: 'application/json', body: '{"regions":[],"environments":[]}' }));
|
||||
await page.route('**/api/v2/context/environments**', (route: Route) => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }));
|
||||
|
||||
// Validate target
|
||||
await page.route('**/api/v1/targets/*/validate', (route: Route) => {
|
||||
const targetId = route.request().url().match(/targets\/([^/]+)\/validate/)?.[1] ?? '';
|
||||
const rpt = MOCK_READINESS.items.find(r => r.targetId === targetId) ?? MOCK_READINESS.items[0];
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(rpt) });
|
||||
});
|
||||
|
||||
// Readiness
|
||||
await page.route('**/api/v1/environments/*/readiness', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_READINESS) });
|
||||
});
|
||||
|
||||
// Topology APIs (registered last = highest priority)
|
||||
await page.route('**/api/v2/topology/promotion-paths**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_PROMOTION_PATHS) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/hosts**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_HOSTS) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/agents**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_AGENTS) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/targets**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_TARGETS) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/layout**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ nodes: [], edges: [], metadata: { regionCount: 0, environmentCount: 0, promotionPathCount: 0, canvasWidth: 0, canvasHeight: 0 } }) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/environments**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_ENVIRONMENTS) });
|
||||
});
|
||||
}
|
||||
|
||||
const DETAIL_URL = `/environments/environments/${ENV_ID}`;
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Environment Detail Page', () => {
|
||||
|
||||
test('header shows environment name, health badge, back link, and actions', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=overview`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Back link
|
||||
const back = page.locator('.hdr__back');
|
||||
await expect(back).toBeVisible();
|
||||
await expect(back).toContainText('Environments');
|
||||
|
||||
// Environment name
|
||||
await expect(page.locator('.hdr__title-row h1')).toContainText('EU Production');
|
||||
|
||||
// Health badge
|
||||
const healthBadge = page.locator('.hdr__title-row app-status-badge').first();
|
||||
await expect(healthBadge).toBeVisible();
|
||||
|
||||
// Deploy button in header
|
||||
await expect(page.locator('.hdr__actions a:has-text("Deploy")')).toBeVisible();
|
||||
|
||||
// Refresh button
|
||||
await expect(page.locator('button:has-text("Refresh")')).toBeVisible();
|
||||
|
||||
await snap(page, '01-header');
|
||||
|
||||
const critical = errors.filter(e => e.includes('NG0') || e.includes('TypeError'));
|
||||
expect(critical).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('overview tab shows metric cards with correct values', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=overview`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Metric cards should be present (4 in the stat group)
|
||||
const metricCards = page.locator('app-metric-card');
|
||||
expect(await metricCards.count()).toBeGreaterThanOrEqual(4);
|
||||
|
||||
// Health circle should be visible
|
||||
const healthCircle = page.locator('.health-circle');
|
||||
await expect(healthCircle).toBeVisible();
|
||||
|
||||
// Quick stats
|
||||
const quickStats = page.locator('.quick-stats');
|
||||
await expect(quickStats).toBeVisible();
|
||||
|
||||
await snap(page, '02-overview');
|
||||
});
|
||||
|
||||
test('overview tab shows blockers when unhealthy targets exist', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=overview`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should have blockers (1 unhealthy target + 1 blocking finding + 1 failing readiness + 1 degraded agent)
|
||||
const blockerItems = page.locator('.blocker-item');
|
||||
expect(await blockerItems.count()).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Each blocker should have a status badge
|
||||
const badges = page.locator('.blocker-item app-status-badge');
|
||||
expect(await badges.count()).toBeGreaterThanOrEqual(1);
|
||||
|
||||
await snap(page, '03-blockers');
|
||||
});
|
||||
|
||||
test('targets tab shows display names instead of GUIDs', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=targets`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should show host display names, not raw GUIDs
|
||||
const tableText = await page.locator('table').innerText();
|
||||
expect(tableText).toContain('prod-host-alpha');
|
||||
expect(tableText).toContain('agent-eu-01');
|
||||
|
||||
// Should NOT show raw GUID h-1 or a-1
|
||||
expect(tableText).not.toContain('h-1');
|
||||
|
||||
// Health status should use StatusBadge
|
||||
const statusBadges = page.locator('table app-status-badge');
|
||||
expect(await statusBadges.count()).toBe(3); // 3 targets
|
||||
|
||||
await snap(page, '04-targets');
|
||||
});
|
||||
|
||||
test('readiness tab shows gate grid with status badges', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=readiness`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Summary metric cards (Ready/Failing/Pending)
|
||||
const metrics = page.locator('app-metric-card');
|
||||
expect(await metrics.count()).toBe(3);
|
||||
|
||||
// Gate grid table should have 3 target rows
|
||||
const rows = page.locator('table tbody tr');
|
||||
expect(await rows.count()).toBe(3);
|
||||
|
||||
// Validate All button
|
||||
const validateAll = page.locator('button:has-text("Validate All")');
|
||||
await expect(validateAll).toBeVisible();
|
||||
|
||||
// Per-row Validate buttons
|
||||
const validateBtns = page.locator('button:has-text("Validate")');
|
||||
expect(await validateBtns.count()).toBeGreaterThanOrEqual(3); // 3 per-target + 1 Validate All
|
||||
|
||||
await snap(page, '05-readiness');
|
||||
});
|
||||
|
||||
test('readiness tab has validate buttons for each target', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=readiness`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should have per-row Validate buttons (one per target)
|
||||
const validateBtns = page.locator('table tbody .btn--xs');
|
||||
expect(await validateBtns.count()).toBe(3); // 3 targets
|
||||
|
||||
// Validate All button should be visible
|
||||
const validateAll = page.locator('.panel__hdr button:has-text("Validate All")');
|
||||
await expect(validateAll).toBeVisible();
|
||||
|
||||
// Buttons should not be disabled initially
|
||||
const firstBtn = validateBtns.first();
|
||||
await expect(firstBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('runs tab shows formatted dates and status badges', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=deployments`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const tableText = await page.locator('table').innerText();
|
||||
|
||||
// Should show release name
|
||||
expect(tableText).toContain('v2.4.1');
|
||||
expect(tableText).toContain('v2.4.0');
|
||||
|
||||
// Status badges
|
||||
const badges = page.locator('table app-status-badge');
|
||||
expect(await badges.count()).toBe(2);
|
||||
|
||||
// Should have "View" links
|
||||
const viewLinks = page.locator('table a:has-text("View")');
|
||||
expect(await viewLinks.count()).toBe(2);
|
||||
|
||||
// Dates should be relative
|
||||
expect(tableText).toMatch(/ago|just now/);
|
||||
|
||||
await snap(page, '06-runs');
|
||||
});
|
||||
|
||||
test('agents tab shows status badges and relative heartbeat times', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=agents`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const tableText = await page.locator('table').innerText();
|
||||
expect(tableText).toContain('agent-eu-01');
|
||||
expect(tableText).toContain('agent-eu-02');
|
||||
|
||||
// Status badges (active + degraded)
|
||||
const badges = page.locator('table app-status-badge');
|
||||
expect(await badges.count()).toBe(2);
|
||||
|
||||
// Heartbeat should show relative time, not raw ISO
|
||||
// (RelativeTimePipe converts to "X minutes ago" etc.)
|
||||
expect(tableText).toMatch(/ago|just now|seconds|minutes|hours|days/);
|
||||
|
||||
await snap(page, '07-agents');
|
||||
});
|
||||
|
||||
test('security tab shows CVSS, reachability, and links', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=security`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const tableText = await page.locator('table').innerText();
|
||||
|
||||
// CVE IDs
|
||||
expect(tableText).toContain('CVE-2024-1234');
|
||||
expect(tableText).toContain('CVE-2024-5678');
|
||||
|
||||
// CVSS scores
|
||||
expect(tableText).toContain('9.8');
|
||||
expect(tableText).toContain('5.3');
|
||||
|
||||
// Severity badges
|
||||
const badges = page.locator('table app-status-badge');
|
||||
expect(await badges.count()).toBeGreaterThanOrEqual(4); // severity + reachable per row
|
||||
|
||||
// View links
|
||||
const viewLinks = page.locator('table a:has-text("View")');
|
||||
expect(await viewLinks.count()).toBe(2);
|
||||
|
||||
await snap(page, '08-security');
|
||||
});
|
||||
|
||||
test('evidence tab shows card grid with signed badges', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=evidence`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Evidence cards
|
||||
const cards = page.locator('.ev-card');
|
||||
expect(await cards.count()).toBe(2);
|
||||
|
||||
// Content type badges
|
||||
const typeBadges = page.locator('.ev-type');
|
||||
expect(await typeBadges.count()).toBe(2);
|
||||
|
||||
// Signature badges
|
||||
const sigBadges = page.locator('.ev-badges app-status-badge');
|
||||
expect(await sigBadges.count()).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await snap(page, '09-evidence');
|
||||
});
|
||||
|
||||
test('drift tab detects version divergence across targets', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=drift`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// t-1 and t-2 have rv-1, t-3 has rv-2 — drift should be detected
|
||||
const driftAlert = page.locator('.drift-alert');
|
||||
await expect(driftAlert).toBeVisible();
|
||||
|
||||
// Should mention drifted target count
|
||||
const alertText = await driftAlert.innerText();
|
||||
expect(alertText).toContain('1 target');
|
||||
|
||||
await snap(page, '10-drift');
|
||||
});
|
||||
|
||||
test('data quality tab shows metric cards with severity coloring', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=data-quality`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const metricCards = page.locator('app-metric-card');
|
||||
expect(await metricCards.count()).toBe(4);
|
||||
|
||||
await snap(page, '11-data-quality');
|
||||
});
|
||||
|
||||
test('tab bar shows status dots and badge counts', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=overview`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Tab bar should exist with 9 tabs
|
||||
const tabs = page.locator('stella-page-tabs button[role="tab"]');
|
||||
expect(await tabs.count()).toBe(9);
|
||||
|
||||
await snap(page, '12-tab-bar');
|
||||
});
|
||||
|
||||
test('no Angular errors across all tabs', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await setupMocks(page);
|
||||
|
||||
const tabs: string[] = ['overview', 'targets', 'readiness', 'deployments', 'agents', 'security', 'evidence', 'drift', 'data-quality'];
|
||||
|
||||
for (const tab of tabs) {
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=${tab}`);
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
const critical = errors.filter(e => e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError'));
|
||||
expect(critical, 'Critical errors: ' + critical.join('\n')).toHaveLength(0);
|
||||
|
||||
await snap(page, '13-no-errors');
|
||||
});
|
||||
|
||||
test('promotion path context shown in header', async ({ authenticatedPage: page }) => {
|
||||
await setupMocks(page);
|
||||
await navigateAndWait(page, `${DETAIL_URL}?tab=overview`);
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Promotion line should show upstream env
|
||||
const promoLine = page.locator('.hdr__promo');
|
||||
if (await promoLine.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
const text = await promoLine.innerText();
|
||||
// Should mention EU Staging (upstream) and EU Production (this env)
|
||||
expect(text).toContain('EU Staging');
|
||||
expect(text).toContain('EU Production');
|
||||
}
|
||||
|
||||
await snap(page, '14-promotion-context');
|
||||
});
|
||||
});
|
||||
731
src/Web/StellaOps.Web/e2e/environments-command.e2e.spec.ts
Normal file
731
src/Web/StellaOps.Web/e2e/environments-command.e2e.spec.ts
Normal file
@@ -0,0 +1,731 @@
|
||||
/**
|
||||
* Environments Command Center — E2E Tests
|
||||
*
|
||||
* Verifies the unified environments page that combines readiness gate status
|
||||
* with topology visualization. Tests command view (cards + gates),
|
||||
* topology view (SVG graph), filtering, actions, and redirects.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
import { navigateAndWait, assertPageHasContent } from './helpers/nav.helper';
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
|
||||
// ─── Mock data ──────────────────────────────────────────────────────
|
||||
|
||||
const MOCK_ENVS = {
|
||||
items: [
|
||||
{ environmentId: 'eu-prod', displayName: 'EU Production', regionId: 'eu-west', environmentType: 'production' },
|
||||
{ environmentId: 'eu-stage', displayName: 'EU Staging', regionId: 'eu-west', environmentType: 'staging' },
|
||||
{ environmentId: 'us-prod', displayName: 'US Production', regionId: 'us-east', environmentType: 'production' },
|
||||
{ environmentId: 'us-uat', displayName: 'US UAT', regionId: 'us-east', environmentType: 'uat' },
|
||||
{ environmentId: 'prod-us-west', displayName: 'US West Production', regionId: 'us-west', environmentType: 'production' },
|
||||
{ environmentId: 'apac-prod', displayName: 'APAC Production', regionId: 'apac', environmentType: 'production' },
|
||||
],
|
||||
};
|
||||
|
||||
function gate(name: string, status: string, msg: string) {
|
||||
return { gateName: name, status, message: msg, checkedAt: new Date().toISOString(), durationMs: 120 };
|
||||
}
|
||||
|
||||
function allPassGates() {
|
||||
return [
|
||||
gate('agent_bound', 'pass', 'Agent heartbeat OK'),
|
||||
gate('docker_version_ok', 'pass', 'Docker 24.0.9'),
|
||||
gate('docker_ping_ok', 'pass', 'Daemon reachable'),
|
||||
gate('registry_pull_ok', 'pass', 'Pull test OK'),
|
||||
gate('vault_reachable', 'pass', 'Vault unsealed'),
|
||||
gate('consul_reachable', 'pass', 'Consul leader elected'),
|
||||
gate('connectivity_ok', 'pass', 'All required gates pass'),
|
||||
];
|
||||
}
|
||||
|
||||
/** EU Production: 3 targets, ALL READY */
|
||||
const READINESS_EU_PROD = {
|
||||
items: [
|
||||
{ targetId: 't-eup-1', targetName: 'eu-prod-app-01', environmentId: 'eu-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
{ targetId: 't-eup-2', targetName: 'eu-prod-app-02', environmentId: 'eu-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
{ targetId: 't-eup-3', targetName: 'eu-prod-worker-01', environmentId: 'eu-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
/** EU Staging: 2 targets, 1 FAILING (registry) */
|
||||
const READINESS_EU_STAGE = {
|
||||
items: [
|
||||
{ targetId: 't-eus-1', targetName: 'eu-stage-app-01', environmentId: 'eu-stage', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
{ targetId: 't-eus-2', targetName: 'eu-stage-app-02', environmentId: 'eu-stage', isReady: false, gates: [
|
||||
gate('agent_bound', 'pass', 'Agent heartbeat OK'),
|
||||
gate('docker_version_ok', 'pass', 'Docker 24.0.9'),
|
||||
gate('docker_ping_ok', 'pass', 'Daemon reachable'),
|
||||
gate('registry_pull_ok', 'fail', 'Connection refused: registry.internal:5000'),
|
||||
gate('vault_reachable', 'pass', 'Vault unsealed'),
|
||||
gate('consul_reachable', 'pass', 'Consul leader elected'),
|
||||
gate('connectivity_ok', 'fail', 'Required gate registry_pull_ok failed'),
|
||||
], evaluatedAt: new Date().toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
/** US Production: 2 targets, ALL READY */
|
||||
const READINESS_US_PROD = {
|
||||
items: [
|
||||
{ targetId: 't-usp-1', targetName: 'us-prod-app-01', environmentId: 'us-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
{ targetId: 't-usp-2', targetName: 'us-prod-worker-01', environmentId: 'us-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
/** US UAT: 2 targets, 1 PENDING (agent not bound) */
|
||||
const READINESS_US_UAT = {
|
||||
items: [
|
||||
{ targetId: 't-usu-1', targetName: 'us-uat-app-01', environmentId: 'us-uat', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
{ targetId: 't-usu-2', targetName: 'us-uat-worker-01', environmentId: 'us-uat', isReady: false, gates: [
|
||||
gate('agent_bound', 'pending', 'Awaiting agent registration'),
|
||||
gate('docker_version_ok', 'pending', 'Blocked by agent_bound'),
|
||||
gate('docker_ping_ok', 'pending', 'Blocked by agent_bound'),
|
||||
gate('registry_pull_ok', 'pending', 'Blocked by agent_bound'),
|
||||
gate('vault_reachable', 'pending', 'Blocked by agent_bound'),
|
||||
gate('consul_reachable', 'pending', 'Blocked by agent_bound'),
|
||||
gate('connectivity_ok', 'fail', 'Required gate agent_bound is pending'),
|
||||
], evaluatedAt: new Date().toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
/** US West Production: 1 target, ALL READY */
|
||||
const READINESS_US_WEST = {
|
||||
items: [
|
||||
{ targetId: 't-uwp-1', targetName: 'usw-prod-app-01', environmentId: 'prod-us-west', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
/** APAC Production: 2 targets, 1 FAILING (consul partitioned) */
|
||||
const READINESS_APAC = {
|
||||
items: [
|
||||
{ targetId: 't-ap-1', targetName: 'apac-prod-app-01', environmentId: 'apac-prod', isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString() },
|
||||
{ targetId: 't-ap-2', targetName: 'apac-prod-worker-01', environmentId: 'apac-prod', isReady: false, gates: [
|
||||
gate('agent_bound', 'pass', 'Agent heartbeat OK'),
|
||||
gate('docker_version_ok', 'pass', 'Docker 25.0.3'),
|
||||
gate('docker_ping_ok', 'pass', 'Daemon reachable'),
|
||||
gate('registry_pull_ok', 'pass', 'Pull test OK'),
|
||||
gate('vault_reachable', 'pass', 'Vault unsealed'),
|
||||
gate('consul_reachable', 'fail', 'No Consul leader — cluster partitioned'),
|
||||
gate('connectivity_ok', 'fail', 'Required gate consul_reachable failed'),
|
||||
], evaluatedAt: new Date().toISOString() },
|
||||
],
|
||||
};
|
||||
|
||||
const READINESS_BY_ENV: Record<string, { items: any[] }> = {
|
||||
'eu-prod': READINESS_EU_PROD,
|
||||
'eu-stage': READINESS_EU_STAGE,
|
||||
'us-prod': READINESS_US_PROD,
|
||||
'us-uat': READINESS_US_UAT,
|
||||
'prod-us-west': READINESS_US_WEST,
|
||||
'apac-prod': READINESS_APAC,
|
||||
};
|
||||
|
||||
const MOCK_TOPOLOGY_LAYOUT = {
|
||||
nodes: [
|
||||
{ id: 'region-eu-west', label: 'EU West', kind: 'region', parentNodeId: null, x: 0, y: 0, width: 400, height: 150, hostCount: 0, targetCount: 5, isFrozen: false, promotionPathCount: 1, deployingCount: 0, pendingCount: 0, failedCount: 0, totalDeployments: 0 },
|
||||
{ id: 'env-eu-prod', label: 'EU Production', kind: 'environment', parentNodeId: 'region-eu-west', x: 220, y: 50, width: 160, height: 50, environmentId: 'eu-prod', regionId: 'eu-west', environmentType: 'production', healthStatus: 'healthy', hostCount: 3, targetCount: 3, isFrozen: false, promotionPathCount: 1, deployingCount: 0, pendingCount: 0, failedCount: 0, totalDeployments: 5 },
|
||||
{ id: 'env-eu-stage', label: 'EU Staging', kind: 'environment', parentNodeId: 'region-eu-west', x: 20, y: 50, width: 160, height: 50, environmentId: 'eu-stage', regionId: 'eu-west', environmentType: 'staging', healthStatus: 'degraded', hostCount: 2, targetCount: 2, isFrozen: false, promotionPathCount: 1, deployingCount: 0, pendingCount: 0, failedCount: 1, totalDeployments: 3 },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'path-eu-stage-to-prod', sourceNodeId: 'env-eu-stage', targetNodeId: 'env-eu-prod', kind: 'promotion', label: 'auto-promote', pathMode: 'auto', status: 'active', requiredApprovals: 0, sections: [{ startPoint: { x: 180, y: 75 }, endPoint: { x: 220, y: 75 }, bendPoints: [] }] },
|
||||
],
|
||||
metadata: { regionCount: 1, environmentCount: 2, promotionPathCount: 1, canvasWidth: 440, canvasHeight: 170 },
|
||||
};
|
||||
|
||||
const MOCK_CONTEXT_REGIONS = [
|
||||
{ regionId: 'eu-west', displayName: 'EU West', enabled: true },
|
||||
{ regionId: 'us-east', displayName: 'US East', enabled: true },
|
||||
{ regionId: 'us-west', displayName: 'US West', enabled: true },
|
||||
{ regionId: 'apac', displayName: 'Asia-Pacific', enabled: true },
|
||||
];
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
const SCREENSHOT_DIR = 'e2e/screenshots/environments-command';
|
||||
|
||||
async function snap(page: Page, label: string) {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
function collectErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()); });
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function setupMockRoutes(page: Page) {
|
||||
// IMPORTANT: In Playwright, the LAST registered route is checked FIRST.
|
||||
// Register catch-alls BEFORE specific mocks so specific mocks take priority.
|
||||
|
||||
// Catch-all for release/topology APIs (registered first = lowest priority)
|
||||
await page.route('**/api/v2/releases/**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/targets**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/agents**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/hosts**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
|
||||
// Context APIs
|
||||
await page.route('**/api/v2/context/regions', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_CONTEXT_REGIONS) });
|
||||
});
|
||||
await page.route('**/api/v2/context/preferences', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ regions: [], environments: [] }) });
|
||||
});
|
||||
await page.route('**/api/v2/context/environments**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_ENVS.items) });
|
||||
});
|
||||
|
||||
// Validate target (returns updated report)
|
||||
await page.route('**/api/v1/targets/*/validate', (route: Route) => {
|
||||
const url = route.request().url();
|
||||
const targetId = url.match(/targets\/([^/]+)\/validate/)?.[1] ?? '';
|
||||
for (const env of Object.values(READINESS_BY_ENV)) {
|
||||
const target = env.items.find((t: any) => t.targetId === targetId);
|
||||
if (target) {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(target) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
route.fulfill({ status: 404, body: 'target not found' });
|
||||
});
|
||||
|
||||
// Topology layout (registered after catch-alls = higher priority)
|
||||
await page.route('**/api/v2/topology/layout**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_TOPOLOGY_LAYOUT) });
|
||||
});
|
||||
|
||||
// Per-environment readiness (high priority — registered late)
|
||||
await page.route('**/api/v1/environments/*/readiness', (route: Route) => {
|
||||
const url = route.request().url();
|
||||
const envId = url.match(/environments\/([^/]+)\/readiness/)?.[1] ?? '';
|
||||
const data = READINESS_BY_ENV[envId] ?? { items: [] };
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) });
|
||||
});
|
||||
|
||||
// Environments list (highest priority — registered last)
|
||||
await page.route('**/api/v2/topology/environments**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_ENVS) });
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Environments Command Center', () => {
|
||||
|
||||
// ── Scenario 1: Page loads and renders all environments ──
|
||||
|
||||
test('renders command view with all 6 environments from API', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Shell header
|
||||
await expect(page.locator('h1')).toContainText('Environments');
|
||||
|
||||
// View toggle present with Command active
|
||||
const commandBtn = page.locator('button:has-text("Command")');
|
||||
await expect(commandBtn).toBeVisible();
|
||||
|
||||
// Summary strip shows correct counts
|
||||
// Total targets: eu-prod:3 + eu-stage:2 + us-prod:2 + us-uat:2 + us-west:1 + apac:2 = 12
|
||||
const summaryCards = page.locator('.sc__v');
|
||||
const values = await summaryCards.allTextContents();
|
||||
// [0]=envs, [1]=targets, [2]=ready, [3]=not-ready, [4]=failed-gates
|
||||
expect(parseInt(values[0])).toBe(6);
|
||||
expect(parseInt(values[1])).toBe(12);
|
||||
// Ready: eu-prod:3 + eu-stage:1 + us-prod:2 + us-uat:1 + us-west:1 + apac:1 = 9
|
||||
expect(parseInt(values[2])).toBe(9);
|
||||
// Not ready: eu-stage:1 + us-uat:1 + apac:1 = 3
|
||||
expect(parseInt(values[3])).toBe(3);
|
||||
|
||||
// Environment cards present
|
||||
const envCards = page.locator('.env-card');
|
||||
await expect(envCards).toHaveCount(6);
|
||||
|
||||
await snap(page, '01-command-view-loaded');
|
||||
|
||||
// No critical Angular errors
|
||||
const critical = errors.filter(e => e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError'));
|
||||
expect(critical, 'Critical errors: ' + critical.join('\n')).toHaveLength(0);
|
||||
});
|
||||
|
||||
// ── Scenario 2: Not-ready environments appear first ──
|
||||
|
||||
test('sorts not-ready environments before ready ones', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const firstCard = page.locator('.env-card').first();
|
||||
// First card should be a not-ready env (has --bad class)
|
||||
await expect(firstCard).toHaveClass(/env-card--bad/);
|
||||
|
||||
// Last cards should be fully-ready (--ok class)
|
||||
const cards = page.locator('.env-card');
|
||||
const count = await cards.count();
|
||||
const lastCard = cards.nth(count - 1);
|
||||
await expect(lastCard).toHaveClass(/env-card--ok/);
|
||||
});
|
||||
|
||||
// ── Scenario 3: Gate grid shows correct status icons ──
|
||||
|
||||
test('gate grid shows pass/fail/pending icons correctly', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Find the EU Staging card (has a failing target)
|
||||
const euStageCard = page.locator('.env-card', { hasText: 'EU Staging' });
|
||||
await expect(euStageCard).toBeVisible();
|
||||
|
||||
// Should show 1/2 ready
|
||||
await expect(euStageCard.locator('.env-card__score')).toContainText('1/2 ready');
|
||||
|
||||
// Should contain fail icons (✗)
|
||||
const failCells = euStageCard.locator('.gc--fail');
|
||||
expect(await failCells.count()).toBeGreaterThanOrEqual(2); // registry_pull_ok + connectivity_ok
|
||||
|
||||
// Should contain pass icons (✓)
|
||||
const passCells = euStageCard.locator('.gc--pass');
|
||||
expect(await passCells.count()).toBeGreaterThanOrEqual(5); // most gates pass on the good target
|
||||
|
||||
await snap(page, '02-gate-status-icons');
|
||||
});
|
||||
|
||||
// ── Scenario 4: Blocker section appears for failing environments ──
|
||||
|
||||
test('shows blockers with remediation hints for failing environments', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// APAC card has consul failure
|
||||
const apacCard = page.locator('.env-card', { hasText: 'APAC Production' });
|
||||
await expect(apacCard).toBeVisible();
|
||||
|
||||
// Blockers section present
|
||||
const blockers = apacCard.locator('.blockers');
|
||||
await expect(blockers).toBeVisible();
|
||||
|
||||
// Shows the failed gate name
|
||||
await expect(blockers).toContainText('Consul Reachable');
|
||||
|
||||
// Shows remediation hint
|
||||
await expect(blockers).toContainText('Consul cluster health');
|
||||
|
||||
await snap(page, '03-blockers-remediation');
|
||||
});
|
||||
|
||||
// ── Scenario 5: Deploy button only on all-ready environments ──
|
||||
|
||||
test('Deploy button appears only when all targets are ready', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// EU Production is fully ready — should have Deploy button
|
||||
const euProdCard = page.locator('.env-card', { hasText: 'EU Production' });
|
||||
const deployBtn = euProdCard.locator('a:has-text("Deploy")');
|
||||
await expect(deployBtn).toBeVisible();
|
||||
|
||||
// EU Staging has a failure — no Deploy button
|
||||
const euStageCard = page.locator('.env-card', { hasText: 'EU Staging' });
|
||||
const noDeployBtn = euStageCard.locator('a:has-text("Deploy")');
|
||||
await expect(noDeployBtn).toHaveCount(0);
|
||||
|
||||
// US UAT has pending — no Deploy button
|
||||
const usUatCard = page.locator('.env-card', { hasText: 'US UAT' });
|
||||
const noDeployBtn2 = usUatCard.locator('a:has-text("Deploy")');
|
||||
await expect(noDeployBtn2).toHaveCount(0);
|
||||
|
||||
await snap(page, '04-deploy-button-gating');
|
||||
});
|
||||
|
||||
// ── Scenario 6: Region filtering via global context ──
|
||||
|
||||
test('region filtering is handled by global context bar, not local chips', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// The toolbar should NOT have region chip buttons (those are in the global context bar)
|
||||
const toolbar = page.locator('.toolbar');
|
||||
await expect(toolbar).toBeVisible();
|
||||
|
||||
// Status chips should be in the toolbar
|
||||
const statusChips = toolbar.locator('.status-chips');
|
||||
await expect(statusChips).toBeVisible();
|
||||
await expect(statusChips.locator('button', { hasText: 'Ready' }).first()).toBeVisible();
|
||||
await expect(statusChips.locator('button', { hasText: 'Not Ready' })).toBeVisible();
|
||||
|
||||
// View toggle should be in the same row as status
|
||||
await expect(toolbar.locator('button:has-text("Command")')).toBeVisible();
|
||||
await expect(toolbar.locator('button:has-text("Topology")')).toBeVisible();
|
||||
|
||||
await snap(page, '05-toolbar-layout');
|
||||
});
|
||||
|
||||
// ── Scenario 7: Status filter works ──
|
||||
|
||||
test('status filter shows only ready or not-ready environments', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Click "Not Ready" status chip in the toolbar
|
||||
const notReadyChip = page.locator('.status-chips button.chip--err');
|
||||
await notReadyChip.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should only show targets that are not ready (3 targets across 3 envs)
|
||||
const targets = page.locator('.gg__row');
|
||||
const targetCount = await targets.count();
|
||||
expect(targetCount).toBe(3); // 1 from eu-stage + 1 from us-uat + 1 from apac
|
||||
|
||||
// All rows should have --bad class
|
||||
for (let i = 0; i < targetCount; i++) {
|
||||
await expect(targets.nth(i)).toHaveClass(/gg__row--bad/);
|
||||
}
|
||||
|
||||
await snap(page, '06-status-filter-not-ready');
|
||||
|
||||
// Switch to "Ready" filter
|
||||
const readyChip = page.locator('.status-chips button.chip--ok');
|
||||
await readyChip.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Should show 9 ready targets
|
||||
const readyTargets = page.locator('.gg__row');
|
||||
expect(await readyTargets.count()).toBe(9);
|
||||
|
||||
await snap(page, '07-status-filter-ready');
|
||||
});
|
||||
|
||||
// ── Scenario 8: Toggle to topology view ──
|
||||
|
||||
test('topology toggle shows SVG graph view', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Click Topology toggle
|
||||
const topoBtn = page.locator('button:has-text("Topology")');
|
||||
await topoBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// SVG graph should be visible
|
||||
const svgCanvas = page.locator('svg.topology-canvas');
|
||||
await expect(svgCanvas).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Environment cards should NOT be visible
|
||||
await expect(page.locator('.env-card')).toHaveCount(0);
|
||||
|
||||
// Summary strip should still be visible
|
||||
await expect(page.locator('.summary')).toBeVisible();
|
||||
|
||||
await snap(page, '08-topology-view');
|
||||
});
|
||||
|
||||
// ── Scenario 9: Topology node click opens drawer ──
|
||||
|
||||
test('clicking a topology environment node opens detail drawer', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview?view=topology');
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Click an environment node in the SVG
|
||||
const envNode = page.locator('.env-group').first();
|
||||
if (await envNode.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await envNode.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Drawer should appear
|
||||
const drawer = page.locator('.topo-drawer');
|
||||
if (await drawer.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
// Should show readiness summary
|
||||
await expect(drawer.locator('.td__readiness')).toBeVisible();
|
||||
|
||||
// "Open Detail" link should exist
|
||||
await expect(drawer.locator('a:has-text("Open Detail")')).toBeVisible();
|
||||
|
||||
// "Switch to Command" button should exist
|
||||
await expect(drawer.locator('button:has-text("Switch to Command")')).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
await snap(page, '09-topology-drawer');
|
||||
});
|
||||
|
||||
// ── Scenario 10: /releases/readiness redirects to /environments/overview ──
|
||||
|
||||
test('legacy /releases/readiness redirects to /environments/overview', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await page.goto('/releases/readiness', { waitUntil: 'networkidle', timeout: 15_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// URL should have changed to /environments/overview
|
||||
expect(page.url()).toContain('/environments/overview');
|
||||
|
||||
await snap(page, '10-readiness-redirect');
|
||||
});
|
||||
|
||||
// ── Scenario 11: Validate All triggers API calls ──
|
||||
|
||||
test('Validate All button sends validation requests for all visible targets', async ({ authenticatedPage: page }) => {
|
||||
const validateCalls: string[] = [];
|
||||
await setupMockRoutes(page);
|
||||
|
||||
// Track validate calls
|
||||
await page.route('**/api/v1/targets/*/validate', (route: Route) => {
|
||||
const url = route.request().url();
|
||||
const targetId = url.match(/targets\/([^/]+)\/validate/)?.[1] ?? '';
|
||||
validateCalls.push(targetId);
|
||||
// Return mock result
|
||||
for (const env of Object.values(READINESS_BY_ENV)) {
|
||||
const target = env.items.find((t: any) => t.targetId === targetId);
|
||||
if (target) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(target) }); return; }
|
||||
}
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ targetId, isReady: true, gates: allPassGates(), evaluatedAt: new Date().toISOString(), environmentId: 'unknown' }) });
|
||||
});
|
||||
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Click Validate All
|
||||
const validateAllBtn = page.locator('button:has-text("Validate All")');
|
||||
await validateAllBtn.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should have called validate for all 12 targets
|
||||
expect(validateCalls.length).toBe(12);
|
||||
|
||||
await snap(page, '11-validate-all');
|
||||
});
|
||||
|
||||
// ── Scenario 12: Per-environment Re-check ──
|
||||
|
||||
test('Re-check button validates only targets in that environment', async ({ authenticatedPage: page }) => {
|
||||
const validateCalls: string[] = [];
|
||||
await setupMockRoutes(page);
|
||||
|
||||
await page.route('**/api/v1/targets/*/validate', (route: Route) => {
|
||||
const url = route.request().url();
|
||||
const targetId = url.match(/targets\/([^/]+)\/validate/)?.[1] ?? '';
|
||||
validateCalls.push(targetId);
|
||||
for (const env of Object.values(READINESS_BY_ENV)) {
|
||||
const target = env.items.find((t: any) => t.targetId === targetId);
|
||||
if (target) { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(target) }); return; }
|
||||
}
|
||||
route.fulfill({ status: 404 });
|
||||
});
|
||||
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Find the EU Staging card and click Re-check
|
||||
const euStageCard = page.locator('.env-card', { hasText: 'EU Staging' });
|
||||
const recheckBtn = euStageCard.locator('button:has-text("Re-check")');
|
||||
await recheckBtn.click();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Should have called validate for only 2 targets (eu-stage's targets)
|
||||
expect(validateCalls.length).toBe(2);
|
||||
expect(validateCalls).toContain('t-eus-1');
|
||||
expect(validateCalls).toContain('t-eus-2');
|
||||
});
|
||||
|
||||
// ── Scenario 13: All environments load when no context filter applied ──
|
||||
|
||||
test('shows all environments when global context has no region filter', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// All 6 environments should be visible
|
||||
const cards = page.locator('.env-card');
|
||||
expect(await cards.count()).toBe(6);
|
||||
|
||||
await snap(page, '12-all-environments');
|
||||
});
|
||||
|
||||
// ── Scenario 14: Clear status filter resets to All ──
|
||||
|
||||
test('Clear button resets status filter back to All', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Apply "Not Ready" filter
|
||||
await page.locator('.status-chips button.chip--err').click();
|
||||
await page.waitForTimeout(500);
|
||||
expect(await page.locator('.gg__row').count()).toBe(3); // only 3 not-ready targets
|
||||
|
||||
// Click Clear
|
||||
const clearBtn = page.locator('button:has-text("Clear")');
|
||||
await clearBtn.click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// All 6 environments visible again
|
||||
expect(await page.locator('.env-card').count()).toBe(6);
|
||||
});
|
||||
|
||||
// ── Scenario 15: View Detail link navigates to environment detail ──
|
||||
|
||||
test('View Detail link navigates to environment detail page', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
|
||||
// Mock the environment detail page APIs
|
||||
await page.route('**/api/v2/topology/targets**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
await page.route('**/api/v2/topology/agents**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
await page.route('**/api/v2/security/**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
await page.route('**/api/v2/evidence/**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Click "View Detail" on EU Production card
|
||||
const euProdCard = page.locator('.env-card', { hasText: 'EU Production' });
|
||||
const detailLink = euProdCard.locator('a:has-text("View Detail")');
|
||||
await detailLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should navigate to /environments/environments/eu-prod
|
||||
expect(page.url()).toContain('/environments/environments/eu-prod');
|
||||
|
||||
await snap(page, '13-view-detail-navigation');
|
||||
});
|
||||
|
||||
// ── Scenario 16: No Angular errors across all views ──
|
||||
|
||||
test('no critical Angular errors navigating between command and topology views', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await setupMockRoutes(page);
|
||||
|
||||
// Load command view
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Switch to topology
|
||||
await page.locator('button:has-text("Topology")').click();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Switch back to command
|
||||
await page.locator('button:has-text("Command")').click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Apply status filter
|
||||
await page.locator('.status-chips button.chip--err').click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Switch to topology again
|
||||
await page.locator('button:has-text("Topology")').click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const critical = errors.filter(e =>
|
||||
e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError')
|
||||
);
|
||||
expect(critical, 'Critical errors: ' + critical.join('\n')).toHaveLength(0);
|
||||
|
||||
await snap(page, '14-no-errors');
|
||||
});
|
||||
|
||||
// ── Scenario 17: Empty state when no environments match filter ──
|
||||
|
||||
test('status filter narrows down visible targets correctly', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Filter for "Not Ready" — should show 3 failing targets across 3 envs
|
||||
await page.locator('.status-chips button.chip--err').click();
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const rows = page.locator('.gg__row');
|
||||
expect(await rows.count()).toBe(3);
|
||||
|
||||
// All rows should have the --bad class
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await expect(rows.nth(i)).toHaveClass(/gg__row--bad/);
|
||||
}
|
||||
|
||||
await snap(page, '15-filtered-not-ready-targets');
|
||||
});
|
||||
|
||||
// ── Scenario 18: Pending gates are shown distinctly from failures ──
|
||||
|
||||
test('pending gates display with warning color distinct from failures', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Find US UAT card (has pending agent)
|
||||
const usUatCard = page.locator('.env-card', { hasText: 'US UAT' });
|
||||
await expect(usUatCard).toBeVisible();
|
||||
|
||||
// Should have pending indicator cells (●)
|
||||
const pendingCells = usUatCard.locator('.gc--pend');
|
||||
expect(await pendingCells.count()).toBeGreaterThanOrEqual(5); // 5 pending gates on the worker target
|
||||
|
||||
// Should have both fail AND pending blockers
|
||||
const blockers = usUatCard.locator('.blockers');
|
||||
await expect(blockers).toBeVisible();
|
||||
await expect(blockers).toContainText('Agent Bound');
|
||||
await expect(blockers).toContainText('Awaiting agent registration');
|
||||
|
||||
await snap(page, '16-pending-vs-fail');
|
||||
});
|
||||
|
||||
// ── Scenario 19: Deploy button links to correct route ──
|
||||
|
||||
test('Deploy button links to create deployment with environment query param', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
const euProdCard = page.locator('.env-card', { hasText: 'EU Production' });
|
||||
const deployLink = euProdCard.locator('a:has-text("Deploy")');
|
||||
|
||||
const href = await deployLink.getAttribute('href');
|
||||
expect(href).toContain('/releases/deployments/new');
|
||||
expect(href).toContain('environment=eu-prod');
|
||||
});
|
||||
|
||||
// ── Scenario 20: Multiple diverse failure types across environments ──
|
||||
|
||||
test('different failure types shown correctly across environments', async ({ authenticatedPage: page }) => {
|
||||
await setupMockRoutes(page);
|
||||
await navigateAndWait(page, '/environments/overview');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// EU Staging: registry failure
|
||||
const euStageBlockers = page.locator('.env-card', { hasText: 'EU Staging' }).locator('.blockers');
|
||||
await expect(euStageBlockers).toContainText('Registry Pull');
|
||||
|
||||
// US UAT: agent pending
|
||||
const usUatBlockers = page.locator('.env-card', { hasText: 'US UAT' }).locator('.blockers');
|
||||
await expect(usUatBlockers).toContainText('Agent Bound');
|
||||
|
||||
// APAC: consul failure
|
||||
const apacBlockers = page.locator('.env-card', { hasText: 'APAC Production' }).locator('.blockers');
|
||||
await expect(apacBlockers).toContainText('Consul Reachable');
|
||||
|
||||
await snap(page, '17-diverse-failures');
|
||||
});
|
||||
});
|
||||
75
src/Web/StellaOps.Web/e2e/fixtures/live-auth.fixture.ts
Normal file
75
src/Web/StellaOps.Web/e2e/fixtures/live-auth.fixture.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { test as base, expect, Page, APIRequestContext } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Live auth fixture for integration tests against the real Stella Ops stack.
|
||||
*
|
||||
* Unlike the mocked auth.fixture.ts, this performs a real OIDC login against
|
||||
* the live Authority service and extracts a Bearer token for API calls.
|
||||
*/
|
||||
|
||||
const BASE_URL = process.env['PLAYWRIGHT_BASE_URL'] || 'https://stella-ops.local';
|
||||
const ADMIN_USER = process.env['STELLAOPS_ADMIN_USER'] || 'admin';
|
||||
const ADMIN_PASS = process.env['STELLAOPS_ADMIN_PASS'] || 'Admin@Stella2026!';
|
||||
|
||||
export const test = base.extend<{
|
||||
liveAuthPage: Page;
|
||||
apiToken: string;
|
||||
apiRequest: APIRequestContext;
|
||||
}>({
|
||||
liveAuthPage: async ({ page }, use) => {
|
||||
// Navigate to the app — will redirect to login
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
|
||||
// If we land on /welcome, click Sign In
|
||||
if (page.url().includes('/welcome')) {
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await page.waitForURL('**/connect/authorize**', { timeout: 10_000 });
|
||||
}
|
||||
|
||||
// Fill login form if present
|
||||
const usernameField = page.getByRole('textbox', { name: /username/i });
|
||||
if (await usernameField.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await usernameField.fill(ADMIN_USER);
|
||||
await page.getByRole('textbox', { name: /password/i }).fill(ADMIN_PASS);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
await page.waitForURL(`${BASE_URL}/**`, { timeout: 15_000 });
|
||||
}
|
||||
|
||||
// Wait for app to load
|
||||
await page.waitForLoadState('networkidle', { timeout: 15_000 });
|
||||
|
||||
await use(page);
|
||||
},
|
||||
|
||||
apiToken: async ({ liveAuthPage: page }, use) => {
|
||||
// Extract the Bearer token from session storage
|
||||
const token = await page.evaluate(() => {
|
||||
const session = sessionStorage.getItem('stellaops.auth.session.full');
|
||||
if (!session) return null;
|
||||
const parsed = JSON.parse(session);
|
||||
return parsed?.tokens?.accessToken ?? null;
|
||||
});
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Failed to extract auth token from session storage');
|
||||
}
|
||||
|
||||
await use(token);
|
||||
},
|
||||
|
||||
apiRequest: async ({ playwright, apiToken }, use) => {
|
||||
const ctx = await playwright.request.newContext({
|
||||
baseURL: BASE_URL,
|
||||
extraHTTPHeaders: {
|
||||
'Authorization': `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
|
||||
await use(ctx);
|
||||
await ctx.dispose();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
701
src/Web/StellaOps.Web/e2e/navigation-reorganization.e2e.spec.ts
Normal file
701
src/Web/StellaOps.Web/e2e/navigation-reorganization.e2e.spec.ts
Normal file
@@ -0,0 +1,701 @@
|
||||
/**
|
||||
* Navigation Reorganization E2E Tests
|
||||
* Black-box QA verification of the 6-group sidebar structure,
|
||||
* tab rationalization, quick actions cleanup, and removed-item accessibility.
|
||||
*
|
||||
* Sprint: UI Navigation & UX Reorganization
|
||||
*/
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
const SCREENSHOT_DIR = 'e2e/screenshots/nav-reorg';
|
||||
|
||||
async function snap(page: Page, label: string) {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
function collectErrors(page: Page) {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function go(page: Page, path: string) {
|
||||
await page.goto(path, { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
|
||||
function criticalOnly(errors: string[]) {
|
||||
return errors.filter(e =>
|
||||
e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError')
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. SIDEBAR GROUP STRUCTURE
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Sidebar 6-group structure', () => {
|
||||
test('renders exactly 6 nav groups with correct labels', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
|
||||
// The sidebar renders group headers as text. Verify each group label exists.
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const sidebarText = await sidebar.innerText();
|
||||
|
||||
// 6-group labels (Home has no label, so 5 visible labels)
|
||||
expect(sidebarText).toContain('Release Control');
|
||||
expect(sidebarText).toContain('Security');
|
||||
expect(sidebarText).toContain('Evidence');
|
||||
expect(sidebarText).toContain('Operations');
|
||||
expect(sidebarText).toContain('Settings');
|
||||
|
||||
// Dissolved groups must NOT appear
|
||||
expect(sidebarText).not.toContain('Policy');
|
||||
expect(sidebarText).not.toContain('Audit & Evidence');
|
||||
|
||||
await snap(page, '01-sidebar-groups');
|
||||
});
|
||||
|
||||
test('Dashboard is visible in Home group', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const dashLink = page.locator('text=Dashboard').first();
|
||||
expect(await dashLink.isVisible({ timeout: 3000 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. RELEASE CONTROL GROUP
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Release Control group', () => {
|
||||
test('contains Deployments, Releases, Environments, Readiness', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/');
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).toContain('Deployments');
|
||||
expect(text).toContain('Releases');
|
||||
expect(text).toContain('Environments');
|
||||
expect(text).toContain('Readiness');
|
||||
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Readiness navigates to /releases/readiness', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/');
|
||||
|
||||
const link = page.locator('text=Readiness').first();
|
||||
if (await link.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await link.click();
|
||||
await page.waitForTimeout(2000);
|
||||
expect(page.url()).toContain('/releases/readiness');
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.trim().length).toBeGreaterThan(10);
|
||||
await snap(page, '02-readiness');
|
||||
}
|
||||
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. SECURITY GROUP (absorbed Policy items)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Security group', () => {
|
||||
test('contains Vulnerabilities, Security Posture, Scan Image', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).toContain('Vulnerabilities');
|
||||
expect(text).toContain('Security Posture');
|
||||
expect(text).toContain('Scan Image');
|
||||
});
|
||||
|
||||
test('contains absorbed Policy items: VEX & Exceptions, Risk & Governance', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).toContain('VEX & Exceptions');
|
||||
expect(text).toContain('Risk & Governance');
|
||||
});
|
||||
|
||||
test('Security Posture has Findings Explorer child', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/security');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// The Findings Explorer should be visible as a child nav item
|
||||
const findingsExplorer = page.locator('text=Findings Explorer').first();
|
||||
if (await findingsExplorer.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await findingsExplorer.click();
|
||||
await page.waitForTimeout(2000);
|
||||
expect(page.url()).toContain('/security/findings');
|
||||
await snap(page, '03-findings-explorer');
|
||||
}
|
||||
});
|
||||
|
||||
test('Security Posture children: Supply-Chain, Reachability, Unknowns', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/security');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).toContain('Supply-Chain Data');
|
||||
expect(text).toContain('Reachability');
|
||||
expect(text).toContain('Unknowns');
|
||||
});
|
||||
|
||||
test('Risk & Governance children: Simulation, Policy Audit', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/governance');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).toContain('Simulation');
|
||||
expect(text).toContain('Policy Audit');
|
||||
});
|
||||
|
||||
test('VEX & Exceptions navigates to /ops/policy/vex', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/');
|
||||
|
||||
const link = page.locator('text=VEX & Exceptions').first();
|
||||
if (await link.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await link.click();
|
||||
await page.waitForTimeout(2000);
|
||||
expect(page.url()).toContain('/ops/policy/vex');
|
||||
await snap(page, '04-vex-exceptions');
|
||||
}
|
||||
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. EVIDENCE GROUP (trimmed)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Evidence group (trimmed)', () => {
|
||||
test('contains exactly 4 items: Overview, Capsules, Audit Log, Export Center', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/evidence/overview');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).toContain('Evidence Overview');
|
||||
expect(text).toContain('Decision Capsules');
|
||||
expect(text).toContain('Audit Log');
|
||||
expect(text).toContain('Export Center');
|
||||
});
|
||||
|
||||
test('removed items NOT in sidebar: Replay, Bundles, Trust', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/evidence/overview');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
// These should no longer be sidebar items
|
||||
// Note: "Replay" might appear in page content, so check within sidebar only
|
||||
const evidenceGroup = text.split('Operations')[0]; // text before Operations group
|
||||
expect(evidenceGroup).not.toContain('Replay & Verify');
|
||||
expect(evidenceGroup).not.toContain('Bundles');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. OPERATIONS GROUP (trimmed, absorbed Packs)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Operations group', () => {
|
||||
test('contains Policy Packs (absorbed from Policy group)', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/operations');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).toContain('Policy Packs');
|
||||
expect(text).toContain('Operations Hub');
|
||||
expect(text).toContain('Scheduled Jobs');
|
||||
expect(text).toContain('Diagnostics');
|
||||
});
|
||||
|
||||
test('removed items NOT in sidebar: Runtime Drift, Notifications, Watchlist', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/operations');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).not.toContain('Runtime Drift');
|
||||
expect(text).not.toContain('Watchlist');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. SETTINGS GROUP
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Settings group', () => {
|
||||
test('Certificates renamed to Certificates & Trust', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/integrations');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const text = await sidebar.innerText();
|
||||
|
||||
expect(text).toContain('Certificates & Trust');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. REMOVED ITEMS STILL ROUTABLE (no 404)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Removed nav items still routable', () => {
|
||||
const removedRoutes = [
|
||||
{ name: 'Replay & Verify', path: '/evidence/verify-replay' },
|
||||
{ name: 'Bundles', path: '/evidence/bundles' },
|
||||
{ name: 'Runtime Drift', path: '/ops/operations/drift' },
|
||||
{ name: 'Notifications', path: '/ops/operations/notifications' },
|
||||
{ name: 'Watchlist', path: '/ops/operations/watchlist' },
|
||||
];
|
||||
|
||||
for (const route of removedRoutes) {
|
||||
test(`${route.name} (${route.path}) loads without error`, async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, route.path);
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.trim().length, `${route.name} should render content`).toBeGreaterThan(10);
|
||||
|
||||
// No Angular injection errors
|
||||
const critical = criticalOnly(errors);
|
||||
expect(critical, `${route.name} should have no critical errors`).toHaveLength(0);
|
||||
|
||||
await snap(page, `05-removed-${route.name.toLowerCase().replace(/\s+/g, '-')}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. NEW NAV ITEMS ROUTE CORRECTLY
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('New nav items route correctly', () => {
|
||||
test('Readiness → /releases/readiness renders content', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/releases/readiness');
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.trim().length).toBeGreaterThan(10);
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Findings Explorer → /security/findings renders content', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/security/findings');
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.trim().length).toBeGreaterThan(10);
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. TAB RATIONALIZATION - TRUST ADMIN (3 tabs, no Audit)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Trust Admin tabs (rationalized to 3)', () => {
|
||||
test('shows 3 tabs: Signing Keys, Trusted Issuers, Certificates', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/setup/trust-signing');
|
||||
|
||||
const tabBar = page.locator('stella-page-tabs, [class*="page-tabs"]').first();
|
||||
if (await tabBar.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
const tabText = await tabBar.innerText();
|
||||
expect(tabText).toContain('Signing Keys');
|
||||
expect(tabText).toContain('Trusted Issuers');
|
||||
expect(tabText).toContain('Certificates');
|
||||
expect(tabText).not.toContain('Audit');
|
||||
}
|
||||
|
||||
await snap(page, '06-trust-admin-tabs');
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('has audit cross-link in header', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/setup/trust-signing');
|
||||
|
||||
const auditLink = page.locator('a[href*="evidence/audit-log"]').first();
|
||||
if (await auditLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
const text = await auditLink.innerText();
|
||||
expect(text.toLowerCase()).toContain('audit');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. TAB RATIONALIZATION - INTEGRATION HUB (7 tabs, no Activity/Config Audit)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Integration Hub tabs (rationalized to 7)', () => {
|
||||
test('shows 7 tabs, no Activity or Config Audit', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/setup/integrations');
|
||||
|
||||
const tabBar = page.locator('stella-page-tabs, [class*="page-tabs"]').first();
|
||||
if (await tabBar.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
const tabText = await tabBar.innerText();
|
||||
expect(tabText).toContain('Hub');
|
||||
expect(tabText).toContain('Registries');
|
||||
expect(tabText).toContain('SCM');
|
||||
expect(tabText).toContain('CI/CD');
|
||||
expect(tabText).toContain('Secrets');
|
||||
expect(tabText).not.toContain('Activity');
|
||||
expect(tabText).not.toContain('Config Audit');
|
||||
}
|
||||
|
||||
await snap(page, '07-integration-tabs');
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11. TAB RATIONALIZATION - POLICY GOVERNANCE (5 tabs)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Policy Governance tabs (rationalized to 5)', () => {
|
||||
test('shows 5 tabs: Risk Budget, Profiles, Configuration, Conflicts, Developer Tools', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/ops/policy/governance');
|
||||
|
||||
const tabBar = page.locator('stella-page-tabs, [class*="page-tabs"]').first();
|
||||
if (await tabBar.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
const tabText = await tabBar.innerText();
|
||||
expect(tabText).toContain('Risk Budget');
|
||||
expect(tabText).toContain('Profiles');
|
||||
expect(tabText).toContain('Configuration');
|
||||
expect(tabText).toContain('Conflicts');
|
||||
expect(tabText).toContain('Developer Tools');
|
||||
|
||||
// Removed tabs should not appear
|
||||
expect(tabText).not.toContain('Trust Weights');
|
||||
expect(tabText).not.toContain('Staleness');
|
||||
expect(tabText).not.toContain('Sealed Mode');
|
||||
expect(tabText).not.toContain('Validator');
|
||||
expect(tabText).not.toContain('Playground');
|
||||
}
|
||||
|
||||
await snap(page, '08-governance-tabs');
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('Configuration tab contains Trust Weights, Staleness, Sealed Mode sections', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/governance/config');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
// The config panel should show toggle buttons for the 3 sub-sections
|
||||
expect(body).toContain('Trust Weights');
|
||||
expect(body).toContain('Staleness');
|
||||
expect(body).toContain('Sealed Mode');
|
||||
|
||||
await snap(page, '09-governance-config-panel');
|
||||
});
|
||||
|
||||
test('Developer Tools tab contains Validator, Playground, Docs sections', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/governance/tools');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body).toContain('Validator');
|
||||
expect(body).toContain('Playground');
|
||||
|
||||
await snap(page, '10-governance-tools-panel');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 12. TAB RATIONALIZATION - POLICY SIMULATION (6 tabs)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Policy Simulation tabs (rationalized to 6)', () => {
|
||||
test('shows 6 tabs: Shadow Mode, Promotion Gate, Test & Validate, Pre-Promotion Review, Effective Policies, Exceptions', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/ops/policy/simulation');
|
||||
|
||||
const tabBar = page.locator('stella-page-tabs, [class*="page-tabs"]').first();
|
||||
if (await tabBar.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
const tabText = await tabBar.innerText();
|
||||
expect(tabText).toContain('Shadow Mode');
|
||||
expect(tabText).toContain('Promotion Gate');
|
||||
expect(tabText).toContain('Test');
|
||||
expect(tabText).toContain('Effective Policies');
|
||||
expect(tabText).toContain('Exceptions');
|
||||
|
||||
// Removed standalone tabs
|
||||
expect(tabText).not.toContain('Lint');
|
||||
expect(tabText).not.toContain('Batch Evaluation');
|
||||
expect(tabText).not.toContain('History');
|
||||
}
|
||||
|
||||
await snap(page, '11-simulation-tabs');
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 13. QUICK ACTIONS (Ctrl+K)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Quick Actions (Ctrl+K) palette', () => {
|
||||
test('opens with Ctrl+K and shows top 5 actions', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
|
||||
// Open command palette
|
||||
await page.keyboard.press('Control+k');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const palette = page.locator('app-command-palette, [class*="command-palette"]').first();
|
||||
if (await palette.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
const text = await palette.innerText();
|
||||
|
||||
// Top 5 default actions should be visible
|
||||
expect(text).toContain('Scan Artifact');
|
||||
expect(text).toContain('View Findings');
|
||||
expect(text).toContain('Pending Approvals');
|
||||
expect(text).toContain('Security Posture');
|
||||
expect(text).toContain('Create Release');
|
||||
|
||||
await snap(page, '12-command-palette-default');
|
||||
}
|
||||
|
||||
// Close palette
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('no duplicate actions for scan or findings', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
await page.keyboard.press('Control+k');
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const palette = page.locator('app-command-palette, [class*="command-palette"]').first();
|
||||
if (await palette.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
// Type 'scan' to filter
|
||||
await page.keyboard.type('>scan');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const results = await palette.innerText();
|
||||
// Should find "Scan Artifact" but NOT "Scan Image" (removed duplicate)
|
||||
expect(results).toContain('Scan Artifact');
|
||||
expect(results).not.toContain('Scan Image');
|
||||
|
||||
await snap(page, '13-palette-scan-no-dupe');
|
||||
}
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
});
|
||||
|
||||
test('new actions are discoverable: >approvals, >exceptions, >posture, >reach', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/');
|
||||
|
||||
const newShortcuts = [
|
||||
{ shortcut: '>approvals', expected: 'Pending Approvals' },
|
||||
{ shortcut: '>exceptions', expected: 'Exception Queue' },
|
||||
{ shortcut: '>posture', expected: 'Security Posture' },
|
||||
{ shortcut: '>reach', expected: 'Reachability' },
|
||||
{ shortcut: '>export', expected: 'Export Evidence' },
|
||||
{ shortcut: '>budget', expected: 'Risk Budget' },
|
||||
{ shortcut: '>envs', expected: 'Environments' },
|
||||
{ shortcut: '>exception', expected: 'Create Exception' },
|
||||
];
|
||||
|
||||
for (const { shortcut, expected } of newShortcuts) {
|
||||
await page.keyboard.press('Control+k');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const palette = page.locator('app-command-palette, [class*="command-palette"]').first();
|
||||
if (await palette.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
// Clear and type shortcut
|
||||
const input = palette.locator('input').first();
|
||||
await input.fill(shortcut);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const results = await palette.innerText();
|
||||
expect(results, `Shortcut "${shortcut}" should find "${expected}"`).toContain(expected);
|
||||
}
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
await snap(page, '14-palette-new-actions');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 14. AUTO-EXPAND: Security group expands for /ops/policy/* routes
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Sidebar auto-expand behavior', () => {
|
||||
test('navigating to /ops/policy/governance expands Security group', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/governance');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// The sidebar should have auto-expanded the Security group
|
||||
// and Risk & Governance should be visible
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const govItem = sidebar.locator('text=Risk & Governance').first();
|
||||
expect(await govItem.isVisible({ timeout: 3000 }).catch(() => false)).toBe(true);
|
||||
|
||||
await snap(page, '15-auto-expand-security');
|
||||
});
|
||||
|
||||
test('navigating to /ops/policy/packs expands Operations group', async ({ authenticatedPage: page }) => {
|
||||
await go(page, '/ops/policy/packs');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const sidebar = page.locator('app-sidebar, [class*="sidebar"]').first();
|
||||
const packsItem = sidebar.locator('text=Policy Packs').first();
|
||||
expect(await packsItem.isVisible({ timeout: 3000 }).catch(() => false)).toBe(true);
|
||||
|
||||
await snap(page, '16-auto-expand-operations');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 15. FULL NAVIGATION CLICK-THROUGH (smoke)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Full navigation smoke test', () => {
|
||||
test('click through all 6 groups without critical errors', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, '/');
|
||||
|
||||
// Release Control
|
||||
const deploymentsLink = page.locator('text=Deployments').first();
|
||||
if (await deploymentsLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await deploymentsLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// Security
|
||||
const vulnLink = page.locator('text=Vulnerabilities').first();
|
||||
if (await vulnLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await vulnLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// Evidence
|
||||
const auditLink = page.locator('text=Audit Log').first();
|
||||
if (await auditLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await auditLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// Operations
|
||||
const opsLink = page.locator('text=Operations Hub').first();
|
||||
if (await opsLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await opsLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
// Settings
|
||||
const intLink = page.locator('text=Integrations').first();
|
||||
if (await intLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await intLink.click();
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
await snap(page, '17-smoke-complete');
|
||||
|
||||
const critical = criticalOnly(errors);
|
||||
expect(critical, 'No critical errors during full smoke: ' + critical.join('\n')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 16. CRITICAL ROUTE RENDERING (batch)
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Critical route rendering (all new/changed routes)', () => {
|
||||
const routes = [
|
||||
{ path: '/', name: 'Dashboard' },
|
||||
{ path: '/releases/deployments', name: 'Deployments' },
|
||||
{ path: '/releases', name: 'Releases' },
|
||||
{ path: '/releases/readiness', name: 'Readiness' },
|
||||
{ path: '/environments/overview', name: 'Environments' },
|
||||
{ path: '/triage/artifacts', name: 'Vulnerabilities' },
|
||||
{ path: '/security', name: 'Security Posture' },
|
||||
{ path: '/security/findings', name: 'Findings Explorer' },
|
||||
{ path: '/security/reachability', name: 'Reachability' },
|
||||
{ path: '/security/unknowns', name: 'Unknowns' },
|
||||
{ path: '/security/scan', name: 'Scan Image' },
|
||||
{ path: '/ops/policy/vex', name: 'VEX & Exceptions' },
|
||||
{ path: '/ops/policy/governance', name: 'Risk & Governance' },
|
||||
{ path: '/ops/policy/governance/config', name: 'Governance Config' },
|
||||
{ path: '/ops/policy/governance/tools', name: 'Governance Tools' },
|
||||
{ path: '/ops/policy/simulation', name: 'Policy Simulation' },
|
||||
{ path: '/ops/policy/simulation/testing', name: 'Simulation Test & Validate' },
|
||||
{ path: '/ops/policy/simulation/review', name: 'Simulation Pre-Promotion Review' },
|
||||
{ path: '/ops/policy/audit', name: 'Policy Audit' },
|
||||
{ path: '/evidence/overview', name: 'Evidence Overview' },
|
||||
{ path: '/evidence/capsules', name: 'Decision Capsules' },
|
||||
{ path: '/evidence/audit-log', name: 'Audit Log' },
|
||||
{ path: '/evidence/exports', name: 'Export Center' },
|
||||
{ path: '/ops/operations', name: 'Operations Hub' },
|
||||
{ path: '/ops/policy/packs', name: 'Policy Packs' },
|
||||
{ path: '/ops/operations/jobengine', name: 'Scheduled Jobs' },
|
||||
{ path: '/ops/operations/feeds-airgap', name: 'Feeds & Airgap' },
|
||||
{ path: '/ops/operations/doctor', name: 'Diagnostics' },
|
||||
{ path: '/setup/integrations', name: 'Integrations' },
|
||||
{ path: '/setup/identity-access', name: 'Identity & Access' },
|
||||
{ path: '/setup/trust-signing', name: 'Trust & Signing' },
|
||||
{ path: '/setup/tenant-branding', name: 'Theme & Branding' },
|
||||
];
|
||||
|
||||
for (const route of routes) {
|
||||
test(`${route.name} (${route.path}) renders without errors`, async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
await go(page, route.path);
|
||||
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body.trim().length, `${route.name} should have content`).toBeGreaterThan(10);
|
||||
|
||||
const critical = criticalOnly(errors);
|
||||
expect(critical, `${route.name} errors: ${critical.join('\n')}`).toHaveLength(0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 17. BACK/FORWARD NAVIGATION STABILITY
|
||||
// ---------------------------------------------------------------------------
|
||||
test.describe('Browser navigation stability', () => {
|
||||
test('back/forward between reorganized routes works', async ({ authenticatedPage: page }) => {
|
||||
const errors = collectErrors(page);
|
||||
|
||||
await go(page, '/');
|
||||
await go(page, '/security');
|
||||
await go(page, '/ops/policy/governance');
|
||||
await go(page, '/evidence/audit-log');
|
||||
|
||||
// Go back 3 times
|
||||
await page.goBack({ waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(1000);
|
||||
expect(page.url()).toContain('/ops/policy/governance');
|
||||
|
||||
await page.goBack({ waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(1000);
|
||||
expect(page.url()).toContain('/security');
|
||||
|
||||
await page.goBack({ waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Go forward
|
||||
await page.goForward({ waitUntil: 'networkidle' });
|
||||
await page.waitForTimeout(1000);
|
||||
expect(page.url()).toContain('/security');
|
||||
|
||||
expect(criticalOnly(errors)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
468
src/Web/StellaOps.Web/e2e/release-workflow.e2e.spec.ts
Normal file
468
src/Web/StellaOps.Web/e2e/release-workflow.e2e.spec.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Release Workflow — Full E2E Test
|
||||
*
|
||||
* Exercises the complete release lifecycle against the live Docker stack:
|
||||
* 1. Create Version (with image + script components)
|
||||
* 2. Create Release from that version
|
||||
* 3. Deploy release to first environment
|
||||
* 4. Request promotion to second environment
|
||||
* 5. Approve promotion
|
||||
* 6. Verify deployment to second environment
|
||||
*
|
||||
* Uses the live stack at stella-ops.local with auth fixture.
|
||||
* API calls that aren't fully wired get mock fallbacks.
|
||||
*/
|
||||
|
||||
import { test, expect } from './fixtures/auth.fixture';
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
|
||||
const SCREENSHOT_DIR = 'e2e/screenshots/release-workflow';
|
||||
|
||||
async function snap(page: Page, label: string) {
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/${label}.png`, fullPage: true });
|
||||
}
|
||||
|
||||
// ── API mock fallbacks for endpoints not yet wired in the backend ──
|
||||
|
||||
const MOCK_BUNDLE = {
|
||||
id: 'bndl-e2e-001',
|
||||
slug: 'e2e-api-gateway',
|
||||
name: 'E2E API Gateway',
|
||||
description: 'End-to-end test bundle',
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
const MOCK_VERSION = {
|
||||
id: 'ver-e2e-001',
|
||||
bundleId: 'bndl-e2e-001',
|
||||
version: '1.0.0-e2e',
|
||||
changelog: 'E2E workflow test',
|
||||
status: 'sealed',
|
||||
components: [
|
||||
{ componentName: 'api-gateway', componentVersionId: 'cv-1', imageDigest: 'sha256:e2etest123abc456def', deployOrder: 1, metadataJson: '{}' },
|
||||
{ componentName: 'db-migrate', componentVersionId: 'cv-2', imageDigest: 'script:migrate-v1', deployOrder: 0, metadataJson: '{"type":"script"}' },
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const MOCK_RELEASE = {
|
||||
id: 'rel-e2e-001',
|
||||
name: 'E2E API Gateway',
|
||||
version: '1.0.0-e2e',
|
||||
description: 'E2E workflow test release',
|
||||
status: 'ready',
|
||||
releaseType: 'standard',
|
||||
slug: 'e2e-api-gateway',
|
||||
digest: 'sha256:e2etest123abc456def',
|
||||
currentStage: null,
|
||||
currentEnvironment: null,
|
||||
targetEnvironment: 'staging',
|
||||
targetRegion: 'us-east',
|
||||
componentCount: 2,
|
||||
gateStatus: 'pass',
|
||||
gateBlockingCount: 0,
|
||||
gatePendingApprovals: 0,
|
||||
gateBlockingReasons: [],
|
||||
riskCriticalReachable: 0,
|
||||
riskHighReachable: 0,
|
||||
riskTrend: 'stable',
|
||||
riskTier: 'low',
|
||||
evidencePosture: 'verified',
|
||||
needsApproval: false,
|
||||
blocked: false,
|
||||
hotfixLane: false,
|
||||
replayMismatch: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: 'e2e-test',
|
||||
updatedAt: new Date().toISOString(),
|
||||
lastActor: 'e2e-test',
|
||||
deployedAt: null,
|
||||
deploymentStrategy: 'rolling',
|
||||
};
|
||||
|
||||
const MOCK_DEPLOYED_RELEASE = {
|
||||
...MOCK_RELEASE,
|
||||
status: 'deployed',
|
||||
currentEnvironment: 'staging',
|
||||
targetEnvironment: null,
|
||||
deployedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const MOCK_PROMOTION_APPROVAL = {
|
||||
id: 'apr-e2e-001',
|
||||
releaseId: 'rel-e2e-001',
|
||||
releaseName: 'E2E API Gateway',
|
||||
releaseVersion: '1.0.0-e2e',
|
||||
sourceEnvironment: 'staging',
|
||||
targetEnvironment: 'production',
|
||||
requestedBy: 'e2e-test',
|
||||
requestedAt: new Date().toISOString(),
|
||||
urgency: 'normal',
|
||||
justification: 'E2E workflow test promotion',
|
||||
status: 'pending',
|
||||
currentApprovals: 0,
|
||||
requiredApprovals: 1,
|
||||
gatesPassed: true,
|
||||
expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000).toISOString(),
|
||||
gateResults: [
|
||||
{ gateId: 'g1', gateName: 'Security Scan', type: 'security', status: 'passed', message: 'Clean', evaluatedAt: new Date().toISOString() },
|
||||
{ gateId: 'g2', gateName: 'Policy Compliance', type: 'policy', status: 'passed', message: 'OK', evaluatedAt: new Date().toISOString() },
|
||||
],
|
||||
releaseComponents: [
|
||||
{ name: 'api-gateway', version: '1.0.0-e2e', digest: 'sha256:e2etest123abc456def' },
|
||||
{ name: 'db-migrate', version: '1.0.0-e2e', digest: 'script:migrate-v1' },
|
||||
],
|
||||
};
|
||||
|
||||
async function setupWorkflowMocks(page: Page) {
|
||||
// Bundle creation
|
||||
await page.route('**/api/v1/release-control/bundles', (route: Route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(MOCK_BUNDLE) });
|
||||
} else {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [MOCK_BUNDLE] }) });
|
||||
}
|
||||
});
|
||||
|
||||
// Version publish
|
||||
await page.route('**/api/v1/release-control/bundles/*/versions', (route: Route) => {
|
||||
if (route.request().method() === 'POST') {
|
||||
route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify(MOCK_VERSION) });
|
||||
} else {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [MOCK_VERSION] }) });
|
||||
}
|
||||
});
|
||||
|
||||
// Version materialize (create release from version)
|
||||
await page.route('**/api/v1/release-control/bundles/*/versions/*/materialize', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_RELEASE) });
|
||||
});
|
||||
|
||||
// Registry image search
|
||||
await page.route('**/api/v1/registries/images/search**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
|
||||
items: [
|
||||
{ repository: 'stellaops/api-gateway', tag: 'v1.0.0', digest: 'sha256:e2etest123abc456def', size: 45_000_000, pushedAt: new Date().toISOString() },
|
||||
{ repository: 'stellaops/worker', tag: 'v1.0.0', digest: 'sha256:worker789ghi012jkl', size: 32_000_000, pushedAt: new Date().toISOString() },
|
||||
],
|
||||
}) });
|
||||
});
|
||||
|
||||
// Release detail
|
||||
await page.route('**/api/v2/releases/rel-e2e-*', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_RELEASE) });
|
||||
});
|
||||
await page.route('**/api/v1/releases/rel-e2e-*', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_RELEASE) });
|
||||
});
|
||||
|
||||
// Deploy
|
||||
await page.route('**/api/v1/releases/*/deploy', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_DEPLOYED_RELEASE) });
|
||||
});
|
||||
await page.route('**/api/release-orchestrator/releases/*/deploy', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_DEPLOYED_RELEASE) });
|
||||
});
|
||||
|
||||
// Promote
|
||||
await page.route('**/api/v1/release-orchestrator/releases/*/promote', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_PROMOTION_APPROVAL) });
|
||||
});
|
||||
await page.route('**/api/release-orchestrator/releases/*/promote', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(MOCK_PROMOTION_APPROVAL) });
|
||||
});
|
||||
|
||||
// Approval list (return our pending promotion)
|
||||
await page.route('**/api/release-orchestrator/approvals', (route: Route) => {
|
||||
if (route.request().method() === 'GET') {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([MOCK_PROMOTION_APPROVAL]) });
|
||||
} else {
|
||||
route.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// Approve
|
||||
await page.route('**/api/release-orchestrator/approvals/*/approve', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
|
||||
...MOCK_PROMOTION_APPROVAL,
|
||||
status: 'approved',
|
||||
currentApprovals: 1,
|
||||
}) });
|
||||
});
|
||||
|
||||
// Available environments for promotion
|
||||
await page.route('**/available-environments**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([
|
||||
{ id: 'env-staging', name: 'staging', displayName: 'Staging', isProduction: false },
|
||||
{ id: 'env-production', name: 'production', displayName: 'Production', isProduction: true },
|
||||
]) });
|
||||
});
|
||||
|
||||
// Promotion preview
|
||||
await page.route('**/promotion-preview**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
|
||||
allGatesPassed: true,
|
||||
requiredApprovers: 1,
|
||||
estimatedDeployTime: '5m',
|
||||
warnings: [],
|
||||
gateResults: [
|
||||
{ gateId: 'g1', gateName: 'Security', type: 'security', status: 'passed', message: 'Clean' },
|
||||
],
|
||||
}) });
|
||||
});
|
||||
|
||||
// Release components
|
||||
await page.route('**/releases/*/components', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
|
||||
items: MOCK_VERSION.components,
|
||||
}) });
|
||||
});
|
||||
|
||||
// Release events
|
||||
await page.route('**/releases/*/events', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [] }) });
|
||||
});
|
||||
|
||||
// Context APIs
|
||||
await page.route('**/api/v2/context/regions', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
|
||||
});
|
||||
await page.route('**/api/v2/context/preferences', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '{"regions":[],"environments":[]}' });
|
||||
});
|
||||
await page.route('**/api/v2/context/environments**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '[]' });
|
||||
});
|
||||
|
||||
// Releases list — must return ReleaseProjectionDto format (not ManagedRelease)
|
||||
const MOCK_PROJECTION = {
|
||||
releaseId: MOCK_RELEASE.id,
|
||||
slug: MOCK_RELEASE.slug,
|
||||
name: MOCK_RELEASE.name,
|
||||
releaseType: MOCK_RELEASE.releaseType,
|
||||
status: MOCK_RELEASE.status,
|
||||
targetEnvironment: MOCK_RELEASE.targetEnvironment,
|
||||
targetRegion: MOCK_RELEASE.targetRegion,
|
||||
totalVersions: MOCK_RELEASE.componentCount,
|
||||
latestVersionDigest: MOCK_RELEASE.digest,
|
||||
createdAt: MOCK_RELEASE.createdAt,
|
||||
updatedAt: MOCK_RELEASE.updatedAt,
|
||||
latestPublishedAt: MOCK_RELEASE.updatedAt,
|
||||
gate: {
|
||||
status: MOCK_RELEASE.gateStatus,
|
||||
blockingCount: MOCK_RELEASE.gateBlockingCount,
|
||||
pendingApprovals: MOCK_RELEASE.gatePendingApprovals,
|
||||
blockingReasons: [],
|
||||
},
|
||||
risk: {
|
||||
criticalReachable: 0,
|
||||
highReachable: 0,
|
||||
trend: 'stable',
|
||||
},
|
||||
};
|
||||
|
||||
await page.route('**/api/v2/releases**', (route: Route) => {
|
||||
const url = route.request().url();
|
||||
if (url.match(/releases\/rel-/)) {
|
||||
// Detail: return ReleaseDetailDto format
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ summary: MOCK_PROJECTION, recentActivity: [] }) });
|
||||
return;
|
||||
}
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
|
||||
items: [MOCK_PROJECTION],
|
||||
total: 1,
|
||||
count: 1,
|
||||
}) });
|
||||
});
|
||||
|
||||
// Activity feed
|
||||
await page.route('**/api/v2/releases/activity**', (route: Route) => {
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
|
||||
items: [
|
||||
{ activityId: 'act-e2e-1', releaseId: MOCK_RELEASE.id, releaseName: MOCK_RELEASE.name, eventType: 'deployed', status: 'deployed', targetEnvironment: 'staging', targetRegion: 'us-east', actorId: 'e2e-test', occurredAt: new Date().toISOString(), correlationKey: 'ck-e2e-1' },
|
||||
],
|
||||
count: 1,
|
||||
}) });
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('Release Workflow — Full E2E', () => {
|
||||
|
||||
test('Step 1: Releases page shows versions and releases panels', async ({ authenticatedPage: page }) => {
|
||||
await setupWorkflowMocks(page);
|
||||
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Both panels visible
|
||||
const panels = page.locator('.panel');
|
||||
expect(await panels.count()).toBe(2);
|
||||
|
||||
// Versions panel header
|
||||
await expect(page.locator('.panel').first().locator('h2')).toContainText('Versions');
|
||||
|
||||
// Releases panel header
|
||||
await expect(page.locator('.panel').nth(1).locator('h2')).toContainText('Releases');
|
||||
|
||||
// New Version button in header (the link, not the hint text)
|
||||
await expect(page.locator('a:has-text("New Version")')).toBeVisible();
|
||||
|
||||
await snap(page, '01-releases-dual-panel');
|
||||
});
|
||||
|
||||
test('Step 2: Version panel shows data with "+ Release" action', async ({ authenticatedPage: page }) => {
|
||||
await setupWorkflowMocks(page);
|
||||
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// The versions panel should contain version data
|
||||
const versionsPanel = page.locator('.panel').first();
|
||||
const panelText = await versionsPanel.innerText();
|
||||
|
||||
// Should show the version name from mock data
|
||||
expect(panelText).toContain('e2e');
|
||||
|
||||
// Should have a "+ Release" link somewhere in the panel
|
||||
const releaseLink = versionsPanel.locator('a:has-text("Release")');
|
||||
if (await releaseLink.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
const href = await releaseLink.getAttribute('href');
|
||||
expect(href).toContain('/releases/new');
|
||||
}
|
||||
|
||||
await snap(page, '02-version-panel');
|
||||
});
|
||||
|
||||
test('Step 3: Clicking version row highlights it and filters releases', async ({ authenticatedPage: page }) => {
|
||||
await setupWorkflowMocks(page);
|
||||
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Click the first clickable row in the versions panel
|
||||
const versionRow = page.locator('.ver-row').first();
|
||||
if (await versionRow.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await versionRow.click();
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Filter indicator should appear
|
||||
const filterBar = page.locator('.version-filter-bar');
|
||||
if (await filterBar.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await expect(filterBar).toContainText('Filtered by');
|
||||
}
|
||||
}
|
||||
|
||||
await snap(page, '03-version-filter');
|
||||
});
|
||||
|
||||
test('Step 4: Releases panel shows release data', async ({ authenticatedPage: page }) => {
|
||||
await setupWorkflowMocks(page);
|
||||
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// The releases panel (second panel) should show data
|
||||
const releasesPanel = page.locator('.panel').nth(1);
|
||||
const panelText = await releasesPanel.innerText();
|
||||
|
||||
// Should contain release info from the mock
|
||||
expect(panelText).toContain('E2E API Gateway');
|
||||
|
||||
// Should show gate/risk/evidence info
|
||||
expect(panelText.toLowerCase()).toMatch(/pass|warn|block|verified|partial|missing/);
|
||||
|
||||
await snap(page, '04-releases-panel');
|
||||
});
|
||||
|
||||
test('Step 5: Deployments page shows pipeline and approvals', async ({ authenticatedPage: page }) => {
|
||||
await setupWorkflowMocks(page);
|
||||
await page.goto('/releases/deployments', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Approvals panel should show our pending approval
|
||||
const approvalsPanel = page.locator('.panel--approvals');
|
||||
await expect(approvalsPanel).toBeVisible();
|
||||
|
||||
// Should show the E2E approval
|
||||
const approvalRow = page.locator('.apr-row').first();
|
||||
if (await approvalRow.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await expect(approvalRow).toContainText('E2E API Gateway');
|
||||
await expect(approvalRow).toContainText('staging');
|
||||
await expect(approvalRow).toContainText('production');
|
||||
|
||||
// Approve button should be visible
|
||||
const approveBtn = approvalRow.locator('button:has-text("Approve")');
|
||||
await expect(approveBtn).toBeVisible();
|
||||
}
|
||||
|
||||
// Pipeline panel should show activity
|
||||
const pipelinePanel = page.locator('.panel--pipeline');
|
||||
await expect(pipelinePanel).toBeVisible();
|
||||
|
||||
await snap(page, '05-deployments-page');
|
||||
});
|
||||
|
||||
test('Step 6: Filter toggles are interactive', async ({ authenticatedPage: page }) => {
|
||||
await setupWorkflowMocks(page);
|
||||
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Find and click a seg-btn filter
|
||||
const filterBtns = page.locator('.seg-btn');
|
||||
expect(await filterBtns.count()).toBeGreaterThan(0);
|
||||
|
||||
// Click "Pass" filter on versions panel
|
||||
const passBtn = page.locator('.panel').first().locator('.seg-btn:has-text("Pass")');
|
||||
if (await passBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await passBtn.click();
|
||||
await page.waitForTimeout(1500);
|
||||
// Should become active
|
||||
await expect(passBtn).toHaveClass(/seg-btn--active/);
|
||||
}
|
||||
|
||||
await snap(page, '06-filter-interactive');
|
||||
});
|
||||
|
||||
test('Step 7: Data loads and panels are populated', async ({ authenticatedPage: page }) => {
|
||||
await setupWorkflowMocks(page);
|
||||
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Both panels should have content (not empty state)
|
||||
const panels = page.locator('.panel');
|
||||
const firstPanelText = await panels.first().innerText();
|
||||
const secondPanelText = await panels.nth(1).innerText();
|
||||
|
||||
// At least one panel should have real data
|
||||
const hasData = firstPanelText.includes('e2e') || secondPanelText.includes('E2E');
|
||||
expect(hasData).toBe(true);
|
||||
|
||||
await snap(page, '07-data-loaded');
|
||||
});
|
||||
|
||||
test('Step 8: No "Create Deployment" on deployments page', async ({ authenticatedPage: page }) => {
|
||||
await setupWorkflowMocks(page);
|
||||
await page.goto('/releases/deployments', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// "Create Deployment" should NOT appear anywhere
|
||||
const body = await page.locator('body').innerText();
|
||||
expect(body).not.toContain('Create Deployment');
|
||||
|
||||
await snap(page, '08-no-create-deployment');
|
||||
});
|
||||
|
||||
test('Step 9: No critical Angular errors on releases page', async ({ authenticatedPage: page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('console', msg => { if (msg.type() === 'error') errors.push(msg.text()); });
|
||||
page.on('pageerror', err => errors.push(err.message));
|
||||
|
||||
await setupWorkflowMocks(page);
|
||||
|
||||
await page.goto('/releases', { waitUntil: 'networkidle', timeout: 30_000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const critical = errors.filter(e => e.includes('NG0') || e.includes('TypeError') || e.includes('ReferenceError'));
|
||||
expect(critical, 'Critical errors: ' + critical.join('\n')).toHaveLength(0);
|
||||
|
||||
await snap(page, '09-no-errors');
|
||||
});
|
||||
});
|
||||
@@ -32,8 +32,8 @@ export interface ApprovalApi {
|
||||
@Injectable()
|
||||
export class ApprovalHttpClient implements ApprovalApi {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly queueBaseUrl = '/api/v1/approvals';
|
||||
private readonly detailBaseUrl = '/api/v1/approvals';
|
||||
private readonly queueBaseUrl = '/api/release-orchestrator/approvals';
|
||||
private readonly detailBaseUrl = '/api/release-orchestrator/approvals';
|
||||
|
||||
listApprovals(filter?: ApprovalFilter): Observable<ApprovalRequest[]> {
|
||||
const params: Record<string, string> = {};
|
||||
@@ -78,20 +78,16 @@ export class ApprovalHttpClient implements ApprovalApi {
|
||||
}
|
||||
|
||||
approve(id: string, comment: string): Observable<ApprovalDetail> {
|
||||
return this.http.post<any>(`${this.detailBaseUrl}/${id}/decision`, {
|
||||
action: 'approve',
|
||||
return this.http.post<any>(`${this.detailBaseUrl}/${id}/approve`, {
|
||||
comment,
|
||||
actor: 'ui-operator',
|
||||
}).pipe(
|
||||
map(row => this.mapV2ApprovalDetail(row))
|
||||
);
|
||||
}
|
||||
|
||||
reject(id: string, comment: string): Observable<ApprovalDetail> {
|
||||
return this.http.post<any>(`${this.detailBaseUrl}/${id}/decision`, {
|
||||
action: 'reject',
|
||||
return this.http.post<any>(`${this.detailBaseUrl}/${id}/reject`, {
|
||||
comment,
|
||||
actor: 'ui-operator',
|
||||
}).pipe(
|
||||
map(row => this.mapV2ApprovalDetail(row))
|
||||
);
|
||||
|
||||
@@ -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)',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -92,6 +92,9 @@ interface PendingAction {
|
||||
<h1 class="board-title">Dashboard</h1>
|
||||
<p class="board-subtitle">{{ tenantLabel() }}</p>
|
||||
</div>
|
||||
<div class="header-quick-links">
|
||||
<stella-quick-links [links]="dashboardQuickLinks" label="Quick Links" layout="strip" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (!contextReady()) {
|
||||
@@ -161,11 +164,14 @@ interface PendingAction {
|
||||
<a routerLink="/setup/topology/environments" queryParamsHandling="merge" class="section-link">All environments</a>
|
||||
</div>
|
||||
|
||||
<div class="env-grid-wrapper" [class.can-scroll-left]="showPipelineLeftArrow()" [class.can-scroll-right]="showPipelineRightArrow()">
|
||||
<div class="env-grid-wrapper">
|
||||
@if (showPipelineLeftArrow()) {
|
||||
<div class="env-fade env-fade--left"></div>
|
||||
<button
|
||||
class="scroll-arrow scroll-arrow--left"
|
||||
(click)="scrollPipeline('left')"
|
||||
(mouseenter)="startPipelineAutoScroll('left')"
|
||||
(mouseleave)="stopPipelineAutoScroll()"
|
||||
(click)="jumpPipelineScroll('left')"
|
||||
aria-label="Scroll left"
|
||||
type="button"
|
||||
>
|
||||
@@ -249,9 +255,12 @@ interface PendingAction {
|
||||
}
|
||||
</div>
|
||||
@if (showPipelineRightArrow()) {
|
||||
<div class="env-fade env-fade--right"></div>
|
||||
<button
|
||||
class="scroll-arrow scroll-arrow--right"
|
||||
(click)="scrollPipeline('right')"
|
||||
(mouseenter)="startPipelineAutoScroll('right')"
|
||||
(mouseleave)="stopPipelineAutoScroll()"
|
||||
(click)="jumpPipelineScroll('right')"
|
||||
aria-label="Scroll right"
|
||||
type="button"
|
||||
>
|
||||
@@ -453,10 +462,6 @@ interface PendingAction {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 6. Quick Links (right aside) -->
|
||||
<aside class="dashboard-aside">
|
||||
<stella-quick-links [links]="dashboardQuickLinks" label="Quick Links" layout="aside" />
|
||||
</aside>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
@@ -472,15 +477,19 @@ interface PendingAction {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
/* -- Header (full width) ------------------------------------------------- */
|
||||
.board-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.header-identity {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.board-title {
|
||||
@@ -495,6 +504,11 @@ interface PendingAction {
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.header-quick-links {
|
||||
flex: 0 1 60%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
@@ -1114,13 +1128,6 @@ interface PendingAction {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.dashboard-aside {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.75rem 0;
|
||||
background: var(--color-surface-elevated);
|
||||
}
|
||||
|
||||
/* Status dot (reused in summary cards) */
|
||||
.status-dot {
|
||||
@@ -1141,32 +1148,26 @@ interface PendingAction {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
padding: 0.75rem 1rem 0;
|
||||
--_env-fade-color: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
/* Left gradient fade */
|
||||
.env-grid-wrapper.can-scroll-left::before {
|
||||
content: '';
|
||||
.env-fade {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 56px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.env-fade--left {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 56px;
|
||||
background: linear-gradient(to right, var(--color-surface-primary) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
background: linear-gradient(to right, var(--_env-fade-color) 30%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Right gradient fade */
|
||||
.env-grid-wrapper.can-scroll-right::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
.env-fade--right {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 56px;
|
||||
background: linear-gradient(to left, var(--color-surface-primary) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
background: linear-gradient(to left, var(--_env-fade-color) 30%, transparent 100%);
|
||||
}
|
||||
|
||||
.scroll-arrow {
|
||||
@@ -1382,7 +1383,7 @@ export class DashboardV3Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
readonly riskTableAtTop = signal(true);
|
||||
|
||||
readonly dashboardQuickLinks: StellaQuickLink[] = [
|
||||
{ label: 'Release Runs', route: '/releases/runs', description: 'Deployment timeline and run history' },
|
||||
{ label: 'Deployments', route: '/releases/deployments', description: 'Deployment timeline and run history' },
|
||||
{ label: 'Security & Risk', route: '/security', description: 'Posture, findings, and reachability' },
|
||||
{ label: 'Operations', route: '/ops/operations', description: 'Platform health and execution control' },
|
||||
{ label: 'Evidence', route: '/evidence', description: 'Decision capsules and audit trail' },
|
||||
@@ -1447,6 +1448,7 @@ export class DashboardV3Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
this.stopPipelineAutoScroll();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
@@ -1468,14 +1470,42 @@ export class DashboardV3Component implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.updatePipelineArrows();
|
||||
}
|
||||
|
||||
scrollPipeline(direction: 'left' | 'right'): void {
|
||||
// -- Pipeline auto-scroll (hover = animate, click = jump) ------------------
|
||||
private pipelineAutoRaf = 0;
|
||||
private pipelineAutoDir: 'left' | 'right' | null = null;
|
||||
|
||||
startPipelineAutoScroll(dir: 'left' | 'right'): void {
|
||||
this.pipelineAutoDir = dir;
|
||||
if (!this.pipelineAutoRaf) {
|
||||
this.ngZone.runOutsideAngular(() => this.tickPipeline());
|
||||
}
|
||||
}
|
||||
|
||||
stopPipelineAutoScroll(): void {
|
||||
this.pipelineAutoDir = null;
|
||||
if (this.pipelineAutoRaf) {
|
||||
cancelAnimationFrame(this.pipelineAutoRaf);
|
||||
this.pipelineAutoRaf = 0;
|
||||
}
|
||||
}
|
||||
|
||||
jumpPipelineScroll(dir: 'left' | 'right'): void {
|
||||
this.stopPipelineAutoScroll();
|
||||
const el = this.pipelineScrollRef?.nativeElement;
|
||||
if (!el) return;
|
||||
const scrollAmount = 300;
|
||||
el.scrollBy({
|
||||
left: direction === 'left' ? -scrollAmount : scrollAmount,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
el.scrollBy({ left: dir === 'right' ? 300 : -300, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
private tickPipeline = (): void => {
|
||||
const el = this.pipelineScrollRef?.nativeElement;
|
||||
if (!el || !this.pipelineAutoDir) { this.pipelineAutoRaf = 0; return; }
|
||||
el.scrollLeft += this.pipelineAutoDir === 'right' ? 2 : -2;
|
||||
this.pipelineAutoRaf = requestAnimationFrame(this.tickPipeline);
|
||||
};
|
||||
|
||||
/** @deprecated Use jumpPipelineScroll instead */
|
||||
scrollPipeline(direction: 'left' | 'right'): void {
|
||||
this.jumpPipelineScroll(direction);
|
||||
}
|
||||
|
||||
onRiskTableScroll(): void {
|
||||
|
||||
@@ -56,7 +56,7 @@ interface TocHeading {
|
||||
<ul>
|
||||
@for (heading of headings(); track heading.id) {
|
||||
<li [class]="'docs-viewer__toc-level-' + heading.level">
|
||||
<a [href]="'#' + heading.id">{{ heading.text }}</a>
|
||||
<a [href]="'#' + heading.id" [title]="heading.text">{{ heading.text }}</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -166,6 +166,8 @@ interface TocHeading {
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.9rem;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.docs-viewer__toc strong {
|
||||
@@ -181,9 +183,10 @@ interface TocHeading {
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.docs-viewer__toc li { margin: 0; }
|
||||
.docs-viewer__toc li { margin: 0; min-width: 0; }
|
||||
|
||||
.docs-viewer__toc a {
|
||||
color: var(--color-text-secondary);
|
||||
@@ -192,6 +195,9 @@ interface TocHeading {
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
padding: 0.1rem 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.docs-viewer__toc a:hover { color: var(--color-text-link); }
|
||||
@@ -383,9 +389,10 @@ export class DocsViewerComponent {
|
||||
const markdown = await firstValueFrom(this.http.get(candidate, { responseType: 'text' }));
|
||||
if (requestVersion !== this.requestVersion) return;
|
||||
|
||||
// Extract title from first H1
|
||||
// Extract title from first H1, strip inline markdown formatting
|
||||
const titleMatch = markdown.match(/^#\s+(.+)$/m);
|
||||
this.title.set(titleMatch?.[1]?.trim() ?? 'Documentation');
|
||||
const rawTitle = titleMatch?.[1]?.trim() ?? 'Documentation';
|
||||
this.title.set(rawTitle.replace(/\*{1,2}([^*]+)\*{1,2}/g, '$1').replace(/`([^`]+)`/g, '$1'));
|
||||
this.resolvedAssetPath.set(candidate);
|
||||
this.rawMarkdown.set(markdown);
|
||||
this.loading.set(false);
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -19,9 +19,6 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
|
||||
<div class="jobengine-dashboard">
|
||||
<header class="jobengine-dashboard__header">
|
||||
<div>
|
||||
<a [routerLink]="OPERATIONS_PATHS.jobsQueues" class="jobengine-dashboard__back">
|
||||
← Back to Jobs & Queues
|
||||
</a>
|
||||
<h1>Scheduled Jobs</h1>
|
||||
<p>Execution queues, quotas, dead-letter recovery, and scheduler handoffs.</p>
|
||||
</div>
|
||||
@@ -191,19 +188,6 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.jobengine-dashboard__back {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.65rem;
|
||||
color: var(--color-status-info);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
transition: color 150ms ease;
|
||||
}
|
||||
|
||||
.jobengine-dashboard__back:hover {
|
||||
filter: brightness(0.85);
|
||||
}
|
||||
|
||||
.jobengine-dashboard__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -15,9 +15,9 @@ import { RouterLink } from '@angular/router';
|
||||
|
||||
<div class="cards">
|
||||
<article>
|
||||
<h2>Release Runs</h2>
|
||||
<h2>Deployments</h2>
|
||||
<p>Latest standard and hotfix promotions with gate checkpoints.</p>
|
||||
<a routerLink="/releases/runs" queryParamsHandling="merge">Open Runs</a>
|
||||
<a routerLink="/releases/deployments" queryParamsHandling="merge">Open Deployments</a>
|
||||
</article>
|
||||
<article>
|
||||
<h2>Evidence</h2>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
|
||||
<div class="ops-overview__header-right">
|
||||
<stella-quick-links class="ops-overview__inline-links" [links]="quickNav" label="Jump to" layout="aside" />
|
||||
<stella-quick-links class="ops-overview__inline-links" [links]="quickNav" label="Quick Links" layout="strip" />
|
||||
<div class="ops-overview__actions">
|
||||
<a [routerLink]="OPERATIONS_PATHS.doctor">Run Doctor</a>
|
||||
<a routerLink="/evidence/audit-log">Audit Log</a>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -53,7 +53,7 @@ interface PromotionRule {
|
||||
</article>
|
||||
|
||||
<footer class="links">
|
||||
<a routerLink="/releases/runs">Open Release Runs</a>
|
||||
<a routerLink="/releases/deployments">Open Deployments</a>
|
||||
<a routerLink="/setup/topology/promotion-graph">Open Topology Path View</a>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { RouterLink } from '@angular/router';
|
||||
|
||||
<div class="doors">
|
||||
<a routerLink="/releases/versions" queryParamsHandling="merge">Release Versions</a>
|
||||
<a routerLink="/releases/runs" queryParamsHandling="merge">Release Runs</a>
|
||||
<a routerLink="/releases/deployments" queryParamsHandling="merge">Deployments</a>
|
||||
<a routerLink="/releases/approvals" queryParamsHandling="merge">Approvals Queue</a>
|
||||
<a routerLink="/releases/hotfixes" queryParamsHandling="merge">Hotfixes</a>
|
||||
<a routerLink="/releases/promotions" queryParamsHandling="merge">Promotions</a>
|
||||
|
||||
@@ -17,12 +17,9 @@ import type { ApprovalRequest, ApprovalDetail } from '../../core/api/approval.mo
|
||||
import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/confirm-dialog.component';
|
||||
import { ModalComponent } from '../../shared/components/modal/modal.component';
|
||||
|
||||
const VIEW_MODE_TABS: StellaPageTab[] = [
|
||||
{ id: 'timeline', label: 'Pipeline', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' },
|
||||
{ id: 'approvals', label: 'Approvals', icon: 'M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' },
|
||||
];
|
||||
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
|
||||
import { PageActionOutletComponent } from '../../shared/components/page-action-outlet/page-action-outlet.component';
|
||||
import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component';
|
||||
import { RelativeTimePipe } from '../../shared/pipes/format.pipes';
|
||||
interface ReleaseActivityProjection {
|
||||
activityId: string;
|
||||
releaseId: string;
|
||||
@@ -62,276 +59,239 @@ function deriveOutcomeIcon(status: string): string {
|
||||
@Component({
|
||||
selector: 'app-releases-activity',
|
||||
standalone: true,
|
||||
imports: [RouterLink, FormsModule, TimelineListComponent, StellaPageTabsComponent, StellaFilterChipComponent, PaginationComponent, PageActionOutletComponent, ConfirmDialogComponent, ModalComponent],
|
||||
imports: [RouterLink, FormsModule, TimelineListComponent, StellaFilterChipComponent, PaginationComponent, PageActionOutletComponent, ConfirmDialogComponent, ModalComponent, StatusBadgeComponent, RelativeTimePipe],
|
||||
template: `
|
||||
<section class="activity">
|
||||
<header>
|
||||
<h1>Deployments</h1>
|
||||
<p>Deployment runs, approvals, and promotion activity.</p>
|
||||
</header>
|
||||
|
||||
<!-- Pending approvals inline lane (only visible on Pipeline tab) -->
|
||||
@if (showPendingLane()) {
|
||||
<div class="pending-lane" [class.pending-lane--exiting]="pendingLaneExiting()">
|
||||
<div class="pending-lane__header">
|
||||
<h2 class="pending-lane__title">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
{{ pendingApprovals().length }} Pending Approval{{ pendingApprovals().length === 1 ? '' : 's' }}
|
||||
</h2>
|
||||
<button type="button" class="pending-lane__link" (click)="onTabChange('approvals')">
|
||||
View all <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="apc-lane">
|
||||
@for (apr of pendingApprovals().slice(0, 5); track apr.id) {
|
||||
<div class="apc" [class.apc--prod]="isProductionEnv(apr.targetEnvironment)" [class.apc--expiring]="isExpiringSoon(apr.expiresAt)">
|
||||
<div class="apc__head">
|
||||
<div class="apc__id"><span class="apc__name">{{ apr.releaseName }}</span>@if (apr.releaseVersion && apr.releaseVersion !== apr.releaseName) {<span class="apc__ver">{{ apr.releaseVersion }}</span>}</div>
|
||||
<span class="urgency-chip" [attr.data-urgency]="apr.urgency">{{ apr.urgency }}</span>
|
||||
</div>
|
||||
<div class="apc__envs">
|
||||
<span class="apc__env">{{ apr.sourceEnvironment }}</span>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="apc__arr"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
<span class="apc__env" [class.apc__env--prod]="isProductionEnv(apr.targetEnvironment)">{{ apr.targetEnvironment }}</span>
|
||||
</div>
|
||||
<div class="apc__meta">
|
||||
<span class="apc__who">by {{ apr.requestedBy }}</span>
|
||||
<span class="apc__gate" [class.apc__gate--pass]="apr.gatesPassed" [class.apc__gate--fail]="!apr.gatesPassed">{{ apr.gatesPassed ? 'Gates OK' : 'Gates fail' }}</span>
|
||||
<span class="apc__exp" [class.text-warning]="isExpiringSoon(apr.expiresAt)">{{ timeRemaining(apr.expiresAt) }}</span>
|
||||
</div>
|
||||
<div class="apc__btns">
|
||||
<button class="btn btn-primary btn--sm" (click)="onApprove(apr)" type="button">Approve</button>
|
||||
<button class="btn btn-danger btn--sm" (click)="onReject(apr)" type="button">Reject</button>
|
||||
<button class="btn btn-secondary btn--sm" (click)="onView(apr)" type="button">View</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="page-hdr">
|
||||
<div class="hdr-row">
|
||||
<div>
|
||||
<h1>Deployments</h1>
|
||||
<p class="page-sub">Deployment pipeline and approval queue.</p>
|
||||
</div>
|
||||
<app-page-action-outlet />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Approve production confirmation -->
|
||||
<!-- ── Dialogs (kept as-is) ── -->
|
||||
<app-confirm-dialog #approveConfirm title="Approve Production Deployment"
|
||||
[message]="'Approve ' + (activeApr()?.releaseName ?? '') + ' ' + (activeApr()?.releaseVersion ?? '') + ' to PRODUCTION?'"
|
||||
confirmLabel="Approve" cancelLabel="Cancel" variant="warning" (confirmed)="confirmApprove()" />
|
||||
|
||||
<!-- Reject dialog -->
|
||||
@if (showRejectDlg()) {
|
||||
<div class="dlg-overlay" (click)="cancelReject()" role="presentation">
|
||||
<div class="dlg-box" (click)="$event.stopPropagation()" role="alertdialog" aria-modal="true">
|
||||
<h2 class="dlg-box__title">Reject Release</h2>
|
||||
<p class="dlg-box__msg">Reject <strong>{{ activeApr()?.releaseName }}</strong> {{ activeApr()?.releaseVersion }} to <strong>{{ activeApr()?.targetEnvironment }}</strong>?</p>
|
||||
<label class="dlg-box__label" for="rejectReason">Reason (optional)</label>
|
||||
<textarea id="rejectReason" class="dlg-box__ta" [(ngModel)]="rejectReason" rows="3" placeholder="Why are you rejecting?"></textarea>
|
||||
<div class="dlg-box__actions">
|
||||
<button type="button" class="dlg-btn dlg-btn--cancel" (click)="cancelReject()">Cancel</button>
|
||||
<button type="button" class="dlg-btn dlg-btn--danger" (click)="confirmReject()">Reject</button>
|
||||
</div>
|
||||
@if (showRejectDlg()) {
|
||||
<div class="dlg-overlay" (click)="cancelReject()" role="presentation">
|
||||
<div class="dlg-box" (click)="$event.stopPropagation()" role="alertdialog" aria-modal="true">
|
||||
<h2 class="dlg-box__title">Reject Release</h2>
|
||||
<p class="dlg-box__msg">Reject <strong>{{ activeApr()?.releaseName }}</strong> {{ activeApr()?.releaseVersion }} to <strong>{{ activeApr()?.targetEnvironment }}</strong>?</p>
|
||||
<label class="dlg-box__label" for="rejectReason">Reason (optional)</label>
|
||||
<textarea id="rejectReason" class="dlg-box__ta" [(ngModel)]="rejectReason" rows="3" placeholder="Why are you rejecting?"></textarea>
|
||||
<div class="dlg-box__actions">
|
||||
<button type="button" class="dlg-btn dlg-btn--cancel" (click)="cancelReject()">Cancel</button>
|
||||
<button type="button" class="dlg-btn dlg-btn--danger" (click)="confirmReject()">Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Detail popup -->
|
||||
<app-modal [open]="showDetailDlg()" (closed)="closeDetail()" [title]="detailApr()?.releaseName ?? 'Release'" [description]="'Version ' + (detailApr()?.releaseVersion ?? '')" size="lg" iconVariant="info">
|
||||
@if (detailApr(); as d) {
|
||||
<div class="det">
|
||||
<div class="det__sec"><h3 class="det__h">Promotion</h3>
|
||||
<div class="det__r"><span class="det__l">From</span><span>{{ d.sourceEnvironment }}</span></div>
|
||||
<div class="det__r"><span class="det__l">To</span><span [class.det__prod]="isProductionEnv(d.targetEnvironment)">{{ d.targetEnvironment }}</span></div>
|
||||
<div class="det__r"><span class="det__l">Urgency</span><span>{{ d.urgency }}</span></div>
|
||||
<div class="det__r"><span class="det__l">Requested by</span><span>{{ d.requestedBy }}</span></div>
|
||||
<div class="det__r"><span class="det__l">Approvals</span><span>{{ d.currentApprovals }} / {{ d.requiredApprovals }}</span></div>
|
||||
</div>
|
||||
@if (d.justification) { <div class="det__sec"><h3 class="det__h">Justification</h3><p class="det__txt">{{ d.justification }}</p></div> }
|
||||
@if (detailFull()?.gateResults?.length) {
|
||||
<div class="det__sec"><h3 class="det__h">Gate Results</h3>
|
||||
@for (g of detailFull()!.gateResults; track g.gateId) {
|
||||
<div class="det__gate"><span class="gate-chip" [attr.data-gate]="g.status === 'passed' ? 'pass' : g.status">{{ g.status }}</span><strong>{{ g.gateName }}</strong><span class="det__gmsg">{{ g.message }}</span></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (detailFull()?.releaseComponents?.length) {
|
||||
<div class="det__sec"><h3 class="det__h">Components</h3>
|
||||
@for (c of detailFull()!.releaseComponents; track c.name) { <div class="det__r"><span class="det__l">{{ c.name }}</span><span class="det__mono">{{ c.version }}</span></div> }
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else { <div style="text-align:center;padding:2rem;color:var(--color-text-muted)">Loading...</div> }
|
||||
<div modal-footer><button class="dlg-btn dlg-btn--cancel" (click)="closeDetail()">Close</button></div>
|
||||
</app-modal>
|
||||
|
||||
<stella-page-tabs
|
||||
[tabs]="viewModeTabs"
|
||||
[activeTab]="viewMode()"
|
||||
urlParam="view"
|
||||
(tabChange)="onTabChange($any($event))"
|
||||
ariaLabel="Run list views"
|
||||
>
|
||||
<app-page-action-outlet tabBarAction />
|
||||
|
||||
@if (viewMode() === 'approvals') {
|
||||
<!-- Approvals tab content -->
|
||||
<div class="activity-filters">
|
||||
<div class="filter-search">
|
||||
<svg class="filter-search__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
|
||||
</svg>
|
||||
<input type="text" class="filter-search__input" placeholder="Search approvals..."
|
||||
[value]="approvalSearchQuery()" (input)="approvalSearchQuery.set($any($event.target).value)" />
|
||||
</div>
|
||||
@for (toggle of gateToggles; track toggle.id) {
|
||||
<button type="button"
|
||||
class="gate-toggle"
|
||||
[class.gate-toggle--active]="gateToggleState()[toggle.id]"
|
||||
(click)="toggleGate(toggle.id)">
|
||||
{{ toggle.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (approvalsLoading()) {
|
||||
<div class="approvals-skeleton">
|
||||
@for (i of [1,2,3]; track i) {
|
||||
<div class="skeleton-row">
|
||||
<div class="skeleton-cell skeleton-cell--wide"></div>
|
||||
<div class="skeleton-cell skeleton-cell--wide"></div>
|
||||
<div class="skeleton-cell skeleton-cell--sm"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (filteredApprovals().length === 0) {
|
||||
<div class="empty-state">No approvals match the active gate filters.</div>
|
||||
} @else {
|
||||
<!-- Summary cards -->
|
||||
<div class="gate-summary">
|
||||
<div class="gate-summary__card gate-summary__card--pending">
|
||||
<strong>{{ countByStatus('pending') }}</strong>
|
||||
<span>Pending Approvals</span>
|
||||
</div>
|
||||
<div class="gate-summary__card gate-summary__card--blocked">
|
||||
<strong>{{ countByGateResult(false) }}</strong>
|
||||
<span>Gate Failures</span>
|
||||
</div>
|
||||
<div class="gate-summary__card gate-summary__card--passed">
|
||||
<strong>{{ countByGateResult(true) }}</strong>
|
||||
<span>Gates Passed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Release</th>
|
||||
<th>Promotion</th>
|
||||
<th>Gate Type</th>
|
||||
<th>Status</th>
|
||||
<th>Reason</th>
|
||||
<th>Requested</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (apr of filteredApprovals(); track apr.id) {
|
||||
<tr [class.row--prod]="isProductionEnv(apr.targetEnvironment)">
|
||||
<td>
|
||||
<strong>{{ apr.releaseName }}</strong>
|
||||
@if (apr.releaseVersion && apr.releaseVersion !== apr.releaseName) {
|
||||
<br><span class="muted mono">{{ apr.releaseVersion }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ apr.sourceEnvironment }} → {{ apr.targetEnvironment }}</td>
|
||||
<td><span class="gate-type-chip" [attr.data-gate]="deriveGateType(apr)">{{ deriveGateType(apr) }}</span></td>
|
||||
<td><span class="status-chip" [attr.data-status]="apr.status">{{ apr.status }}</span></td>
|
||||
<td class="muted" [title]="apr.gatesPassed ? 'All gates passed' : 'One or more gates blocking'">
|
||||
{{ apr.gatesPassed ? 'All gates passed' : 'Gates blocking — review required' }}
|
||||
</td>
|
||||
<td class="muted">{{ formatDate(apr.requestedAt) }}<br>by {{ apr.requestedBy }}</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
@if (apr.status === 'pending') {
|
||||
<button class="btn btn-primary btn--sm" (click)="onApprove(apr)" type="button">Approve</button>
|
||||
<button class="btn btn-danger btn--sm" (click)="onReject(apr)" type="button">Reject</button>
|
||||
}
|
||||
@if (!apr.gatesPassed) {
|
||||
<a class="btn btn-secondary btn--sm" routerLink="/ops/policy/packs">Policy</a>
|
||||
}
|
||||
<button class="btn btn-secondary btn--sm" (click)="onView(apr)" type="button">View</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="7" style="text-align:center;padding:1.5rem;color:var(--color-text-muted)">No approvals or gate evaluations match the current filters.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
} @else {
|
||||
<!-- Existing deployment views: filters + timeline/table/correlations -->
|
||||
<div class="activity-filters">
|
||||
<div class="filter-search">
|
||||
<svg class="filter-search__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
|
||||
</svg>
|
||||
<input type="text" class="filter-search__input" placeholder="Search activity..."
|
||||
[value]="searchQuery()" (input)="searchQuery.set($any($event.target).value); currentPage.set(1)" />
|
||||
</div>
|
||||
<stella-filter-chip label="Status" [value]="statusFilter()" [options]="statusChipOptions" (valueChange)="statusFilter.set($event); currentPage.set(1); applyFilters()" />
|
||||
<stella-filter-chip label="Env" [value]="envFilter()" [options]="envChipOptions" (valueChange)="envFilter.set($event); currentPage.set(1); applyFilters()" />
|
||||
<stella-filter-chip label="Outcome" [value]="outcomeFilter()" [options]="outcomeChipOptions" (valueChange)="outcomeFilter.set($event); currentPage.set(1); applyFilters()" />
|
||||
</div>
|
||||
|
||||
@if (error()) {
|
||||
<div class="banner error">{{ error() }}</div>
|
||||
}
|
||||
|
||||
@if (loading()) {
|
||||
<div class="banner">Loading release runs...</div>
|
||||
} @else {
|
||||
@switch (viewMode()) {
|
||||
@case ('timeline') {
|
||||
<!-- Canonical timeline rendering -->
|
||||
<div class="timeline-container">
|
||||
<app-timeline-list
|
||||
[events]="timelineEvents()"
|
||||
[loading]="loading()"
|
||||
[groupByDate]="true"
|
||||
emptyMessage="No runs match the active filters."
|
||||
ariaLabel="Release activity timeline"
|
||||
>
|
||||
<ng-template #eventContent let-event>
|
||||
@if (event.metadata) {
|
||||
<div class="run-meta">
|
||||
@if (event.metadata['lane']) {
|
||||
<span class="run-chip">{{ event.metadata['lane'] }}</span>
|
||||
}
|
||||
@if (event.metadata['environment']) {
|
||||
<span class="run-chip">{{ event.metadata['environment'] }}</span>
|
||||
}
|
||||
@if (event.metadata['outcome']) {
|
||||
<span class="run-chip run-chip--outcome" [attr.data-outcome]="event.metadata['outcome']">{{ event.metadata['outcome'] }}</span>
|
||||
}
|
||||
@if (event.evidenceLink) {
|
||||
<a class="run-link" [routerLink]="event.evidenceLink">View run</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-timeline-list>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</stella-page-tabs>
|
||||
|
||||
<app-modal [open]="showDetailDlg()" (closed)="closeDetail()" [title]="detailApr()?.releaseName ?? 'Release'" [description]="'Version ' + (detailApr()?.releaseVersion ?? '')" size="lg" iconVariant="info">
|
||||
@if (detailApr(); as d) {
|
||||
<div class="det">
|
||||
<div class="det__sec"><h3 class="det__h">Promotion</h3>
|
||||
<div class="det__r"><span class="det__l">From</span><span>{{ d.sourceEnvironment }}</span></div>
|
||||
<div class="det__r"><span class="det__l">To</span><span [class.det__prod]="isProductionEnv(d.targetEnvironment)">{{ d.targetEnvironment }}</span></div>
|
||||
<div class="det__r"><span class="det__l">Urgency</span><span>{{ d.urgency }}</span></div>
|
||||
<div class="det__r"><span class="det__l">Requested by</span><span>{{ d.requestedBy }}</span></div>
|
||||
<div class="det__r"><span class="det__l">Approvals</span><span>{{ d.currentApprovals }} / {{ d.requiredApprovals }}</span></div>
|
||||
</div>
|
||||
@if (d.justification) { <div class="det__sec"><h3 class="det__h">Justification</h3><p class="det__txt">{{ d.justification }}</p></div> }
|
||||
@if (detailFull()?.gateResults?.length) {
|
||||
<div class="det__sec"><h3 class="det__h">Gate Results</h3>
|
||||
@for (g of detailFull()!.gateResults; track g.gateId) {
|
||||
<div class="det__gate"><span class="gate-chip" [attr.data-gate]="g.status === 'passed' ? 'pass' : g.status">{{ g.status }}</span><strong>{{ g.gateName }}</strong><span class="det__gmsg">{{ g.message }}</span></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (detailFull()?.releaseComponents?.length) {
|
||||
<div class="det__sec"><h3 class="det__h">Components</h3>
|
||||
@for (c of detailFull()!.releaseComponents; track c.name) { <div class="det__r"><span class="det__l">{{ c.name }}</span><span class="det__mono">{{ c.version }}</span></div> }
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else { <div style="text-align:center;padding:2rem;color:var(--color-text-muted)">Loading...</div> }
|
||||
<div modal-footer><button class="dlg-btn dlg-btn--cancel" (click)="closeDetail()">Close</button></div>
|
||||
</app-modal>
|
||||
|
||||
<!-- ═══════ TWO-PANEL LAYOUT: Approvals first ═══════ -->
|
||||
<div class="dual-panel">
|
||||
|
||||
<!-- ── LEFT: Approvals Queue ── -->
|
||||
<div class="panel panel--approvals">
|
||||
<div class="panel__head">
|
||||
<h2>Approvals @if (pendingApprovals().length > 0) { <span class="badge badge--warn">{{ pendingApprovals().length }}</span> }</h2>
|
||||
<div class="head-toggles">
|
||||
<div class="seg-group">
|
||||
<button class="seg-btn" [class.seg-btn--active]="approvalStatusFilter() === 'pending'" (click)="switchApprovalMode('pending')">Action Required</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="approvalStatusFilter() === 'all'" (click)="switchApprovalMode('all')">All</button>
|
||||
</div>
|
||||
<div class="seg-group">
|
||||
<button class="seg-btn" [class.seg-btn--active]="gateFilter() === 'all'" (click)="switchGateFilter('all')">All Gates</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="gateFilter() === 'blocked'" (click)="switchGateFilter('blocked')">Blocked</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="gateFilter() === 'passed'" (click)="switchGateFilter('passed')">Passed</button>
|
||||
</div>
|
||||
<button class="sort-btn" (click)="toggleApprovalSort()" title="Sort by requested date">
|
||||
{{ approvalSortAsc() ? '↑' : '↓' }} Date
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (pendingLoading() || approvalsLoading()) {
|
||||
<div class="skeleton-list">
|
||||
@for (i of [1,2,3]; track i) {
|
||||
<div class="skeleton-row"><div class="skeleton-cell skeleton-cell--wide"></div><div class="skeleton-cell skeleton-cell--wide"></div><div class="skeleton-cell skeleton-cell--sm"></div></div>
|
||||
}
|
||||
</div>
|
||||
} @else if (pagedApprovals().length === 0) {
|
||||
<div class="empty-state">
|
||||
@if (approvalStatusFilter() === 'pending') {
|
||||
<p>No pending approvals — all caught up.</p>
|
||||
<p class="empty-hint">Approvals appear here when a release needs manual sign-off before promotion.</p>
|
||||
} @else {
|
||||
<p>No historical approvals match the current filters.</p>
|
||||
<p class="empty-hint">Try adjusting the gate or status filters above.</p>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="apr-list">
|
||||
@for (apr of pagedApprovals(); track apr.id) {
|
||||
<div class="apr-row" [class.apr-row--prod]="isProductionEnv(apr.targetEnvironment)" [class.apr-row--expiring]="isExpiringSoon(apr.expiresAt)">
|
||||
<div class="apr-row__main">
|
||||
<div class="apr-row__release">
|
||||
<strong>{{ apr.releaseName }}</strong>
|
||||
@if (apr.releaseVersion && apr.releaseVersion !== apr.releaseName) {
|
||||
<span class="mono muted">{{ apr.releaseVersion }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="apr-row__flow">
|
||||
<span>{{ apr.sourceEnvironment }}</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||
<span [class.env--prod]="isProductionEnv(apr.targetEnvironment)">{{ apr.targetEnvironment }}</span>
|
||||
</div>
|
||||
<div class="apr-row__meta">
|
||||
<span class="urgency-chip" [attr.data-urgency]="apr.urgency">{{ apr.urgency }}</span>
|
||||
<span class="status-chip" [attr.data-status]="apr.status">{{ apr.status }}</span>
|
||||
<span class="muted">{{ apr.requestedAt | relativeTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="apr-row__actions">
|
||||
@if (apr.status === 'pending') {
|
||||
<button class="btn btn-primary btn--sm" (click)="onApprove(apr)" type="button">Approve</button>
|
||||
<button class="btn btn-danger btn--sm" (click)="onReject(apr)" type="button">Reject</button>
|
||||
}
|
||||
<button class="btn btn-secondary btn--sm" (click)="onView(apr)" type="button">View</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (sortedApprovals().length > approvalPageSize) {
|
||||
<app-pagination [total]="sortedApprovals().length" [pageSize]="approvalPageSize" [currentPage]="approvalPage()" (pageChange)="approvalPage.set($event.page)" [compact]="true" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── RIGHT: Pipeline ── -->
|
||||
<div class="panel panel--pipeline">
|
||||
<div class="panel__head">
|
||||
<h2>Pipeline</h2>
|
||||
<div class="head-toggles">
|
||||
<div class="seg-group">
|
||||
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === ''" (click)="statusFilter.set(''); currentPage.set(1); load()">All</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === 'pending_approval'" (click)="statusFilter.set('pending_approval'); currentPage.set(1); load()">Pending</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === 'approved' || statusFilter() === 'published'" (click)="statusFilter.set('published'); currentPage.set(1); load()">Published</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === 'blocked' || statusFilter() === 'rejected'" (click)="statusFilter.set('blocked'); currentPage.set(1); load()">Blocked</button>
|
||||
</div>
|
||||
<div class="seg-group">
|
||||
<button class="seg-btn" [class.seg-btn--active]="outcomeFilter() === ''" (click)="outcomeFilter.set(''); currentPage.set(1); load()">All</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="outcomeFilter() === 'success'" (click)="outcomeFilter.set('success'); currentPage.set(1); load()">Success</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="outcomeFilter() === 'failed'" (click)="outcomeFilter.set('failed'); currentPage.set(1); load()">Failed</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (error()) { <div class="banner error">{{ error() }}</div> }
|
||||
|
||||
@if (loading()) {
|
||||
<div class="skeleton-list">
|
||||
@for (i of [1,2,3,4,5]; track i) {
|
||||
<div class="skeleton-row"><div class="skeleton-cell skeleton-cell--xs"></div><div class="skeleton-cell skeleton-cell--wide"></div><div class="skeleton-cell skeleton-cell--sm"></div></div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="timeline-container">
|
||||
<app-timeline-list
|
||||
[events]="pagedTimelineEvents()"
|
||||
[loading]="loading()"
|
||||
[groupByDate]="true"
|
||||
emptyMessage="No deployment runs yet. Create a deployment to see pipeline activity here."
|
||||
ariaLabel="Release activity timeline"
|
||||
>
|
||||
<ng-template #eventContent let-event>
|
||||
@if (event.metadata) {
|
||||
<div class="run-meta">
|
||||
@if (event.metadata['lane']) { <span class="run-chip">{{ event.metadata['lane'] }}</span> }
|
||||
@if (event.metadata['environment']) { <span class="run-chip">{{ event.metadata['environment'] }}</span> }
|
||||
@if (event.metadata['outcome']) { <span class="run-chip run-chip--outcome" [attr.data-outcome]="event.metadata['outcome']">{{ event.metadata['outcome'] }}</span> }
|
||||
@if (event.evidenceLink) { <a class="run-link" [routerLink]="event.evidenceLink">View run</a> }
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-timeline-list>
|
||||
</div>
|
||||
@if (timelineEvents().length > pipelinePageSize) {
|
||||
<app-pagination [total]="timelineEvents().length" [pageSize]="pipelinePageSize" [currentPage]="currentPage()" (pageChange)="currentPage.set($event.page)" [compact]="true" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.activity{display:grid;gap:.6rem}.activity header h1{margin:0}.activity header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem}
|
||||
.activity{display:grid;gap:.6rem}
|
||||
.hdr-row{display:flex;justify-content:space-between;align-items:flex-start;gap:.5rem;flex-wrap:wrap}
|
||||
.page-hdr h1{margin:0}.page-sub{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem}
|
||||
|
||||
/* ── Dual-panel responsive layout ── */
|
||||
.dual-panel{display:grid;grid-template-columns:repeat(auto-fit,minmax(500px,1fr));gap:.65rem;align-items:start}
|
||||
.panel{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary);padding:.65rem;display:grid;gap:.5rem}
|
||||
.panel__head{display:flex;justify-content:space-between;align-items:center;padding-bottom:.35rem;border-bottom:1px solid var(--color-border-primary)}
|
||||
.panel__head h2{margin:0;font-size:.88rem;font-weight:600;display:flex;align-items:center;gap:.35rem}
|
||||
.badge{font-size:.6rem;padding:.1rem .35rem;border-radius:var(--radius-full);font-weight:700}
|
||||
.badge--warn{background:var(--color-status-warning-bg);color:var(--color-status-warning-text)}
|
||||
|
||||
/* ── Title bar toggles ── */
|
||||
.head-toggles{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap}
|
||||
.seg-group{display:inline-flex;border:1px solid var(--color-border-primary);border-radius:var(--radius-sm);overflow:hidden}
|
||||
.sort-btn{padding:.2rem .45rem;border:1px solid var(--color-border-primary);border-radius:var(--radius-sm);background:transparent;font-size:.66rem;font-weight:500;color:var(--color-text-muted);cursor:pointer;transition:all 150ms}
|
||||
.sort-btn:hover{color:var(--color-text-primary);border-color:var(--color-border-emphasis)}
|
||||
.seg-btn{padding:.2rem .5rem;border:none;background:transparent;font-size:.68rem;font-weight:500;color:var(--color-text-muted);cursor:pointer;transition:all 150ms}
|
||||
.seg-btn:hover:not(.seg-btn--active){color:var(--color-text-secondary);background:var(--color-surface-tertiary,rgba(0,0,0,.04))}
|
||||
.seg-btn--active{background:var(--color-surface-tertiary,rgba(0,0,0,.04));color:var(--color-text-primary);font-weight:600}
|
||||
|
||||
/* ── Approval row cards ── */
|
||||
.apr-list{display:grid;gap:.35rem}
|
||||
.apr-row{display:flex;justify-content:space-between;align-items:center;gap:.5rem;padding:.45rem .55rem;border:1px solid var(--color-border-primary);border-radius:var(--radius-sm);transition:background 120ms}
|
||||
.apr-row:hover{background:var(--color-surface-secondary)}
|
||||
.apr-row--prod{border-left:3px solid var(--color-status-warning)}
|
||||
.apr-row--expiring{border-left:3px solid var(--color-severity-high,#c2410c)}
|
||||
.apr-row__main{display:grid;gap:.2rem;min-width:0;flex:1}
|
||||
.apr-row__release{display:flex;align-items:center;gap:.35rem;font-size:.78rem}
|
||||
.apr-row__release strong{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.apr-row__flow{display:flex;align-items:center;gap:.25rem;font-size:.68rem;color:var(--color-text-secondary)}
|
||||
.env--prod{color:var(--color-status-warning-text);font-weight:600}
|
||||
.apr-row__meta{display:flex;align-items:center;gap:.25rem;flex-wrap:wrap}
|
||||
.apr-row__actions{display:flex;gap:.25rem;align-items:center;flex-shrink:0}
|
||||
.context{display:flex;gap:.35rem;flex-wrap:wrap}.context span{border:1px solid var(--color-border-primary);border-radius:var(--radius-full);padding:.1rem .45rem;font-size:.7rem;color:var(--color-text-secondary)}
|
||||
|
||||
/* Approvals skeleton loading */
|
||||
/* Skeleton loading (shared by both panels) */
|
||||
.skeleton-list{display:flex;flex-direction:column;gap:.5rem;padding:.5rem 0}
|
||||
.approvals-skeleton{display:flex;flex-direction:column;gap:.5rem;padding:.75rem 0}
|
||||
.skeleton-row{display:flex;gap:.75rem;align-items:center}
|
||||
.skeleton-cell{height:12px;border-radius:var(--radius-sm);background:var(--color-surface-tertiary);animation:skeleton-pulse 1.2s ease-in-out infinite}
|
||||
@@ -433,7 +393,9 @@ function deriveOutcomeIcon(status: string): string {
|
||||
.pending-lane__link{display:inline-flex;align-items:center;gap:.25rem;background:none;border:none;font-size:.8rem;font-weight:500;color:var(--color-status-warning-text);cursor:pointer;text-decoration:underline;text-underline-offset:2px}
|
||||
.pending-lane__link:hover{color:var(--color-text-primary)}
|
||||
|
||||
.empty-state{text-align:center;padding:2.5rem;color:var(--color-text-muted);border:1px dashed var(--color-border-primary);border-radius:var(--radius-lg);margin-top:.5rem}
|
||||
.empty-state{text-align:center;padding:1.5rem;color:var(--color-text-muted);border:1px dashed var(--color-border-primary);border-radius:var(--radius-lg)}
|
||||
.empty-state p{margin:.15rem 0}
|
||||
.empty-hint{font-size:.72rem;color:var(--color-text-muted);opacity:.7}
|
||||
|
||||
.apc__btns{display:flex;border-top:1px solid var(--color-border-primary)}
|
||||
.apc__btns .btn{flex:1;justify-content:center;border-radius:0;border:none;border-right:1px solid var(--color-border-primary)}
|
||||
@@ -519,23 +481,29 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
|
||||
private readonly approvalApi = inject(APPROVAL_API);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.pageAction.set({ label: 'Create Deployment', route: '/releases/deployments/new' });
|
||||
// Deployments are created by release start/promotion, not directly
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
readonly viewModeTabs = VIEW_MODE_TABS;
|
||||
readonly loading = signal(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly rows = signal<ReleaseActivityProjection[]>([]);
|
||||
readonly viewMode = signal<'timeline' | 'approvals'>('timeline');
|
||||
readonly viewMode = signal<'timeline'>('timeline');
|
||||
readonly approvalStatusFilter = signal<'pending' | 'all'>('pending');
|
||||
readonly gateFilter = signal<'all' | 'blocked' | 'passed'>('all');
|
||||
readonly approvalSortAsc = signal(false);
|
||||
readonly approvalPage = signal(1);
|
||||
readonly approvalPageSize = 10;
|
||||
readonly pipelinePageSize = 15;
|
||||
|
||||
// ── Pending approvals card lane ──────────────────────────────────
|
||||
@ViewChild('apcScroll') apcScrollRef?: ElementRef<HTMLDivElement>;
|
||||
@ViewChild('approveConfirm') approveConfirmRef!: ConfirmDialogComponent;
|
||||
readonly pendingApprovals = signal<ApprovalRequest[]>([]);
|
||||
readonly pendingLoading = signal(false);
|
||||
readonly pendingBannerCollapsed = signal(false);
|
||||
readonly showApcLeft = signal(false);
|
||||
readonly showApcRight = signal(false);
|
||||
@@ -549,7 +517,6 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
|
||||
// ── Approvals tab state ───────────────────────────────────────────
|
||||
readonly allApprovals = signal<ApprovalRequest[]>([]);
|
||||
readonly approvalsLoading = signal(false);
|
||||
private _approvalsFetched = false;
|
||||
|
||||
readonly gateToggles = [
|
||||
{ id: 'gated', label: 'Gated' },
|
||||
@@ -595,6 +562,76 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
|
||||
return result;
|
||||
});
|
||||
|
||||
/** Approvals after status + gate filtering */
|
||||
readonly displayedApprovals = computed(() => {
|
||||
const statusF = this.approvalStatusFilter();
|
||||
const gateF = this.gateFilter();
|
||||
let result: ApprovalRequest[];
|
||||
|
||||
if (statusF === 'pending') {
|
||||
result = [...this.pendingApprovals()];
|
||||
} else {
|
||||
result = [...this.filteredApprovals()];
|
||||
}
|
||||
|
||||
// Gate filter
|
||||
if (gateF === 'blocked') result = result.filter(a => !a.gatesPassed);
|
||||
if (gateF === 'passed') result = result.filter(a => a.gatesPassed);
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
/** Sorted approvals */
|
||||
readonly sortedApprovals = computed(() => {
|
||||
const data = [...this.displayedApprovals()];
|
||||
const asc = this.approvalSortAsc();
|
||||
data.sort((a, b) => {
|
||||
const cmp = a.requestedAt.localeCompare(b.requestedAt);
|
||||
return asc ? cmp : -cmp;
|
||||
});
|
||||
return data;
|
||||
});
|
||||
|
||||
/** Paged approvals */
|
||||
readonly pagedApprovals = computed(() => {
|
||||
const all = this.sortedApprovals();
|
||||
const start = (this.approvalPage() - 1) * this.approvalPageSize;
|
||||
return all.slice(start, start + this.approvalPageSize);
|
||||
});
|
||||
|
||||
/** Paged timeline events */
|
||||
readonly pagedTimelineEvents = computed(() => {
|
||||
const all = this.timelineEvents();
|
||||
const start = (this.currentPage() - 1) * this.pipelinePageSize;
|
||||
return all.slice(start, start + this.pipelinePageSize);
|
||||
});
|
||||
|
||||
toggleApprovalSort(): void {
|
||||
this.approvalSortAsc.update(v => !v);
|
||||
this.approvalPage.set(1);
|
||||
this.reloadCurrentApprovals();
|
||||
}
|
||||
|
||||
switchApprovalMode(mode: 'pending' | 'all'): void {
|
||||
this.approvalStatusFilter.set(mode);
|
||||
this.approvalPage.set(1);
|
||||
this.reloadCurrentApprovals();
|
||||
}
|
||||
|
||||
switchGateFilter(gate: 'all' | 'blocked' | 'passed'): void {
|
||||
this.gateFilter.set(gate);
|
||||
this.approvalPage.set(1);
|
||||
this.reloadCurrentApprovals();
|
||||
}
|
||||
|
||||
private reloadCurrentApprovals(): void {
|
||||
if (this.approvalStatusFilter() === 'pending') {
|
||||
this.loadPendingApprovals();
|
||||
} else {
|
||||
this.loadApprovals();
|
||||
}
|
||||
}
|
||||
|
||||
// Lane filter reads from global context (header toggle)
|
||||
|
||||
// ── Filter-chip options ──────────────────────────────────────────────
|
||||
@@ -720,26 +757,24 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
this.route.queryParamMap.subscribe((params) => {
|
||||
const view = (params.get('view') ?? '').toLowerCase();
|
||||
if (view && (view === 'timeline' || view === 'approvals')) {
|
||||
if (this.viewMode() !== view) {
|
||||
this.viewMode.set(view);
|
||||
if (view === 'approvals') this.loadApprovals();
|
||||
}
|
||||
}
|
||||
|
||||
if (params.get('status')) this.statusFilter.set(params.get('status')!);
|
||||
if (params.get('lane')) this.context.setReleaseLane(params.get('lane') as 'standard' | 'hotfix');
|
||||
if (params.get('env')) this.envFilter.set(params.get('env')!);
|
||||
if (params.get('outcome')) this.outcomeFilter.set(params.get('outcome')!);
|
||||
});
|
||||
|
||||
// Load pending approvals for the banner
|
||||
// Load pending approvals
|
||||
this.loadPendingApprovals();
|
||||
|
||||
// Load pipeline data once on init, then only on explicit context changes
|
||||
this.load();
|
||||
let lastCtxVersion = this.context.contextVersion();
|
||||
effect(() => {
|
||||
this.context.contextVersion();
|
||||
this.load();
|
||||
const v = this.context.contextVersion();
|
||||
if (v !== lastCtxVersion) {
|
||||
lastCtxVersion = v;
|
||||
this.load();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -797,33 +832,7 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
|
||||
return parsed.toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
readonly pendingLaneExiting = signal(false);
|
||||
readonly showPendingLane = computed(() =>
|
||||
this.pendingApprovals().length > 0 && this.viewMode() === 'timeline' && !this.pendingLaneHidden()
|
||||
);
|
||||
private readonly pendingLaneHidden = signal(false);
|
||||
|
||||
onTabChange(tab: string): void {
|
||||
// Animate pending lane out before switching to approvals
|
||||
if (tab === 'approvals' && this.pendingApprovals().length > 0 && this.viewMode() === 'timeline') {
|
||||
this.pendingLaneExiting.set(true);
|
||||
setTimeout(() => {
|
||||
this.pendingLaneHidden.set(true);
|
||||
this.pendingLaneExiting.set(false);
|
||||
this.viewMode.set('approvals');
|
||||
this.loadApprovals();
|
||||
this.applyFilters();
|
||||
}, 250);
|
||||
return;
|
||||
}
|
||||
// When switching back to pipeline, show pending lane again
|
||||
if (tab === 'timeline') {
|
||||
this.pendingLaneHidden.set(false);
|
||||
}
|
||||
this.viewMode.set(tab as 'timeline' | 'approvals');
|
||||
if (tab === 'approvals') this.loadApprovals();
|
||||
this.applyFilters();
|
||||
}
|
||||
// Tab switching removed — both panels visible simultaneously
|
||||
|
||||
toggleGate(id: string): void {
|
||||
const current = this.gateToggleState();
|
||||
@@ -914,15 +923,14 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
|
||||
closeDetail(): void { this.showDetailDlg.set(false); this.detailApr.set(null); this.detailFull.set(null); }
|
||||
|
||||
private loadPendingApprovals(): void {
|
||||
this.pendingLoading.set(true);
|
||||
this.approvalApi.listApprovals({ statuses: ['pending'] }).pipe(take(1)).subscribe({
|
||||
next: (approvals) => { this.pendingApprovals.set(approvals); setTimeout(() => this.updateApcArrows(), 150); },
|
||||
error: () => this.pendingApprovals.set([]),
|
||||
next: (approvals) => { this.pendingApprovals.set(approvals); this.pendingLoading.set(false); },
|
||||
error: () => { this.pendingApprovals.set([]); this.pendingLoading.set(false); },
|
||||
});
|
||||
}
|
||||
|
||||
private loadApprovals(): void {
|
||||
if (this._approvalsFetched) return;
|
||||
this._approvalsFetched = true;
|
||||
loadApprovals(): void {
|
||||
this.approvalsLoading.set(true);
|
||||
this.approvalApi.listApprovals().pipe(take(1)).subscribe({
|
||||
next: (approvals) => {
|
||||
@@ -936,7 +944,7 @@ export class ReleasesActivityComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
load(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
|
||||
@@ -1,38 +1,29 @@
|
||||
/**
|
||||
* Releases Unified Page Component
|
||||
* Releases Unified Page — Dual-Panel Layout
|
||||
*
|
||||
* Combines release versions, deployments, hotfixes, and approvals into a single
|
||||
* tabbed interface with decision capsules on each release row.
|
||||
* Left panel: Versions (sealed artifact catalog)
|
||||
* Right panel: Releases (plans for deployment, filtered by selected version)
|
||||
*
|
||||
* Tab 1 "Pipeline": unified release table (standard + hotfix) with contextual actions.
|
||||
* Tab 2 "Approvals": embeds the existing ApprovalQueueComponent.
|
||||
* Clicking a version row filters the releases panel. "+ Release" on a version
|
||||
* row navigates to create a release from that version.
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy, ViewChild, effect, inject, signal, computed } from '@angular/core';
|
||||
import { PageActionService } from '../../core/services/page-action.service';
|
||||
import { PageActionOutletComponent } from '../../shared/components/page-action-outlet/page-action-outlet.component';
|
||||
import { UpperCasePipe, SlicePipe } from '@angular/common';
|
||||
import { RouterLink, ActivatedRoute } from '@angular/router';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { take } from 'rxjs';
|
||||
import { APPROVAL_API } from '../../core/api/approval.client';
|
||||
import type { ApprovalApi } from '../../core/api/approval.client';
|
||||
import { ConfirmDialogComponent } from '../../shared/components/confirm-dialog/confirm-dialog.component';
|
||||
import { StellaFilterChipComponent, FilterChipOption } from '../../shared/components/stella-filter-chip/stella-filter-chip.component';
|
||||
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
|
||||
import { PaginationComponent, PageChangeEvent } from '../../shared/components/pagination/pagination.component';
|
||||
import { TableColumn } from '../../shared/components/data-table/data-table.component';
|
||||
import { ReleaseManagementStore } from '../release-orchestrator/releases/release.store';
|
||||
import { ReleaseListComponent } from '../release-orchestrator/releases/release-list/release-list.component';
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import { StatusBadgeComponent } from '../../shared/ui/status-badge/status-badge.component';
|
||||
import { RelativeTimePipe } from '../../shared/pipes/format.pipes';
|
||||
import type { ReleaseWorkflowStatus } from '../../core/api/release-management.models';
|
||||
|
||||
const RELEASE_TABS: readonly StellaPageTab[] = [
|
||||
{ id: 'releases', label: 'Releases', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z' },
|
||||
{ id: 'versions', label: 'Versions', icon: 'M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5' },
|
||||
];
|
||||
|
||||
// ── Data model ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PipelineRelease {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -52,194 +43,174 @@ export interface PipelineRelease {
|
||||
lastActor: string;
|
||||
}
|
||||
|
||||
|
||||
// ── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Component({
|
||||
selector: 'app-releases-unified-page',
|
||||
standalone: true,
|
||||
imports: [
|
||||
UpperCasePipe,
|
||||
SlicePipe,
|
||||
RouterLink,
|
||||
StellaFilterChipComponent,
|
||||
StellaPageTabsComponent,
|
||||
PaginationComponent,
|
||||
PageActionOutletComponent,
|
||||
ReleaseListComponent,
|
||||
ConfirmDialogComponent,
|
||||
UpperCasePipe, SlicePipe, RouterLink, PaginationComponent,
|
||||
PageActionOutletComponent, ConfirmDialogComponent, StatusBadgeComponent, RelativeTimePipe,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="rup">
|
||||
<header class="rup__header">
|
||||
<div class="rup__header">
|
||||
<div>
|
||||
<h1 class="rup__title">Release Control</h1>
|
||||
<p class="rup__subtitle">{{ activeSubtitle() }}</p>
|
||||
<h1>Releases</h1>
|
||||
<p class="rup__sub">Version catalog and release plans.</p>
|
||||
</div>
|
||||
<app-page-action-outlet />
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<stella-page-tabs [tabs]="releaseTabs" [activeTab]="activeTab()" urlParam="tab"
|
||||
(tabChange)="activeTab.set($event)" ariaLabel="Releases tabs">
|
||||
<app-confirm-dialog #approveConfirm
|
||||
title="Approve Release"
|
||||
[message]="approveMessage()"
|
||||
confirmLabel="Approve" cancelLabel="Cancel" variant="warning"
|
||||
(confirmed)="executeApprove()" />
|
||||
|
||||
@if (activeTab() === 'releases') {
|
||||
<!-- Pipeline -->
|
||||
<div class="rup__filters">
|
||||
<div class="rup__search">
|
||||
<svg class="rup__search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="8"></circle><path d="m21 21-4.3-4.3"></path>
|
||||
</svg>
|
||||
<input type="text" class="rup__search-input" placeholder="Search releases..."
|
||||
[value]="searchQuery()" (input)="searchQuery.set($any($event.target).value); currentPage.set(1)" />
|
||||
<!-- ═══════ DUAL-PANEL ═══════ -->
|
||||
<div class="dual-panel">
|
||||
|
||||
<!-- ── LEFT: Versions ── -->
|
||||
<div class="panel">
|
||||
<div class="panel__head">
|
||||
<h2>Versions <span class="badge">{{ releases().length }}</span></h2>
|
||||
<div class="head-toggles">
|
||||
<div class="seg-group">
|
||||
<button class="seg-btn" [class.seg-btn--active]="versionGateFilter() === ''" (click)="setVersionGate('')">All</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="versionGateFilter() === 'pass'" (click)="setVersionGate('pass')">Pass</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="versionGateFilter() === 'block'" (click)="setVersionGate('block')">Block</button>
|
||||
</div>
|
||||
<button class="sort-btn" (click)="toggleVersionSort()">{{ versionSortAsc() ? '↑' : '↓' }} Date</button>
|
||||
</div>
|
||||
<stella-filter-chip label="Status" [value]="statusFilter()" [options]="statusOptions" (valueChange)="statusFilter.set($event); currentPage.set(1)" />
|
||||
<stella-filter-chip label="Gates" [value]="gateFilter()" [options]="gateOptions" (valueChange)="gateFilter.set($event); currentPage.set(1)" />
|
||||
</div>
|
||||
|
||||
<!-- Releases table -->
|
||||
@if (sortedReleases().length === 0) {
|
||||
<div class="rup__empty">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
<p class="rup__empty-title">No releases found</p>
|
||||
<p class="rup__empty-text">Create a new release or adjust your filters.</p>
|
||||
@if (store.loading()) {
|
||||
<div class="skeleton-list">
|
||||
@for (i of [1,2,3,4]; track i) {
|
||||
<div class="skeleton-row"><div class="skeleton-cell skeleton-cell--wide"></div><div class="skeleton-cell skeleton-cell--sm"></div></div>
|
||||
}
|
||||
</div>
|
||||
} @else if (pagedVersions().length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>No versions match the current filters.</p>
|
||||
<p class="empty-hint">Create a new version to start building releases.</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="rup__table-wrap">
|
||||
<div class="ver-list">
|
||||
@for (v of pagedVersions(); track v.id) {
|
||||
<div class="ver-row" [class.ver-row--selected]="selectedVersionId() === v.id" (click)="selectVersion(v)">
|
||||
<div class="ver-row__main">
|
||||
<div class="ver-row__id">
|
||||
<span class="ver-row__digest">{{ v.digest | slice:0:16 }}...</span>
|
||||
<strong class="ver-row__name">{{ v.version || v.name }}</strong>
|
||||
</div>
|
||||
<div class="ver-row__meta">
|
||||
<span class="lane-badge" [class.lane-badge--hotfix]="v.lane === 'hotfix'">{{ v.lane }}</span>
|
||||
<span class="ver-row__env">{{ v.environment }}</span>
|
||||
<app-status-badge [status]="gateToStatus(v.gateStatus)" [label]="v.gateStatus" size="sm" />
|
||||
<span class="muted">{{ v.updatedAt | relativeTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a class="btn btn--xs btn--secondary ver-row__create" [routerLink]="['/releases/new']" [queryParams]="{ versionId: v.id }" (click)="$event.stopPropagation()">+ Release</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (filteredVersions().length > versionPageSize) {
|
||||
<app-pagination [total]="filteredVersions().length" [pageSize]="versionPageSize" [currentPage]="versionPage()" (pageChange)="versionPage.set($event.page)" [compact]="true" />
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- ── RIGHT: Releases ── -->
|
||||
<div class="panel">
|
||||
<div class="panel__head">
|
||||
<h2>Releases</h2>
|
||||
<div class="head-toggles">
|
||||
<div class="seg-group">
|
||||
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === ''" (click)="setStatus('')">All</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === 'draft'" (click)="setStatus('draft')">Draft</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === 'ready'" (click)="setStatus('ready')">Ready</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === 'deployed'" (click)="setStatus('deployed')">Deployed</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="statusFilter() === 'failed'" (click)="setStatus('failed')">Failed</button>
|
||||
</div>
|
||||
<div class="seg-group">
|
||||
<button class="seg-btn" [class.seg-btn--active]="gateFilter() === ''" (click)="setGate('')">All</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="gateFilter() === 'pass'" (click)="setGate('pass')">Pass</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="gateFilter() === 'warn'" (click)="setGate('warn')">Warn</button>
|
||||
<button class="seg-btn" [class.seg-btn--active]="gateFilter() === 'block'" (click)="setGate('block')">Block</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (selectedVersionId()) {
|
||||
<div class="version-filter-bar">
|
||||
Filtered by: <strong>{{ selectedVersionLabel() }}</strong>
|
||||
<button class="clear-btn" (click)="clearVersionFilter()">×</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (store.loading()) {
|
||||
<div class="skeleton-list">
|
||||
@for (i of [1,2,3,4]; track i) {
|
||||
<div class="skeleton-row"><div class="skeleton-cell skeleton-cell--wide"></div><div class="skeleton-cell skeleton-cell--wide"></div><div class="skeleton-cell skeleton-cell--sm"></div></div>
|
||||
}
|
||||
</div>
|
||||
} @else if (pagedReleases().length === 0) {
|
||||
<div class="empty-state">
|
||||
@if (selectedVersionId()) {
|
||||
<p>No releases for this version yet.</p>
|
||||
<p class="empty-hint">Click "+ Release" on the version to create one.</p>
|
||||
} @else {
|
||||
<p>No releases match the current filters.</p>
|
||||
<p class="empty-hint">Create a release from a version in the left panel.</p>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="rel-table-wrap">
|
||||
<table class="stella-table stella-table--striped stella-table--hoverable stella-table--bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
@for (col of columns; track col.key) {
|
||||
<th
|
||||
[class.rup__th--sortable]="col.sortable"
|
||||
[class.rup__th--sorted]="sortState()?.column === col.key"
|
||||
[attr.aria-sort]="getSortAria(col.key)"
|
||||
(click)="col.sortable ? toggleSort(col.key) : null"
|
||||
>
|
||||
<div class="rup__th-content">
|
||||
<span>{{ col.label }}</span>
|
||||
@if (col.sortable) {
|
||||
<span class="rup__sort-icon" [class.rup__sort-icon--active]="sortState()?.column === col.key">
|
||||
@if (sortState()?.column === col.key) {
|
||||
@if (sortState()?.direction === 'asc') {
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
<polyline points="18 15 12 9 6 15" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
} @else {
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
}
|
||||
} @else {
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" class="rup__sort-icon--inactive" aria-hidden="true">
|
||||
<polyline points="8 10 12 6 16 10" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<polyline points="8 14 12 18 16 14" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</th>
|
||||
}
|
||||
<th>Release</th>
|
||||
<th>Stage</th>
|
||||
<th>Gates</th>
|
||||
<th>Risk</th>
|
||||
<th>Evidence</th>
|
||||
<th>Status</th>
|
||||
<th>Decisions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of pagedReleases(); track r.id) {
|
||||
<tr>
|
||||
<!-- Release -->
|
||||
<td>
|
||||
<div class="rup__release-cell">
|
||||
<a class="rup__release-name" [routerLink]="['/releases/detail', r.id, 'overview']">{{ r.name }}</a>
|
||||
<span class="rup__release-version">{{ r.version }}</span>
|
||||
<span class="rup__lane-badge" [class.rup__lane-badge--hotfix]="r.lane === 'hotfix'">
|
||||
{{ r.lane === 'hotfix' ? 'Hotfix' : 'Standard' }}
|
||||
</span>
|
||||
<span class="rup__digest" [title]="r.digest">{{ r.digest | slice:0:19 }}</span>
|
||||
<div class="rel-cell">
|
||||
<a class="rel-name" [routerLink]="['/releases/detail', r.id, 'overview']">{{ r.name }}</a>
|
||||
<span class="rel-ver">{{ r.version }}</span>
|
||||
<span class="lane-badge" [class.lane-badge--hotfix]="r.lane === 'hotfix'">{{ r.lane }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Stage -->
|
||||
<td><span class="rel-stage">{{ r.environment }}</span><span class="rel-region">{{ r.region }}</span></td>
|
||||
<td>
|
||||
<span class="rup__stage">{{ r.environment }}</span>
|
||||
<span class="rup__region">{{ r.region }}</span>
|
||||
</td>
|
||||
<!-- Gates -->
|
||||
<td>
|
||||
<span class="rup__badge"
|
||||
[class.rup__badge--success]="r.gateStatus === 'pass'"
|
||||
[class.rup__badge--warning]="r.gateStatus === 'warn'"
|
||||
[class.rup__badge--error]="r.gateStatus === 'block'">
|
||||
{{ r.gateStatus | uppercase }}
|
||||
@if (r.gateBlockingCount > 0) {
|
||||
<span class="rup__badge-count">{{ r.gateBlockingCount }}</span>
|
||||
}
|
||||
<span class="pill" [class.pill--pass]="r.gateStatus==='pass'" [class.pill--warn]="r.gateStatus==='warn'" [class.pill--block]="r.gateStatus==='block'">
|
||||
{{ r.gateStatus | uppercase }}@if (r.gateBlockingCount > 0) { <span class="pill__count">{{ r.gateBlockingCount }}</span> }
|
||||
</span>
|
||||
</td>
|
||||
<!-- Risk -->
|
||||
<td><span class="pill" [attr.data-tier]="r.riskTier">{{ r.riskTier | uppercase }}</span></td>
|
||||
<td><span class="pill" [class.pill--pass]="r.evidencePosture==='verified'" [class.pill--warn]="r.evidencePosture==='partial'" [class.pill--block]="r.evidencePosture==='missing'">{{ r.evidencePosture }}</span></td>
|
||||
<td><span class="status-pill" [attr.data-status]="r.status">{{ formatStatus(r.status) }}</span></td>
|
||||
<td>
|
||||
<span class="rup__risk-badge"
|
||||
[attr.data-tier]="r.riskTier">
|
||||
{{ r.riskTier | uppercase }}
|
||||
</span>
|
||||
</td>
|
||||
<!-- Evidence -->
|
||||
<td>
|
||||
<span class="rup__evidence-badge"
|
||||
[class.rup__evidence-badge--verified]="r.evidencePosture === 'verified'"
|
||||
[class.rup__evidence-badge--partial]="r.evidencePosture === 'partial'"
|
||||
[class.rup__evidence-badge--missing]="r.evidencePosture === 'missing'">
|
||||
{{ r.evidencePosture === 'verified' ? 'Verified' : r.evidencePosture === 'partial' ? 'Partial' : 'Missing' }}
|
||||
</span>
|
||||
</td>
|
||||
<!-- Status -->
|
||||
<td>
|
||||
<span class="rup__status-badge" [attr.data-status]="r.status">
|
||||
{{ formatStatus(r.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<!-- Decisions -->
|
||||
<td>
|
||||
<div class="rup__decisions">
|
||||
<div class="decisions">
|
||||
@if (r.status === 'ready' && r.gateStatus === 'pass') {
|
||||
<button type="button" class="decision-capsule decision-capsule--deploy">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5"/><path d="M5 12l7-7 7 7"/></svg>
|
||||
Deploy
|
||||
</button>
|
||||
<button class="dcap dcap--deploy" type="button">Deploy</button>
|
||||
}
|
||||
@if (r.gatePendingApprovals > 0) {
|
||||
<button type="button" class="decision-capsule decision-capsule--approve" (click)="openApproveDialog(r)">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 11l3 3L22 4"/></svg>
|
||||
Approve ({{ r.gatePendingApprovals }})
|
||||
</button>
|
||||
<button class="dcap dcap--approve" (click)="openApproveDialog(r)" type="button">Approve ({{ r.gatePendingApprovals }})</button>
|
||||
}
|
||||
@if (r.gateStatus === 'block') {
|
||||
<a class="decision-capsule decision-capsule--review" [routerLink]="['/releases/detail', r.id, 'gates']">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
Review Gates
|
||||
</a>
|
||||
}
|
||||
@if (r.evidencePosture === 'partial' || r.evidencePosture === 'missing') {
|
||||
<a class="decision-capsule decision-capsule--evidence" [routerLink]="['/releases/detail', r.id, 'evidence']">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
|
||||
View Evidence
|
||||
</a>
|
||||
}
|
||||
@if (r.status === 'deploying' && r.deploymentProgress !== null) {
|
||||
<span class="decision-capsule decision-capsule--progress">
|
||||
<span class="decision-capsule__progress-track">
|
||||
<span class="decision-capsule__progress-fill" [style.width.%]="r.deploymentProgress"></span>
|
||||
</span>
|
||||
<span class="decision-capsule__progress-text">{{ r.deploymentProgress }}%</span>
|
||||
</span>
|
||||
<a class="dcap dcap--review" [routerLink]="['/releases/detail', r.id, 'gates']">Review</a>
|
||||
}
|
||||
@if (r.status === 'deployed' && r.gateStatus === 'pass') {
|
||||
<a class="decision-capsule decision-capsule--promote" [routerLink]="['/releases/promotions']" [queryParams]="{ releaseId: r.id }">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></svg>
|
||||
Promote
|
||||
</a>
|
||||
<a class="dcap dcap--promote" [routerLink]="['/releases/promotions']" [queryParams]="{ releaseId: r.id }">Promote</a>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
@@ -248,245 +219,145 @@ export interface PipelineRelease {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination (right-aligned) -->
|
||||
<div class="rup__pager">
|
||||
<app-pagination
|
||||
[total]="sortedReleases().length"
|
||||
[currentPage]="currentPage()"
|
||||
[pageSize]="pageSize()"
|
||||
[pageSizes]="[5, 10, 25, 50]"
|
||||
(pageChange)="onPageChange($event)"
|
||||
/>
|
||||
</div>
|
||||
@if (sortedReleases().length > releasePageSize) {
|
||||
<app-pagination [total]="sortedReleases().length" [pageSize]="releasePageSize" [currentPage]="releasePage()" (pageChange)="releasePage.set($event.page)" [compact]="true" />
|
||||
}
|
||||
}
|
||||
} <!-- end pipeline tab -->
|
||||
|
||||
@if (activeTab() === 'versions') {
|
||||
<app-release-list [embedded]="true" />
|
||||
}
|
||||
</stella-page-tabs>
|
||||
|
||||
<app-confirm-dialog #approveConfirm
|
||||
title="Approve Release"
|
||||
[message]="approveMessage()"
|
||||
confirmLabel="Approve" cancelLabel="Cancel" variant="warning"
|
||||
(confirmed)="executeApprove()" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.rup { padding: 1.5rem; }
|
||||
.rup__header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
|
||||
.rup__title { font-size: 1.5rem; font-weight: var(--font-weight-bold, 700); color: var(--color-text-heading); margin: 0 0 0.25rem; }
|
||||
.rup__subtitle { font-size: 0.8125rem; color: var(--color-text-secondary); margin: 0; }
|
||||
.rup__filters { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; overflow: visible; }
|
||||
.rup__search { position: relative; flex: 0 1 240px; min-width: 160px; }
|
||||
.rup__search-icon { position: absolute; left: 0.5rem; top: 50%; transform: translateY(-50%); color: var(--color-text-muted); pointer-events: none; }
|
||||
.rup__search-input {
|
||||
width: 100%; height: 28px; padding: 0 0.5rem 0 1.75rem;
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm);
|
||||
background: transparent; color: var(--color-text-primary);
|
||||
font-size: 0.75rem; outline: none; transition: border-color 150ms ease;
|
||||
}
|
||||
.rup__search-input:focus { border-color: var(--color-brand-primary); }
|
||||
.rup__search-input::placeholder { color: var(--color-text-muted); }
|
||||
.rup { display: grid; gap: 0.65rem; }
|
||||
.rup__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.5rem; }
|
||||
.rup__header h1 { margin: 0; font-size: 1.3rem; }
|
||||
.rup__sub { margin: 0.15rem 0 0; color: var(--color-text-secondary); font-size: 0.8rem; }
|
||||
|
||||
.rup__table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
/* ── Dual-panel ── */
|
||||
.dual-panel { display: grid; grid-template-columns: repeat(auto-fit, minmax(500px, 1fr)); gap: 0.65rem; align-items: start; }
|
||||
.panel { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); padding: 0.65rem; display: grid; gap: 0.5rem; }
|
||||
.panel__head { display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.35rem; border-bottom: 1px solid var(--color-border-primary); flex-wrap: wrap; gap: 0.3rem; }
|
||||
.panel__head h2 { margin: 0; font-size: 0.88rem; font-weight: 600; display: flex; align-items: center; gap: 0.35rem; }
|
||||
.badge { font-size: 0.6rem; padding: 0.1rem 0.35rem; border-radius: var(--radius-full); font-weight: 700; background: var(--color-surface-secondary); color: var(--color-text-muted); }
|
||||
.head-toggles { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; }
|
||||
.seg-group { display: inline-flex; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.seg-btn { padding: 0.2rem 0.5rem; border: none; background: transparent; font-size: 0.68rem; font-weight: 500; color: var(--color-text-muted); cursor: pointer; transition: all 150ms; }
|
||||
.seg-btn:hover:not(.seg-btn--active) { color: var(--color-text-secondary); background: var(--color-surface-tertiary, rgba(0,0,0,0.04)); }
|
||||
.seg-btn--active { background: var(--color-surface-tertiary, rgba(0,0,0,0.04)); color: var(--color-text-primary); font-weight: 600; }
|
||||
.sort-btn { padding: 0.2rem 0.45rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background: transparent; font-size: 0.66rem; font-weight: 500; color: var(--color-text-muted); cursor: pointer; }
|
||||
.sort-btn:hover { color: var(--color-text-primary); border-color: var(--color-border-emphasis); }
|
||||
|
||||
.rup__release-cell { display: flex; flex-direction: column; gap: 0.125rem; min-width: 180px; }
|
||||
.rup__release-name { font-weight: var(--font-weight-semibold, 600); color: var(--color-text-heading); text-decoration: none; font-size: 0.8125rem; }
|
||||
.rup__release-name:hover { text-decoration: underline; }
|
||||
.rup__release-version { font-size: 0.75rem; color: var(--color-text-secondary); font-family: var(--font-mono, monospace); }
|
||||
.rup__digest { font-size: 0.625rem; color: var(--color-text-muted); font-family: var(--font-mono, monospace); }
|
||||
/* ── Version filter bar ── */
|
||||
.version-filter-bar { font-size: 0.74rem; padding: 0.3rem 0.5rem; background: var(--color-brand-soft, rgba(59,130,246,0.08)); border-radius: var(--radius-sm); display: flex; align-items: center; gap: 0.3rem; }
|
||||
.clear-btn { background: none; border: none; font-size: 0.9rem; cursor: pointer; color: var(--color-text-muted); padding: 0 0.2rem; }
|
||||
.clear-btn:hover { color: var(--color-text-primary); }
|
||||
|
||||
.rup__lane-badge {
|
||||
display: inline-block; width: fit-content; padding: 0.0625rem 0.375rem;
|
||||
border-radius: var(--radius-full, 9999px); font-size: 0.5625rem;
|
||||
font-weight: var(--font-weight-semibold, 600); text-transform: uppercase;
|
||||
letter-spacing: 0.04em; background: var(--color-surface-secondary);
|
||||
color: var(--color-text-secondary); border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
.rup__lane-badge--hotfix { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); border-color: var(--color-status-warning, #C89820); }
|
||||
/* ── Skeleton ── */
|
||||
.skeleton-list { display: flex; flex-direction: column; gap: 0.5rem; padding: 0.5rem 0; }
|
||||
.skeleton-row { display: flex; gap: 0.75rem; align-items: center; }
|
||||
.skeleton-cell { height: 12px; border-radius: var(--radius-sm); background: var(--color-surface-tertiary); animation: sk 1.2s ease-in-out infinite; }
|
||||
.skeleton-cell--wide { flex: 2; } .skeleton-cell--sm { flex: 0.7; }
|
||||
@keyframes sk { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
.rup__stage { display: block; font-size: 0.8125rem; color: var(--color-text-primary); font-weight: var(--font-weight-medium, 500); }
|
||||
.rup__region { display: block; font-size: 0.6875rem; color: var(--color-text-muted); }
|
||||
.empty-state { text-align: center; padding: 1.5rem; color: var(--color-text-muted); border: 1px dashed var(--color-border-primary); border-radius: var(--radius-lg); }
|
||||
.empty-state p { margin: 0.15rem 0; }
|
||||
.empty-hint { font-size: 0.72rem; opacity: 0.7; }
|
||||
|
||||
/* Shared pill base for gate/risk/evidence/status badges */
|
||||
.rup__badge, .rup__risk-badge, .rup__evidence-badge, .rup__status-badge {
|
||||
display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.125rem 0.5rem;
|
||||
border-radius: var(--radius-full, 9999px); font-size: 0.6875rem;
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
}
|
||||
.rup__badge { text-transform: uppercase; letter-spacing: 0.03em; }
|
||||
.rup__badge--success { background: var(--color-status-success-bg, #E8F5E9); color: var(--color-status-success, #2E7D32); }
|
||||
.rup__badge--warning { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); }
|
||||
.rup__badge--error { background: var(--color-status-error-bg, #F8EFEB); color: var(--color-status-error, #CB4535); }
|
||||
.rup__badge-count { display: inline-flex; align-items: center; justify-content: center; min-width: 16px; height: 16px; padding: 0 4px; border-radius: 8px; background: rgba(0,0,0,0.15); font-size: 0.5625rem; font-weight: 700; }
|
||||
/* ── Version rows ── */
|
||||
.ver-list { display: grid; gap: 0.35rem; }
|
||||
.ver-row { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; padding: 0.45rem 0.55rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); cursor: pointer; transition: all 120ms; }
|
||||
.ver-row:hover { background: var(--color-surface-secondary); }
|
||||
.ver-row--selected { border-left: 3px solid var(--color-brand, #3b82f6); background: var(--color-brand-soft, rgba(59,130,246,0.06)); }
|
||||
.ver-row__main { display: grid; gap: 0.15rem; min-width: 0; flex: 1; }
|
||||
.ver-row__id { display: flex; align-items: center; gap: 0.35rem; }
|
||||
.ver-row__digest { font-family: var(--font-mono, monospace); font-size: 0.66rem; color: var(--color-text-muted); }
|
||||
.ver-row__name { font-size: 0.78rem; }
|
||||
.ver-row__meta { display: flex; align-items: center; gap: 0.25rem; flex-wrap: wrap; font-size: 0.68rem; }
|
||||
.ver-row__env { color: var(--color-text-secondary); }
|
||||
.ver-row__create { flex-shrink: 0; white-space: nowrap; }
|
||||
.muted { color: var(--color-text-muted); font-size: 0.66rem; }
|
||||
|
||||
.rup__risk-badge[data-tier="critical"] { background: var(--color-status-error-bg, #F8EFEB); color: var(--color-status-error, #CB4535); }
|
||||
.rup__risk-badge[data-tier="high"] { background: #FFF3E0; color: #E65100; }
|
||||
.rup__risk-badge[data-tier="medium"] { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); }
|
||||
.rup__risk-badge[data-tier="low"] { background: var(--color-status-success-bg, #E8F5E9); color: var(--color-status-success, #2E7D32); }
|
||||
.rup__risk-badge[data-tier="none"] { background: var(--color-surface-secondary); color: var(--color-text-muted); }
|
||||
.lane-badge { padding: 0.06rem 0.3rem; border-radius: var(--radius-full); font-size: 0.56rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; background: var(--color-surface-secondary); color: var(--color-text-secondary); border: 1px solid var(--color-border-primary); }
|
||||
.lane-badge--hotfix { background: var(--color-status-warning-bg); color: var(--color-status-warning); border-color: var(--color-status-warning); }
|
||||
|
||||
.rup__evidence-badge--verified { background: var(--color-status-success-bg, #E8F5E9); color: var(--color-status-success, #2E7D32); }
|
||||
.rup__evidence-badge--partial { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); }
|
||||
.rup__evidence-badge--missing { background: var(--color-status-error-bg, #F8EFEB); color: var(--color-status-error, #CB4535); }
|
||||
/* ── Buttons ── */
|
||||
.btn { padding: 0.3rem 0.65rem; border-radius: var(--radius-sm); cursor: pointer; font-size: 0.74rem; font-weight: 500; border: none; text-decoration: none; display: inline-flex; align-items: center; gap: 0.2rem; transition: background 150ms; }
|
||||
.btn--xs { padding: 0.2rem 0.4rem; font-size: 0.68rem; }
|
||||
.btn--secondary { background: var(--color-surface-secondary); color: var(--color-text-primary); border: 1px solid var(--color-border-primary); }
|
||||
.btn--secondary:hover { background: var(--color-brand-soft); }
|
||||
|
||||
.rup__status-badge { text-transform: capitalize; }
|
||||
.rup__status-badge[data-status="draft"] { background: var(--color-surface-secondary); color: var(--color-text-muted); }
|
||||
.rup__status-badge[data-status="ready"] { background: #E3F2FD; color: #1565C0; }
|
||||
.rup__status-badge[data-status="deploying"] { background: #EDE7F6; color: #6A1B9A; }
|
||||
.rup__status-badge[data-status="deployed"] { background: var(--color-status-success-bg, #E8F5E9); color: var(--color-status-success, #2E7D32); }
|
||||
.rup__status-badge[data-status="failed"] { background: var(--color-status-error-bg, #F8EFEB); color: var(--color-status-error, #CB4535); }
|
||||
.rup__status-badge[data-status="rolled_back"] { background: var(--color-status-warning-bg, #FDF4E0); color: var(--color-status-warning, #C89820); }
|
||||
/* ── Release table ── */
|
||||
.rel-table-wrap { overflow-x: auto; }
|
||||
.rel-cell { display: flex; flex-direction: column; gap: 0.1rem; min-width: 160px; }
|
||||
.rel-name { font-weight: 600; color: var(--color-text-heading); text-decoration: none; font-size: 0.78rem; }
|
||||
.rel-name:hover { text-decoration: underline; }
|
||||
.rel-ver { font-size: 0.68rem; color: var(--color-text-secondary); font-family: var(--font-mono, monospace); }
|
||||
.rel-stage { display: block; font-size: 0.78rem; font-weight: 500; }
|
||||
.rel-region { display: block; font-size: 0.66rem; color: var(--color-text-muted); }
|
||||
|
||||
.rup__decisions { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; }
|
||||
.rup__decisions-done { color: var(--color-status-success, #2E7D32); display: inline-flex; }
|
||||
.pill { display: inline-flex; align-items: center; gap: 0.2rem; padding: 0.1rem 0.4rem; border-radius: var(--radius-full); font-size: 0.66rem; font-weight: 600; background: var(--color-surface-secondary); color: var(--color-text-muted); text-transform: capitalize; }
|
||||
.pill--pass { background: var(--color-status-success-bg); color: var(--color-status-success); }
|
||||
.pill--warn { background: var(--color-status-warning-bg); color: var(--color-status-warning); }
|
||||
.pill--block { background: var(--color-status-error-bg); color: var(--color-status-error); }
|
||||
.pill[data-tier="critical"] { background: var(--color-status-error-bg); color: var(--color-status-error); }
|
||||
.pill[data-tier="high"] { background: #FFF3E0; color: #E65100; }
|
||||
.pill[data-tier="medium"] { background: var(--color-status-warning-bg); color: var(--color-status-warning); }
|
||||
.pill[data-tier="low"] { background: var(--color-status-success-bg); color: var(--color-status-success); }
|
||||
.pill__count { display: inline-flex; align-items: center; justify-content: center; min-width: 14px; height: 14px; padding: 0 3px; border-radius: 7px; background: rgba(0,0,0,0.15); font-size: 0.55rem; }
|
||||
|
||||
.decision-capsule {
|
||||
display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-sm); font-size: 0.65rem;
|
||||
font-weight: 500; cursor: pointer;
|
||||
border: 1px solid var(--color-border-primary); transition: all 150ms ease;
|
||||
text-decoration: none; white-space: nowrap; line-height: 1.3;
|
||||
background: transparent; color: var(--color-text-secondary);
|
||||
}
|
||||
.decision-capsule:hover { background: var(--color-surface-tertiary); border-color: var(--color-brand-primary); color: var(--color-text-primary); }
|
||||
.decision-capsule--deploy { color: var(--color-status-success-text); }
|
||||
.decision-capsule--approve { color: var(--color-status-info-text); }
|
||||
.decision-capsule--review { color: var(--color-status-warning-text); }
|
||||
.decision-capsule--evidence { color: var(--color-text-secondary); }
|
||||
.decision-capsule--promote { color: var(--color-status-success-text); }
|
||||
.decision-capsule--progress { cursor: default; background: var(--color-surface-secondary); border-color: var(--color-border-primary); gap: 0.375rem; }
|
||||
.decision-capsule__progress-track { width: 48px; height: 6px; border-radius: 3px; background: var(--color-border-primary); overflow: hidden; }
|
||||
.decision-capsule__progress-fill { display: block; height: 100%; border-radius: 3px; background: var(--color-brand-primary, #4F46E5); transition: width 300ms ease; }
|
||||
.decision-capsule__progress-text { font-size: 0.625rem; color: var(--color-text-secondary); font-variant-numeric: tabular-nums; }
|
||||
.status-pill { display: inline-block; padding: 0.1rem 0.4rem; border-radius: var(--radius-full); font-size: 0.66rem; font-weight: 600; text-transform: capitalize; }
|
||||
.status-pill[data-status="draft"] { background: var(--color-surface-secondary); color: var(--color-text-muted); }
|
||||
.status-pill[data-status="ready"] { background: #E3F2FD; color: #1565C0; }
|
||||
.status-pill[data-status="deploying"] { background: #EDE7F6; color: #6A1B9A; }
|
||||
.status-pill[data-status="deployed"] { background: var(--color-status-success-bg); color: var(--color-status-success); }
|
||||
.status-pill[data-status="failed"] { background: var(--color-status-error-bg); color: var(--color-status-error); }
|
||||
.status-pill[data-status="rolled_back"] { background: var(--color-status-warning-bg); color: var(--color-status-warning); }
|
||||
|
||||
/* Sortable column headers */
|
||||
.rup__th--sortable { cursor: pointer; user-select: none; transition: color 150ms ease; }
|
||||
.rup__th--sortable:hover { color: var(--color-text-primary); }
|
||||
.rup__th--sorted { color: var(--color-text-heading); border-bottom-color: var(--color-text-heading); }
|
||||
.rup__th-content { display: flex; align-items: center; gap: 0.375rem; }
|
||||
.rup__sort-icon { display: flex; align-items: center; opacity: 0.5; transition: opacity 150ms ease; }
|
||||
.rup__sort-icon--active { opacity: 1; color: var(--color-text-heading); }
|
||||
.rup__sort-icon--inactive { opacity: 0.3; }
|
||||
.decisions { display: flex; flex-wrap: wrap; gap: 0.2rem; }
|
||||
.dcap { display: inline-flex; align-items: center; gap: 0.2rem; padding: 0.18rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.62rem; font-weight: 500; cursor: pointer; border: 1px solid var(--color-border-primary); background: transparent; color: var(--color-text-secondary); text-decoration: none; white-space: nowrap; transition: all 150ms; }
|
||||
.dcap:hover { background: var(--color-surface-tertiary); border-color: var(--color-brand, #3b82f6); color: var(--color-text-primary); }
|
||||
.dcap--deploy { color: var(--color-status-success); }
|
||||
.dcap--approve { color: var(--color-brand, #3b82f6); }
|
||||
.dcap--review { color: var(--color-status-warning); }
|
||||
.dcap--promote { color: var(--color-status-success); }
|
||||
|
||||
/* Right-aligned pagination */
|
||||
.rup__pager { display: flex; justify-content: flex-end; padding-top: 0.75rem; }
|
||||
|
||||
.rup__empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 3rem 1rem; color: var(--color-text-muted); text-align: center; }
|
||||
.rup__empty svg { margin-bottom: 1rem; opacity: 0.4; }
|
||||
.rup__empty-title { font-size: 1rem; font-weight: var(--font-weight-semibold, 600); color: var(--color-text-secondary); margin: 0 0 0.25rem; }
|
||||
.rup__empty-text { font-size: 0.8125rem; margin: 0; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rup { padding: 1rem; }
|
||||
.rup__filters { gap: 0.375rem; }
|
||||
.rup__search { flex: 1 1 100%; }
|
||||
}
|
||||
@media (max-width: 768px) { .rup { padding: 0.5rem; } }
|
||||
`],
|
||||
})
|
||||
export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
|
||||
private readonly pageAction = inject(PageActionService);
|
||||
private readonly store = inject(ReleaseManagementStore);
|
||||
readonly store = inject(ReleaseManagementStore);
|
||||
private readonly context = inject(PlatformContextStore);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly approvalApi = inject<ApprovalApi>(APPROVAL_API);
|
||||
|
||||
readonly releaseTabs = RELEASE_TABS;
|
||||
readonly activeTab = signal<string>(this.route.snapshot.queryParamMap.get('tab') || 'releases');
|
||||
// ── Version panel state ──
|
||||
readonly versionGateFilter = signal('');
|
||||
readonly versionSortAsc = signal(false);
|
||||
readonly versionPage = signal(1);
|
||||
readonly versionPageSize = 10;
|
||||
|
||||
readonly activeSubtitle = computed(() =>
|
||||
this.activeTab() === 'versions'
|
||||
? 'Sealed digest-first version catalog across standard and hotfix lanes.'
|
||||
: 'Promotion pipeline with gate status, risk posture, and evidence for every release.'
|
||||
);
|
||||
// ── Release panel state ──
|
||||
readonly statusFilter = signal('');
|
||||
readonly gateFilter = signal('');
|
||||
readonly releasePage = signal(1);
|
||||
readonly releasePageSize = 10;
|
||||
|
||||
constructor() {
|
||||
// Update page action when tab changes
|
||||
effect(() => {
|
||||
const tab = this.activeTab();
|
||||
if (tab === 'versions') {
|
||||
this.pageAction.set({ label: 'New Version', route: '/releases/versions/new' });
|
||||
} else {
|
||||
this.pageAction.set({ label: 'New Release', route: '/releases/new' });
|
||||
}
|
||||
});
|
||||
}
|
||||
// ── Cross-filter: selected version ──
|
||||
readonly selectedVersionId = signal<string | null>(null);
|
||||
readonly selectedVersionLabel = signal<string | null>(null);
|
||||
|
||||
// Approve dialog
|
||||
// ── Approve dialog ──
|
||||
readonly pendingApproveRelease = signal<PipelineRelease | null>(null);
|
||||
@ViewChild('approveConfirm') approveConfirmRef!: ConfirmDialogComponent;
|
||||
|
||||
readonly approveMessage = computed(() => {
|
||||
const r = this.pendingApproveRelease();
|
||||
if (!r) return '';
|
||||
return `Approve release "${r.name}" (${r.version || r.digest?.slice(0, 19) || 'unknown'}) for promotion?`;
|
||||
});
|
||||
|
||||
openApproveDialog(r: PipelineRelease): void {
|
||||
this.pendingApproveRelease.set(r);
|
||||
this.approveConfirmRef.open();
|
||||
}
|
||||
|
||||
executeApprove(): void {
|
||||
const r = this.pendingApproveRelease();
|
||||
if (!r) return;
|
||||
// Call the approval decision endpoint for this release
|
||||
this.approvalApi.approve(r.id, `Approved from releases page`).pipe(take(1)).subscribe({
|
||||
next: () => {
|
||||
this.pendingApproveRelease.set(null);
|
||||
this.store.loadReleases({});
|
||||
},
|
||||
error: () => this.pendingApproveRelease.set(null),
|
||||
});
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.context.initialize();
|
||||
this.store.loadReleases({});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
// ── Filter-chip options ──────────────────────────────────────────────
|
||||
|
||||
// Lane filter reads from global context (header toggle)
|
||||
readonly statusOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All Status' },
|
||||
{ id: 'draft', label: 'Draft' },
|
||||
{ id: 'ready', label: 'Ready' },
|
||||
{ id: 'deploying', label: 'Deploying' },
|
||||
{ id: 'deployed', label: 'Deployed' },
|
||||
{ id: 'failed', label: 'Failed' },
|
||||
{ id: 'rolled_back', label: 'Rolled Back' },
|
||||
];
|
||||
readonly gateOptions: FilterChipOption[] = [
|
||||
{ id: '', label: 'All Gates' },
|
||||
{ id: 'pass', label: 'Pass' },
|
||||
{ id: 'warn', label: 'Warn' },
|
||||
{ id: 'block', label: 'Block' },
|
||||
];
|
||||
|
||||
// ── Columns definition ───────────────────────────────────────────────
|
||||
|
||||
readonly columns: TableColumn<PipelineRelease>[] = [
|
||||
{ key: 'name', label: 'Release', sortable: true },
|
||||
{ key: 'environment', label: 'Stage', sortable: true },
|
||||
{ key: 'gateStatus', label: 'Gates', sortable: true },
|
||||
{ key: 'riskTier', label: 'Risk', sortable: true },
|
||||
{ key: 'evidencePosture', label: 'Evidence', sortable: true },
|
||||
{ key: 'status', label: 'Status', sortable: true },
|
||||
{ key: 'decisions', label: 'Decisions', sortable: false },
|
||||
];
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
|
||||
readonly releases = computed<PipelineRelease[]>(() => {
|
||||
return this.store.releases().map(r => ({
|
||||
// ── Map store releases to PipelineRelease ──
|
||||
readonly releases = computed<PipelineRelease[]>(() =>
|
||||
this.store.releases().map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
version: r.version,
|
||||
@@ -503,108 +374,130 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
|
||||
deploymentProgress: r.status === 'deploying' ? 50 : null,
|
||||
updatedAt: r.updatedAt ?? '',
|
||||
lastActor: r.lastActor ?? '',
|
||||
}));
|
||||
}))
|
||||
);
|
||||
|
||||
// ── Versions (left panel) ──
|
||||
readonly filteredVersions = computed(() => {
|
||||
let list = this.releases();
|
||||
const gate = this.versionGateFilter();
|
||||
if (gate) list = list.filter(v => v.gateStatus === gate);
|
||||
const asc = this.versionSortAsc();
|
||||
list = [...list].sort((a, b) => asc ? a.updatedAt.localeCompare(b.updatedAt) : b.updatedAt.localeCompare(a.updatedAt));
|
||||
return list;
|
||||
});
|
||||
readonly searchQuery = signal('');
|
||||
readonly laneFilter = computed(() => this.context.releaseLane());
|
||||
readonly statusFilter = signal('');
|
||||
readonly gateFilter = signal('');
|
||||
readonly sortState = signal<{ column: string; direction: 'asc' | 'desc' } | null>(null);
|
||||
|
||||
// ── Pagination ────────────────────────────────────────────────────────
|
||||
|
||||
readonly currentPage = signal(1);
|
||||
readonly pageSize = signal(10);
|
||||
readonly pagedVersions = computed(() => {
|
||||
const all = this.filteredVersions();
|
||||
const start = (this.versionPage() - 1) * this.versionPageSize;
|
||||
return all.slice(start, start + this.versionPageSize);
|
||||
});
|
||||
|
||||
// ── Releases (right panel, filtered by selected version) ──
|
||||
readonly filteredReleases = computed(() => {
|
||||
let list = this.releases();
|
||||
const q = this.searchQuery().toLowerCase().trim();
|
||||
const lane = this.laneFilter();
|
||||
const verId = this.selectedVersionId();
|
||||
if (verId) list = list.filter(r => r.id === verId || r.version === this.selectedVersionLabel());
|
||||
const status = this.statusFilter();
|
||||
if (status) list = list.filter(r => r.status === status);
|
||||
const gate = this.gateFilter();
|
||||
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
r.version.toLowerCase().includes(q) ||
|
||||
r.digest.toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
if (lane) {
|
||||
list = list.filter((r) => r.lane === lane);
|
||||
}
|
||||
if (status !== '') {
|
||||
list = list.filter((r) => r.status === status);
|
||||
}
|
||||
if (gate !== '') {
|
||||
list = list.filter((r) => r.gateStatus === gate);
|
||||
}
|
||||
if (gate) list = list.filter(r => r.gateStatus === gate);
|
||||
return list;
|
||||
});
|
||||
|
||||
readonly sortedReleases = computed(() => {
|
||||
const list = [...this.filteredReleases()];
|
||||
const sort = this.sortState();
|
||||
if (!sort) return list;
|
||||
|
||||
const { column, direction } = sort;
|
||||
const dir = direction === 'asc' ? 1 : -1;
|
||||
|
||||
return list.sort((a, b) => {
|
||||
const aVal = (a as unknown as Record<string, unknown>)[column];
|
||||
const bVal = (b as unknown as Record<string, unknown>)[column];
|
||||
|
||||
if (aVal == null && bVal == null) return 0;
|
||||
if (aVal == null) return 1;
|
||||
if (bVal == null) return -1;
|
||||
|
||||
if (typeof aVal === 'string' && typeof bVal === 'string') {
|
||||
return aVal.localeCompare(bVal) * dir;
|
||||
}
|
||||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||
return (aVal - bVal) * dir;
|
||||
}
|
||||
return String(aVal).localeCompare(String(bVal)) * dir;
|
||||
});
|
||||
return [...this.filteredReleases()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
});
|
||||
|
||||
readonly pagedReleases = computed(() => {
|
||||
const all = this.sortedReleases();
|
||||
const page = this.currentPage();
|
||||
const size = this.pageSize();
|
||||
const start = (page - 1) * size;
|
||||
return all.slice(start, start + size);
|
||||
const start = (this.releasePage() - 1) * this.releasePageSize;
|
||||
return all.slice(start, start + this.releasePageSize);
|
||||
});
|
||||
|
||||
onPageChange(event: PageChangeEvent): void {
|
||||
this.currentPage.set(event.page);
|
||||
this.pageSize.set(event.pageSize);
|
||||
// ── Lifecycle ──
|
||||
|
||||
constructor() {
|
||||
this.pageAction.set({ label: 'New Version', route: '/releases/versions/new' });
|
||||
}
|
||||
|
||||
// ── Sort handlers ────────────────────────────────────────────────────
|
||||
ngOnInit(): void {
|
||||
this.context.initialize();
|
||||
this.store.loadReleases({});
|
||||
}
|
||||
|
||||
toggleSort(columnKey: string): void {
|
||||
const current = this.sortState();
|
||||
if (current?.column === columnKey) {
|
||||
if (current.direction === 'asc') {
|
||||
this.sortState.set({ column: columnKey, direction: 'desc' });
|
||||
} else {
|
||||
// Third click clears sort
|
||||
this.sortState.set(null);
|
||||
}
|
||||
ngOnDestroy(): void {
|
||||
this.pageAction.clear();
|
||||
}
|
||||
|
||||
// ── Version panel actions ──
|
||||
|
||||
setVersionGate(gate: string): void {
|
||||
this.versionGateFilter.set(gate);
|
||||
this.versionPage.set(1);
|
||||
this.store.loadReleases({});
|
||||
}
|
||||
|
||||
toggleVersionSort(): void {
|
||||
this.versionSortAsc.update(v => !v);
|
||||
this.versionPage.set(1);
|
||||
this.store.loadReleases({});
|
||||
}
|
||||
|
||||
selectVersion(v: PipelineRelease): void {
|
||||
if (this.selectedVersionId() === v.id) {
|
||||
this.selectedVersionId.set(null);
|
||||
this.selectedVersionLabel.set(null);
|
||||
} else {
|
||||
this.sortState.set({ column: columnKey, direction: 'asc' });
|
||||
this.selectedVersionId.set(v.id);
|
||||
this.selectedVersionLabel.set(v.version);
|
||||
}
|
||||
this.releasePage.set(1);
|
||||
this.store.loadReleases({});
|
||||
}
|
||||
|
||||
getSortAria(columnKey: string): string | null {
|
||||
const sort = this.sortState();
|
||||
if (sort?.column !== columnKey) return null;
|
||||
return sort.direction === 'asc' ? 'ascending' : 'descending';
|
||||
clearVersionFilter(): void {
|
||||
this.selectedVersionId.set(null);
|
||||
this.selectedVersionLabel.set(null);
|
||||
this.releasePage.set(1);
|
||||
this.store.loadReleases({});
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
// ── Release panel actions ──
|
||||
|
||||
setStatus(s: string): void {
|
||||
this.statusFilter.set(s);
|
||||
this.releasePage.set(1);
|
||||
this.store.loadReleases({});
|
||||
}
|
||||
|
||||
setGate(g: string): void {
|
||||
this.gateFilter.set(g);
|
||||
this.releasePage.set(1);
|
||||
this.store.loadReleases({});
|
||||
}
|
||||
|
||||
// ── Approve ──
|
||||
|
||||
openApproveDialog(r: PipelineRelease): void {
|
||||
this.pendingApproveRelease.set(r);
|
||||
this.approveConfirmRef.open();
|
||||
}
|
||||
|
||||
executeApprove(): void {
|
||||
const r = this.pendingApproveRelease();
|
||||
if (!r) return;
|
||||
this.approvalApi.approve(r.id, 'Approved from releases page').pipe(take(1)).subscribe({
|
||||
next: () => { this.pendingApproveRelease.set(null); this.store.loadReleases({}); },
|
||||
error: () => this.pendingApproveRelease.set(null),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
gateToStatus(gate: string): 'success' | 'warning' | 'error' | 'neutral' {
|
||||
return gate === 'pass' ? 'success' : gate === 'warn' ? 'warning' : gate === 'block' ? 'error' : 'neutral';
|
||||
}
|
||||
|
||||
formatStatus(status: string): string {
|
||||
switch (status) {
|
||||
@@ -618,36 +511,23 @@ export class ReleasesUnifiedPageComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status mapping helpers ────────────────────────────────────────────
|
||||
|
||||
private mapStatus(status: ReleaseWorkflowStatus): PipelineRelease['status'] {
|
||||
const valid: PipelineRelease['status'][] = ['draft', 'ready', 'deploying', 'deployed', 'failed', 'rolled_back'];
|
||||
return valid.includes(status as PipelineRelease['status'])
|
||||
? (status as PipelineRelease['status'])
|
||||
: 'draft';
|
||||
return valid.includes(status as PipelineRelease['status']) ? (status as PipelineRelease['status']) : 'draft';
|
||||
}
|
||||
|
||||
private mapGateStatus(gate: string): PipelineRelease['gateStatus'] {
|
||||
const map: Record<string, PipelineRelease['gateStatus']> = {
|
||||
pass: 'pass', warn: 'warn', block: 'block',
|
||||
pending: 'warn', unknown: 'warn',
|
||||
};
|
||||
const map: Record<string, PipelineRelease['gateStatus']> = { pass: 'pass', warn: 'warn', block: 'block', pending: 'warn', unknown: 'warn' };
|
||||
return map[gate] ?? 'warn';
|
||||
}
|
||||
|
||||
private mapRiskTier(tier: string): PipelineRelease['riskTier'] {
|
||||
const map: Record<string, PipelineRelease['riskTier']> = {
|
||||
critical: 'critical', high: 'high', medium: 'medium', low: 'low', none: 'none',
|
||||
unknown: 'none',
|
||||
};
|
||||
const map: Record<string, PipelineRelease['riskTier']> = { critical: 'critical', high: 'high', medium: 'medium', low: 'low', none: 'none', unknown: 'none' };
|
||||
return map[tier] ?? 'none';
|
||||
}
|
||||
|
||||
private mapEvidencePosture(posture: string): PipelineRelease['evidencePosture'] {
|
||||
const map: Record<string, PipelineRelease['evidencePosture']> = {
|
||||
verified: 'verified', partial: 'partial', missing: 'missing',
|
||||
replay_mismatch: 'partial', unknown: 'missing',
|
||||
};
|
||||
const map: Record<string, PipelineRelease['evidencePosture']> = { verified: 'verified', partial: 'partial', missing: 'missing', replay_mismatch: 'partial', unknown: 'missing' };
|
||||
return map[posture] ?? 'missing';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,11 @@ import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
|
||||
import { scannerOpsPath } from '../platform/ops/operations-paths';
|
||||
import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component';
|
||||
import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component';
|
||||
import { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-security-overview-page',
|
||||
imports: [RouterLink, StellaMetricCardComponent, StellaMetricGridComponent],
|
||||
imports: [RouterLink, StellaMetricCardComponent, StellaMetricGridComponent, StellaQuickLinksComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="security-overview">
|
||||
@@ -24,12 +25,15 @@ import { StellaMetricGridComponent } from '../../shared/components/stella-metric
|
||||
<div>
|
||||
<h1 class="page-title">Security Overview</h1>
|
||||
<p class="page-subtitle">Aggregated security posture across all environments</p>
|
||||
<div class="page-actions">
|
||||
<button type="button" class="btn btn--secondary" (click)="runScan()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Run Scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button type="button" class="btn btn--secondary" (click)="runScan()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> Run Scan
|
||||
</button>
|
||||
</div>
|
||||
<aside class="page-aside">
|
||||
<stella-quick-links [links]="quickLinks" label="Quick Links" layout="strip" />
|
||||
</aside>
|
||||
</header>
|
||||
|
||||
<!-- Quick Stats -->
|
||||
@@ -165,6 +169,8 @@ import { StellaMetricGridComponent } from '../../shared/components/stella-metric
|
||||
}
|
||||
.page-title { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: var(--font-weight-semibold); }
|
||||
.page-subtitle { margin: 0; color: var(--color-text-secondary); }
|
||||
.page-actions { margin-top: 0.75rem; }
|
||||
.page-aside { flex: 0 1 60%; min-width: 0; }
|
||||
|
||||
/* Stats — now uses stella-metric-grid/card */
|
||||
stella-metric-grid { margin-bottom: 1.5rem; }
|
||||
@@ -289,6 +295,14 @@ export class SecurityOverviewPageComponent implements OnInit {
|
||||
private readonly overviewApi = inject(SECURITY_OVERVIEW_API);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly quickLinks: readonly StellaQuickLink[] = [
|
||||
{ label: 'Findings Explorer', route: '/security/findings', description: 'All vulnerability findings across artifacts' },
|
||||
{ label: 'Vulnerabilities', route: '/triage/artifacts', description: 'Triage and investigate artifact vulnerabilities' },
|
||||
{ label: 'VEX & Exceptions', route: '/ops/policy/vex', description: 'VEX statements and vulnerability exceptions' },
|
||||
{ label: 'Reachability', route: '/security/reachability', description: 'Reachability coverage and proof of exposure' },
|
||||
{ label: 'Supply-Chain Data', route: '/security/supply-chain-data', description: 'SBOM health and component inventory' },
|
||||
{ label: 'Disposition Center', route: '/security/disposition', description: 'Advisory sources and VEX configuration' },
|
||||
];
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,353 +0,0 @@
|
||||
import { Component, OnInit, signal, computed, inject, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { catchError, of, forkJoin, interval } from 'rxjs';
|
||||
|
||||
interface GateResult {
|
||||
gateName: string;
|
||||
status: string; // 'pass' | 'fail' | 'skip' | 'pending'
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface ReadinessReport {
|
||||
targetId: string;
|
||||
environmentId: string;
|
||||
isReady: boolean;
|
||||
gates: GateResult[];
|
||||
evaluatedAt: string;
|
||||
}
|
||||
|
||||
interface Region {
|
||||
regionId: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface Environment {
|
||||
environmentId: string;
|
||||
displayName: string;
|
||||
regionId?: string;
|
||||
}
|
||||
|
||||
interface Target {
|
||||
targetId: string;
|
||||
name: string;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-readiness-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LoadingStateComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="readiness-dashboard">
|
||||
<div class="dashboard-header">
|
||||
<h2>Topology Readiness</h2>
|
||||
<p class="subtitle">Gate status for all targets across environments and regions</p>
|
||||
<button class="btn btn--secondary btn--sm" (click)="refresh()">
|
||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (loading() && reports().length === 0) {
|
||||
<app-loading-state size="md" message="Loading readiness data..." />
|
||||
}
|
||||
|
||||
@if (!loading() && reports().length === 0) {
|
||||
<div class="empty-state">
|
||||
<p>No readiness data available. Run validation on targets to see results.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@for (group of groupedReports(); track group.environmentId) {
|
||||
<div class="env-group">
|
||||
<h3 class="env-group__title">{{ group.environmentName }}</h3>
|
||||
<div class="gate-grid">
|
||||
<div class="gate-grid__header">
|
||||
<span class="gate-grid__cell gate-grid__cell--name">Target</span>
|
||||
@for (gate of gateNames; track gate) {
|
||||
<span class="gate-grid__cell gate-grid__cell--gate">{{ formatGateName(gate) }}</span>
|
||||
}
|
||||
<span class="gate-grid__cell gate-grid__cell--ready">Ready</span>
|
||||
</div>
|
||||
@for (report of group.reports; track report.targetId) {
|
||||
<div class="gate-grid__row" [class.gate-grid__row--ready]="report.isReady" [class.gate-grid__row--not-ready]="!report.isReady">
|
||||
<span class="gate-grid__cell gate-grid__cell--name">{{ getTargetName(report.targetId) }}</span>
|
||||
@for (gate of gateNames; track gate) {
|
||||
<span class="gate-grid__cell gate-grid__cell--gate" [title]="getGateMessage(report, gate)">
|
||||
{{ getGateIcon(report, gate) }}
|
||||
</span>
|
||||
}
|
||||
<span class="gate-grid__cell gate-grid__cell--ready">
|
||||
{{ report.isReady ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (lastRefresh()) {
|
||||
<p class="last-refresh">Last refresh: {{ lastRefresh() }}</p>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.readiness-dashboard {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dashboard-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
transition: background 150ms ease, border-color 150ms ease;
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.btn--secondary:hover:not(:disabled) {
|
||||
background: var(--color-brand-soft);
|
||||
border-color: var(--color-border-emphasis);
|
||||
}
|
||||
|
||||
.btn--secondary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--sm {
|
||||
padding: 0.3rem 0.65rem;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
border: 1px dashed var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.env-group {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.65rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.env-group__title {
|
||||
font-size: 0.92rem;
|
||||
color: var(--color-card-heading);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
padding-bottom: 0.35rem;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.gate-grid {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
font-size: 0.76rem;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.gate-grid__header {
|
||||
display: grid;
|
||||
grid-template-columns: 160px repeat(7, 80px) 60px;
|
||||
gap: 1px;
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.4rem 0;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.gate-grid__row {
|
||||
display: grid;
|
||||
grid-template-columns: 160px repeat(7, 80px) 60px;
|
||||
gap: 1px;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-secondary);
|
||||
transition: background 120ms ease;
|
||||
}
|
||||
|
||||
.gate-grid__row:nth-child(even) {
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
.gate-grid__row:hover {
|
||||
background: var(--color-brand-soft);
|
||||
}
|
||||
|
||||
.gate-grid__row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.gate-grid__row--ready {
|
||||
background: var(--color-status-success-bg);
|
||||
}
|
||||
|
||||
.gate-grid__row--not-ready {
|
||||
background: var(--color-status-error-bg);
|
||||
}
|
||||
|
||||
.gate-grid__cell {
|
||||
padding: 0.2rem 0.45rem;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.gate-grid__cell--name {
|
||||
text-align: left;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.gate-grid__cell--ready {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.last-refresh {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class ReadinessDashboardComponent implements OnInit {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly reports = signal<ReadinessReport[]>([]);
|
||||
readonly targets = signal<Map<string, string>>(new Map());
|
||||
readonly environments = signal<Map<string, string>>(new Map());
|
||||
readonly lastRefresh = signal<string | null>(null);
|
||||
|
||||
readonly gateNames = [
|
||||
'agent_bound', 'docker_version_ok', 'docker_ping_ok',
|
||||
'registry_pull_ok', 'vault_reachable', 'consul_reachable', 'connectivity_ok'
|
||||
];
|
||||
|
||||
readonly groupedReports = computed(() => {
|
||||
const envMap = this.environments();
|
||||
const grouped = new Map<string, { environmentId: string; environmentName: string; reports: ReadinessReport[] }>();
|
||||
|
||||
for (const report of this.reports()) {
|
||||
if (!grouped.has(report.environmentId)) {
|
||||
grouped.set(report.environmentId, {
|
||||
environmentId: report.environmentId,
|
||||
environmentName: envMap.get(report.environmentId) || report.environmentId,
|
||||
reports: [],
|
||||
});
|
||||
}
|
||||
grouped.get(report.environmentId)!.reports.push(report);
|
||||
}
|
||||
|
||||
return [...grouped.values()];
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.refresh();
|
||||
// Auto-refresh every 30 seconds
|
||||
interval(30000).subscribe(() => this.refresh());
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.loading.set(true);
|
||||
|
||||
// Fetch environments and their readiness
|
||||
this.http.get<{ items: { environmentId: string; displayName: string }[] }>('/api/v2/topology/environments')
|
||||
.pipe(catchError(() => of({ items: [] })))
|
||||
.subscribe(envResponse => {
|
||||
const envMap = new Map<string, string>();
|
||||
envResponse.items.forEach(e => envMap.set(e.environmentId, e.displayName));
|
||||
this.environments.set(envMap);
|
||||
|
||||
// For each environment, get readiness
|
||||
const readinessRequests = envResponse.items.map(env =>
|
||||
this.http.get<{ items: ReadinessReport[] }>(`/api/v1/environments/${env.environmentId}/readiness`)
|
||||
.pipe(catchError(() => of({ items: [] })))
|
||||
);
|
||||
|
||||
if (readinessRequests.length === 0) {
|
||||
this.loading.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
forkJoin(readinessRequests).subscribe(results => {
|
||||
const allReports = results.flatMap(r => r.items);
|
||||
this.reports.set(allReports);
|
||||
this.lastRefresh.set(new Date().toLocaleTimeString());
|
||||
this.loading.set(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getTargetName(targetId: string): string {
|
||||
return this.targets().get(targetId) || targetId.substring(0, 8);
|
||||
}
|
||||
|
||||
getGateIcon(report: ReadinessReport, gateName: string): string {
|
||||
const gate = report.gates.find(g => g.gateName === gateName);
|
||||
if (!gate) return '-';
|
||||
switch (gate.status) {
|
||||
case 'pass': return 'Pass';
|
||||
case 'fail': return 'Fail';
|
||||
case 'skip': return 'N/A';
|
||||
case 'pending': return '...';
|
||||
default: return '-';
|
||||
}
|
||||
}
|
||||
|
||||
getGateMessage(report: ReadinessReport, gateName: string): string {
|
||||
const gate = report.gates.find(g => g.gateName === gateName);
|
||||
return gate?.message || '';
|
||||
}
|
||||
|
||||
formatGateName(gate: string): string {
|
||||
return gate.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()).replace(' Ok', '');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
|
||||
import { SidebarPreferenceService } from '../app-sidebar/sidebar-preference.service';
|
||||
import { ContentWidthService } from '../../core/services/content-width.service';
|
||||
import { SearchAssistantHostComponent } from '../search-assistant-host/search-assistant-host.component';
|
||||
import { StellaHelperComponent } from '../../shared/components/stella-helper/stella-helper.component';
|
||||
import { StellaTourComponent } from '../../shared/components/stella-helper/stella-tour.component';
|
||||
|
||||
/**
|
||||
* AppShellComponent - Main application shell with permanent left rail navigation.
|
||||
@@ -31,6 +33,8 @@ import { SearchAssistantHostComponent } from '../search-assistant-host/search-as
|
||||
BreadcrumbComponent,
|
||||
OverlayHostComponent,
|
||||
SearchAssistantHostComponent,
|
||||
StellaHelperComponent,
|
||||
StellaTourComponent,
|
||||
],
|
||||
template: `
|
||||
<div class="shell" [class.shell--mobile-open]="mobileMenuOpen()" [class.shell--collapsed]="sidebarPrefs.sidebarCollapsed()">
|
||||
@@ -95,7 +99,14 @@ import { SearchAssistantHostComponent } from '../search-assistant-host/search-as
|
||||
|
||||
<!-- Overlay host for drawers/modals -->
|
||||
<app-overlay-host></app-overlay-host>
|
||||
<!-- AI Chat drawer — full ChatComponent side panel. Opened by mascot, search bar, and Ctrl+K -->
|
||||
<app-search-assistant-host></app-search-assistant-host>
|
||||
|
||||
<!-- Stella Assistant — unified tips + search + AI chat -->
|
||||
<app-stella-helper></app-stella-helper>
|
||||
|
||||
<!-- Stella Tour Engine — guided walkthroughs -->
|
||||
<app-stella-tour></app-stella-tour>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
|
||||
@@ -626,8 +626,9 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
private readonly pendingApprovalsBadgeLoading = signal(false);
|
||||
|
||||
/**
|
||||
* Navigation sections - canonical 7-group IA.
|
||||
* Groups: Home (ungrouped), Release Control, Security, Policy, Operations, Audit & Evidence, Setup & Admin.
|
||||
* Navigation sections - canonical 6-group IA.
|
||||
* Groups: Home (ungrouped), Release Control, Security, Evidence, Operations, Settings.
|
||||
* Policy dissolved: VEX/Governance/Simulation/Audit absorbed into Security; Packs into Operations.
|
||||
*/
|
||||
readonly navSections: NavSection[] = [
|
||||
// ── Home (ungrouped) ───────────────────────────────────────────
|
||||
@@ -640,6 +641,18 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
menuGroupLabel: '',
|
||||
},
|
||||
// ── Group 1: Release Control ─────────────────────────────────────
|
||||
{
|
||||
id: 'ops-environments',
|
||||
label: 'Environments',
|
||||
icon: 'globe',
|
||||
route: '/environments/overview',
|
||||
menuGroupId: 'release-control',
|
||||
menuGroupLabel: 'Release Control',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'deployments',
|
||||
label: 'Deployments',
|
||||
@@ -669,9 +682,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
StellaOpsScopes.RELEASE_PUBLISH,
|
||||
],
|
||||
},
|
||||
// Versions merged into Releases page as a tab
|
||||
// Approvals nav item removed — merged into Deployments page as a tab
|
||||
// ── Group 2: Security ────────────────────────────────────────────
|
||||
// ── Group 2: Security (absorbs former Policy group) ──────────────
|
||||
{
|
||||
id: 'vulnerabilities',
|
||||
label: 'Vulnerabilities',
|
||||
@@ -705,6 +716,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
],
|
||||
children: [
|
||||
{ id: 'sec-supply-chain', label: 'Supply-Chain Data', route: '/security/supply-chain-data', icon: 'graph' },
|
||||
{ id: 'sec-findings-explorer', label: 'Findings Explorer', route: '/security/findings', icon: 'list' },
|
||||
{ id: 'sec-reachability', label: 'Reachability', route: '/security/reachability', icon: 'cpu' },
|
||||
{ id: 'sec-unknowns', label: 'Unknowns', route: '/security/unknowns', icon: 'help-circle' },
|
||||
],
|
||||
@@ -718,8 +730,83 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
menuGroupLabel: 'Security',
|
||||
requireAnyScope: [StellaOpsScopes.SCANNER_READ],
|
||||
},
|
||||
// Reports merged into Security Posture (export actions moved inline)
|
||||
// ── Group 3: Operations ──────────────────────────────────────────
|
||||
{
|
||||
id: 'sec-vex-exceptions',
|
||||
label: 'VEX & Exceptions',
|
||||
icon: 'file-text',
|
||||
route: '/ops/policy/vex',
|
||||
menuGroupId: 'security',
|
||||
menuGroupLabel: 'Security',
|
||||
requireAnyScope: [StellaOpsScopes.VEX_READ, StellaOpsScopes.EXCEPTION_READ],
|
||||
},
|
||||
{
|
||||
id: 'sec-risk-governance',
|
||||
label: 'Risk & Governance',
|
||||
icon: 'shield',
|
||||
route: '/ops/policy/governance',
|
||||
menuGroupId: 'security',
|
||||
menuGroupLabel: 'Security',
|
||||
requireAnyScope: [StellaOpsScopes.POLICY_READ],
|
||||
children: [
|
||||
{ id: 'sec-simulation', label: 'Simulation', route: '/ops/policy/simulation', icon: 'play' },
|
||||
{ id: 'sec-policy-audit', label: 'Policy Audit', route: '/ops/policy/audit', icon: 'list' },
|
||||
],
|
||||
},
|
||||
// ── Group 3: Evidence (trimmed from 7 to 4) ──────────────────────
|
||||
{
|
||||
id: 'evidence-overview',
|
||||
label: 'Evidence Overview',
|
||||
icon: 'file-text',
|
||||
route: '/evidence/overview',
|
||||
menuGroupId: 'evidence',
|
||||
menuGroupLabel: 'Evidence',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
StellaOpsScopes.AUTHORITY_AUDIT_READ,
|
||||
StellaOpsScopes.SIGNER_READ,
|
||||
StellaOpsScopes.VEX_EXPORT,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'evidence-capsules',
|
||||
label: 'Decision Capsules',
|
||||
icon: 'archive',
|
||||
route: '/evidence/capsules',
|
||||
menuGroupId: 'evidence',
|
||||
menuGroupLabel: 'Evidence',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'evidence-audit-log',
|
||||
label: 'Audit Log',
|
||||
icon: 'list',
|
||||
route: '/evidence/audit-log',
|
||||
menuGroupId: 'evidence',
|
||||
menuGroupLabel: 'Evidence',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
StellaOpsScopes.AUTHORITY_AUDIT_READ,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'evidence-exports',
|
||||
label: 'Export Center',
|
||||
icon: 'download',
|
||||
route: '/evidence/exports',
|
||||
menuGroupId: 'evidence',
|
||||
menuGroupLabel: 'Evidence',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.VEX_EXPORT,
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
],
|
||||
},
|
||||
// Replay & Verify, Bundles, Trust — removed from nav, still routable.
|
||||
// Accessible from Evidence Overview, Decision Capsules detail, and Audit Log filters.
|
||||
// ── Group 4: Operations (trimmed, absorbs Policy Packs) ──────────
|
||||
{
|
||||
id: 'ops',
|
||||
label: 'Operations Hub',
|
||||
@@ -737,6 +824,15 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
StellaOpsScopes.POLICY_READ,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ops-policy-packs',
|
||||
label: 'Policy Packs',
|
||||
icon: 'clipboard',
|
||||
route: '/ops/policy/packs',
|
||||
menuGroupId: 'operations',
|
||||
menuGroupLabel: 'Operations',
|
||||
requireAnyScope: [StellaOpsScopes.POLICY_READ],
|
||||
},
|
||||
{
|
||||
id: 'ops-jobs',
|
||||
label: 'Scheduled Jobs',
|
||||
@@ -750,10 +846,22 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ops-scripts',
|
||||
label: 'Scripts',
|
||||
icon: 'code',
|
||||
route: '/ops/scripts',
|
||||
id: 'ops-feeds-airgap',
|
||||
label: 'Feeds & Airgap',
|
||||
icon: 'download-cloud',
|
||||
route: '/ops/operations/feeds-airgap',
|
||||
menuGroupId: 'operations',
|
||||
menuGroupLabel: 'Operations',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.ADVISORY_READ,
|
||||
StellaOpsScopes.VEX_READ,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ops-agents',
|
||||
label: 'Agent Fleet',
|
||||
icon: 'cpu',
|
||||
route: '/ops/operations/agents',
|
||||
menuGroupId: 'operations',
|
||||
menuGroupLabel: 'Operations',
|
||||
requireAnyScope: [
|
||||
@@ -774,10 +882,10 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ops-agents',
|
||||
label: 'Agent Fleet',
|
||||
icon: 'cpu',
|
||||
route: '/ops/operations/agents',
|
||||
id: 'ops-scripts',
|
||||
label: 'Scripts',
|
||||
icon: 'code',
|
||||
route: '/ops/scripts',
|
||||
menuGroupId: 'operations',
|
||||
menuGroupLabel: 'Operations',
|
||||
requireAnyScope: [
|
||||
@@ -785,75 +893,6 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ops-drift',
|
||||
label: 'Runtime Drift',
|
||||
icon: 'alert-triangle',
|
||||
route: '/ops/operations/drift',
|
||||
menuGroupId: 'operations',
|
||||
menuGroupLabel: 'Operations',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ops-environments',
|
||||
label: 'Environments',
|
||||
icon: 'globe',
|
||||
route: '/environments/overview',
|
||||
menuGroupId: 'release-control',
|
||||
menuGroupLabel: 'Release Control',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.ORCH_READ,
|
||||
StellaOpsScopes.ORCH_OPERATE,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ops-policy',
|
||||
label: 'Packs',
|
||||
icon: 'clipboard',
|
||||
route: '/ops/policy/packs',
|
||||
menuGroupId: 'policy',
|
||||
menuGroupLabel: 'Policy',
|
||||
requireAnyScope: [StellaOpsScopes.POLICY_READ],
|
||||
},
|
||||
{
|
||||
id: 'ops-policy-governance',
|
||||
label: 'Governance',
|
||||
icon: 'shield',
|
||||
route: '/ops/policy/governance',
|
||||
menuGroupId: 'policy',
|
||||
menuGroupLabel: 'Policy',
|
||||
requireAnyScope: [StellaOpsScopes.POLICY_READ],
|
||||
},
|
||||
{
|
||||
id: 'ops-policy-simulation',
|
||||
label: 'Simulation',
|
||||
icon: 'play',
|
||||
route: '/ops/policy/simulation',
|
||||
menuGroupId: 'policy',
|
||||
menuGroupLabel: 'Policy',
|
||||
requireAnyScope: [StellaOpsScopes.POLICY_SIMULATE],
|
||||
},
|
||||
{
|
||||
id: 'ops-policy-vex',
|
||||
label: 'VEX & Exceptions',
|
||||
icon: 'file-text',
|
||||
route: '/ops/policy/vex',
|
||||
menuGroupId: 'policy',
|
||||
menuGroupLabel: 'Policy',
|
||||
requireAnyScope: [StellaOpsScopes.VEX_READ, StellaOpsScopes.EXCEPTION_READ],
|
||||
},
|
||||
// Release Gates absorbed into Deployments > Approvals tab
|
||||
{
|
||||
id: 'ops-policy-audit',
|
||||
label: 'Policy Audit',
|
||||
icon: 'list',
|
||||
route: '/ops/policy/audit',
|
||||
menuGroupId: 'policy',
|
||||
menuGroupLabel: 'Policy',
|
||||
requireAnyScope: [StellaOpsScopes.POLICY_AUDIT],
|
||||
},
|
||||
{
|
||||
id: 'ops-diagnostics',
|
||||
label: 'Diagnostics',
|
||||
@@ -863,123 +902,9 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
menuGroupLabel: 'Operations',
|
||||
requireAnyScope: [StellaOpsScopes.HEALTH_READ, StellaOpsScopes.UI_ADMIN],
|
||||
},
|
||||
{
|
||||
id: 'ops-notifications',
|
||||
label: 'Notifications',
|
||||
icon: 'bell',
|
||||
route: '/ops/operations/notifications',
|
||||
menuGroupId: 'operations',
|
||||
menuGroupLabel: 'Operations',
|
||||
requireAnyScope: [StellaOpsScopes.NOTIFY_VIEWER],
|
||||
},
|
||||
{
|
||||
id: 'ops-feeds-airgap',
|
||||
label: 'Feeds & Airgap',
|
||||
icon: 'download-cloud',
|
||||
route: '/ops/operations/feeds-airgap',
|
||||
menuGroupId: 'operations',
|
||||
menuGroupLabel: 'Operations',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.ADVISORY_READ,
|
||||
StellaOpsScopes.VEX_READ,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ops-watchlist',
|
||||
label: 'Watchlist',
|
||||
icon: 'eye',
|
||||
route: '/ops/operations/watchlist',
|
||||
menuGroupId: 'operations',
|
||||
menuGroupLabel: 'Operations',
|
||||
requireAnyScope: [StellaOpsScopes.SIGNER_READ],
|
||||
},
|
||||
// Trust Analytics merged into Trust (Audit & Evidence group)
|
||||
// ── Group 4: Audit & Evidence ────────────────────────────────────
|
||||
{
|
||||
id: 'evidence-overview',
|
||||
label: 'Evidence Overview',
|
||||
icon: 'file-text',
|
||||
route: '/evidence/overview',
|
||||
menuGroupId: 'audit-evidence',
|
||||
menuGroupLabel: 'Audit & Evidence',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
StellaOpsScopes.AUTHORITY_AUDIT_READ,
|
||||
StellaOpsScopes.SIGNER_READ,
|
||||
StellaOpsScopes.VEX_EXPORT,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'evidence-capsules',
|
||||
label: 'Decision Capsules',
|
||||
icon: 'archive',
|
||||
route: '/evidence/capsules',
|
||||
menuGroupId: 'audit-evidence',
|
||||
menuGroupLabel: 'Audit & Evidence',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'evidence-verify',
|
||||
label: 'Replay & Verify',
|
||||
icon: 'refresh',
|
||||
route: '/evidence/verify-replay',
|
||||
menuGroupId: 'audit-evidence',
|
||||
menuGroupLabel: 'Audit & Evidence',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.SIGNER_READ,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'evidence-exports',
|
||||
label: 'Export Center',
|
||||
icon: 'download',
|
||||
route: '/evidence/exports',
|
||||
menuGroupId: 'audit-evidence',
|
||||
menuGroupLabel: 'Audit & Evidence',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.VEX_EXPORT,
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'evidence-audit-log',
|
||||
label: 'Audit Log',
|
||||
icon: 'list',
|
||||
route: '/evidence/audit-log',
|
||||
menuGroupId: 'audit-evidence',
|
||||
menuGroupLabel: 'Audit & Evidence',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
StellaOpsScopes.AUTHORITY_AUDIT_READ,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'evidence-bundles',
|
||||
label: 'Bundles',
|
||||
icon: 'inbox',
|
||||
route: '/evidence/bundles',
|
||||
menuGroupId: 'audit-evidence',
|
||||
menuGroupLabel: 'Audit & Evidence',
|
||||
requireAnyScope: [
|
||||
StellaOpsScopes.RELEASE_READ,
|
||||
StellaOpsScopes.POLICY_AUDIT,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'evidence-trust',
|
||||
label: 'Trust',
|
||||
icon: 'shield-check',
|
||||
route: '/evidence/audit-log/trust',
|
||||
menuGroupId: 'audit-evidence',
|
||||
menuGroupLabel: 'Audit & Evidence',
|
||||
requireAnyScope: [StellaOpsScopes.SIGNER_READ],
|
||||
},
|
||||
// ── Group 5: Setup & Admin ───────────────────────────────────────
|
||||
// Runtime Drift, Notifications, Watchlist — removed from nav, still routable.
|
||||
// Accessible from Operations Hub landing page.
|
||||
// ── Group 5: Settings ────────────────────────────────────────────
|
||||
{
|
||||
id: 'setup-integrations',
|
||||
label: 'Integrations',
|
||||
@@ -1003,7 +928,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
},
|
||||
{
|
||||
id: 'setup-trust-signing',
|
||||
label: 'Certificates',
|
||||
label: 'Certificates & Trust',
|
||||
icon: 'key',
|
||||
route: '/setup/trust-signing',
|
||||
menuGroupId: 'setup-admin',
|
||||
@@ -1052,7 +977,7 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
/** Menu groups rendered in deterministic order for scanability */
|
||||
readonly displaySectionGroups = computed<NavSectionGroup[]>(() => {
|
||||
const orderedGroups = new Map<string, NavSectionGroup>();
|
||||
const groupOrder = ['home', 'release-control', 'security', 'policy', 'operations', 'audit-evidence', 'setup-admin', 'misc'];
|
||||
const groupOrder = ['home', 'release-control', 'security', 'evidence', 'operations', 'setup-admin', 'misc'];
|
||||
|
||||
for (const groupId of groupOrder) {
|
||||
orderedGroups.set(groupId, {
|
||||
@@ -1171,12 +1096,10 @@ export class AppSidebarComponent implements AfterViewInit {
|
||||
return 'Release Control';
|
||||
case 'security':
|
||||
return 'Security';
|
||||
case 'policy':
|
||||
return 'Policy';
|
||||
case 'evidence':
|
||||
return 'Evidence';
|
||||
case 'operations':
|
||||
return 'Operations';
|
||||
case 'audit-evidence':
|
||||
return 'Audit & Evidence';
|
||||
case 'setup-admin':
|
||||
return 'Settings';
|
||||
default:
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface NavItem {
|
||||
[routerLink]="route"
|
||||
routerLinkActive="nav-item--active"
|
||||
[routerLinkActiveOptions]="isChild ? { paths: 'subset', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored' } : { paths: 'exact', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored' }"
|
||||
[attr.title]="collapsed ? label : null"
|
||||
[attr.title]="label"
|
||||
>
|
||||
<span class="nav-item__icon" [attr.aria-hidden]="true">
|
||||
@switch (icon) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -209,9 +209,14 @@ function toDateKey(iso: string, locale?: string): string {
|
||||
[class]="'timeline__marker--' + (event.eventKind || 'neutral')"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@if (event.icon) {
|
||||
<span class="timeline__icon material-symbols-outlined">{{ event.icon }}</span>
|
||||
}
|
||||
<svg class="timeline__icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
@switch (event.eventKind) {
|
||||
@case ('success') { <polyline points="20 6 9 17 4 12"/> }
|
||||
@case ('error') { <circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/> }
|
||||
@case ('warning') { <circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/> }
|
||||
@default { <circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/> }
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="timeline__content">
|
||||
@@ -280,7 +285,10 @@ function toDateKey(iso: string, locale?: string): string {
|
||||
<!-- Empty state template -->
|
||||
<ng-template #emptyState>
|
||||
<div class="timeline__empty" role="status">
|
||||
<span class="timeline__empty-icon material-symbols-outlined" aria-hidden="true">event_busy</span>
|
||||
<svg class="timeline__empty-icon" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
||||
<line x1="10" y1="14" x2="14" y2="18"/><line x1="14" y1="14" x2="10" y2="18"/>
|
||||
</svg>
|
||||
<p class="timeline__empty-text">{{ emptyMessage() }}</p>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -369,8 +377,9 @@ function toDateKey(iso: string, locale?: string): string {
|
||||
}
|
||||
|
||||
.timeline__icon {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timeline__marker--success {
|
||||
@@ -539,7 +548,6 @@ function toDateKey(iso: string, locale?: string): string {
|
||||
}
|
||||
|
||||
.timeline__empty-icon {
|
||||
font-size: 2rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Navigation model unit tests
|
||||
* Sprint: SPRINT_20260220_021_FE_pack22_run_centric_releases_platform_scope (FE21-01, FE21-11)
|
||||
* Validates the 6-group sidebar structure after Policy dissolution.
|
||||
*/
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
@@ -12,31 +12,30 @@ import { AUTH_SERVICE } from '../../app/core/auth';
|
||||
import { APPROVAL_API } from '../../app/core/api/approval.client';
|
||||
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
|
||||
|
||||
/**
|
||||
* The 6 canonical menuGroupId values used in the sidebar.
|
||||
* Policy was dissolved: VEX/Governance/Simulation/Audit -> Security, Packs -> Operations.
|
||||
*/
|
||||
const CANONICAL_DOMAIN_IDS = [
|
||||
'dashboard',
|
||||
'releases',
|
||||
'home',
|
||||
'release-control',
|
||||
'security',
|
||||
'evidence',
|
||||
'topology',
|
||||
'platform',
|
||||
] as const;
|
||||
|
||||
const CANONICAL_DOMAIN_ROUTES = [
|
||||
'/dashboard',
|
||||
'/releases',
|
||||
'/security',
|
||||
'/evidence',
|
||||
'/topology',
|
||||
'/platform',
|
||||
'operations',
|
||||
'setup-admin',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Expected group labels rendered in the sidebar header for each menuGroupId.
|
||||
* 'home' has no visible label (empty string); the rest use clean canonical names.
|
||||
*/
|
||||
const EXPECTED_SECTION_LABELS: Record<string, string> = {
|
||||
dashboard: 'Mission Control',
|
||||
releases: 'Releases',
|
||||
security: 'Security',
|
||||
evidence: 'Evidence',
|
||||
topology: 'Topology',
|
||||
platform: 'Platform',
|
||||
'home': '',
|
||||
'release-control': 'Release Control',
|
||||
'security': 'Security',
|
||||
'evidence': 'Evidence',
|
||||
'operations': 'Operations',
|
||||
'setup-admin': 'Settings',
|
||||
};
|
||||
|
||||
describe('AppSidebarComponent nav model (navigation)', () => {
|
||||
@@ -84,68 +83,158 @@ describe('AppSidebarComponent nav model (navigation)', () => {
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('defines exactly 6 canonical root domains', () => {
|
||||
expect(component.navSections.length).toBe(6);
|
||||
it('renders exactly 6 menu groups', () => {
|
||||
const groupIds = new Set(
|
||||
component.navSections
|
||||
.map((s) => s.menuGroupId)
|
||||
.filter((id): id is string => !!id),
|
||||
);
|
||||
expect(groupIds.size).toBe(6);
|
||||
});
|
||||
|
||||
it('root domain IDs match canonical IA order', () => {
|
||||
expect(component.navSections.map((s) => s.id)).toEqual([...CANONICAL_DOMAIN_IDS]);
|
||||
it('menu group IDs match the 6 canonical domain IDs', () => {
|
||||
const groupIds = [
|
||||
...new Set(
|
||||
component.navSections
|
||||
.map((s) => s.menuGroupId)
|
||||
.filter((id): id is string => !!id),
|
||||
),
|
||||
];
|
||||
// Order is determined by the navSections declaration order
|
||||
expect(groupIds).toEqual([...CANONICAL_DOMAIN_IDS]);
|
||||
});
|
||||
|
||||
it('root domain routes all point to canonical paths', () => {
|
||||
expect(component.navSections.map((s) => s.route)).toEqual([...CANONICAL_DOMAIN_ROUTES]);
|
||||
});
|
||||
|
||||
it('section labels use clean canonical names', () => {
|
||||
it('menu group labels match canonical names', () => {
|
||||
// Build a map of menuGroupId -> menuGroupLabel from the first section in each group
|
||||
const labelMap = new Map<string, string>();
|
||||
for (const section of component.navSections) {
|
||||
expect(section.label).toBe(EXPECTED_SECTION_LABELS[section.id]);
|
||||
const gid = section.menuGroupId;
|
||||
if (gid && !labelMap.has(gid)) {
|
||||
labelMap.set(gid, section.menuGroupLabel ?? '');
|
||||
}
|
||||
}
|
||||
for (const [groupId, expectedLabel] of Object.entries(EXPECTED_SECTION_LABELS)) {
|
||||
expect(labelMap.get(groupId)).toBe(expectedLabel);
|
||||
}
|
||||
});
|
||||
|
||||
it('Releases uses run/version-first navigation shortcuts', () => {
|
||||
const releases = component.navSections.find((s) => s.id === 'releases')!;
|
||||
const childIds = releases.children?.map((child) => child.id) ?? [];
|
||||
|
||||
expect(childIds).toContain('rel-versions');
|
||||
expect(childIds).toContain('rel-runs');
|
||||
expect(childIds).toContain('rel-approvals');
|
||||
expect(childIds).toContain('rel-hotfix');
|
||||
it('policy is not a standalone group (dissolved into Security and Operations)', () => {
|
||||
const groupIds = new Set(component.navSections.map((s) => s.menuGroupId));
|
||||
expect(groupIds.has('policy')).toBeFalse();
|
||||
expect(groupIds.has('policy-governance')).toBeFalse();
|
||||
});
|
||||
|
||||
it('Releases create route uses canonical version-creation path', () => {
|
||||
const releases = component.navSections.find((s) => s.id === 'releases')!;
|
||||
const create = releases.children!.find((child) => child.id === 'rel-create')!;
|
||||
expect(create.route).toBe('/releases/versions/new');
|
||||
it('Release Control group contains Environments, Deployments, Releases', () => {
|
||||
const rcSections = component.navSections.filter((s) => s.menuGroupId === 'release-control');
|
||||
const ids = rcSections.map((s) => s.id);
|
||||
|
||||
expect(ids).toContain('ops-environments');
|
||||
expect(ids).toContain('deployments');
|
||||
expect(ids).toContain('releases');
|
||||
});
|
||||
|
||||
it('derives approvals queue badge from pending approvals', () => {
|
||||
const releases = component.visibleSections().find((s) => s.id === 'releases')!;
|
||||
const approvals = releases.children!.find((child) => child.id === 'rel-approvals')!;
|
||||
expect(approvals.badge).toBe(1);
|
||||
it('Environments is the first item in Release Control and uses /environments/overview', () => {
|
||||
const rcSections = component.navSections.filter((s) => s.menuGroupId === 'release-control');
|
||||
const first = rcSections[0];
|
||||
expect(first.id).toBe('ops-environments');
|
||||
expect(first.route).toBe('/environments/overview');
|
||||
});
|
||||
|
||||
it('Evidence uses capsule-first workflow labels', () => {
|
||||
const evidence = component.navSections.find((s) => s.id === 'evidence')!;
|
||||
const capsules = evidence.children?.find((child) => child.id === 'ev-capsules');
|
||||
const verify = evidence.children?.find((child) => child.id === 'ev-verify');
|
||||
expect(capsules?.route).toBe('/evidence/capsules');
|
||||
expect(verify?.route).toBe('/evidence/verification/replay');
|
||||
it('Security group absorbs former Policy items (VEX, Governance, Simulation, Audit)', () => {
|
||||
const secSections = component.navSections.filter((s) => s.menuGroupId === 'security');
|
||||
const ids = secSections.map((s) => s.id);
|
||||
|
||||
expect(ids).toContain('vulnerabilities');
|
||||
expect(ids).toContain('security-posture');
|
||||
expect(ids).toContain('scan-image');
|
||||
expect(ids).toContain('sec-vex-exceptions');
|
||||
expect(ids).toContain('sec-risk-governance');
|
||||
});
|
||||
|
||||
it('Platform group owns ops/integrations/setup shortcuts', () => {
|
||||
const platform = component.navSections.find((s) => s.id === 'platform')!;
|
||||
const routes = platform.children?.map((child) => child.route) ?? [];
|
||||
it('Findings Explorer is surfaced under Security Posture children', () => {
|
||||
const posture = component.navSections.find((s) => s.id === 'security-posture')!;
|
||||
const childIds = posture.children?.map((c) => c.id) ?? [];
|
||||
expect(childIds).toContain('sec-findings-explorer');
|
||||
|
||||
expect(routes).toContain('/platform/ops');
|
||||
expect(routes).toContain('/platform/integrations');
|
||||
expect(routes).toContain('/platform/setup');
|
||||
const findings = posture.children?.find((c) => c.id === 'sec-findings-explorer');
|
||||
expect(findings?.route).toBe('/security/findings');
|
||||
});
|
||||
|
||||
it('no section root route uses deprecated root prefixes', () => {
|
||||
const legacyRootSegments = ['release-control', 'security-risk', 'evidence-audit', 'platform-ops'];
|
||||
it('Evidence group has 4 items (Overview, Capsules, Audit Log, Export Center)', () => {
|
||||
const evSections = component.navSections.filter((s) => s.menuGroupId === 'evidence');
|
||||
const ids = evSections.map((s) => s.id);
|
||||
|
||||
expect(evSections.length).toBe(4);
|
||||
expect(ids).toContain('evidence-overview');
|
||||
expect(ids).toContain('evidence-capsules');
|
||||
expect(ids).toContain('evidence-audit-log');
|
||||
expect(ids).toContain('evidence-exports');
|
||||
});
|
||||
|
||||
it('Evidence does not contain Replay & Verify, Bundles, or Trust (removed from nav)', () => {
|
||||
const evSections = component.navSections.filter((s) => s.menuGroupId === 'evidence');
|
||||
const routes = evSections.map((s) => s.route);
|
||||
|
||||
expect(routes).not.toContain('/evidence/verify-replay');
|
||||
expect(routes).not.toContain('/evidence/verification/replay');
|
||||
expect(routes).not.toContain('/evidence/bundles');
|
||||
|
||||
const ids = evSections.map((s) => s.id);
|
||||
expect(ids).not.toContain('ev-verify');
|
||||
expect(ids).not.toContain('ev-bundles');
|
||||
expect(ids).not.toContain('ev-trust');
|
||||
});
|
||||
|
||||
it('Operations group absorbs Policy Packs from dissolved Policy group', () => {
|
||||
const opsSections = component.navSections.filter((s) => s.menuGroupId === 'operations');
|
||||
const ids = opsSections.map((s) => s.id);
|
||||
|
||||
expect(ids).toContain('ops-policy-packs');
|
||||
expect(ids).toContain('ops');
|
||||
expect(ids).toContain('ops-jobs');
|
||||
expect(ids).toContain('ops-feeds-airgap');
|
||||
expect(ids).toContain('ops-agents');
|
||||
expect(ids).toContain('ops-signals');
|
||||
expect(ids).toContain('ops-scripts');
|
||||
expect(ids).toContain('ops-diagnostics');
|
||||
});
|
||||
|
||||
it('Operations does not contain Runtime Drift, Notifications, or Watchlist (removed from nav)', () => {
|
||||
const opsSections = component.navSections.filter((s) => s.menuGroupId === 'operations');
|
||||
const ids = opsSections.map((s) => s.id);
|
||||
const routes = opsSections.map((s) => s.route);
|
||||
|
||||
expect(ids).not.toContain('ops-drift');
|
||||
expect(ids).not.toContain('ops-notifications');
|
||||
expect(ids).not.toContain('ops-watchlist');
|
||||
expect(routes).not.toContain('/ops/operations/drift');
|
||||
expect(routes).not.toContain('/ops/operations/notifications');
|
||||
expect(routes).not.toContain('/ops/operations/watchlist');
|
||||
});
|
||||
|
||||
it('Settings group uses setup-admin menuGroupId', () => {
|
||||
const setupSections = component.navSections.filter((s) => s.menuGroupId === 'setup-admin');
|
||||
const ids = setupSections.map((s) => s.id);
|
||||
|
||||
expect(ids).toContain('setup-integrations');
|
||||
expect(ids).toContain('setup-iam');
|
||||
expect(ids).toContain('setup-trust-signing');
|
||||
expect(ids).toContain('setup-branding');
|
||||
expect(ids).toContain('setup-preferences');
|
||||
});
|
||||
|
||||
it('Certificates & Trust label replaces old "Certificates" label', () => {
|
||||
const trustItem = component.navSections.find((s) => s.id === 'setup-trust-signing')!;
|
||||
expect(trustItem).toBeDefined();
|
||||
expect(trustItem.label).toBe('Certificates & Trust');
|
||||
});
|
||||
|
||||
it('no section uses deprecated root prefixes or legacy group IDs', () => {
|
||||
const legacyGroupIds = ['audit-evidence', 'policy', 'platform', 'topology', 'dashboard', 'integrations', 'administration'];
|
||||
for (const section of component.navSections) {
|
||||
const rootSegment = section.route.replace(/^\/+/, '').split('/')[0] ?? '';
|
||||
expect(legacyRootSegments).not.toContain(rootSegment);
|
||||
if (section.menuGroupId) {
|
||||
expect(legacyGroupIds).not.toContain(section.menuGroupId);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* Navigation route integrity tests
|
||||
* Validates that every sidebar route resolves to a concrete canonical route
|
||||
* and that required routes are present after the 7->6 group restructure.
|
||||
*/
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, type Route } from '@angular/router';
|
||||
|
||||
@@ -5,12 +11,11 @@ import { AUTH_SERVICE } from '../../app/core/auth';
|
||||
import { AppSidebarComponent } from '../../app/layout/app-sidebar/app-sidebar.component';
|
||||
import { EVIDENCE_ROUTES } from '../../app/routes/evidence.routes';
|
||||
import { OPERATIONS_ROUTES } from '../../app/routes/operations.routes';
|
||||
import { PLATFORM_ROUTES } from '../../app/routes/platform.routes';
|
||||
import { RELEASES_ROUTES } from '../../app/routes/releases.routes';
|
||||
import { SECURITY_ROUTES } from '../../app/routes/security.routes';
|
||||
import { TOPOLOGY_ROUTES } from '../../app/routes/topology.routes';
|
||||
import { integrationHubRoutes } from '../../app/features/integration-hub/integration-hub.routes';
|
||||
import { PLATFORM_SETUP_ROUTES } from '../../app/features/platform/setup/platform-setup.routes';
|
||||
import { SETUP_ROUTES } from '../../app/routes/setup.routes';
|
||||
import { OPS_ROUTES } from '../../app/routes/ops.routes';
|
||||
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
|
||||
|
||||
function joinPath(prefix: string, path: string | undefined): string | null {
|
||||
@@ -59,85 +64,188 @@ describe('AppSidebarComponent route integrity (navigation)', () => {
|
||||
});
|
||||
|
||||
it('every sidebar route resolves to a concrete canonical route', () => {
|
||||
// Build allowed route set from all registered route modules
|
||||
const allowed = new Set<string>([
|
||||
'/dashboard',
|
||||
'/',
|
||||
'/releases',
|
||||
'/security',
|
||||
'/evidence',
|
||||
'/topology',
|
||||
'/platform',
|
||||
'/triage/artifacts',
|
||||
]);
|
||||
|
||||
for (const path of collectConcretePaths('/releases', RELEASES_ROUTES)) allowed.add(path);
|
||||
for (const path of collectConcretePaths('/security', SECURITY_ROUTES)) allowed.add(path);
|
||||
for (const path of collectConcretePaths('/evidence', EVIDENCE_ROUTES)) allowed.add(path);
|
||||
for (const path of collectConcretePaths('/topology', TOPOLOGY_ROUTES)) allowed.add(path);
|
||||
for (const path of collectConcretePaths('/platform', PLATFORM_ROUTES)) allowed.add(path);
|
||||
for (const path of collectConcretePaths('/platform/ops', OPERATIONS_ROUTES)) allowed.add(path);
|
||||
for (const path of collectConcretePaths('/platform/integrations', integrationHubRoutes)) allowed.add(path);
|
||||
for (const path of collectConcretePaths('/platform/setup', PLATFORM_SETUP_ROUTES)) allowed.add(path);
|
||||
allowed.add('/security/sbom/lake');
|
||||
for (const path of collectConcretePaths('/ops', OPS_ROUTES)) allowed.add(path);
|
||||
for (const path of collectConcretePaths('/ops/operations', OPERATIONS_ROUTES)) allowed.add(path);
|
||||
for (const path of collectConcretePaths('/setup', SETUP_ROUTES)) allowed.add(path);
|
||||
for (const path of collectConcretePaths('/environments', TOPOLOGY_ROUTES)) allowed.add(path);
|
||||
|
||||
// Routes used in the nav that are defined in nested lazy-loaded children
|
||||
allowed.add('/ops/policy/vex');
|
||||
allowed.add('/ops/policy/governance');
|
||||
allowed.add('/ops/policy/simulation');
|
||||
allowed.add('/ops/policy/audit');
|
||||
allowed.add('/ops/policy/packs');
|
||||
allowed.add('/ops/operations/jobengine');
|
||||
allowed.add('/ops/operations/feeds-airgap');
|
||||
allowed.add('/ops/operations/agents');
|
||||
allowed.add('/ops/operations/signals');
|
||||
allowed.add('/ops/operations/doctor');
|
||||
allowed.add('/ops/scripts');
|
||||
allowed.add('/security/supply-chain-data');
|
||||
allowed.add('/security/findings');
|
||||
allowed.add('/security/reachability');
|
||||
allowed.add('/security/unknowns');
|
||||
allowed.add('/security/scan');
|
||||
allowed.add('/evidence/overview');
|
||||
allowed.add('/evidence/capsules');
|
||||
allowed.add('/evidence/audit-log');
|
||||
allowed.add('/evidence/exports');
|
||||
allowed.add('/releases/deployments');
|
||||
allowed.add('/environments/overview');
|
||||
allowed.add('/setup/integrations');
|
||||
allowed.add('/setup/identity-access');
|
||||
allowed.add('/setup/trust-signing');
|
||||
allowed.add('/setup/tenant-branding');
|
||||
allowed.add('/setup/preferences');
|
||||
allowed.add('/console/admin/tenants');
|
||||
allowed.add('/console/admin/users');
|
||||
allowed.add('/console/admin/roles');
|
||||
allowed.add('/console/admin/clients');
|
||||
allowed.add('/console/admin/tokens');
|
||||
allowed.add('/concelier/trivy-db-settings');
|
||||
|
||||
for (const section of component.navSections) {
|
||||
expect(allowed.has(section.route)).toBeTrue();
|
||||
expect(allowed.has(section.route))
|
||||
.withContext(`Section route not in allowed: ${section.route} (id=${section.id})`)
|
||||
.toBeTrue();
|
||||
for (const child of section.children ?? []) {
|
||||
expect(allowed.has(child.route)).toBeTrue();
|
||||
expect(allowed.has(child.route))
|
||||
.withContext(`Child route not in allowed: ${child.route} (id=${child.id})`)
|
||||
.toBeTrue();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('includes required canonical shell routes from active Pack22 sprints', () => {
|
||||
const allowed = new Set<string>();
|
||||
for (const path of collectConcretePaths('/releases', RELEASES_ROUTES)) allowed.add(path);
|
||||
for (const path of collectConcretePaths('/security', SECURITY_ROUTES)) allowed.add(path);
|
||||
for (const path of collectConcretePaths('/evidence', EVIDENCE_ROUTES)) allowed.add(path);
|
||||
for (const path of collectConcretePaths('/topology', TOPOLOGY_ROUTES)) allowed.add(path);
|
||||
for (const path of collectConcretePaths('/platform/ops', OPERATIONS_ROUTES)) allowed.add(path);
|
||||
for (const path of collectConcretePaths('/platform/integrations', integrationHubRoutes)) allowed.add(path);
|
||||
for (const path of collectConcretePaths('/platform/setup', PLATFORM_SETUP_ROUTES)) allowed.add(path);
|
||||
allowed.add('/security/sbom/lake');
|
||||
it('includes required canonical routes from active navigation', () => {
|
||||
// Collect all routes actually declared in the sidebar
|
||||
const sidebarRoutes = new Set<string>();
|
||||
for (const section of component.navSections) {
|
||||
sidebarRoutes.add(section.route);
|
||||
for (const child of section.children ?? []) {
|
||||
sidebarRoutes.add(child.route);
|
||||
}
|
||||
}
|
||||
|
||||
const required = [
|
||||
'/releases/versions',
|
||||
'/releases/runs',
|
||||
'/security/triage',
|
||||
'/security/disposition',
|
||||
'/security/sbom/lake',
|
||||
// Release Control
|
||||
'/releases/deployments',
|
||||
'/releases',
|
||||
'/environments/overview',
|
||||
// Security (including absorbed Policy items)
|
||||
'/triage/artifacts',
|
||||
'/security',
|
||||
'/security/findings',
|
||||
'/security/scan',
|
||||
'/ops/policy/vex',
|
||||
'/ops/policy/governance',
|
||||
'/ops/policy/simulation',
|
||||
'/ops/policy/audit',
|
||||
// Evidence
|
||||
'/evidence/overview',
|
||||
'/evidence/capsules',
|
||||
'/evidence/verification/replay',
|
||||
'/topology/agents',
|
||||
'/topology/promotion-graph',
|
||||
'/platform/ops/jobs-queues',
|
||||
'/platform/ops/feeds-airgap',
|
||||
'/platform/integrations/runtime-hosts',
|
||||
'/platform/integrations/vex-sources',
|
||||
'/platform/setup/feed-policy',
|
||||
'/platform/setup/gate-profiles',
|
||||
'/platform/setup/defaults-guardrails',
|
||||
'/platform/setup/trust-signing',
|
||||
'/evidence/audit-log',
|
||||
'/evidence/exports',
|
||||
// Operations (including absorbed Policy Packs)
|
||||
'/ops/operations',
|
||||
'/ops/policy/packs',
|
||||
'/ops/operations/jobengine',
|
||||
'/ops/operations/feeds-airgap',
|
||||
'/ops/operations/agents',
|
||||
'/ops/operations/signals',
|
||||
'/ops/scripts',
|
||||
'/ops/operations/doctor',
|
||||
// Settings
|
||||
'/setup/integrations',
|
||||
'/setup/identity-access',
|
||||
'/setup/trust-signing',
|
||||
'/setup/tenant-branding',
|
||||
'/setup/preferences',
|
||||
];
|
||||
|
||||
for (const path of required) {
|
||||
expect(allowed.has(path)).toBeTrue();
|
||||
expect(sidebarRoutes.has(path))
|
||||
.withContext(`Required route missing from sidebar: ${path}`)
|
||||
.toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
it('removed routes are no longer present in the sidebar', () => {
|
||||
const sidebarRoutes = new Set<string>();
|
||||
for (const section of component.navSections) {
|
||||
sidebarRoutes.add(section.route);
|
||||
for (const child of section.children ?? []) {
|
||||
sidebarRoutes.add(child.route);
|
||||
}
|
||||
}
|
||||
|
||||
const removed = [
|
||||
'/ops/operations/drift',
|
||||
'/ops/operations/notifications',
|
||||
'/ops/operations/watchlist',
|
||||
'/evidence/verify-replay',
|
||||
'/evidence/verification/replay',
|
||||
'/evidence/bundles',
|
||||
'/evidence/audit-log/trust',
|
||||
];
|
||||
|
||||
for (const path of removed) {
|
||||
expect(sidebarRoutes.has(path))
|
||||
.withContext(`Removed route still in sidebar: ${path}`)
|
||||
.toBeFalse();
|
||||
}
|
||||
});
|
||||
|
||||
it('policy is not a menuGroupId (dissolved into Security and Operations)', () => {
|
||||
const groupIds = new Set(component.navSections.map((s) => s.menuGroupId).filter(Boolean));
|
||||
expect(groupIds.has('policy')).toBeFalse();
|
||||
expect(groupIds.has('policy-governance')).toBeFalse();
|
||||
});
|
||||
|
||||
it('has no duplicate routes within the sidebar', () => {
|
||||
const seen = new Set<string>();
|
||||
const duplicates: string[] = [];
|
||||
for (const section of component.navSections) {
|
||||
if (seen.has(section.route)) duplicates.push(section.route);
|
||||
seen.add(section.route);
|
||||
for (const child of section.children ?? []) {
|
||||
// Children may share routes with their parent section (drill-down pattern), skip those
|
||||
if (child.route === section.route) continue;
|
||||
if (seen.has(child.route)) duplicates.push(child.route);
|
||||
seen.add(child.route);
|
||||
}
|
||||
}
|
||||
expect(duplicates)
|
||||
.withContext(`Duplicate routes found: ${duplicates.join(', ')}`)
|
||||
.toEqual([]);
|
||||
});
|
||||
|
||||
it('has no duplicate concrete route declarations inside canonical route families', () => {
|
||||
const routeGroups: Array<{ name: string; paths: string[] }> = [
|
||||
{ name: 'releases', paths: collectConcretePathsArray('/releases', RELEASES_ROUTES) },
|
||||
{ name: 'security', paths: collectConcretePathsArray('/security', SECURITY_ROUTES) },
|
||||
{ name: 'evidence', paths: collectConcretePathsArray('/evidence', EVIDENCE_ROUTES) },
|
||||
{ name: 'topology', paths: collectConcretePathsArray('/topology', TOPOLOGY_ROUTES) },
|
||||
{ name: 'platform', paths: collectConcretePathsArray('/platform', PLATFORM_ROUTES) },
|
||||
{ name: 'platform-ops', paths: collectConcretePathsArray('/platform/ops', OPERATIONS_ROUTES) },
|
||||
{ name: 'platform-integrations', paths: collectConcretePathsArray('/platform/integrations', integrationHubRoutes) },
|
||||
{ name: 'platform-setup', paths: collectConcretePathsArray('/platform/setup', PLATFORM_SETUP_ROUTES) },
|
||||
{ name: 'ops-operations', paths: collectConcretePathsArray('/ops/operations', OPERATIONS_ROUTES) },
|
||||
{ name: 'setup', paths: collectConcretePathsArray('/setup', SETUP_ROUTES) },
|
||||
];
|
||||
|
||||
for (const group of routeGroups) {
|
||||
const seen = new Set<string>();
|
||||
for (const path of group.paths) {
|
||||
expect(seen.has(path)).toBeFalse();
|
||||
expect(seen.has(path))
|
||||
.withContext(`Duplicate in ${group.name}: ${path}`)
|
||||
.toBeFalse();
|
||||
seen.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user