Stabilize U

This commit is contained in:
master
2026-02-16 07:33:20 +02:00
parent 45c0f1bb59
commit 70fdbfcf25
166 changed files with 20156 additions and 4833 deletions

View File

@@ -0,0 +1,142 @@
import { test as base, expect, Page } from '@playwright/test';
/**
* StubAuthSession shape matches src/app/testing/auth-fixtures.ts.
* The Angular APP_INITIALIZER in app.config.ts reads
* `window.__stellaopsTestSession` and calls seedAuthSession() to
* populate the AuthSessionStore before guards execute.
*/
interface StubAuthSession {
subjectId: string;
tenant: string;
scopes: string[];
}
/** Admin session with all major scopes for unrestricted route access. */
const adminTestSession: StubAuthSession = {
subjectId: 'e2e-admin-user',
tenant: 'tenant-default',
scopes: [
'admin',
'ui.read',
'ui.admin',
'orch:read',
'orch:operate',
'orch:quota',
'orch:backfill',
'policy:read',
'policy:write',
'policy:author',
'policy:review',
'policy:approve',
'policy:operate',
'policy:simulate',
'policy:audit',
'exception:read',
'exception:write',
'exception:approve',
'release:read',
'release:write',
'release:publish',
'analytics.read',
'graph:read',
'graph:write',
'graph:admin',
'sbom:read',
'sbom:write',
'scanner:read',
'vex:read',
'vex:export',
'advisory:read',
'scheduler:read',
'scheduler:operate',
'findings:read',
'exceptions:read',
],
};
export const test = base.extend<{ authenticatedPage: Page }>({
authenticatedPage: async ({ page }, use) => {
// Intercept branding endpoint that can return 500 in dev/Docker
await page.route('**/console/branding**', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
tenantId: 'tenant-default',
productName: 'Stella Ops',
logoUrl: null,
theme: 'default',
}),
});
});
// Intercept OIDC authorize to prevent redirect loops
await page.route('**/connect/authorize**', (route) => {
route.fulfill({ status: 200, body: '' });
});
// Intercept console profile/introspect calls that fire after session seed
await page.route('**/console/profile**', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
subjectId: adminTestSession.subjectId,
username: 'qa-tester',
displayName: 'QA Test User',
tenant: adminTestSession.tenant,
roles: ['admin'],
scopes: adminTestSession.scopes,
audiences: ['stellaops'],
authenticationMethods: ['pwd'],
}),
});
});
await page.route('**/console/token/introspect**', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
active: true,
tenant: adminTestSession.tenant,
subject: adminTestSession.subjectId,
clientId: 'stellaops-console',
scopes: adminTestSession.scopes,
audiences: ['stellaops'],
}),
});
});
await page.route('**/console/tenants**', (route) => {
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
tenants: [
{
id: adminTestSession.tenant,
displayName: 'Default Tenant',
status: 'active',
isolationMode: 'shared',
defaultRoles: ['admin'],
},
],
}),
});
});
// Inject test session via addInitScript so it is available
// before any Angular code runs (APP_INITIALIZER reads it).
await page.addInitScript((session: StubAuthSession) => {
(window as any).__stellaopsTestSession = session;
}, adminTestSession);
await use(page);
},
});
export { expect } from '@playwright/test';
export { adminTestSession };
export type { StubAuthSession };

View File

@@ -0,0 +1,6 @@
import { test as setup, expect } from '@playwright/test';
setup('verify stack is reachable', async ({ request }) => {
const response = await request.get('/');
expect(response.status()).toBeLessThan(500);
});

View File

@@ -0,0 +1,46 @@
import { Page, expect } from '@playwright/test';
export async function navigateAndWait(
page: Page,
route: string,
options?: { timeout?: number }
) {
const timeout = options?.timeout ?? 15_000;
await page.goto(route, { waitUntil: 'networkidle', timeout });
await page.waitForLoadState('domcontentloaded');
// Allow Angular change detection to settle
await page.waitForTimeout(500);
}
export async function assertNoAngularErrors(page: Page) {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error' && msg.text().includes('NG0')) {
errors.push(msg.text());
}
});
await page.waitForTimeout(1000);
expect(errors, `Angular errors found: ${errors.join(', ')}`).toHaveLength(0);
}
export async function assertPageHasContent(page: Page) {
const bodyText = await page.locator('body').innerText();
expect(
bodyText.trim().length,
'Page should have visible text content'
).toBeGreaterThan(10);
}
export async function getPageHeading(
page: Page
): Promise<string | null> {
const h1 = page.locator('h1').first();
if (await h1.isVisible({ timeout: 3000 }).catch(() => false)) {
return h1.innerText();
}
const h2 = page.locator('h2').first();
if (await h2.isVisible({ timeout: 2000 }).catch(() => false)) {
return h2.innerText();
}
return null;
}

View File

@@ -0,0 +1,109 @@
/**
* Critical Route Rendering Tests — Batch 1 (25 routes)
*
* Verifies that each critical SPA route:
* 1. Navigates without error
* 2. Renders visible content (not blank)
* 3. Has no Angular injection errors (NG0201, NG0200, etc.)
*
* Uses the admin auth fixture that injects __stellaopsTestSession
* before Angular initializes.
*/
import { test, expect } from '../fixtures/auth.fixture';
import { navigateAndWait, assertPageHasContent } from '../helpers/nav.helper';
// Collect NG errors per test via console listener
function setupErrorCollector(page: import('@playwright/test').Page) {
const errors: string[] = [];
page.on('console', (msg) => {
const text = msg.text();
if (msg.type() === 'error' && /NG0\d{3,4}/.test(text)) {
errors.push(text);
}
});
return errors;
}
const CRITICAL_ROUTES: { path: string; name: string; expectRedirect?: boolean }[] = [
{ path: '/', name: 'Control Plane' },
{ path: '/approvals', name: 'Approvals' },
{ path: '/releases', name: 'Releases' },
{ path: '/deployments', name: 'Deployments' },
{ path: '/security', name: 'Security Overview' },
{ path: '/security/overview', name: 'Security Overview (detail)' },
{ path: '/security/findings', name: 'Security Findings' },
{ path: '/security/vulnerabilities', name: 'Security Vulnerabilities' },
{ path: '/security/vex', name: 'Security VEX' },
{ path: '/policy', name: 'Policy' },
{ path: '/policy/packs', name: 'Policy Packs' },
{ path: '/policy/governance', name: 'Policy Governance' },
{ path: '/policy/exceptions', name: 'Policy Exceptions' },
{ path: '/operations', name: 'Operations' },
{ path: '/operations/orchestrator', name: 'Operations Orchestrator' },
{ path: '/operations/scheduler', name: 'Operations Scheduler' },
{ path: '/evidence', name: 'Evidence' },
{ path: '/evidence-packs', name: 'Evidence Packs' },
{ path: '/settings', name: 'Settings' },
{ path: '/console/profile', name: 'Profile' },
{ path: '/admin/trust', name: 'Trust Admin' },
{ path: '/admin/vex-hub', name: 'VEX Hub Admin' },
{ path: '/integrations', name: 'Integration Hub' },
{ path: '/findings', name: 'Findings' },
{ path: '/triage', name: 'Triage Canvas' },
];
test.describe('Critical Route Rendering (Batch 1)', () => {
for (const route of CRITICAL_ROUTES) {
test(`renders ${route.name} (${route.path})`, async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, route.path, { timeout: 30_000 });
// Allow time for lazy-loaded modules to initialize
await page.waitForTimeout(2000);
// Verify page has visible content (not blank)
await assertPageHasContent(page);
// Verify no Angular injection/DI errors
expect(
ngErrors,
`Angular errors on ${route.path}: ${ngErrors.join('\n')}`
).toHaveLength(0);
});
}
});
test.describe('Critical Route Navigation Stability', () => {
test('can navigate between multiple routes without errors', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
const routesToVisit = ['/', '/security', '/policy', '/evidence', '/settings'];
for (const route of routesToVisit) {
await navigateAndWait(page, route, { timeout: 30_000 });
await page.waitForTimeout(1000);
}
expect(
ngErrors,
`Angular errors during multi-route navigation: ${ngErrors.join('\n')}`
).toHaveLength(0);
});
test('browser back/forward navigation works', async ({ authenticatedPage: page }) => {
await navigateAndWait(page, '/', { timeout: 30_000 });
await navigateAndWait(page, '/security', { timeout: 30_000 });
await navigateAndWait(page, '/policy', { timeout: 30_000 });
// Go back
await page.goBack();
await page.waitForTimeout(1000);
expect(page.url()).toContain('/security');
// Go forward
await page.goForward();
await page.waitForTimeout(1000);
expect(page.url()).toContain('/policy');
});
});

View File

@@ -0,0 +1,156 @@
/**
* Extended Route Rendering Tests — Batch 2 (40 routes)
*
* Tests additional SPA routes beyond the critical set.
* Same verification pattern: navigate, check content, check for NG errors.
*/
import { test, expect } from '../fixtures/auth.fixture';
import { navigateAndWait, assertPageHasContent } from '../helpers/nav.helper';
function setupErrorCollector(page: import('@playwright/test').Page) {
const errors: string[] = [];
page.on('console', (msg) => {
const text = msg.text();
if (msg.type() === 'error' && /NG0\d{3,4}/.test(text)) {
errors.push(text);
}
});
return errors;
}
const EXTENDED_ROUTES: { path: string; name: string }[] = [
// Legacy routes
{ path: '/environments', name: 'Environments' },
{ path: '/home', name: 'Home Dashboard (legacy)' },
{ path: '/dashboard/sources', name: 'Sources Dashboard' },
{ path: '/console/status', name: 'Console Status' },
{ path: '/console/admin', name: 'Console Admin' },
{ path: '/console/configuration', name: 'Configuration' },
// Orchestrator (legacy paths)
{ path: '/orchestrator', name: 'Orchestrator (legacy)' },
{ path: '/orchestrator/jobs', name: 'Orchestrator Jobs' },
{ path: '/orchestrator/quotas', name: 'Orchestrator Quotas' },
{ path: '/release-orchestrator', name: 'Release Orchestrator' },
// Policy Studio
{ path: '/policy-studio/packs', name: 'Policy Studio Packs' },
// Module-specific routes
{ path: '/concelier/trivy-db-settings', name: 'Trivy DB Settings' },
{ path: '/risk', name: 'Risk Dashboard' },
{ path: '/graph', name: 'Graph Explorer' },
{ path: '/lineage', name: 'Lineage' },
{ path: '/reachability', name: 'Reachability Center' },
{ path: '/timeline', name: 'Timeline' },
{ path: '/evidence-thread', name: 'Evidence Thread' },
// Vulnerability routes
{ path: '/vulnerabilities', name: 'Vulnerability Explorer' },
{ path: '/vulnerabilities/triage', name: 'Vulnerability Triage' },
// Triage routes
{ path: '/triage/inbox', name: 'Triage Inbox' },
{ path: '/triage/artifacts', name: 'Triage Artifacts' },
{ path: '/triage/quiet-lane', name: 'Quiet Lane' },
{ path: '/triage/ai-recommendations', name: 'AI Recommendations' },
// Notify & Admin
{ path: '/notify', name: 'Notify Panel' },
{ path: '/admin/notifications', name: 'Admin Notifications' },
// Ops routes
{ path: '/ops/feeds', name: 'Feed Mirror' },
{ path: '/ops/signals', name: 'Signals Dashboard' },
{ path: '/ops/packs', name: 'Pack Registry Browser' },
{ path: '/admin/policy/governance', name: 'Policy Governance Admin' },
{ path: '/admin/policy/simulation', name: 'Policy Simulation Admin' },
{ path: '/scheduler', name: 'Scheduler' },
{ path: '/exceptions', name: 'Exceptions' },
// More admin routes
{ path: '/admin/registries', name: 'Registry Admin' },
{ path: '/admin/issuers', name: 'Issuer Trust' },
{ path: '/ops/scanner', name: 'Scanner Ops' },
{ path: '/ops/offline-kit', name: 'Offline Kit' },
{ path: '/ops/aoc', name: 'AOC Compliance' },
{ path: '/admin/audit', name: 'Audit Log' },
// Welcome page (no auth)
{ path: '/welcome', name: 'Welcome Page' },
];
test.describe('Extended Route Rendering (Batch 2)', () => {
for (const route of EXTENDED_ROUTES) {
test(`renders ${route.name} (${route.path})`, async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, route.path, { timeout: 30_000 });
await page.waitForTimeout(2000);
await assertPageHasContent(page);
expect(
ngErrors,
`Angular errors on ${route.path}: ${ngErrors.join('\n')}`
).toHaveLength(0);
});
}
});
test.describe('Extended Route — Deep Paths', () => {
const DEEP_PATHS: { path: string; name: string }[] = [
{ path: '/ops/quotas', name: 'Quota Dashboard' },
{ path: '/ops/orchestrator/dead-letter', name: 'Dead Letter Queue' },
{ path: '/ops/orchestrator/slo', name: 'SLO Burn Rate' },
{ path: '/ops/health', name: 'Platform Health' },
{ path: '/ops/doctor', name: 'Doctor Diagnostics' },
{ path: '/ops/agents', name: 'Agent Fleet' },
{ path: '/analyze/unknowns', name: 'Unknowns Tracking' },
{ path: '/analyze/patch-map', name: 'Patch Map Explorer' },
{ path: '/ops/binary-index', name: 'Binary Index Ops' },
{ path: '/settings/determinization-config', name: 'Determinization Config' },
{ path: '/sbom-sources', name: 'SBOM Sources' },
{ path: '/sbom/diff', name: 'SBOM Diff' },
{ path: '/deploy/diff', name: 'Deploy Diff' },
{ path: '/vex/timeline', name: 'VEX Timeline' },
{ path: '/workspace/dev', name: 'Developer Workspace' },
{ path: '/workspace/audit', name: 'Auditor Workspace' },
{ path: '/ai/autofix', name: 'AI Autofix' },
{ path: '/ai/chat', name: 'AI Chat' },
{ path: '/ai/chips', name: 'AI Chips Showcase' },
{ path: '/ai-runs', name: 'AI Runs' },
{ path: '/change-trace', name: 'Change Trace' },
{ path: '/aoc/verify', name: 'AOC Verification' },
{ path: '/audit/reasons', name: 'Audit Reasons' },
{ path: '/triage/audit-bundles', name: 'Triage Audit Bundles' },
];
for (const route of DEEP_PATHS) {
test(`renders ${route.name} (${route.path})`, async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, route.path, { timeout: 30_000 });
await page.waitForTimeout(2000);
await assertPageHasContent(page);
expect(
ngErrors,
`Angular errors on ${route.path}: ${ngErrors.join('\n')}`
).toHaveLength(0);
});
}
});
test.describe('Setup Wizard Route (no auth required)', () => {
test('renders setup page', async ({ page }) => {
// Setup wizard does NOT need auth — test with bare page
await page.goto('/setup', { waitUntil: 'networkidle', timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
expect(bodyText.trim().length).toBeGreaterThan(10);
});
});

View File

@@ -0,0 +1,326 @@
/**
* Critical Workflow Tests — Interactive Behavior Verification (20 workflows)
*
* Tests interactive behaviors beyond static rendering: clicking tabs,
* opening drawers, toggling themes, verifying tables, etc.
*/
import { test, expect } from '../fixtures/auth.fixture';
import { navigateAndWait, getPageHeading } from '../helpers/nav.helper';
function setupErrorCollector(page: import('@playwright/test').Page) {
const errors: string[] = [];
page.on('console', (msg) => {
const text = msg.text();
if (msg.type() === 'error' && /NG0\d{3,4}/.test(text)) {
errors.push(text);
}
});
return errors;
}
test.describe('Workflow: Navigation Sidebar', () => {
test('left rail renders all top-level nav sections', async ({ authenticatedPage: page }) => {
await navigateAndWait(page, '/', { timeout: 30_000 });
// The app should have a navigation element
const nav = page.locator('nav, [role="navigation"], mat-sidenav, .shell-nav, .left-rail');
await expect(nav.first()).toBeVisible({ timeout: 10_000 });
// Verify nav links exist (at least some expected labels)
const navText = await nav.first().innerText();
const expectedSections = ['Security', 'Policy', 'Operations'];
for (const section of expectedSections) {
expect(navText.toLowerCase()).toContain(section.toLowerCase());
}
});
});
test.describe('Workflow: Security Overview', () => {
test('security overview renders metrics widgets', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/security', { timeout: 30_000 });
await page.waitForTimeout(2000);
// Verify the page has content
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(50);
// Check for heading
const heading = await getPageHeading(page);
expect(heading).toBeTruthy();
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: Policy Packs', () => {
test('policy packs list renders with tabs and filters', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/policy/packs', { timeout: 30_000 });
await page.waitForTimeout(3000);
// Look for policy-related content (tabs, list, or table)
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(50);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: Findings List', () => {
test('findings page renders table or list view', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/findings', { timeout: 30_000 });
await page.waitForTimeout(2000);
// Findings should have a table or list component
const table = page.locator('table, mat-table, [role="grid"], .findings-list, .findings-container');
const hasTable = await table.first().isVisible({ timeout: 5_000 }).catch(() => false);
// Page should at least have content
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(20);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: Triage Inbox', () => {
test('triage inbox renders queue view', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/triage/inbox', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(20);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: Trust Management', () => {
test('trust admin renders with tabs', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/admin/trust', { timeout: 30_000 });
await page.waitForTimeout(3000);
// Expect Trust Management heading or tabs
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(50);
// Look for tab elements (Trust Management should have 7 tabs)
const tabs = page.locator('[role="tab"], mat-tab, .mat-mdc-tab');
const tabCount = await tabs.count();
// Should have multiple tabs for the trust management sections
expect(tabCount).toBeGreaterThanOrEqual(1);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: VEX Hub Admin', () => {
test('VEX hub admin renders with tab navigation', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/admin/vex-hub', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(20);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: Evidence Export', () => {
test('evidence page renders export options', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/evidence', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(20);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: Scheduler Runs', () => {
test('scheduler page renders run table', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/scheduler', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(20);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: Doctor Diagnostics', () => {
test('doctor page renders diagnostics panel', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/ops/doctor', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(20);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: Graph Explorer', () => {
test('graph explorer renders canvas', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/graph', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(10);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: Timeline View', () => {
test('timeline renders event list or visualization', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/timeline', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(10);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: Risk Dashboard', () => {
test('risk dashboard renders risk widgets', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/risk', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(20);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: Integration Hub', () => {
test('integration hub renders integration cards', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/integrations', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(20);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: Settings Page', () => {
test('settings page renders configuration sections', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/settings', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(20);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: Profile Page', () => {
test('profile page renders user info', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/console/profile', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(10);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: Admin Notifications', () => {
test('notification rules page renders', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/admin/notifications', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(20);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: Approvals Queue', () => {
test('approvals page renders approval queue', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/approvals', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(20);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: AI Chat', () => {
test('AI chat panel renders', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/ai/chat', { timeout: 30_000 });
await page.waitForTimeout(2000);
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(10);
expect(ngErrors).toHaveLength(0);
});
});
test.describe('Workflow: Control Plane Dashboard', () => {
test('control plane renders with all dashboard widgets', async ({ authenticatedPage: page }) => {
const ngErrors = setupErrorCollector(page);
await navigateAndWait(page, '/', { timeout: 30_000 });
await page.waitForTimeout(3000);
// The control plane should have substantial content
const bodyText = await page.locator('body').innerText();
expect(bodyText.length).toBeGreaterThan(100);
// Should have a heading
const heading = await getPageHeading(page);
expect(heading).toBeTruthy();
expect(ngErrors).toHaveLength(0);
});
});

View File

@@ -18,7 +18,8 @@
"ci:install": "npm ci --prefer-offline --no-audit --no-fund",
"storybook": "ng run stellaops-web:storybook",
"storybook:build": "ng run stellaops-web:build-storybook",
"test:a11y": "FAIL_ON_A11Y=0 playwright test tests/e2e/a11y-smoke.spec.ts"
"test:a11y": "FAIL_ON_A11Y=0 playwright test tests/e2e/a11y-smoke.spec.ts",
"test:e2e:docker": "playwright test --config playwright.e2e.config.ts"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || ^24.0.0",

View File

@@ -0,0 +1,36 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright config targeting the Docker compose stack.
* Usage: npx playwright test --config playwright.e2e.config.ts
*/
export default defineConfig({
testDir: 'e2e',
timeout: 60_000,
expect: { timeout: 10_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { open: 'never' }],
['json', { outputFile: 'e2e-results.json' }],
],
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://stella-ops.local',
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'setup',
testMatch: /global\.setup\.ts/,
},
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
},
],
});

View File

@@ -109,6 +109,7 @@ import {
RELEASE_DASHBOARD_API,
RELEASE_DASHBOARD_API_BASE_URL,
ReleaseDashboardHttpClient,
MockReleaseDashboardClient,
} from './core/api/release-dashboard.client';
import {
RELEASE_ENVIRONMENT_API,
@@ -143,6 +144,43 @@ import {
WITNESS_API,
WitnessHttpClient,
} from './core/api/witness.client';
import {
NOTIFIER_API,
NOTIFIER_API_BASE_URL,
NotifierApiHttpClient,
} from './core/api/notifier.client';
import {
POLICY_ENGINE_API,
PolicyEngineHttpClient,
} from './core/api/policy-engine.client';
import {
TRUST_API,
TrustHttpService,
} from './core/api/trust.client';
import {
VULN_ANNOTATION_API,
HttpVulnAnnotationClient,
} from './core/api/vuln-annotation.client';
import {
AUTHORITY_ADMIN_API,
AUTHORITY_ADMIN_API_BASE_URL,
AuthorityAdminHttpClient,
MockAuthorityAdminClient,
} from './core/api/authority-admin.client';
import {
SECURITY_FINDINGS_API,
SECURITY_FINDINGS_API_BASE_URL,
SecurityFindingsHttpClient,
} from './core/api/security-findings.client';
import {
SECURITY_OVERVIEW_API,
SecurityOverviewHttpClient,
} from './core/api/security-overview.client';
import {
SCHEDULER_API,
SCHEDULER_API_BASE_URL,
SchedulerHttpClient,
} from './core/api/scheduler.client';
export const appConfig: ApplicationConfig = {
providers: [
@@ -524,6 +562,7 @@ export const appConfig: ApplicationConfig = {
},
},
ReleaseDashboardHttpClient,
MockReleaseDashboardClient,
{
provide: RELEASE_DASHBOARD_API,
useExisting: ReleaseDashboardHttpClient,
@@ -589,5 +628,95 @@ export const appConfig: ApplicationConfig = {
provide: WITNESS_API,
useExisting: WitnessHttpClient,
},
// Notifier API (Bug fix: missing DI providers caused NG0201)
{
provide: NOTIFIER_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
try {
return new URL('/api/v1/notifier', gatewayBase).toString();
} catch {
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
return `${normalized}/api/v1/notifier`;
}
},
},
NotifierApiHttpClient,
{
provide: NOTIFIER_API,
useExisting: NotifierApiHttpClient,
},
// Policy Engine API (Bug fix: missing DI provider caused NG0201 on /policy/packs)
{
provide: POLICY_ENGINE_API,
useExisting: PolicyEngineHttpClient,
},
// Trust API (Bug fix: missing DI provider caused NG0201 on /admin/trust)
{
provide: TRUST_API,
useExisting: TrustHttpService,
},
// Vuln Annotation API (Bug fix: missing DI provider caused NG0201 on /vulnerabilities/triage)
HttpVulnAnnotationClient,
{
provide: VULN_ANNOTATION_API,
useExisting: HttpVulnAnnotationClient,
},
// Authority Admin API (admin CRUD for users/roles/clients/tokens/tenants)
{
provide: AUTHORITY_ADMIN_API_BASE_URL,
useValue: '/console/admin',
},
AuthorityAdminHttpClient,
MockAuthorityAdminClient,
{
provide: AUTHORITY_ADMIN_API,
useExisting: AuthorityAdminHttpClient,
},
// Security Findings API (scanner findings via gateway)
{
provide: SECURITY_FINDINGS_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
try {
return new URL('/scanner', gatewayBase).toString();
} catch {
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
return `${normalized}/scanner`;
}
},
},
SecurityFindingsHttpClient,
{
provide: SECURITY_FINDINGS_API,
useExisting: SecurityFindingsHttpClient,
},
// Security Overview API (aggregated security dashboard data)
SecurityOverviewHttpClient,
{
provide: SECURITY_OVERVIEW_API,
useExisting: SecurityOverviewHttpClient,
},
// Scheduler API (schedule CRUD)
{
provide: SCHEDULER_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
try {
return new URL('/scheduler', gatewayBase).toString();
} catch {
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
return `${normalized}/scheduler`;
}
},
},
SchedulerHttpClient,
{
provide: SCHEDULER_API,
useExisting: SchedulerHttpClient,
},
],
};

View File

@@ -2,7 +2,7 @@ import { Injectable, inject, InjectionToken } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable, of, delay, throwError } from 'rxjs';
import { APP_CONFIG } from '../config/app-config.model';
import { AppConfigService } from '../config/app-config.service';
import { AuthSessionStore } from '../auth/auth-session.store';
/**
@@ -230,7 +230,8 @@ export const ABAC_OVERLAY_API = new InjectionToken<AbacOverlayApi>('ABAC_OVERLAY
@Injectable({ providedIn: 'root' })
export class AbacOverlayHttpClient implements AbacOverlayApi {
private readonly http = inject(HttpClient);
private readonly config = inject(APP_CONFIG);
private readonly configService = inject(AppConfigService);
private get config() { return this.configService.config; }
private readonly authStore = inject(AuthSessionStore);
private get baseUrl(): string {

View File

@@ -0,0 +1,185 @@
/**
* Authority Admin API Client
* Provides admin CRUD operations for users, roles, OAuth clients, tokens, and tenants.
*/
import { Injectable, InjectionToken, Inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, of, delay, map } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
// ============================================================================
// Models
// ============================================================================
export interface AdminUser {
id: string;
username: string;
email: string;
displayName: string;
roles: string[];
status: 'active' | 'disabled' | 'locked';
createdAt: string;
lastLoginAt?: string;
}
export interface AdminRole {
id: string;
name: string;
description: string;
permissions: string[];
userCount: number;
isBuiltIn: boolean;
}
export interface AdminClient {
id: string;
clientId: string;
displayName: string;
grantTypes: string[];
scopes: string[];
status: 'active' | 'disabled';
createdAt: string;
}
export interface AdminToken {
id: string;
name: string;
clientId: string;
scopes: string[];
expiresAt: string;
createdAt: string;
lastUsedAt?: string;
status: 'active' | 'expired' | 'revoked';
}
export interface AdminTenant {
id: string;
displayName: string;
status: 'active' | 'disabled';
isolationMode: string;
userCount: number;
createdAt: string;
}
// ============================================================================
// API Interface
// ============================================================================
export interface AuthorityAdminApi {
listUsers(tenantId?: string): Observable<AdminUser[]>;
listRoles(tenantId?: string): Observable<AdminRole[]>;
listClients(tenantId?: string): Observable<AdminClient[]>;
listTokens(tenantId?: string): Observable<AdminToken[]>;
listTenants(): Observable<AdminTenant[]>;
}
export const AUTHORITY_ADMIN_API = new InjectionToken<AuthorityAdminApi>('AUTHORITY_ADMIN_API');
export const AUTHORITY_ADMIN_API_BASE_URL = new InjectionToken<string>('AUTHORITY_ADMIN_API_BASE_URL');
// ============================================================================
// HTTP Implementation
// ============================================================================
@Injectable()
export class AuthorityAdminHttpClient implements AuthorityAdminApi {
constructor(
private readonly http: HttpClient,
@Inject(AUTHORITY_ADMIN_API_BASE_URL) private readonly baseUrl: string,
private readonly authSession: AuthSessionStore,
) {}
listUsers(tenantId?: string): Observable<AdminUser[]> {
return this.http.get<{ users: AdminUser[] }>(`${this.baseUrl}/users`, {
headers: this.buildHeaders(tenantId),
}).pipe(map(r => r.users ?? []));
}
listRoles(tenantId?: string): Observable<AdminRole[]> {
return this.http.get<{ roles: AdminRole[] }>(`${this.baseUrl}/roles`, {
headers: this.buildHeaders(tenantId),
}).pipe(map(r => r.roles ?? []));
}
listClients(tenantId?: string): Observable<AdminClient[]> {
return this.http.get<{ clients: AdminClient[] }>(`${this.baseUrl}/clients`, {
headers: this.buildHeaders(tenantId),
}).pipe(map(r => r.clients ?? []));
}
listTokens(tenantId?: string): Observable<AdminToken[]> {
return this.http.get<{ tokens: AdminToken[] }>(`${this.baseUrl}/tokens`, {
headers: this.buildHeaders(tenantId),
}).pipe(map(r => r.tokens ?? []));
}
listTenants(): Observable<AdminTenant[]> {
return this.http.get<{ tenants: AdminTenant[] }>(`${this.baseUrl}/tenants`, {
headers: this.buildHeaders(),
}).pipe(map(r => r.tenants ?? []));
}
private buildHeaders(tenantOverride?: string): HttpHeaders {
const tenantId =
(tenantOverride && tenantOverride.trim()) ||
this.authSession.getActiveTenantId() ||
'default';
return new HttpHeaders({
'X-StellaOps-Tenant': tenantId,
});
}
}
// ============================================================================
// Mock Implementation
// ============================================================================
@Injectable({ providedIn: 'root' })
export class MockAuthorityAdminClient implements AuthorityAdminApi {
listUsers(): Observable<AdminUser[]> {
const data: AdminUser[] = [
{ id: 'u-1', username: 'admin', email: 'admin@stella-ops.local', displayName: 'Platform Admin', roles: ['admin', 'operator'], status: 'active', createdAt: '2026-01-01T00:00:00Z', lastLoginAt: '2026-02-15T10:30:00Z' },
{ id: 'u-2', username: 'jane.smith', email: 'jane.smith@example.com', displayName: 'Jane Smith', roles: ['reviewer'], status: 'active', createdAt: '2026-01-10T00:00:00Z', lastLoginAt: '2026-02-14T15:00:00Z' },
{ id: 'u-3', username: 'bob.wilson', email: 'bob.wilson@example.com', displayName: 'Bob Wilson', roles: ['developer'], status: 'active', createdAt: '2026-01-15T00:00:00Z' },
{ id: 'u-4', username: 'svc-scanner', email: 'scanner@stella-ops.local', displayName: 'Scanner Service', roles: ['service'], status: 'active', createdAt: '2026-01-01T00:00:00Z' },
{ id: 'u-5', username: 'alice.johnson', email: 'alice@example.com', displayName: 'Alice Johnson', roles: ['operator', 'reviewer'], status: 'disabled', createdAt: '2026-01-20T00:00:00Z' },
];
return of(data).pipe(delay(300));
}
listRoles(): Observable<AdminRole[]> {
const data: AdminRole[] = [
{ id: 'r-1', name: 'admin', description: 'Full platform administrator', permissions: ['*'], userCount: 1, isBuiltIn: true },
{ id: 'r-2', name: 'operator', description: 'Manage releases and deployments', permissions: ['release:*', 'deploy:*'], userCount: 2, isBuiltIn: true },
{ id: 'r-3', name: 'reviewer', description: 'Review and approve promotions', permissions: ['approval:read', 'approval:approve', 'release:read'], userCount: 2, isBuiltIn: true },
{ id: 'r-4', name: 'developer', description: 'Read-only access to releases and security', permissions: ['release:read', 'security:read'], userCount: 1, isBuiltIn: false },
{ id: 'r-5', name: 'service', description: 'Machine-to-machine service account', permissions: ['scanner:write', 'findings:write'], userCount: 1, isBuiltIn: true },
];
return of(data).pipe(delay(300));
}
listClients(): Observable<AdminClient[]> {
const data: AdminClient[] = [
{ id: 'c-1', clientId: 'stella-ops-ui', displayName: 'StellaOps Web Console', grantTypes: ['authorization_code'], scopes: ['openid', 'profile', 'ui.read'], status: 'active', createdAt: '2026-01-01T00:00:00Z' },
{ id: 'c-2', clientId: 'scanner-agent', displayName: 'Scanner Agent', grantTypes: ['client_credentials'], scopes: ['scanner:write', 'findings:write'], status: 'active', createdAt: '2026-01-01T00:00:00Z' },
{ id: 'c-3', clientId: 'ci-pipeline', displayName: 'CI/CD Pipeline', grantTypes: ['client_credentials'], scopes: ['release:create', 'deploy:trigger'], status: 'active', createdAt: '2026-01-05T00:00:00Z' },
];
return of(data).pipe(delay(300));
}
listTokens(): Observable<AdminToken[]> {
const data: AdminToken[] = [
{ id: 't-1', name: 'CI Deploy Token', clientId: 'ci-pipeline', scopes: ['release:create', 'deploy:trigger'], expiresAt: '2026-06-01T00:00:00Z', createdAt: '2026-01-15T00:00:00Z', status: 'active' },
{ id: 't-2', name: 'Scanner Agent Key', clientId: 'scanner-agent', scopes: ['scanner:write'], expiresAt: '2026-12-31T00:00:00Z', createdAt: '2026-01-01T00:00:00Z', status: 'active' },
{ id: 't-3', name: 'Old Integration Key', clientId: 'ci-pipeline', scopes: ['release:read'], expiresAt: '2026-01-31T00:00:00Z', createdAt: '2025-12-01T00:00:00Z', status: 'expired' },
];
return of(data).pipe(delay(300));
}
listTenants(): Observable<AdminTenant[]> {
const data: AdminTenant[] = [
{ id: 'tn-1', displayName: 'Default', status: 'active', isolationMode: 'shared', userCount: 5, createdAt: '2026-01-01T00:00:00Z' },
{ id: 'tn-2', displayName: 'Production Tenant', status: 'active', isolationMode: 'dedicated', userCount: 3, createdAt: '2026-01-10T00:00:00Z' },
];
return of(data).pipe(delay(300));
}
}

View File

@@ -2,7 +2,7 @@ import { Injectable, inject, InjectionToken, signal } from '@angular/core';
import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
import { Observable, of, delay, throwError, timer, retry, catchError, map, tap } from 'rxjs';
import { APP_CONFIG } from '../config/app-config.model';
import { AppConfigService } from '../config/app-config.service';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { generateTraceId } from './trace.util';
@@ -187,7 +187,8 @@ export const FINDINGS_LEDGER_API = new InjectionToken<FindingsLedgerApi>('FINDIN
@Injectable({ providedIn: 'root' })
export class FindingsLedgerHttpClient implements FindingsLedgerApi {
private readonly http = inject(HttpClient);
private readonly config = inject(APP_CONFIG);
private readonly configService = inject(AppConfigService);
private get config() { return this.configService.config; }
private readonly authStore = inject(AuthSessionStore);
private readonly tenantService = inject(TenantActivationService);

View File

@@ -1,7 +1,7 @@
// Sprint: SPRINT_20251229_032_FE - Platform Health Dashboard
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Observable, of, delay } from 'rxjs';
import {
PlatformHealthSummary,
DependencyGraph,
@@ -10,6 +10,7 @@ import {
ServiceDetail,
HealthAlertConfig,
ServiceName,
ServiceHealth,
} from './platform-health.models';
@Injectable({ providedIn: 'root' })
@@ -126,3 +127,145 @@ export class PlatformHealthClient {
return this.http.get(`${this.baseUrl}/export`, { params, responseType: 'blob' });
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Mock Implementation
// ─────────────────────────────────────────────────────────────────────────────
@Injectable()
export class MockPlatformHealthClient {
private readonly now = new Date().toISOString();
private readonly mockServices: ServiceHealth[] = [
{ name: 'scanner', displayName: 'Scanner', state: 'healthy', uptime: 99.98, latencyP50Ms: 12, latencyP95Ms: 45, latencyP99Ms: 120, errorRate: 0.02, checks: [{ name: 'http', status: 'pass', lastChecked: this.now }, { name: 'db', status: 'pass', lastChecked: this.now }], lastUpdated: this.now, version: '1.4.2', dependencies: ['authority', 'concelier'] },
{ name: 'orchestrator', displayName: 'Orchestrator', state: 'healthy', uptime: 99.95, latencyP50Ms: 8, latencyP95Ms: 32, latencyP99Ms: 85, errorRate: 0.05, checks: [{ name: 'http', status: 'pass', lastChecked: this.now }, { name: 'queue', status: 'pass', lastChecked: this.now }], lastUpdated: this.now, version: '1.3.1', dependencies: ['scheduler', 'authority'] },
{ name: 'policy', displayName: 'Policy Engine', state: 'healthy', uptime: 99.99, latencyP50Ms: 5, latencyP95Ms: 18, latencyP99Ms: 42, errorRate: 0.01, checks: [{ name: 'http', status: 'pass', lastChecked: this.now }], lastUpdated: this.now, version: '2.1.0', dependencies: [] },
{ name: 'authority', displayName: 'Authority', state: 'healthy', uptime: 99.99, latencyP50Ms: 6, latencyP95Ms: 22, latencyP99Ms: 55, errorRate: 0.01, checks: [{ name: 'http', status: 'pass', lastChecked: this.now }, { name: 'db', status: 'pass', lastChecked: this.now }], lastUpdated: this.now, version: '1.2.0', dependencies: [] },
{ name: 'scheduler', displayName: 'Scheduler', state: 'degraded', uptime: 98.50, latencyP50Ms: 25, latencyP95Ms: 180, latencyP99Ms: 450, errorRate: 1.20, checks: [{ name: 'http', status: 'pass', lastChecked: this.now }, { name: 'queue', status: 'warn', message: 'Queue depth above threshold', lastChecked: this.now }], lastUpdated: this.now, version: '1.1.3', dependencies: ['authority'] },
{ name: 'concelier', displayName: 'Concelier', state: 'healthy', uptime: 99.90, latencyP50Ms: 15, latencyP95Ms: 60, latencyP99Ms: 150, errorRate: 0.10, checks: [{ name: 'http', status: 'pass', lastChecked: this.now }], lastUpdated: this.now, version: '1.0.8', dependencies: ['vexlens'] },
{ name: 'vexlens', displayName: 'VexLens', state: 'healthy', uptime: 99.92, latencyP50Ms: 10, latencyP95Ms: 38, latencyP99Ms: 95, errorRate: 0.08, checks: [{ name: 'http', status: 'pass', lastChecked: this.now }], lastUpdated: this.now, version: '1.1.0', dependencies: [] },
{ name: 'attestor', displayName: 'Attestor', state: 'healthy', uptime: 99.97, latencyP50Ms: 8, latencyP95Ms: 28, latencyP99Ms: 70, errorRate: 0.03, checks: [{ name: 'http', status: 'pass', lastChecked: this.now }, { name: 'hsm', status: 'pass', lastChecked: this.now }], lastUpdated: this.now, version: '1.0.5', dependencies: ['signer'] },
{ name: 'signer', displayName: 'Signer', state: 'healthy', uptime: 99.99, latencyP50Ms: 4, latencyP95Ms: 15, latencyP99Ms: 35, errorRate: 0.00, checks: [{ name: 'http', status: 'pass', lastChecked: this.now }], lastUpdated: this.now, version: '1.0.3', dependencies: [] },
{ name: 'notifier', displayName: 'Notifier', state: 'healthy', uptime: 99.85, latencyP50Ms: 20, latencyP95Ms: 75, latencyP99Ms: 200, errorRate: 0.15, checks: [{ name: 'http', status: 'pass', lastChecked: this.now }, { name: 'smtp', status: 'pass', lastChecked: this.now }], lastUpdated: this.now, version: '1.0.2', dependencies: [] },
];
getSummary(): Observable<PlatformHealthSummary> {
const healthy = this.mockServices.filter(s => s.state === 'healthy').length;
const degraded = this.mockServices.filter(s => s.state === 'degraded').length;
const data: PlatformHealthSummary = {
totalServices: this.mockServices.length,
healthyCount: healthy,
degradedCount: degraded,
unhealthyCount: 0,
unknownCount: 0,
overallState: degraded > 0 ? 'degraded' : 'healthy',
averageLatencyMs: 45,
averageErrorRate: 0.17,
activeIncidents: 1,
lastUpdated: this.now,
services: this.mockServices,
};
return of(data).pipe(delay(400));
}
getDependencyGraph(): Observable<DependencyGraph> {
const data: DependencyGraph = {
nodes: [
{ id: 'postgres', name: 'PostgreSQL', type: 'database', state: 'healthy' },
{ id: 'redis', name: 'Redis Cache', type: 'cache', state: 'healthy' },
{ id: 'rabbitmq', name: 'RabbitMQ', type: 'queue', state: 'degraded' },
{ id: 'smtp', name: 'SMTP Relay', type: 'external', state: 'healthy' },
],
edges: [
{ from: 'authority', to: 'postgres', latencyMs: 2, healthy: true },
{ from: 'scanner', to: 'postgres', latencyMs: 3, healthy: true },
{ from: 'scheduler', to: 'rabbitmq', latencyMs: 15, healthy: false },
{ from: 'orchestrator', to: 'rabbitmq', latencyMs: 8, healthy: true },
{ from: 'notifier', to: 'smtp', latencyMs: 45, healthy: true },
{ from: 'scanner', to: 'redis', latencyMs: 1, healthy: true },
],
lastUpdated: this.now,
};
return of(data).pipe(delay(300));
}
getIncidents(hoursBack: number = 24, includeResolved: boolean = true): Observable<IncidentTimeline> {
const twoHoursAgo = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
const sixHoursAgo = new Date(Date.now() - 6 * 60 * 60 * 1000).toISOString();
const data: IncidentTimeline = {
incidents: [
{
id: 'inc-1',
severity: 'warning',
state: 'active',
title: 'Scheduler queue depth elevated',
description: 'RabbitMQ queue depth for scheduler has exceeded the warning threshold of 500 messages.',
affectedServices: ['scheduler', 'orchestrator'],
rootCauseSuggestion: 'Increased scan workload from recent feed sync may be causing backpressure.',
correlatedEvents: [
{ timestamp: twoHoursAgo, service: 'scheduler', eventType: 'latency_spike', description: 'P95 latency increased to 180ms' },
],
startedAt: twoHoursAgo,
},
{
id: 'inc-2',
severity: 'info',
state: 'resolved',
title: 'Authority certificate renewal',
description: 'Automatic TLS certificate renewal completed successfully.',
affectedServices: ['authority'],
correlatedEvents: [],
startedAt: sixHoursAgo,
resolvedAt: new Date(Date.now() - 5.5 * 60 * 60 * 1000).toISOString(),
duration: '30m',
},
],
timeRangeStart: new Date(Date.now() - hoursBack * 60 * 60 * 1000).toISOString(),
timeRangeEnd: this.now,
totalCount: 2,
};
return of(data).pipe(delay(350));
}
getAggregateMetrics(timeRange: string = '24h'): Observable<AggregateMetrics> {
const data: AggregateMetrics = {
timeRange,
dataPoints: [],
summary: { avgLatencyP50Ms: 11, avgLatencyP95Ms: 45, avgLatencyP99Ms: 120, avgErrorRate: 0.17, peakErrorRate: 1.2, totalRequests: 284500, totalErrors: 483 },
};
return of(data).pipe(delay(200));
}
getServiceHealth(serviceName: ServiceName): Observable<ServiceDetail> {
const service = this.mockServices.find(s => s.name === serviceName) ?? this.mockServices[0];
const data: ServiceDetail = {
service,
recentErrors: [],
metricHistory: [],
dependencyStatus: [],
};
return of(data).pipe(delay(200));
}
getAlertConfig(): Observable<HealthAlertConfig> {
const data: HealthAlertConfig = {
degradedThreshold: { errorRatePercent: 1, latencyP95Ms: 200 },
unhealthyThreshold: { errorRatePercent: 5, latencyP95Ms: 1000 },
notificationChannels: ['email', 'webhook'],
enabled: true,
};
return of(data).pipe(delay(200));
}
getServiceMetrics(serviceName: ServiceName, timeRange: string = '24h'): Observable<AggregateMetrics> {
return this.getAggregateMetrics(timeRange);
}
updateAlertConfig(config: HealthAlertConfig): Observable<HealthAlertConfig> {
return of(config).pipe(delay(200));
}
exportReport(): Observable<Blob> {
return of(new Blob(['mock report'], { type: 'application/json' })).pipe(delay(200));
}
}

View File

@@ -178,32 +178,32 @@ export interface HealthAlertConfig {
enabled: boolean;
}
// Display constants
// Display constants (CSS classes for design-token-based styles)
export const SERVICE_STATE_COLORS: Record<ServiceHealthState, string> = {
healthy: 'bg-green-500',
degraded: 'bg-yellow-500',
unhealthy: 'bg-red-500',
unknown: 'bg-gray-400',
healthy: 'state-dot--healthy',
degraded: 'state-dot--degraded',
unhealthy: 'state-dot--unhealthy',
unknown: 'state-dot--unknown',
};
export const SERVICE_STATE_TEXT_COLORS: Record<ServiceHealthState, string> = {
healthy: 'text-green-600',
degraded: 'text-yellow-600',
unhealthy: 'text-red-600',
unknown: 'text-gray-500',
healthy: 'state-text--healthy',
degraded: 'state-text--degraded',
unhealthy: 'state-text--unhealthy',
unknown: 'state-text--unknown',
};
export const SERVICE_STATE_BG_LIGHT: Record<ServiceHealthState, string> = {
healthy: 'bg-green-50 border-green-200',
degraded: 'bg-yellow-50 border-yellow-200',
unhealthy: 'bg-red-50 border-red-200',
unknown: 'bg-gray-50 border-gray-200',
healthy: 'state-bg--healthy',
degraded: 'state-bg--degraded',
unhealthy: 'state-bg--unhealthy',
unknown: 'state-bg--unknown',
};
export const INCIDENT_SEVERITY_COLORS: Record<IncidentSeverity, string> = {
info: 'bg-blue-100 text-blue-800',
warning: 'bg-yellow-100 text-yellow-800',
critical: 'bg-red-100 text-red-800',
info: 'severity--info',
warning: 'severity--warning',
critical: 'severity--critical',
};
export const SERVICE_DISPLAY_NAMES: Record<ServiceName, string> = {

View File

@@ -2,7 +2,7 @@ import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, InjectionToken, inject } from '@angular/core';
import { Observable, delay, map, of, throwError } from 'rxjs';
import { APP_CONFIG } from '../config/app-config.model';
import { AppConfigService } from '../config/app-config.service';
import { generateTraceId } from './trace.util';
import {
RiskProfileListResponse,
@@ -158,7 +158,8 @@ export const POLICY_ENGINE_API = new InjectionToken<PolicyEngineApi>('POLICY_ENG
@Injectable({ providedIn: 'root' })
export class PolicyEngineHttpClient implements PolicyEngineApi {
private readonly http = inject(HttpClient);
private readonly config = inject(APP_CONFIG);
private readonly configService = inject(AppConfigService);
private get config() { return this.configService.config; }
private get baseUrl(): string {
return this.config.apiBaseUrls.policy;

View File

@@ -2,7 +2,7 @@ import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, InjectionToken, inject } from '@angular/core';
import { Observable, delay, of, catchError, map } from 'rxjs';
import { APP_CONFIG } from '../config/app-config.model';
import { AppConfigService } from '../config/app-config.service';
import { generateTraceId } from './trace.util';
import { PolicyQueryOptions } from './policy-engine.models';
@@ -186,7 +186,8 @@ export const POLICY_REGISTRY_API = new InjectionToken<PolicyRegistryApi>('POLICY
@Injectable({ providedIn: 'root' })
export class PolicyRegistryHttpClient implements PolicyRegistryApi {
private readonly http = inject(HttpClient);
private readonly config = inject(APP_CONFIG);
private readonly configService = inject(AppConfigService);
private get config() { return this.configService.config; }
private get baseUrl(): string {
return this.config.apiBaseUrls.policy;

View File

@@ -1,7 +1,7 @@
import { Injectable, inject, NgZone } from '@angular/core';
import { Observable, Subject, finalize } from 'rxjs';
import { APP_CONFIG } from '../config/app-config.model';
import { AppConfigService } from '../config/app-config.service';
import { AuthSessionStore } from '../auth/auth-session.store';
import {
RiskSimulationResult,
@@ -113,7 +113,8 @@ export interface StreamingEvaluationRequest {
*/
@Injectable({ providedIn: 'root' })
export class PolicyStreamingClient {
private readonly config = inject(APP_CONFIG);
private readonly configService = inject(AppConfigService);
private get config() { return this.configService.config; }
private readonly authStore = inject(AuthSessionStore);
private readonly ngZone = inject(NgZone);

View File

@@ -0,0 +1,128 @@
/**
* Scheduler API Client
* Provides schedule CRUD operations and impact preview.
*/
import { Injectable, InjectionToken, Inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
import type {
Schedule,
ScheduleImpactPreview,
ScheduleTaskType,
RetryPolicy,
} from '../../features/scheduler-ops/scheduler-ops.models';
// ============================================================================
// DTOs
// ============================================================================
export interface CreateScheduleDto {
name: string;
description: string;
cronExpression: string;
timezone: string;
enabled: boolean;
taskType: ScheduleTaskType;
taskConfig?: Record<string, unknown>;
tags?: string[];
retryPolicy?: RetryPolicy;
concurrencyLimit?: number;
}
export type UpdateScheduleDto = Partial<CreateScheduleDto>;
// ============================================================================
// API Interface
// ============================================================================
export interface SchedulerApi {
listSchedules(): Observable<Schedule[]>;
getSchedule(id: string): Observable<Schedule>;
createSchedule(schedule: CreateScheduleDto): Observable<Schedule>;
updateSchedule(id: string, schedule: UpdateScheduleDto): Observable<Schedule>;
deleteSchedule(id: string): Observable<void>;
pauseSchedule(id: string): Observable<void>;
resumeSchedule(id: string): Observable<void>;
triggerSchedule(id: string): Observable<void>;
previewImpact(schedule: CreateScheduleDto): Observable<ScheduleImpactPreview>;
}
export const SCHEDULER_API = new InjectionToken<SchedulerApi>('SCHEDULER_API');
export const SCHEDULER_API_BASE_URL = new InjectionToken<string>('SCHEDULER_API_BASE_URL');
// ============================================================================
// HTTP Implementation
// ============================================================================
@Injectable()
export class SchedulerHttpClient implements SchedulerApi {
constructor(
private readonly http: HttpClient,
@Inject(SCHEDULER_API_BASE_URL) private readonly baseUrl: string,
private readonly authSession: AuthSessionStore,
) {}
listSchedules(): Observable<Schedule[]> {
return this.http.get<Schedule[]>(`${this.baseUrl}/schedules/`, {
headers: this.buildHeaders(),
});
}
getSchedule(id: string): Observable<Schedule> {
return this.http.get<Schedule>(`${this.baseUrl}/schedules/${id}`, {
headers: this.buildHeaders(),
});
}
createSchedule(schedule: CreateScheduleDto): Observable<Schedule> {
return this.http.post<Schedule>(`${this.baseUrl}/schedules/`, schedule, {
headers: this.buildHeaders(),
});
}
updateSchedule(id: string, schedule: UpdateScheduleDto): Observable<Schedule> {
return this.http.put<Schedule>(`${this.baseUrl}/schedules/${id}`, schedule, {
headers: this.buildHeaders(),
});
}
deleteSchedule(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/schedules/${id}`, {
headers: this.buildHeaders(),
});
}
pauseSchedule(id: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/schedules/${id}/pause`, {}, {
headers: this.buildHeaders(),
});
}
resumeSchedule(id: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/schedules/${id}/resume`, {}, {
headers: this.buildHeaders(),
});
}
triggerSchedule(id: string): Observable<void> {
return this.http.post<void>(`${this.baseUrl}/schedules/${id}/trigger`, {}, {
headers: this.buildHeaders(),
});
}
previewImpact(schedule: CreateScheduleDto): Observable<ScheduleImpactPreview> {
return this.http.post<ScheduleImpactPreview>(`${this.baseUrl}/schedules/preview-impact`, schedule, {
headers: this.buildHeaders(),
});
}
private buildHeaders(): HttpHeaders {
const tenantId = this.authSession.getActiveTenantId();
const headers: Record<string, string> = {};
if (tenantId) {
headers['X-StellaOps-Tenant'] = tenantId;
}
return new HttpHeaders(headers);
}
}

View File

@@ -0,0 +1,96 @@
/**
* Security Findings API Client
* Provides access to scanner findings data via the gateway.
*/
import { Injectable, InjectionToken, Inject } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
import { AuthSessionStore } from '../auth/auth-session.store';
// ============================================================================
// Models
// ============================================================================
export interface FindingsFilter {
severity?: string;
reachability?: string;
environment?: string;
limit?: number;
sort?: string;
}
export interface FindingDto {
id: string;
package: string;
version: string;
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
cvss: number;
reachable: boolean | null;
reachabilityConfidence?: number;
vexStatus: string;
releaseId: string;
releaseVersion: string;
delta: string;
environments: string[];
firstSeen: string;
}
export interface FindingDetailDto extends FindingDto {
description: string;
references: string[];
affectedVersions: string[];
fixedVersions: string[];
}
// ============================================================================
// API Interface
// ============================================================================
export interface SecurityFindingsApi {
listFindings(filter?: FindingsFilter): Observable<FindingDto[]>;
getFinding(findingId: string): Observable<FindingDetailDto>;
}
export const SECURITY_FINDINGS_API = new InjectionToken<SecurityFindingsApi>('SECURITY_FINDINGS_API');
export const SECURITY_FINDINGS_API_BASE_URL = new InjectionToken<string>('SECURITY_FINDINGS_API_BASE_URL');
// ============================================================================
// HTTP Implementation
// ============================================================================
@Injectable()
export class SecurityFindingsHttpClient implements SecurityFindingsApi {
constructor(
private readonly http: HttpClient,
@Inject(SECURITY_FINDINGS_API_BASE_URL) private readonly baseUrl: string,
private readonly authSession: AuthSessionStore,
) {}
listFindings(filter?: FindingsFilter): Observable<FindingDto[]> {
let params = new HttpParams();
if (filter?.severity) params = params.set('severity', filter.severity);
if (filter?.reachability) params = params.set('reachability', filter.reachability);
if (filter?.environment) params = params.set('environment', filter.environment);
if (filter?.limit) params = params.set('limit', filter.limit.toString());
if (filter?.sort) params = params.set('sort', filter.sort);
return this.http.get<FindingDto[]>(`${this.baseUrl}/api/v1/findings`, {
params,
headers: this.buildHeaders(),
});
}
getFinding(findingId: string): Observable<FindingDetailDto> {
return this.http.get<FindingDetailDto>(`${this.baseUrl}/api/v1/findings/${findingId}`, {
headers: this.buildHeaders(),
});
}
private buildHeaders(): HttpHeaders {
const tenantId = this.authSession.getActiveTenantId();
const headers: Record<string, string> = {};
if (tenantId) {
headers['X-StellaOps-Tenant'] = tenantId;
}
return new HttpHeaders(headers);
}
}

View File

@@ -0,0 +1,167 @@
/**
* Security Overview API Client
* Aggregates data from scanner and policy services for the security dashboard.
*/
import { Injectable, InjectionToken, Inject } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { AuthSessionStore } from '../auth/auth-session.store';
import { SECURITY_FINDINGS_API_BASE_URL } from './security-findings.client';
import { POLICY_EXCEPTIONS_API_BASE_URL } from './policy-exceptions.client';
// ============================================================================
// Models
// ============================================================================
export interface SecurityOverviewStats {
critical: number;
high: number;
medium: number;
low: number;
reachable: number;
}
export interface SecurityOverviewVexStats {
covered: number;
pending: number;
}
export interface RecentFinding {
id: string;
package: string;
severity: string;
reachable: boolean;
time: string;
}
export interface TopPackage {
name: string;
version: string;
critical: number;
high: number;
medium: number;
}
export interface ActiveException {
id: string;
finding: string;
reason: string;
expiresIn: string;
}
export interface SecurityOverviewData {
stats: SecurityOverviewStats;
vexStats: SecurityOverviewVexStats;
recentFindings: RecentFinding[];
topPackages: TopPackage[];
activeExceptions: ActiveException[];
}
// ============================================================================
// API Interface
// ============================================================================
export interface SecurityOverviewApi {
getOverviewStats(): Observable<SecurityOverviewData>;
}
export const SECURITY_OVERVIEW_API = new InjectionToken<SecurityOverviewApi>('SECURITY_OVERVIEW_API');
// ============================================================================
// HTTP Implementation
// ============================================================================
@Injectable()
export class SecurityOverviewHttpClient implements SecurityOverviewApi {
constructor(
private readonly http: HttpClient,
@Inject(SECURITY_FINDINGS_API_BASE_URL) private readonly scannerBaseUrl: string,
@Inject(POLICY_EXCEPTIONS_API_BASE_URL) private readonly policyBaseUrl: string,
private readonly authSession: AuthSessionStore,
) {}
getOverviewStats(): Observable<SecurityOverviewData> {
const headers = this.buildHeaders();
const findings$ = this.http.get<any[]>(
`${this.scannerBaseUrl}/api/v1/findings`,
{ headers }
).pipe(catchError(() => of([] as any[])));
const exceptions$ = this.http.get<any[]>(
`${this.policyBaseUrl}/policyGateway/api/v1/policy/exception/requests`,
{ params: { status: 'active' }, headers }
).pipe(catchError(() => of([] as any[])));
return forkJoin({ findings: findings$, exceptions: exceptions$ }).pipe(
map(({ findings, exceptions }) => this.mapToOverviewData(findings, exceptions))
);
}
private mapToOverviewData(findings: any[], exceptions: any[]): SecurityOverviewData {
const stats: SecurityOverviewStats = {
critical: findings.filter((f: any) => f.severity === 'CRITICAL').length,
high: findings.filter((f: any) => f.severity === 'HIGH').length,
medium: findings.filter((f: any) => f.severity === 'MEDIUM').length,
low: findings.filter((f: any) => f.severity === 'LOW').length,
reachable: findings.filter((f: any) => f.reachable === true).length,
};
const withVex = findings.filter((f: any) => f.vexStatus && f.vexStatus !== 'none');
const vexStats: SecurityOverviewVexStats = {
covered: withVex.length,
pending: findings.length - withVex.length,
};
const recentFindings: RecentFinding[] = findings
.slice(0, 5)
.map((f: any) => ({
id: f.id,
package: `${f.package}:${f.version}`,
severity: f.severity,
reachable: f.reachable === true,
time: f.firstSeen,
}));
const pkgMap = new Map<string, TopPackage>();
for (const f of findings) {
const key = f.package;
const existing = pkgMap.get(key) ?? { name: f.package, version: f.version, critical: 0, high: 0, medium: 0 };
if (f.severity === 'CRITICAL') existing.critical++;
else if (f.severity === 'HIGH') existing.high++;
else if (f.severity === 'MEDIUM') existing.medium++;
pkgMap.set(key, existing);
}
const topPackages = Array.from(pkgMap.values())
.sort((a, b) => (b.critical * 100 + b.high * 10 + b.medium) - (a.critical * 100 + a.high * 10 + a.medium))
.slice(0, 5);
const activeExceptions: ActiveException[] = (exceptions ?? []).slice(0, 5).map((e: any) => ({
id: e.id ?? '',
finding: e.findingId ?? e.cveId ?? '',
reason: e.reason ?? e.justification ?? '',
expiresIn: e.expiresAt ? this.formatExpiresIn(e.expiresAt) : 'N/A',
}));
return { stats, vexStats, recentFindings, topPackages, activeExceptions };
}
private formatExpiresIn(expiresAt: string): string {
const ms = new Date(expiresAt).getTime() - Date.now();
if (ms <= 0) return 'Expired';
const days = Math.floor(ms / 86400000);
if (days > 0) return `${days} day${days > 1 ? 's' : ''}`;
const hours = Math.floor(ms / 3600000);
return `${hours}h`;
}
private buildHeaders(): HttpHeaders {
const tenantId = this.authSession.getActiveTenantId();
const headers: Record<string, string> = {};
if (tenantId) {
headers['X-StellaOps-Tenant'] = tenantId;
}
return new HttpHeaders(headers);
}
}

View File

@@ -2,7 +2,7 @@ import { Injectable, inject, signal, InjectionToken } from '@angular/core';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Observable, Subject, of, delay, throwError, map, tap, catchError, finalize } from 'rxjs';
import { APP_CONFIG } from '../config/app-config.model';
import { AppConfigService } from '../config/app-config.service';
import { AuthSessionStore } from '../auth/auth-session.store';
import { TenantActivationService } from '../auth/tenant-activation.service';
import { generateTraceId } from './trace.util';
@@ -176,7 +176,8 @@ export const VEX_CONSENSUS_API = new InjectionToken<VexConsensusApi>('VEX_CONSEN
@Injectable({ providedIn: 'root' })
export class VexConsensusHttpClient implements VexConsensusApi {
private readonly http = inject(HttpClient);
private readonly config = inject(APP_CONFIG);
private readonly configService = inject(AppConfigService);
private get config() { return this.configService.config; }
private readonly authStore = inject(AuthSessionStore);
private readonly tenantService = inject(TenantActivationService);

View File

@@ -3,7 +3,7 @@ import { Observable, Subject, of, timer, switchMap, takeWhile, map, tap, catchEr
import { TenantActivationService } from '../auth/tenant-activation.service';
import { AuthSessionStore } from '../auth/auth-session.store';
import { APP_CONFIG } from '../config/app-config.model';
import { AppConfigService } from '../config/app-config.service';
import { generateTraceId } from './trace.util';
import {
VulnExportRequest,
@@ -145,7 +145,8 @@ export const VULN_EXPORT_ORCHESTRATOR_API = new InjectionToken<VulnExportOrchest
*/
@Injectable({ providedIn: 'root' })
export class VulnExportOrchestratorService implements VulnExportOrchestratorApi {
private readonly config = inject(APP_CONFIG);
private readonly configService = inject(AppConfigService);
private get config() { return this.configService.config; }
private readonly authStore = inject(AuthSessionStore);
private readonly tenantService = inject(TenantActivationService);

View File

@@ -11,8 +11,7 @@ const RETRY_HEADER = 'X-StellaOps-DPoP-Retry';
@Injectable()
export class AuthHttpInterceptor implements HttpInterceptor {
private excludedOrigins: Set<string> | null = null;
private tokenEndpoint: string | null = null;
private excludedPrefixes: string[] = [];
private authorityResolved = false;
constructor(
@@ -118,11 +117,7 @@ export class AuthHttpInterceptor implements HttpInterceptor {
if (resolved.pathname.endsWith('/config.json')) {
return true;
}
if (this.tokenEndpoint && absolute.startsWith(this.tokenEndpoint)) {
return true;
}
const origin = resolved.origin;
return this.excludedOrigins?.has(origin) ?? false;
return this.excludedPrefixes.some((prefix) => absolute.startsWith(prefix));
} catch {
return false;
}
@@ -149,14 +144,10 @@ export class AuthHttpInterceptor implements HttpInterceptor {
}
try {
const authority = this.config.authority;
this.tokenEndpoint = new URL(
authority.tokenEndpoint,
authority.issuer
).toString();
this.excludedOrigins = new Set<string>([
this.tokenEndpoint,
new URL(authority.authorizeEndpoint, authority.issuer).origin,
]);
this.excludedPrefixes = [
new URL(authority.tokenEndpoint, authority.issuer).toString(),
new URL(authority.authorizeEndpoint, authority.issuer).toString(),
];
this.authorityResolved = true;
} catch {
// Configuration not yet loaded; interceptor will retry on the next request.

View File

@@ -322,6 +322,7 @@ export class AuthorityAuthService {
const authority = this.config.authority;
if (!authority.logoutEndpoint) {
window.location.assign(authority.postLogoutRedirectUri ?? authority.redirectUri);
return;
}

View File

@@ -302,18 +302,23 @@ export class AppConfigService {
}
/**
* Converts absolute Docker-internal URLs (e.g. http://gateway.stella-ops.local)
* to relative paths (e.g. /gateway) so requests go through the console's nginx
* Converts absolute Docker-internal URLs (e.g. http://scanner.stella-ops.local)
* to relative paths (e.g. /scanner) so requests go through the gateway's
* reverse proxy and avoid CORS failures in containerized deployments.
*
* The `gateway` key is a special case: since the browser is already talking
* to the gateway (the SPA is served by it), its base URL is normalized to
* empty string (same origin) instead of `/gateway` to avoid a self-proxy loop.
*/
private normalizeApiBaseUrls(urls: ApiBaseUrlConfig): ApiBaseUrlConfig {
const entries = Object.entries(urls) as [string, string | undefined][];
const normalized: Record<string, string | undefined> = {};
for (const [key, value] of entries) {
normalized[key] =
typeof value === 'string' && /^https?:\/\//.test(value)
? `/${key}`
: value;
if (typeof value === 'string' && /^https?:\/\//.test(value)) {
normalized[key] = key === 'gateway' ? '' : `/${key}`;
} else {
normalized[key] = value;
}
}
return normalized as unknown as ApiBaseUrlConfig;
}

View File

@@ -9,7 +9,7 @@ import { Injectable, inject } from '@angular/core';
import { Observable, throwError, timer } from 'rxjs';
import { catchError, retry } from 'rxjs/operators';
import { APP_CONFIG } from '../config/app-config.model';
import { AppConfigService } from '../config/app-config.service';
import { parsePolicyError, PolicyApiError } from './policy-error.handler';
const MAX_RETRIES = 2;
@@ -27,7 +27,8 @@ const RETRY_DELAY_MS = 1000;
*/
@Injectable()
export class PolicyErrorInterceptor implements HttpInterceptor {
private readonly config = inject(APP_CONFIG);
private readonly configService = inject(AppConfigService);
private get config() { return this.configService.config; }
private get policyApiBase(): string {
return this.config.apiBaseUrls.policy ?? '';

View File

@@ -3,7 +3,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { Observable, BehaviorSubject, timer, of, catchError, map, tap } from 'rxjs';
import { APP_CONFIG } from '../config/app-config.model';
import { AppConfigService } from '../config/app-config.service';
import { ConsoleSessionStore } from '../console/console-session.store';
import { QuotaInfo, RateLimitInfo } from '../api/policy-engine.models';
@@ -66,7 +66,8 @@ interface LocalQuotaState {
@Injectable({ providedIn: 'root' })
export class PolicyQuotaService {
private readonly http = inject(HttpClient);
private readonly config = inject(APP_CONFIG);
private readonly configService = inject(AppConfigService);
private get config() { return this.configService.config; }
private readonly session = inject(ConsoleSessionStore);
private readonly destroyRef = inject(DestroyRef);

View File

@@ -1,9 +1,13 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { Component, ChangeDetectionStrategy, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { catchError, of } from 'rxjs';
import { APPROVAL_API } from '../../core/api/approval.client';
import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.models';
/**
* ApprovalsInboxComponent - Approval decision cockpit.
* Wired to real APPROVAL_API for live data.
*/
@Component({
selector: 'app-approvals-inbox',
@@ -17,16 +21,16 @@ import { RouterLink } from '@angular/router';
Decide promotions with policy + reachability, backed by signed evidence.
</p>
</div>
<a routerLink="/docs" class="btn btn--secondary">Docs </a>
<a routerLink="/docs" class="btn btn--secondary">Docs &rarr;</a>
</header>
<!-- Filters -->
<div class="approvals__filters">
<select class="filter-select">
<select class="filter-select" (change)="onStatusFilter($event)">
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="all">All</option>
<option value="">All</option>
</select>
<select class="filter-select">
<option value="">All Environments</option>
@@ -38,45 +42,60 @@ import { RouterLink } from '@angular/router';
<input type="text" class="filter-search" placeholder="Search..." />
</div>
<!-- Pending approvals -->
<section class="approvals__section">
<h2 class="approvals__section-title">Pending (3)</h2>
@if (loading()) {
<div class="loading-banner">Loading approvals...</div>
}
@for (approval of pendingApprovals; track approval.id) {
<div class="approval-card">
<div class="approval-card__header">
<a [routerLink]="['/releases', approval.release]" class="approval-card__release">
{{ approval.release }}
</a>
<span class="approval-card__flow">{{ approval.from }} → {{ approval.to }}</span>
<span class="approval-card__meta">Requested by: {{ approval.requestedBy }} • {{ approval.timeAgo }}</span>
</div>
@if (error()) {
<div class="error-banner">{{ error() }}</div>
}
<div class="approval-card__changes">
<strong>WHAT CHANGED:</strong>
{{ approval.changes }}
</div>
<!-- Approvals list -->
@if (!loading()) {
<section class="approvals__section">
<h2 class="approvals__section-title">Results ({{ approvals().length }})</h2>
<div class="approval-card__gates">
<div class="gates-row">
@for (gate of approval.gates; track gate.name) {
<div class="gate-item" [class]="'gate-item--' + gate.state">
<span class="gate-item__badge">{{ gate.state | uppercase }}</span>
<span class="gate-item__name">{{ gate.name }}</span>
@for (approval of approvals(); track approval.id) {
<div class="approval-card">
<div class="approval-card__header">
<a [routerLink]="['/releases', approval.releaseId]" class="approval-card__release">
{{ approval.releaseName }} v{{ approval.releaseVersion }}
</a>
<span class="approval-card__flow">{{ approval.sourceEnvironment }} &rarr; {{ approval.targetEnvironment }}</span>
<span class="approval-card__meta">Requested by: {{ approval.requestedBy }} &bull; {{ timeAgo(approval.requestedAt) }}</span>
</div>
<div class="approval-card__changes">
<strong>JUSTIFICATION:</strong>
{{ approval.justification }}
</div>
<div class="approval-card__gates">
<div class="gates-row">
<div class="gate-item" [class]="approval.gatesPassed ? 'gate-item--pass' : 'gate-item--block'">
<span class="gate-item__badge">{{ approval.gatesPassed ? 'PASS' : 'BLOCK' }}</span>
<span class="gate-item__name">Policy Gates</span>
</div>
<div class="gate-item">
<span class="gate-item__badge">{{ approval.currentApprovals }}/{{ approval.requiredApprovals }}</span>
<span class="gate-item__name">Approvals</span>
</div>
</div>
</div>
<div class="approval-card__actions">
@if (approval.status === 'pending') {
<button type="button" class="btn btn--success" (click)="approveRequest(approval.id)">Approve</button>
<button type="button" class="btn btn--danger" (click)="rejectRequest(approval.id)">Reject</button>
}
<a [routerLink]="['/approvals', approval.id]" class="btn btn--secondary">View Details</a>
</div>
</div>
<div class="approval-card__actions">
<button type="button" class="btn btn--success">Approve</button>
<button type="button" class="btn btn--danger">Reject</button>
<a [routerLink]="['/approvals', approval.id]" class="btn btn--secondary">View Details</a>
<a [routerLink]="['/evidence', approval.evidenceId]" class="btn btn--ghost">Open Evidence</a>
</div>
</div>
}
</section>
} @empty {
<div class="empty-state">No approvals match the current filters</div>
}
</section>
}
</div>
`,
styles: [`
@@ -125,6 +144,28 @@ import { RouterLink } from '@angular/router';
min-width: 200px;
}
.loading-banner {
padding: 2rem;
text-align: center;
color: var(--color-text-secondary);
}
.error-banner {
padding: 1rem;
margin-bottom: 1rem;
background: var(--color-status-error-bg);
border: 1px solid rgba(248, 113, 113, 0.5);
color: var(--color-status-error);
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
.empty-state {
padding: 2rem;
text-align: center;
color: var(--color-text-secondary);
}
.approvals__section {
margin-bottom: 2rem;
}
@@ -277,55 +318,64 @@ import { RouterLink } from '@angular/router';
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ApprovalsInboxComponent {
readonly pendingApprovals = [
{
id: '1',
release: 'v1.2.5',
from: 'QA',
to: 'Staging',
requestedBy: 'deploy-bot',
timeAgo: '2h ago',
changes: '+3 pkgs +2 CVEs (1 reachable) -5 fixed Drift: none',
evidenceId: 'EVD-2026-0045',
gates: [
{ name: 'SBOM signed', state: 'pass' },
{ name: 'Provenance', state: 'pass' },
{ name: 'Reachability', state: 'warn' },
{ name: 'Critical CVEs', state: 'pass' },
],
},
{
id: '2',
release: 'v1.2.6',
from: 'Dev',
to: 'QA',
requestedBy: 'ci-pipeline',
timeAgo: '4h ago',
changes: '+1 pkg 0 CVEs -2 fixed Drift: none',
evidenceId: 'EVD-2026-0046',
gates: [
{ name: 'SBOM signed', state: 'pass' },
{ name: 'Provenance', state: 'pass' },
{ name: 'Reachability', state: 'pass' },
{ name: 'Critical CVEs', state: 'pass' },
],
},
{
id: '3',
release: 'v1.2.4',
from: 'Staging',
to: 'Prod',
requestedBy: 'release-mgr',
timeAgo: '1d ago',
changes: '+0 pkgs +1 CVE (reachable!) Drift: 1 config',
evidenceId: 'EVD-2026-0044',
gates: [
{ name: 'SBOM signed', state: 'pass' },
{ name: 'Provenance', state: 'pass' },
{ name: 'Reachability', state: 'block' },
{ name: 'Critical CVEs', state: 'block' },
],
},
];
export class ApprovalsInboxComponent implements OnInit {
private readonly api = inject(APPROVAL_API);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly approvals = signal<ApprovalRequest[]>([]);
private currentStatusFilter: ApprovalStatus[] = ['pending'];
ngOnInit(): void {
this.loadApprovals();
}
onStatusFilter(event: Event): void {
const value = (event.target as HTMLSelectElement).value;
this.currentStatusFilter = value ? [value as ApprovalStatus] : [];
this.loadApprovals();
}
approveRequest(id: string): void {
this.api.approve(id, '').pipe(
catchError(() => {
this.error.set('Failed to approve request');
return of(null);
})
).subscribe(() => this.loadApprovals());
}
rejectRequest(id: string): void {
this.api.reject(id, '').pipe(
catchError(() => {
this.error.set('Failed to reject request');
return of(null);
})
).subscribe(() => this.loadApprovals());
}
timeAgo(dateStr: string): string {
const ms = Date.now() - new Date(dateStr).getTime();
const hours = Math.floor(ms / 3600000);
if (hours < 1) return 'just now';
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
private loadApprovals(): void {
this.loading.set(true);
this.error.set(null);
const filter = this.currentStatusFilter.length
? { statuses: this.currentStatusFilter }
: {};
this.api.listApprovals(filter).pipe(
catchError(() => {
this.error.set('Failed to load approvals. The backend may be unavailable.');
return of([]);
})
).subscribe(approvals => {
this.approvals.set(approvals);
this.loading.set(false);
});
}
}

View File

@@ -1,3 +1,10 @@
/**
* Auth Callback Component
* Redesigned: "Stellar Mission Control" aesthetic
*
* Intermediate screen during OAuth redirect. Shows orbital spinner
* while processing, or error state with retry.
*/
import { Component, OnInit, inject, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
@@ -8,34 +15,59 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
selector: 'app-auth-callback',
imports: [],
template: `
<div class="auth-callback-backdrop">
<section class="auth-callback-card" role="status" [attr.aria-busy]="state() === 'processing'">
<div class="viewport">
@if (state() === 'processing') {
<!-- Brand icon -->
<div class="brand-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L4 7v6c0 5.25 3.4 10.15 8 11 4.6-.85 8-5.75 8-11V7l-8-5z"/>
<rect x="10" y="10" width="4" height="5" rx="0.5"/>
<path d="M10 10V8.5a2 2 0 1 1 4 0V10"/>
</svg>
</div>
<!-- Spinner -->
<div class="spinner-container" aria-hidden="true">
<div class="spinner"></div>
</div>
<!-- Status text -->
<p class="status-text">Completing sign-in&hellip;</p>
<p class="status-subtext">Securely verifying your credentials</p>
<!-- Starfield (lighter density than welcome) -->
<div class="stars" aria-hidden="true">
@for (s of stars; track $index) {
<i class="star"
[style.left.%]="s.x"
[style.top.%]="s.y"
[style.width.px]="s.s"
[style.height.px]="s.s"
[style.animation-delay.ms]="s.d"
[style.animation-duration.ms]="s.dur"></i>
}
</div>
<!-- Processing state -->
@if (state() === 'processing') {
<section class="card" role="status" aria-busy="true">
<!-- Orbital spinner -->
<div class="spinner">
<svg class="spinner__svg" viewBox="0 0 100 100" width="88" height="88">
<circle class="orbit orbit--1" cx="50" cy="50" r="44" />
<circle class="orbit orbit--2" cx="50" cy="50" r="34" />
<circle class="orbit orbit--3" cx="50" cy="50" r="24" />
</svg>
<!-- Shield icon in center -->
<div class="spinner__icon">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none"
stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round">
<path class="shield-path"
d="M12 2L4 7v6c0 5.25 3.4 10.15 8 11 4.6-.85 8-5.75 8-11V7l-8-5z"/>
<rect class="lock-body" x="10" y="10" width="4" height="5" rx="0.5"/>
<path class="lock-shackle" d="M10 10V8.5a2 2 0 1 1 4 0V10"/>
</svg>
</div>
</div>
<p class="status-heading">Completing sign-in&hellip;</p>
<p class="status-sub">Securely verifying your credentials</p>
</section>
}
<!-- Error state -->
@if (state() === 'error') {
<section class="card card--error">
@if (state() === 'error') {
<!-- Error icon -->
<div class="error-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
<div class="err-icon">
<svg viewBox="0 0 24 24" width="28" height="28" fill="none"
stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
@@ -43,271 +75,376 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
</svg>
</div>
<p class="error-heading">Sign-in failed</p>
<p class="error-message">
<p class="err-heading">Sign-in failed</p>
<p class="err-message">
We were unable to complete the sign-in flow.
Please check your connection and try again.
</p>
<a class="retry-link" href="/" aria-label="Return to the home page to retry sign-in">
<svg class="retry-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<a class="retry-btn" href="/" aria-label="Return to the home page to retry sign-in">
<svg class="retry-btn__icon" viewBox="0 0 24 24" width="16" height="16"
fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg>
Try again
</a>
}
</section>
}
</section>
</div>
`,
styles: [
`
/* ------------------------------------------------------------------ */
/* Keyframes */
/* ------------------------------------------------------------------ */
@keyframes spin {
to { transform: rotate(360deg); }
`,
styles: [`
/* ==================================================================
VIEWPORT
================================================================== */
:host {
display: block;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
}
.viewport {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
min-height: 100dvh;
overflow: hidden;
position: relative;
background:
radial-gradient(ellipse 70% 50% at 50% 30%, rgba(245, 184, 74, 0.05) 0%, transparent 60%),
radial-gradient(ellipse 40% 50% at 80% 90%, rgba(59, 130, 246, 0.03) 0%, transparent 50%),
#060a14;
}
/* ==================================================================
STARFIELD (shared with welcome — lighter density)
================================================================== */
.stars {
position: absolute;
inset: 0;
pointer-events: none;
}
.star {
position: absolute;
border-radius: 50%;
background: rgba(255, 255, 255, 0.6);
animation: twinkle ease-in-out infinite alternate;
}
/* ==================================================================
CARD — glassmorphic container for states
================================================================== */
.card {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
max-width: 400px;
width: 100%;
padding: 2.75rem 2.5rem 2.25rem;
border-radius: 24px;
background: rgba(8, 14, 26, 0.5);
backdrop-filter: blur(20px) saturate(1.3);
-webkit-backdrop-filter: blur(20px) saturate(1.3);
border: 1px solid rgba(245, 184, 74, 0.08);
box-shadow:
0 0 60px rgba(245, 184, 74, 0.03),
0 16px 48px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
animation: card-in 600ms cubic-bezier(0.18, 0.89, 0.32, 1) both;
}
.card--error {
border-color: rgba(239, 68, 68, 0.12);
box-shadow:
0 0 60px rgba(239, 68, 68, 0.04),
0 16px 48px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.03);
}
/* ==================================================================
ORBITAL SPINNER
================================================================== */
.spinner {
position: relative;
width: 88px;
height: 88px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
animation: fade-in 500ms ease both;
}
.spinner__svg {
position: absolute;
inset: 0;
}
.orbit {
fill: none;
stroke-linecap: round;
transform-origin: 50px 50px;
}
/* Outer orbit — slow CW */
.orbit--1 {
stroke: rgba(245, 184, 74, 0.2);
stroke-width: 1;
stroke-dasharray: 80 196;
animation: orbit-spin-cw 3s linear infinite;
}
/* Middle orbit — medium CCW */
.orbit--2 {
stroke: rgba(245, 184, 74, 0.3);
stroke-width: 1.2;
stroke-dasharray: 55 159;
animation: orbit-spin-ccw 2.2s linear infinite;
}
/* Inner orbit — fast CW */
.orbit--3 {
stroke: rgba(245, 184, 74, 0.45);
stroke-width: 1.5;
stroke-dasharray: 35 116;
animation: orbit-spin-cw 1.6s linear infinite;
}
/* Shield icon in spinner center */
.spinner__icon {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
color: rgba(245, 184, 74, 0.7);
animation: icon-breathe 3s ease-in-out infinite;
}
/* Shield stroke drawing */
.shield-path {
stroke-dasharray: 70;
stroke-dashoffset: 70;
animation: draw-shield 1.2s cubic-bezier(0.4, 0, 0.2, 1) 200ms forwards;
}
.lock-body {
opacity: 0;
animation: fade-in 400ms ease 900ms forwards;
}
.lock-shackle {
stroke-dasharray: 20;
stroke-dashoffset: 20;
animation: draw-shield 0.6s ease 700ms forwards;
}
/* ==================================================================
STATUS TEXT (processing)
================================================================== */
.status-heading {
margin: 0 0 0.375rem;
font-size: 1.125rem;
font-weight: 600;
color: #F5F0E6;
line-height: 1.3;
animation: slide-up 500ms cubic-bezier(0.18, 0.89, 0.32, 1) 200ms both;
}
.status-sub {
margin: 0;
font-size: 0.8125rem;
font-weight: 400;
color: rgba(212, 203, 190, 0.6);
line-height: 1.5;
animation: pulse-text 2.8s ease-in-out 1s infinite;
}
/* ==================================================================
ERROR STATE
================================================================== */
.err-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
margin-bottom: 1.25rem;
border-radius: 50%;
background: rgba(239, 68, 68, 0.12);
color: #f87171;
animation: fade-in 500ms ease both;
}
.err-heading {
margin: 0 0 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: #F5F0E6;
line-height: 1.3;
animation: slide-up 500ms ease 100ms both;
}
.err-message {
margin: 0 0 1.5rem;
font-size: 0.8125rem;
font-weight: 400;
color: rgba(212, 203, 190, 0.6);
line-height: 1.6;
max-width: 280px;
animation: slide-up 500ms ease 200ms both;
}
.retry-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.5rem;
border: 1px solid rgba(245, 184, 74, 0.2);
border-radius: 12px;
background: rgba(245, 184, 74, 0.06);
color: rgba(245, 184, 74, 0.8);
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition:
background-color 200ms ease,
border-color 200ms ease,
color 200ms ease,
box-shadow 200ms ease,
transform 200ms ease;
animation: slide-up 500ms ease 300ms both;
}
.retry-btn:hover {
background: rgba(245, 184, 74, 0.12);
border-color: rgba(245, 184, 74, 0.35);
color: rgba(245, 184, 74, 1);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(245, 184, 74, 0.1);
}
.retry-btn:focus-visible {
outline: 2px solid rgba(245, 184, 74, 0.4);
outline-offset: 2px;
}
.retry-btn__icon {
flex-shrink: 0;
}
/* ==================================================================
KEYFRAMES
================================================================== */
@keyframes twinkle {
0% { opacity: 0.1; transform: scale(0.8); }
100% { opacity: 0.75; transform: scale(1.1); }
}
@keyframes card-in {
from { opacity: 0; transform: translateY(20px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes orbit-spin-cw {
to { transform: rotate(360deg); }
}
@keyframes orbit-spin-ccw {
to { transform: rotate(-360deg); }
}
@keyframes draw-shield {
to { stroke-dashoffset: 0; }
}
@keyframes icon-breathe {
0%, 100% { opacity: 0.65; }
50% { opacity: 1; }
}
@keyframes pulse-text {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
/* ==================================================================
REDUCED MOTION
================================================================== */
@media (prefers-reduced-motion: reduce) {
.star,
.card,
.spinner,
.spinner__icon,
.orbit--1, .orbit--2, .orbit--3,
.shield-path, .lock-body, .lock-shackle,
.status-heading, .status-sub,
.err-icon, .err-heading, .err-message,
.retry-btn {
animation: none !important;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
.card, .spinner, .spinner__icon,
.status-heading, .status-sub,
.err-icon, .err-heading, .err-message,
.retry-btn, .lock-body {
opacity: 1;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
.shield-path, .lock-shackle {
stroke-dashoffset: 0;
}
@keyframes cardEntrance {
from {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
/* Keep a simple rotation for the spinner so user knows it's loading */
.orbit--2 {
animation: orbit-spin-ccw 3s linear infinite !important;
}
/* ------------------------------------------------------------------ */
/* Backdrop (full viewport) */
/* ------------------------------------------------------------------ */
.auth-callback-backdrop {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
min-height: 100dvh;
padding: var(--space-4);
background:
radial-gradient(
ellipse 80% 60% at 50% 40%,
var(--color-brand-soft) 0%,
transparent 70%
),
var(--color-surface-primary);
font-family: var(--font-family-base);
.retry-btn {
transition: none;
}
}
/* ------------------------------------------------------------------ */
/* Card */
/* ------------------------------------------------------------------ */
.auth-callback-card {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 400px;
padding: var(--space-10) var(--space-8) var(--space-8);
background: var(--color-surface-elevated);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg), var(--shadow-brand-sm);
text-align: center;
animation: cardEntrance 500ms var(--motion-ease-entrance) both;
}
/* ------------------------------------------------------------------ */
/* Brand icon (shield/lock) */
/* ------------------------------------------------------------------ */
.brand-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
margin-bottom: var(--space-6);
border-radius: var(--radius-xl);
background: var(--color-brand-light);
color: var(--color-brand-primary);
animation: fadeInUp 600ms var(--motion-ease-entrance) both;
}
.brand-icon svg {
width: 28px;
height: 28px;
}
/* ------------------------------------------------------------------ */
/* Spinner */
/* ------------------------------------------------------------------ */
.spinner-container {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-5);
animation: fadeInUp 600ms var(--motion-ease-entrance) 100ms both;
/* ==================================================================
RESPONSIVE
================================================================== */
@media (max-width: 640px) {
.card {
padding: 2rem 1.5rem 1.75rem;
margin: 0 1rem;
border-radius: 20px;
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--color-border-primary);
border-top-color: var(--color-brand-primary);
border-radius: var(--radius-full);
animation: spin 0.85s linear infinite;
width: 72px;
height: 72px;
}
/* ------------------------------------------------------------------ */
/* Status text (processing state) */
/* ------------------------------------------------------------------ */
.status-text {
margin: 0 0 var(--space-1-5) 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-snug);
color: var(--color-text-heading);
animation: fadeInUp 600ms var(--motion-ease-entrance) 200ms both;
.spinner__svg {
width: 72px;
height: 72px;
}
.status-subtext {
margin: 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-base);
color: var(--color-text-muted);
animation: pulse 2.4s ease-in-out infinite;
animation-delay: 800ms;
}
/* ------------------------------------------------------------------ */
/* Error state */
/* ------------------------------------------------------------------ */
.error-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
margin-bottom: var(--space-5);
border-radius: var(--radius-full);
background: var(--color-status-error-bg);
color: var(--color-status-error);
animation: fadeInUp 500ms var(--motion-ease-entrance) both;
}
.error-icon svg {
width: 28px;
height: 28px;
}
.error-heading {
margin: 0 0 var(--space-2) 0;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-snug);
color: var(--color-text-heading);
animation: fadeInUp 500ms var(--motion-ease-entrance) 80ms both;
}
.error-message {
margin: 0 0 var(--space-6) 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-normal);
line-height: var(--line-height-relaxed);
color: var(--color-text-secondary);
max-width: 300px;
animation: fadeInUp 500ms var(--motion-ease-entrance) 160ms both;
}
/* ------------------------------------------------------------------ */
/* Retry link */
/* ------------------------------------------------------------------ */
.retry-link {
display: inline-flex;
align-items: center;
gap: var(--space-1-5);
padding: var(--space-2) var(--space-5);
font-family: var(--font-family-base);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-base);
color: var(--color-brand-primary);
text-decoration: none;
border: 1px solid var(--color-border-emphasis);
border-radius: var(--radius-lg);
background: transparent;
cursor: pointer;
transition:
background-color var(--motion-duration-sm) var(--motion-ease-standard),
border-color var(--motion-duration-sm) var(--motion-ease-standard),
color var(--motion-duration-sm) var(--motion-ease-standard),
box-shadow var(--motion-duration-sm) var(--motion-ease-standard);
animation: fadeInUp 500ms var(--motion-ease-entrance) 240ms both;
}
.retry-link:hover {
background: var(--color-brand-light);
border-color: var(--color-brand-primary);
box-shadow: var(--shadow-brand-sm);
}
.retry-link:focus-visible {
outline: 2px solid var(--color-focus-ring);
outline-offset: 2px;
}
.retry-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* ------------------------------------------------------------------ */
/* Reduced motion */
/* ------------------------------------------------------------------ */
@media (prefers-reduced-motion: reduce) {
.auth-callback-card,
.brand-icon,
.spinner-container,
.status-text,
.status-subtext,
.error-icon,
.error-heading,
.error-message,
.retry-link {
animation: none;
}
.spinner {
animation: spin 1.6s linear infinite;
}
.status-subtext {
animation: none;
opacity: 1;
}
}
`,
]
}
`]
})
export class AuthCallbackComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
@@ -316,6 +453,15 @@ export class AuthCallbackComponent implements OnInit {
readonly state = signal<'processing' | 'error'>('processing');
/** Deterministic star positions (lighter density for callback screen). */
readonly stars = Array.from({ length: 30 }, (_, i) => ({
x: ((i * 73 + 17) % 97),
y: ((i * 43 + 31) % 97),
s: 1 + (i % 2) * 0.5,
d: (i * 137) % 4000,
dur: 2800 + (i * 89) % 2200,
}));
async ngOnInit(): Promise<void> {
const params = this.route.snapshot.queryParamMap;
const searchParams = new URLSearchParams();

View File

@@ -390,8 +390,8 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
gap: 16px;
margin-bottom: 20px;
padding: 14px 20px;
background: var(--so-error-soft);
border: 1px solid rgba(220,38,38,.2);
background: var(--color-status-error-bg);
border: 1px solid var(--color-status-error-border);
border-radius: var(--radius-xl);
animation: banner-in 300ms var(--so-ease-out) both;
}
@@ -522,13 +522,13 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
}
.pipeline__stage--degraded {
border-color: rgba(217,119,6,.35);
background: var(--so-warning-soft);
border-color: var(--color-status-warning-border);
background: var(--color-status-warning-bg);
}
.pipeline__stage--unhealthy {
border-color: rgba(220,38,38,.35);
background: var(--so-error-soft);
border-color: var(--color-status-error-border);
background: var(--color-status-error-bg);
}
.pipeline__stage-header {
@@ -561,21 +561,21 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
}
.pipeline__health-badge--healthy {
background: var(--so-success-soft);
color: var(--so-success);
border: 1px solid rgba(5,150,105,.2);
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
border: 1px solid var(--color-status-success-border);
}
.pipeline__health-badge--degraded {
background: var(--so-warning-soft);
color: var(--so-warning);
border: 1px solid rgba(217,119,6,.2);
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
border: 1px solid var(--color-status-warning-border);
}
.pipeline__health-badge--unhealthy {
background: var(--so-error-soft);
color: var(--so-error);
border: 1px solid rgba(220,38,38,.2);
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
border: 1px solid var(--color-status-error-border);
}
.pipeline__health-badge--unknown {
@@ -687,9 +687,9 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
.card__urgency--high,
.card__urgency--critical {
background: var(--so-error-soft);
color: var(--so-error);
border: 1px solid rgba(220,38,38,.2);
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
border: 1px solid var(--color-status-error-border);
}
.card__urgency--normal {
@@ -714,16 +714,16 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
}
.card__dep-status--running {
background: var(--so-info-soft);
color: var(--so-info);
border: 1px solid rgba(37,99,235,.2);
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
border: 1px solid var(--color-status-info-border);
}
.card__dep-status--paused,
.card__dep-status--waiting {
background: var(--so-warning-soft);
color: var(--so-warning);
border: 1px solid rgba(217,119,6,.2);
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
border: 1px solid var(--color-status-warning-border);
}
.card__progress {
@@ -819,16 +819,16 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
}
.badge--deployed {
background: var(--so-success-soft);
color: var(--so-success);
border: 1px solid rgba(5,150,105,.2);
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
border: 1px solid var(--color-status-success-border);
}
.badge--ready,
.badge--promoting {
background: var(--so-warning-soft);
color: var(--so-warning);
border: 1px solid rgba(217,119,6,.2);
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
border: 1px solid var(--color-status-warning-border);
}
.badge--draft {
@@ -839,9 +839,9 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa
.badge--failed,
.badge--rolled_back {
background: var(--so-error-soft);
color: var(--so-error);
border: 1px solid rgba(220,38,38,.2);
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
border: 1px solid var(--color-status-error-border);
}
.badge--deprecated {

View File

@@ -36,6 +36,14 @@ export const evidenceExportRoutes: Routes = [
),
data: { title: 'Verdict Replay' },
},
{
path: 'proof-chains',
loadComponent: () =>
import('../proof-chain/proof-chain.component').then(
(m) => m.ProofChainComponent
),
data: { title: 'Proof Chains' },
},
{
path: 'provenance',
loadComponent: () =>

View File

@@ -211,15 +211,16 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
}
.type-badge--promotion { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.type-badge--scan { background: var(--color-severity-info-bg); color: var(--color-status-info-text); }
.type-badge--deployment { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
.type-badge--attestation { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); }
.type-badge--exception { background: var(--color-severity-high-bg); color: var(--color-severity-high); }
.type-badge--promotion { background: var(--color-brand-primary-10); color: var(--color-brand-secondary); border: 1px solid var(--color-brand-primary-20); }
.type-badge--scan { background: var(--color-status-info-bg); color: var(--color-status-info-text); border: 1px solid var(--color-status-info-border); }
.type-badge--deployment { background: var(--color-status-success-bg); color: var(--color-status-success-text); border: 1px solid var(--color-status-success-border); }
.type-badge--attestation { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); border: 1px solid var(--color-status-warning-border); }
.type-badge--exception { background: var(--color-status-error-bg); color: var(--color-status-error-text); border: 1px solid var(--color-status-error-border); }
.verified-badge {
padding: 0.25rem 0.75rem;
background: var(--color-severity-low-bg);
background: var(--color-status-success-bg);
color: var(--color-status-success-text);
border: 1px solid var(--color-status-success-border);
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);

View File

@@ -281,8 +281,8 @@ type TabMode = 'mirrors' | 'airgap' | 'version-locks';
styles: [`
.feed-mirror-dashboard {
padding: 1.5rem;
color: rgba(212, 201, 168, 0.3);
background: var(--color-text-heading);
color: var(--color-text-primary);
background: var(--color-surface-secondary);
min-height: calc(100vh - 120px);
}
@@ -320,7 +320,7 @@ type TabMode = 'mirrors' | 'airgap' | 'version-locks';
display: flex;
gap: 0.25rem;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--color-surface-inverse);
border-bottom: 1px solid var(--color-border-primary);
padding-bottom: 0.25rem;
button {
@@ -339,13 +339,13 @@ type TabMode = 'mirrors' | 'airgap' | 'version-locks';
margin-bottom: -1px;
&:hover {
color: rgba(212, 201, 168, 0.3);
background: rgba(255, 255, 255, 0.02);
color: var(--color-text-primary);
background: var(--color-nav-hover);
}
&.tab--active {
color: var(--color-status-info);
border-bottom-color: var(--color-status-info);
color: var(--color-brand-primary);
border-bottom-color: var(--color-brand-primary);
}
}
}
@@ -357,14 +357,14 @@ type TabMode = 'mirrors' | 'airgap' | 'version-locks';
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--color-text-primary);
background: var(--color-surface-secondary);
border-radius: var(--radius-lg);
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold);
&--error {
background: rgba(239, 68, 68, 0.2);
color: var(--color-status-error);
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
}
@@ -379,10 +379,10 @@ type TabMode = 'mirrors' | 'airgap' | 'version-locks';
display: flex;
flex-direction: column;
padding: 1rem 1.25rem;
background: var(--color-text-heading);
border: 1px solid var(--color-surface-inverse);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
border-left: 3px solid var(--color-text-primary);
border-left: 3px solid var(--color-border-primary);
.stat-value {
font-size: 1.5rem;
@@ -399,22 +399,22 @@ type TabMode = 'mirrors' | 'airgap' | 'version-locks';
&--synced {
border-left-color: var(--color-status-success);
.stat-value { color: var(--color-status-success); }
.stat-value { color: var(--color-status-success-text); }
}
&--stale {
border-left-color: var(--color-status-warning);
.stat-value { color: var(--color-status-warning); }
.stat-value { color: var(--color-status-warning-text); }
}
&--error {
border-left-color: var(--color-status-error);
.stat-value { color: var(--color-status-error); }
.stat-value { color: var(--color-status-error-text); }
}
&--storage {
border-left-color: var(--color-status-info);
.stat-value { color: var(--color-status-info); }
border-left-color: var(--color-brand-primary);
.stat-value { color: var(--color-brand-secondary); }
}
}
@@ -435,8 +435,8 @@ type TabMode = 'mirrors' | 'airgap' | 'version-locks';
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-text-primary);
border-top-color: var(--color-status-info);
border: 3px solid var(--color-border-primary);
border-top-color: var(--color-brand-primary);
border-radius: var(--radius-full);
animation: spin 1s linear infinite;
}
@@ -461,29 +461,29 @@ type TabMode = 'mirrors' | 'airgap' | 'version-locks';
align-items: center;
gap: 1rem;
padding: 1.5rem;
background: var(--color-text-heading);
border: 1px solid var(--color-surface-inverse);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
text-decoration: none;
color: inherit;
transition: all 0.15s;
&:hover {
border-color: var(--color-text-primary);
background: var(--color-text-primary);
border-color: var(--color-brand-primary);
background: var(--color-surface-secondary);
}
&--import {
&:hover {
border-color: rgba(34, 197, 94, 0.4);
.action-icon { color: var(--color-status-success); }
border-color: var(--color-status-success-border);
.action-icon { color: var(--color-status-success-text); }
}
}
&--export {
&:hover {
border-color: rgba(59, 130, 246, 0.4);
.action-icon { color: var(--color-status-info); }
border-color: var(--color-brand-primary-30);
.action-icon { color: var(--color-brand-secondary); }
}
}
}
@@ -494,7 +494,7 @@ type TabMode = 'mirrors' | 'airgap' | 'version-locks';
justify-content: center;
width: 56px;
height: 56px;
background: var(--color-surface-inverse);
background: var(--color-surface-secondary);
border-radius: var(--radius-lg);
color: var(--color-text-muted);
transition: color 0.15s;
@@ -515,8 +515,8 @@ type TabMode = 'mirrors' | 'airgap' | 'version-locks';
}
.bundles-section {
background: var(--color-text-heading);
border: 1px solid var(--color-surface-inverse);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
@@ -526,7 +526,7 @@ type TabMode = 'mirrors' | 'airgap' | 'version-locks';
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-surface-inverse);
border-bottom: 1px solid var(--color-border-primary);
h2 {
margin: 0;
@@ -562,12 +562,12 @@ type TabMode = 'mirrors' | 'airgap' | 'version-locks';
.bundle-card {
padding: 1rem;
background: var(--color-text-heading);
border: 1px solid var(--color-surface-inverse);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
&--building {
border-color: rgba(59, 130, 246, 0.3);
border-color: var(--color-status-info-border);
}
}
@@ -591,11 +591,11 @@ type TabMode = 'mirrors' | 'airgap' | 'version-locks';
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
&--ready { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); }
&--building { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); }
&--pending { background: rgba(148, 163, 184, 0.2); color: var(--color-text-muted); }
&--error { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); }
&--expired { background: rgba(234, 179, 8, 0.2); color: var(--color-status-warning); }
&--ready { background: var(--color-status-success-bg); color: var(--color-status-success-text); border: 1px solid var(--color-status-success-border); }
&--building { background: var(--color-status-info-bg); color: var(--color-status-info-text); border: 1px solid var(--color-status-info-border); }
&--pending { background: var(--color-severity-none-bg); color: var(--color-text-muted); border: 1px solid var(--color-severity-none-border); }
&--error { background: var(--color-status-error-bg); color: var(--color-status-error-text); border: 1px solid var(--color-status-error-border); }
&--expired { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); border: 1px solid var(--color-status-warning-border); }
}
.bundle-description {
@@ -616,13 +616,14 @@ type TabMode = 'mirrors' | 'airgap' | 'version-locks';
border-radius: var(--radius-sm);
font-size: 0.5625rem;
font-weight: var(--font-weight-bold);
border: 1px solid var(--color-border-primary);
&--nvd { background: rgba(59, 130, 246, 0.2); color: var(--color-status-info); }
&--ghsa { background: rgba(168, 85, 247, 0.2); color: var(--color-status-excepted); }
&--oval { background: rgba(236, 72, 153, 0.2); color: var(--color-status-excepted); }
&--osv { background: rgba(34, 197, 94, 0.2); color: var(--color-status-success); }
&--epss { background: rgba(249, 115, 22, 0.2); color: var(--color-severity-high); }
&--kev { background: rgba(239, 68, 68, 0.2); color: var(--color-status-error); }
&--nvd { background: var(--color-severity-info-bg); color: var(--color-status-info-text); border-color: var(--color-severity-info-border); }
&--ghsa { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); border-color: var(--color-status-excepted-border); }
&--oval { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); border-color: var(--color-status-excepted-border); }
&--osv { background: var(--color-severity-low-bg); color: var(--color-status-success-text); border-color: var(--color-severity-low-border); }
&--epss { background: var(--color-severity-high-bg); color: var(--color-status-warning-text); border-color: var(--color-severity-high-border); }
&--kev { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); border-color: var(--color-severity-critical-border); }
}
.bundle-meta {
@@ -646,7 +647,7 @@ type TabMode = 'mirrors' | 'airgap' | 'version-locks';
.bundle-actions {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-surface-inverse);
border-top: 1px solid var(--color-border-primary);
}
.btn {
@@ -667,12 +668,12 @@ type TabMode = 'mirrors' | 'airgap' | 'version-locks';
}
&--primary {
background: var(--color-status-info-text);
background: var(--color-brand-primary);
border: none;
color: white;
color: var(--color-text-heading);
&:hover {
background: var(--color-status-info-text);
background: var(--color-brand-secondary);
}
}
}

View File

@@ -291,8 +291,8 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
background: var(--color-text-heading);
border: 1px solid var(--color-surface-inverse);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
}
@@ -338,12 +338,12 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
&--secondary {
background: transparent;
border: 1px solid var(--color-text-primary);
border: 1px solid var(--color-border-primary);
color: var(--color-text-muted);
&:hover:not(:disabled) {
background: var(--color-surface-inverse);
color: rgba(212, 201, 168, 0.3);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
}
}
}
@@ -359,7 +359,7 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-text-primary);
border: 3px solid var(--color-border-primary);
border-top-color: var(--color-status-info);
border-radius: var(--radius-full);
animation: spin 1s linear infinite;
@@ -371,8 +371,8 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
}
.locks-table-container {
background: var(--color-text-heading);
border: 1px solid var(--color-surface-inverse);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
@@ -388,14 +388,14 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
text-transform: uppercase;
color: var(--color-text-secondary);
font-weight: var(--font-weight-medium);
border-bottom: 1px solid var(--color-surface-inverse);
border-bottom: 1px solid var(--color-border-primary);
letter-spacing: 0.05em;
}
td {
padding: 0.875rem 1rem;
font-size: 0.875rem;
border-bottom: 1px solid var(--color-surface-inverse);
border-bottom: 1px solid var(--color-border-primary);
}
tbody tr:last-child td {
@@ -500,15 +500,15 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
height: 28px;
padding: 0;
background: transparent;
border: 1px solid var(--color-text-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--color-surface-inverse);
color: rgba(212, 201, 168, 0.3);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
}
&--active {
@@ -549,8 +549,8 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
}
.info-panel {
background: var(--color-text-heading);
border: 1px solid var(--color-surface-inverse);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1.25rem;
@@ -571,7 +571,7 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
margin-bottom: 0.5rem;
strong {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-primary);
}
}
}
@@ -590,8 +590,8 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
}
.modal-content {
background: var(--color-text-heading);
border: 1px solid var(--color-surface-inverse);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
width: 100%;
max-width: 480px;
@@ -602,7 +602,7 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
align-items: center;
justify-content: space-between;
padding: 1.25rem;
border-bottom: 1px solid var(--color-surface-inverse);
border-bottom: 1px solid var(--color-border-primary);
h3 {
margin: 0;
@@ -620,7 +620,7 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
cursor: pointer;
&:hover {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-primary);
}
}
@@ -635,7 +635,7 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
gap: 0.75rem;
justify-content: flex-end;
padding: 1rem 1.25rem;
border-top: 1px solid var(--color-surface-inverse);
border-top: 1px solid var(--color-border-primary);
}
.form-group {
@@ -653,10 +653,10 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
.form-select,
.form-input {
padding: 0.625rem 1rem;
background: var(--color-text-heading);
border: 1px solid var(--color-text-primary);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-primary);
font-size: 0.875rem;
&:focus {

View File

@@ -114,7 +114,7 @@ interface FreshnessWarning {
styles: [`
.freshness-warnings {
margin-bottom: 1.5rem;
background: var(--color-text-heading);
background: var(--color-surface-primary);
border: 1px solid;
border-radius: var(--radius-lg);
overflow: hidden;
@@ -142,7 +142,7 @@ interface FreshnessWarning {
transition: background 0.15s;
&:hover {
background: rgba(255, 255, 255, 0.02);
background: var(--color-nav-hover);
}
}
@@ -207,7 +207,7 @@ interface FreshnessWarning {
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-surface-inverse);
border-bottom: 1px solid var(--color-border-primary);
&:last-child {
border-bottom: none;
@@ -281,7 +281,7 @@ interface FreshnessWarning {
.warnings-footer {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-surface-inverse);
border-top: 1px solid var(--color-border-primary);
}
.recommendation {
@@ -291,7 +291,7 @@ interface FreshnessWarning {
line-height: 1.5;
strong {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-primary);
}
}
`],

View File

@@ -190,10 +190,10 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
.search-input {
width: 100%;
padding: 0.625rem 1rem;
background: var(--color-text-heading);
border: 1px solid var(--color-surface-inverse);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-primary);
font-size: 0.875rem;
&::placeholder {
@@ -208,10 +208,10 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
.filter-select {
padding: 0.625rem 2rem 0.625rem 1rem;
background: var(--color-text-heading);
border: 1px solid var(--color-surface-inverse);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-primary);
font-size: 0.875rem;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236B5A2E' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
@@ -230,16 +230,16 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: var(--color-surface-inverse);
border: 1px solid var(--color-text-primary);
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-primary);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--color-text-primary);
background: var(--color-surface-secondary);
}
}
@@ -250,16 +250,16 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
}
.mirror-card {
background: var(--color-text-heading);
border: 1px solid var(--color-surface-inverse);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1rem;
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: var(--color-text-primary);
background: var(--color-text-primary);
border-color: var(--color-brand-primary);
background: var(--color-surface-secondary);
}
&:focus {
@@ -475,7 +475,7 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-surface-inverse);
border-top: 1px solid var(--color-border-primary);
}
.action-btn {
@@ -483,7 +483,7 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-text-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
@@ -506,11 +506,11 @@ import { FEED_MIRROR_API } from '../../core/api/feed-mirror.client';
}
&--view {
background: var(--color-surface-inverse);
color: rgba(212, 201, 168, 0.3);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
&:hover:not(:disabled) {
background: var(--color-text-primary);
background: var(--color-surface-secondary);
}
}
}

View File

@@ -137,8 +137,8 @@ import { OfflineSyncStatus, OfflineSyncState } from '../../core/api/feed-mirror.
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--color-text-heading);
border: 1px solid var(--color-surface-inverse);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
position: relative;
flex-wrap: wrap;
@@ -261,7 +261,7 @@ import { OfflineSyncStatus, OfflineSyncState } from '../../core/api/feed-mirror.
transition: color 0.15s;
&:hover {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-primary);
}
svg {
@@ -277,7 +277,7 @@ import { OfflineSyncStatus, OfflineSyncState } from '../../core/api/feed-mirror.
width: 100%;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-surface-inverse);
border-top: 1px solid var(--color-border-primary);
}
.details-grid {
@@ -320,7 +320,7 @@ import { OfflineSyncStatus, OfflineSyncState } from '../../core/api/feed-mirror.
.recommendations {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-surface-inverse);
border-top: 1px solid var(--color-border-primary);
}
.recommendations-label {

View File

@@ -88,8 +88,8 @@ import {
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
background: var(--color-text-heading);
border: 1px solid var(--color-surface-inverse);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.8125rem;
@@ -153,7 +153,7 @@ import {
.status-text {
font-weight: var(--font-weight-medium);
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-primary);
}
.sync-time {
@@ -173,7 +173,7 @@ import {
transition: color 0.15s;
&:hover {
color: rgba(212, 201, 168, 0.3);
color: var(--color-text-primary);
}
svg {
@@ -188,8 +188,8 @@ import {
.details-panel {
margin-top: 0.5rem;
padding: 0.75rem;
background: var(--color-text-heading);
border: 1px solid var(--color-surface-inverse);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.8125rem;
}
@@ -200,7 +200,7 @@ import {
padding: 0.25rem 0;
&:not(:last-child) {
border-bottom: 1px solid var(--color-surface-inverse);
border-bottom: 1px solid var(--color-border-primary);
padding-bottom: 0.5rem;
margin-bottom: 0.5rem;
}

View File

@@ -64,6 +64,13 @@
letter-spacing: -0.01em;
}
.dashboard__subtitle {
margin: 0.25rem 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
font-weight: var(--font-weight-regular);
}
.dashboard__actions {
display: flex;
align-items: center;

View File

@@ -3,6 +3,7 @@ import { Component, inject, OnInit, OnDestroy, signal, computed } from '@angular
import { RouterLink } from '@angular/router';
import { HomeDashboardService, VulnerabilitySummary, RiskSummary } from './home-dashboard.service';
import { AUTH_SERVICE, AuthService } from '../../core/auth';
import { ReachabilitySummary } from '../../core/api/reachability.models';
import { SkeletonComponent } from '../../shared/components/skeleton/skeleton.component';
@@ -16,7 +17,8 @@ import { SkeletonComponent } from '../../shared/components/skeleton/skeleton.com
template: `
<div class="dashboard page-enter">
<header class="dashboard__header">
<h1 class="dashboard__title">Security Dashboard</h1>
<h1 class="dashboard__title">{{ greeting() }}</h1>
<p class="dashboard__subtitle">Your security posture at a glance</p>
<div class="dashboard__actions">
@if (service.lastUpdated(); as updated) {
<span class="dashboard__updated">
@@ -298,6 +300,14 @@ import { SkeletonComponent } from '../../shared/components/skeleton/skeleton.com
})
export class HomeDashboardComponent implements OnInit, OnDestroy {
protected readonly service = inject(HomeDashboardService);
private readonly authService = inject(AUTH_SERVICE) as AuthService;
readonly greeting = computed(() => {
const hour = new Date().getHours();
const name = this.authService.user()?.name?.split(' ')[0];
const base = hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
return name ? `${base}, ${name}` : base;
});
private refreshInterval: ReturnType<typeof setInterval> | null = null;

View File

@@ -4,7 +4,9 @@
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core';
import { Router, RouterModule, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs/operators';
import { filter, catchError } from 'rxjs/operators';
import { of } from 'rxjs';
import { TRUST_API } from '../../core/api/trust.client';
type TabType = 'list' | 'detail';
@@ -122,13 +124,19 @@ type TabType = 'list' | 'detail';
})
export class IssuerTrustComponent implements OnInit {
private readonly router = inject(Router);
private readonly trustApi = inject(TRUST_API);
readonly totalIssuers = signal(0);
readonly expiringKeys = signal(0);
ngOnInit(): void {
// Stats would be loaded from API in real implementation
this.totalIssuers.set(5);
this.expiringKeys.set(1);
this.trustApi.getDashboardSummary().pipe(
catchError(() => of(null))
).subscribe(summary => {
if (summary) {
this.totalIssuers.set(summary.issuers.total);
this.expiringKeys.set(summary.keys.expiringSoon);
}
});
}
}

View File

@@ -3,7 +3,7 @@ import { Component, inject, signal, computed, OnInit, OnDestroy } from '@angular
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { PlatformHealthClient } from '../../core/api/platform-health.client';
import { PlatformHealthClient, MockPlatformHealthClient } from '../../core/api/platform-health.client';
import {
PlatformHealthSummary,
ServiceHealth,
@@ -23,6 +23,7 @@ import { Subject, interval, takeUntil, switchMap, startWith, forkJoin } from 'rx
@Component({
selector: 'app-platform-health-dashboard',
imports: [CommonModule, RouterModule, FormsModule],
providers: [{ provide: PlatformHealthClient, useClass: MockPlatformHealthClient }],
template: `
<div class="platform-health p-6">
<header class="mb-6">
@@ -334,16 +335,163 @@ import { Subject, interval, takeUntil, switchMap, startWith, forkJoin } from 'rx
`,
styles: [`
.platform-health {
min-height: 100vh;
background: var(--color-surface-primary);
min-height: calc(100vh - 120px);
background: var(--color-surface-secondary);
}
.p-6 { padding: 1.5rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 0.75rem; }
.mb-4 { margin-bottom: 1rem; }
.mr-2 { margin-right: 0.5rem; }
.mt-4 { margin-top: 1rem; }
.pt-4 { padding-top: 1rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.flex { display: flex; }
.grid { display: grid; }
.block { display: block; }
.items-center { align-items: center; }
.items-start { align-items: flex-start; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
.gap-1 { gap: 0.25rem; }
.flex-1 { flex: 1; }
.text-center { text-align: center; }
.text-left { text-align: left; }
.grid-cols-5 { grid-template-columns: repeat(5, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(3, 1fr); }
.grid-cols-2 { grid-template-columns: repeat(2, 1fr); }
.col-span-2 { grid-column: span 2; }
.rounded { border-radius: var(--radius-md); }
.rounded-lg { border-radius: var(--radius-lg); }
.rounded-md { border-radius: var(--radius-md); }
.rounded-full { border-radius: var(--radius-full); }
.border { border: 1px solid var(--color-border-primary); }
.border-b { border-bottom: 1px solid var(--color-border-primary); }
.border-t { border-top: 1px solid var(--color-border-primary); }
.border-red-200 { border-color: rgba(239, 68, 68, 0.3); }
.border-red-100 { border-color: rgba(239, 68, 68, 0.2); }
.bg-white { background: var(--color-surface-primary); }
.bg-red-50 { background: rgba(239, 68, 68, 0.06); }
.bg-gray-50 { background: var(--color-surface-secondary); }
.text-2xl { font-size: 1.5rem; }
.text-lg { font-size: 1.125rem; }
.text-sm { font-size: 0.875rem; }
.text-xs { font-size: 0.75rem; }
.font-bold { font-weight: var(--font-weight-bold); }
.font-semibold { font-weight: var(--font-weight-semibold); }
.font-medium { font-weight: var(--font-weight-medium); }
.text-gray-900 { color: var(--color-text-heading); }
.text-gray-600 { color: var(--color-text-secondary); }
.text-gray-500 { color: var(--color-text-muted); }
.text-red-800 { color: var(--color-status-error); }
.text-red-600 { color: var(--color-status-error); }
.text-red-700 { color: var(--color-status-error); }
.text-green-600 { color: var(--color-status-success); }
.text-yellow-600 { color: var(--color-status-warning); }
.text-blue-600 { color: var(--color-status-info); }
.w-3 { width: 0.75rem; }
.h-3 { height: 0.75rem; }
.w-4 { width: 1rem; }
.h-4 { height: 1rem; }
.w-2 { width: 0.5rem; }
.h-2 { height: 0.5rem; }
.w-16 { width: 4rem; }
header { margin-bottom: 0; }
.space-y-2 > * + * { margin-top: 0.5rem; }
.space-y-3 > * + * { margin-top: 0.75rem; }
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
.py-0\\.5 { padding-top: 0.125rem; padding-bottom: 0.125rem; }
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
.ml-1 { margin-left: 0.25rem; }
.mt-1\\.5 { margin-top: 0.375rem; }
.hover\\:bg-gray-50:hover { background: var(--color-surface-secondary); }
.hover\\:shadow-md:hover { box-shadow: 0 4px 6px -1px rgba(0,0,0,0.08), 0 2px 4px -2px rgba(0,0,0,0.04); }
.hover\\:underline:hover { text-decoration: underline; }
.transition-shadow { transition: box-shadow 0.15s; }
/* State dots */
.state-dot--healthy { background: var(--color-status-success); }
.state-dot--degraded { background: var(--color-status-warning); }
.state-dot--unhealthy { background: var(--color-status-error); }
.state-dot--unknown { background: var(--color-text-muted); }
/* State text */
.state-text--healthy { color: var(--color-status-success); }
.state-text--degraded { color: var(--color-status-warning); }
.state-text--unhealthy { color: var(--color-status-error); }
.state-text--unknown { color: var(--color-text-muted); }
/* State backgrounds */
.state-bg--healthy { background: rgba(34, 197, 94, 0.06); border-color: rgba(34, 197, 94, 0.2); }
.state-bg--degraded { background: rgba(234, 179, 8, 0.06); border-color: rgba(234, 179, 8, 0.2); }
.state-bg--unhealthy { background: rgba(239, 68, 68, 0.06); border-color: rgba(239, 68, 68, 0.2); }
.state-bg--unknown { background: var(--color-surface-secondary); border-color: var(--color-border-primary); }
/* Severity badges */
.severity--info { background: rgba(59, 130, 246, 0.1); color: var(--color-status-info); }
.severity--warning { background: rgba(234, 179, 8, 0.1); color: var(--color-status-warning); }
.severity--critical { background: rgba(239, 68, 68, 0.1); color: var(--color-status-error); }
button {
cursor: pointer;
font-family: inherit;
}
select {
font-family: inherit;
background: var(--color-surface-primary);
color: var(--color-text-primary);
border: 1px solid var(--color-border-primary);
}
a {
color: var(--color-status-info);
text-decoration: none;
}
.animate-spin {
animation: spin 1s linear infinite;
display: inline-block;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 1024px) {
.grid-cols-5 { grid-template-columns: repeat(3, 1fr); }
.grid-cols-3 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 640px) {
.grid-cols-5 { grid-template-columns: repeat(2, 1fr); }
.grid-cols-3 { grid-template-columns: 1fr; }
.col-span-2 { grid-column: span 1; }
}
`]
})
export class PlatformHealthDashboardComponent implements OnInit, OnDestroy {

View File

@@ -208,8 +208,8 @@ import { ScoreComparisonViewComponent } from './score-comparison-view.component'
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: var(--color-info);
color: white;
background: var(--color-brand-primary);
color: var(--color-text-heading);
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
@@ -217,7 +217,7 @@ import { ScoreComparisonViewComponent } from './score-comparison-view.component'
transition: background 0.15s;
}
.trigger-btn:hover:not(:disabled) { background: var(--color-status-info-text); }
.trigger-btn:hover:not(:disabled) { background: var(--color-brand-secondary); }
.trigger-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.spinner {

View File

@@ -2,16 +2,20 @@
import {
ChangeDetectionStrategy,
Component,
OnInit,
computed,
inject,
signal,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { catchError, of } from 'rxjs';
import {
Schedule,
ScheduleTaskType,
ScheduleImpactPreview,
} from './scheduler-ops.models';
import { SCHEDULER_API, type CreateScheduleDto } from '../../core/api/scheduler.client';
/**
* Schedule Management Component (Sprint: SPRINT_20251229_017)
@@ -702,72 +706,37 @@ import {
`],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ScheduleManagementComponent {
export class ScheduleManagementComponent implements OnInit {
private readonly schedulerApi = inject(SCHEDULER_API);
readonly showModal = signal(false);
readonly editingSchedule = signal<Schedule | null>(null);
readonly activeMenu = signal<string | null>(null);
readonly impactPreview = signal<ScheduleImpactPreview | null>(null);
readonly formError = signal<string | null>(null);
readonly actionNotice = signal<string | null>(null);
readonly loading = signal(true);
scheduleForm = this.getEmptyForm();
readonly schedules = signal<Schedule[]>([
{
id: 'sch-001',
name: 'Daily Vulnerability Sync',
description: 'Synchronize vulnerability data from all configured sources.',
cronExpression: '0 6 * * *',
timezone: 'UTC',
enabled: true,
taskType: 'vulnerability-sync',
taskConfig: {},
lastRunAt: new Date(Date.now() - 86400000).toISOString(),
nextRunAt: new Date(Date.now() + 43200000).toISOString(),
createdAt: new Date(Date.now() - 2592000000).toISOString(),
updatedAt: new Date(Date.now() - 86400000).toISOString(),
createdBy: 'admin@example.com',
tags: ['production', 'critical'],
retryPolicy: { maxRetries: 3, backoffMultiplier: 2, initialDelayMs: 1000, maxDelayMs: 60000 },
concurrencyLimit: 1,
},
{
id: 'sch-002',
name: 'Hourly SBOM Refresh',
description: 'Refresh SBOMs for newly pushed container images.',
cronExpression: '0 * * * *',
timezone: 'UTC',
enabled: true,
taskType: 'sbom-refresh',
taskConfig: {},
lastRunAt: new Date(Date.now() - 3600000).toISOString(),
nextRunAt: new Date(Date.now() + 3600000).toISOString(),
createdAt: new Date(Date.now() - 604800000).toISOString(),
updatedAt: new Date(Date.now() - 604800000).toISOString(),
createdBy: 'admin@example.com',
tags: ['production'],
retryPolicy: { maxRetries: 2, backoffMultiplier: 2, initialDelayMs: 5000, maxDelayMs: 30000 },
concurrencyLimit: 5,
},
{
id: 'sch-003',
name: 'Weekly Export',
description: 'Export compliance data to S3 for archival.',
cronExpression: '0 0 * * 0',
timezone: 'America/New_York',
enabled: false,
taskType: 'export',
taskConfig: {},
lastRunAt: new Date(Date.now() - 604800000).toISOString(),
nextRunAt: undefined,
createdAt: new Date(Date.now() - 2592000000).toISOString(),
updatedAt: new Date(Date.now() - 86400000).toISOString(),
createdBy: 'ops@example.com',
tags: ['compliance'],
retryPolicy: { maxRetries: 3, backoffMultiplier: 2, initialDelayMs: 10000, maxDelayMs: 120000 },
concurrencyLimit: 1,
},
]);
readonly schedules = signal<Schedule[]>([]);
ngOnInit(): void {
this.loadSchedules();
}
private loadSchedules(): void {
this.loading.set(true);
this.schedulerApi.listSchedules().pipe(
catchError(() => {
this.actionNotice.set('Failed to load schedules. The backend may be unavailable.');
return of([]);
})
).subscribe(data => {
this.schedules.set(data);
this.loading.set(false);
});
}
readonly sortedSchedules = computed(() =>
[...this.schedules()].sort((left, right) => {
@@ -818,15 +787,21 @@ export class ScheduleManagementComponent {
}
toggleEnabled(schedule: Schedule): void {
this.schedules.update(schedules =>
schedules.map(s =>
s.id === schedule.id ? { ...s, enabled: !s.enabled } : s
)
);
this.activeMenu.set(null);
this.actionNotice.set(
`Schedule "${schedule.name}" ${schedule.enabled ? 'disabled' : 'enabled'}.`
);
const obs$ = schedule.enabled
? this.schedulerApi.pauseSchedule(schedule.id)
: this.schedulerApi.resumeSchedule(schedule.id);
obs$.pipe(
catchError(() => {
this.actionNotice.set(`Failed to ${schedule.enabled ? 'pause' : 'resume'} schedule.`);
return of(undefined);
})
).subscribe(() => {
this.actionNotice.set(
`Schedule "${schedule.name}" ${schedule.enabled ? 'disabled' : 'enabled'}.`
);
this.activeMenu.set(null);
this.loadSchedules();
});
}
duplicateSchedule(schedule: Schedule): void {
@@ -846,37 +821,52 @@ export class ScheduleManagementComponent {
}
runNow(schedule: Schedule): void {
this.actionNotice.set(`Manual run queued for "${schedule.name}".`);
this.activeMenu.set(null);
this.schedulerApi.triggerSchedule(schedule.id).pipe(
catchError(() => {
this.actionNotice.set(`Failed to trigger schedule "${schedule.name}".`);
return of(undefined);
})
).subscribe(() => {
this.actionNotice.set(`Manual run queued for "${schedule.name}".`);
this.activeMenu.set(null);
});
}
deleteSchedule(schedule: Schedule): void {
if (confirm(`Delete schedule "${schedule.name}"?`)) {
this.schedules.update(schedules => schedules.filter(s => s.id !== schedule.id));
this.actionNotice.set(`Schedule "${schedule.name}" deleted.`);
this.schedulerApi.deleteSchedule(schedule.id).pipe(
catchError(() => {
this.actionNotice.set(`Failed to delete schedule "${schedule.name}".`);
return of(undefined);
})
).subscribe(() => {
this.actionNotice.set(`Schedule "${schedule.name}" deleted.`);
this.loadSchedules();
});
}
this.activeMenu.set(null);
}
previewImpact(): void {
// Simulate impact preview
this.impactPreview.set({
scheduleId: this.editingSchedule()?.id || 'new',
proposedChange: this.editingSchedule() ? 'update' : 'enable',
affectedRuns: 0,
nextRunTime: this.calculateNextRun(this.scheduleForm.cronExpression),
estimatedLoad: 15,
conflicts: this.scheduleForm.cronExpression === '0 6 * * *' ? [
{
scheduleId: 'sch-001',
scheduleName: 'Daily Vulnerability Sync',
overlapTime: '06:00 UTC',
severity: 'medium',
},
] : [],
warnings: this.scheduleForm.concurrencyLimit > 10
? ['High concurrency limit may impact system performance.']
: [],
const dto: CreateScheduleDto = {
name: this.scheduleForm.name,
description: this.scheduleForm.description,
cronExpression: this.scheduleForm.cronExpression,
timezone: this.scheduleForm.timezone,
enabled: this.scheduleForm.enabled,
taskType: this.scheduleForm.taskType as ScheduleTaskType,
retryPolicy: { ...this.scheduleForm.retryPolicy },
concurrencyLimit: this.scheduleForm.concurrencyLimit,
};
this.schedulerApi.previewImpact(dto).pipe(
catchError(() => {
this.formError.set('Failed to preview impact.');
return of(null);
})
).subscribe(preview => {
if (preview) {
this.impactPreview.set(preview);
}
});
}
@@ -894,48 +884,34 @@ export class ScheduleManagementComponent {
.filter((tag, index, all) => all.indexOf(tag) === index)
.sort((left, right) => left.localeCompare(right));
if (editing) {
this.schedules.update(schedules =>
schedules.map(s =>
s.id === editing.id
? {
...s,
name: this.scheduleForm.name,
description: this.scheduleForm.description,
cronExpression: this.scheduleForm.cronExpression,
timezone: this.scheduleForm.timezone,
taskType: this.scheduleForm.taskType as ScheduleTaskType,
retryPolicy: { ...this.scheduleForm.retryPolicy },
concurrencyLimit: this.scheduleForm.concurrencyLimit,
tags,
enabled: this.scheduleForm.enabled,
updatedAt: new Date().toISOString(),
}
: s
)
);
this.actionNotice.set(`Schedule "${this.scheduleForm.name}" updated.`);
} else {
const newSchedule: Schedule = {
id: `sch-${Date.now()}`,
name: this.scheduleForm.name,
description: this.scheduleForm.description,
cronExpression: this.scheduleForm.cronExpression,
timezone: this.scheduleForm.timezone,
enabled: this.scheduleForm.enabled,
taskType: this.scheduleForm.taskType as ScheduleTaskType,
taskConfig: {},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdBy: 'current-user@example.com',
tags,
retryPolicy: { ...this.scheduleForm.retryPolicy },
concurrencyLimit: this.scheduleForm.concurrencyLimit,
};
this.schedules.update(schedules => [...schedules, newSchedule]);
this.actionNotice.set(`Schedule "${newSchedule.name}" created.`);
}
this.closeModal();
const dto: CreateScheduleDto = {
name: this.scheduleForm.name,
description: this.scheduleForm.description,
cronExpression: this.scheduleForm.cronExpression,
timezone: this.scheduleForm.timezone,
enabled: this.scheduleForm.enabled,
taskType: this.scheduleForm.taskType as ScheduleTaskType,
tags,
retryPolicy: { ...this.scheduleForm.retryPolicy },
concurrencyLimit: this.scheduleForm.concurrencyLimit,
};
const save$ = editing
? this.schedulerApi.updateSchedule(editing.id, dto)
: this.schedulerApi.createSchedule(dto);
save$.pipe(
catchError(() => {
this.formError.set(`Failed to ${editing ? 'update' : 'create'} schedule.`);
return of(null);
})
).subscribe(result => {
if (result) {
this.actionNotice.set(`Schedule "${this.scheduleForm.name}" ${editing ? 'updated' : 'created'}.`);
this.closeModal();
this.loadSchedules();
}
});
}
isFormValid(): boolean {

View File

@@ -5,10 +5,12 @@
* Full findings table with filters and reachability.
*/
import { Component, ChangeDetectionStrategy, signal, computed } from '@angular/core';
import { Component, ChangeDetectionStrategy, OnInit, inject, signal, computed } from '@angular/core';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { catchError, of } from 'rxjs';
import { SECURITY_FINDINGS_API, type FindingDto } from '../../core/api/security-findings.client';
interface Finding {
id: string;
@@ -351,19 +353,44 @@ interface Finding {
.empty-state { text-align: center; padding: 2rem; color: var(--color-text-secondary); }
`]
})
export class SecurityFindingsPageComponent {
export class SecurityFindingsPageComponent implements OnInit {
private readonly findingsApi = inject(SECURITY_FINDINGS_API);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
searchQuery = signal('');
severityFilter = signal('');
reachabilityFilter = signal('');
envFilter = signal('');
findings = signal<Finding[]>([
{ id: 'CVE-2026-1234', package: 'log4j-core', version: '2.14.1', severity: 'CRITICAL', cvss: 10.0, reachable: true, reachabilityConfidence: 82, vexStatus: 'affected', releaseId: 'rel-prod-231', releaseVersion: 'prod@2.3.1', delta: 'new', environments: ['Dev', 'QA'], firstSeen: '2h ago' },
{ id: 'CVE-2026-5678', package: 'spring-boot', version: '2.7.5', severity: 'HIGH', cvss: 8.1, reachable: false, reachabilityConfidence: 94, vexStatus: 'not_affected', releaseId: 'rel-stg-230', releaseVersion: 'staging@2.3.0', delta: 'resolved', environments: ['QA', 'Staging'], firstSeen: '4h ago' },
{ id: 'CVE-2026-9012', package: 'express', version: '4.18.2', severity: 'MEDIUM', cvss: 5.3, reachable: true, reachabilityConfidence: 67, vexStatus: 'under_investigation', releaseId: 'rel-dev-232', releaseVersion: 'dev@2.3.2', delta: 'regressed', environments: ['Dev'], firstSeen: '1d ago' },
{ id: 'CVE-2026-3456', package: 'jackson-databind', version: '2.13.0', severity: 'HIGH', cvss: 7.5, reachable: null, vexStatus: 'none', releaseId: 'rel-prod-230', releaseVersion: 'prod@2.3.0', delta: 'carried', environments: ['Staging', 'Prod'], firstSeen: '3d ago' },
{ id: 'CVE-2026-7890', package: 'lodash', version: '4.17.20', severity: 'LOW', cvss: 3.1, reachable: false, reachabilityConfidence: 99, vexStatus: 'fixed', releaseId: 'rel-dev-229', releaseVersion: 'dev@2.2.9', delta: 'resolved', environments: ['Dev'], firstSeen: '1w ago' },
]);
findings = signal<Finding[]>([]);
ngOnInit(): void {
this.findingsApi.listFindings().pipe(
catchError(() => {
this.error.set('Failed to load findings. The backend may be unavailable.');
return of([]);
})
).subscribe(data => {
const mapped: Finding[] = (data ?? []).map((f: FindingDto) => ({
id: f.id,
package: f.package,
version: f.version,
severity: f.severity,
cvss: f.cvss,
reachable: f.reachable,
reachabilityConfidence: f.reachabilityConfidence,
vexStatus: (f.vexStatus || 'none') as Finding['vexStatus'],
releaseId: f.releaseId,
releaseVersion: f.releaseVersion,
delta: (f.delta || 'carried') as Finding['delta'],
environments: f.environments ?? [],
firstSeen: f.firstSeen,
}));
this.findings.set(mapped);
this.loading.set(false);
});
}
filteredFindings = computed(() => {
const severityRank: Record<Finding['severity'], number> = {

View File

@@ -5,9 +5,11 @@
* Dashboard-style overview of security posture.
*/
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { Component, ChangeDetectionStrategy, OnInit, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { catchError, of } from 'rxjs';
import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
@Component({
selector: 'app-security-overview-page',
@@ -291,37 +293,51 @@ import { RouterLink } from '@angular/router';
}
`]
})
export class SecurityOverviewPageComponent {
export class SecurityOverviewPageComponent implements OnInit {
private readonly overviewApi = inject(SECURITY_OVERVIEW_API);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
stats = signal({
critical: 2,
high: 5,
medium: 12,
low: 8,
reachable: 3,
critical: 0,
high: 0,
medium: 0,
low: 0,
reachable: 0,
});
vexStats = signal({
covered: 18,
pending: 9,
covered: 0,
pending: 0,
});
vexCoverage = () => Math.round((this.vexStats().covered / (this.vexStats().covered + this.vexStats().pending)) * 100);
vexCoverage = () => {
const total = this.vexStats().covered + this.vexStats().pending;
return total > 0 ? Math.round((this.vexStats().covered / total) * 100) : 0;
};
recentFindings = [
{ id: 'CVE-2026-1234', package: 'log4j-core:2.14.1', severity: 'CRITICAL', reachable: true, time: '2h ago' },
{ id: 'CVE-2026-5678', package: 'spring-boot:2.7.5', severity: 'HIGH', reachable: false, time: '4h ago' },
{ id: 'CVE-2026-9012', package: 'express:4.18.2', severity: 'MEDIUM', reachable: true, time: '1d ago' },
];
recentFindings: { id: string; package: string; severity: string; reachable: boolean; time: string }[] = [];
topPackages: { name: string; version: string; critical: number; high: number; medium: number }[] = [];
activeExceptions: { id: string; finding: string; reason: string; expiresIn: string }[] = [];
topPackages = [
{ name: 'log4j-core', version: '2.14.1', critical: 2, high: 1, medium: 0 },
{ name: 'spring-boot', version: '2.7.5', critical: 0, high: 2, medium: 3 },
{ name: 'jackson-databind', version: '2.13.0', critical: 0, high: 1, medium: 2 },
];
activeExceptions = [
{ id: 'EXC-001', finding: 'CVE-2025-1111', reason: 'Not in execution path', expiresIn: '3 days' },
];
ngOnInit(): void {
this.overviewApi.getOverviewStats().pipe(
catchError(() => {
this.error.set('Failed to load security overview. The backend may be unavailable.');
return of(null);
})
).subscribe(data => {
if (data) {
this.stats.set(data.stats);
this.vexStats.set(data.vexStats);
this.recentFindings = data.recentFindings;
this.topPackages = data.topPackages;
this.activeExceptions = data.activeExceptions;
}
this.loading.set(false);
});
}
runScan(): void {
console.log('Run security scan');

View File

@@ -66,9 +66,16 @@ export const SECURITY_ROUTES: Routes = [
import('./vulnerability-detail-page.component').then(m => m.VulnerabilityDetailPageComponent),
data: { breadcrumb: 'Vulnerability Detail' },
},
// Exceptions (sidebar links here; also available at /policy/exceptions per SEC-007)
{
path: 'exceptions',
loadComponent: () =>
import('./exceptions-page.component').then(m => m.ExceptionsPageComponent),
data: { breadcrumb: 'Exceptions' },
},
// SBOM Graph
{
path: 'sbom/graph',
path: 'sbom',
loadComponent: () =>
import('./sbom-graph-page.component').then(m => m.SbomGraphPageComponent),
data: { breadcrumb: 'SBOM Graph' },

View File

@@ -1,10 +1,20 @@
/**
* Admin Settings Page (Identity & Access)
* Sprint: SPRINT_20260118_002_FE_settings_consolidation (SETTINGS-004)
* Wired to real AUTHORITY_ADMIN_API for live data.
*/
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { Component, ChangeDetectionStrategy, OnInit, inject, signal } from '@angular/core';
import { catchError, of, tap } from 'rxjs';
import type { Observable } from 'rxjs';
import {
AUTHORITY_ADMIN_API,
type AdminUser,
type AdminRole,
type AdminClient,
type AdminToken,
type AdminTenant,
} from '../../../core/api/authority-admin.client';
@Component({
selector: 'app-admin-settings-page',
@@ -28,6 +38,10 @@ import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
}
</div>
@if (error()) {
<div class="error-banner">{{ error() }}</div>
}
<div class="admin-content">
@switch (activeTab()) {
@case ('users') {
@@ -36,33 +50,34 @@ import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
<h2>Users</h2>
<button type="button" class="btn btn--primary">+ Add User</button>
</div>
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td>Admin User</td>
<td>admin&#64;example.com</td>
<td>Administrator</td>
<td><span class="badge badge--success">Active</span></td>
<td><button class="btn btn--sm">Edit</button></td>
</tr>
<tr>
<td>Developer User</td>
<td>dev&#64;example.com</td>
<td>Developer</td>
<td><span class="badge badge--success">Active</span></td>
<td><button class="btn btn--sm">Edit</button></td>
</tr>
</tbody>
</table>
@if (loading()) {
<p class="loading-text">Loading users...</p>
} @else {
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (user of users(); track user.id) {
<tr>
<td>{{ user.displayName || user.username }}</td>
<td>{{ user.email }}</td>
<td>{{ user.roles.join(', ') }}</td>
<td><span class="badge" [class]="'badge--' + user.status">{{ user.status }}</span></td>
<td><button class="btn btn--sm">Edit</button></td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-cell">No users found</td></tr>
}
</tbody>
</table>
}
</div>
}
@case ('roles') {
@@ -71,7 +86,34 @@ import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
<h2>Roles</h2>
<button type="button" class="btn btn--primary">+ Create Role</button>
</div>
<p>Configure roles and permissions for your organization.</p>
@if (loading()) {
<p class="loading-text">Loading roles...</p>
} @else {
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Users</th>
<th>Built-in</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (role of roles(); track role.id) {
<tr>
<td>{{ role.name }}</td>
<td>{{ role.description }}</td>
<td>{{ role.userCount }}</td>
<td>{{ role.isBuiltIn ? 'Yes' : 'No' }}</td>
<td><button class="btn btn--sm" [disabled]="role.isBuiltIn">Edit</button></td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-cell">No roles found</td></tr>
}
</tbody>
</table>
}
</div>
}
@case ('clients') {
@@ -80,7 +122,34 @@ import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
<h2>OAuth Clients</h2>
<button type="button" class="btn btn--primary">+ Register Client</button>
</div>
<p>Manage OAuth 2.0 clients for API access.</p>
@if (loading()) {
<p class="loading-text">Loading clients...</p>
} @else {
<table class="data-table">
<thead>
<tr>
<th>Client ID</th>
<th>Display Name</th>
<th>Grant Types</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (client of clients(); track client.id) {
<tr>
<td><code>{{ client.clientId }}</code></td>
<td>{{ client.displayName }}</td>
<td>{{ client.grantTypes.join(', ') }}</td>
<td><span class="badge" [class]="'badge--' + client.status">{{ client.status }}</span></td>
<td><button class="btn btn--sm">Edit</button></td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-cell">No OAuth clients found</td></tr>
}
</tbody>
</table>
}
</div>
}
@case ('tokens') {
@@ -89,7 +158,36 @@ import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
<h2>API Tokens</h2>
<button type="button" class="btn btn--primary">+ Generate Token</button>
</div>
<p>Create and manage API access tokens.</p>
@if (loading()) {
<p class="loading-text">Loading tokens...</p>
} @else {
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Client</th>
<th>Scopes</th>
<th>Expires</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (token of tokens(); track token.id) {
<tr>
<td>{{ token.name }}</td>
<td>{{ token.clientId }}</td>
<td>{{ token.scopes.join(', ') }}</td>
<td>{{ token.expiresAt }}</td>
<td><span class="badge" [class]="'badge--' + token.status">{{ token.status }}</span></td>
<td><button class="btn btn--sm">Revoke</button></td>
</tr>
} @empty {
<tr><td colspan="6" class="empty-cell">No API tokens found</td></tr>
}
</tbody>
</table>
}
</div>
}
@case ('tenants') {
@@ -98,7 +196,34 @@ import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
<h2>Tenants</h2>
<button type="button" class="btn btn--primary">+ Add Tenant</button>
</div>
<p>Manage multi-tenant configuration.</p>
@if (loading()) {
<p class="loading-text">Loading tenants...</p>
} @else {
<table class="data-table">
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Isolation</th>
<th>Users</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (tenant of tenants(); track tenant.id) {
<tr>
<td>{{ tenant.displayName }}</td>
<td><span class="badge" [class]="'badge--' + tenant.status">{{ tenant.status }}</span></td>
<td>{{ tenant.isolationMode }}</td>
<td>{{ tenant.userCount }}</td>
<td><button class="btn btn--sm">Edit</button></td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-cell">No tenants found</td></tr>
}
</tbody>
</table>
}
</div>
}
}
@@ -157,7 +282,10 @@ import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
}
.badge--success { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
.badge--active, .badge--success { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
.badge--disabled, .badge--locked { background: var(--color-severity-none-bg); color: var(--color-text-secondary); }
.badge--expired { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); }
.badge--revoked { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); }
.btn {
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
@@ -170,9 +298,29 @@ import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
color: var(--color-text-heading);
}
.btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); }
.btn--sm:disabled { opacity: 0.5; cursor: not-allowed; }
.loading-text { color: var(--color-text-secondary); font-size: 0.875rem; }
.empty-cell { text-align: center; color: var(--color-text-secondary); padding: 2rem !important; }
.error-banner {
padding: 1rem;
margin-bottom: 1rem;
background: var(--color-status-error-bg);
border: 1px solid rgba(248, 113, 113, 0.5);
color: var(--color-status-error);
border-radius: var(--radius-lg);
font-size: 0.875rem;
}
code {
padding: 0.125rem 0.25rem;
background: var(--color-surface-secondary);
border-radius: var(--radius-sm);
font-size: 0.8125rem;
}
`]
})
export class AdminSettingsPageComponent {
export class AdminSettingsPageComponent implements OnInit {
private readonly api = inject(AUTHORITY_ADMIN_API);
tabs = [
{ id: 'users', label: 'Users' },
{ id: 'roles', label: 'Roles' },
@@ -182,8 +330,54 @@ export class AdminSettingsPageComponent {
];
activeTab = signal('users');
loading = signal(true);
error = signal<string | null>(null);
users = signal<AdminUser[]>([]);
roles = signal<AdminRole[]>([]);
clients = signal<AdminClient[]>([]);
tokens = signal<AdminToken[]>([]);
tenants = signal<AdminTenant[]>([]);
ngOnInit(): void {
this.loadTab('users');
}
setTab(tabId: string): void {
this.activeTab.set(tabId);
this.loadTab(tabId);
}
private loadTab(tabId: string): void {
this.loading.set(true);
this.error.set(null);
let obs$: Observable<any>;
switch (tabId) {
case 'users':
obs$ = this.api.listUsers().pipe(tap(d => this.users.set(d)));
break;
case 'roles':
obs$ = this.api.listRoles().pipe(tap(d => this.roles.set(d)));
break;
case 'clients':
obs$ = this.api.listClients().pipe(tap(d => this.clients.set(d)));
break;
case 'tokens':
obs$ = this.api.listTokens().pipe(tap(d => this.tokens.set(d)));
break;
case 'tenants':
obs$ = this.api.listTenants().pipe(tap(d => this.tenants.set(d)));
break;
default:
return;
}
obs$.pipe(
catchError(() => {
this.error.set(`Failed to load ${tabId}. The backend may be unavailable.`);
return of([]);
})
).subscribe(() => this.loading.set(false));
}
}

View File

@@ -101,7 +101,34 @@ interface Integration {
@for (integration of filteredIntegrations(); track integration.id) {
<a class="integration-card" [routerLink]="['./', integration.id]">
<div class="integration-card__header">
<span class="integration-card__icon">{{ integration.icon }}</span>
<span class="integration-card__icon" aria-hidden="true">
@switch (integration.icon) {
@case ('github') {
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.482 0-.237-.008-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836c.85.004 1.705.114 2.504.336 1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.167 22 16.418 22 12c0-5.523-4.477-10-10-10z"/></svg>
}
@case ('gitlab') {
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M22.65 14.39L12 22.13 1.35 14.39a.84.84 0 01-.3-.94l1.22-3.78 2.44-7.51A.42.42 0 014.82 2a.43.43 0 01.58 0c.05.05.1.11.11.18l2.44 7.49h8.1l2.44-7.51A.42.42 0 0118.6 2a.43.43 0 01.58 0c.05.05.1.11.11.18l2.44 7.51L23 13.45a.84.84 0 01-.35.94z"/></svg>
}
@case ('jenkins') {
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M14 7v7c0 1.5-.8 3-3 3s-3-1.5-3-3"/></svg>
}
@case ('harbor') {
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="2"/><line x1="12" y1="7" x2="12" y2="17"/><path d="M6 12c0 3 2 5 6 5s6-2 6-5"/><line x1="4" y1="20" x2="20" y2="20"/></svg>
}
@case ('vault') {
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/><circle cx="12" cy="16" r="1.5" fill="currentColor" stroke="none"/></svg>
}
@case ('slack') {
<svg viewBox="0 0 24 24" width="24" height="24" fill="currentColor"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>
}
@case ('feed') {
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a9 9 0 019 9"/><path d="M4 4a16 16 0 0116 16"/><circle cx="5" cy="19" r="1" fill="currentColor"/></svg>
}
@default {
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
}
}
</span>
<span class="integration-card__status" [class]="'integration-card__status--' + integration.status">
{{ integration.status }}
</span>
@@ -257,7 +284,14 @@ interface Integration {
}
.integration-card__icon {
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
}
.integration-card__status {
@@ -314,14 +348,14 @@ export class IntegrationsSettingsPageComponent {
filterType = signal<string | null>(null);
integrations = signal<Integration[]>([
{ id: 'github-1', name: 'GitHub Enterprise', type: 'scm', status: 'connected', lastSync: '5m ago', icon: '🐙' },
{ id: 'gitlab-1', name: 'GitLab SaaS', type: 'scm', status: 'connected', lastSync: '2m ago', icon: '🦊' },
{ id: 'jenkins-1', name: 'Jenkins', type: 'ci', status: 'degraded', lastSync: '1h ago', icon: '🔧' },
{ id: 'harbor-1', name: 'Harbor Registry', type: 'registry', status: 'connected', lastSync: '30m ago', icon: '📦' },
{ id: 'vault-1', name: 'HashiCorp Vault', type: 'secrets', status: 'connected', lastSync: '10m ago', icon: '🔒' },
{ id: 'slack-1', name: 'Slack', type: 'notifications', status: 'connected', icon: '💬' },
{ id: 'osv-1', name: 'OSV Feed', type: 'feeds', status: 'connected', lastSync: '1h ago', icon: '📡' },
{ id: 'nvd-1', name: 'NVD Feed', type: 'feeds', status: 'disconnected', icon: '📡' },
{ id: 'github-1', name: 'GitHub Enterprise', type: 'scm', status: 'connected', lastSync: '5m ago', icon: 'github' },
{ id: 'gitlab-1', name: 'GitLab SaaS', type: 'scm', status: 'connected', lastSync: '2m ago', icon: 'gitlab' },
{ id: 'jenkins-1', name: 'Jenkins', type: 'ci', status: 'degraded', lastSync: '1h ago', icon: 'jenkins' },
{ id: 'harbor-1', name: 'Harbor Registry', type: 'registry', status: 'connected', lastSync: '30m ago', icon: 'harbor' },
{ id: 'vault-1', name: 'HashiCorp Vault', type: 'secrets', status: 'connected', lastSync: '10m ago', icon: 'vault' },
{ id: 'slack-1', name: 'Slack', type: 'notifications', status: 'connected', icon: 'slack' },
{ id: 'osv-1', name: 'OSV Feed', type: 'feeds', status: 'connected', lastSync: '1h ago', icon: 'feed' },
{ id: 'nvd-1', name: 'NVD Feed', type: 'feeds', status: 'disconnected', icon: 'feed' },
]);
filteredIntegrations = () => {

View File

@@ -5,180 +5,32 @@
* Shell page with sidebar navigation for all settings sections.
*/
import { Component, ChangeDetectionStrategy, computed, inject } from '@angular/core';
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
import { AUTH_SERVICE, AuthService, StellaOpsScopes } from '../../core/auth';
interface SettingsCategory {
id: string;
label: string;
icon: string;
route: string;
adminOnly?: boolean;
}
import { RouterOutlet } from '@angular/router';
/**
* Settings Page Component (Shell)
*
* Navigation is handled by the global sidebar.
* This shell provides the content area for settings sub-routes.
*/
@Component({
selector: 'app-settings-page',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
imports: [RouterOutlet],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="settings-layout">
<!-- Settings Sidebar -->
<aside class="settings-sidebar">
<h2 class="settings-sidebar__title">Settings</h2>
<nav class="settings-sidebar__nav">
@for (category of categories(); track category.id) {
<a
class="settings-sidebar__link"
[routerLink]="category.route"
routerLinkActive="settings-sidebar__link--active"
>
<span class="settings-sidebar__icon">{{ category.icon }}</span>
<span class="settings-sidebar__label">{{ category.label }}</span>
@if (category.adminOnly) {
<span class="settings-sidebar__badge">Admin</span>
}
</a>
}
</nav>
</aside>
<!-- Settings Content -->
<main class="settings-content">
<router-outlet></router-outlet>
</main>
<div class="settings-content">
<router-outlet></router-outlet>
</div>
`,
styles: [`
.settings-layout {
display: grid;
grid-template-columns: 240px 1fr;
min-height: calc(100vh - 120px);
}
.settings-sidebar {
background: var(--color-surface-primary);
border-right: 1px solid var(--color-border-primary);
padding: 1.5rem 0;
}
.settings-sidebar__title {
margin: 0 1.5rem 1.5rem;
font-size: 1.125rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.settings-sidebar__nav {
display: flex;
flex-direction: column;
}
.settings-sidebar__link {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.5rem;
color: var(--color-text-secondary);
text-decoration: none;
transition: all 0.15s;
border-left: 3px solid transparent;
}
.settings-sidebar__link:hover {
background: var(--color-nav-hover);
color: var(--color-text-primary);
}
.settings-sidebar__link--active {
background: var(--color-brand-soft);
color: var(--color-brand-primary);
border-left-color: var(--color-brand-primary);
}
.settings-sidebar__icon {
font-size: 1.125rem;
width: 1.5rem;
text-align: center;
}
.settings-sidebar__label {
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
}
.settings-sidebar__badge {
margin-left: auto;
padding: 0.125rem 0.375rem;
background: var(--color-status-excepted-bg);
color: var(--color-status-excepted);
font-size: 0.625rem;
font-weight: var(--font-weight-semibold);
border-radius: var(--radius-sm);
text-transform: uppercase;
}
.settings-content {
padding: 2rem;
background: var(--color-surface-secondary);
min-height: calc(100vh - 120px);
overflow: auto;
}
@media (max-width: 768px) {
.settings-layout {
grid-template-columns: 1fr;
}
.settings-sidebar {
border-right: none;
border-bottom: 1px solid var(--color-border-primary);
}
.settings-sidebar__nav {
flex-direction: row;
overflow-x: auto;
padding: 0 1rem;
}
.settings-sidebar__link {
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem 1rem;
border-left: none;
border-bottom: 2px solid transparent;
white-space: nowrap;
}
.settings-sidebar__link--active {
border-left-color: transparent;
border-bottom-color: var(--color-brand-primary);
}
}
`]
})
export class SettingsPageComponent {
private readonly authService = inject(AUTH_SERVICE) as AuthService;
private readonly allCategories: readonly SettingsCategory[] = [
{ id: 'integrations', label: 'Integrations', icon: 'plug', route: './integrations' },
{ id: 'release-control', label: 'Release Control', icon: 'rocket', route: './release-control' },
{ id: 'trust', label: 'Trust & Signing', icon: 'key', route: './trust' },
{ id: 'security-data', label: 'Security Data', icon: 'shield', route: './security-data' },
{ id: 'admin', label: 'Identity & Access', icon: 'users', route: './admin', adminOnly: true },
{ id: 'branding', label: 'Tenant / Branding', icon: 'palette', route: './branding' },
{ id: 'usage', label: 'Usage & Limits', icon: 'chart', route: './usage' },
{ id: 'notifications', label: 'Notifications', icon: 'bell', route: './notifications' },
{ id: 'policy', label: 'Policy Governance', icon: 'book', route: './policy' },
{ id: 'system', label: 'System', icon: 'settings', route: './system', adminOnly: true },
];
readonly categories = computed((): SettingsCategory[] => {
const hasAdminAccess = this.authService.hasScope(StellaOpsScopes.ADMIN);
if (hasAdminAccess) {
return [...this.allCategories];
}
return this.allCategories.filter((category) => !category.adminOnly);
});
}
export class SettingsPageComponent {}

View File

@@ -101,6 +101,12 @@ export const SETTINGS_ROUTES: Routes = [
import('./policy/policy-governance-settings-page.component').then(m => m.PolicyGovernanceSettingsPageComponent),
data: { breadcrumb: 'Policy Governance' },
},
{
path: 'offline',
loadComponent: () =>
import('../offline-kit/components/offline-dashboard.component').then(m => m.OfflineDashboardComponent),
data: { breadcrumb: 'Offline Settings' },
},
{
path: 'system',
loadComponent: () =>

View File

@@ -1,3 +1,10 @@
/**
* Welcome Page Component
* Redesigned: Warm amber light theme — consistent with app design tokens.
*
* Full-viewport cinematic splash with animated golden motes, orbital rings,
* geometric lattice, and choreographed entrance sequence.
*/
import {
ChangeDetectionStrategy,
@@ -12,201 +19,735 @@ import { AuthorityAuthService } from '../../core/auth/authority-auth.service';
@Component({
selector: 'app-welcome-page',
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="welcome-card">
<div class="welcome-card__header">
<svg class="welcome-card__logo" viewBox="0 0 24 24" width="48" height="48" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.15"/>
<path d="M12 2L15 8L22 9L17 14L18 21L12 18L6 21L7 14L2 9L9 8L12 2Z" fill="currentColor"/>
<div class="viewport">
<!-- Ambient geometric lattice -->
<div class="lattice" aria-hidden="true">
<svg class="lattice__svg" viewBox="0 0 800 600" preserveAspectRatio="xMidYMid slice">
<!-- Hex grid pattern -->
<defs>
<pattern id="hex-grid" x="0" y="0" width="60" height="52" patternUnits="userSpaceOnUse">
<path d="M30 0 L60 15 L60 37 L30 52 L0 37 L0 15 Z"
fill="none" stroke="rgba(212,168,75,0.06)" stroke-width="0.5"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#hex-grid)" />
<!-- Animated glow circle -->
<circle class="lattice__pulse" cx="400" cy="300" r="180"
fill="none" stroke="rgba(245,166,35,0.08)" stroke-width="1"/>
<circle class="lattice__pulse lattice__pulse--2" cx="400" cy="300" r="260"
fill="none" stroke="rgba(245,166,35,0.05)" stroke-width="0.7"/>
</svg>
<div>
<h1>{{ title() }}</h1>
<p class="welcome-card__tagline">Release Control Plane</p>
</div>
</div>
<p class="message">{{ message() }}</p>
<div class="welcome-card__actions">
<button
type="button"
class="welcome-card__btn welcome-card__btn--signin"
(click)="signIn()"
>
Sign In
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M5 12H19M19 12L13 6M19 12L13 18" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Floating golden motes -->
<div class="motes" aria-hidden="true">
@for (m of motes; track $index) {
<i class="mote"
[style.left.%]="m.x"
[style.width.px]="m.s"
[style.height.px]="m.s"
[style.animation-delay.ms]="m.d"
[style.animation-duration.ms]="m.dur"></i>
}
</div>
<!-- Diagonal accent lines -->
<div class="accents" aria-hidden="true">
<div class="accent accent--1"></div>
<div class="accent accent--2"></div>
<div class="accent accent--3"></div>
</div>
<!-- Scan line -->
<div class="scan" aria-hidden="true"></div>
<!-- Hero content -->
<main class="hero">
<!-- Logo with animated orbital rings -->
<div class="orb">
<svg class="orb__svg" viewBox="0 0 200 200" width="160" height="160">
<!-- Outer ring (slow CW drift) -->
<g class="rg rg--1">
<circle class="ring ring--1" cx="100" cy="100" r="90" />
</g>
<!-- Middle ring (static) -->
<g class="rg">
<circle class="ring ring--2" cx="100" cy="100" r="76" />
</g>
<!-- Inner ring (slow CCW drift) -->
<g class="rg rg--3">
<circle class="ring ring--3" cx="100" cy="100" r="62" />
</g>
</svg>
</button>
@if (docsUrl(); as docs) {
<a
class="welcome-card__btn welcome-card__btn--docs"
[href]="docs"
rel="noreferrer"
target="_blank"
>
View deployment guide
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M7 17L17 7M17 7H7M17 7V17" stroke-linecap="round" stroke-linejoin="round"/>
<div class="orb__glow"></div>
<img src="assets/img/site.png" alt="" class="orb__logo" />
</div>
<!-- Typography -->
<h1 class="title">{{ title() }}</h1>
<p class="tagline">Release Control Plane</p>
<p class="message">{{ message() }}</p>
<!-- System status indicators (decorative) -->
<div class="sys-row">
<span class="sys-tag sys-tag--1">
<i class="sys-dot"></i>
<span>Encrypted</span>
</span>
<span class="sys-tag sys-tag--2">
<i class="sys-dot"></i>
<span>Identity</span>
</span>
<span class="sys-tag sys-tag--3">
<i class="sys-dot"></i>
<span>Pipeline</span>
</span>
</div>
<!-- Actions -->
<div class="actions">
<button type="button" class="cta" (click)="signIn()">
<span class="cta__shimmer"></span>
<span class="cta__label">Sign In</span>
<svg class="cta__arrow" viewBox="0 0 24 24" width="18" height="18"
fill="none" stroke="currentColor" stroke-width="2.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12H19M19 12L13 6M19 12L13 18"/>
</svg>
</a>
}
</div>
</section>
`,
styles: [
`
:host {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
padding: var(--space-8);
}
</button>
.welcome-card {
max-width: 480px;
width: 100%;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-default, rgba(212, 201, 168, 0.3));
border-radius: var(--radius-2xl);
padding: var(--space-8);
box-shadow: var(--shadow-lg);
text-align: center;
animation: card-entrance var(--motion-duration-xl) var(--motion-ease-entrance) both;
}
.welcome-card__header {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-2);
}
.welcome-card__logo {
color: var(--color-brand-primary);
animation: logo-entrance var(--motion-duration-lg) var(--motion-ease-bounce) both;
}
h1 {
margin: 0;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-heading);
}
.welcome-card__tagline {
margin: var(--space-0-5) 0 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.message {
margin: 0 0 var(--space-6);
color: var(--color-text-secondary);
font-size: var(--font-size-base);
line-height: 1.5;
}
.welcome-card__actions {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
}
.welcome-card__btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
border: none;
border-radius: var(--radius-full);
font-weight: var(--font-weight-semibold);
text-decoration: none;
cursor: pointer;
transition: background-color var(--motion-duration-sm) var(--motion-ease-standard),
transform var(--motion-duration-sm) var(--motion-ease-standard),
box-shadow var(--motion-duration-sm) var(--motion-ease-standard);
}
.welcome-card__btn--signin {
padding: var(--space-3) var(--space-8);
font-size: var(--font-size-base);
background: var(--color-brand-primary);
color: var(--color-surface-primary);
box-shadow: var(--shadow-brand-sm);
&:hover {
background: var(--color-brand-primary-hover, var(--color-brand-secondary));
transform: translateY(-1px);
box-shadow: var(--shadow-brand-md);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
}
.welcome-card__btn--docs {
padding: var(--space-2) var(--space-4);
font-size: var(--font-size-sm);
background: transparent;
color: var(--color-text-secondary);
&:hover {
color: var(--color-text-primary);
background: var(--color-surface-tertiary);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
}
@keyframes logo-entrance {
from { opacity: 0; transform: scale(0.85); }
to { opacity: 1; transform: scale(1); }
}
@keyframes card-entrance {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.welcome-card,
.welcome-card__logo {
animation: none;
}
.welcome-card__btn {
transition: none;
&:hover {
transform: none;
@if (docsUrl(); as docs) {
<a class="docs" [href]="docs" rel="noreferrer" target="_blank">
View deployment guide
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M7 17L17 7M17 7H7M17 7V17"/>
</svg>
</a>
}
}
</div>
</main>
</div>
`,
styles: [`
/* ==================================================================
VIEWPORT — full-screen warm light backdrop
================================================================== */
:host {
display: block;
margin: 0;
padding: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.viewport {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
min-height: 100dvh;
overflow: hidden;
background:
radial-gradient(ellipse 70% 50% at 50% 0%, rgba(245, 166, 35, 0.08) 0%, transparent 60%),
radial-gradient(ellipse 60% 50% at 0% 100%, rgba(245, 166, 35, 0.04) 0%, transparent 50%),
radial-gradient(ellipse 50% 40% at 100% 80%, rgba(212, 146, 10, 0.03) 0%, transparent 50%),
linear-gradient(175deg, #FFFCF5 0%, #FFF9ED 40%, #FFFFFF 100%);
}
/* ==================================================================
GEOMETRIC LATTICE — subtle hex grid with pulsing circles
================================================================== */
.lattice {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
animation: fade-in 1.5s ease 200ms forwards;
}
.lattice__svg {
width: 100%;
height: 100%;
}
.lattice__pulse {
animation: pulse-ring 6s ease-in-out infinite;
transform-origin: center;
}
.lattice__pulse--2 {
animation-delay: 3s;
}
/* ==================================================================
FLOATING MOTES — amber particles drifting upward
================================================================== */
.motes {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.mote {
position: absolute;
bottom: -12px;
border-radius: 50%;
background: radial-gradient(circle, rgba(245, 166, 35, 0.35) 0%, rgba(245, 166, 35, 0) 70%);
animation: float-up linear infinite;
}
/* ==================================================================
DIAGONAL ACCENT LINES — sweeping decorative stripes
================================================================== */
.accents {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.accent {
position: absolute;
width: 200%;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(212, 168, 75, 0.12) 30%,
rgba(245, 166, 35, 0.18) 50%,
rgba(212, 168, 75, 0.12) 70%,
transparent 100%
);
transform-origin: center;
}
.accent--1 {
top: 25%;
left: -50%;
transform: rotate(-8deg);
animation: accent-slide 12s ease-in-out infinite;
}
.accent--2 {
top: 55%;
left: -50%;
transform: rotate(5deg);
opacity: 0.6;
animation: accent-slide 15s ease-in-out 3s infinite;
}
.accent--3 {
top: 78%;
left: -50%;
transform: rotate(-3deg);
opacity: 0.4;
animation: accent-slide 18s ease-in-out 6s infinite;
}
/* ==================================================================
SCAN LINE — horizontal sweep for tech ambiance
================================================================== */
.scan {
position: absolute;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(245, 166, 35, 0.15) 25%,
rgba(245, 166, 35, 0.3) 50%,
rgba(245, 166, 35, 0.15) 75%,
transparent 100%
);
pointer-events: none;
filter: blur(0.3px);
animation: scan-sweep 9s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* ==================================================================
HERO — centered content card
================================================================== */
.hero {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
max-width: 460px;
width: 100%;
padding: 2.75rem 2.5rem 2.25rem;
border-radius: 28px;
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
border: 1px solid rgba(212, 201, 168, 0.25);
box-shadow:
0 0 60px rgba(245, 166, 35, 0.06),
0 20px 60px rgba(28, 18, 0, 0.06),
0 8px 24px rgba(28, 18, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
animation: hero-entrance 700ms cubic-bezier(0.18, 0.89, 0.32, 1) both;
}
/* ==================================================================
ORB — logo image + concentric SVG orbital rings + glow
================================================================== */
.orb {
position: relative;
width: 160px;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.25rem;
}
.orb__svg {
position: absolute;
inset: 0;
}
.orb__svg circle {
fill: none;
stroke-linecap: round;
}
/* Ring group rotation (container for each ring) */
.rg { transform-origin: 100px 100px; }
.rg--1 { animation: ring-drift-cw 140s linear 1.5s infinite; }
.rg--3 { animation: ring-drift-ccw 100s linear 1.5s infinite; }
/* Ring stroke drawing */
.ring--1 {
stroke: rgba(212, 168, 75, 0.2);
stroke-width: 0.8;
stroke-dasharray: 566;
stroke-dashoffset: 566;
animation: draw-ring 1.4s cubic-bezier(0.4, 0, 0.2, 1) 200ms forwards;
}
.ring--2 {
stroke: rgba(245, 166, 35, 0.25);
stroke-width: 0.6;
stroke-dasharray: 478;
stroke-dashoffset: 478;
animation: draw-ring 1.2s cubic-bezier(0.4, 0, 0.2, 1) 400ms forwards;
}
.ring--3 {
stroke: rgba(245, 166, 35, 0.35);
stroke-width: 1;
stroke-dasharray: 390;
stroke-dashoffset: 390;
animation: draw-ring 1s cubic-bezier(0.4, 0, 0.2, 1) 550ms forwards;
}
/* Glow behind logo */
.orb__glow {
position: absolute;
width: 100px;
height: 100px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 50%;
background: radial-gradient(circle, rgba(245, 166, 35, 0.15) 0%, transparent 70%);
animation:
fade-in 800ms ease 300ms both,
glow-breathe 5s ease-in-out 1.2s infinite;
}
/* Logo image */
.orb__logo {
position: relative;
width: 72px;
height: 72px;
object-fit: contain;
border-radius: 18px;
z-index: 1;
filter: drop-shadow(0 4px 12px rgba(245, 166, 35, 0.2));
animation: logo-pop 650ms cubic-bezier(0.34, 1.56, 0.64, 1) 100ms both;
}
/* ==================================================================
TYPOGRAPHY
================================================================== */
.title {
margin: 0 0 0.375rem;
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.04em;
color: #1C1200;
line-height: 1.1;
animation: slide-up 600ms cubic-bezier(0.18, 0.89, 0.32, 1) 500ms both;
}
.tagline {
margin: 0 0 0.875rem;
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #D4920A;
animation: fade-in 500ms ease 680ms both;
}
.message {
margin: 0 0 1.5rem;
font-size: 0.875rem;
font-weight: 400;
line-height: 1.6;
color: #6B5A2E;
max-width: 300px;
animation: fade-in 500ms ease 800ms both;
}
/* ==================================================================
SYSTEM STATUS INDICATORS (decorative)
================================================================== */
.sys-row {
display: flex;
gap: 0.75rem;
margin-bottom: 1.75rem;
}
.sys-tag {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
border-radius: 100px;
background: rgba(245, 166, 35, 0.06);
border: 1px solid rgba(212, 201, 168, 0.3);
font-size: 0.625rem;
font-weight: 500;
letter-spacing: 0.04em;
color: #6B5A2E;
text-transform: uppercase;
}
.sys-tag--1 { animation: tag-in 400ms cubic-bezier(0.18, 0.89, 0.32, 1) 900ms both; }
.sys-tag--2 { animation: tag-in 400ms cubic-bezier(0.18, 0.89, 0.32, 1) 1000ms both; }
.sys-tag--3 { animation: tag-in 400ms cubic-bezier(0.18, 0.89, 0.32, 1) 1100ms both; }
.sys-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: rgba(34, 197, 94, 0.9);
box-shadow: 0 0 6px rgba(34, 197, 94, 0.4);
animation: dot-ping 2s ease-in-out infinite;
}
.sys-tag--1 .sys-dot { animation-delay: 1.2s; }
.sys-tag--2 .sys-dot { animation-delay: 1.5s; }
.sys-tag--3 .sys-dot { animation-delay: 1.8s; }
/* ==================================================================
CTA BUTTON + DOCS LINK
================================================================== */
.actions {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.875rem;
animation: slide-up 550ms cubic-bezier(0.18, 0.89, 0.32, 1) 1050ms both;
}
.cta {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.625rem;
width: 100%;
padding: 0.875rem 2rem;
border: none;
border-radius: 14px;
background: linear-gradient(135deg, #F5A623 0%, #D4920A 100%);
color: #FFFFFF;
font-family: inherit;
font-size: 0.9375rem;
font-weight: 600;
letter-spacing: 0.01em;
cursor: pointer;
overflow: hidden;
transition:
transform 220ms cubic-bezier(0.18, 0.89, 0.32, 1),
box-shadow 220ms cubic-bezier(0.18, 0.89, 0.32, 1);
box-shadow:
0 2px 12px rgba(245, 166, 35, 0.3),
0 1px 3px rgba(28, 18, 0, 0.08);
}
.cta:hover {
transform: translateY(-2px);
box-shadow:
0 6px 24px rgba(245, 166, 35, 0.4),
0 2px 8px rgba(28, 18, 0, 0.08);
}
.cta:active {
transform: translateY(0);
box-shadow:
0 1px 6px rgba(245, 166, 35, 0.2),
0 1px 2px rgba(28, 18, 0, 0.06);
}
.cta:focus-visible {
outline: 2px solid rgba(245, 166, 35, 0.5);
outline-offset: 3px;
}
.cta__shimmer {
position: absolute;
inset: 0;
background: linear-gradient(
105deg,
transparent 38%,
rgba(255, 255, 255, 0.3) 50%,
transparent 62%
);
background-size: 250% 100%;
pointer-events: none;
animation: shimmer 2.2s ease 1.5s;
}
.cta:hover .cta__shimmer {
animation: shimmer 0.75s ease;
}
.cta__label {
position: relative;
z-index: 1;
}
.cta__arrow {
position: relative;
z-index: 1;
transition: transform 200ms ease;
}
.cta:hover .cta__arrow {
transform: translateX(3px);
}
.docs {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
color: #D4920A;
text-decoration: none;
transition: color 200ms ease;
animation: fade-in 400ms ease 1250ms both;
}
.docs:hover {
color: #F5A623;
}
.docs:focus-visible {
outline: 2px solid rgba(245, 166, 35, 0.4);
outline-offset: 2px;
border-radius: 4px;
}
/* ==================================================================
KEYFRAMES
================================================================== */
/* Lattice pulse rings */
@keyframes pulse-ring {
0% { opacity: 0.3; transform: scale(0.95); }
50% { opacity: 0.8; transform: scale(1.05); }
100% { opacity: 0.3; transform: scale(0.95); }
}
/* Amber particle floating upward */
@keyframes float-up {
0% { transform: translateY(0); opacity: 0; }
8% { opacity: 0.45; }
88% { opacity: 0.3; }
100% { transform: translateY(calc(-100vh - 20px)); opacity: 0; }
}
/* Accent line sliding */
@keyframes accent-slide {
0% { transform: translateX(-20%) rotate(-8deg); opacity: 0; }
15% { opacity: 1; }
85% { opacity: 1; }
100% { transform: translateX(20%) rotate(-8deg); opacity: 0; }
}
/* Scan line sweeping down */
@keyframes scan-sweep {
0% { top: -2px; opacity: 0; }
3% { opacity: 0.25; }
95% { opacity: 0.08; }
100% { top: 100%; opacity: 0; }
}
/* Hero card entrance */
@keyframes hero-entrance {
from { opacity: 0; transform: translateY(28px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* Logo pop-in */
@keyframes logo-pop {
from { opacity: 0; transform: scale(0.65); }
to { opacity: 1; transform: scale(1); }
}
/* SVG ring stroke draw */
@keyframes draw-ring {
to { stroke-dashoffset: 0; }
}
/* Orbital drift (slow rotation) */
@keyframes ring-drift-cw {
to { transform: rotate(360deg); }
}
@keyframes ring-drift-ccw {
to { transform: rotate(-360deg); }
}
/* Glow breathing */
@keyframes glow-breathe {
0%, 100% { opacity: 0.5; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.18); }
}
/* Content slide up */
@keyframes slide-up {
from { opacity: 0; transform: translateY(18px); }
to { opacity: 1; transform: translateY(0); }
}
/* Content fade in */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
/* System tag entrance */
@keyframes tag-in {
from { opacity: 0; transform: scale(0.85) translateY(6px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* Status dot ping */
@keyframes dot-ping {
0%, 100% { box-shadow: 0 0 4px rgba(34, 197, 94, 0.3); }
50% { box-shadow: 0 0 10px rgba(34, 197, 94, 0.6); }
}
/* Button shimmer sweep */
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -100% 0; }
}
/* ==================================================================
REDUCED MOTION
================================================================== */
@media (prefers-reduced-motion: reduce) {
.lattice,
.lattice__pulse,
.mote,
.accent,
.scan,
.hero,
.orb__logo,
.orb__glow,
.ring--1, .ring--2, .ring--3,
.rg--1, .rg--3,
.title, .tagline, .message,
.sys-tag--1, .sys-tag--2, .sys-tag--3,
.sys-dot,
.actions,
.cta__shimmer,
.docs {
animation: none !important;
}
@media (max-width: 640px) {
:host {
padding: var(--space-4);
}
.welcome-card {
padding: var(--space-5);
}
.ring--1, .ring--2, .ring--3 {
stroke-dashoffset: 0;
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush
.lattice,
.hero, .orb__logo, .orb__glow,
.title, .tagline, .message,
.sys-tag--1, .sys-tag--2, .sys-tag--3,
.actions, .docs {
opacity: 1;
}
.cta, .cta__arrow {
transition: none;
}
}
/* ==================================================================
RESPONSIVE
================================================================== */
@media (max-width: 640px) {
.hero {
padding: 2rem 1.5rem 1.75rem;
margin: 0 1rem;
border-radius: 22px;
}
.orb {
width: 130px;
height: 130px;
}
.orb__svg {
width: 130px;
height: 130px;
}
.orb__logo {
width: 56px;
height: 56px;
border-radius: 14px;
}
.title {
font-size: 1.625rem;
}
.tagline {
font-size: 0.625rem;
letter-spacing: 0.14em;
}
.sys-row {
gap: 0.5rem;
}
.sys-tag {
font-size: 0.5625rem;
padding: 0.1875rem 0.5rem;
}
}
`]
})
export class WelcomePageComponent {
private readonly configService = inject(AppConfigService);
@@ -214,7 +755,7 @@ export class WelcomePageComponent {
readonly config = computed(() => this.configService.config);
readonly title = computed(
() => this.config().welcome?.title ?? 'Welcome to StellaOps'
() => this.config().welcome?.title ?? 'StellaOps'
);
readonly message = computed(
() =>
@@ -223,6 +764,14 @@ export class WelcomePageComponent {
);
readonly docsUrl = computed(() => this.config().welcome?.docsUrl);
/** Floating amber particles. */
readonly motes = Array.from({ length: 10 }, (_, i) => ({
x: ((i * 29 + 7) % 90) + 5,
s: 3 + (i % 4),
d: i * 1200,
dur: 8000 + i * 700,
}));
signIn(): void {
void this.authService.beginLogin('/');
}

View File

@@ -78,10 +78,10 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
styles: [`
.shell {
display: grid;
grid-template-columns: var(--sidebar-width, 200px) 1fr;
grid-template-columns: var(--sidebar-width, 240px) 1fr;
grid-template-rows: 1fr;
min-height: 100vh;
background: radial-gradient(ellipse at 70% 20%, rgba(245, 166, 35, 0.04) 0%, transparent 60%), var(--color-surface-tertiary);
background: var(--color-surface-tertiary);
}
.shell--sidebar-collapsed {
@@ -138,8 +138,7 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
.shell__breadcrumb {
flex-shrink: 0;
padding: 0.75rem 1.5rem;
background: var(--color-surface-secondary);
padding: 0.5rem 1.5rem;
border-bottom: 1px solid var(--color-border-primary);
}

View File

@@ -61,10 +61,7 @@ export interface NavSection {
<!-- Brand/Logo -->
<div class="sidebar__brand">
<a routerLink="/" class="sidebar__logo">
<svg class="sidebar__logo-icon" viewBox="0 0 24 24" width="32" height="32" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="currentColor" opacity="0.2"/>
<path d="M12 2L15 8L22 9L17 14L18 21L12 18L6 21L7 14L2 9L9 8L12 2Z" fill="currentColor"/>
</svg>
<img src="assets/img/site.png" alt="" class="sidebar__logo-img" width="28" height="28" />
@if (!collapsed) {
<span class="sidebar__logo-text">Stella Ops</span>
}
@@ -138,11 +135,11 @@ export interface NavSection {
.sidebar {
display: flex;
flex-direction: column;
width: 200px;
width: 240px;
height: 100%;
background: linear-gradient(180deg, var(--color-nav-bg) 0%, var(--color-brand-soft) 100%);
color: var(--color-header-text);
box-shadow: inset -1px 0 0 var(--color-nav-border);
background: var(--color-surface-primary);
color: var(--color-text-primary);
border-right: 1px solid var(--color-border-primary);
transition: width 0.2s ease;
overflow: hidden;
}
@@ -154,26 +151,27 @@ export interface NavSection {
.sidebar__brand {
display: flex;
align-items: center;
height: 60px;
padding: 0 1.125rem;
border-bottom: 1px solid var(--color-nav-border);
height: 52px;
padding: 0 1rem;
flex-shrink: 0;
border-bottom: 1px solid var(--color-border-primary);
}
.sidebar__logo {
display: flex;
align-items: center;
gap: 0.75rem;
gap: 0.625rem;
color: inherit;
text-decoration: none;
font-weight: var(--font-weight-bold);
font-size: 1.125rem;
letter-spacing: 0.04em;
font-size: 1.0625rem;
letter-spacing: 0.02em;
white-space: nowrap;
}
.sidebar__logo-icon {
.sidebar__logo-img {
flex-shrink: 0;
color: var(--color-brand-primary);
border-radius: 6px;
}
.sidebar__logo-text {
@@ -187,7 +185,7 @@ export interface NavSection {
top: 72px;
width: 24px;
height: 24px;
border: 1px solid var(--color-nav-border);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
@@ -236,20 +234,35 @@ export interface NavSection {
.sidebar__nav {
flex: 1;
overflow-y: auto;
padding: 0.75rem 0.5rem;
overflow-x: hidden;
padding: 0.5rem;
scrollbar-width: thin;
scrollbar-color: var(--color-border-primary) transparent;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--color-border-primary);
border-radius: 4px;
}
}
.sidebar__footer {
padding: 0.75rem 1rem;
border-top: 1px solid var(--color-nav-border);
flex-shrink: 0;
padding: 0.5rem 1rem;
text-align: center;
}
.sidebar__version {
font-size: 0.75rem;
font-size: 0.625rem;
font-family: var(--font-family-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-text-muted);
opacity: 0.6;
}
`],
@@ -264,7 +277,7 @@ export class AppSidebarComponent {
@Output() mobileClose = new EventEmitter<void>();
/** Track which groups are expanded */
readonly expandedGroups = signal<Set<string>>(new Set(['security', 'operations', 'settings']));
readonly expandedGroups = signal<Set<string>>(new Set(['security']));
/** Navigation sections */
readonly navSections: NavSection[] = [
@@ -344,10 +357,15 @@ export class AppSidebarComponent {
route: '/settings',
children: [
{ id: 'settings-integrations', label: 'Integrations', route: '/settings/integrations', icon: 'plug' },
{ id: 'settings-trust', label: 'Trust & Keys', route: '/settings/trust', icon: 'key' },
{ id: 'settings-policy', label: 'Policy Governance', route: '/settings/policy', icon: 'book' },
{ id: 'settings-release-control', label: 'Release Control', route: '/settings/release-control', icon: 'rocket' },
{ id: 'settings-trust', label: 'Trust & Signing', route: '/settings/trust', icon: 'key' },
{ id: 'settings-security-data', label: 'Security Data', route: '/settings/security-data', icon: 'shield' },
{ id: 'settings-admin', label: 'Identity & Access', route: '/settings/admin', icon: 'users' },
{ id: 'settings-branding', label: 'Tenant / Branding', route: '/settings/branding', icon: 'palette' },
{ id: 'settings-usage', label: 'Usage & Limits', route: '/settings/usage', icon: 'chart' },
{ id: 'settings-notifications', label: 'Notifications', route: '/settings/notifications', icon: 'bell' },
{ id: 'settings-admin', label: 'Administration', route: '/settings/admin', icon: 'users' },
{ id: 'settings-policy', label: 'Policy Governance', route: '/settings/policy', icon: 'book' },
{ id: 'settings-system', label: 'System', route: '/settings/system', icon: 'settings' },
],
},
];

View File

@@ -96,38 +96,44 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
</div>
`,
styles: [`
:host {
display: block;
}
.nav-group {
margin-bottom: 0.25rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border-primary);
display: block;
position: static;
padding: 0;
margin-bottom: 0.125rem;
margin-top: 0.75rem;
width: 100%;
box-sizing: border-box;
&:first-child {
margin-top: 0;
padding-top: 0;
border-top: none;
}
}
.nav-group__header {
display: flex;
align-items: center;
gap: 0.75rem;
width: calc(100% - 1rem);
padding: 0.625rem 1rem;
margin: 0 0.5rem;
gap: 0.625rem;
width: 100%;
padding: 0.375rem 0.75rem;
margin: 0;
border: none;
border-radius: var(--radius-lg);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
text-align: left;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
transition: background-color 0.15s, color 0.15s;
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
transition: color 0.15s;
&:hover {
background: var(--color-nav-hover);
color: var(--color-text-heading);
}
@@ -138,7 +144,7 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
}
.nav-group__header--active {
color: var(--color-brand-secondary);
color: var(--color-brand-primary);
}
.nav-group__icon {
@@ -146,8 +152,13 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
width: 16px;
height: 16px;
svg {
width: 14px;
height: 14px;
}
}
.nav-group__label {
@@ -159,7 +170,8 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
.nav-group__chevron {
flex-shrink: 0;
transition: transform 0.25s var(--motion-ease-standard, ease);
opacity: 0.4;
transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.nav-group--expanded .nav-group__chevron {
@@ -167,9 +179,9 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
}
.nav-group__children {
padding-left: 1rem;
border-left: 1px dashed var(--color-border-primary);
margin-left: 1.75rem;
padding-left: 0;
margin-left: 0;
margin-top: 0.125rem;
}
.nav-group--collapsed .nav-group__header {

View File

@@ -232,6 +232,19 @@ export interface NavItem {
<line x1="9" y1="9" x2="15" y2="15" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('rocket') {
<svg viewBox="0 0 24 24" width="20" height="20">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('palette') {
<svg viewBox="0 0 24 24" width="20" height="20">
<circle cx="13.5" cy="6.5" r="2" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="17.5" cy="10.5" r="2" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="8.5" cy="7.5" r="2" fill="none" stroke="currentColor" stroke-width="2"/><circle cx="6.5" cy="12.5" r="2" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
<!-- Default circle icon -->
@default {
<svg viewBox="0 0 24 24" width="20" height="20">
@@ -253,24 +266,30 @@ export interface NavItem {
</a>
`,
styles: [`
:host {
display: block;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1rem;
margin: 0 0.5rem 2px;
padding: 0.5625rem 0.75rem;
margin: 0 0.25rem 1px;
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
transition: background-color 0.15s, color 0.15s, box-shadow 0.15s;
transition: all 0.15s;
border-radius: var(--radius-lg);
cursor: pointer;
position: relative;
min-width: 0;
border-left: 3px solid transparent;
&:hover {
background: var(--color-nav-hover);
color: var(--color-text-heading);
border-radius: var(--radius-lg);
color: var(--color-text-primary);
}
&:focus-visible {
@@ -280,17 +299,17 @@ export interface NavItem {
}
.nav-item--active {
background: var(--color-brand-primary-10);
color: var(--color-brand-secondary);
background: var(--color-brand-soft);
color: var(--color-brand-primary);
font-weight: var(--font-weight-semibold);
box-shadow: 0 1px 3px rgba(245, 166, 35, 0.12);
border-left-color: var(--color-brand-primary);
.nav-item__icon {
color: var(--color-brand-primary);
}
&:hover {
background: rgba(245, 166, 35, 0.15);
background: rgba(245, 166, 35, 0.12);
}
}
@@ -300,16 +319,15 @@ export interface NavItem {
}
.nav-item--child {
margin-left: 0.25rem;
font-size: 0.8125rem;
.nav-item__icon {
width: 18px;
height: 18px;
width: 16px;
height: 16px;
svg {
width: 16px;
height: 16px;
width: 14px;
height: 14px;
}
}
}
@@ -328,31 +346,31 @@ export interface NavItem {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.nav-item__badge {
flex-shrink: 0;
min-width: 20px;
height: 20px;
padding: 0 6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--color-brand-primary);
color: var(--color-text-heading);
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
border-radius: var(--radius-xl);
color: #fff;
font-size: 0.6875rem;
font-weight: var(--font-weight-bold);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px rgba(245, 166, 35, 0.25);
}
.nav-item--collapsed .nav-item__badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 16px;
height: 16px;
font-size: 0.625rem;
top: 2px;
right: 2px;
min-width: 14px;
height: 14px;
font-size: 0.5625rem;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -79,13 +79,23 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
.topbar {
display: flex;
align-items: center;
gap: 1.25rem;
height: 60px;
padding: 0 1rem;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px) saturate(1.2);
-webkit-backdrop-filter: blur(12px) saturate(1.2);
border-bottom: 1px solid rgba(212, 201, 168, 0.2);
gap: 1rem;
height: 52px;
padding: 0 1.25rem;
background: var(--color-surface-secondary);
border-bottom: 1px solid var(--color-border-primary);
position: relative;
}
.topbar::after {
content: '';
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, var(--color-brand-primary) 0%, var(--color-brand-secondary) 50%, transparent 100%);
opacity: 0.4;
}
.topbar__menu-toggle {

View File

@@ -8,7 +8,7 @@
"redirectUri": "/auth/callback",
"silentRefreshRedirectUri": "/auth/silent-refresh",
"postLogoutRedirectUri": "/",
"scope": "openid profile email ui.read authority:tenants.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read vuln:view vuln:investigate vuln:operate vuln:audit",
"scope": "openid profile email ui.read ui.admin authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read vuln:view vuln:investigate vuln:operate vuln:audit",
"audience": "/scanner",
"dpopAlgorithms": ["ES256"],
"refreshLeewaySeconds": 60

View File

@@ -61,54 +61,54 @@
--color-card-heading: #977813;
// ---------------------------------------------------------------------------
// Severity Colors (consistent across themes)
// Severity Colors (warm palette, consistent across themes)
// ---------------------------------------------------------------------------
--color-severity-critical: #dc2626;
--color-severity-critical-bg: rgba(220, 38, 38, 0.1);
--color-severity-critical-border: rgba(220, 38, 38, 0.2);
--color-severity-critical: #C03828;
--color-severity-critical-bg: rgba(192, 56, 40, 0.08);
--color-severity-critical-border: rgba(192, 56, 40, 0.18);
--color-severity-high: #ea580c;
--color-severity-high-bg: rgba(234, 88, 12, 0.1);
--color-severity-high-border: rgba(234, 88, 12, 0.2);
--color-severity-high: #C85810;
--color-severity-high-bg: rgba(200, 88, 16, 0.08);
--color-severity-high-border: rgba(200, 88, 16, 0.18);
--color-severity-medium: #f59e0b;
--color-severity-medium-bg: rgba(245, 158, 11, 0.1);
--color-severity-medium-border: rgba(245, 158, 11, 0.2);
--color-severity-medium: #B08B10;
--color-severity-medium-bg: rgba(176, 139, 16, 0.08);
--color-severity-medium-border: rgba(176, 139, 16, 0.18);
--color-severity-low: #22c55e;
--color-severity-low-bg: rgba(34, 197, 94, 0.1);
--color-severity-low-border: rgba(34, 197, 94, 0.2);
--color-severity-low: #4D9040;
--color-severity-low-bg: rgba(77, 144, 64, 0.08);
--color-severity-low-border: rgba(77, 144, 64, 0.18);
--color-severity-info: #3b82f6;
--color-severity-info-bg: rgba(59, 130, 246, 0.1);
--color-severity-info-border: rgba(59, 130, 246, 0.2);
--color-severity-info: #5A7890;
--color-severity-info-bg: rgba(90, 120, 144, 0.08);
--color-severity-info-border: rgba(90, 120, 144, 0.18);
--color-severity-none: #6b7280;
--color-severity-none-bg: rgba(107, 114, 128, 0.1);
--color-severity-none-border: rgba(107, 114, 128, 0.2);
--color-severity-none: #7A7060;
--color-severity-none-bg: rgba(122, 112, 96, 0.08);
--color-severity-none-border: rgba(122, 112, 96, 0.18);
// ---------------------------------------------------------------------------
// Status Colors
// Status Colors (warm earth tones matching amber theme)
// ---------------------------------------------------------------------------
--color-status-success: #22c55e;
--color-status-success-bg: #dcfce7;
--color-status-success-border: #86efac;
--color-status-success-text: #166534;
--color-status-success: #4D9B40;
--color-status-success-bg: #EDF4E8;
--color-status-success-border: #B5D4A0;
--color-status-success-text: #2D5A1C;
--color-status-warning: #f59e0b;
--color-status-warning-bg: #fef3c7;
--color-status-warning-border: #fcd34d;
--color-status-warning-text: #92400e;
--color-status-warning: #C89820;
--color-status-warning-bg: #FDF4E0;
--color-status-warning-border: #E8D498;
--color-status-warning-text: #7A5510;
--color-status-error: #ef4444;
--color-status-error-bg: #fef2f2;
--color-status-error-border: #fca5a5;
--color-status-error-text: #991b1b;
--color-status-error: #CB4535;
--color-status-error-bg: #F8EFEB;
--color-status-error-border: #D8B0A5;
--color-status-error-text: #8C3525;
--color-status-info: #3b82f6;
--color-status-info-bg: #e0f2fe;
--color-status-info-border: #7dd3fc;
--color-status-info-text: #0369a1;
--color-status-info: #6082A8;
--color-status-info-bg: #ECF0F4;
--color-status-info-border: #A8C0D5;
--color-status-info-text: #3A5F7A;
// ---------------------------------------------------------------------------
// Header / Navigation (warm, matching product site)
@@ -202,24 +202,24 @@
--color-evidence-unknown-bg: rgba(107, 114, 128, 0.08);
--color-evidence-unknown-border: rgba(107, 114, 128, 0.2);
// Evidence Type Colors (for evidence-thread components)
--color-evidence-attestation: #7b1fa2;
--color-evidence-attestation-bg: #f3e5f5;
--color-evidence-policy: #00695c;
--color-evidence-policy-bg: #e0f2f1;
--color-evidence-runtime: #c2185b;
--color-evidence-runtime-bg: #fce4ec;
--color-evidence-patch: #3949ab;
--color-evidence-patch-bg: #e8eaf6;
--color-evidence-approval: #558b2f;
--color-evidence-approval-bg: #f1f8e9;
--color-evidence-ai: #ff8f00;
--color-evidence-ai-bg: #fff8e1;
// Evidence Type Colors (warm palette for evidence-thread components)
--color-evidence-attestation: #7A5090;
--color-evidence-attestation-bg: #F4EFF6;
--color-evidence-policy: #3A7068;
--color-evidence-policy-bg: #EDF4F2;
--color-evidence-runtime: #A0405A;
--color-evidence-runtime-bg: #F6EEF0;
--color-evidence-patch: #4A5A8A;
--color-evidence-patch-bg: #EDEEF4;
--color-evidence-approval: #4D7A28;
--color-evidence-approval-bg: #EFF4E8;
--color-evidence-ai: #C08010;
--color-evidence-ai-bg: #FDF5E4;
// Exception Status
--color-status-excepted: #7c3aed;
--color-status-excepted-bg: rgba(124, 58, 237, 0.1);
--color-status-excepted-border: rgba(124, 58, 237, 0.2);
// Exception Status (warm plum)
--color-status-excepted: #7A5090;
--color-status-excepted-bg: rgba(122, 80, 144, 0.08);
--color-status-excepted-border: rgba(122, 80, 144, 0.18);
// ---------------------------------------------------------------------------
// Fresh Auth Status