compose and authority fixes. finish sprints.
This commit is contained in:
@@ -29,7 +29,7 @@ test.describe('Workflow: Navigation Sidebar', () => {
|
||||
|
||||
// Verify nav links exist (at least some expected labels)
|
||||
const navText = await nav.first().innerText();
|
||||
const expectedSections = ['Security', 'Policy', 'Operations'];
|
||||
const expectedSections = ['Security', 'Evidence', 'Operations', 'Settings'];
|
||||
for (const section of expectedSections) {
|
||||
expect(navText.toLowerCase()).toContain(section.toLowerCase());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { test, expect } from '../fixtures/auth.fixture';
|
||||
import { assertPageHasContent, navigateAndWait } from '../helpers/nav.helper';
|
||||
|
||||
type RouteTarget = {
|
||||
path: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ControlMeta = {
|
||||
tag: string;
|
||||
type: string;
|
||||
role: string;
|
||||
label: string;
|
||||
ariaLabel: string;
|
||||
disabled: boolean;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
type RouteSweepResult = {
|
||||
route: string;
|
||||
name: string;
|
||||
candidates: number;
|
||||
clicked: number;
|
||||
skipped: number;
|
||||
reasons: SweepReason[];
|
||||
skippedByReason: Record<SkipReason, number>;
|
||||
failures: string[];
|
||||
angularErrors: string[];
|
||||
};
|
||||
|
||||
type SweepReason = 'no-controls' | 'guarded' | 'occluded' | 'error-state' | 'clicked';
|
||||
type SkipReason = 'hidden' | 'disabled' | 'submit' | 'destructive' | 'unlabeled' | 'unsupported' | 'occluded';
|
||||
|
||||
const RAW_ROUTES: RouteTarget[] = [
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
{ path: '/orchestrator', name: 'Orchestrator Legacy' },
|
||||
{ path: '/orchestrator/jobs', name: 'Orchestrator Jobs' },
|
||||
{ path: '/orchestrator/quotas', name: 'Orchestrator Quotas' },
|
||||
{ path: '/release-orchestrator', name: 'Release Orchestrator' },
|
||||
{ path: '/policy-studio/packs', name: 'Policy Studio Packs' },
|
||||
{ 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' },
|
||||
{ path: '/vulnerabilities', name: 'Vulnerability Explorer' },
|
||||
{ path: '/vulnerabilities/triage', name: 'Vulnerability Triage' },
|
||||
{ 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' },
|
||||
{ path: '/notify', name: 'Notify Panel' },
|
||||
{ path: '/admin/notifications', name: 'Admin Notifications' },
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
{ path: '/welcome', name: 'Welcome Page' },
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
{ path: '/setup', name: 'Setup' },
|
||||
{ path: '/setup/wizard', name: 'Setup Wizard' },
|
||||
];
|
||||
|
||||
const ROUTES: RouteTarget[] = RAW_ROUTES.filter(
|
||||
(route, index, all) => all.findIndex((candidate) => candidate.path === route.path) === index
|
||||
);
|
||||
|
||||
const DESTRUCTIVE_LABEL_PATTERN = /\b(delete|remove|purge|destroy|drop|wipe|revoke|terminate|logout|sign out|clear all)\b/i;
|
||||
|
||||
const KNOWN_NO_CONTROL_ROUTES = new Set<string>([
|
||||
'/security/vulnerabilities',
|
||||
'/policy/governance',
|
||||
'/evidence',
|
||||
'/console/profile',
|
||||
'/triage/inbox',
|
||||
'/operations',
|
||||
'/operations/orchestrator',
|
||||
'/orchestrator',
|
||||
'/orchestrator/jobs',
|
||||
'/orchestrator/quotas',
|
||||
'/admin/audit',
|
||||
'/workspace/dev',
|
||||
'/workspace/audit',
|
||||
'/sbom/diff',
|
||||
'/deploy/diff',
|
||||
'/vex/timeline',
|
||||
'/admin/registries',
|
||||
'/admin/issuers',
|
||||
'/admin/policy/governance',
|
||||
]);
|
||||
|
||||
const SWEEP_THRESHOLDS = {
|
||||
minRouteCoverage: 0.75,
|
||||
minActionCoverage: 0.40,
|
||||
maxSkipRatio: 0.75,
|
||||
maxErrorStateRoutes: 0,
|
||||
maxUnreviewedNoControlRoutes: 0,
|
||||
} as const;
|
||||
|
||||
const GUARDED_SKIP_REASONS: SkipReason[] = ['hidden', 'disabled', 'submit', 'destructive', 'unlabeled', 'unsupported'];
|
||||
|
||||
function addReason(result: RouteSweepResult, reason: SweepReason): void {
|
||||
if (!result.reasons.includes(reason)) {
|
||||
result.reasons.push(reason);
|
||||
}
|
||||
}
|
||||
|
||||
async function describeControl(handle: import('@playwright/test').ElementHandle<SVGElement | HTMLElement>): Promise<ControlMeta | null> {
|
||||
return handle.evaluate((node) => {
|
||||
const element = node as HTMLElement;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
const tag = element.tagName.toLowerCase();
|
||||
const type = (element.getAttribute('type') ?? '').toLowerCase();
|
||||
const role = (element.getAttribute('role') ?? '').toLowerCase();
|
||||
const label = (element.innerText ?? element.textContent ?? '').replace(/\s+/g, ' ').trim();
|
||||
const ariaLabel = (element.getAttribute('aria-label') ?? element.getAttribute('title') ?? '').replace(/\s+/g, ' ').trim();
|
||||
const disabled =
|
||||
element.hasAttribute('disabled') ||
|
||||
element.getAttribute('aria-disabled') === 'true' ||
|
||||
(element as HTMLButtonElement).disabled === true;
|
||||
const visible =
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
style.opacity !== '0';
|
||||
|
||||
if (tag !== 'button' && role !== 'button') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
tag,
|
||||
type,
|
||||
role,
|
||||
label,
|
||||
ariaLabel,
|
||||
disabled,
|
||||
visible,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Workflow: Exhaustive Button Sweep', () => {
|
||||
test('clicks visible non-destructive controls on all routes', async ({ authenticatedPage: page }, testInfo) => {
|
||||
test.setTimeout(45 * 60 * 1000);
|
||||
|
||||
const consoleErrors: string[] = [];
|
||||
const pageErrors: string[] = [];
|
||||
const results: RouteSweepResult[] = [];
|
||||
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
page.on('pageerror', (error) => {
|
||||
pageErrors.push(error.message);
|
||||
});
|
||||
|
||||
page.on('dialog', (dialog) => {
|
||||
void dialog.dismiss();
|
||||
});
|
||||
|
||||
for (const route of ROUTES) {
|
||||
const routeResult: RouteSweepResult = {
|
||||
route: route.path,
|
||||
name: route.name,
|
||||
candidates: 0,
|
||||
clicked: 0,
|
||||
skipped: 0,
|
||||
reasons: [],
|
||||
skippedByReason: {
|
||||
hidden: 0,
|
||||
disabled: 0,
|
||||
submit: 0,
|
||||
destructive: 0,
|
||||
unlabeled: 0,
|
||||
unsupported: 0,
|
||||
occluded: 0,
|
||||
},
|
||||
failures: [],
|
||||
angularErrors: [],
|
||||
};
|
||||
|
||||
const consoleStart = consoleErrors.length;
|
||||
const pageErrorStart = pageErrors.length;
|
||||
|
||||
try {
|
||||
await navigateAndWait(page, route.path, { timeout: 40_000 });
|
||||
await page.waitForTimeout(1200);
|
||||
await assertPageHasContent(page);
|
||||
} catch (error) {
|
||||
routeResult.failures.push(`route_load_failed: ${String(error)}`);
|
||||
addReason(routeResult, 'error-state');
|
||||
results.push(routeResult);
|
||||
continue;
|
||||
}
|
||||
|
||||
const root = (await page.locator('#main-content').count()) > 0 ? '#main-content' : 'body';
|
||||
const controls = await page.locator(`${root} button, ${root} [role="button"]`).elementHandles();
|
||||
routeResult.candidates = controls.length;
|
||||
if (controls.length === 0) {
|
||||
addReason(routeResult, 'no-controls');
|
||||
}
|
||||
|
||||
for (const control of controls) {
|
||||
let meta: ControlMeta | null = null;
|
||||
try {
|
||||
meta = await describeControl(control);
|
||||
} catch {
|
||||
routeResult.skipped += 1;
|
||||
routeResult.skippedByReason.unsupported += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!meta) {
|
||||
routeResult.skipped += 1;
|
||||
routeResult.skippedByReason.unsupported += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = `${meta.label} ${meta.ariaLabel}`.trim();
|
||||
let skipReason: SkipReason | null = null;
|
||||
if (!meta.visible) {
|
||||
skipReason = 'hidden';
|
||||
} else if (meta.disabled) {
|
||||
skipReason = 'disabled';
|
||||
} else if (meta.type === 'submit') {
|
||||
skipReason = 'submit';
|
||||
} else if (!label) {
|
||||
skipReason = 'unlabeled';
|
||||
} else if (DESTRUCTIVE_LABEL_PATTERN.test(label)) {
|
||||
skipReason = 'destructive';
|
||||
}
|
||||
|
||||
if (skipReason) {
|
||||
routeResult.skipped += 1;
|
||||
routeResult.skippedByReason[skipReason] += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await control.scrollIntoViewIfNeeded();
|
||||
await control.click({ timeout: 3_000 });
|
||||
routeResult.clicked += 1;
|
||||
await page.waitForTimeout(250);
|
||||
|
||||
if (!page.url().includes(route.path)) {
|
||||
await navigateAndWait(page, route.path, { timeout: 30_000 });
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
} catch (error) {
|
||||
const errorText = String(error);
|
||||
if (errorText.includes('intercepts pointer events')) {
|
||||
routeResult.skipped += 1;
|
||||
routeResult.skippedByReason.occluded += 1;
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
continue;
|
||||
}
|
||||
|
||||
routeResult.failures.push(`click_failed "${label}": ${errorText}`);
|
||||
await page.keyboard.press('Escape').catch(() => undefined);
|
||||
if (!page.url().includes(route.path)) {
|
||||
await navigateAndWait(page, route.path, { timeout: 30_000 }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const routeConsoleErrors = consoleErrors.slice(consoleStart);
|
||||
const routePageErrors = pageErrors.slice(pageErrorStart);
|
||||
routeResult.angularErrors = [...routeConsoleErrors, ...routePageErrors].filter((error) =>
|
||||
/NG0\d{3,4}/.test(error)
|
||||
);
|
||||
|
||||
if (routeResult.clicked > 0) {
|
||||
addReason(routeResult, 'clicked');
|
||||
}
|
||||
if (routeResult.skippedByReason.occluded > 0) {
|
||||
addReason(routeResult, 'occluded');
|
||||
}
|
||||
const guardedSkips = GUARDED_SKIP_REASONS.reduce((sum, key) => sum + routeResult.skippedByReason[key], 0);
|
||||
if (guardedSkips > 0) {
|
||||
addReason(routeResult, 'guarded');
|
||||
}
|
||||
if (routeResult.angularErrors.length > 0 || routeResult.failures.length > 0) {
|
||||
addReason(routeResult, 'error-state');
|
||||
}
|
||||
|
||||
results.push(routeResult);
|
||||
}
|
||||
|
||||
const totalCandidates = results.reduce((sum, item) => sum + item.candidates, 0);
|
||||
const totalClicked = results.reduce((sum, item) => sum + item.clicked, 0);
|
||||
const totalSkipped = results.reduce((sum, item) => sum + item.skipped, 0);
|
||||
const totalFailures = results.reduce((sum, item) => sum + item.failures.length, 0);
|
||||
const actionableRoutes = results.filter((item) => item.candidates > 0).length;
|
||||
const clickedRoutes = results.filter((item) => item.clicked > 0).length;
|
||||
const routeCoverage = actionableRoutes > 0 ? clickedRoutes / actionableRoutes : 1;
|
||||
const actionCoverage = totalCandidates > 0 ? totalClicked / totalCandidates : 1;
|
||||
const skipRatio = totalCandidates > 0 ? totalSkipped / totalCandidates : 0;
|
||||
const zeroCandidateRoutes = results.filter((item) => item.candidates === 0);
|
||||
const reviewedNoControlRoutes = zeroCandidateRoutes
|
||||
.filter((item) => KNOWN_NO_CONTROL_ROUTES.has(item.route))
|
||||
.map((item) => item.route);
|
||||
const unreviewedNoControlRoutes = zeroCandidateRoutes
|
||||
.filter((item) => !KNOWN_NO_CONTROL_ROUTES.has(item.route))
|
||||
.map((item) => item.route);
|
||||
const errorStateRoutes = results
|
||||
.filter((item) => item.reasons.includes('error-state'))
|
||||
.map((item) => item.route);
|
||||
const failedChecks: string[] = [];
|
||||
|
||||
if (routeCoverage < SWEEP_THRESHOLDS.minRouteCoverage) {
|
||||
failedChecks.push(
|
||||
`routeCoverage ${routeCoverage.toFixed(3)} below threshold ${SWEEP_THRESHOLDS.minRouteCoverage.toFixed(3)}`
|
||||
);
|
||||
}
|
||||
if (actionCoverage < SWEEP_THRESHOLDS.minActionCoverage) {
|
||||
failedChecks.push(
|
||||
`actionCoverage ${actionCoverage.toFixed(3)} below threshold ${SWEEP_THRESHOLDS.minActionCoverage.toFixed(3)}`
|
||||
);
|
||||
}
|
||||
if (skipRatio > SWEEP_THRESHOLDS.maxSkipRatio) {
|
||||
failedChecks.push(`skipRatio ${skipRatio.toFixed(3)} above threshold ${SWEEP_THRESHOLDS.maxSkipRatio.toFixed(3)}`);
|
||||
}
|
||||
if (errorStateRoutes.length > SWEEP_THRESHOLDS.maxErrorStateRoutes) {
|
||||
failedChecks.push(
|
||||
`errorStateRoutes ${errorStateRoutes.length} exceeds threshold ${SWEEP_THRESHOLDS.maxErrorStateRoutes}: ${errorStateRoutes.join(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
}
|
||||
if (unreviewedNoControlRoutes.length > SWEEP_THRESHOLDS.maxUnreviewedNoControlRoutes) {
|
||||
failedChecks.push(
|
||||
`unreviewedNoControlRoutes ${unreviewedNoControlRoutes.length} exceeds threshold ${SWEEP_THRESHOLDS.maxUnreviewedNoControlRoutes}: ${unreviewedNoControlRoutes.join(
|
||||
', '
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const report = {
|
||||
generatedAtUtc: new Date().toISOString(),
|
||||
totalRoutes: results.length,
|
||||
totalCandidates,
|
||||
totalClicked,
|
||||
totalSkipped,
|
||||
totalFailures,
|
||||
coverage: {
|
||||
actionableRoutes,
|
||||
clickedRoutes,
|
||||
routeCoverage,
|
||||
actionCoverage,
|
||||
skipRatio,
|
||||
zeroCandidateRoutes: zeroCandidateRoutes.length,
|
||||
reviewedNoControlRoutes,
|
||||
unreviewedNoControlRoutes,
|
||||
errorStateRoutes,
|
||||
thresholds: SWEEP_THRESHOLDS,
|
||||
failedChecks,
|
||||
},
|
||||
routes: results,
|
||||
};
|
||||
|
||||
const outputDirectory = path.resolve(process.cwd(), 'test-results');
|
||||
fs.mkdirSync(outputDirectory, { recursive: true });
|
||||
const outputFile = path.join(outputDirectory, 'exhaustive-button-sweep-report.json');
|
||||
fs.writeFileSync(outputFile, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
||||
await testInfo.attach('exhaustive-button-sweep-report', { path: outputFile, contentType: 'application/json' });
|
||||
expect(failedChecks, `Coverage gates failed. See ${outputFile}`).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, test } from '../fixtures/auth.fixture';
|
||||
|
||||
test.describe('Workflow: Settings Policy Actions', () => {
|
||||
test('all policy settings actions navigate to working policy workflows', async ({
|
||||
authenticatedPage: page,
|
||||
}) => {
|
||||
const actions = [
|
||||
{ label: '+ Create Baseline', expectedPath: '/policy/packs' },
|
||||
{ label: 'Edit Rules', expectedPath: '/policy/governance' },
|
||||
{ label: 'Run Simulation', expectedPath: '/policy/simulation' },
|
||||
{ label: 'Configure Workflow', expectedPath: '/policy/exceptions' },
|
||||
] as const;
|
||||
|
||||
for (const action of actions) {
|
||||
await page.goto('/settings/policy', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const trigger = page.locator(`a:has-text("${action.label}")`).first();
|
||||
await expect(trigger).toBeVisible({ timeout: 10_000 });
|
||||
await trigger.click();
|
||||
|
||||
await expect
|
||||
.poll(() => new URL(page.url()).pathname, {
|
||||
timeout: 15_000,
|
||||
message: `Expected action "${action.label}" to navigate to ${action.expectedPath}.`,
|
||||
})
|
||||
.toContain(action.expectedPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.use({ ignoreHTTPSErrors: true });
|
||||
|
||||
test.describe('Workflow: Welcome Sign-In', () => {
|
||||
test('upgrades insecure HTTP welcome page to HTTPS when Sign In is clicked', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('http://stella-ops.local/welcome', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
const signIn = page
|
||||
.locator(
|
||||
'button:has-text("Sign in"), button:has-text("Sign In"), a:has-text("Sign in"), a:has-text("Sign In")'
|
||||
)
|
||||
.first();
|
||||
|
||||
await expect(signIn).toBeVisible({ timeout: 10_000 });
|
||||
await signIn.click();
|
||||
|
||||
await expect
|
||||
.poll(() => page.url(), {
|
||||
timeout: 15_000,
|
||||
message: 'Expected Sign In to navigate from insecure HTTP to secure HTTPS context.',
|
||||
})
|
||||
.toContain('https://stella-ops.local');
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,8 @@ export default defineConfig({
|
||||
['json', { outputFile: 'e2e-results.json' }],
|
||||
],
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://stella-ops.local',
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://stella-ops.local',
|
||||
ignoreHTTPSErrors: true,
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
|
||||
@@ -1,122 +1,122 @@
|
||||
{
|
||||
"/envsettings.json": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/platform": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/api": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/authority": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/console": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/connect": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/.well-known": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/jwks": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/scanner": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/policyGateway": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/policyEngine": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/concelier": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/attestor": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/gateway": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/notify": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/scheduler": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/signals": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/excititor": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/findingsLedger": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/vexhub": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/vexlens": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/orchestrator": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/graph": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/doctor": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/integrations": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/replay": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/exportcenter": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/healthz": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/policy": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
},
|
||||
"/v1": {
|
||||
"target": "http://127.1.0.5:80",
|
||||
"target": "http://127.1.0.1:80",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ import { AnalyticsHttpClient, MockAnalyticsClient } from './core/api/analytics.c
|
||||
import { FEED_MIRROR_API, FEED_MIRROR_API_BASE_URL, FeedMirrorHttpClient } from './core/api/feed-mirror.client';
|
||||
import { ATTESTATION_CHAIN_API, AttestationChainHttpClient } from './core/api/attestation-chain.client';
|
||||
import { CONSOLE_SEARCH_API, ConsoleSearchHttpClient } from './core/api/console-search.client';
|
||||
import { POLICY_GOVERNANCE_API, HttpPolicyGovernanceApi } from './core/api/policy-governance.client';
|
||||
import { POLICY_GOVERNANCE_API, MockPolicyGovernanceApi } from './core/api/policy-governance.client';
|
||||
import { POLICY_GATES_API, POLICY_GATES_API_BASE_URL, PolicyGatesHttpClient } from './core/api/policy-gates.client';
|
||||
import { RELEASE_API, ReleaseHttpClient } from './core/api/release.client';
|
||||
import { TRIAGE_EVIDENCE_API, TriageEvidenceHttpClient } from './core/api/triage-evidence.client';
|
||||
@@ -860,10 +860,10 @@ export const appConfig: ApplicationConfig = {
|
||||
useExisting: ConsoleSearchHttpClient,
|
||||
},
|
||||
// Policy Governance API
|
||||
HttpPolicyGovernanceApi,
|
||||
MockPolicyGovernanceApi,
|
||||
{
|
||||
provide: POLICY_GOVERNANCE_API,
|
||||
useExisting: HttpPolicyGovernanceApi,
|
||||
useExisting: MockPolicyGovernanceApi,
|
||||
},
|
||||
// Policy Gates API (Policy Gateway backend)
|
||||
{
|
||||
|
||||
@@ -128,6 +128,9 @@ export interface ResolutionEvidence {
|
||||
/** Confidence in the fix determination */
|
||||
fixConfidence?: number;
|
||||
|
||||
/** Confidence score (legacy contract shape, 0.0-1.0) */
|
||||
confidence?: number;
|
||||
|
||||
/** Reference to detailed evidence record */
|
||||
evidenceRecordRef?: string;
|
||||
|
||||
@@ -137,8 +140,20 @@ export interface ResolutionEvidence {
|
||||
/** Upstream commit that introduced the fix */
|
||||
patchCommit?: string;
|
||||
|
||||
/** SHA-256 hash of the patch (legacy contract shape) */
|
||||
patchHash?: string;
|
||||
|
||||
/** Functions that changed (for function-level evidence) */
|
||||
changedFunctions?: FunctionChangeInfo[];
|
||||
|
||||
/** Legacy list of matched fingerprint identifiers */
|
||||
matchedFingerprintIds?: string[];
|
||||
|
||||
/** Legacy human-readable function diff summary */
|
||||
functionDiffSummary?: string;
|
||||
|
||||
/** Hybrid source-symbol-binary diff evidence chain */
|
||||
hybridDiff?: HybridDiffEvidence;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -173,6 +188,112 @@ export interface FunctionChangeInfo {
|
||||
patchedDisasm?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Hybrid evidence bundle linking semantic edit scripts, symbol maps,
|
||||
* symbol patch plans, and normalized patch manifests.
|
||||
*/
|
||||
export interface HybridDiffEvidence {
|
||||
/** Digest of semantic edit script */
|
||||
semanticEditScriptDigest?: string;
|
||||
|
||||
/** Digest of old symbol map */
|
||||
oldSymbolMapDigest?: string;
|
||||
|
||||
/** Digest of new symbol map */
|
||||
newSymbolMapDigest?: string;
|
||||
|
||||
/** Digest of symbol patch plan */
|
||||
symbolPatchPlanDigest?: string;
|
||||
|
||||
/** Digest of patch manifest */
|
||||
patchManifestDigest?: string;
|
||||
|
||||
/** Semantic edit script artifact */
|
||||
semanticEditScript?: SemanticEditScriptArtifact;
|
||||
|
||||
/** Symbol patch plan artifact */
|
||||
symbolPatchPlan?: SymbolPatchPlanArtifact;
|
||||
|
||||
/** Patch manifest artifact */
|
||||
patchManifest?: PatchManifestArtifact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic edit script contract.
|
||||
*/
|
||||
export interface SemanticEditScriptArtifact {
|
||||
schemaVersion?: string;
|
||||
sourceTreeDigest?: string;
|
||||
edits?: SemanticEditRecord[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Single semantic edit.
|
||||
*/
|
||||
export interface SemanticEditRecord {
|
||||
stableId?: string;
|
||||
editType?: string;
|
||||
nodeKind?: string;
|
||||
nodePath?: string;
|
||||
anchor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Symbol patch plan contract.
|
||||
*/
|
||||
export interface SymbolPatchPlanArtifact {
|
||||
schemaVersion?: string;
|
||||
buildIdBefore?: string;
|
||||
buildIdAfter?: string;
|
||||
editsDigest?: string;
|
||||
symbolMapDigestBefore?: string;
|
||||
symbolMapDigestAfter?: string;
|
||||
changes?: SymbolPatchChange[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Single symbol change within a patch plan.
|
||||
*/
|
||||
export interface SymbolPatchChange {
|
||||
symbol: string;
|
||||
changeType: 'added' | 'removed' | 'modified' | 'moved' | string;
|
||||
astAnchors?: string[];
|
||||
preHash?: string;
|
||||
postHash?: string;
|
||||
deltaRef?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch manifest contract.
|
||||
*/
|
||||
export interface PatchManifestArtifact {
|
||||
schemaVersion?: string;
|
||||
buildId?: string;
|
||||
normalizationRecipeId?: string;
|
||||
totalDeltaBytes?: number;
|
||||
patches?: SymbolPatchArtifact[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-symbol patch entry.
|
||||
*/
|
||||
export interface SymbolPatchArtifact {
|
||||
symbol: string;
|
||||
addressRange?: string;
|
||||
deltaDigest?: string;
|
||||
pre?: PatchSizeHash;
|
||||
post?: PatchSizeHash;
|
||||
deltaSizeBytes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Size and hash tuple for a patch boundary.
|
||||
*/
|
||||
export interface PatchSizeHash {
|
||||
size: number;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch request for resolving multiple vulnerabilities.
|
||||
*/
|
||||
@@ -266,3 +387,4 @@ export interface VulnResolutionSummary {
|
||||
/** Whether detailed evidence is available */
|
||||
hasEvidence: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanMatchFn, Router } from '@angular/router';
|
||||
|
||||
import { AuthorityAuthService } from './authority-auth.service';
|
||||
import { AuthSessionStore } from './auth-session.store';
|
||||
import { StellaOpsScopes, type StellaOpsScope } from './scopes';
|
||||
|
||||
@@ -10,9 +11,23 @@ import { StellaOpsScopes, type StellaOpsScope } from './scopes';
|
||||
*/
|
||||
export const requireAuthGuard: CanMatchFn = () => {
|
||||
const auth = inject(AuthSessionStore);
|
||||
const authorityAuth = inject(AuthorityAuthService);
|
||||
const router = inject(Router);
|
||||
const isAuthenticated = auth.isAuthenticated();
|
||||
return isAuthenticated ? true : router.createUrlTree(['/welcome']);
|
||||
if (isAuthenticated) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// On hard reload, AuthSessionStore keeps only lightweight metadata in
|
||||
// sessionStorage. Give silent refresh one chance before redirecting.
|
||||
if (auth.subjectHint()) {
|
||||
return authorityAuth
|
||||
.trySilentRefresh()
|
||||
.then((restored) => (restored && auth.isAuthenticated()) ? true : router.createUrlTree(['/welcome']))
|
||||
.catch(() => router.createUrlTree(['/welcome']));
|
||||
}
|
||||
|
||||
return router.createUrlTree(['/welcome']);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -76,6 +76,10 @@ export class AuthorityAuthService {
|
||||
}
|
||||
|
||||
async beginLogin(returnUrl?: string): Promise<void> {
|
||||
if (!this.ensureSecureContextForAuth()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authority = this.config.authority;
|
||||
const pkce = await createPkcePair();
|
||||
const state = crypto.randomUUID ? crypto.randomUUID() : createRandomId();
|
||||
@@ -113,6 +117,10 @@ export class AuthorityAuthService {
|
||||
* @returns `true` if session was restored, `false` otherwise.
|
||||
*/
|
||||
async trySilentRefresh(): Promise<boolean> {
|
||||
if (!this.ensureSecureContextForAuth({ redirectOnHttp: false })) {
|
||||
this.sessionStore.setStatus('unauthenticated');
|
||||
return false;
|
||||
}
|
||||
// Already authenticated — nothing to do.
|
||||
if (this.sessionStore.session()) {
|
||||
return true;
|
||||
@@ -697,6 +705,34 @@ export class AuthorityAuthService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private ensureSecureContextForAuth(
|
||||
options: { redirectOnHttp?: boolean } = {}
|
||||
): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hasWebCrypto =
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.getRandomValues === 'function' &&
|
||||
typeof crypto.subtle !== 'undefined';
|
||||
|
||||
if (window.isSecureContext && hasWebCrypto) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const shouldRedirect = options.redirectOnHttp ?? true;
|
||||
if (window.location.protocol === 'http:' && shouldRedirect) {
|
||||
const secureUrl = new URL(window.location.href);
|
||||
secureUrl.protocol = 'https:';
|
||||
window.location.assign(secureUrl.toString());
|
||||
return false;
|
||||
}
|
||||
|
||||
this.lastError = 'dpop_generation_failed';
|
||||
return false;
|
||||
}
|
||||
|
||||
private buildAuthorizeUrl(
|
||||
authority: AuthorityConfig,
|
||||
options: {
|
||||
|
||||
@@ -74,7 +74,7 @@ const LEGACY_ROUTE_MAP: Record<string, string> = {
|
||||
'admin/issuers': '/settings/trust/issuers',
|
||||
'admin/notifications': '/settings/notifications',
|
||||
'admin/audit': '/evidence/audit',
|
||||
'admin/policy/governance': '/settings/policy/governance',
|
||||
'admin/policy/governance': '/policy/governance',
|
||||
'concelier/trivy-db-settings': '/settings/security-data/trivy',
|
||||
|
||||
// Integrations -> Settings
|
||||
|
||||
@@ -119,7 +119,7 @@ function parseScopes(accessToken: string): string[] {
|
||||
const parts = accessToken.split('.');
|
||||
if (parts.length !== 3) return [];
|
||||
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
const payload = JSON.parse(decodeBase64Url(parts[1]));
|
||||
const scopeStr = payload.scope ?? payload.scp ?? '';
|
||||
|
||||
if (Array.isArray(scopeStr)) {
|
||||
@@ -132,6 +132,13 @@ function parseScopes(accessToken: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string): string {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const paddingLength = (4 - (normalized.length % 4)) % 4;
|
||||
const padded = normalized + '='.repeat(paddingLength);
|
||||
return atob(padded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check scope inheritance per RBAC contract.
|
||||
* See docs/contracts/web-gateway-tenant-rbac.md
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, ChangeDetectionStrategy, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { APPROVAL_API } from '../../core/api/approval.client';
|
||||
@@ -11,7 +12,7 @@ import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.mo
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-approvals-inbox',
|
||||
imports: [CommonModule, RouterLink],
|
||||
imports: [CommonModule, RouterLink, FormsModule],
|
||||
template: `
|
||||
<div class="approvals">
|
||||
<header class="approvals__header">
|
||||
@@ -26,20 +27,43 @@ import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.mo
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="approvals__filters">
|
||||
<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</option>
|
||||
</select>
|
||||
<select class="filter-select">
|
||||
<option value="">All Environments</option>
|
||||
<option value="dev">Dev</option>
|
||||
<option value="qa">QA</option>
|
||||
<option value="staging">Staging</option>
|
||||
<option value="prod">Prod</option>
|
||||
</select>
|
||||
<input type="text" class="filter-search" placeholder="Search..." />
|
||||
<div class="filter-group">
|
||||
<span class="filter-group__label">Status</span>
|
||||
<div class="filter-chips">
|
||||
@for (status of statusOptions; track status.value) {
|
||||
<button type="button"
|
||||
class="filter-chip"
|
||||
[class.filter-chip--active]="currentStatusFilter === status.value"
|
||||
(click)="onStatusChipClick(status.value)">
|
||||
{{ status.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group filter-group--env" [class.filter-group--visible]="currentStatusFilter !== null">
|
||||
<span class="filter-group__label">Environment</span>
|
||||
<div class="filter-chips">
|
||||
@for (env of environmentOptions; track env.value) {
|
||||
<button type="button"
|
||||
class="filter-chip"
|
||||
[class.filter-chip--active]="currentEnvironmentFilter === env.value"
|
||||
(click)="onEnvironmentFilter(env.value)">
|
||||
{{ env.label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-search-wrapper">
|
||||
<svg class="filter-search-icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
<input type="text" class="filter-search" placeholder="Search approvals..." [(ngModel)]="searchQuery" (ngModelChange)="onSearchChange()" />
|
||||
@if (searchQuery) {
|
||||
<button type="button" class="filter-search-clear" (click)="clearSearch()">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading()) {
|
||||
@@ -130,18 +154,115 @@ import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.mo
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-select,
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-group__label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-chips {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--color-surface-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
border-color: var(--color-brand-primary);
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.filter-chip--active {
|
||||
background: var(--color-brand-primary);
|
||||
border-color: var(--color-brand-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filter-chip--active:hover {
|
||||
background: var(--color-brand-primary-hover);
|
||||
border-color: var(--color-brand-primary-hover);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.filter-group--env {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease, opacity 0.25s ease, margin 0.3s ease;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.filter-group--env.filter-group--visible {
|
||||
max-height: 60px;
|
||||
opacity: 1;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.filter-search-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.filter-search-icon {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--color-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.filter-search {
|
||||
padding: 0.5rem 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 2rem 0.5rem 2.25rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
.filter-search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
.filter-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.filter-search-clear {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-search-clear:hover {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.loading-banner {
|
||||
@@ -324,15 +445,46 @@ export class ApprovalsInboxComponent implements OnInit {
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly approvals = signal<ApprovalRequest[]>([]);
|
||||
private currentStatusFilter: ApprovalStatus[] = ['pending'];
|
||||
|
||||
readonly statusOptions = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
];
|
||||
|
||||
readonly environmentOptions = [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'dev', label: 'Dev' },
|
||||
{ value: 'qa', label: 'QA' },
|
||||
{ value: 'staging', label: 'Staging' },
|
||||
{ value: 'prod', label: 'Prod' },
|
||||
];
|
||||
|
||||
currentStatusFilter: string = 'pending';
|
||||
currentEnvironmentFilter: string = '';
|
||||
searchQuery: string = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadApprovals();
|
||||
}
|
||||
|
||||
onStatusFilter(event: Event): void {
|
||||
const value = (event.target as HTMLSelectElement).value;
|
||||
this.currentStatusFilter = value ? [value as ApprovalStatus] : [];
|
||||
onStatusChipClick(value: string): void {
|
||||
this.currentStatusFilter = value;
|
||||
this.loadApprovals();
|
||||
}
|
||||
|
||||
onEnvironmentFilter(value: string): void {
|
||||
this.currentEnvironmentFilter = value;
|
||||
this.loadApprovals();
|
||||
}
|
||||
|
||||
onSearchChange(): void {
|
||||
this.loadApprovals();
|
||||
}
|
||||
|
||||
clearSearch(): void {
|
||||
this.searchQuery = '';
|
||||
this.loadApprovals();
|
||||
}
|
||||
|
||||
@@ -365,9 +517,13 @@ export class ApprovalsInboxComponent implements OnInit {
|
||||
private loadApprovals(): void {
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
const filter = this.currentStatusFilter.length
|
||||
? { statuses: this.currentStatusFilter }
|
||||
: {};
|
||||
const filter: any = {};
|
||||
if (this.currentStatusFilter) {
|
||||
filter.statuses = [this.currentStatusFilter];
|
||||
}
|
||||
if (this.currentEnvironmentFilter) {
|
||||
filter.environment = this.currentEnvironmentFilter;
|
||||
}
|
||||
this.api.listApprovals(filter).pipe(
|
||||
catchError(() => {
|
||||
this.error.set('Failed to load approvals. The backend may be unavailable.');
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,7 @@ import {
|
||||
Replay All Retryable ({{ stats()?.stats?.retryable || 0 }})
|
||||
</button>
|
||||
<button class="btn btn-icon" (click)="refreshData()" [disabled]="loading()">
|
||||
<span class="icon" [class.spinning]="loading()">refresh</span>
|
||||
<span class="icon" [class.spinning]="refreshing()">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -722,6 +722,7 @@ export class DeadLetterDashboardComponent implements OnInit, OnDestroy {
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly refreshing = signal(false);
|
||||
readonly stats = signal<DeadLetterStatsSummary | null>(null);
|
||||
readonly entries = signal<DeadLetterEntrySummary[]>([]);
|
||||
readonly selectedEntries = signal<string[]>([]);
|
||||
@@ -756,10 +757,11 @@ export class DeadLetterDashboardComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
refreshData(): void {
|
||||
this.loadData();
|
||||
this.refreshing.set(true);
|
||||
this.loadData(true);
|
||||
}
|
||||
|
||||
private loadData(): void {
|
||||
private loadData(isManualRefresh = false): void {
|
||||
this.loading.set(true);
|
||||
|
||||
forkJoin({
|
||||
@@ -772,9 +774,11 @@ export class DeadLetterDashboardComponent implements OnInit, OnDestroy {
|
||||
this.stats.set(data.stats);
|
||||
this.entries.set(data.entries.items);
|
||||
this.loading.set(false);
|
||||
if (isManualRefresh) this.refreshing.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
if (isManualRefresh) this.refreshing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,8 +139,8 @@ interface DashboardStats {
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
border: 1px solid var(--color-text-primary);
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
@@ -157,7 +157,7 @@ interface DashboardStats {
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-border-primary);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
@@ -167,8 +167,8 @@ interface DashboardStats {
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
background: rgba(30, 41, 59, 0.4);
|
||||
border: 1px solid var(--color-text-primary);
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
@@ -176,7 +176,7 @@ interface DashboardStats {
|
||||
.dashboard-card h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-border-primary);
|
||||
color: var(--color-text-heading);
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
@@ -188,14 +188,14 @@ interface DashboardStats {
|
||||
|
||||
.mode-status {
|
||||
padding: 1rem;
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||
background: var(--color-status-success-bg);
|
||||
border: 1px solid var(--color-status-success-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.mode-status.offline {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
background: var(--color-status-error-bg);
|
||||
border-color: var(--color-status-error-border);
|
||||
}
|
||||
|
||||
.mode-indicator {
|
||||
@@ -247,7 +247,7 @@ interface DashboardStats {
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
@@ -267,7 +267,7 @@ interface DashboardStats {
|
||||
|
||||
.feature-name {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-border-primary);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.feature-status {
|
||||
@@ -310,7 +310,7 @@ interface DashboardStats {
|
||||
|
||||
.activity-message {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-border-primary);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
@@ -331,19 +331,19 @@ interface DashboardStats {
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
background: var(--color-status-info);
|
||||
background: var(--color-brand-primary);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.btn--secondary {
|
||||
background: var(--color-text-primary);
|
||||
color: var(--color-border-primary);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(15, 23, 42, 0.3);
|
||||
border: 2px solid var(--color-border-primary);
|
||||
border-top-color: var(--color-text-heading);
|
||||
border-radius: var(--radius-full);
|
||||
animation: spin 0.8s linear infinite;
|
||||
|
||||
@@ -236,7 +236,9 @@ export class IncidentTimelineComponent implements OnInit {
|
||||
return this.incidents().filter((incident) => {
|
||||
if (severity !== 'all' && incident.severity !== severity) return false;
|
||||
if (state !== 'all' && incident.state !== state) return false;
|
||||
if (query && !incident.title.toLowerCase().includes(query) && !incident.description.toLowerCase().includes(query)) {
|
||||
const title = incident.title ?? '';
|
||||
const description = incident.description ?? '';
|
||||
if (query && !title.toLowerCase().includes(query) && !description.toLowerCase().includes(query)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -251,7 +253,14 @@ export class IncidentTimelineComponent implements OnInit {
|
||||
this.loading.set(true);
|
||||
this.healthClient.getIncidents(this.hoursBack, this.includeResolved).subscribe({
|
||||
next: (response) => {
|
||||
this.incidents.set(response.incidents);
|
||||
const normalized = (response.incidents ?? []).map((incident) => ({
|
||||
...incident,
|
||||
title: incident.title ?? 'Untitled incident',
|
||||
description: incident.description ?? '',
|
||||
affectedServices: Array.isArray(incident.affectedServices) ? incident.affectedServices : [],
|
||||
correlatedEvents: Array.isArray(incident.correlatedEvents) ? incident.correlatedEvents : [],
|
||||
}));
|
||||
this.incidents.set(normalized);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => this.loading.set(false),
|
||||
|
||||
@@ -39,7 +39,7 @@ import { Subject, interval, takeUntil, switchMap, startWith, forkJoin } from 'rx
|
||||
(click)="refresh()"
|
||||
class="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
|
||||
>
|
||||
<span [class.animate-spin]="loading()">↻</span>
|
||||
<span [class.animate-spin]="refreshing()">↻</span>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
@@ -503,6 +503,7 @@ export class PlatformHealthDashboardComponent implements OnInit, OnDestroy {
|
||||
dependencyGraph = signal<DependencyGraph | null>(null);
|
||||
incidents = signal<Incident[]>([]);
|
||||
loading = signal(false);
|
||||
refreshing = signal(false);
|
||||
loadError = signal<string | null>(null);
|
||||
groupBy = signal<'state' | 'none'>('state');
|
||||
|
||||
@@ -575,6 +576,7 @@ export class PlatformHealthDashboardComponent implements OnInit, OnDestroy {
|
||||
|
||||
refresh(): void {
|
||||
this.loading.set(true);
|
||||
this.refreshing.set(true);
|
||||
this.loadError.set(null);
|
||||
forkJoin({
|
||||
summary: this.healthClient.getSummary(),
|
||||
@@ -587,10 +589,12 @@ export class PlatformHealthDashboardComponent implements OnInit, OnDestroy {
|
||||
this.incidents.set(incidents.incidents ?? []);
|
||||
this.loadError.set(null);
|
||||
this.loading.set(false);
|
||||
this.refreshing.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loadError.set('Unable to load platform health data. Try refreshing.');
|
||||
this.loading.set(false);
|
||||
this.refreshing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
}
|
||||
</div>
|
||||
<textarea
|
||||
id="schema-playground-editor"
|
||||
[(ngModel)]="schemaContent"
|
||||
class="editor"
|
||||
placeholder="Paste or type your risk profile schema here..."
|
||||
@@ -946,7 +947,62 @@ export class SchemaPlaygroundComponent {
|
||||
}
|
||||
|
||||
protected goToLine(path?: string): void {
|
||||
// In a real implementation, would scroll editor to the line containing the error
|
||||
console.log('Navigate to path:', path);
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = document.getElementById('schema-playground-editor') as HTMLTextAreaElement | null;
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directLine = this.extractLineNumber(path);
|
||||
const inferredLine = directLine ?? this.findLineByPath(path);
|
||||
if (!inferredLine) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = this.schemaContent.split('\n');
|
||||
const targetIndex = Math.max(0, Math.min(inferredLine - 1, lines.length - 1));
|
||||
const startOffset = lines.slice(0, targetIndex).reduce((sum, line) => sum + line.length + 1, 0);
|
||||
const endOffset = startOffset + lines[targetIndex].length;
|
||||
|
||||
editor.focus();
|
||||
editor.selectionStart = startOffset;
|
||||
editor.selectionEnd = endOffset;
|
||||
editor.scrollTop = targetIndex * 24;
|
||||
}
|
||||
|
||||
private extractLineNumber(path: string): number | null {
|
||||
const linePattern = /(?:line\s*|:)(\d+)/i;
|
||||
const match = path.match(linePattern);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const line = Number.parseInt(match[1], 10);
|
||||
return Number.isFinite(line) && line > 0 ? line : null;
|
||||
}
|
||||
|
||||
private findLineByPath(path: string): number | null {
|
||||
const normalized = path.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = normalized
|
||||
.split(/[.[\]]+/)
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.length > 0 && !/^\d+$/.test(part));
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = this.schemaContent.split('\n');
|
||||
const index = lines.findIndex((line) =>
|
||||
line.includes(`"${key}"`) || line.includes(`${key}:`)
|
||||
);
|
||||
|
||||
return index >= 0 ? index + 1 : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,11 +715,24 @@ export class SealedModeOverridesComponent implements OnInit {
|
||||
if (!hours) return;
|
||||
|
||||
const durationHours = parseInt(hours, 10);
|
||||
if (isNaN(durationHours) || durationHours < 1) return;
|
||||
if (isNaN(durationHours) || durationHours < 1 || durationHours > 168) return;
|
||||
|
||||
// In real app, would call API to extend
|
||||
console.log(`Extending override ${override.id} by ${durationHours} hours`);
|
||||
this.loadOverrides();
|
||||
this.creating.set(true);
|
||||
this.api
|
||||
.createSealedModeOverride(
|
||||
{
|
||||
type: override.type,
|
||||
target: override.target,
|
||||
reason: `Extension for ${override.id}: ${override.reason}`,
|
||||
durationHours,
|
||||
},
|
||||
{ tenantId: 'acme-tenant' }
|
||||
)
|
||||
.pipe(finalize(() => this.creating.set(false)))
|
||||
.subscribe({
|
||||
next: () => this.loadOverrides(),
|
||||
error: (err) => console.error('Failed to extend override:', err),
|
||||
});
|
||||
}
|
||||
|
||||
protected revokeOverride(override: SealedModeOverride): void {
|
||||
|
||||
@@ -107,7 +107,7 @@ import {
|
||||
type="button"
|
||||
class="source-tab"
|
||||
[class.source-tab--active]="artifactSource() === 'sbom'"
|
||||
(click)="artifactSource.set('sbom')"
|
||||
(click)="setArtifactSource('sbom')"
|
||||
>
|
||||
SBOMs
|
||||
</button>
|
||||
@@ -115,7 +115,7 @@ import {
|
||||
type="button"
|
||||
class="source-tab"
|
||||
[class.source-tab--active]="artifactSource() === 'image'"
|
||||
(click)="artifactSource.set('image')"
|
||||
(click)="setArtifactSource('image')"
|
||||
>
|
||||
Images
|
||||
</button>
|
||||
@@ -123,7 +123,7 @@ import {
|
||||
type="button"
|
||||
class="source-tab"
|
||||
[class.source-tab--active]="artifactSource() === 'repository'"
|
||||
(click)="artifactSource.set('repository')"
|
||||
(click)="setArtifactSource('repository')"
|
||||
>
|
||||
Repositories
|
||||
</button>
|
||||
@@ -1129,6 +1129,7 @@ export class BatchEvaluationComponent implements OnInit, OnDestroy {
|
||||
readonly selectedArtifacts = signal<BatchEvaluationArtifact[]>([]);
|
||||
readonly tags = signal<string[]>([]);
|
||||
readonly currentBatch = signal<BatchEvaluationResult | undefined>(undefined);
|
||||
readonly allHistoryEntries = signal<BatchEvaluationHistoryEntry[]>([]);
|
||||
readonly historyEntries = signal<BatchEvaluationHistoryEntry[]>([]);
|
||||
readonly filteredArtifacts = signal<BatchEvaluationArtifact[]>([]);
|
||||
|
||||
@@ -1194,6 +1195,11 @@ export class BatchEvaluationComponent implements OnInit, OnDestroy {
|
||||
this.updateFilteredArtifacts(input.value);
|
||||
}
|
||||
|
||||
setArtifactSource(source: 'sbom' | 'image' | 'repository'): void {
|
||||
this.artifactSource.set(source);
|
||||
this.updateFilteredArtifacts();
|
||||
}
|
||||
|
||||
isArtifactSelected(artifactId: string): boolean {
|
||||
return this.selectedArtifacts().some(a => a.artifactId === artifactId);
|
||||
}
|
||||
@@ -1402,14 +1408,26 @@ export class BatchEvaluationComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
exportResults(): void {
|
||||
console.log('Exporting results:', this.currentBatch());
|
||||
// Would trigger download of results
|
||||
const current = this.currentBatch();
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify(current, null, 2);
|
||||
const blob = new Blob([payload], { type: 'application/json;charset=utf-8' });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = `${current.batchId}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
|
||||
startNewEvaluation(): void {
|
||||
this.currentBatch.set(undefined);
|
||||
this.selectedArtifacts.set([]);
|
||||
this.tags.set([]);
|
||||
this.activeTab.set('new');
|
||||
}
|
||||
|
||||
loadHistory(): void {
|
||||
@@ -1457,6 +1475,7 @@ export class BatchEvaluationComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
];
|
||||
|
||||
this.allHistoryEntries.set(mockHistory);
|
||||
this.historyEntries.set(mockHistory);
|
||||
}
|
||||
|
||||
@@ -1464,12 +1483,45 @@ export class BatchEvaluationComponent implements OnInit, OnDestroy {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const status = select.value;
|
||||
|
||||
// Would filter history entries by status
|
||||
console.log('Filter by status:', status);
|
||||
const allEntries = this.allHistoryEntries();
|
||||
if (!status) {
|
||||
this.historyEntries.set(allEntries);
|
||||
return;
|
||||
}
|
||||
|
||||
this.historyEntries.set(allEntries.filter((entry) => entry.status === status));
|
||||
}
|
||||
|
||||
loadBatchDetails(batchId: string): void {
|
||||
console.log('Loading batch details:', batchId);
|
||||
// Would load full batch result and switch to results view
|
||||
this.api.getBatchEvaluation(batchId, { tenantId: 'default' }).subscribe({
|
||||
next: (batch) => {
|
||||
this.currentBatch.set(batch);
|
||||
this.activeTab.set('new');
|
||||
},
|
||||
error: () => {
|
||||
const historyEntry = this.allHistoryEntries().find((entry) => entry.batchId === batchId);
|
||||
if (!historyEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentBatch.set({
|
||||
batchId: historyEntry.batchId,
|
||||
status: historyEntry.status,
|
||||
policyPackId: historyEntry.policyPackId,
|
||||
policyVersion: historyEntry.policyVersion,
|
||||
totalArtifacts: historyEntry.totalArtifacts,
|
||||
completedArtifacts: historyEntry.passed + historyEntry.failed + historyEntry.blocked,
|
||||
failedArtifacts: historyEntry.failed,
|
||||
passedArtifacts: historyEntry.passed,
|
||||
warnedArtifacts: 0,
|
||||
blockedArtifacts: historyEntry.blocked,
|
||||
results: [],
|
||||
startedAt: historyEntry.startedAt,
|
||||
completedAt: historyEntry.completedAt,
|
||||
tags: historyEntry.tags ? [...historyEntry.tags] : undefined,
|
||||
});
|
||||
this.activeTab.set('new');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1239,32 +1239,153 @@ export class ConflictDetectionComponent implements OnInit {
|
||||
}
|
||||
|
||||
selectSuggestion(conflict: PolicyConflict, suggestion: ResolutionSuggestion): void {
|
||||
// In a real implementation, this would update the conflict's selectedResolution
|
||||
console.log('Selected suggestion:', suggestion.id, 'for conflict:', conflict.id);
|
||||
this.updateConflict(conflict.id, current => ({
|
||||
...current,
|
||||
selectedResolution: suggestion.id,
|
||||
}));
|
||||
}
|
||||
|
||||
applyResolution(conflict: PolicyConflict): void {
|
||||
console.log('Applying resolution for conflict:', conflict.id);
|
||||
// Would call API to apply the resolution
|
||||
const selectedSuggestion = conflict.suggestions.find(
|
||||
suggestion => suggestion.id === conflict.selectedResolution
|
||||
);
|
||||
if (!selectedSuggestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedValue =
|
||||
selectedSuggestion.suggestedValue ??
|
||||
(selectedSuggestion.action === 'use_source'
|
||||
? conflict.sourceValue
|
||||
: selectedSuggestion.action === 'use_target'
|
||||
? conflict.targetValue
|
||||
: conflict.targetValue);
|
||||
|
||||
this.updateConflict(conflict.id, current => ({
|
||||
...current,
|
||||
isResolved: true,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
resolvedBy: 'current-user',
|
||||
resolvedValue,
|
||||
}));
|
||||
}
|
||||
|
||||
openManualResolution(conflict: PolicyConflict): void {
|
||||
console.log('Opening manual resolution for conflict:', conflict.id);
|
||||
// Would open a modal for manual resolution input
|
||||
const candidate = prompt(
|
||||
'Enter resolved JSON value for this conflict:',
|
||||
JSON.stringify(conflict.targetValue, null, 2)
|
||||
);
|
||||
|
||||
if (candidate === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedValue: unknown = candidate;
|
||||
if (candidate.trim().length > 0) {
|
||||
try {
|
||||
parsedValue = JSON.parse(candidate);
|
||||
} catch {
|
||||
// Keep raw string when the value is not valid JSON.
|
||||
}
|
||||
}
|
||||
|
||||
this.updateConflict(conflict.id, current => ({
|
||||
...current,
|
||||
selectedResolution: 'manual',
|
||||
isResolved: true,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
resolvedBy: 'current-user',
|
||||
resolvedValue: parsedValue,
|
||||
}));
|
||||
}
|
||||
|
||||
skipConflict(conflict: PolicyConflict): void {
|
||||
console.log('Skipping conflict:', conflict.id);
|
||||
this.toggleExpand(conflict.id);
|
||||
if (this.isExpanded(conflict.id)) {
|
||||
this.toggleExpand(conflict.id);
|
||||
}
|
||||
}
|
||||
|
||||
reopenConflict(conflict: PolicyConflict): void {
|
||||
console.log('Reopening conflict:', conflict.id);
|
||||
// Would call API to reopen the conflict
|
||||
this.updateConflict(conflict.id, current => ({
|
||||
...current,
|
||||
isResolved: false,
|
||||
resolvedAt: undefined,
|
||||
resolvedBy: undefined,
|
||||
resolvedValue: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
autoResolveAll(): void {
|
||||
console.log('Auto-resolving all conflicts');
|
||||
// Would call API to auto-resolve all auto-resolvable conflicts
|
||||
const currentResult = this.detectionResult();
|
||||
if (!currentResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedConflicts = currentResult.conflicts.map((conflict) => {
|
||||
if (conflict.isResolved || conflict.suggestions.length === 0) {
|
||||
return conflict;
|
||||
}
|
||||
|
||||
const bestSuggestion = [...conflict.suggestions].sort(
|
||||
(left, right) => right.confidence - left.confidence
|
||||
)[0];
|
||||
const resolvedValue =
|
||||
bestSuggestion.suggestedValue ??
|
||||
(bestSuggestion.action === 'use_source' ? conflict.sourceValue : conflict.targetValue);
|
||||
|
||||
return {
|
||||
...conflict,
|
||||
selectedResolution: bestSuggestion.id,
|
||||
isResolved: true,
|
||||
resolvedAt: new Date().toISOString(),
|
||||
resolvedBy: 'auto-resolver',
|
||||
resolvedValue,
|
||||
};
|
||||
});
|
||||
|
||||
this.setConflicts(updatedConflicts);
|
||||
}
|
||||
|
||||
private updateConflict(
|
||||
conflictId: string,
|
||||
updater: (conflict: PolicyConflict) => PolicyConflict
|
||||
): void {
|
||||
const currentResult = this.detectionResult();
|
||||
if (!currentResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedConflicts = currentResult.conflicts.map((conflict) =>
|
||||
conflict.id === conflictId ? updater(conflict) : conflict
|
||||
);
|
||||
this.setConflicts(updatedConflicts);
|
||||
}
|
||||
|
||||
private setConflicts(conflicts: readonly PolicyConflict[]): void {
|
||||
const currentResult = this.detectionResult();
|
||||
if (!currentResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unresolved = conflicts.filter((conflict) => !conflict.isResolved);
|
||||
const autoResolvableCount = unresolved.filter(
|
||||
(conflict) => conflict.suggestions.length > 0
|
||||
).length;
|
||||
const manualResolutionRequired = unresolved.filter(
|
||||
(conflict) => conflict.suggestions.length === 0
|
||||
).length;
|
||||
|
||||
this.detectionResult.set({
|
||||
...currentResult,
|
||||
conflicts: [...conflicts],
|
||||
totalConflicts: conflicts.length,
|
||||
criticalCount: conflicts.filter((conflict) => conflict.severity === 'critical').length,
|
||||
highCount: conflicts.filter((conflict) => conflict.severity === 'high').length,
|
||||
mediumCount: conflicts.filter((conflict) => conflict.severity === 'medium').length,
|
||||
lowCount: conflicts.filter((conflict) => conflict.severity === 'low').length,
|
||||
autoResolvableCount,
|
||||
manualResolutionRequired,
|
||||
});
|
||||
this.applyFilters();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, Input, OnChanges, SimpleChanges } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
@@ -631,6 +632,7 @@ import {
|
||||
export class PromotionGateComponent implements OnChanges {
|
||||
private readonly api = inject(POLICY_SIMULATION_API);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
@Input() policyPackId = 'policy-pack-001';
|
||||
@Input() policyVersion = 2;
|
||||
@@ -688,8 +690,13 @@ export class PromotionGateComponent implements OnChanges {
|
||||
}
|
||||
|
||||
onPromote(): void {
|
||||
// Would trigger actual promotion logic
|
||||
console.log('Promoting policy to', this.targetEnvironment);
|
||||
void this.router.navigate(['/policy/packs'], {
|
||||
queryParams: {
|
||||
promotedPack: this.policyPackId,
|
||||
promotedVersion: this.policyVersion,
|
||||
targetEnvironment: this.targetEnvironment,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
formatStatus(status: GateCheckStatus): string {
|
||||
|
||||
@@ -1200,8 +1200,37 @@ export class SimulationHistoryComponent implements OnInit {
|
||||
}
|
||||
|
||||
togglePin(entry: SimulationHistoryEntry): void {
|
||||
// Would call API to toggle pin status
|
||||
console.log('Toggle pin for', entry.simulationId);
|
||||
this.loadHistory();
|
||||
const nextPinned = !entry.pinned;
|
||||
const currentResult = this.historyResult();
|
||||
if (!currentResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.historyResult.set({
|
||||
...currentResult,
|
||||
items: currentResult.items.map((item) =>
|
||||
item.simulationId === entry.simulationId
|
||||
? { ...item, pinned: nextPinned }
|
||||
: item
|
||||
),
|
||||
});
|
||||
|
||||
this.api.pinSimulation(entry.simulationId, nextPinned, { tenantId: 'default' }).subscribe({
|
||||
error: () => {
|
||||
const rollbackResult = this.historyResult();
|
||||
if (!rollbackResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.historyResult.set({
|
||||
...rollbackResult,
|
||||
items: rollbackResult.items.map((item) =>
|
||||
item.simulationId === entry.simulationId
|
||||
? { ...item, pinned: entry.pinned }
|
||||
: item
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { firstValueFrom, of } from 'rxjs';
|
||||
import { take } from 'rxjs/operators';
|
||||
|
||||
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
|
||||
import { PolicyPackSummary } from '../models/policy.models';
|
||||
import { PolicyApiService } from './policy-api.service';
|
||||
import { PolicyPackStore } from './policy-pack.store';
|
||||
|
||||
describe('PolicyPackStore', () => {
|
||||
let store: PolicyPackStore;
|
||||
let api: jasmine.SpyObj<PolicyApiService>;
|
||||
let isAuthenticatedSpy: jasmine.Spy<() => boolean>;
|
||||
let sessionSpy: jasmine.Spy<() => { scopes?: string[] } | null>;
|
||||
|
||||
const apiPacks: PolicyPackSummary[] = [
|
||||
{
|
||||
id: 'pack-core',
|
||||
name: 'Core Policy Pack',
|
||||
description: 'Default baseline.',
|
||||
version: '2026.02.17',
|
||||
status: 'active',
|
||||
createdAt: '2026-02-17T00:00:00Z',
|
||||
modifiedAt: '2026-02-17T00:00:00Z',
|
||||
createdBy: 'system',
|
||||
modifiedBy: 'system',
|
||||
tags: ['baseline'],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
|
||||
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['listPacks']);
|
||||
isAuthenticatedSpy = jasmine.createSpy<() => boolean>('isAuthenticated').and.returnValue(false);
|
||||
sessionSpy = jasmine.createSpy<() => { scopes?: string[] } | null>('session').and.returnValue(null);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
PolicyPackStore,
|
||||
{ provide: PolicyApiService, useValue: api },
|
||||
{
|
||||
provide: AuthSessionStore,
|
||||
useValue: {
|
||||
isAuthenticated: isAuthenticatedSpy,
|
||||
session: sessionSpy,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
store = TestBed.inject(PolicyPackStore);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('returns fallback packs without API call when unauthenticated', async () => {
|
||||
const packs = await firstValueFrom(store.getPacks().pipe(take(1)));
|
||||
|
||||
expect(api.listPacks).not.toHaveBeenCalled();
|
||||
expect(packs.length).toBe(1);
|
||||
expect(packs[0]?.name).toBe('Core Policy Pack');
|
||||
});
|
||||
|
||||
it('fetches packs from API when authenticated with policy:read scope', async () => {
|
||||
isAuthenticatedSpy.and.returnValue(true);
|
||||
sessionSpy.and.returnValue({ scopes: ['policy:read'] });
|
||||
api.listPacks.and.returnValue(of(apiPacks));
|
||||
|
||||
const packs = await firstValueFrom(store.getPacks().pipe(take(1)));
|
||||
|
||||
expect(api.listPacks).toHaveBeenCalledWith({ limit: 50 });
|
||||
expect(packs).toEqual(apiPacks);
|
||||
});
|
||||
|
||||
it('refreshes fallback packs with API data after auth becomes available', async () => {
|
||||
const fallback = await firstValueFrom(store.getPacks().pipe(take(1)));
|
||||
expect(fallback[0]?.version).toBe('latest');
|
||||
expect(api.listPacks).not.toHaveBeenCalled();
|
||||
|
||||
isAuthenticatedSpy.and.returnValue(true);
|
||||
sessionSpy.and.returnValue({ scopes: ['policy:read'] });
|
||||
api.listPacks.and.returnValue(of(apiPacks));
|
||||
|
||||
const refreshed = await firstValueFrom(store.getPacks().pipe(take(1)));
|
||||
|
||||
expect(api.listPacks).toHaveBeenCalledTimes(1);
|
||||
expect(refreshed).toEqual(apiPacks);
|
||||
});
|
||||
|
||||
it('does not trigger API fetch when remote loading is disabled', async () => {
|
||||
isAuthenticatedSpy.and.returnValue(true);
|
||||
sessionSpy.and.returnValue({ scopes: ['policy:read'] });
|
||||
api.listPacks.and.returnValue(of(apiPacks));
|
||||
|
||||
const packs = await firstValueFrom(
|
||||
store.getPacks({ allowRemoteFetch: false }).pipe(take(1))
|
||||
);
|
||||
|
||||
expect(api.listPacks).not.toHaveBeenCalled();
|
||||
expect(packs.length).toBe(1);
|
||||
expect(packs[0]?.name).toBe('Core Policy Pack');
|
||||
});
|
||||
});
|
||||
@@ -4,17 +4,28 @@ import { filter } from 'rxjs/operators';
|
||||
|
||||
import { PolicyPackSummary } from '../models/policy.models';
|
||||
import { PolicyApiService } from './policy-api.service';
|
||||
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PolicyPackStore {
|
||||
private readonly api = inject(PolicyApiService);
|
||||
private readonly authStore = inject(AuthSessionStore);
|
||||
private readonly packs$ = new BehaviorSubject<PolicyPackSummary[] | null>(null);
|
||||
private loading = false;
|
||||
private usingFallback = false;
|
||||
private readonly cacheKey = 'policy-studio:packs-cache';
|
||||
|
||||
getPacks(): Observable<PolicyPackSummary[]> {
|
||||
if (!this.packs$.value && !this.loading) {
|
||||
this.fetch();
|
||||
getPacks(options: { allowRemoteFetch?: boolean } = {}): Observable<PolicyPackSummary[]> {
|
||||
const allowRemoteFetch = options.allowRemoteFetch ?? true;
|
||||
if (!this.loading) {
|
||||
const hasNoData = !this.packs$.value;
|
||||
if (allowRemoteFetch && (hasNoData || (this.usingFallback && this.canReadPolicyPacks()))) {
|
||||
this.fetch();
|
||||
} else if (hasNoData) {
|
||||
const cached = this.readCache();
|
||||
this.usingFallback = !cached;
|
||||
this.packs$.next(cached ?? this.fallbackPacks());
|
||||
}
|
||||
}
|
||||
|
||||
return this.packs$.pipe(filter((p): p is PolicyPackSummary[] => Array.isArray(p)));
|
||||
@@ -25,9 +36,18 @@ export class PolicyPackStore {
|
||||
}
|
||||
|
||||
private fetch(): void {
|
||||
if (!this.canReadPolicyPacks()) {
|
||||
this.usingFallback = true;
|
||||
this.packs$.next(this.fallbackPacks());
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
let fetchedFromFallback = false;
|
||||
const cached = this.readCache();
|
||||
if (cached) {
|
||||
this.usingFallback = false;
|
||||
this.packs$.next(cached);
|
||||
this.loading = false;
|
||||
return;
|
||||
@@ -36,15 +56,28 @@ export class PolicyPackStore {
|
||||
this.api
|
||||
.listPacks({ limit: 50 })
|
||||
.pipe(
|
||||
catchError(() => of(this.fallbackPacks())),
|
||||
catchError(() => {
|
||||
fetchedFromFallback = true;
|
||||
return of(this.fallbackPacks());
|
||||
}),
|
||||
finalize(() => (this.loading = false))
|
||||
)
|
||||
.subscribe((packs) => {
|
||||
this.usingFallback = fetchedFromFallback;
|
||||
this.packs$.next(packs);
|
||||
this.writeCache(packs);
|
||||
});
|
||||
}
|
||||
|
||||
private canReadPolicyPacks(): boolean {
|
||||
if (!this.authStore.isAuthenticated()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const scopes = this.authStore.session()?.scopes ?? [];
|
||||
return scopes.includes('policy:read');
|
||||
}
|
||||
|
||||
private fallbackPacks(): PolicyPackSummary[] {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -197,7 +197,7 @@ type SortOrder = 'asc' | 'desc';
|
||||
<td>
|
||||
<a
|
||||
class="policy-studio__link"
|
||||
[routerLink]="['/policy/profiles', profile.profileId]"
|
||||
[routerLink]="['/policy/governance/profiles', profile.profileId]"
|
||||
>
|
||||
{{ profile.profileId }}
|
||||
</a>
|
||||
@@ -1090,7 +1090,7 @@ export class PolicyStudioComponent implements OnInit {
|
||||
viewProfile(profile: RiskProfileSummary): void {
|
||||
this.store.loadProfile(profile.profileId, { tenantId: this.tenantId });
|
||||
this.store.loadProfileVersions(profile.profileId, { tenantId: this.tenantId });
|
||||
this.router.navigate(['/policy/profiles', profile.profileId]);
|
||||
this.router.navigate(['/policy/governance/profiles', profile.profileId]);
|
||||
}
|
||||
|
||||
simulateWithProfile(profile: RiskProfileSummary): void {
|
||||
@@ -1099,7 +1099,7 @@ export class PolicyStudioComponent implements OnInit {
|
||||
}
|
||||
|
||||
viewPack(pack: PolicyPackSummary): void {
|
||||
this.router.navigate(['/policy/packs', pack.packId]);
|
||||
this.router.navigate(['/policy-studio/packs', pack.packId, 'dashboard']);
|
||||
}
|
||||
|
||||
createRevision(pack: PolicyPackSummary): void {
|
||||
@@ -1112,11 +1112,11 @@ export class PolicyStudioComponent implements OnInit {
|
||||
}
|
||||
|
||||
openCreateProfile(): void {
|
||||
this.router.navigate(['/policy/profiles/new']);
|
||||
this.router.navigate(['/policy/governance/profiles/new']);
|
||||
}
|
||||
|
||||
openCreatePack(): void {
|
||||
this.router.navigate(['/policy/packs/new']);
|
||||
this.router.navigate(['/policy-studio/packs']);
|
||||
}
|
||||
|
||||
runSimulation(): void {
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
Export Report
|
||||
</button>
|
||||
<button class="btn btn-icon" (click)="refreshData()" [disabled]="loading()">
|
||||
<span class="icon" [class.spinning]="loading()">refresh</span>
|
||||
<span class="icon" [class.spinning]="refreshing()">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -902,6 +902,7 @@ export class QuotaDashboardComponent implements OnInit, OnDestroy {
|
||||
private readonly refreshInterval = 30000; // 30 seconds
|
||||
|
||||
readonly loading = signal(false);
|
||||
readonly refreshing = signal(false);
|
||||
readonly summary = signal<QuotaDashboardSummary | null>(null);
|
||||
readonly consumptionHistory = signal<Array<{ timestamp: string; percentage: number }>>([]);
|
||||
readonly forecasts = signal<QuotaForecast[]>([]);
|
||||
@@ -935,10 +936,11 @@ export class QuotaDashboardComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
refreshData(): void {
|
||||
this.loadData();
|
||||
this.refreshing.set(true);
|
||||
this.loadData(true);
|
||||
}
|
||||
|
||||
private loadData(): void {
|
||||
private loadData(isManualRefresh = false): void {
|
||||
this.loading.set(true);
|
||||
|
||||
forkJoin({
|
||||
@@ -957,9 +959,11 @@ export class QuotaDashboardComponent implements OnInit, OnDestroy {
|
||||
this.topTenants.set(data.tenants.items);
|
||||
this.recentViolations.set(data.violations.items);
|
||||
this.loading.set(false);
|
||||
if (isManualRefresh) this.refreshing.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
if (isManualRefresh) this.refreshing.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -929,8 +929,24 @@ export class ReleaseDetailComponent implements OnInit {
|
||||
}
|
||||
|
||||
showConfigDialog(comp: ReleaseComponent): void {
|
||||
// TODO: Implement config dialog
|
||||
alert('Config dialog not yet implemented');
|
||||
const release = this.release();
|
||||
if (!release) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = prompt(`Configuration key for "${comp.name}"`, '');
|
||||
if (key === null || key.trim().length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = prompt(`Value for "${key.trim()}"`, '');
|
||||
if (value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.store.updateComponentConfig(release.id, comp.id, {
|
||||
[key.trim()]: value,
|
||||
});
|
||||
}
|
||||
|
||||
confirmPromote(): void {
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* Sprint: SPRINT_20260118_007_FE_security_consolidation (SEC-007)
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy, Inject, OnInit, signal } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, Inject, OnInit, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { EXCEPTION_API, type ExceptionApi } from '../../core/api/exception.client';
|
||||
|
||||
@Component({
|
||||
@@ -85,6 +85,7 @@ import { EXCEPTION_API, type ExceptionApi } from '../../core/api/exception.clien
|
||||
`]
|
||||
})
|
||||
export class ExceptionsPageComponent implements OnInit {
|
||||
private readonly router = inject(Router);
|
||||
exceptions = signal<Array<{ id: string; finding: string; reason: string; requestedBy: string; approvedBy: string | null; expires: string; status: string }>>([]);
|
||||
loading = signal(true);
|
||||
error = signal<string | null>(null);
|
||||
@@ -121,6 +122,8 @@ export class ExceptionsPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
requestException(): void {
|
||||
console.log('Request exception');
|
||||
void this.router.navigate(['/policy/exceptions'], {
|
||||
queryParams: { create: '1' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { Component, ChangeDetectionStrategy, OnInit, inject, signal, computed } from '@angular/core';
|
||||
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Router, 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';
|
||||
@@ -355,6 +355,7 @@ interface Finding {
|
||||
})
|
||||
export class SecurityFindingsPageComponent implements OnInit {
|
||||
private readonly findingsApi = inject(SECURITY_FINDINGS_API);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
@@ -470,14 +471,78 @@ export class SecurityFindingsPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
openWitness(finding: Finding): void {
|
||||
console.log('Open witness for:', finding.id);
|
||||
void this.router.navigate(['/security/reachability'], {
|
||||
queryParams: { findingId: finding.id },
|
||||
});
|
||||
}
|
||||
|
||||
requestException(finding: Finding): void {
|
||||
console.log('Request exception for:', finding.id);
|
||||
void this.router.navigate(['/policy/exceptions'], {
|
||||
queryParams: {
|
||||
cveId: finding.id,
|
||||
package: finding.package,
|
||||
severity: finding.severity.toLowerCase(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
exportFindings(): void {
|
||||
console.log('Export findings to CSV');
|
||||
const findings = this.filteredFindings();
|
||||
if (findings.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = [
|
||||
'cve_id',
|
||||
'package',
|
||||
'version',
|
||||
'severity',
|
||||
'cvss',
|
||||
'reachable',
|
||||
'reachability_confidence',
|
||||
'vex_status',
|
||||
'release_id',
|
||||
'release_version',
|
||||
'delta',
|
||||
'environments',
|
||||
'first_seen',
|
||||
];
|
||||
|
||||
const escapeCsv = (value: unknown): string => {
|
||||
const text = String(value ?? '');
|
||||
if (text.includes(',') || text.includes('"') || text.includes('\n')) {
|
||||
return `"${text.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const rows = findings.map((finding) => [
|
||||
finding.id,
|
||||
finding.package,
|
||||
finding.version,
|
||||
finding.severity,
|
||||
finding.cvss,
|
||||
finding.reachable === null ? 'unknown' : finding.reachable ? 'reachable' : 'unreachable',
|
||||
finding.reachabilityConfidence ?? '',
|
||||
finding.vexStatus,
|
||||
finding.releaseId,
|
||||
finding.releaseVersion,
|
||||
finding.delta,
|
||||
finding.environments.join('|'),
|
||||
finding.firstSeen,
|
||||
]);
|
||||
|
||||
const csv = [
|
||||
headers.join(','),
|
||||
...rows.map((row) => row.map((value) => escapeCsv(value)).join(',')),
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = `security-findings-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { Component, ChangeDetectionStrategy, OnInit, inject, signal } from '@angular/core';
|
||||
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { catchError, of } from 'rxjs';
|
||||
import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
|
||||
|
||||
@@ -59,11 +59,11 @@ import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>Recent Findings</h3>
|
||||
<a routerLink="./findings" class="panel-link">View All →</a>
|
||||
<a routerLink="/security/findings" class="panel-link">View All →</a>
|
||||
</div>
|
||||
<div class="findings-list">
|
||||
@for (finding of recentFindings; track finding.id) {
|
||||
<a class="finding-item" [routerLink]="['./findings', finding.id]">
|
||||
<a class="finding-item" [routerLink]="['/security/findings', finding.id]">
|
||||
<span class="severity-dot" [class]="'severity-dot--' + finding.severity.toLowerCase()"></span>
|
||||
<div class="finding-info">
|
||||
<span class="finding-id">{{ finding.id }}</span>
|
||||
@@ -104,7 +104,7 @@ import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>VEX Coverage</h3>
|
||||
<a routerLink="./vex" class="panel-link">Manage VEX →</a>
|
||||
<a routerLink="/security/vex" class="panel-link">Manage VEX →</a>
|
||||
</div>
|
||||
<div class="vex-stats">
|
||||
<div class="vex-stat">
|
||||
@@ -126,7 +126,7 @@ import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>Active Exceptions</h3>
|
||||
<a routerLink="./exceptions" class="panel-link">Manage →</a>
|
||||
<a routerLink="/security/exceptions" class="panel-link">Manage →</a>
|
||||
</div>
|
||||
<div class="exceptions-list">
|
||||
@for (exception of activeExceptions; track exception.id) {
|
||||
@@ -295,6 +295,7 @@ import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
|
||||
})
|
||||
export class SecurityOverviewPageComponent implements OnInit {
|
||||
private readonly overviewApi = inject(SECURITY_OVERVIEW_API);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly error = signal<string | null>(null);
|
||||
@@ -340,6 +341,7 @@ export class SecurityOverviewPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
runScan(): void {
|
||||
console.log('Run security scan');
|
||||
void this.router.navigateByUrl('/ops/scanner');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -194,10 +194,10 @@ import { FormsModule } from '@angular/forms';
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
.status-badge--not-affected { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
|
||||
.status-badge--affected { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); }
|
||||
.status-badge--fixed { background: var(--color-severity-info-bg); color: var(--color-status-info-text); }
|
||||
.status-badge--under-investigation { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); }
|
||||
.status-badge--not-affected { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.status-badge--affected { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
|
||||
.status-badge--fixed { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
|
||||
.status-badge--under-investigation { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
|
||||
|
||||
.justification {
|
||||
max-width: 250px;
|
||||
@@ -216,8 +216,8 @@ import { FormsModule } from '@angular/forms';
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.source-badge--vendor { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
|
||||
.source-badge--internal { background: var(--color-severity-info-bg); color: var(--color-status-info-text); }
|
||||
.source-badge--community { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
|
||||
.source-badge--internal { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
|
||||
.source-badge--community { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
|
||||
interface DeployedEnvironment {
|
||||
name: string;
|
||||
@@ -515,6 +515,7 @@ interface GateImpact {
|
||||
})
|
||||
export class VulnerabilityDetailPageComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
findingId = signal('');
|
||||
|
||||
@@ -587,26 +588,49 @@ export class VulnerabilityDetailPageComponent implements OnInit {
|
||||
}
|
||||
|
||||
openEvidence(): void {
|
||||
console.log('Opening evidence for:', this.vuln().id);
|
||||
void this.router.navigate(['/evidence'], {
|
||||
queryParams: {
|
||||
subject: this.vuln().id,
|
||||
source: 'security-vulnerability-detail',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openWitness(): void {
|
||||
console.log('Opening witness for:', this.vuln().id);
|
||||
void this.router.navigate(['/security/reachability'], {
|
||||
queryParams: { cveId: this.vuln().id },
|
||||
});
|
||||
}
|
||||
|
||||
updateVex(): void {
|
||||
console.log('Update VEX statement');
|
||||
void this.router.navigate(['/security/vex'], {
|
||||
queryParams: { cve: this.vuln().id },
|
||||
});
|
||||
}
|
||||
|
||||
requestException(): void {
|
||||
console.log('Request exception');
|
||||
void this.router.navigate(['/policy/exceptions'], {
|
||||
queryParams: {
|
||||
cveId: this.vuln().id,
|
||||
package: this.vuln().package,
|
||||
severity: this.vuln().severity.toLowerCase(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
createRemediationTask(): void {
|
||||
console.log('Create remediation task');
|
||||
void this.router.navigate(['/operations/orchestrator/jobs'], {
|
||||
queryParams: {
|
||||
action: 'remediate',
|
||||
cveId: this.vuln().id,
|
||||
package: this.vuln().package,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
viewRelatedReleases(): void {
|
||||
console.log('View related releases');
|
||||
void this.router.navigate(['/releases'], {
|
||||
queryParams: { cveId: this.vuln().id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
* Integration hub with KPI strip and list of integrations.
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
|
||||
interface Integration {
|
||||
id: string;
|
||||
@@ -345,6 +345,7 @@ interface Integration {
|
||||
`]
|
||||
})
|
||||
export class IntegrationsSettingsPageComponent {
|
||||
private readonly router = inject(Router);
|
||||
filterType = signal<string | null>(null);
|
||||
|
||||
integrations = signal<Integration[]>([
|
||||
@@ -373,7 +374,6 @@ export class IntegrationsSettingsPageComponent {
|
||||
}
|
||||
|
||||
openAddWizard(): void {
|
||||
// TODO: Open integration wizard modal
|
||||
console.log('Open add integration wizard');
|
||||
this.router.navigate(['/integrations/onboarding/registry']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-governance-settings-page',
|
||||
imports: [],
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="policy-settings">
|
||||
@@ -19,25 +20,25 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
<section class="settings-section">
|
||||
<h2>Policy Baselines</h2>
|
||||
<p>Manage policy baselines for different environments.</p>
|
||||
<button type="button" class="btn btn--primary">+ Create Baseline</button>
|
||||
<a routerLink="/policy/packs" class="btn btn--primary">+ Create Baseline</a>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Governance Rules</h2>
|
||||
<p>Define organizational governance rules for releases.</p>
|
||||
<button type="button" class="btn btn--secondary">Edit Rules</button>
|
||||
<a routerLink="/policy/governance" class="btn btn--secondary">Edit Rules</a>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Policy Simulation</h2>
|
||||
<p>Test policy changes before applying them.</p>
|
||||
<button type="button" class="btn btn--secondary">Run Simulation</button>
|
||||
<a routerLink="/policy/simulation" class="btn btn--secondary">Run Simulation</a>
|
||||
</section>
|
||||
|
||||
<section class="settings-section">
|
||||
<h2>Exception Workflow</h2>
|
||||
<p>Configure how policy exceptions are requested and approved.</p>
|
||||
<button type="button" class="btn btn--secondary">Configure Workflow</button>
|
||||
<a routerLink="/policy/exceptions" class="btn btn--secondary">Configure Workflow</a>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,7 +60,16 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
}
|
||||
.settings-section h2 { margin: 0 0 0.5rem; font-size: 1rem; font-weight: var(--font-weight-semibold); }
|
||||
.settings-section p { margin: 0 0 1rem; font-size: 0.875rem; color: var(--color-text-secondary); }
|
||||
.btn { padding: 0.375rem 0.75rem; border-radius: var(--radius-md); font-size: 0.875rem; cursor: pointer; }
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn--primary { background: var(--color-brand-primary); border: none; color: var(--color-text-heading); }
|
||||
.btn--secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); }
|
||||
`]
|
||||
|
||||
@@ -1345,7 +1345,7 @@ import {
|
||||
[class.selected]="sourceFeedMode() === 'mirror'"
|
||||
(click)="selectSourceFeedMode('mirror')">
|
||||
<span class="provider-card-name">Stella Ops Mirror</span>
|
||||
<span class="provider-card-desc">Pre-aggregated advisory feeds from Stella Ops cloud. Includes NVD, GHSA, OSV, and distribution-specific advisories. Recommended for most deployments.</span>
|
||||
<span class="provider-card-desc">Pre-aggregated advisory feeds from Stella Ops cloud. Includes NVD, GHSA, OSV, and distribution-specific advisories. Use when an external or internal mirror endpoint is reachable from this environment.</span>
|
||||
</button>
|
||||
<button class="provider-card"
|
||||
[class.selected]="sourceFeedMode() === 'custom'"
|
||||
@@ -1359,15 +1359,15 @@ import {
|
||||
@if (sourceFeedMode() === 'mirror') {
|
||||
<div class="subsection" style="margin-top: 16px;">
|
||||
<h4>Mirror Configuration</h4>
|
||||
<p class="section-hint">Connect to the Stella Ops advisory mirror. Anonymous access is available; credentials unlock higher rate limits and priority feeds.</p>
|
||||
<p class="section-hint">Connect to a reachable advisory mirror endpoint. For local/offline setup, prefer Custom Feed Sources unless a mirror URL is explicitly available.</p>
|
||||
<div class="form-group">
|
||||
<label for="sources-mirror-url">Mirror URL</label>
|
||||
<input
|
||||
type="text"
|
||||
id="sources-mirror-url"
|
||||
[value]="getConfigValue('sources.mirror.url') || 'https://mirror.stella-ops.org/feeds'"
|
||||
[value]="getConfigValue('sources.mirror.url') || ''"
|
||||
(input)="onInputChange('sources.mirror.url', $event)"
|
||||
placeholder="https://mirror.stella-ops.org/feeds"
|
||||
placeholder="https://<mirror-host>/feeds"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -3095,8 +3095,7 @@ export class StepContentComponent {
|
||||
'crypto.provider': 'default',
|
||||
},
|
||||
sources: {
|
||||
'sources.mode': 'mirror',
|
||||
'sources.mirror.url': 'https://mirror.stella-ops.org/feeds',
|
||||
'sources.mode': 'custom',
|
||||
},
|
||||
telemetry: {
|
||||
'telemetry.otlpEndpoint': 'http://localhost:4317',
|
||||
@@ -3417,10 +3416,6 @@ export class StepContentComponent {
|
||||
selectSourceFeedMode(mode: 'mirror' | 'custom'): void {
|
||||
this.sourceFeedMode.set(mode);
|
||||
this.configChange.emit({ key: 'sources.mode', value: mode });
|
||||
if (mode === 'mirror') {
|
||||
// Clear individual source selections when switching to mirror
|
||||
this.configChange.emit({ key: 'sources.mirror.url', value: 'https://mirror.stella-ops.org/feeds' });
|
||||
}
|
||||
}
|
||||
|
||||
toggleSource(sourceId: string, event: Event): void {
|
||||
|
||||
@@ -93,10 +93,15 @@ export class VulnerabilityDetailComponent implements OnInit {
|
||||
cveId: vuln.cveId,
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
const evidenceConfidence = response.evidence?.fixConfidence
|
||||
?? response.evidence?.confidence
|
||||
?? response.confidence
|
||||
?? 0;
|
||||
|
||||
this.binaryResolution.set({
|
||||
status: response.status,
|
||||
matchType: response.evidence?.matchType as any || 'version_range',
|
||||
confidence: response.evidence?.fixConfidence || 0,
|
||||
confidence: evidenceConfidence,
|
||||
fixedVersion: response.fixedVersion,
|
||||
distroAdvisoryId: response.evidence?.distroAdvisoryId,
|
||||
hasEvidence: !!response.evidence,
|
||||
@@ -179,3 +184,4 @@ export class VulnerabilityDetailComponent implements OnInit {
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -138,14 +138,19 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
|
||||
|
||||
.shell__breadcrumb {
|
||||
flex-shrink: 0;
|
||||
padding: 0.5rem 1.5rem;
|
||||
padding: 0.375rem 1.5rem;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.shell__outlet {
|
||||
flex: 1;
|
||||
padding: var(--space-6, 1.5rem);
|
||||
outline: none;
|
||||
background:
|
||||
radial-gradient(circle at 1px 1px, var(--color-border-primary) 0.5px, transparent 0.5px),
|
||||
var(--color-surface-tertiary);
|
||||
background-size: 24px 24px, auto;
|
||||
}
|
||||
|
||||
.shell__overlay {
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
NgZone,
|
||||
OnDestroy,
|
||||
} from '@angular/core';
|
||||
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
@@ -54,15 +56,18 @@ export interface NavSection {
|
||||
template: `
|
||||
<aside
|
||||
class="sidebar"
|
||||
[class.sidebar--collapsed]="collapsed"
|
||||
[class.sidebar--collapsed]="collapsed && !hoverExpanded()"
|
||||
[class.sidebar--hover-expanded]="hoverExpanded()"
|
||||
role="navigation"
|
||||
aria-label="Main navigation"
|
||||
(mouseenter)="onSidebarMouseEnter()"
|
||||
(mouseleave)="onSidebarMouseLeave()"
|
||||
>
|
||||
<!-- Brand/Logo -->
|
||||
<div class="sidebar__brand">
|
||||
<a routerLink="/" class="sidebar__logo">
|
||||
<img src="assets/img/site.png" alt="" class="sidebar__logo-img" width="28" height="28" />
|
||||
@if (!collapsed) {
|
||||
@if (!collapsed || hoverExpanded()) {
|
||||
<span class="sidebar__logo-text">Stella Ops</span>
|
||||
}
|
||||
</a>
|
||||
@@ -107,7 +112,7 @@ export interface NavSection {
|
||||
[icon]="section.icon"
|
||||
[route]="section.route"
|
||||
[children]="section.children"
|
||||
[collapsed]="collapsed"
|
||||
[collapsed]="collapsed && !hoverExpanded()"
|
||||
[expanded]="expandedGroups().has(section.id)"
|
||||
(expandedChange)="onGroupToggle(section.id, $event)"
|
||||
></app-sidebar-nav-group>
|
||||
@@ -117,7 +122,7 @@ export interface NavSection {
|
||||
[icon]="section.icon"
|
||||
[route]="section.route"
|
||||
[badge]="section.badge$ ? section.badge$() : null"
|
||||
[collapsed]="collapsed"
|
||||
[collapsed]="collapsed && !hoverExpanded()"
|
||||
></app-sidebar-nav-item>
|
||||
}
|
||||
}
|
||||
@@ -125,7 +130,7 @@ export interface NavSection {
|
||||
|
||||
<!-- Bottom section (optional version info) -->
|
||||
<div class="sidebar__footer">
|
||||
@if (!collapsed) {
|
||||
@if (!collapsed || hoverExpanded()) {
|
||||
<span class="sidebar__version">v1.0.0</span>
|
||||
}
|
||||
</div>
|
||||
@@ -153,13 +158,37 @@ export interface NavSection {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Hover auto-expand: slides out over content */
|
||||
.sidebar--hover-expanded {
|
||||
width: 240px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 200;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.12), 1px 0 4px rgba(0, 0, 0, 0.06);
|
||||
animation: sidebar-slide-in 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes sidebar-slide-in {
|
||||
from {
|
||||
width: 56px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
to {
|
||||
width: 240px;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 52px;
|
||||
height: 48px;
|
||||
padding: 0 1rem;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar__logo {
|
||||
@@ -168,19 +197,21 @@ export interface NavSection {
|
||||
gap: 0.625rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-size: 1.0625rem;
|
||||
letter-spacing: 0.02em;
|
||||
font-weight: 700;
|
||||
font-size: 0.9375rem;
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sidebar__logo-img {
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
border-radius: 5px;
|
||||
filter: drop-shadow(0 1px 2px rgba(212,146,10,0.12));
|
||||
}
|
||||
|
||||
.sidebar__logo-text {
|
||||
color: var(--color-text-heading);
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.sidebar__toggle {
|
||||
@@ -263,24 +294,29 @@ export interface NavSection {
|
||||
}
|
||||
|
||||
.sidebar__version {
|
||||
font-size: 0.625rem;
|
||||
font-size: 0.5625rem;
|
||||
font-family: var(--font-family-mono);
|
||||
letter-spacing: 0.06em;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.6;
|
||||
opacity: 0.4;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppSidebarComponent {
|
||||
export class AppSidebarComponent implements OnDestroy {
|
||||
private readonly router = inject(Router);
|
||||
private readonly authService = inject(AUTH_SERVICE) as AuthService;
|
||||
private readonly ngZone = inject(NgZone);
|
||||
|
||||
@Input() collapsed = false;
|
||||
@Output() toggleCollapse = new EventEmitter<void>();
|
||||
@Output() mobileClose = new EventEmitter<void>();
|
||||
|
||||
/** Whether sidebar is temporarily expanded due to hover */
|
||||
readonly hoverExpanded = signal(false);
|
||||
private hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Track which groups are expanded */
|
||||
readonly expandedGroups = signal<Set<string>>(new Set(['security']));
|
||||
|
||||
@@ -429,6 +465,29 @@ export class AppSidebarComponent {
|
||||
return this.authService.hasAnyScope(scopes);
|
||||
}
|
||||
|
||||
onSidebarMouseEnter(): void {
|
||||
if (!this.collapsed || this.hoverExpanded()) return;
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
this.hoverTimer = setTimeout(() => {
|
||||
this.ngZone.run(() => this.hoverExpanded.set(true));
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
onSidebarMouseLeave(): void {
|
||||
if (this.hoverTimer) {
|
||||
clearTimeout(this.hoverTimer);
|
||||
this.hoverTimer = null;
|
||||
}
|
||||
this.hoverExpanded.set(false);
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.hoverTimer) {
|
||||
clearTimeout(this.hoverTimer);
|
||||
}
|
||||
}
|
||||
|
||||
onGroupToggle(groupId: string, expanded: boolean): void {
|
||||
this.expandedGroups.update((groups) => {
|
||||
const newGroups = new Set(groups);
|
||||
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
Output,
|
||||
EventEmitter,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
inject,
|
||||
signal,
|
||||
DestroyRef,
|
||||
NgZone,
|
||||
} from '@angular/core';
|
||||
import { Router, NavigationEnd } from '@angular/router';
|
||||
import { filter } from 'rxjs/operators';
|
||||
@@ -33,6 +35,8 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
|
||||
class="nav-group__header"
|
||||
[class.nav-group__header--active]="isGroupActive()"
|
||||
(click)="onToggle()"
|
||||
(mouseenter)="onHeaderMouseEnter()"
|
||||
(mouseleave)="onHeaderMouseLeave()"
|
||||
[attr.aria-expanded]="expanded"
|
||||
[attr.aria-controls]="'nav-group-' + label"
|
||||
>
|
||||
@@ -88,7 +92,7 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
|
||||
|
||||
<!-- Children (expanded, not collapsed) -->
|
||||
@if (expanded && !collapsed && children && children.length > 0) {
|
||||
<div class="nav-group__children" [id]="'nav-group-' + label" role="group">
|
||||
<div class="nav-group__children" [class.nav-group__children--auto-opened]="autoExpanded()" [id]="'nav-group-' + label" role="group">
|
||||
@for (child of children; track child.id) {
|
||||
<app-sidebar-nav-item
|
||||
[label]="child.label"
|
||||
@@ -149,13 +153,14 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
letter-spacing: 0.1em;
|
||||
transition: color 0.15s;
|
||||
|
||||
&:hover {
|
||||
@@ -205,14 +210,32 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
|
||||
|
||||
.nav-group__children {
|
||||
padding-left: 0;
|
||||
margin-left: 0.25rem;
|
||||
margin-left: 0.5rem;
|
||||
margin-top: 0.125rem;
|
||||
border-left: 2px solid rgba(245, 166, 35, 0.2);
|
||||
border-left: 1px solid var(--color-border-primary);
|
||||
transition: border-color 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-group__children--auto-opened {
|
||||
animation: nav-children-slide-down 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
@keyframes nav-children-slide-down {
|
||||
from {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-group--expanded .nav-group__children {
|
||||
border-left-color: rgba(245, 166, 35, 0.35);
|
||||
border-left-color: var(--color-brand-primary-20);
|
||||
}
|
||||
|
||||
/* Collapsed state */
|
||||
@@ -272,9 +295,10 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SidebarNavGroupComponent implements OnInit {
|
||||
export class SidebarNavGroupComponent implements OnInit, OnDestroy {
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
|
||||
@Input({ required: true }) label!: string;
|
||||
@Input({ required: true }) icon!: string;
|
||||
@@ -288,6 +312,10 @@ export class SidebarNavGroupComponent implements OnInit {
|
||||
/** Reactive active state wired to Router navigation events */
|
||||
readonly isGroupActive = signal(false);
|
||||
|
||||
/** Whether the group was auto-expanded by hover (for animation class) */
|
||||
readonly autoExpanded = signal(false);
|
||||
private hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.checkActive(this.router.url);
|
||||
|
||||
@@ -306,6 +334,32 @@ export class SidebarNavGroupComponent implements OnInit {
|
||||
}
|
||||
|
||||
onToggle(): void {
|
||||
this.autoExpanded.set(false);
|
||||
this.expandedChange.emit(!this.expanded);
|
||||
}
|
||||
|
||||
onHeaderMouseEnter(): void {
|
||||
if (this.expanded || this.collapsed) return;
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
this.hoverTimer = setTimeout(() => {
|
||||
this.ngZone.run(() => {
|
||||
this.autoExpanded.set(true);
|
||||
this.expandedChange.emit(true);
|
||||
});
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
onHeaderMouseLeave(): void {
|
||||
if (this.hoverTimer) {
|
||||
clearTimeout(this.hoverTimer);
|
||||
this.hoverTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.hoverTimer) {
|
||||
clearTimeout(this.hoverTimer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,18 +274,18 @@ export interface NavItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5625rem 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: 0 0.25rem 1px;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
transition: all 0.15s;
|
||||
border-radius: var(--radius-md);
|
||||
transition: all 0.12s;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
border-left: 3px solid transparent;
|
||||
border-left: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-nav-hover);
|
||||
@@ -309,7 +309,7 @@ export interface NavItem {
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(245, 166, 35, 0.12);
|
||||
background: var(--color-brand-primary-10);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,9 +80,9 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
height: 52px;
|
||||
height: 48px;
|
||||
padding: 0 1.25rem;
|
||||
background: var(--color-surface-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
position: relative;
|
||||
}
|
||||
@@ -92,9 +92,9 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
|
||||
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%);
|
||||
width: 64px;
|
||||
height: 1px;
|
||||
background: var(--color-brand-primary);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
@@ -163,21 +163,22 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
|
||||
.topbar__tenant-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--color-nav-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
backdrop-filter: blur(8px);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-family: var(--font-family-mono);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
transition: all 0.12s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-nav-hover);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-color: var(--color-border-secondary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
|
||||
@@ -88,14 +88,14 @@ export class BreadcrumbService {
|
||||
`,
|
||||
styles: [`
|
||||
.breadcrumb {
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.breadcrumb__list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
gap: 0.125rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
@@ -139,12 +139,13 @@ export class BreadcrumbService {
|
||||
|
||||
.breadcrumb__text--current {
|
||||
color: var(--color-text-primary);
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.breadcrumb__separator {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--color-text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -66,7 +66,7 @@ import { PolicyPackStore } from '../../features/policy-studio/services/policy-pa
|
||||
})
|
||||
export class PolicyBaselineChipComponent {
|
||||
private readonly policyPackStore = inject(PolicyPackStore);
|
||||
private readonly packs = toSignal(this.policyPackStore.getPacks(), {
|
||||
private readonly packs = toSignal(this.policyPackStore.getPacks({ allowRemoteFetch: false }), {
|
||||
initialValue: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -171,17 +171,17 @@ export interface SearchResult {
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0 0.75rem;
|
||||
height: 36px;
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
transition: border-color 0.15s, background-color 0.15s, box-shadow 0.15s;
|
||||
height: 34px;
|
||||
background: var(--color-surface-tertiary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: border-color 0.12s, background-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
|
||||
.search--focused .search__input-wrapper {
|
||||
background: var(--color-surface-primary);
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 2px rgba(245, 166, 35, 0.1);
|
||||
box-shadow: 0 0 0 1px var(--color-brand-primary-20);
|
||||
}
|
||||
|
||||
.search--has-results .search__input-wrapper {
|
||||
@@ -198,24 +198,26 @@ export interface SearchResult {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-primary);
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.search__shortcut {
|
||||
flex-shrink: 0;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--color-surface-secondary);
|
||||
padding: 1px 5px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.6875rem;
|
||||
border-radius: 2px;
|
||||
font-size: 0.5625rem;
|
||||
font-family: var(--font-family-mono);
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--color-text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
|
||||
@@ -140,7 +140,7 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
|
||||
{ path: 'admin/issuers', redirectTo: '/settings/trust/issuers', pathMatch: 'full' },
|
||||
{ path: 'admin/notifications', redirectTo: '/settings/notifications', pathMatch: 'full' },
|
||||
{ path: 'admin/audit', redirectTo: '/evidence/audit', pathMatch: 'full' },
|
||||
{ path: 'admin/policy/governance', redirectTo: '/settings/policy/governance', pathMatch: 'full' },
|
||||
{ path: 'admin/policy/governance', redirectTo: '/policy/governance', pathMatch: 'full' },
|
||||
{ path: 'concelier/trivy-db-settings', redirectTo: '/settings/security-data/trivy', pathMatch: 'full' },
|
||||
|
||||
// ===========================================
|
||||
|
||||
@@ -13,12 +13,72 @@ describe('EvidenceDrawerComponent', () => {
|
||||
let fixture: ComponentFixture<EvidenceDrawerComponent>;
|
||||
|
||||
const mockEvidence: ResolutionEvidence = {
|
||||
evidenceType: 'binary_fingerprint_match',
|
||||
matchType: 'fingerprint',
|
||||
confidence: 0.87,
|
||||
fixConfidence: 0.87,
|
||||
distroAdvisoryId: 'DSA-5343-1',
|
||||
patchHash: 'abc123def456789012345678901234567890abcd',
|
||||
matchedFingerprintIds: ['ssl3_get_record:001', 'tls1_enc:002'],
|
||||
functionDiffSummary: 'ssl3_get_record() patched; 3 functions changed',
|
||||
functionDiffSummary: 'ssl3_get_record() patched; 2 functions changed',
|
||||
changedFunctions: [
|
||||
{
|
||||
name: 'ssl3_get_record',
|
||||
changeType: 'Modified',
|
||||
},
|
||||
{
|
||||
name: 'tls1_enc',
|
||||
changeType: 'Added',
|
||||
},
|
||||
],
|
||||
hybridDiff: {
|
||||
semanticEditScriptDigest: 'sha256:semantic',
|
||||
oldSymbolMapDigest: 'sha256:old-map',
|
||||
newSymbolMapDigest: 'sha256:new-map',
|
||||
symbolPatchPlanDigest: 'sha256:plan',
|
||||
patchManifestDigest: 'sha256:manifest',
|
||||
semanticEditScript: {
|
||||
schemaVersion: '1.0.0',
|
||||
sourceTreeDigest: 'sha256:tree',
|
||||
edits: [
|
||||
{
|
||||
stableId: 'edit-1',
|
||||
editType: 'update',
|
||||
nodeKind: 'method',
|
||||
nodePath: 'src/ssl.c::ssl3_get_record',
|
||||
anchor: 'ssl3_get_record',
|
||||
},
|
||||
],
|
||||
},
|
||||
symbolPatchPlan: {
|
||||
schemaVersion: '1.0.0',
|
||||
buildIdBefore: 'build-old',
|
||||
buildIdAfter: 'build-new',
|
||||
changes: [
|
||||
{
|
||||
symbol: 'ssl3_get_record',
|
||||
changeType: 'modified',
|
||||
astAnchors: ['ssl3_get_record'],
|
||||
deltaRef: 'sha256:delta-ref',
|
||||
},
|
||||
],
|
||||
},
|
||||
patchManifest: {
|
||||
schemaVersion: '1.0.0',
|
||||
buildId: 'build-new',
|
||||
normalizationRecipeId: 'norm-v1',
|
||||
totalDeltaBytes: 312,
|
||||
patches: [
|
||||
{
|
||||
symbol: 'ssl3_get_record',
|
||||
addressRange: '0x401120-0x4012AF',
|
||||
deltaDigest: 'sha256:delta',
|
||||
pre: { size: 304, hash: 'sha256:pre' },
|
||||
post: { size: 312, hash: 'sha256:post' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockResolution: VulnResolutionSummary = {
|
||||
@@ -114,9 +174,10 @@ describe('EvidenceDrawerComponent', () => {
|
||||
expect(content).toContain('3.0.7-1+deb12u1');
|
||||
});
|
||||
|
||||
it('should display patch hash', () => {
|
||||
it('should display patch hash and diff summary', () => {
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('abc123def456');
|
||||
expect(content).toContain('ssl3_get_record() patched');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,23 +190,50 @@ describe('EvidenceDrawerComponent', () => {
|
||||
|
||||
it('should display changed functions', () => {
|
||||
const functionItems = fixture.nativeElement.querySelectorAll('.function-list__item');
|
||||
expect(functionItems.length).toBeGreaterThan(0);
|
||||
expect(functionItems.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should show function names', () => {
|
||||
const functionNames = fixture.nativeElement.querySelectorAll('.function-list__name');
|
||||
const names = Array.from(functionNames).map((el: any) => el.textContent.trim());
|
||||
expect(names).toContain('ssl3_get_record');
|
||||
expect(names).toContain('tls1_enc');
|
||||
});
|
||||
|
||||
it('should emit viewDiff when diff button clicked', () => {
|
||||
spyOn(component.viewDiff, 'emit');
|
||||
|
||||
const diffButton = fixture.nativeElement.querySelector('.function-list__diff-btn');
|
||||
if (diffButton) {
|
||||
diffButton.click();
|
||||
expect(component.viewDiff.emit).toHaveBeenCalled();
|
||||
}
|
||||
diffButton.click();
|
||||
|
||||
expect(component.viewDiff.emit).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hybrid diff sections', () => {
|
||||
beforeEach(() => {
|
||||
fixture.componentRef.setInput('evidence', mockEvidence);
|
||||
fixture.componentRef.setInput('isOpen', true);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should render hybrid diff summary', () => {
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('Hybrid Diff Summary');
|
||||
expect(content).toContain('Semantic Edits');
|
||||
expect(content).toContain('Symbol Changes');
|
||||
expect(content).toContain('Patch Entries');
|
||||
});
|
||||
|
||||
it('should render symbol patch plan entries', () => {
|
||||
const symbolItem = fixture.nativeElement.querySelector('.symbol-change-list__symbol');
|
||||
expect(symbolItem.textContent).toContain('ssl3_get_record');
|
||||
});
|
||||
|
||||
it('should render artifact digests', () => {
|
||||
const content = fixture.nativeElement.textContent;
|
||||
expect(content).toContain('Artifact Digests');
|
||||
expect(content).toContain('sha256:manifest');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -164,8 +252,9 @@ describe('EvidenceDrawerComponent', () => {
|
||||
});
|
||||
|
||||
it('should show attestation section when DSSE available', () => {
|
||||
const section = fixture.nativeElement.querySelector('.evidence-drawer__section:last-of-type h4');
|
||||
expect(section?.textContent).toContain('Attestation');
|
||||
const headings = Array.from(fixture.nativeElement.querySelectorAll('.evidence-drawer__section h4'))
|
||||
.map((el: any) => el.textContent.trim());
|
||||
expect(headings).toContain('Attestation');
|
||||
});
|
||||
|
||||
it('should show copy button for attestation', () => {
|
||||
@@ -216,7 +305,7 @@ describe('EvidenceDrawerComponent', () => {
|
||||
|
||||
describe('confidence gauge styling', () => {
|
||||
it('should apply high confidence style for >= 80%', () => {
|
||||
fixture.componentRef.setInput('evidence', { ...mockEvidence, confidence: 0.92 });
|
||||
fixture.componentRef.setInput('evidence', { ...mockEvidence, fixConfidence: 0.92, confidence: 0.92 });
|
||||
fixture.componentRef.setInput('isOpen', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -225,7 +314,7 @@ describe('EvidenceDrawerComponent', () => {
|
||||
});
|
||||
|
||||
it('should apply medium confidence style for 50-79%', () => {
|
||||
fixture.componentRef.setInput('evidence', { ...mockEvidence, confidence: 0.65 });
|
||||
fixture.componentRef.setInput('evidence', { ...mockEvidence, fixConfidence: 0.65, confidence: 0.65 });
|
||||
fixture.componentRef.setInput('isOpen', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -234,7 +323,7 @@ describe('EvidenceDrawerComponent', () => {
|
||||
});
|
||||
|
||||
it('should apply low confidence style for < 50%', () => {
|
||||
fixture.componentRef.setInput('evidence', { ...mockEvidence, confidence: 0.35 });
|
||||
fixture.componentRef.setInput('evidence', { ...mockEvidence, fixConfidence: 0.35, confidence: 0.35 });
|
||||
fixture.componentRef.setInput('isOpen', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
@@ -348,9 +437,9 @@ describe('EvidenceDrawerComponent', () => {
|
||||
it('should have focus-visible support on interactive elements', () => {
|
||||
const buttons = fixture.nativeElement.querySelectorAll('button');
|
||||
buttons.forEach((button: HTMLElement) => {
|
||||
// Buttons should have focus styles (checked via CSS)
|
||||
expect(button.tagName.toLowerCase()).toBe('button');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ import type {
|
||||
ResolutionEvidence,
|
||||
VulnResolutionSummary,
|
||||
FunctionChangeInfo,
|
||||
HybridDiffEvidence,
|
||||
SymbolPatchChange,
|
||||
SymbolPatchArtifact,
|
||||
} from '../../../core/api/binary-resolution.models';
|
||||
|
||||
/**
|
||||
@@ -136,6 +139,24 @@ import type {
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
@if (!ev.patchCommit && ev.patchHash) {
|
||||
<div>
|
||||
<dt>Patch Hash</dt>
|
||||
<dd><code>{{ ev.patchHash | slice:0:16 }}...</code></dd>
|
||||
</div>
|
||||
}
|
||||
@if (ev.matchedFingerprintIds?.length) {
|
||||
<div>
|
||||
<dt>Fingerprints</dt>
|
||||
<dd>{{ ev.matchedFingerprintIds.length }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (ev.functionDiffSummary) {
|
||||
<div>
|
||||
<dt>Diff Summary</dt>
|
||||
<dd>{{ ev.functionDiffSummary }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
<!-- Changed Functions -->
|
||||
@@ -166,6 +187,136 @@ import type {
|
||||
</ul>
|
||||
</section>
|
||||
}
|
||||
@if (hasHybridDiff()) {
|
||||
<section class="evidence-drawer__section">
|
||||
<h4>Hybrid Diff Summary</h4>
|
||||
<dl class="evidence-drawer__details">
|
||||
@if (hybridSemanticEditCount() > 0) {
|
||||
<div>
|
||||
<dt>Semantic Edits</dt>
|
||||
<dd>{{ hybridSemanticEditCount() }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (hybridSymbolChangeCount() > 0) {
|
||||
<div>
|
||||
<dt>Symbol Changes</dt>
|
||||
<dd>{{ hybridSymbolChangeCount() }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (hybridPatchCount() > 0) {
|
||||
<div>
|
||||
<dt>Patch Entries</dt>
|
||||
<dd>{{ hybridPatchCount() }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (hybridTotalDeltaBytes() > 0) {
|
||||
<div>
|
||||
<dt>Total Delta</dt>
|
||||
<dd>{{ formatBytes(hybridTotalDeltaBytes()) }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (hybridBuildId()) {
|
||||
<div>
|
||||
<dt>Build ID</dt>
|
||||
<dd><code>{{ hybridBuildId() }}</code></dd>
|
||||
</div>
|
||||
}
|
||||
@if (hybridRecipeId()) {
|
||||
<div>
|
||||
<dt>Recipe</dt>
|
||||
<dd><code>{{ hybridRecipeId() }}</code></dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (hybridTopSymbolChanges().length > 0) {
|
||||
<section class="evidence-drawer__section">
|
||||
<h4>Symbol Patch Plan</h4>
|
||||
<ul class="symbol-change-list">
|
||||
@for (change of hybridTopSymbolChanges(); track change.symbol + change.changeType) {
|
||||
<li class="symbol-change-list__item">
|
||||
<span class="symbol-change-list__symbol">{{ change.symbol }}</span>
|
||||
<span class="symbol-change-list__type">{{ change.changeType }}</span>
|
||||
@if (change.astAnchors?.length) {
|
||||
<span class="symbol-change-list__anchors">{{ change.astAnchors.length }} anchors</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (hybridSymbolChangeCount() > hybridTopSymbolChanges().length) {
|
||||
<p class="evidence-drawer__hint">
|
||||
Showing first {{ hybridTopSymbolChanges().length }} of {{ hybridSymbolChangeCount() }} symbol changes.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (hybridTopPatchEntries().length > 0) {
|
||||
<section class="evidence-drawer__section">
|
||||
<h4>Patch Manifest</h4>
|
||||
<ul class="patch-list">
|
||||
@for (patch of hybridTopPatchEntries(); track patch.symbol) {
|
||||
<li class="patch-list__item">
|
||||
<div class="patch-list__header">
|
||||
<span class="patch-list__symbol">{{ patch.symbol }}</span>
|
||||
<span class="patch-list__delta">{{ formatBytes(getPatchDeltaBytes(patch)) }}</span>
|
||||
</div>
|
||||
@if (patch.addressRange) {
|
||||
<code class="patch-list__address">{{ patch.addressRange }}</code>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (hybridPatchCount() > hybridTopPatchEntries().length) {
|
||||
<p class="evidence-drawer__hint">
|
||||
Showing first {{ hybridTopPatchEntries().length }} of {{ hybridPatchCount() }} patch entries.
|
||||
</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (hybridDiff(); as hybrid) {
|
||||
@if (hasHybridDiffDigests()) {
|
||||
<section class="evidence-drawer__section">
|
||||
<h4>Artifact Digests</h4>
|
||||
<dl class="evidence-drawer__details evidence-drawer__details--dense">
|
||||
@if (hybrid.semanticEditScriptDigest) {
|
||||
<div>
|
||||
<dt>Edit Script</dt>
|
||||
<dd><code>{{ hybrid.semanticEditScriptDigest }}</code></dd>
|
||||
</div>
|
||||
}
|
||||
@if (hybrid.oldSymbolMapDigest) {
|
||||
<div>
|
||||
<dt>Old Map</dt>
|
||||
<dd><code>{{ hybrid.oldSymbolMapDigest }}</code></dd>
|
||||
</div>
|
||||
}
|
||||
@if (hybrid.newSymbolMapDigest) {
|
||||
<div>
|
||||
<dt>New Map</dt>
|
||||
<dd><code>{{ hybrid.newSymbolMapDigest }}</code></dd>
|
||||
</div>
|
||||
}
|
||||
@if (hybrid.symbolPatchPlanDigest) {
|
||||
<div>
|
||||
<dt>Patch Plan</dt>
|
||||
<dd><code>{{ hybrid.symbolPatchPlanDigest }}</code></dd>
|
||||
</div>
|
||||
}
|
||||
@if (hybrid.patchManifestDigest) {
|
||||
<div>
|
||||
<dt>Manifest</dt>
|
||||
<dd><code>{{ hybrid.patchManifestDigest }}</code></dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- DSSE Attestation -->
|
||||
@if (attestationAvailable()) {
|
||||
<section class="evidence-drawer__section">
|
||||
@@ -439,6 +590,73 @@ import type {
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-drawer__details--dense code {
|
||||
font-size: 0.7rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.evidence-drawer__hint {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.symbol-change-list,
|
||||
.patch-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.symbol-change-list__item,
|
||||
.patch-list__item {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.5rem;
|
||||
background: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.symbol-change-list__item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.25rem 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.symbol-change-list__symbol,
|
||||
.patch-list__symbol,
|
||||
.patch-list__address {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.symbol-change-list__type,
|
||||
.symbol-change-list__anchors,
|
||||
.patch-list__delta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.symbol-change-list__anchors {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.patch-list__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.patch-list__address {
|
||||
display: inline-block;
|
||||
margin-top: 0.375rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
// Copy button
|
||||
.evidence-drawer__copy-btn {
|
||||
display: block;
|
||||
@@ -502,8 +720,8 @@ export class EvidenceDrawerComponent implements OnInit, OnDestroy {
|
||||
/** Compute confidence percentage */
|
||||
confidencePercent = computed((): number => {
|
||||
const ev = this.evidence();
|
||||
if (!ev?.fixConfidence) return 0;
|
||||
return Math.round(ev.fixConfidence * 100);
|
||||
const raw = ev?.fixConfidence ?? ev?.confidence ?? this.resolution()?.confidence ?? 0;
|
||||
return Math.round(raw * 100);
|
||||
});
|
||||
|
||||
/** Get list of changed functions */
|
||||
@@ -513,6 +731,79 @@ export class EvidenceDrawerComponent implements OnInit, OnDestroy {
|
||||
return ev.changedFunctions;
|
||||
});
|
||||
|
||||
/** Hybrid diff evidence if available */
|
||||
hybridDiff = computed((): HybridDiffEvidence | null => {
|
||||
const ev = this.evidence();
|
||||
return ev?.hybridDiff ?? null;
|
||||
});
|
||||
|
||||
/** Whether hybrid evidence exists */
|
||||
hasHybridDiff = computed((): boolean => {
|
||||
return this.hybridDiff() !== null;
|
||||
});
|
||||
|
||||
/** Number of semantic edits in hybrid evidence */
|
||||
hybridSemanticEditCount = computed((): number => {
|
||||
return this.hybridDiff()?.semanticEditScript?.edits?.length ?? 0;
|
||||
});
|
||||
|
||||
/** Number of symbol changes in hybrid evidence */
|
||||
hybridSymbolChangeCount = computed((): number => {
|
||||
return this.hybridDiff()?.symbolPatchPlan?.changes?.length ?? 0;
|
||||
});
|
||||
|
||||
/** Number of patch entries in hybrid evidence */
|
||||
hybridPatchCount = computed((): number => {
|
||||
return this.hybridDiff()?.patchManifest?.patches?.length ?? 0;
|
||||
});
|
||||
|
||||
/** Total patch delta bytes across patch entries */
|
||||
hybridTotalDeltaBytes = computed((): number => {
|
||||
const manifest = this.hybridDiff()?.patchManifest;
|
||||
if (!manifest) return 0;
|
||||
|
||||
if (typeof manifest.totalDeltaBytes === 'number') {
|
||||
return manifest.totalDeltaBytes;
|
||||
}
|
||||
|
||||
return (manifest.patches ?? []).reduce((sum, patch) => sum + this.getPatchDeltaBytes(patch), 0);
|
||||
});
|
||||
|
||||
/** Build ID tied to the patch manifest */
|
||||
hybridBuildId = computed((): string | null => {
|
||||
const hybrid = this.hybridDiff();
|
||||
return hybrid?.patchManifest?.buildId ?? hybrid?.symbolPatchPlan?.buildIdAfter ?? null;
|
||||
});
|
||||
|
||||
/** Normalization recipe identifier */
|
||||
hybridRecipeId = computed((): string | null => {
|
||||
return this.hybridDiff()?.patchManifest?.normalizationRecipeId ?? null;
|
||||
});
|
||||
|
||||
/** Top symbol changes for compact rendering */
|
||||
hybridTopSymbolChanges = computed((): SymbolPatchChange[] => {
|
||||
return (this.hybridDiff()?.symbolPatchPlan?.changes ?? []).slice(0, 6);
|
||||
});
|
||||
|
||||
/** Top patch entries for compact rendering */
|
||||
hybridTopPatchEntries = computed((): SymbolPatchArtifact[] => {
|
||||
return (this.hybridDiff()?.patchManifest?.patches ?? []).slice(0, 6);
|
||||
});
|
||||
|
||||
/** Whether any digest information is available */
|
||||
hasHybridDiffDigests = computed((): boolean => {
|
||||
const hybrid = this.hybridDiff();
|
||||
if (!hybrid) return false;
|
||||
|
||||
return !!(
|
||||
hybrid.semanticEditScriptDigest ||
|
||||
hybrid.oldSymbolMapDigest ||
|
||||
hybrid.newSymbolMapDigest ||
|
||||
hybrid.symbolPatchPlanDigest ||
|
||||
hybrid.patchManifestDigest
|
||||
);
|
||||
});
|
||||
|
||||
/** Check if attestation is available */
|
||||
attestationAvailable = computed((): boolean => {
|
||||
return !!this.attestationDsse();
|
||||
@@ -605,6 +896,26 @@ export class EvidenceDrawerComponent implements OnInit, OnDestroy {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Get absolute delta bytes for a patch entry */
|
||||
getPatchDeltaBytes(patch: SymbolPatchArtifact): number {
|
||||
if (typeof patch.deltaSizeBytes === 'number') {
|
||||
return Math.abs(patch.deltaSizeBytes);
|
||||
}
|
||||
|
||||
if (typeof patch.pre?.size === 'number' && typeof patch.post?.size === 'number') {
|
||||
return Math.abs(patch.post.size - patch.pre.size);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Format bytes for compact display */
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Generate Rekor transparency log link */
|
||||
rekorLink(): string | null {
|
||||
const logIndex = this.rekorLogIndex();
|
||||
@@ -644,3 +955,4 @@ export class EvidenceDrawerComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@
|
||||
|
||||
.user-menu__info {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-menu__info-name {
|
||||
@@ -129,6 +130,10 @@
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -47,7 +47,7 @@ import { NavigationService } from '../../../core/navigation';
|
||||
<!-- User Info -->
|
||||
<div class="user-menu__info">
|
||||
<div class="user-menu__info-name">{{ authService.user()?.name || 'User' }}</div>
|
||||
<div class="user-menu__info-email">{{ authService.user()?.email || '' }}</div>
|
||||
<div class="user-menu__info-email" [title]="authService.user()?.email || ''">{{ authService.user()?.email || '' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="user-menu__divider"></div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"redirectUri": "/auth/callback",
|
||||
"silentRefreshRedirectUri": "/auth/silent-refresh",
|
||||
"postLogoutRedirectUri": "/",
|
||||
"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 scheduler:operate notify.viewer notify.operator notify.admin notify.escalate export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit",
|
||||
"scope": "openid profile email offline_access 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 scheduler:operate notify.viewer notify.operator notify.admin notify.escalate export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit",
|
||||
"audience": "/scanner",
|
||||
"dpopAlgorithms": ["ES256"],
|
||||
"refreshLeewaySeconds": 60
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="assets/icons/icon-16x16.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/icons/apple-touch-icon.png">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#F5A623">
|
||||
<meta name="theme-color" content="#080A12">
|
||||
<style>
|
||||
/* Inline splash screen — visible immediately, removed by Angular on bootstrap */
|
||||
/* Inline splash — visible immediately, removed by Angular on bootstrap */
|
||||
.stella-splash {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -20,48 +20,53 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0C1220;
|
||||
background:
|
||||
radial-gradient(ellipse 50% 40% at 50% 40%, rgba(212,146,10,0.05) 0%, transparent 70%),
|
||||
#080A12;
|
||||
z-index: 99999;
|
||||
gap: 24px;
|
||||
gap: 28px;
|
||||
}
|
||||
.stella-splash__img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
animation: splash-breathe 1.8s ease-in-out infinite;
|
||||
border-radius: 14px;
|
||||
filter: drop-shadow(0 2px 12px rgba(212,146,10,0.3));
|
||||
animation: splash-breathe 2s ease-in-out infinite;
|
||||
}
|
||||
.stella-splash__text {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(245, 240, 230, 0.6);
|
||||
color: rgba(204,136,16,0.5);
|
||||
}
|
||||
.stella-splash__dots {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
.stella-splash__bar {
|
||||
width: 48px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
background: rgba(212,146,10,0.15);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.stella-splash__dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #F5A623;
|
||||
animation: splash-dot 1.4s ease-in-out infinite;
|
||||
.stella-splash__bar-fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, transparent, #CC8810, transparent);
|
||||
animation: splash-slide 1.6s ease-in-out infinite;
|
||||
}
|
||||
.stella-splash__dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.stella-splash__dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes splash-breathe {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.04); opacity: 0.85; }
|
||||
50% { transform: scale(1.03); opacity: 0.85; }
|
||||
}
|
||||
@keyframes splash-dot {
|
||||
0%, 80%, 100% { transform: scale(0.6); opacity: 0.3; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
@keyframes splash-slide {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stella-splash__img { animation: none; }
|
||||
.stella-splash__dot { animation: none; opacity: 0.7; }
|
||||
.stella-splash__bar-fill { animation: none; opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -70,10 +75,8 @@
|
||||
<div class="stella-splash" id="stella-splash">
|
||||
<img class="stella-splash__img" src="assets/img/site.png" alt="Stella Ops" />
|
||||
<span class="stella-splash__text">Stella Ops</span>
|
||||
<div class="stella-splash__dots">
|
||||
<div class="stella-splash__dot"></div>
|
||||
<div class="stella-splash__dot"></div>
|
||||
<div class="stella-splash__dot"></div>
|
||||
<div class="stella-splash__bar">
|
||||
<div class="stella-splash__bar-fill"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>document.getElementById('stella-splash').dataset.ts=Date.now();</script>
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
--input-text-disabled: var(--color-text-muted);
|
||||
|
||||
// Focus ring
|
||||
--focus-ring-color: rgba(245, 166, 35, 0.4);
|
||||
--focus-ring-offset: 2px;
|
||||
--focus-ring-width: 3px;
|
||||
--focus-ring-color: rgba(245, 166, 35, 0.3);
|
||||
--focus-ring-offset: 1px;
|
||||
--focus-ring-width: 2px;
|
||||
|
||||
// Transition
|
||||
--input-transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -56,11 +56,10 @@ select {
|
||||
color: var(--input-text);
|
||||
background-color: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: 0.375rem;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
outline: none;
|
||||
transition:
|
||||
border-color var(--input-transition),
|
||||
background-color var(--input-transition),
|
||||
box-shadow var(--input-transition);
|
||||
|
||||
&::placeholder {
|
||||
@@ -70,15 +69,12 @@ select {
|
||||
// Hover state
|
||||
&:hover:not(:disabled):not(:focus) {
|
||||
border-color: var(--input-border-hover);
|
||||
background-color: var(--input-bg-hover);
|
||||
}
|
||||
|
||||
// Focus state with ring
|
||||
&:focus {
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow:
|
||||
0 0 0 var(--focus-ring-offset) var(--input-bg),
|
||||
0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
|
||||
box-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring-color);
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
@@ -402,36 +398,28 @@ select {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--input-transition),
|
||||
color var(--input-transition),
|
||||
box-shadow var(--input-transition),
|
||||
transform var(--input-transition);
|
||||
box-shadow var(--input-transition);
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 var(--focus-ring-offset) var(--color-surface-primary),
|
||||
0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
|
||||
box-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring-color);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,13 +468,13 @@ select {
|
||||
|
||||
// Button sizes
|
||||
.btn--sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.btn--lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
// Icon-only button
|
||||
|
||||
@@ -66,14 +66,14 @@
|
||||
// Hover Effects
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// Lift on hover (for cards, buttons)
|
||||
// Lift on hover (subtle — prefer .hover-border for precision aesthetic)
|
||||
.hover-lift {
|
||||
transition: transform var(--motion-duration-normal) var(--motion-ease-default),
|
||||
box-shadow var(--motion-duration-normal) var(--motion-ease-default);
|
||||
border-color var(--motion-duration-normal) var(--motion-ease-default);
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--color-border-secondary);
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -86,20 +86,20 @@
|
||||
transition: transform var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.02);
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
transform: scale(0.99);
|
||||
}
|
||||
}
|
||||
|
||||
// Glow on hover
|
||||
// Glow on hover (muted)
|
||||
.hover-glow {
|
||||
transition: box-shadow var(--motion-duration-normal) var(--motion-ease-default);
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 20px var(--color-brand-primary-30);
|
||||
box-shadow: 0 0 12px var(--color-brand-primary-20);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,9 +192,7 @@
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 0 0 2px var(--color-surface-primary),
|
||||
0 0 0 4px var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-color, rgba(245, 166, 35, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,9 +200,7 @@
|
||||
transition: box-shadow var(--motion-duration-fast) var(--motion-ease-default);
|
||||
|
||||
&:focus-within {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--color-surface-primary),
|
||||
0 0 0 4px var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-color, rgba(245, 166, 35, 0.3));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -431,26 +427,21 @@
|
||||
.card {
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-xl, 12px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
box-shadow: none;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
|
||||
.card-interactive {
|
||||
transition:
|
||||
transform var(--motion-duration-normal) var(--motion-ease-default),
|
||||
box-shadow var(--motion-duration-normal) var(--motion-ease-default),
|
||||
border-color var(--motion-duration-normal) var(--motion-ease-default);
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--color-border-secondary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,22 +16,22 @@
|
||||
color-scheme: light;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Surface Colors (backgrounds)
|
||||
// Surface Colors (backgrounds) — warm cream/parchment matching stella-ops.org
|
||||
// ---------------------------------------------------------------------------
|
||||
--color-surface-primary: #FFFFFF;
|
||||
--color-surface-secondary: #FFFCF5;
|
||||
--color-surface-tertiary: #FFF9ED;
|
||||
--color-surface-primary: #FFFCF5;
|
||||
--color-surface-secondary: #FFF9ED;
|
||||
--color-surface-tertiary: #FEF3E2;
|
||||
--color-surface-elevated: #FFFFFF;
|
||||
--color-surface-inverse: #0C1220;
|
||||
--color-surface-inverse: #070B14;
|
||||
--color-surface-overlay: rgba(0, 0, 0, 0.5);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text Colors (tuned for readability – darker than pure amber)
|
||||
// Text Colors (warm brown tones matching stella-ops.org)
|
||||
// ---------------------------------------------------------------------------
|
||||
--color-text-primary: #2A1F05;
|
||||
--color-text-heading: #110B00;
|
||||
--color-text-secondary: #524318;
|
||||
--color-text-muted: #857A62;
|
||||
--color-text-primary: #3D2E0A;
|
||||
--color-text-heading: #1C1200;
|
||||
--color-text-secondary: #6B5A2E;
|
||||
--color-text-muted: #9A8F78;
|
||||
--color-text-inverse: #F5F0E6;
|
||||
--color-text-link: #D4920A;
|
||||
--color-text-link-hover: #F5A623;
|
||||
@@ -111,19 +111,19 @@
|
||||
--color-status-info-text: #3A5F7A;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header / Navigation (warm, matching product site)
|
||||
// Header / Navigation (warm parchment tones matching stella-ops.org)
|
||||
// ---------------------------------------------------------------------------
|
||||
--color-header-bg: #FFFCF5;
|
||||
--color-header-text: #2A1F05;
|
||||
--color-header-text-muted: #524318;
|
||||
--color-header-text: #3D2E0A;
|
||||
--color-header-text-muted: #6B5A2E;
|
||||
|
||||
--color-nav-bg: #FBF7EE;
|
||||
--color-nav-bg: #FFF9ED;
|
||||
--color-nav-border: #D4C9A8;
|
||||
--color-nav-hover: #F5EDDC;
|
||||
--color-nav-hover: #FEF3E2;
|
||||
|
||||
// Sidebar (slightly tinted – not pure white)
|
||||
--color-sidebar-bg: #F8F4EA;
|
||||
--color-sidebar-text: #2A1F05;
|
||||
// Sidebar (warm cream tint)
|
||||
--color-sidebar-bg: #FFF9ED;
|
||||
--color-sidebar-text: #3D2E0A;
|
||||
|
||||
--color-dropdown-bg: #FFFFFF;
|
||||
--color-dropdown-border: #D4C9A8;
|
||||
@@ -255,13 +255,13 @@
|
||||
color-scheme: dark;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Surface Colors (backgrounds)
|
||||
// Surface Colors (backgrounds) — deep navy with blue undertone (stella-ops.org)
|
||||
// ---------------------------------------------------------------------------
|
||||
--color-surface-primary: #0C1220;
|
||||
--color-surface-secondary: #141C2E;
|
||||
--color-surface-tertiary: #1E2A42;
|
||||
--color-surface-elevated: #141C2E;
|
||||
--color-surface-inverse: #F5F0E6;
|
||||
--color-surface-inverse: #F0EDE4;
|
||||
--color-surface-overlay: rgba(0, 0, 0, 0.7);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -275,15 +275,15 @@
|
||||
--color-text-link-hover: #FFCF70;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Border Colors
|
||||
// Border Colors (navy tones matching stella-ops.org)
|
||||
// ---------------------------------------------------------------------------
|
||||
--color-border-primary: #334155;
|
||||
--color-border-secondary: #475569;
|
||||
--color-border-primary: rgba(30, 42, 66, 0.6);
|
||||
--color-border-secondary: rgba(30, 42, 66, 0.8);
|
||||
--color-border-focus: #F5B84A;
|
||||
--color-border-error: #f87171;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Brand Colors (adjusted for dark mode contrast)
|
||||
// Brand Colors (luminous gold matching stella-ops.org dark theme)
|
||||
// ---------------------------------------------------------------------------
|
||||
--color-brand-primary: #F5B84A;
|
||||
--color-brand-primary-hover: #FFCF70;
|
||||
@@ -296,8 +296,8 @@
|
||||
--color-brand-primary-30: rgba(245, 184, 74, 0.3);
|
||||
--color-brand-soft: rgba(245, 184, 74, 0.12);
|
||||
--color-border-emphasis: rgba(245, 184, 74, 0.3);
|
||||
--color-card-heading: #d5a00f;
|
||||
--color-text-heading: #F5F0E6;
|
||||
--color-card-heading: #F5B84A;
|
||||
--color-text-heading: #F0EDE4;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Severity Colors (adjusted backgrounds for dark mode)
|
||||
@@ -340,18 +340,18 @@
|
||||
--color-status-info-text: #60a5fa;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header / Navigation (dark mode)
|
||||
// Header / Navigation (dark mode — deep navy matching stella-ops.org)
|
||||
// ---------------------------------------------------------------------------
|
||||
--color-header-bg: linear-gradient(90deg, #020617 0%, #0C1220 45%, #3D2E0A 100%);
|
||||
--color-header-bg: linear-gradient(90deg, #070B14 0%, #0C1220 45%, #141C2E 100%);
|
||||
|
||||
--color-nav-bg: #020617;
|
||||
--color-nav-bg: #0A0F1A;
|
||||
--color-nav-border: #1E2A42;
|
||||
--color-nav-hover: #141C2E;
|
||||
|
||||
--color-sidebar-bg: #0A0F1A;
|
||||
--color-sidebar-text: #F5F0E6;
|
||||
--color-sidebar-text: #F0EDE4;
|
||||
|
||||
--color-dropdown-bg: #020617;
|
||||
--color-dropdown-bg: #0C1220;
|
||||
--color-dropdown-border: #1E2A42;
|
||||
--color-dropdown-hover: #141C2E;
|
||||
|
||||
@@ -372,8 +372,8 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scrollbar
|
||||
// ---------------------------------------------------------------------------
|
||||
--color-scrollbar-thumb: #475569;
|
||||
--color-scrollbar-thumb-hover: #64748b;
|
||||
--color-scrollbar-thumb: #1E2A42;
|
||||
--color-scrollbar-thumb-hover: #2A3A56;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skeleton Loading
|
||||
@@ -465,12 +465,12 @@
|
||||
:root:not([data-theme]) {
|
||||
color-scheme: dark;
|
||||
|
||||
// Surface
|
||||
// Surface (deep navy matching stella-ops.org)
|
||||
--color-surface-primary: #0C1220;
|
||||
--color-surface-secondary: #141C2E;
|
||||
--color-surface-tertiary: #1E2A42;
|
||||
--color-surface-elevated: #141C2E;
|
||||
--color-surface-inverse: #F5F0E6;
|
||||
--color-surface-inverse: #F0EDE4;
|
||||
--color-surface-overlay: rgba(0, 0, 0, 0.7);
|
||||
|
||||
// Text
|
||||
@@ -481,12 +481,12 @@
|
||||
--color-text-link: #F5B84A;
|
||||
--color-text-link-hover: #FFCF70;
|
||||
|
||||
// Borders
|
||||
--color-border-primary: #334155;
|
||||
--color-border-secondary: #475569;
|
||||
// Borders (navy tones)
|
||||
--color-border-primary: rgba(30, 42, 66, 0.6);
|
||||
--color-border-secondary: rgba(30, 42, 66, 0.8);
|
||||
--color-border-focus: #F5B84A;
|
||||
|
||||
// Brand
|
||||
// Brand (luminous gold matching stella-ops.org)
|
||||
--color-brand-primary: #F5B84A;
|
||||
--color-brand-primary-hover: #FFCF70;
|
||||
--color-brand-secondary: #FFD369;
|
||||
@@ -498,8 +498,8 @@
|
||||
--color-brand-primary-30: rgba(245, 184, 74, 0.3);
|
||||
--color-brand-soft: rgba(245, 184, 74, 0.12);
|
||||
--color-border-emphasis: rgba(245, 184, 74, 0.3);
|
||||
--color-card-heading: #d5a00f;
|
||||
--color-text-heading: #F5F0E6;
|
||||
--color-card-heading: #F5B84A;
|
||||
--color-text-heading: #F0EDE4;
|
||||
|
||||
// Status backgrounds
|
||||
--color-status-success-bg: rgba(34, 197, 94, 0.15);
|
||||
@@ -514,12 +514,12 @@
|
||||
--color-severity-low-bg: rgba(34, 197, 94, 0.2);
|
||||
--color-severity-info-bg: rgba(59, 130, 246, 0.2);
|
||||
|
||||
// Header
|
||||
--color-header-bg: linear-gradient(90deg, #020617 0%, #0C1220 45%, #3D2E0A 100%);
|
||||
--color-nav-bg: #020617;
|
||||
// Header (deep navy matching stella-ops.org)
|
||||
--color-header-bg: linear-gradient(90deg, #070B14 0%, #0C1220 45%, #141C2E 100%);
|
||||
--color-nav-bg: #0A0F1A;
|
||||
--color-sidebar-bg: #0A0F1A;
|
||||
--color-sidebar-text: #F5F0E6;
|
||||
--color-dropdown-bg: #020617;
|
||||
--color-sidebar-text: #F0EDE4;
|
||||
--color-dropdown-bg: #0C1220;
|
||||
|
||||
// Shadows
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
@@ -533,8 +533,8 @@
|
||||
--shadow-brand-lg: 0 8px 32px rgba(245, 184, 74, 0.1);
|
||||
|
||||
// Scrollbar
|
||||
--color-scrollbar-thumb: #475569;
|
||||
--color-scrollbar-thumb-hover: #64748b;
|
||||
--color-scrollbar-thumb: #1E2A42;
|
||||
--color-scrollbar-thumb-hover: #2A3A56;
|
||||
|
||||
// Skeleton
|
||||
--color-skeleton-base: #141C2E;
|
||||
|
||||
Reference in New Issue
Block a user