Close admin trust audit gaps and stabilize live sweeps

This commit is contained in:
master
2026-03-12 10:14:00 +02:00
parent a00efb7ab2
commit 6964a046a5
50 changed files with 5968 additions and 2850 deletions

View File

@@ -30,15 +30,20 @@
"src/styles"
]
},
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest",
{
"glob": "config.json",
"input": "src/config",
"output": "."
}
"assets": [
"src/favicon.ico",
"src/assets",
"src/manifest.webmanifest",
{
"glob": "**/*",
"input": "../../../docs",
"output": "docs-content"
},
{
"glob": "config.json",
"input": "src/config",
"output": "."
}
],
"styles": [
"src/styles.scss"

View File

@@ -0,0 +1,252 @@
#!/usr/bin/env node
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const webRoot = path.resolve(__dirname, '..');
const outputDir = path.join(webRoot, 'output', 'playwright');
const resultPath = path.join(outputDir, 'live-full-core-audit.json');
const suites = [
{
name: 'frontdoor-canonical-route-sweep',
script: 'live-frontdoor-canonical-route-sweep.mjs',
reportPath: path.join(outputDir, 'live-frontdoor-canonical-route-sweep.json'),
},
{
name: 'mission-control-action-sweep',
script: 'live-mission-control-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-mission-control-action-sweep.json'),
},
{
name: 'ops-policy-action-sweep',
script: 'live-ops-policy-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-ops-policy-action-sweep.json'),
},
{
name: 'integrations-action-sweep',
script: 'live-integrations-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-integrations-action-sweep.json'),
},
{
name: 'setup-topology-action-sweep',
script: 'live-setup-topology-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-setup-topology-action-sweep.json'),
},
{
name: 'setup-admin-action-sweep',
script: 'live-setup-admin-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-setup-admin-action-sweep.json'),
},
{
name: 'user-reported-admin-trust-check',
script: 'live-user-reported-admin-trust-check.mjs',
reportPath: path.join(outputDir, 'live-user-reported-admin-trust-check.json'),
},
{
name: 'jobs-queues-action-sweep',
script: 'live-jobs-queues-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-jobs-queues-action-sweep.json'),
},
{
name: 'triage-artifacts-scope-compat',
script: 'live-triage-artifacts-scope-compat.mjs',
reportPath: path.join(outputDir, 'live-triage-artifacts-scope-compat.json'),
},
{
name: 'releases-deployments-check',
script: 'live-releases-deployments-check.mjs',
reportPath: path.join(outputDir, 'live-releases-deployments-check.json'),
},
{
name: 'release-promotion-submit-check',
script: 'live-release-promotion-submit-check.mjs',
reportPath: path.join(outputDir, 'live-release-promotion-submit-check.json'),
},
{
name: 'hotfix-action-check',
script: 'live-hotfix-action-check.mjs',
reportPath: path.join(outputDir, 'live-hotfix-action-check.json'),
},
{
name: 'registry-admin-audit-check',
script: 'live-registry-admin-audit-check.mjs',
reportPath: path.join(outputDir, 'live-registry-admin-audit-check.json'),
},
{
name: 'evidence-export-action-sweep',
script: 'live-evidence-export-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-evidence-export-action-sweep.json'),
},
{
name: 'watchlist-action-sweep',
script: 'live-watchlist-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-watchlist-action-sweep.json'),
},
{
name: 'notifications-watchlist-recheck',
script: 'live-notifications-watchlist-recheck.mjs',
reportPath: path.join(outputDir, 'live-notifications-watchlist-recheck.json'),
},
{
name: 'policy-simulation-direct-routes',
script: 'live-policy-simulation-direct-routes.mjs',
reportPath: path.join(outputDir, 'live-policy-simulation-direct-routes.json'),
},
{
name: 'uncovered-surface-action-sweep',
script: 'live-uncovered-surface-action-sweep.mjs',
reportPath: path.join(outputDir, 'live-uncovered-surface-action-sweep.json'),
},
{
name: 'unified-search-route-matrix',
script: 'live-frontdoor-unified-search-route-matrix.mjs',
reportPath: path.join(outputDir, 'live-frontdoor-unified-search-route-matrix.json'),
},
];
const failureCountKeys = new Set([
'failedRouteCount',
'failedCheckCount',
'failedChecks',
'failedActionCount',
'failedCount',
'failureCount',
'errorCount',
'runtimeIssueCount',
'issueCount',
'unexpectedErrorCount',
]);
const arrayFailureKeys = new Set([
'failures',
'runtimeIssues',
'runtimeErrors',
'errors',
'warnings',
]);
function collectFailureSignals(value) {
const signals = [];
function visit(node, trail = []) {
if (Array.isArray(node)) {
if (trail.at(-1) && arrayFailureKeys.has(trail.at(-1)) && node.length > 0) {
signals.push({ path: trail.join('.'), count: node.length });
}
const failingEntries = node.filter((entry) => entry && typeof entry === 'object' && entry.ok === false);
if (failingEntries.length > 0) {
signals.push({ path: trail.join('.'), count: failingEntries.length });
}
node.forEach((entry, index) => visit(entry, [...trail, String(index)]));
return;
}
if (!node || typeof node !== 'object') {
return;
}
for (const [key, child] of Object.entries(node)) {
if (typeof child === 'number' && failureCountKeys.has(key) && child > 0) {
signals.push({ path: [...trail, key].join('.'), count: child });
}
visit(child, [...trail, key]);
}
}
visit(value);
return signals;
}
async function readReport(reportPath) {
try {
const content = await readFile(reportPath, 'utf8');
return JSON.parse(content);
} catch (error) {
return {
reportReadFailed: true,
error: error instanceof Error ? error.message : String(error),
};
}
}
function runSuite({ name, script }) {
return new Promise((resolve) => {
const startedAt = Date.now();
const child = spawn(process.execPath, [path.join(__dirname, script)], {
cwd: webRoot,
env: process.env,
stdio: 'inherit',
});
child.on('exit', (code, signal) => {
resolve({
name,
script,
exitCode: code ?? null,
signal: signal ?? null,
durationMs: Date.now() - startedAt,
});
});
});
}
async function main() {
await mkdir(outputDir, { recursive: true });
const summary = {
generatedAtUtc: new Date().toISOString(),
baseUrl: process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local',
suiteCount: suites.length,
suites: [],
};
for (const suite of suites) {
process.stdout.write(`[live-full-core-audit] START ${suite.name}\n`);
const execution = await runSuite(suite);
const report = await readReport(suite.reportPath);
const failureSignals = collectFailureSignals(report);
const ok = execution.exitCode === 0 && failureSignals.length === 0 && !report.reportReadFailed;
const result = {
...execution,
reportPath: suite.reportPath,
ok,
failureSignals,
report,
};
summary.suites.push(result);
process.stdout.write(
`[live-full-core-audit] DONE ${suite.name} ok=${ok} exitCode=${execution.exitCode ?? 'null'} ` +
`signals=${failureSignals.length} durationMs=${execution.durationMs}\n`,
);
}
summary.failedSuiteCount = summary.suites.filter((suite) => !suite.ok).length;
summary.passedSuiteCount = summary.suiteCount - summary.failedSuiteCount;
summary.failedSuites = summary.suites
.filter((suite) => !suite.ok)
.map((suite) => ({
name: suite.name,
exitCode: suite.exitCode,
signal: suite.signal,
failureSignals: suite.failureSignals,
reportPath: suite.reportPath,
}));
await writeFile(resultPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
if (summary.failedSuiteCount > 0) {
process.exitCode = 1;
}
}
await main();

View File

@@ -239,6 +239,28 @@ async function waitForAnyButton(page, names, timeoutMs = ELEMENT_WAIT_MS) {
return null;
}
async function waitForEnabledButton(page, names, timeoutMs = ELEMENT_WAIT_MS) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
for (const name of names) {
const locator = page.getByRole('button', { name });
const count = await locator.count();
for (let index = 0; index < count; index += 1) {
const candidate = locator.nth(index);
const disabled = await candidate.isDisabled().catch(() => true);
if (!disabled) {
return { name, locator: candidate };
}
}
}
await page.waitForTimeout(250);
}
return null;
}
async function clickLink(context, page, route, name, index = 0) {
await navigate(page, route);
const target = await waitForNavigationTarget(page, name, index);
@@ -380,8 +402,8 @@ async function exerciseShadowResults(page) {
let restoredDisabledState = false;
if (initiallyDisabled) {
const enableButton = page.getByRole('button', { name: 'Enable' }).first();
if ((await enableButton.count()) === 0) {
const enableTarget = await waitForEnabledButton(page, ['Enable Shadow Mode', /^Enable$/], 12_000);
if (!enableTarget) {
return {
action: 'button:View Results',
ok: false,
@@ -391,16 +413,13 @@ async function exerciseShadowResults(page) {
};
}
await enableButton.click({ timeout: 10_000 });
await enableTarget.locator.click({ timeout: 10_000 });
enabledInFlow = true;
await Promise.race([
page.waitForFunction(() => {
const buttons = Array.from(document.querySelectorAll('button'));
const button = buttons.find((candidate) => (candidate.textContent || '').trim() === 'View Results');
return button instanceof HTMLButtonElement && !button.disabled;
}, null, { timeout: 12_000 }).catch(() => {}),
page.waitForTimeout(2_000),
]);
await page.waitForFunction(() => {
const buttons = Array.from(document.querySelectorAll('button'));
const button = buttons.find((candidate) => (candidate.textContent || '').trim() === 'View Results');
return button instanceof HTMLButtonElement && !button.disabled;
}, null, { timeout: 12_000 }).catch(() => {});
steps.push({
step: 'enable-shadow-mode',
snapshot: await captureSnapshot(page, 'policy-simulation:enabled-shadow-mode'),

View File

@@ -20,7 +20,7 @@ const topologyScope = {
timeWindow: '7d',
};
const topologyScopeQuery = new URLSearchParams(topologyScope).toString();
const STEP_TIMEOUT_MS = 30_000;
const STEP_TIMEOUT_MS = 60_000;
const GENERIC_TITLES = new Set(['StellaOps', 'Stella Ops Dashboard']);
const ROUTE_READINESS = [
{
@@ -41,17 +41,17 @@ const ROUTE_READINESS = [
{
path: '/setup/topology/targets',
title: 'Targets - StellaOps',
markers: ['No targets for current filters.', 'Select a target row to view its topology mapping details.'],
markers: ['Targets', 'Selected Target', 'No targets for current filters.'],
},
{
path: '/setup/topology/hosts',
title: 'Hosts - StellaOps',
markers: ['No hosts for current filters.', 'Select a host row to inspect runtime drift and impact.'],
markers: ['Hosts', 'Selected Host', 'No hosts for current filters.'],
},
{
path: '/setup/topology/agents',
title: 'Agent Fleet - StellaOps',
markers: ['No groups for current filters.', 'All Agents', 'View Targets'],
markers: ['Agent Groups', 'All Agents', 'No groups for current filters.'],
},
];
@@ -135,6 +135,10 @@ function routeReadiness(pathname) {
return ROUTE_READINESS.find((entry) => entry.path === pathname) ?? null;
}
function normalizeReadyTitle(value) {
return value.toLowerCase().replace(/[^a-z0-9]+/g, '');
}
async function waitForRouteReady(page, routeOrPath) {
const expectedPath = routePath(routeOrPath);
const readiness = routeReadiness(expectedPath);
@@ -165,7 +169,12 @@ async function waitForRouteReady(page, routeOrPath) {
}
const title = document.title.trim();
if (title.length === 0 || genericTitles.includes(title) || title !== expectedTitle) {
const normalizedTitle = title.toLowerCase().replace(/[^a-z0-9]+/g, '');
if (
title.length === 0 ||
genericTitles.includes(title) ||
!normalizedTitle.includes(expectedTitle)
) {
return false;
}
@@ -174,7 +183,7 @@ async function waitForRouteReady(page, routeOrPath) {
},
{
expectedPathValue: expectedPath,
expectedTitle: readiness.title,
expectedTitle: normalizeReadyTitle(readiness.title),
markers: readiness.markers,
genericTitles: [...GENERIC_TITLES],
},

View File

@@ -0,0 +1,423 @@
#!/usr/bin/env node
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const webRoot = path.resolve(__dirname, '..');
const outputDir = path.join(webRoot, 'output', 'playwright');
const outputPath = path.join(outputDir, 'live-uncovered-surface-action-sweep.json');
const authStatePath = path.join(outputDir, 'live-uncovered-surface-action-sweep.state.json');
const authReportPath = path.join(outputDir, 'live-uncovered-surface-action-sweep.auth.json');
const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
const scopeQuery = 'tenant=demo-prod&regions=us-east&environments=stage&timeWindow=7d';
const linkChecks = [
['/releases/overview', 'Release Versions', '/releases/versions'],
['/releases/overview', 'Release Runs', '/releases/runs'],
['/releases/overview', 'Approvals Queue', '/releases/approvals'],
['/releases/overview', 'Hotfixes', '/releases/hotfixes'],
['/releases/overview', 'Promotions', '/releases/promotions'],
['/releases/overview', 'Deployment History', '/releases/deployments'],
['/releases/runs', 'Timeline', '/releases/runs?view=timeline'],
['/releases/runs', 'Table', '/releases/runs?view=table'],
['/releases/runs', 'Correlations', '/releases/runs?view=correlations'],
['/releases/approvals', 'Pending', '/releases/approvals?tab=pending'],
['/releases/approvals', 'Approved', '/releases/approvals?tab=approved'],
['/releases/approvals', 'Rejected', '/releases/approvals?tab=rejected'],
['/releases/approvals', 'Expiring', '/releases/approvals?tab=expiring'],
['/releases/approvals', 'My Team', '/releases/approvals?tab=my-team'],
['/releases/environments', 'Open Environment', '/setup/topology/environments/stage/posture'],
['/releases/environments', 'Open Targets', '/setup/topology/targets'],
['/releases/environments', 'Open Agents', '/setup/topology/agents'],
['/releases/environments', 'Open Runs', '/releases/runs'],
['/releases/investigation/deploy-diff', 'Open Deployments', '/releases/deployments'],
['/releases/investigation/deploy-diff', 'Open Releases Overview', '/releases/overview'],
['/releases/investigation/change-trace', 'Open Deployments', '/releases/deployments'],
['/security/posture', 'Open triage', '/security/triage'],
['/security/posture', 'Disposition', '/security/disposition'],
['/security/posture', 'Configure sources', '/ops/integrations/advisory-vex-sources'],
['/security/posture', 'Open reachability coverage board', '/security/reachability'],
['/security/advisories-vex', 'Providers', '/security/advisories-vex?tab=providers'],
['/security/advisories-vex', 'VEX Library', '/security/advisories-vex?tab=vex-library'],
['/security/advisories-vex', 'Issuer Trust', '/security/advisories-vex?tab=issuer-trust'],
['/security/disposition', 'Conflicts', '/security/disposition?tab=conflicts'],
['/security/supply-chain-data', 'SBOM Graph', '/security/supply-chain-data/graph'],
['/security/supply-chain-data', 'Reachability', '/security/reachability'],
['/evidence/overview', 'Audit Log', '/evidence/audit-log'],
['/evidence/overview', 'Export Center', '/evidence/exports'],
['/evidence/overview', 'Replay & Verify', '/evidence/verify-replay'],
['/evidence/audit-log', 'View All Events', '/evidence/audit-log/events'],
['/evidence/audit-log', 'Export', '/evidence/exports'],
['/evidence/audit-log', /Policy Audit/i, '/evidence/audit-log/policy'],
['/ops/operations/health-slo', 'View Full Timeline', '/ops/operations/health-slo/incidents'],
['/ops/operations/feeds-airgap', 'Configure Sources', '/ops/integrations/advisory-vex-sources'],
['/ops/operations/feeds-airgap', 'Open Offline Bundles', '/ops/operations/offline-kit/bundles'],
['/ops/operations/feeds-airgap', 'Version Locks', '/ops/operations/feeds-airgap?tenant=demo-prod&regions=us-east&environments=stage&timeWindow=7d&tab=version-locks'],
['/ops/operations/data-integrity', 'Feeds Freshness WARN Impact: BLOCKING NVD feed stale by 3h 12m', '/ops/operations/data-integrity/feeds-freshness'],
['/ops/operations/data-integrity', 'Hotfix 1.2.4', '/releases/approvals?releaseId=rel-hotfix-124'],
['/ops/operations/jobengine', 'Scheduler Runs', '/ops/operations/scheduler/runs'],
['/ops/operations/jobengine', /Execution Quotas/i, '/ops/operations/jobengine/quotas'],
['/ops/operations/offline-kit', 'Bundles', '/ops/operations/offline-kit/bundles'],
['/ops/operations/offline-kit', 'JWKS', '/ops/operations/offline-kit/jwks'],
];
const buttonChecks = [
['/releases/versions', 'Create Release Version', '/releases/versions/new'],
['/releases/versions', 'Create Hotfix Run', '/releases/versions/new'],
['/releases/versions', 'Search'],
['/releases/versions', 'Clear'],
['/releases/investigation/timeline', 'Export'],
['/releases/investigation/change-trace', 'Export'],
['/security/sbom-lake', 'Refresh'],
['/security/sbom-lake', 'Clear'],
['/security/reachability', 'Witnesses'],
['/security/reachability', /PoE|Proof of Exposure/i],
['/evidence/capsules', 'Search'],
['/evidence/threads', 'Search'],
['/evidence/proofs', 'Search'],
['/ops/operations/system-health', 'Services'],
['/ops/operations/system-health', 'Incidents'],
['/ops/operations/system-health', 'Quick Diagnostics'],
['/ops/operations/scheduler', 'Manage Schedules', '/ops/operations/scheduler/schedules'],
['/ops/operations/scheduler', 'Worker Fleet', '/ops/operations/scheduler/workers'],
['/ops/operations/doctor', 'Quick Check'],
['/ops/operations/signals', 'Refresh'],
['/ops/operations/packs', 'Refresh'],
['/ops/operations/status', 'Refresh'],
];
function buildUrl(route) {
const separator = route.includes('?') ? '&' : '?';
return `${baseUrl}${route}${separator}${scopeQuery}`;
}
async function settle(page) {
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(1_500);
}
async function captureSnapshot(page, label) {
const alerts = await page
.locator('[role="alert"], .mat-mdc-snack-bar-container, .toast, .notification, .error-banner, .warning-banner')
.evaluateAll((nodes) =>
nodes
.map((node) => (node.textContent || '').trim().replace(/\s+/g, ' '))
.filter(Boolean)
.slice(0, 5),
)
.catch(() => []);
return {
label,
url: page.url(),
title: await page.title().catch(() => ''),
heading: await page.locator('h1, h2, [data-testid="page-title"], .page-title').first().innerText().catch(() => ''),
alerts,
};
}
async function navigate(page, route) {
await page.goto(buildUrl(route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
await settle(page);
}
async function findLink(page, name, timeoutMs = 10_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const link = page.getByRole('link', { name }).first();
if (await link.count()) {
return link;
}
await page.waitForTimeout(250);
}
return null;
}
function matchesLocatorName(name, text) {
if (!text) {
return false;
}
const normalizedText = text.trim();
if (!normalizedText) {
return false;
}
if (name instanceof RegExp) {
return name.test(normalizedText);
}
return normalizedText.includes(name);
}
async function findScopedLink(page, scopeSelector, name) {
const scope = page.locator(scopeSelector).first();
if (!(await scope.count())) {
return null;
}
const descendantLink = scope.getByRole('link', { name }).first();
if (await descendantLink.count()) {
return descendantLink;
}
const [tagName, href, text] = await Promise.all([
scope.evaluate((element) => element.tagName.toLowerCase()).catch(() => ''),
scope.getAttribute('href').catch(() => null),
scope.textContent().catch(() => ''),
]);
if (tagName === 'a' && href && matchesLocatorName(name, text ?? '')) {
return scope;
}
return null;
}
async function findLinkForRoute(page, route, name, timeoutMs = 10_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
let locator = null;
if (route === '/releases/environments' && name === 'Open Environment') {
locator = page.locator('.actions').getByRole('link', { name }).first();
} else if (route === '/ops/operations/jobengine' && name instanceof RegExp && String(name) === String(/Execution Quotas/i)) {
locator = await findScopedLink(page, '[data-testid="jobengine-quotas-card"]', name);
} else if (route === '/ops/operations/offline-kit' && name === 'Bundles') {
locator = page.locator('.tab-nav').getByRole('link', { name }).first();
} else {
locator = page.getByRole('link', { name }).first();
}
if (await locator.count()) {
return locator;
}
await page.waitForTimeout(250);
}
return null;
}
async function findButton(page, name, timeoutMs = 10_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
for (const role of ['button', 'tab']) {
const button = page.getByRole(role, { name }).first();
if (await button.count()) {
return button;
}
}
await page.waitForTimeout(250);
}
return null;
}
function normalizeUrl(url) {
return decodeURIComponent(url);
}
function shouldIgnoreConsoleError(message) {
return message === 'Failed to load resource: the server responded with a status of 401 ()';
}
function shouldIgnoreRequestFailure(request) {
return request.failure === 'net::ERR_ABORTED';
}
function shouldIgnoreResponseError(response) {
return response.status === 401 && /\/doctor\/api\/v1\/doctor\/run\/[^/]+\/stream$/i.test(response.url);
}
async function runLinkCheck(page, route, name, expectedPath) {
const action = `${route} -> link:${name}`;
try {
await navigate(page, route);
const link = await findLinkForRoute(page, route, name);
if (
!link &&
route === '/ops/operations/jobengine' &&
name instanceof RegExp &&
String(name) === String(/Execution Quotas/i)
) {
const quotasCardText = await page.locator('[data-testid="jobengine-quotas-card"]').textContent().catch(() => '');
const snapshot = await captureSnapshot(page, action);
return {
action,
ok: /access required to manage quotas/i.test(quotasCardText ?? ''),
expectedPath,
finalUrl: normalizeUrl(snapshot.url),
snapshot,
reason: 'restricted-card',
};
}
if (!link) {
return { action, ok: false, reason: 'missing-link', snapshot: await captureSnapshot(page, action) };
}
await link.click({ timeout: 10_000 });
await settle(page);
const snapshot = await captureSnapshot(page, action);
const finalUrl = normalizeUrl(snapshot.url);
return {
action,
ok: finalUrl.includes(expectedPath),
expectedPath,
finalUrl,
snapshot,
};
} catch (error) {
return {
action,
ok: false,
reason: 'exception',
error: error instanceof Error ? error.message : String(error),
snapshot: await captureSnapshot(page, action),
};
}
}
async function runButtonCheck(page, route, name, expectedPath = null) {
const action = `${route} -> button:${name}`;
try {
await navigate(page, route);
const button = await findButton(page, name);
if (!button) {
return { action, ok: false, reason: 'missing-button', snapshot: await captureSnapshot(page, action) };
}
const disabled = await button.isDisabled().catch(() => false);
if (disabled) {
const snapshot = await captureSnapshot(page, action);
return {
action,
ok: true,
reason: 'disabled-by-design',
expectedPath,
finalUrl: normalizeUrl(snapshot.url),
snapshot,
};
}
await button.click({ timeout: 10_000 });
await settle(page);
const snapshot = await captureSnapshot(page, action);
const finalUrl = normalizeUrl(snapshot.url);
const hasRuntimeAlert = snapshot.alerts.some((text) => /(error|failed|unable|timed out|unavailable)/i.test(text));
return {
action,
ok: expectedPath ? finalUrl.includes(expectedPath) : snapshot.heading.trim().length > 0 && !hasRuntimeAlert,
expectedPath,
finalUrl,
snapshot,
};
} catch (error) {
return {
action,
ok: false,
reason: 'exception',
error: error instanceof Error ? error.message : String(error),
snapshot: await captureSnapshot(page, action),
};
}
}
async function main() {
await mkdir(outputDir, { recursive: true });
const browser = await chromium.launch({
headless: process.env.PLAYWRIGHT_HEADLESS !== 'false',
});
const auth = await authenticateFrontdoor({ statePath: authStatePath, reportPath: authReportPath });
const context = await createAuthenticatedContext(browser, auth, { statePath: auth.statePath });
const page = await context.newPage();
const results = [];
const runtime = {
consoleErrors: [],
pageErrors: [],
requestFailures: [],
responseErrors: [],
};
page.on('console', (message) => {
if (message.type() === 'error' && !shouldIgnoreConsoleError(message.text())) {
runtime.consoleErrors.push(message.text());
}
});
page.on('pageerror', (error) => runtime.pageErrors.push(error.message));
page.on('requestfailed', (request) => {
const failure = request.failure()?.errorText ?? 'unknown';
if (!shouldIgnoreRequestFailure({ url: request.url(), failure })) {
runtime.requestFailures.push({ url: request.url(), failure });
}
});
page.on('response', (response) => {
if (response.status() >= 400) {
const candidate = { url: response.url(), status: response.status() };
if (!shouldIgnoreResponseError(candidate)) {
runtime.responseErrors.push(candidate);
}
}
});
try {
for (const [route, name, expectedPath] of linkChecks) {
process.stdout.write(`[live-uncovered-surface-action-sweep] START ${route} -> link:${name}\n`);
const result = await runLinkCheck(page, route, name, expectedPath);
process.stdout.write(
`[live-uncovered-surface-action-sweep] DONE ${route} -> link:${name} ok=${result.ok}\n`,
);
results.push(result);
}
for (const [route, name, expectedPath] of buttonChecks) {
process.stdout.write(`[live-uncovered-surface-action-sweep] START ${route} -> button:${name}\n`);
const result = await runButtonCheck(page, route, name, expectedPath);
process.stdout.write(
`[live-uncovered-surface-action-sweep] DONE ${route} -> button:${name} ok=${result.ok}\n`,
);
results.push(result);
}
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
await browser.close().catch(() => {});
}
const failedActions = results.filter((result) => !result.ok);
const report = {
generatedAtUtc: new Date().toISOString(),
baseUrl,
actionCount: results.length,
passedActionCount: results.length - failedActions.length,
failedActionCount: failedActions.length,
failedActions,
results,
runtime,
runtimeIssueCount:
runtime.consoleErrors.length + runtime.pageErrors.length + runtime.requestFailures.length + runtime.responseErrors.length,
};
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
if (failedActions.length > 0 || report.runtimeIssueCount > 0) {
process.exitCode = 1;
}
}
await main();

View File

@@ -0,0 +1,548 @@
#!/usr/bin/env node
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const webRoot = path.resolve(__dirname, '..');
const outputDir = path.join(webRoot, 'output', 'playwright');
const outputPath = path.join(outputDir, 'live-user-reported-admin-trust-check.json');
const authStatePath = path.join(outputDir, 'live-user-reported-admin-trust-check.state.json');
const authReportPath = path.join(outputDir, 'live-user-reported-admin-trust-check.auth.json');
const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
const scopeQuery = 'tenant=demo-prod&regions=us-east&environments=stage&timeWindow=7d';
function buildUrl(route) {
const separator = route.includes('?') ? '&' : '?';
return `${baseUrl}${route}${separator}${scopeQuery}`;
}
async function settle(page, ms = 1500) {
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(ms);
}
async function navigate(page, route) {
await page.goto(buildUrl(route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
await settle(page);
}
async function snapshot(page, label) {
const heading = await page.locator('h1, h2, [data-testid="page-title"], .page-title').first().innerText().catch(() => '');
const alerts = await page
.locator('[role="alert"], .alert, .error-banner, .success-banner, .loading-text, .trust-admin__error, .trust-admin__loading')
.evaluateAll((nodes) => nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 8))
.catch(() => []);
return {
label,
url: page.url(),
title: await page.title().catch(() => ''),
heading,
alerts,
};
}
function boxesOverlap(left, right) {
if (!left || !right) {
return null;
}
return !(
left.x + left.width <= right.x ||
right.x + right.width <= left.x ||
left.y + left.height <= right.y ||
right.y + right.height <= left.y
);
}
async function collectTrustTabState(page, tab) {
const tableRowCount = await page.locator('tbody tr').count().catch(() => 0);
const eventCardCount = await page.locator('.event-card').count().catch(() => 0);
const emptyTexts = await page
.locator('.key-dashboard__empty, .issuer-trust__empty, .certificate-inventory__empty, .trust-audit-log__empty, .empty-state')
.evaluateAll((nodes) => nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 6))
.catch(() => []);
const loadingTexts = await page
.locator('.key-dashboard__loading, .trust-admin__loading, .issuer-trust__loading, .certificate-inventory__loading, .trust-audit-log__loading, .loading-text')
.evaluateAll((nodes) => nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 6))
.catch(() => []);
const primaryButtons = await page
.locator('button')
.evaluateAll((nodes) =>
nodes
.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim())
.filter(Boolean)
.slice(0, 8),
)
.catch(() => []);
return {
tab,
tableRowCount,
eventCardCount,
emptyTexts,
loadingTexts,
primaryButtons,
};
}
async function createRoleCheck(page) {
const roleName = `qa-role-${Date.now()}`;
await page.getByRole('button', { name: '+ Create Role' }).click();
await settle(page, 750);
await page.locator('input[placeholder="security-analyst"]').fill(roleName);
await page.locator('input[placeholder="Security analyst with triage access"]').fill('QA-created role');
await page.locator('textarea[placeholder*="findings:read"]').fill('findings:read, vex:read');
await page.getByRole('button', { name: 'Create Role', exact: true }).click();
await settle(page, 1250);
const successText = await page.locator('.success-banner').first().textContent().then((text) => text?.trim() || '').catch(() => '');
const tableContainsRole = await page.locator('tbody tr td:first-child').evaluateAll(
(cells, expected) => cells.some((cell) => (cell.textContent || '').replace(/\s+/g, ' ').trim() === expected),
roleName,
).catch(() => false);
return {
roleName,
successText,
tableContainsRole,
};
}
async function createTenantCheck(page) {
const tenantId = `qa-tenant-${Date.now()}`;
const displayName = `QA Tenant ${Date.now()}`;
await page.getByRole('button', { name: '+ Add Tenant' }).click();
await settle(page, 750);
await page.locator('input[placeholder="customer-stage"]').fill(tenantId);
await page.locator('input[placeholder="Customer Stage"]').fill(displayName);
await page.locator('select').last().selectOption('shared').catch(() => {});
await page.getByRole('button', { name: 'Create Tenant', exact: true }).click();
await settle(page, 1250);
const successText = await page.locator('.success-banner').first().textContent().then((text) => text?.trim() || '').catch(() => '');
const tableContainsTenant = await page.locator('tbody tr td:first-child').evaluateAll(
(cells, expected) => cells.some((cell) => (cell.textContent || '').replace(/\s+/g, ' ').trim() === expected),
displayName,
).catch(() => false);
return {
tenantId,
displayName,
successText,
tableContainsTenant,
};
}
async function collectReportsTabState(page, tab) {
await page.getByRole('tab', { name: tab }).click();
await settle(page, 1000);
return {
tab,
url: page.url(),
headings: await page.locator('h1, h2, h3').evaluateAll((nodes) =>
nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 16)
).catch(() => []),
primaryButtons: await page.locator('main button').evaluateAll((nodes) =>
nodes.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, 12)
).catch(() => []),
};
}
async function runSearchQueryCheck(page, query) {
const searchInput = page.locator('input[aria-label="Global search"]').first();
const responses = [];
const responseListener = async (response) => {
if (!response.url().includes('/api/v1/search/query')) {
return;
}
try {
responses.push({
status: response.status(),
url: response.url(),
body: await response.json(),
});
} catch {
responses.push({
status: response.status(),
url: response.url(),
body: null,
});
}
};
page.on('response', responseListener);
try {
await searchInput.click();
await searchInput.fill(query);
await settle(page, 2500);
const cards = await page.locator('.entity-card').evaluateAll((nodes) =>
nodes.slice(0, 5).map((node) => {
const title = node.querySelector('.entity-card__title')?.textContent?.trim() || '';
const domain = node.querySelector('.entity-card__badge')?.textContent?.trim() || '';
const snippet = node.querySelector('.entity-card__snippet')?.textContent?.replace(/\s+/g, ' ').trim() || '';
const actions = Array.from(node.querySelectorAll('.entity-card__action'))
.map((button) => button.textContent?.replace(/\s+/g, ' ').trim() || '')
.filter(Boolean);
return { title, domain, snippet, actions };
}),
).catch(() => []);
const firstPrimaryAction = page.locator('.entity-card').first().locator('.entity-card__action--primary').first();
const primaryActionLabel = await firstPrimaryAction.textContent().then((text) => text?.replace(/\s+/g, ' ').trim() || '').catch(() => '');
await firstPrimaryAction.click({ timeout: 10000 }).catch(() => {});
await settle(page, 1500);
const latestResponse = responses.at(-1)?.body;
return {
query,
cards,
primaryActionLabel,
latestDiagnostics: latestResponse?.diagnostics ?? null,
latestCardActions: (latestResponse?.cards ?? []).slice(0, 3).map((card) => ({
title: card.title ?? '',
domain: card.domain ?? '',
actions: (card.actions ?? []).map((action) => ({
label: action.label ?? '',
actionType: action.actionType ?? '',
route: action.route ?? '',
})),
})),
finalUrl: page.url(),
snapshot: await snapshot(page, `search:${query}`),
};
} finally {
page.off('response', responseListener);
}
}
async function main() {
await mkdir(outputDir, { recursive: true });
const browser = await chromium.launch({ headless: process.env.PLAYWRIGHT_HEADLESS !== 'false' });
const auth = await authenticateFrontdoor({ statePath: authStatePath, reportPath: authReportPath });
const context = await createAuthenticatedContext(browser, auth, { statePath: auth.statePath });
const page = await context.newPage();
const results = [];
try {
console.log('[live-user-reported-admin-trust-check] sidebar');
await navigate(page, '/setup/identity-access');
const setupGroupContainsDiagnostics = await page
.locator('[role="group"][aria-label="Platform & Setup"]')
.getByText('Diagnostics', { exact: true })
.count()
.then((count) => count > 0)
.catch(() => false);
results.push({
action: 'sidebar:diagnostics-under-setup',
setupGroupContainsDiagnostics,
snapshot: await snapshot(page, 'sidebar:diagnostics-under-setup'),
});
console.log('[live-user-reported-admin-trust-check] invalid-email');
await page.getByRole('button', { name: 'Users' }).click();
await settle(page, 500);
await page.getByRole('button', { name: '+ Add User' }).click();
await settle(page, 500);
const emailInput = page.locator('input[type="email"]').first();
await page.locator('input[placeholder="e.g. jane.doe"]').fill(`qa-user-${Date.now()}`);
await emailInput.fill('not-an-email');
await page.locator('input[placeholder="Jane Doe"]').fill('QA Invalid Email');
const emailValidity = await emailInput.evaluate((input) => ({
valid: input.checkValidity(),
validationMessage: input.validationMessage,
}));
await page.getByRole('button', { name: 'Create User' }).click();
await settle(page, 1000);
results.push({
action: 'identity-access:create-user-invalid-email',
emailValidity,
snapshot: await snapshot(page, 'identity-access:create-user-invalid-email'),
});
console.log('[live-user-reported-admin-trust-check] roles');
await page.getByRole('button', { name: 'Roles' }).click();
await settle(page, 1000);
const roleNames = await page.locator('tbody tr td:first-child').evaluateAll((cells) =>
cells.map((cell) => (cell.textContent || '').replace(/\s+/g, ' ').trim()).filter((value) => value.length > 0),
).catch(() => []);
const roleCreate = await createRoleCheck(page);
results.push({
action: 'identity-access:roles-tab',
roleNames,
roleCreate,
snapshot: await snapshot(page, 'identity-access:roles-tab'),
});
console.log('[live-user-reported-admin-trust-check] tenants');
await page.getByRole('button', { name: 'Tenants' }).click();
await settle(page, 1000);
const tenantCreate = await createTenantCheck(page);
results.push({
action: 'identity-access:tenants-tab',
tenantCreate,
snapshot: await snapshot(page, 'identity-access:tenants-tab'),
});
console.log('[live-user-reported-admin-trust-check] trust-tabs');
await navigate(page, '/setup/trust-signing');
for (const tab of ['Signing Keys', 'Trusted Issuers', 'Certificates', 'Audit Log']) {
await page.getByRole('tab', { name: tab }).click();
await settle(page, 1500);
results.push({
action: `trust-signing:${tab}`,
detail: await collectTrustTabState(page, tab),
snapshot: await snapshot(page, `trust-signing:${tab}`),
});
}
console.log('[live-user-reported-admin-trust-check] reports-tabs');
await navigate(page, '/security/reports');
results.push({
action: 'security-reports:tabs-embedded',
detail: [
await collectReportsTabState(page, 'Risk Report'),
await collectReportsTabState(page, 'VEX Ledger'),
await collectReportsTabState(page, 'Evidence Export'),
],
snapshot: await snapshot(page, 'security-reports:tabs-embedded'),
});
console.log('[live-user-reported-admin-trust-check] triage');
await navigate(page, '/security/triage');
const triageRawSvgTextVisible = await page.locator('main').innerText().then((text) => /<svg|stroke-width|viewBox=/.test(text)).catch(() => false);
results.push({
action: 'security-triage:raw-svg-visible',
triageRawSvgTextVisible,
snapshot: await snapshot(page, 'security-triage:raw-svg-visible'),
});
console.log('[live-user-reported-admin-trust-check] decision-capsules');
await navigate(page, '/evidence/capsules');
const capsulesSearchInput = page.locator('.filter-bar__search-input').first();
const capsulesSearchButton = page.locator('.filter-bar__search-btn').first();
const capsulesSearchInputBox = await capsulesSearchInput.boundingBox().catch(() => null);
const capsulesSearchButtonBox = await capsulesSearchButton.boundingBox().catch(() => null);
results.push({
action: 'decision-capsules:search-layout',
overlaps: boxesOverlap(capsulesSearchInputBox, capsulesSearchButtonBox),
inputBox: capsulesSearchInputBox,
buttonBox: capsulesSearchButtonBox,
snapshot: await snapshot(page, 'decision-capsules:search-layout'),
});
console.log('[live-user-reported-admin-trust-check] global-search');
await navigate(page, '/mission-control/board');
results.push({
action: 'global-search:cve',
detail: await runSearchQueryCheck(page, 'cve'),
});
console.log('[live-user-reported-admin-trust-check] docs');
await navigate(page, '/docs/modules/platform/architecture-overview.md');
results.push({
action: 'docs:architecture-overview',
snapshot: await snapshot(page, 'docs:architecture-overview'),
bodyPreview: await page.locator('.docs-viewer__content').innerText().then((text) => text.slice(0, 240)).catch(() => ''),
});
console.log('[live-user-reported-admin-trust-check] branding');
await navigate(page, '/setup/tenant-branding');
const titleInput = page.locator('#title');
const applyButton = page.getByRole('button', { name: 'Apply Changes' });
const tokenKeyInput = page.locator('input[placeholder="--theme-custom-color"]').first();
const tokenValueInput = page.locator('input[placeholder="var(--color-text-heading)"]').first();
const updatedTitle = `Stella Ops QA ${Date.now()}`;
await titleInput.fill(updatedTitle).catch(() => {});
await settle(page, 300);
const applyDisabled = await applyButton.isDisabled().catch(() => null);
await tokenKeyInput.fill(`--theme-qa-${Date.now()}`).catch(() => {});
await tokenValueInput.fill('#123456').catch(() => {});
await settle(page, 200);
const addTokenButton = page.getByRole('button', { name: 'Add Token' });
const addTokenDisabled = await addTokenButton.isDisabled().catch(() => null);
await addTokenButton.click().catch(() => {});
await settle(page, 500);
const tokenFormCleared =
(await tokenKeyInput.inputValue().catch(() => '')) === '' &&
(await tokenValueInput.inputValue().catch(() => '')) === '';
let applyResult = {
successText: '',
errorText: '',
url: page.url(),
freshAuthPrompt: '',
};
let persistedBranding = {
titleValue: '',
matchesUpdatedTitle: false,
};
if (applyDisabled === false) {
console.log('[live-user-reported-admin-trust-check] branding-apply');
page.once('dialog', async (dialog) => {
applyResult.freshAuthPrompt = dialog.message();
await dialog.accept().catch(() => {});
});
await applyButton.click().catch(() => {});
await settle(page, 2500);
applyResult = {
successText: await page.locator('.success, .success-banner, .alert-success').first().textContent().then((text) => text?.trim() || '').catch(() => ''),
errorText: await page.locator('.error, .error-banner, .alert-error').first().textContent().then((text) => text?.trim() || '').catch(() => ''),
url: page.url(),
freshAuthPrompt: applyResult.freshAuthPrompt,
};
await page.waitForTimeout(2500);
await navigate(page, '/setup/tenant-branding');
persistedBranding = {
titleValue: await page.locator('#title').inputValue().catch(() => ''),
matchesUpdatedTitle: (await page.locator('#title').inputValue().catch(() => '')) === updatedTitle,
};
}
results.push({
action: 'tenant-branding:action-state',
applyDisabled,
addTokenDisabled,
tokenFormCleared,
applyResult,
persistedBranding,
snapshot: await snapshot(page, 'tenant-branding:action-state'),
});
} finally {
console.log('[live-user-reported-admin-trust-check] cleanup');
await page.close().catch(() => {});
await context.close().catch(() => {});
await browser.close().catch(() => {});
}
const report = {
generatedAtUtc: new Date().toISOString(),
baseUrl,
results,
};
const failures = [];
const byAction = new Map(results.map((entry) => [entry.action, entry]));
if (!byAction.get('sidebar:diagnostics-under-setup')?.setupGroupContainsDiagnostics) {
failures.push('Diagnostics is not grouped under Platform & Setup in the sidebar.');
}
if (byAction.get('identity-access:create-user-invalid-email')?.emailValidity?.valid !== false) {
failures.push('Identity & Access user creation did not reject an invalid email address.');
}
if ((byAction.get('identity-access:roles-tab')?.roleNames?.length ?? 0) === 0) {
failures.push('Identity & Access roles table still shows empty role names.');
}
if (!byAction.get('identity-access:roles-tab')?.roleCreate?.tableContainsRole) {
failures.push('Identity & Access role creation did not persist a new role in the table.');
}
if (!byAction.get('identity-access:tenants-tab')?.tenantCreate?.tableContainsTenant) {
failures.push('Identity & Access tenant creation did not persist a new tenant in the table.');
}
for (const tab of ['Signing Keys', 'Trusted Issuers', 'Certificates', 'Audit Log']) {
const detail = byAction.get(`trust-signing:${tab}`)?.detail;
const resolved =
(detail?.tableRowCount ?? 0) > 0 ||
(detail?.eventCardCount ?? 0) > 0 ||
(detail?.emptyTexts?.length ?? 0) > 0;
const stillLoading = (detail?.loadingTexts?.length ?? 0) > 0;
if (!resolved || stillLoading) {
failures.push(`Trust & Signing tab "${tab}" did not resolve cleanly.`);
}
}
for (const tabState of byAction.get('security-reports:tabs-embedded')?.detail ?? []) {
if (!tabState.url.includes('/security/reports')) {
failures.push(`Security Reports tab "${tabState.tab}" still navigates away instead of embedding its workspace.`);
}
}
if (byAction.get('security-triage:raw-svg-visible')?.triageRawSvgTextVisible) {
failures.push('Triage still renders SVG markup as raw code.');
}
if (byAction.get('decision-capsules:search-layout')?.overlaps !== false) {
failures.push('Decision Capsules search input still overlaps the search button.');
}
const searchDetail = byAction.get('global-search:cve')?.detail;
const topCardDomain = (searchDetail?.cards?.[0]?.domain ?? '').toLowerCase();
const topCardTitle = searchDetail?.cards?.[0]?.title ?? '';
const topCardActionLabels = (searchDetail?.latestCardActions?.[0]?.actions ?? []).map((action) => action.label);
const topCardRoute = (searchDetail?.latestCardActions?.[0]?.actions ?? [])[0]?.route ?? '';
const topCardHasDeadNavigation = (searchDetail?.latestCardActions?.[0]?.actions ?? [])
.some((action) => action.actionType === 'navigate' && !action.route);
const searchLandedOnDashboardFallback =
searchDetail?.snapshot?.title === 'Dashboard - StellaOps' ||
searchDetail?.snapshot?.heading === 'Dashboard';
const searchLandedOnBlankDocs =
searchDetail?.finalUrl?.includes('/docs/') &&
!(searchDetail?.snapshot?.heading ?? '').trim();
if (
topCardDomain.includes('api') ||
topCardDomain.includes('knowledge') ||
topCardActionLabels.includes('Copy Curl') ||
topCardHasDeadNavigation ||
searchLandedOnDashboardFallback ||
searchLandedOnBlankDocs
) {
failures.push(`Global search still misranks generic CVE queries or routes them into dead docs/dashboard fallback (top card: ${topCardTitle || 'unknown'}).`);
}
const docsRoute = byAction.get('docs:architecture-overview');
if (!(docsRoute?.snapshot?.heading ?? '').trim() || !(docsRoute?.bodyPreview ?? '').trim()) {
failures.push('Direct /docs navigation still renders an empty or blank documentation page.');
}
if (byAction.get('tenant-branding:action-state')?.applyDisabled !== false) {
failures.push('Tenant & Branding apply action did not become available after editing.');
}
if (byAction.get('tenant-branding:action-state')?.tokenFormCleared !== true) {
failures.push('Tenant & Branding add-token action did not complete cleanly.');
}
const brandingApplyResult = byAction.get('tenant-branding:action-state')?.applyResult;
if (
byAction.get('tenant-branding:action-state')?.applyDisabled === false &&
!brandingApplyResult?.successText &&
!brandingApplyResult?.errorText
) {
failures.push('Tenant & Branding apply action did not produce a visible success or error outcome.');
}
if (
byAction.get('tenant-branding:action-state')?.applyDisabled === false &&
byAction.get('tenant-branding:action-state')?.persistedBranding?.matchesUpdatedTitle !== true
) {
failures.push('Tenant & Branding apply action did not persist the updated title after reload.');
}
report.failures = failures;
report.failedCheckCount = failures.length;
report.ok = failures.length === 0;
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
if (failures.length > 0) {
process.exitCode = 1;
}
}
await main();

View File

@@ -166,6 +166,21 @@ export const routes: Routes = [
data: { breadcrumb: 'Settings' },
loadChildren: () => import('./features/settings/settings.routes').then((m) => m.SETTINGS_ROUTES),
},
{
path: 'docs',
title: 'Documentation',
children: [
{
path: '',
pathMatch: 'full',
loadComponent: () => import('./features/docs/docs-viewer.component').then((m) => m.DocsViewerComponent),
},
{
path: '**',
loadComponent: () => import('./features/docs/docs-viewer.component').then((m) => m.DocsViewerComponent),
},
],
},
{
path: 'welcome',
title: 'Welcome',

View File

@@ -40,6 +40,9 @@ export interface AdminClient {
scopes: string[];
status: 'active' | 'disabled';
createdAt: string;
updatedAt?: string;
defaultTenant?: string;
tenants?: string[];
}
export interface AdminToken {
@@ -73,6 +76,18 @@ export interface CreateUserRequest {
roles: string[];
}
export interface CreateRoleRequest {
name: string;
description: string;
permissions: string[];
}
export interface CreateTenantRequest {
id: string;
displayName: string;
isolationMode: string;
}
export interface AuthorityAdminApi {
listUsers(tenantId?: string): Observable<AdminUser[]>;
listRoles(tenantId?: string): Observable<AdminRole[]>;
@@ -80,11 +95,89 @@ export interface AuthorityAdminApi {
listTokens(tenantId?: string): Observable<AdminToken[]>;
listTenants(): Observable<AdminTenant[]>;
createUser(request: CreateUserRequest): Observable<AdminUser>;
createRole(request: CreateRoleRequest): Observable<AdminRole>;
createTenant(request: CreateTenantRequest): Observable<AdminTenant>;
}
export const AUTHORITY_ADMIN_API = new InjectionToken<AuthorityAdminApi>('AUTHORITY_ADMIN_API');
export const AUTHORITY_ADMIN_API_BASE_URL = new InjectionToken<string>('AUTHORITY_ADMIN_API_BASE_URL');
interface AdminUsersResponseDto {
users?: AdminUserDto[];
}
interface AdminRolesResponseDto {
roles?: AdminRoleDto[];
}
interface AdminClientsResponseDto {
clients?: AdminClientDto[];
}
interface AdminTokensResponseDto {
tokens?: AdminTokenDto[];
}
interface AdminTenantsResponseDto {
tenants?: AdminTenantDto[];
}
interface AdminUserDto {
id?: string;
username?: string;
email?: string;
displayName?: string;
roles?: string[];
status?: string;
createdAt?: string;
lastLoginAt?: string;
}
interface AdminRoleDto {
id?: string;
name?: string;
description?: string;
permissions?: string[];
userCount?: number;
isBuiltIn?: boolean;
}
interface AdminClientDto {
id?: string;
clientId?: string;
displayName?: string;
grantTypes?: string[];
scopes?: string[];
status?: string;
createdAt?: string;
enabled?: boolean;
allowedGrantTypes?: string[];
allowedScopes?: string[];
updatedAt?: string;
defaultTenant?: string | null;
tenants?: string[];
}
interface AdminTokenDto {
id?: string;
name?: string;
clientId?: string;
scopes?: string[];
expiresAt?: string;
createdAt?: string;
lastUsedAt?: string;
status?: string;
}
interface AdminTenantDto {
id?: string;
displayName?: string;
status?: string;
isolationMode?: string;
userCount?: number;
createdAt?: string;
}
// ============================================================================
// HTTP Implementation
// ============================================================================
@@ -98,39 +191,59 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
) {}
listUsers(tenantId?: string): Observable<AdminUser[]> {
return this.http.get<{ users: AdminUser[] }>(`${this.baseUrl}/users`, {
return this.http.get<AdminUsersResponseDto>(`${this.baseUrl}/users`, {
headers: this.buildHeaders(tenantId),
}).pipe(map(r => r.users ?? []));
}).pipe(map((response) => (response.users ?? []).map((user) => this.mapUser(user))));
}
listRoles(tenantId?: string): Observable<AdminRole[]> {
return this.http.get<{ roles: AdminRole[] }>(`${this.baseUrl}/roles`, {
return this.http.get<AdminRolesResponseDto>(`${this.baseUrl}/roles`, {
headers: this.buildHeaders(tenantId),
}).pipe(map(r => r.roles ?? []));
}).pipe(map((response) => (response.roles ?? []).map((role) => this.mapRole(role))));
}
listClients(tenantId?: string): Observable<AdminClient[]> {
return this.http.get<{ clients: AdminClient[] }>(`${this.baseUrl}/clients`, {
return this.http.get<AdminClientsResponseDto>(`${this.baseUrl}/clients`, {
headers: this.buildHeaders(tenantId),
}).pipe(map(r => r.clients ?? []));
}).pipe(map((response) => (response.clients ?? []).map((client) => this.mapClient(client))));
}
listTokens(tenantId?: string): Observable<AdminToken[]> {
return this.http.get<{ tokens: AdminToken[] }>(`${this.baseUrl}/tokens`, {
return this.http.get<AdminTokensResponseDto>(`${this.baseUrl}/tokens`, {
headers: this.buildHeaders(tenantId),
}).pipe(map(r => r.tokens ?? []));
}).pipe(map((response) => (response.tokens ?? []).map((token) => this.mapToken(token))));
}
listTenants(): Observable<AdminTenant[]> {
return this.http.get<{ tenants: AdminTenant[] }>(`${this.baseUrl}/tenants`, {
return this.http.get<AdminTenantsResponseDto>(`${this.baseUrl}/tenants`, {
headers: this.buildHeaders(),
}).pipe(map(r => r.tenants ?? []));
}).pipe(map((response) => (response.tenants ?? []).map((tenant) => this.mapTenant(tenant))));
}
createUser(request: CreateUserRequest): Observable<AdminUser> {
return this.http.post<AdminUser>(`${this.baseUrl}/users`, request, {
return this.http.post<AdminUserDto>(`${this.baseUrl}/users`, request, {
headers: this.buildHeaders(),
});
}).pipe(map((user) => this.mapUser(user)));
}
createRole(request: CreateRoleRequest): Observable<AdminRole> {
return this.http.post<AdminRoleDto>(`${this.baseUrl}/roles`, {
roleId: request.name,
displayName: request.description,
scopes: request.permissions,
}, {
headers: this.buildHeaders(),
}).pipe(map((role) => this.mapRole(role)));
}
createTenant(request: CreateTenantRequest): Observable<AdminTenant> {
return this.http.post<AdminTenantDto>(`${this.baseUrl}/tenants`, {
id: request.id,
displayName: request.displayName,
isolationMode: request.isolationMode,
}, {
headers: this.buildHeaders(),
}).pipe(map((tenant) => this.mapTenant(tenant)));
}
private buildHeaders(tenantOverride?: string): HttpHeaders {
@@ -142,6 +255,86 @@ export class AuthorityAdminHttpClient implements AuthorityAdminApi {
[StellaOpsHeaders.Tenant]: tenantId,
});
}
private mapUser(dto: AdminUserDto): AdminUser {
const username = dto.username?.trim() || dto.email?.trim() || 'unknown-user';
return {
id: dto.id?.trim() || username,
username,
email: dto.email?.trim() || '',
displayName: dto.displayName?.trim() || username,
roles: (dto.roles ?? []).map((role) => role.trim()).filter((role) => role.length > 0),
status: this.normalizeStatus(dto.status, ['active', 'disabled', 'locked'], 'active'),
createdAt: dto.createdAt?.trim() || new Date(0).toISOString(),
lastLoginAt: dto.lastLoginAt?.trim() || undefined,
};
}
private mapRole(dto: AdminRoleDto): AdminRole {
const name = dto.name?.trim() || dto.id?.trim() || 'unnamed-role';
return {
id: dto.id?.trim() || name,
name,
description: dto.description?.trim() || '',
permissions: (dto.permissions ?? []).map((permission) => permission.trim()).filter((permission) => permission.length > 0),
userCount: dto.userCount ?? 0,
isBuiltIn: dto.isBuiltIn ?? false,
};
}
private mapClient(dto: AdminClientDto): AdminClient {
const clientId = dto.clientId?.trim() || dto.id?.trim() || 'unknown-client';
return {
id: dto.id?.trim() || clientId,
clientId,
displayName: dto.displayName?.trim() || clientId,
grantTypes: this.normalizeValues(dto.grantTypes ?? dto.allowedGrantTypes),
scopes: this.normalizeValues(dto.scopes ?? dto.allowedScopes),
status: this.normalizeStatus(
dto.status ?? (dto.enabled === false ? 'disabled' : 'active'),
['active', 'disabled'],
'active',
),
createdAt: dto.createdAt?.trim() || dto.updatedAt?.trim() || new Date(0).toISOString(),
updatedAt: dto.updatedAt?.trim() || undefined,
defaultTenant: dto.defaultTenant?.trim() || undefined,
tenants: this.normalizeValues(dto.tenants),
};
}
private mapToken(dto: AdminTokenDto): AdminToken {
return {
id: dto.id?.trim() || dto.name?.trim() || 'unknown-token',
name: dto.name?.trim() || 'Unnamed token',
clientId: dto.clientId?.trim() || 'unknown-client',
scopes: this.normalizeValues(dto.scopes),
expiresAt: dto.expiresAt?.trim() || '',
createdAt: dto.createdAt?.trim() || new Date(0).toISOString(),
lastUsedAt: dto.lastUsedAt?.trim() || undefined,
status: this.normalizeStatus(dto.status, ['active', 'expired', 'revoked'], 'active'),
};
}
private mapTenant(dto: AdminTenantDto): AdminTenant {
const tenantId = dto.id?.trim() || 'unknown-tenant';
return {
id: tenantId,
displayName: dto.displayName?.trim() || tenantId,
status: this.normalizeStatus(dto.status, ['active', 'disabled'], 'active'),
isolationMode: dto.isolationMode?.trim() || 'shared',
userCount: dto.userCount ?? 0,
createdAt: dto.createdAt?.trim() || new Date(0).toISOString(),
};
}
private normalizeValues(values?: string[]): string[] {
return (values ?? []).map((value) => value.trim()).filter((value) => value.length > 0);
}
private normalizeStatus<T extends string>(status: string | undefined, allowed: readonly T[], fallback: T): T {
const normalizedStatus = status?.trim().toLowerCase();
return allowed.includes(normalizedStatus as T) ? normalizedStatus as T : fallback;
}
}
// ============================================================================
@@ -210,4 +403,28 @@ export class MockAuthorityAdminClient implements AuthorityAdminApi {
};
return of(user).pipe(delay(400));
}
createRole(request: CreateRoleRequest): Observable<AdminRole> {
const role: AdminRole = {
id: `r-${Date.now()}`,
name: request.name,
description: request.description,
permissions: request.permissions,
userCount: 0,
isBuiltIn: false,
};
return of(role).pipe(delay(400));
}
createTenant(request: CreateTenantRequest): Observable<AdminTenant> {
const tenant: AdminTenant = {
id: request.id,
displayName: request.displayName,
isolationMode: request.isolationMode,
status: 'active',
userCount: 0,
createdAt: new Date().toISOString(),
};
return of(tenant).pipe(delay(400));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -188,6 +188,8 @@ describe('BrandingService', () => {
}).subscribe((response) => {
expect(response.branding.title).toBe('Demo Production');
expect(response.branding.logoUrl).toBe('data:image/png;base64,AAAA');
expect(response.message).toBe('Branding saved.');
expect(response.metadata?.hash).toBe('hash-456');
});
const req = httpMock.expectOne('/console/admin/branding');
@@ -202,6 +204,7 @@ describe('BrandingService', () => {
},
});
req.flush({
message: 'Branding saved.',
branding: {
tenantId: 'demo-prod',
displayName: 'Demo Production',
@@ -211,6 +214,10 @@ describe('BrandingService', () => {
'--theme-brand-primary': '#112233',
},
},
metadata: {
tenantId: 'demo-prod',
hash: 'hash-456',
},
});
});

View File

@@ -19,6 +19,8 @@ export interface BrandingConfiguration {
export interface BrandingResponse {
branding: BrandingConfiguration;
message?: string;
metadata?: BrandingMetadata;
}
export interface BrandingMetadata {
@@ -50,6 +52,7 @@ interface AuthorityBrandingDto {
interface AuthorityAdminBrandingEnvelopeDto {
branding: AuthorityBrandingDto;
message?: string;
metadata?: BrandingMetadata;
}
@@ -135,14 +138,18 @@ export class BrandingService {
themeTokens: request.themeTokens ?? {},
};
return this.http.put<{ branding: AuthorityBrandingDto }>(
return this.http.put<AuthorityAdminBrandingEnvelopeDto>(
'/console/admin/branding',
payload,
{
headers: this.buildTenantHeaders(resolvedTenantId),
}
).pipe(
map((response) => this.mapBrandingResponse(response.branding)),
map((response) => ({
...this.mapBrandingResponse(response.branding),
message: response.message,
metadata: response.metadata,
})),
tap((response) => {
this.applyBranding(response.branding);
})

View File

@@ -0,0 +1,43 @@
import {
buildDocsAssetCandidates,
buildDocsRoute,
normalizeDocsPath,
parseDocsUrl,
resolveDocsLink,
} from './docs-route';
describe('docs-route helpers', () => {
it('builds canonical docs routes without collapsing path segments', () => {
expect(buildDocsRoute('modules/platform/architecture-overview.md', 'release-flow'))
.toBe('/docs/modules/platform/architecture-overview.md#release-flow');
});
it('normalizes already-encoded full doc paths', () => {
expect(normalizeDocsPath('/docs/modules%2Fplatform%2Farchitecture-overview.md'))
.toBe('modules/platform/architecture-overview.md');
});
it('falls back to the docs index when the requested slug is empty', () => {
expect(buildDocsAssetCandidates('/docs')).toEqual(['/docs-content/README.md']);
});
it('tries markdown and README fallbacks for friendly slugs', () => {
expect(buildDocsAssetCandidates('getting-started')).toEqual([
'/docs-content/getting-started.md',
'/docs-content/getting-started/README.md',
'/docs-content/README.md',
]);
});
it('parses full docs urls into path and anchor', () => {
expect(parseDocsUrl('https://stella-ops.local/docs/modules/platform/architecture-overview.md#release-flow')).toEqual({
path: 'modules/platform/architecture-overview.md',
anchor: 'release-flow',
});
});
it('resolves relative markdown links against the current document path', () => {
expect(resolveDocsLink('../signals/architecture.md#telemetry', 'modules/platform/architecture-overview.md'))
.toBe('/docs/modules/signals/architecture.md#telemetry');
});
});

View File

@@ -0,0 +1,149 @@
const DOCS_ROUTE_PREFIX = '/docs';
const DOCS_ASSET_PREFIX = '/docs-content';
const DEFAULT_DOCS_PATH = 'README.md';
export interface ParsedDocsUrl {
path: string;
anchor: string | null;
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function hasExplicitExtension(path: string): boolean {
const lastSegment = path.split('/').at(-1) ?? '';
return /\.[a-z0-9]+$/i.test(lastSegment);
}
export function normalizeDocsAnchor(anchor: string | null | undefined): string | null {
const normalized = safeDecode((anchor ?? '').trim()).replace(/^#+/, '').trim();
return normalized.length > 0 ? normalized : null;
}
export function normalizeDocsPath(path: string | null | undefined): string {
const normalized = safeDecode((path ?? '').trim())
.replace(/^https?:\/\/[^/]+/i, '')
.split('#', 1)[0]
.split('?', 1)[0]
.replace(/\\/g, '/')
.replace(/^\/?docs\/?/, '')
.replace(/^\/+/, '')
.replace(/\/{2,}/g, '/')
.trim();
if (!normalized) {
return DEFAULT_DOCS_PATH;
}
if (normalized.endsWith('/')) {
return `${normalized}README.md`;
}
return normalized;
}
export function encodeDocsPath(path: string | null | undefined): string {
return normalizeDocsPath(path)
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
}
export function buildDocsRoute(path: string | null | undefined, anchor?: string | null): string {
const route = `${DOCS_ROUTE_PREFIX}/${encodeDocsPath(path)}`;
const normalizedAnchor = normalizeDocsAnchor(anchor);
return normalizedAnchor ? `${route}#${encodeURIComponent(normalizedAnchor)}` : route;
}
export function buildDocsAssetCandidates(path: string | null | undefined): string[] {
const normalizedPath = normalizeDocsPath(path);
const candidates = new Set<string>();
const addCandidate = (candidate: string) => {
candidates.add(`${DOCS_ASSET_PREFIX}/${candidate}`);
};
if (hasExplicitExtension(normalizedPath)) {
addCandidate(normalizedPath);
} else {
addCandidate(`${normalizedPath}.md`);
addCandidate(`${normalizedPath}/README.md`);
}
if (normalizedPath !== DEFAULT_DOCS_PATH) {
addCandidate(DEFAULT_DOCS_PATH);
}
return Array.from(candidates);
}
function resolveRelativeDocsPath(targetPath: string, currentDocPath: string): string {
const baseSegments = normalizeDocsPath(currentDocPath).split('/');
if (baseSegments.length > 0) {
baseSegments.pop();
}
for (const segment of targetPath.split('/')) {
const normalizedSegment = safeDecode(segment).trim();
if (!normalizedSegment || normalizedSegment === '.') {
continue;
}
if (normalizedSegment === '..') {
if (baseSegments.length > 0) {
baseSegments.pop();
}
continue;
}
baseSegments.push(normalizedSegment);
}
return baseSegments.join('/') || DEFAULT_DOCS_PATH;
}
export function resolveDocsLink(target: string, currentDocPath: string): string | null {
const normalizedTarget = target.trim();
if (!normalizedTarget) {
return null;
}
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(normalizedTarget)) {
return normalizedTarget;
}
if (normalizedTarget.startsWith('#')) {
const anchor = normalizeDocsAnchor(normalizedTarget);
return anchor ? `#${encodeURIComponent(anchor)}` : null;
}
const [pathPart, anchorPart] = normalizedTarget.split('#', 2);
if (pathPart.startsWith('/docs') || pathPart.startsWith('docs/')) {
return buildDocsRoute(pathPart, anchorPart);
}
if (pathPart.startsWith('/')) {
return buildDocsRoute(pathPart, anchorPart);
}
return buildDocsRoute(resolveRelativeDocsPath(pathPart, currentDocPath), anchorPart);
}
export function parseDocsUrl(url: string): ParsedDocsUrl {
const decodedUrl = safeDecode(url);
const hashIndex = decodedUrl.indexOf('#');
const beforeHash = hashIndex >= 0 ? decodedUrl.slice(0, hashIndex) : decodedUrl;
const rawAnchor = hashIndex >= 0 ? decodedUrl.slice(hashIndex + 1) : '';
const pathWithoutOrigin = beforeHash.replace(/^https?:\/\/[^/]+/i, '');
return {
path: normalizeDocsPath(pathWithoutOrigin),
anchor: normalizeDocsAnchor(rawAnchor),
};
}

View File

@@ -318,11 +318,36 @@ describe('Topology scope-preserving links', () => {
expect(component.selectedRegionId()).toBe('us-east');
expect(component.selectedEnvironmentId()).toBe('stage');
expect(links.find((item) => item.text === 'Open Environment')?.link.queryParams).toEqual({
environment: 'stage',
environments: 'stage',
});
expect(links.find((item) => item.text === 'Open Targets')?.link.queryParams).toEqual({ environment: 'stage' });
expect(links.find((item) => item.text === 'Open Agents')?.link.queryParams).toEqual({ environment: 'stage' });
expect(links.find((item) => item.text === 'Open Runs')?.link.queryParams).toEqual({ environment: 'stage' });
});
it('prefers the explicit route environment over broader hydrated context selections', () => {
const originalSelectedEnvironments = mockContextStore.selectedEnvironments;
mockContextStore.selectedEnvironments = () => ['dev', 'stage'];
try {
configureTestingModule(TopologyRegionsEnvironmentsPageComponent);
routeData$.next({ defaultView: 'region-first' });
queryParamMap$.next(convertToParamMap({ tenant: 'demo-prod', regions: 'us-east', environments: 'stage' }));
const fixture = TestBed.createComponent(TopologyRegionsEnvironmentsPageComponent);
fixture.detectChanges();
fixture.detectChanges();
const component = fixture.componentInstance;
expect(component.selectedRegionId()).toBe('us-east');
expect(component.selectedEnvironmentId()).toBe('stage');
} finally {
mockContextStore.selectedEnvironments = originalSelectedEnvironments;
}
});
it('marks promotion inventory links to merge the active query scope', () => {
configureTestingModule(TopologyPromotionPathsPageComponent);

View File

@@ -755,15 +755,9 @@ export class BrandingEditorComponent implements OnInit {
};
this.brandingService.updateBranding(payload).subscribe({
next: () => {
this.success.set('Branding applied successfully! Refreshing page...');
next: (response) => {
this.success.set(response.message || 'Branding applied successfully.');
this.hasChanges.set(false);
// Reload page after 2 seconds to ensure all components reflect the changes
setTimeout(() => {
window.location.reload();
}, 2000);
this.isSaving.set(false);
},
error: (err) => {

View File

@@ -0,0 +1,531 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { NavigationEnd, Router } from '@angular/router';
import { filter, firstValueFrom } from 'rxjs';
import {
buildDocsAssetCandidates,
normalizeDocsAnchor,
parseDocsUrl,
resolveDocsLink,
} from '../../core/navigation/docs-route';
interface DocsHeading {
level: number;
text: string;
id: string;
}
interface RenderedDocument {
title: string;
headings: DocsHeading[];
html: string;
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function slugifyHeading(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[`*_~]/g, '')
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
}
function renderInlineMarkdown(value: string, currentDocPath: string): string {
let rendered = escapeHtml(value);
rendered = rendered.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label: string, target: string) => {
const resolvedHref = resolveDocsLink(target, currentDocPath) ?? '#';
const isExternal = /^[a-z][a-z0-9+.-]*:\/\//i.test(resolvedHref);
const attributes = isExternal ? ' target="_blank" rel="noopener"' : '';
return `<a href="${escapeHtml(resolvedHref)}"${attributes}>${escapeHtml(label)}</a>`;
});
rendered = rendered.replace(/`([^`]+)`/g, '<code>$1</code>');
rendered = rendered.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
rendered = rendered.replace(/\*([^*]+)\*/g, '<em>$1</em>');
return rendered;
}
function renderMarkdownDocument(markdown: string, currentDocPath: string): RenderedDocument {
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
const parts: string[] = [];
const headings: DocsHeading[] = [];
let title = 'Documentation';
let index = 0;
while (index < lines.length) {
const line = lines[index];
if (!line.trim()) {
index++;
continue;
}
const codeFenceMatch = line.match(/^```(\w+)?\s*$/);
if (codeFenceMatch) {
const language = codeFenceMatch[1]?.trim() ?? '';
index++;
const codeLines: string[] = [];
while (index < lines.length && !/^```/.test(lines[index])) {
codeLines.push(lines[index]);
index++;
}
if (index < lines.length) {
index++;
}
parts.push(
`<pre class="docs-viewer__code"><code class="language-${escapeHtml(language)}">${escapeHtml(codeLines.join('\n'))}</code></pre>`,
);
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
const level = headingMatch[1].length;
const text = headingMatch[2].trim();
const id = slugifyHeading(text);
if (headings.length === 0 && text) {
title = text;
}
headings.push({ level, text, id });
parts.push(`<h${level} id="${id}">${renderInlineMarkdown(text, currentDocPath)}</h${level}>`);
index++;
continue;
}
if (/^\s*>\s?/.test(line)) {
const quoteLines: string[] = [];
while (index < lines.length && /^\s*>\s?/.test(lines[index])) {
quoteLines.push(lines[index].replace(/^\s*>\s?/, '').trim());
index++;
}
parts.push(
`<blockquote>${quoteLines.map((entry) => `<p>${renderInlineMarkdown(entry, currentDocPath)}</p>`).join('')}</blockquote>`,
);
continue;
}
if (/^\s*[-*]\s+/.test(line)) {
const items: string[] = [];
while (index < lines.length && /^\s*[-*]\s+/.test(lines[index])) {
items.push(lines[index].replace(/^\s*[-*]\s+/, '').trim());
index++;
}
parts.push(`<ul>${items.map((item) => `<li>${renderInlineMarkdown(item, currentDocPath)}</li>`).join('')}</ul>`);
continue;
}
if (/^\s*\d+\.\s+/.test(line)) {
const items: string[] = [];
while (index < lines.length && /^\s*\d+\.\s+/.test(lines[index])) {
items.push(lines[index].replace(/^\s*\d+\.\s+/, '').trim());
index++;
}
parts.push(`<ol>${items.map((item) => `<li>${renderInlineMarkdown(item, currentDocPath)}</li>`).join('')}</ol>`);
continue;
}
if (/^\|/.test(line)) {
const tableLines: string[] = [];
while (index < lines.length && /^\|/.test(lines[index])) {
tableLines.push(lines[index]);
index++;
}
parts.push(`<pre class="docs-viewer__table-fallback">${escapeHtml(tableLines.join('\n'))}</pre>`);
continue;
}
const paragraphLines: string[] = [];
while (
index < lines.length &&
lines[index].trim() &&
!/^(#{1,6})\s+/.test(lines[index]) &&
!/^```/.test(lines[index]) &&
!/^\s*>\s?/.test(lines[index]) &&
!/^\s*[-*]\s+/.test(lines[index]) &&
!/^\s*\d+\.\s+/.test(lines[index]) &&
!/^\|/.test(lines[index])
) {
paragraphLines.push(lines[index].trim());
index++;
}
parts.push(`<p>${renderInlineMarkdown(paragraphLines.join(' '), currentDocPath)}</p>`);
}
if (parts.length === 0) {
parts.push('<p>No rendered documentation content is available for this entry.</p>');
}
return {
title,
headings,
html: parts.join('\n'),
};
}
@Component({
selector: 'app-docs-viewer',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="docs-viewer">
<header class="docs-viewer__header">
<div>
<p class="docs-viewer__eyebrow">Documentation</p>
<h1>{{ title() }}</h1>
<p class="docs-viewer__path">{{ requestedPath() }}</p>
</div>
<div class="docs-viewer__actions">
<a href="/docs/README.md">Docs home</a>
@if (resolvedAssetPath(); as assetPath) {
<a [href]="assetPath" target="_blank" rel="noopener">Open raw</a>
}
</div>
</header>
@if (loading()) {
<div class="docs-viewer__banner">Loading documentation...</div>
} @else if (error(); as errorMessage) {
<div class="docs-viewer__banner docs-viewer__banner--error" role="alert">
<strong>Documentation entry not found.</strong>
<span>{{ errorMessage }}</span>
</div>
}
<div class="docs-viewer__layout">
@if (headings().length > 1) {
<nav class="docs-viewer__toc" aria-label="Documentation outline">
<strong>On this page</strong>
<ul>
@for (heading of headings(); track heading.id) {
<li [class]="'docs-viewer__toc-level-' + heading.level">
<a [href]="'#' + heading.id">{{ heading.text }}</a>
</li>
}
</ul>
</nav>
}
<article class="docs-viewer__content" [innerHTML]="renderedHtml()"></article>
</div>
</section>
`,
styles: [`
.docs-viewer {
display: grid;
gap: 1rem;
max-width: 1200px;
margin: 0 auto;
padding: 1.25rem;
}
.docs-viewer__header {
display: flex;
justify-content: space-between;
gap: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: linear-gradient(135deg, rgba(15, 23, 42, 0.03), rgba(191, 219, 254, 0.28));
padding: 1rem 1.1rem;
}
.docs-viewer__eyebrow {
margin: 0 0 0.35rem;
font-size: 0.72rem;
font-weight: var(--font-weight-semibold);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.docs-viewer__header h1 {
margin: 0;
font-size: 1.6rem;
color: var(--color-text-heading);
}
.docs-viewer__path {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
font-family: var(--font-mono, monospace);
word-break: break-word;
}
.docs-viewer__actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: flex-start;
}
.docs-viewer__actions a {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-primary);
color: var(--color-brand-primary);
text-decoration: none;
padding: 0.3rem 0.65rem;
font-size: 0.78rem;
white-space: nowrap;
}
.docs-viewer__banner {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
padding: 0.8rem 0.9rem;
display: grid;
gap: 0.25rem;
}
.docs-viewer__banner--error {
border-color: var(--color-status-error-border);
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.docs-viewer__layout {
display: grid;
grid-template-columns: minmax(0, 220px) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.docs-viewer__toc {
position: sticky;
top: 1rem;
display: grid;
gap: 0.55rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.9rem;
}
.docs-viewer__toc strong {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-secondary);
}
.docs-viewer__toc ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.docs-viewer__toc li {
margin: 0;
}
.docs-viewer__toc a {
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.82rem;
line-height: 1.4;
}
.docs-viewer__toc-level-3,
.docs-viewer__toc-level-4,
.docs-viewer__toc-level-5,
.docs-viewer__toc-level-6 {
padding-left: 0.75rem;
}
.docs-viewer__content {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 1.2rem 1.3rem;
min-width: 0;
}
.docs-viewer__content :is(h1, h2, h3, h4, h5, h6) {
color: var(--color-text-heading);
scroll-margin-top: 1rem;
}
.docs-viewer__content h1 {
font-size: 1.7rem;
margin-top: 0;
}
.docs-viewer__content h2 {
font-size: 1.3rem;
margin-top: 1.5rem;
}
.docs-viewer__content h3 {
font-size: 1.05rem;
margin-top: 1.2rem;
}
.docs-viewer__content p,
.docs-viewer__content li,
.docs-viewer__content blockquote {
color: var(--color-text-primary);
line-height: 1.65;
font-size: 0.95rem;
}
.docs-viewer__content ul,
.docs-viewer__content ol {
padding-left: 1.25rem;
}
.docs-viewer__content a {
color: var(--color-brand-primary);
}
.docs-viewer__content code {
font-family: var(--font-mono, monospace);
font-size: 0.88em;
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
padding: 0.08rem 0.28rem;
}
.docs-viewer__content pre {
overflow: auto;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
padding: 0.9rem;
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
line-height: 1.55;
}
.docs-viewer__content blockquote {
margin: 0;
border-left: 3px solid var(--color-brand-primary);
background: var(--color-brand-primary-10);
padding: 0.2rem 0.9rem;
}
@media (max-width: 960px) {
.docs-viewer__layout {
grid-template-columns: 1fr;
}
.docs-viewer__toc {
position: static;
}
.docs-viewer__header {
flex-direction: column;
}
}
`],
})
export class DocsViewerComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private readonly sanitizer = inject(DomSanitizer);
private requestVersion = 0;
readonly title = signal('Documentation');
readonly requestedPath = signal('README.md');
readonly resolvedAssetPath = signal<string | null>(null);
readonly headings = signal<DocsHeading[]>([]);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly renderedHtml = signal<SafeHtml | string>('');
constructor() {
this.loadFromUrl(this.router.url);
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((event) => {
this.loadFromUrl(event.urlAfterRedirects);
});
}
private loadFromUrl(url: string): void {
const { path, anchor } = parseDocsUrl(url);
this.requestedPath.set(path);
void this.loadDocument(path, anchor);
}
private async loadDocument(path: string, anchor: string | null): Promise<void> {
const requestVersion = ++this.requestVersion;
this.loading.set(true);
this.error.set(null);
this.resolvedAssetPath.set(null);
this.headings.set([]);
for (const candidate of buildDocsAssetCandidates(path)) {
try {
const markdown = await firstValueFrom(this.http.get(candidate, { responseType: 'text' }));
if (requestVersion !== this.requestVersion) {
return;
}
const rendered = renderMarkdownDocument(markdown, path);
this.title.set(rendered.title);
this.headings.set(rendered.headings);
this.resolvedAssetPath.set(candidate);
this.renderedHtml.set(this.sanitizer.bypassSecurityTrustHtml(rendered.html));
this.loading.set(false);
queueMicrotask(() => this.scrollToAnchor(anchor));
return;
} catch {
continue;
}
}
if (requestVersion !== this.requestVersion) {
return;
}
this.title.set('Documentation');
this.renderedHtml.set(this.sanitizer.bypassSecurityTrustHtml(''));
this.error.set(`No documentation asset matched ${path}.`);
this.loading.set(false);
}
private scrollToAnchor(anchor: string | null): void {
const normalizedAnchor = normalizeDocsAnchor(anchor);
if (!normalizedAnchor) {
return;
}
const target =
document.getElementById(normalizedAnchor) ??
document.getElementById(slugifyHeading(normalizedAnchor));
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}

View File

@@ -0,0 +1,72 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { AUTH_SERVICE } from '../../core/auth';
import { ORCHESTRATOR_CONTROL_API } from '../../core/api/jobengine-control.client';
import { JobEngineDashboardComponent } from './jobengine-dashboard.component';
describe('JobEngineDashboardComponent', () => {
function configure(canManageJobEngineQuotas: boolean) {
TestBed.configureTestingModule({
imports: [JobEngineDashboardComponent],
providers: [
provideRouter([]),
{
provide: AUTH_SERVICE,
useValue: {
canViewOrchestrator: () => true,
canOperateOrchestrator: () => true,
canManageJobEngineQuotas: () => canManageJobEngineQuotas,
canInitiateBackfill: () => false,
},
},
{
provide: ORCHESTRATOR_CONTROL_API,
useValue: {
getJobSummary: () => of({
totalJobs: 12,
leasedJobs: 2,
failedJobs: 1,
pendingJobs: 3,
scheduledJobs: 4,
succeededJobs: 5,
}),
getQuotaSummary: () => of({
totalQuotas: 3,
pausedQuotas: 1,
averageTokenUtilization: 0.42,
averageConcurrencyUtilization: 0.33,
}),
getDeadLetterStats: () => of({
totalEntries: 4,
retryableEntries: 2,
replayedEntries: 1,
resolvedEntries: 1,
}),
},
},
],
});
}
it('renders the quotas card as read-only when the user lacks quota scope', () => {
configure(false);
const fixture = TestBed.createComponent(JobEngineDashboardComponent);
fixture.detectChanges();
const quotasCard = fixture.nativeElement.querySelector('[data-testid="jobengine-quotas-card"]') as HTMLElement;
expect(quotasCard.tagName).toBe('ARTICLE');
expect(quotasCard.textContent).toContain('Access required to manage quotas.');
});
it('renders the quotas card as a link when the user can manage quotas', () => {
configure(true);
const fixture = TestBed.createComponent(JobEngineDashboardComponent);
fixture.detectChanges();
const quotasCard = fixture.nativeElement.querySelector('[data-testid="jobengine-quotas-card"]') as HTMLElement;
expect(quotasCard.tagName).toBe('A');
expect(quotasCard.getAttribute('href')).toContain('/ops/operations/jobengine/quotas');
});
});

View File

@@ -78,24 +78,46 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
</dl>
</a>
<a class="surface" [routerLink]="OPERATIONS_PATHS.jobEngineQuotas">
<h2>Execution Quotas</h2>
<p>Manage per-job-type concurrency, refill rate, and pause state.</p>
<dl>
<div>
<dt>Average Token Usage</dt>
<dd>{{ quotaPercent(quotaSummary()?.averageTokenUtilization) }}</dd>
</div>
<div>
<dt>Average Concurrency Usage</dt>
<dd>{{ quotaPercent(quotaSummary()?.averageConcurrencyUtilization) }}</dd>
</div>
<div>
<dt>Paused Quotas</dt>
<dd>{{ quotaSummary()?.pausedQuotas ?? 0 }}</dd>
</div>
</dl>
</a>
@if (authService.canManageJobEngineQuotas()) {
<a class="surface" data-testid="jobengine-quotas-card" [routerLink]="OPERATIONS_PATHS.jobEngineQuotas">
<h2>Execution Quotas</h2>
<p>Manage per-job-type concurrency, refill rate, and pause state.</p>
<dl>
<div>
<dt>Average Token Usage</dt>
<dd>{{ quotaPercent(quotaSummary()?.averageTokenUtilization) }}</dd>
</div>
<div>
<dt>Average Concurrency Usage</dt>
<dd>{{ quotaPercent(quotaSummary()?.averageConcurrencyUtilization) }}</dd>
</div>
<div>
<dt>Paused Quotas</dt>
<dd>{{ quotaSummary()?.pausedQuotas ?? 0 }}</dd>
</div>
</dl>
</a>
} @else {
<article class="surface surface--restricted" data-testid="jobengine-quotas-card" aria-disabled="true">
<h2>Execution Quotas</h2>
<p>Quota metrics are visible, but management stays locked until the session has quota-admin scope.</p>
<dl>
<div>
<dt>Average Token Usage</dt>
<dd>{{ quotaPercent(quotaSummary()?.averageTokenUtilization) }}</dd>
</div>
<div>
<dt>Average Concurrency Usage</dt>
<dd>{{ quotaPercent(quotaSummary()?.averageConcurrencyUtilization) }}</dd>
</div>
<div>
<dt>Paused Quotas</dt>
<dd>{{ quotaSummary()?.pausedQuotas ?? 0 }}</dd>
</div>
</dl>
<span class="surface__notice">Access required to manage quotas.</span>
</article>
}
<a class="surface" [routerLink]="OPERATIONS_PATHS.deadLetter">
<h2>Dead-Letter Recovery</h2>
@@ -234,6 +256,10 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
color: var(--color-text-heading);
}
.surface--restricted {
cursor: default;
}
.surface p {
margin: 0;
color: var(--color-text-secondary);
@@ -263,6 +289,15 @@ import { OPERATIONS_PATHS } from '../platform/ops/operations-paths';
font-weight: var(--font-weight-semibold);
}
.surface__notice {
display: inline-flex;
align-items: center;
gap: 0.35rem;
color: var(--color-status-warning-border);
font-size: 0.8rem;
font-weight: var(--font-weight-medium);
}
.jobengine-dashboard__access ul {
margin: 0;
padding-left: 1.2rem;

View File

@@ -1,18 +1,21 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ExportCenterComponent } from '../evidence-export/export-center.component';
import { TriageWorkspaceComponent } from '../triage/triage-workspace.component';
import { SecurityDispositionPageComponent } from './security-disposition-page.component';
type ReportTab = 'risk' | 'vex' | 'evidence';
@Component({
selector: 'app-security-reports-page',
standalone: true,
imports: [RouterLink],
imports: [TriageWorkspaceComponent, SecurityDispositionPageComponent, ExportCenterComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="security-reports">
<header>
<h1>Security Reports</h1>
<p>Export posture snapshots, waiver ledgers, and evidence-linked risk summaries.</p>
<p>Review risk posture, VEX decisions, and evidence exports without leaving the reports workspace.</p>
</header>
<nav class="tabs" role="tablist">
@@ -42,31 +45,19 @@ type ReportTab = 'risk' | 'vex' | 'evidence';
<div class="tab-content" role="tabpanel">
@switch (activeTab()) {
@case ('risk') {
<article class="report-card">
<h2>Risk Report</h2>
<p>Aggregated risk posture across all scanned artifacts, environments, and triage dispositions.</p>
<div class="report-actions">
<a routerLink="/security/triage" class="btn btn--primary">View Full Triage</a>
</div>
</article>
<section class="report-panel">
<app-triage-workspace></app-triage-workspace>
</section>
}
@case ('vex') {
<article class="report-card">
<h2>VEX and Waiver Ledger</h2>
<p>Active VEX statements, exception waivers, and disposition history with expiration tracking.</p>
<div class="report-actions">
<a routerLink="/security/disposition" class="btn btn--primary">View Dispositions</a>
</div>
</article>
<section class="report-panel">
<app-security-disposition-page></app-security-disposition-page>
</section>
}
@case ('evidence') {
<article class="report-card">
<h2>Evidence Export Bundle</h2>
<p>Export signed evidence bundles for audit, compliance, and offline verification workflows.</p>
<div class="report-actions">
<a routerLink="/evidence/exports" class="btn btn--primary">Open Export Center</a>
</div>
</article>
<section class="report-panel">
<app-export-center></app-export-center>
</section>
}
}
</div>
@@ -105,49 +96,11 @@ type ReportTab = 'risk' | 'vex' | 'evidence';
border-bottom-color: var(--color-brand-primary);
}
.report-card {
padding: 1rem;
.report-panel {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
}
.report-card h2 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.report-card p {
margin: 0 0 0.75rem;
color: var(--color-text-secondary);
font-size: 0.82rem;
line-height: 1.5;
}
.report-actions {
display: flex;
gap: 0.5rem;
}
.btn {
display: inline-flex;
align-items: center;
padding: 0.4rem 0.75rem;
border-radius: var(--radius-md);
font-size: 0.78rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.btn--primary {
background: var(--color-brand-primary);
color: #fff;
}
.btn--primary:hover {
opacity: 0.9;
overflow: hidden;
}
`,
],

View File

@@ -43,6 +43,9 @@ import {
@if (error()) {
<div class="error-banner">{{ error() }}</div>
}
@if (successMessage()) {
<div class="success-banner">{{ successMessage() }}</div>
}
<div class="admin-content">
@switch (activeTab()) {
@@ -71,9 +74,11 @@ import {
<div class="form-field">
<label class="form-label">Role</label>
<select class="form-input" #addUserRole>
<option value="viewer">Viewer</option>
<option value="operator">Operator</option>
<option value="admin">Admin</option>
@for (role of availableUserRoles(); track role.id) {
<option [value]="role.name">{{ role.name }}</option>
} @empty {
<option value="admin">admin</option>
}
</select>
</div>
</div>
@@ -96,7 +101,7 @@ import {
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Actions</th>
<th>Directory</th>
</tr>
</thead>
<tbody>
@@ -106,7 +111,7 @@ import {
<td>{{ user.email }}</td>
<td>{{ user.roles.join(', ') }}</td>
<td><span class="badge" [class]="'badge--' + user.status">{{ user.status }}</span></td>
<td><button class="btn btn--sm">Edit</button></td>
<td class="muted-cell">Managed by Authority directory</td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-cell">No users found</td></tr>
@@ -122,6 +127,34 @@ import {
<h2>Roles</h2>
<button type="button" class="btn btn--primary" (click)="showAddForm('roles')">+ Create Role</button>
</div>
@if (addFormVisible() === 'roles') {
<div class="add-form">
<h3 class="add-form__title">New Role</h3>
<div class="add-form__fields">
<div class="form-field">
<label class="form-label">Role Name</label>
<input type="text" class="form-input" placeholder="security-analyst" #addRoleName />
</div>
<div class="form-field">
<label class="form-label">Description</label>
<input type="text" class="form-input" placeholder="Security analyst with triage access" #addRoleDescription />
</div>
<div class="form-field form-field--full">
<label class="form-label">Permissions</label>
<textarea class="form-input form-input--textarea" placeholder="findings:read, vex:read, vuln:investigate" #addRolePermissions></textarea>
</div>
</div>
<div class="add-form__actions">
<button type="button" class="btn btn--secondary" (click)="hideAddForm()">Cancel</button>
<button
type="button"
class="btn btn--primary"
(click)="createRole(addRoleName.value, addRoleDescription.value, addRolePermissions.value)">
Create Role
</button>
</div>
</div>
}
@if (loading()) {
<p class="loading-text">Loading roles...</p>
} @else {
@@ -132,7 +165,6 @@ import {
<th>Description</th>
<th>Users</th>
<th>Built-in</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -142,10 +174,9 @@ import {
<td>{{ role.description }}</td>
<td>{{ role.userCount }}</td>
<td>{{ role.isBuiltIn ? 'Yes' : 'No' }}</td>
<td><button class="btn btn--sm" [disabled]="role.isBuiltIn">Edit</button></td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-cell">No roles found</td></tr>
<tr><td colspan="4" class="empty-cell">No roles found</td></tr>
}
</tbody>
</table>
@@ -156,8 +187,8 @@ import {
<div class="content-section">
<div class="section-header">
<h2>OAuth Clients</h2>
<button type="button" class="btn btn--primary" (click)="showAddForm('clients')">+ Register Client</button>
</div>
<p class="section-note">OAuth clients are visible here, but registration and secret rotation remain outside this setup tab until the full guided flow is shipped.</p>
@if (loading()) {
<p class="loading-text">Loading clients...</p>
} @else {
@@ -167,8 +198,8 @@ import {
<th>Client ID</th>
<th>Display Name</th>
<th>Grant Types</th>
<th>Tenant Scope</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -177,8 +208,8 @@ import {
<td><code>{{ client.clientId }}</code></td>
<td>{{ client.displayName }}</td>
<td>{{ client.grantTypes.join(', ') }}</td>
<td>{{ describeClientTenants(client) }}</td>
<td><span class="badge" [class]="'badge--' + client.status">{{ client.status }}</span></td>
<td><button class="btn btn--sm">Edit</button></td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-cell">No OAuth clients found</td></tr>
@@ -192,8 +223,8 @@ import {
<div class="content-section">
<div class="section-header">
<h2>API Tokens</h2>
<button type="button" class="btn btn--primary" (click)="showAddForm('tokens')">+ Generate Token</button>
</div>
<p class="section-note">Token issuance and revocation are not exposed on this setup route yet, so this view is intentionally read-only.</p>
@if (loading()) {
<p class="loading-text">Loading tokens...</p>
} @else {
@@ -205,7 +236,6 @@ import {
<th>Scopes</th>
<th>Expires</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@@ -216,10 +246,9 @@ import {
<td>{{ token.scopes.join(', ') }}</td>
<td>{{ token.expiresAt }}</td>
<td><span class="badge" [class]="'badge--' + token.status">{{ token.status }}</span></td>
<td><button class="btn btn--sm">Revoke</button></td>
</tr>
} @empty {
<tr><td colspan="6" class="empty-cell">No API tokens found</td></tr>
<tr><td colspan="5" class="empty-cell">No API tokens found</td></tr>
}
</tbody>
</table>
@@ -232,6 +261,37 @@ import {
<h2>Tenants</h2>
<button type="button" class="btn btn--primary" (click)="showAddForm('tenants')">+ Add Tenant</button>
</div>
@if (addFormVisible() === 'tenants') {
<div class="add-form">
<h3 class="add-form__title">New Tenant</h3>
<div class="add-form__fields">
<div class="form-field">
<label class="form-label">Tenant ID</label>
<input type="text" class="form-input" placeholder="customer-stage" #addTenantId />
</div>
<div class="form-field">
<label class="form-label">Display Name</label>
<input type="text" class="form-input" placeholder="Customer Stage" #addTenantDisplayName />
</div>
<div class="form-field">
<label class="form-label">Isolation Mode</label>
<select class="form-input" #addTenantIsolation>
<option value="shared">Shared</option>
<option value="dedicated">Dedicated</option>
</select>
</div>
</div>
<div class="add-form__actions">
<button type="button" class="btn btn--secondary" (click)="hideAddForm()">Cancel</button>
<button
type="button"
class="btn btn--primary"
(click)="createTenant(addTenantId.value, addTenantDisplayName.value, addTenantIsolation.value)">
Create Tenant
</button>
</div>
</div>
}
@if (loading()) {
<p class="loading-text">Loading tenants...</p>
} @else {
@@ -242,7 +302,7 @@ import {
<th>Status</th>
<th>Isolation</th>
<th>Users</th>
<th>Actions</th>
<th>Lifecycle</th>
</tr>
</thead>
<tbody>
@@ -252,7 +312,7 @@ import {
<td><span class="badge" [class]="'badge--' + tenant.status">{{ tenant.status }}</span></td>
<td>{{ tenant.isolationMode }}</td>
<td>{{ tenant.userCount }}</td>
<td><button class="btn btn--sm">Edit</button></td>
<td class="muted-cell">Branding and policies are managed from the canonical setup surfaces.</td>
</tr>
} @empty {
<tr><td colspan="5" class="empty-cell">No tenants found</td></tr>
@@ -336,7 +396,13 @@ import {
.btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); }
.btn--sm:disabled { opacity: 0.5; cursor: not-allowed; }
.loading-text { color: var(--color-text-secondary); font-size: 0.875rem; }
.section-note {
margin: 0 0 1rem;
color: var(--color-text-secondary);
font-size: 0.8125rem;
}
.empty-cell { text-align: center; color: var(--color-text-secondary); padding: 2rem !important; }
.muted-cell { color: var(--color-text-secondary); font-size: 0.8125rem; }
.error-banner {
padding: 1rem;
margin-bottom: 1rem;
@@ -367,6 +433,7 @@ import {
.add-form__title { margin: 0 0 1rem; font-size: 1rem; font-weight: var(--font-weight-semibold); }
.add-form__fields { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1rem; }
.form-field { display: flex; flex-direction: column; gap: 0.25rem; }
.form-field--full { grid-column: 1 / -1; }
.form-label { font-size: 0.75rem; font-weight: var(--font-weight-medium); color: var(--color-text-secondary); text-transform: uppercase; }
.form-input {
padding: 0.5rem 0.75rem;
@@ -376,6 +443,7 @@ import {
background: var(--color-surface-primary);
color: var(--color-text-primary);
}
.form-input--textarea { min-height: 6rem; resize: vertical; }
.form-input:focus { outline: none; border-color: var(--color-brand-primary); box-shadow: 0 0 0 2px rgba(245,166,35,0.15); }
.add-form__actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
.btn--secondary {
@@ -401,6 +469,8 @@ import {
})
export class AdminSettingsPageComponent implements OnInit {
private readonly api = inject(AUTHORITY_ADMIN_API);
private static readonly emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
private static readonly tenantIdPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
tabs = [
{ id: 'users', label: 'Users' },
@@ -424,6 +494,7 @@ export class AdminSettingsPageComponent implements OnInit {
ngOnInit(): void {
this.loadTab('users');
this.ensureRolesLoaded();
}
setTab(tabId: string): void {
@@ -434,6 +505,12 @@ export class AdminSettingsPageComponent implements OnInit {
showAddForm(formId: string): void {
this.addFormVisible.set(this.addFormVisible() === formId ? null : formId);
if (formId === 'users') {
this.ensureRolesLoaded();
}
if (this.addFormVisible()) {
this.error.set(null);
}
this.successMessage.set(null);
}
@@ -446,22 +523,138 @@ export class AdminSettingsPageComponent implements OnInit {
this.error.set('Username and email are required.');
return;
}
if (!AdminSettingsPageComponent.emailPattern.test(email.trim())) {
this.error.set('Enter a valid email address before creating the user.');
return;
}
this.error.set(null);
this.loading.set(true);
this.api.createUser({ username: username.trim(), email: email.trim(), displayName: displayName.trim(), roles: [role] }).pipe(
catchError((err) => {
this.error.set('Failed to create user. The backend may be unavailable.');
this.error.set(err?.status === 403
? 'Your current session is missing the write scopes required to create users.'
: 'Failed to create user. The backend may be unavailable.');
return of(null);
})
).subscribe((result) => {
this.loading.set(false);
if (result) {
this.addFormVisible.set(null);
this.successMessage.set(`Created user ${result.username}.`);
this.loadTab('users');
}
});
}
createRole(name: string, description: string, permissionsText: string): void {
const normalizedName = name.trim().toLowerCase();
const normalizedDescription = description.trim();
const permissions = permissionsText
.split(/[,\n]/)
.map((value) => value.trim())
.filter((value) => value.length > 0);
if (!normalizedName) {
this.error.set('Role name is required.');
return;
}
if (!normalizedDescription) {
this.error.set('Role description is required.');
return;
}
if (permissions.length === 0) {
this.error.set('At least one permission is required.');
return;
}
this.error.set(null);
this.loading.set(true);
this.api.createRole({
name: normalizedName,
description: normalizedDescription,
permissions,
}).pipe(
catchError((err) => {
this.error.set(err?.status === 403
? 'Your current session is missing the write scopes required to create roles.'
: 'Failed to create role.');
return of(null);
})
).subscribe((result) => {
this.loading.set(false);
if (result) {
this.addFormVisible.set(null);
this.successMessage.set(`Created role ${result.name}.`);
this.roles.update((roles) => [...roles, result].sort((left, right) => left.name.localeCompare(right.name)));
if (this.activeTab() === 'roles') {
this.loadTab('roles');
}
}
});
}
createTenant(id: string, displayName: string, isolationMode: string): void {
const normalizedId = id.trim().toLowerCase();
const normalizedDisplayName = displayName.trim();
if (!normalizedId) {
this.error.set('Tenant ID is required.');
return;
}
if (!AdminSettingsPageComponent.tenantIdPattern.test(normalizedId)) {
this.error.set('Tenant ID must use lowercase letters, digits, and hyphens only.');
return;
}
if (!normalizedDisplayName) {
this.error.set('Tenant display name is required.');
return;
}
this.error.set(null);
this.loading.set(true);
this.api.createTenant({
id: normalizedId,
displayName: normalizedDisplayName,
isolationMode: isolationMode === 'dedicated' ? 'dedicated' : 'shared',
}).pipe(
catchError((err) => {
this.error.set(err?.status === 403
? 'Your current session is missing the write scopes required to create tenants.'
: 'Failed to create tenant.');
return of(null);
})
).subscribe((result) => {
this.loading.set(false);
if (result) {
this.addFormVisible.set(null);
this.successMessage.set(`Created tenant ${result.displayName}.`);
this.loadTab('tenants');
}
});
}
availableUserRoles(): AdminRole[] {
return this.roles().length > 0
? this.roles()
: [{ id: 'admin', name: 'admin', description: 'Administrator', permissions: [], userCount: 0, isBuiltIn: true }];
}
describeClientTenants(client: AdminClient): string {
if (client.defaultTenant) {
return client.defaultTenant;
}
if (client.tenants && client.tenants.length > 0) {
return client.tenants.join(', ');
}
return 'All tenants';
}
private loadTab(tabId: string): void {
this.loading.set(true);
this.error.set(null);
@@ -494,4 +687,14 @@ export class AdminSettingsPageComponent implements OnInit {
})
).subscribe(() => this.loading.set(false));
}
private ensureRolesLoaded(): void {
if (this.roles().length > 0) {
return;
}
this.api.listRoles().pipe(
catchError(() => of([]))
).subscribe((roles) => this.roles.set(roles));
}
}

View File

@@ -92,7 +92,11 @@ type RegionsView = 'region-first' | 'flat' | 'graph';
<td>{{ environmentHealthLabel(env.environmentId) }}</td>
<td>{{ env.targetCount }}</td>
<td>
<a [routerLink]="['/setup/topology/environments', env.environmentId, 'posture']" queryParamsHandling="merge">Open</a>
<a
[routerLink]="['/setup/topology/environments', env.environmentId, 'posture']"
[queryParams]="{ environment: env.environmentId, environments: env.environmentId }"
queryParamsHandling="merge"
>Open</a>
</td>
</tr>
} @empty {
@@ -126,7 +130,13 @@ type RegionsView = 'region-first' | 'flat' | 'graph';
<td>{{ env.environmentType }}</td>
<td>{{ environmentHealthLabel(env.environmentId) }}</td>
<td>{{ env.targetCount }}</td>
<td><a [routerLink]="['/setup/topology/environments', env.environmentId, 'posture']" queryParamsHandling="merge">Open</a></td>
<td>
<a
[routerLink]="['/setup/topology/environments', env.environmentId, 'posture']"
[queryParams]="{ environment: env.environmentId, environments: env.environmentId }"
queryParamsHandling="merge"
>Open</a>
</td>
</tr>
} @empty {
<tr>
@@ -158,7 +168,11 @@ type RegionsView = 'region-first' | 'flat' | 'graph';
· targets {{ selectedEnvironmentTargetCount() }}
</p>
<div class="actions">
<a [routerLink]="['/setup/topology/environments', selectedEnvironmentId(), 'posture']" queryParamsHandling="merge">Open Environment</a>
<a
[routerLink]="['/setup/topology/environments', selectedEnvironmentId(), 'posture']"
[queryParams]="{ environment: selectedEnvironmentId(), environments: selectedEnvironmentId() }"
queryParamsHandling="merge"
>Open Environment</a>
<a [routerLink]="['/setup/topology/targets']" [queryParams]="{ environment: selectedEnvironmentId() }" queryParamsHandling="merge">Open Targets</a>
<a [routerLink]="['/setup/topology/agents']" [queryParams]="{ environment: selectedEnvironmentId() }" queryParamsHandling="merge">Open Agents</a>
<a [routerLink]="['/releases/runs']" [queryParams]="{ environment: selectedEnvironmentId() }" queryParamsHandling="merge">Open Runs</a>
@@ -395,6 +409,7 @@ export class TopologyRegionsEnvironmentsPageComponent {
readonly error = signal<string | null>(null);
readonly searchQuery = signal('');
readonly viewMode = signal<RegionsView>('region-first');
readonly requestedEnvironmentId = signal('');
readonly selectedRegionId = signal('');
readonly selectedEnvironmentId = signal('');
@@ -475,8 +490,13 @@ export class TopologyRegionsEnvironmentsPageComponent {
this.viewMode.set(defaultView);
});
this.route.queryParamMap.subscribe((queryParamMap) => {
this.requestedEnvironmentId.set(this.resolveRequestedEnvironmentId(queryParamMap));
});
effect(() => {
this.context.contextVersion();
this.requestedEnvironmentId();
this.load();
});
}
@@ -558,9 +578,13 @@ export class TopologyRegionsEnvironmentsPageComponent {
const current = this.selectedRegionId();
const scopedEnvironments = this.context.selectedEnvironments();
const scopedRegions = this.context.selectedRegions();
const requestedEnvironmentId = this.requestedEnvironmentId();
const regionFromRequestedEnvironment =
environments.find((item) => item.environmentId === requestedEnvironmentId)?.regionId ?? '';
const regionFromScopedEnvironment = environments.find((item) => scopedEnvironments.includes(item.environmentId))?.regionId ?? '';
const preferredScopedRegion =
regionFromScopedEnvironment
regionFromRequestedEnvironment
|| regionFromScopedEnvironment
|| scopedRegions.find((regionId) => regions.some((item) => item.regionId === regionId))
|| '';
@@ -581,9 +605,18 @@ export class TopologyRegionsEnvironmentsPageComponent {
): string {
const current = this.selectedEnvironmentId();
const scopedEnvironments = this.context.selectedEnvironments();
const requestedEnvironmentId = this.requestedEnvironmentId();
const environmentsInRegion = selectedRegionId
? environments.filter((item) => item.regionId === selectedRegionId)
: environments;
const preferredRequestedEnvironment =
environmentsInRegion.find((item) => item.environmentId === requestedEnvironmentId)?.environmentId
?? environments.find((item) => item.environmentId === requestedEnvironmentId)?.environmentId
?? '';
if (preferredRequestedEnvironment) {
return preferredRequestedEnvironment;
}
const preferredScopedEnvironment =
scopedEnvironments.find((environmentId) =>
@@ -604,6 +637,23 @@ export class TopologyRegionsEnvironmentsPageComponent {
return environmentsInRegion[0]?.environmentId ?? environments[0]?.environmentId ?? '';
}
private resolveRequestedEnvironmentId(queryParamMap: Pick<import('@angular/router').ParamMap, 'get'>): string {
const explicitEnvironment = queryParamMap.get('environment')?.trim();
if (explicitEnvironment) {
return explicitEnvironment;
}
const environmentsValue = queryParamMap.get('environments')?.trim();
if (!environmentsValue) {
return '';
}
return environmentsValue
.split(',')
.map((value) => value.trim())
.find((value) => value.length > 0) ?? '';
}
}

View File

@@ -105,6 +105,13 @@ describe('GatedBucketsComponent', () => {
expect(chip.textContent).toContain('+23');
});
it('renders bucket icons as SVG markup instead of escaped text', () => {
const compiled = fixture.nativeElement;
const icon = compiled.querySelector('.bucket-chip.unreachable .icon');
expect(icon.innerHTML).toContain('<svg');
expect(icon.textContent?.trim()).not.toContain('<svg');
});
it('should render policy-dismissed chip', () => {
const compiled = fixture.nativeElement;
const chip = compiled.querySelector('.bucket-chip.policy-dismissed');

View File

@@ -41,7 +41,7 @@ export interface BucketExpandEvent {
(click)="toggleBucket('unreachable')"
[attr.aria-expanded]="expandedBucket() === 'unreachable'"
attr.aria-label="Show {{ unreachableCount() }} unreachable findings">
<span class="icon">{{ getIcon('unreachable') }}</span>
<span class="icon" [innerHTML]="getIcon('unreachable')"></span>
<span class="count">+{{ unreachableCount() }}</span>
<span class="label">unreachable</span>
</button>
@@ -53,7 +53,7 @@ export interface BucketExpandEvent {
(click)="toggleBucket('policy_dismissed')"
[attr.aria-expanded]="expandedBucket() === 'policy_dismissed'"
attr.aria-label="Show {{ policyDismissedCount() }} policy-dismissed findings">
<span class="icon">{{ getIcon('policy_dismissed') }}</span>
<span class="icon" [innerHTML]="getIcon('policy_dismissed')"></span>
<span class="count">+{{ policyDismissedCount() }}</span>
<span class="label">policy</span>
</button>
@@ -65,7 +65,7 @@ export interface BucketExpandEvent {
(click)="toggleBucket('backported')"
[attr.aria-expanded]="expandedBucket() === 'backported'"
attr.aria-label="Show {{ backportedCount() }} backported findings">
<span class="icon">{{ getIcon('backported') }}</span>
<span class="icon" [innerHTML]="getIcon('backported')"></span>
<span class="count">+{{ backportedCount() }}</span>
<span class="label">backported</span>
</button>
@@ -77,7 +77,7 @@ export interface BucketExpandEvent {
(click)="toggleBucket('vex_not_affected')"
[attr.aria-expanded]="expandedBucket() === 'vex_not_affected'"
attr.aria-label="Show {{ vexNotAffectedCount() }} VEX not-affected findings">
<span class="icon">{{ getIcon('vex_not_affected') }}</span>
<span class="icon" [innerHTML]="getIcon('vex_not_affected')"></span>
<span class="count">+{{ vexNotAffectedCount() }}</span>
<span class="label">VEX</span>
</button>
@@ -89,7 +89,7 @@ export interface BucketExpandEvent {
(click)="toggleBucket('superseded')"
[attr.aria-expanded]="expandedBucket() === 'superseded'"
attr.aria-label="Show {{ supersededCount() }} superseded findings">
<span class="icon">{{ getIcon('superseded') }}</span>
<span class="icon" [innerHTML]="getIcon('superseded')"></span>
<span class="count">+{{ supersededCount() }}</span>
<span class="label">superseded</span>
</button>
@@ -101,7 +101,7 @@ export interface BucketExpandEvent {
(click)="toggleBucket('user_muted')"
[attr.aria-expanded]="expandedBucket() === 'user_muted'"
attr.aria-label="Show {{ userMutedCount() }} user-muted findings">
<span class="icon">{{ getIcon('user_muted') }}</span>
<span class="icon" [innerHTML]="getIcon('user_muted')"></span>
<span class="count">+{{ userMutedCount() }}</span>
<span class="label">muted</span>
</button>
@@ -190,6 +190,9 @@ export interface BucketExpandEvent {
}
.bucket-chip .icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-sm);
}

View File

@@ -381,7 +381,7 @@ export function getGatingReasonClass(reason: GatingReason): string {
* Format trust score for display.
*/
export function formatTrustScore(score?: number): string {
if (score === undefined || score === null) return '';
if (score === undefined || score === null) return '--';
return (score * 100).toFixed(0) + '%';
}

View File

@@ -1,738 +1,230 @@
/**
* @file issuer-trust-list.component.ts
* @sprint SPRINT_20251229_018c_FE
* @description Trusted issuers list with trust scores and management
* @description Live trusted issuer inventory aligned to the administration trust API
*/
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
import {
TrustedIssuer,
IssuerType,
IssuerTrustLevel,
ListIssuersParams,
} from '../../core/api/trust.models';
import { TrustScoreConfigComponent } from './trust-score-config.component';
import { TrustedIssuer, IssuerTrustLevel } from '../../core/api/trust.models';
@Component({
selector: 'app-issuer-trust-list',
imports: [CommonModule, FormsModule, TrustScoreConfigComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="issuer-list">
<!-- Filters -->
<div class="issuer-list__filters">
<div class="filter-group">
<label for="search">Search</label>
<input
id="search"
type="text"
placeholder="Search issuers..."
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event); onSearch()"
/>
selector: 'app-issuer-trust-list',
imports: [CommonModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="issuer-list">
<header class="issuer-list__header">
<div>
<h2>Trusted Issuers</h2>
<p>
This view is bound to the live administration contract: issuer name, issuer URI, trust level, status, and update ownership.
</p>
</div>
<button type="button" class="btn-secondary" (click)="loadIssuers()" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
</header>
<div class="filter-group">
<label for="trustLevel">Trust Level</label>
<select
id="trustLevel"
[ngModel]="selectedTrustLevel()"
(ngModelChange)="selectedTrustLevel.set($event); onFilterChange()"
>
<option value="all">All Levels</option>
<option value="full">Full Trust</option>
<option value="partial">Partial Trust</option>
<option value="minimal">Minimal Trust</option>
<div class="issuer-list__filters">
<label class="filter-field">
<span>Search</span>
<input
type="text"
[ngModel]="searchQuery()"
(ngModelChange)="searchQuery.set($event); applyFilters()"
placeholder="Search issuer name or URI"
/>
</label>
<label class="filter-field">
<span>Trust Level</span>
<select [ngModel]="selectedTrustLevel()" (ngModelChange)="selectedTrustLevel.set($event); applyFilters()">
<option value="all">All trust levels</option>
<option value="full">Full</option>
<option value="partial">Partial</option>
<option value="minimal">Minimal</option>
<option value="untrusted">Untrusted</option>
<option value="blocked">Blocked</option>
</select>
</div>
<div class="filter-group">
<label for="issuerType">Type</label>
<select
id="issuerType"
[ngModel]="selectedType()"
(ngModelChange)="selectedType.set($event); onFilterChange()"
>
<option value="all">All Types</option>
<option value="csaf_publisher">CSAF Publisher</option>
<option value="vex_issuer">VEX Issuer</option>
<option value="sbom_producer">SBOM Producer</option>
<option value="attestation_authority">Attestation Authority</option>
</select>
</div>
<button
type="button"
class="btn-config"
(click)="showConfig.set(!showConfig())"
>
Configure Scoring
</button>
</label>
@if (hasFilters()) {
<button type="button" class="btn-link" (click)="clearFilters()">
Clear filters
</button>
<button type="button" class="btn-link" (click)="clearFilters()">Clear filters</button>
}
</div>
<!-- Trust Score Config Panel -->
@if (showConfig()) {
<app-trust-score-config
[selectedIssuer]="selectedIssuer()"
(configSaved)="onConfigSaved()"
(close)="showConfig.set(false)"
></app-trust-score-config>
}
<!-- Summary Stats -->
<div class="issuer-list__stats">
<div class="stat">
<span class="stat__value">{{ issuers().length }}</span>
<span class="stat__label">Total Issuers</span>
</div>
<div class="stat">
<span class="stat__value stat__value--full">{{ countByLevel('full') }}</span>
<span class="stat__label">Full Trust</span>
</div>
<div class="stat">
<span class="stat__value stat__value--partial">{{ countByLevel('partial') }}</span>
<span class="stat__label">Partial</span>
</div>
<div class="stat">
<span class="stat__value stat__value--blocked">{{ countByLevel('blocked') }}</span>
<span class="stat__label">Blocked</span>
</div>
<div class="stat">
<span class="stat__value">{{ averageScore() | number:'1.1-1' }}</span>
<span class="stat__label">Avg Score</span>
</div>
<div class="contract-note">
Score tuning, issuer blocking, and document-volume analytics are not exposed by the current backend contract, so they are intentionally omitted here.
</div>
<!-- Issuers Table -->
<div class="issuer-list__table-container">
@if (loading()) {
<div class="issuer-list__loading">Loading issuers...</div>
} @else if (error()) {
<div class="issuer-list__error">{{ error() }}</div>
} @else if (issuers().length === 0) {
<div class="issuer-list__empty">
No issuers found.
@if (hasFilters()) {
<button type="button" class="btn-link" (click)="clearFilters()">
Clear filters
</button>
}
</div>
} @else {
<table class="issuer-table">
<thead>
@if (loading()) {
<div class="state state--loading">Loading issuers...</div>
} @else if (error()) {
<div class="state state--error">{{ error() }}</div>
} @else if (issuers().length === 0) {
<div class="state">No issuers found.</div>
} @else {
<table class="issuer-table">
<thead>
<tr>
<th>Name</th>
<th>Issuer URI</th>
<th>Trust Level</th>
<th>Status</th>
<th>Created</th>
<th>Updated</th>
<th>Updated By</th>
</tr>
</thead>
<tbody>
@for (issuer of issuers(); track issuer.issuerId) {
<tr>
<th class="sortable" (click)="onSort('name')">
Issuer
@if (sortBy() === 'name') {
<span class="sort-indicator">{{ sortDirection() === 'asc' ? '+' : '-' }}</span>
}
</th>
<th>Type</th>
<th class="sortable" (click)="onSort('trustScore')">
Score
@if (sortBy() === 'trustScore') {
<span class="sort-indicator">{{ sortDirection() === 'asc' ? '+' : '-' }}</span>
}
</th>
<th>Trust Level</th>
<th>Documents</th>
<th class="sortable" (click)="onSort('lastVerifiedAt')">
Last Verified
@if (sortBy() === 'lastVerifiedAt') {
<span class="sort-indicator">{{ sortDirection() === 'asc' ? '+' : '-' }}</span>
}
</th>
<th>Actions</th>
<td>{{ issuer.displayName }}</td>
<td><a [href]="issuer.url" target="_blank" rel="noopener">{{ issuer.url }}</a></td>
<td><span class="badge" [class]="'badge--' + issuer.trustLevel">{{ formatTrustLevel(issuer.trustLevel) }}</span></td>
<td>{{ issuer.metadata?.['status'] || (issuer.isActive ? 'active' : 'inactive') }}</td>
<td>{{ issuer.createdAt | date:'medium' }}</td>
<td>{{ issuer.updatedAt | date:'medium' }}</td>
<td>{{ issuer.metadata?.['updatedBy'] || 'system' }}</td>
</tr>
</thead>
<tbody>
@for (issuer of issuers(); track issuer.issuerId) {
<tr [class.row-blocked]="issuer.trustLevel === 'blocked'">
<td>
<div class="issuer-info">
<strong>{{ issuer.displayName }}</strong>
<span class="issuer-name">{{ issuer.name }}</span>
@if (issuer.description) {
<span class="issuer-desc">{{ issuer.description }}</span>
}
</div>
</td>
<td>
<span class="type-badge" [class]="'type-' + issuer.issuerType">
{{ formatType(issuer.issuerType) }}
</span>
</td>
<td>
<div class="score-cell">
<div class="score-bar">
<div
class="score-fill"
[style.width.%]="issuer.trustScore"
[class]="'score-fill--' + issuer.trustLevel"
></div>
</div>
<span class="score-value">{{ issuer.trustScore }}</span>
</div>
</td>
<td>
<span class="trust-badge" [class]="'trust-' + issuer.trustLevel">
{{ formatTrustLevel(issuer.trustLevel) }}
</span>
</td>
<td>
<span class="doc-count">{{ issuer.documentCount | number }}</span>
</td>
<td>
@if (issuer.lastVerifiedAt) {
{{ issuer.lastVerifiedAt | date:'short' }}
} @else {
<span class="never-verified">Never</span>
}
</td>
<td class="actions-cell">
<button
type="button"
class="btn-action"
title="Configure Weights"
(click)="selectIssuer(issuer); showConfig.set(true)"
>
Configure
</button>
@if (issuer.trustLevel !== 'blocked') {
<button
type="button"
class="btn-action btn-action--danger"
title="Block Issuer"
(click)="onBlockIssuer(issuer)"
>
Block
</button>
} @else {
<button
type="button"
class="btn-action btn-action--success"
title="Unblock Issuer"
(click)="onUnblockIssuer(issuer)"
>
Unblock
</button>
}
</td>
</tr>
}
</tbody>
</table>
<!-- Pagination -->
@if (totalPages() > 1) {
<div class="issuer-list__pagination">
<button
type="button"
[disabled]="pageNumber() <= 1"
(click)="onPageChange(pageNumber() - 1)"
>
Previous
</button>
<span class="page-info">
Page {{ pageNumber() }} of {{ totalPages() }}
({{ totalCount() }} issuers)
</span>
<button
type="button"
[disabled]="pageNumber() >= totalPages()"
(click)="onPageChange(pageNumber() + 1)"
>
Next
</button>
</div>
}
}
</div>
</div>
}
</tbody>
</table>
}
</section>
`,
styles: [`
.issuer-list {
padding: 1.5rem;
}
.issuer-list__filters {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-end;
margin-bottom: 1.5rem;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.filter-group label {
font-size: 0.8rem;
color: var(--color-text-muted);
}
.filter-group input,
.filter-group select {
background: var(--color-surface-primary);
styles: [`
.issuer-list { padding: 1.5rem; display: grid; gap: 1rem; }
.issuer-list__header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
.issuer-list__header h2 { margin: 0 0 0.35rem; }
.issuer-list__header p { margin: 0; color: var(--color-text-secondary); max-width: 52rem; }
.issuer-list__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
.filter-field { display: grid; gap: 0.25rem; min-width: 15rem; }
.filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; }
.filter-field input, .filter-field select {
padding: 0.55rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
color: var(--color-text-primary);
padding: 0.5rem 0.75rem;
min-width: 160px;
}
.filter-group input:focus,
.filter-group select:focus {
outline: none;
border-color: var(--color-status-info);
}
.btn-config {
background: var(--color-status-excepted-border);
border: none;
color: var(--color-surface-inverse);
border-radius: var(--radius-md);
padding: 0.5rem 1rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
}
.btn-config:hover {
background: var(--color-status-excepted);
}
.btn-link {
background: none;
border: none;
color: var(--color-status-info);
cursor: pointer;
padding: 0.5rem;
font-size: 0.9rem;
}
.btn-link:hover {
text-decoration: underline;
}
.issuer-list__stats {
display: flex;
gap: 1.5rem;
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--color-surface-primary);
border-radius: var(--radius-lg);
color: var(--color-text-primary);
}
.stat {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.stat__value {
font-size: 1.5rem;
font-weight: var(--font-weight-bold);
font-variant-numeric: tabular-nums;
}
.stat__value--full { color: var(--color-status-success-border); }
.stat__value--partial { color: var(--color-status-warning-border); }
.stat__value--blocked { color: var(--color-status-error); }
.stat__label {
font-size: 0.75rem;
.contract-note {
padding: 0.9rem 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
text-transform: uppercase;
font-size: 0.88rem;
}
.issuer-list__table-container {
overflow-x: auto;
}
.issuer-list__loading,
.issuer-list__error,
.issuer-list__empty {
padding: 3rem;
.state {
padding: 2rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
text-align: center;
color: var(--color-text-muted);
}
.issuer-list__error {
color: var(--color-status-error);
}
.issuer-table {
width: 100%;
border-collapse: collapse;
}
.issuer-table th,
.issuer-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--color-border-primary);
}
.issuer-table th {
color: var(--color-text-secondary);
background: var(--color-surface-primary);
color: var(--color-text-muted);
font-weight: var(--font-weight-medium);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.issuer-table th.sortable {
cursor: pointer;
user-select: none;
}
.issuer-table th.sortable:hover {
color: var(--color-status-info);
}
.sort-indicator {
margin-left: 0.25rem;
}
.issuer-table tbody tr {
transition: background-color 0.15s;
}
.issuer-table tbody tr:hover {
background: rgba(34, 211, 238, 0.05);
}
.issuer-table tbody tr.row-blocked {
opacity: 0.6;
}
.issuer-info {
display: flex;
flex-direction: column;
}
.issuer-info strong {
color: var(--color-text-primary);
}
.issuer-name {
font-size: 0.8rem;
color: var(--color-text-secondary);
font-family: monospace;
}
.issuer-desc {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: 0.15rem;
}
.type-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.7rem;
font-weight: var(--font-weight-medium);
}
.type-csaf_publisher { background: rgba(34, 211, 238, 0.15); color: var(--color-status-info); }
.type-vex_issuer { background: rgba(74, 222, 128, 0.15); color: var(--color-status-success-border); }
.type-sbom_producer { background: rgba(167, 139, 250, 0.15); color: var(--color-status-excepted-border); }
.type-attestation_authority { background: rgba(251, 191, 36, 0.15); color: var(--color-status-warning-border); }
.score-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.score-bar {
width: 60px;
height: 6px;
background: var(--color-surface-tertiary);
border-radius: var(--radius-sm);
overflow: hidden;
}
.score-fill {
height: 100%;
transition: width 0.3s;
}
.score-fill--full { background: var(--color-status-success-border); }
.score-fill--partial { background: var(--color-status-warning-border); }
.score-fill--minimal { background: var(--color-severity-high-border); }
.score-fill--untrusted { background: var(--color-text-muted); }
.score-fill--blocked { background: var(--color-status-error); }
.score-value {
font-variant-numeric: tabular-nums;
font-weight: var(--font-weight-medium);
min-width: 2rem;
}
.trust-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
}
.trust-full { background: rgba(74, 222, 128, 0.15); color: var(--color-status-success-border); }
.trust-partial { background: rgba(251, 191, 36, 0.15); color: var(--color-status-warning-border); }
.trust-minimal { background: rgba(251, 146, 60, 0.15); color: var(--color-severity-high-border); }
.trust-untrusted { background: rgba(148, 163, 184, 0.15); color: var(--color-text-muted); }
.trust-blocked { background: rgba(239, 68, 68, 0.15); color: var(--color-status-error); }
.doc-count {
font-variant-numeric: tabular-nums;
}
.never-verified {
color: var(--color-text-secondary);
font-style: italic;
}
.actions-cell {
white-space: nowrap;
}
.btn-action {
background: transparent;
border: 1px solid var(--color-border-primary);
color: var(--color-text-primary);
border-radius: var(--radius-sm);
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
cursor: pointer;
margin-right: 0.25rem;
transition: all 0.15s;
}
.btn-action:hover {
border-color: var(--color-status-info);
color: var(--color-status-info);
}
.btn-action--danger:hover {
border-color: var(--color-status-error);
.state--error {
color: var(--color-status-error);
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
}
.btn-action--success:hover {
border-color: var(--color-status-success-border);
color: var(--color-status-success-border);
.issuer-table { width: 100%; border-collapse: collapse; }
.issuer-table th, .issuer-table td {
padding: 0.75rem 0.9rem;
border-bottom: 1px solid var(--color-border-primary);
text-align: left;
vertical-align: top;
}
.issuer-list__pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
padding: 1rem;
border-top: 1px solid var(--color-border-primary);
.issuer-table th {
font-size: 0.78rem;
text-transform: uppercase;
color: var(--color-text-secondary);
background: var(--color-surface-primary);
}
.issuer-list__pagination button {
background: transparent;
border: 1px solid var(--color-border-primary);
color: var(--color-text-primary);
border-radius: var(--radius-md);
padding: 0.5rem 1rem;
.issuer-table a { color: var(--color-status-info); word-break: break-word; }
.btn-secondary, .btn-link {
cursor: pointer;
border-radius: var(--radius-sm);
}
.issuer-list__pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
.btn-secondary {
padding: 0.4rem 0.7rem;
border: 1px solid var(--color-border-primary);
background: transparent;
color: var(--color-text-primary);
}
.issuer-list__pagination button:hover:not(:disabled) {
border-color: var(--color-status-info);
.btn-link {
border: none;
background: transparent;
color: var(--color-status-info);
padding: 0.35rem 0.5rem;
}
.page-info {
color: var(--color-text-muted);
font-size: 0.9rem;
.badge {
display: inline-flex;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-full);
font-size: 0.74rem;
font-weight: var(--font-weight-medium);
}
`]
.badge--full { background: rgba(74, 222, 128, 0.15); color: var(--color-status-success-border); }
.badge--partial { background: rgba(251, 191, 36, 0.16); color: var(--color-status-warning-border); }
.badge--minimal { background: rgba(251, 146, 60, 0.12); color: var(--color-severity-high-border); }
.badge--untrusted, .badge--blocked { background: rgba(239, 68, 68, 0.12); color: var(--color-status-error); }
`],
})
export class IssuerTrustListComponent implements OnInit {
export class IssuerTrustListComponent {
private readonly trustApi = inject(TRUST_API);
// State
readonly issuers = signal<TrustedIssuer[]>([]);
readonly loading = signal(false);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly selectedIssuer = signal<TrustedIssuer | null>(null);
readonly showConfig = signal(false);
// Pagination
readonly pageNumber = signal(1);
readonly pageSize = signal(20);
readonly totalCount = signal(0);
readonly totalPages = computed(() => Math.ceil(this.totalCount() / this.pageSize()));
// Filters
readonly searchQuery = signal('');
readonly selectedTrustLevel = signal<IssuerTrustLevel | 'all'>('all');
readonly selectedType = signal<IssuerType | 'all'>('all');
readonly sortBy = signal<'name' | 'trustScore' | 'createdAt' | 'lastVerifiedAt'>('trustScore');
readonly sortDirection = signal<'asc' | 'desc'>('desc');
// Computed
readonly hasFilters = computed(() =>
this.searchQuery() !== '' ||
this.selectedTrustLevel() !== 'all' ||
this.selectedType() !== 'all'
);
readonly hasFilters = computed(() => this.searchQuery().trim().length > 0 || this.selectedTrustLevel() !== 'all');
readonly averageScore = computed(() => {
const list = this.issuers();
if (list.length === 0) return 0;
return list.reduce((sum, i) => sum + i.trustScore, 0) / list.length;
});
ngOnInit(): void {
constructor() {
this.loadIssuers();
}
private loadIssuers(): void {
loadIssuers(): void {
this.loading.set(true);
this.error.set(null);
const params: ListIssuersParams = {
pageNumber: this.pageNumber(),
pageSize: this.pageSize(),
search: this.searchQuery() || undefined,
trustLevel: this.selectedTrustLevel() !== 'all' ? this.selectedTrustLevel() as IssuerTrustLevel : undefined,
issuerType: this.selectedType() !== 'all' ? this.selectedType() as IssuerType : undefined,
sortBy: this.sortBy(),
sortDirection: this.sortDirection(),
};
this.trustApi.listIssuers(params).subscribe({
const trustLevel = this.selectedTrustLevel();
this.trustApi.listIssuers({
pageNumber: 1,
pageSize: 200,
search: this.searchQuery().trim() || undefined,
trustLevel: trustLevel === 'all' ? undefined : trustLevel,
sortBy: 'name',
sortDirection: 'asc',
}).subscribe({
next: (result) => {
this.issuers.set([...result.items]);
this.totalCount.set(result.totalCount);
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Failed to load issuers');
this.error.set(err?.error?.error || err?.message || 'Failed to load issuers.');
this.loading.set(false);
},
});
}
onSearch(): void {
this.pageNumber.set(1);
this.loadIssuers();
}
onFilterChange(): void {
this.pageNumber.set(1);
this.loadIssuers();
}
onSort(column: 'name' | 'trustScore' | 'createdAt' | 'lastVerifiedAt'): void {
if (this.sortBy() === column) {
this.sortDirection.set(this.sortDirection() === 'asc' ? 'desc' : 'asc');
} else {
this.sortBy.set(column);
this.sortDirection.set(column === 'trustScore' ? 'desc' : 'asc');
}
this.loadIssuers();
}
onPageChange(page: number): void {
this.pageNumber.set(page);
applyFilters(): void {
this.loadIssuers();
}
clearFilters(): void {
this.searchQuery.set('');
this.selectedTrustLevel.set('all');
this.selectedType.set('all');
this.pageNumber.set(1);
this.loadIssuers();
}
selectIssuer(issuer: TrustedIssuer): void {
this.selectedIssuer.set(issuer);
}
countByLevel(level: IssuerTrustLevel): number {
return this.issuers().filter(i => i.trustLevel === level).length;
}
onBlockIssuer(issuer: TrustedIssuer): void {
const reason = prompt(`Enter reason for blocking "${issuer.displayName}":`);
if (!reason) return;
this.trustApi.blockIssuer(issuer.issuerId, reason).subscribe({
next: () => {
this.loadIssuers();
},
error: (err) => {
this.error.set(`Failed to block issuer: ${err.message}`);
},
});
}
onUnblockIssuer(issuer: TrustedIssuer): void {
if (!confirm(`Unblock "${issuer.displayName}"?`)) return;
this.trustApi.unblockIssuer(issuer.issuerId).subscribe({
next: () => {
this.loadIssuers();
},
error: (err) => {
this.error.set(`Failed to unblock issuer: ${err.message}`);
},
});
}
onConfigSaved(): void {
this.loadIssuers();
this.selectedIssuer.set(null);
}
formatType(type: IssuerType): string {
const labels: Record<IssuerType, string> = {
csaf_publisher: 'CSAF Publisher',
vex_issuer: 'VEX Issuer',
sbom_producer: 'SBOM Producer',
attestation_authority: 'Attestation Authority',
};
return labels[type] || type;
}
formatTrustLevel(level: IssuerTrustLevel): string {
const labels: Record<IssuerTrustLevel, string> = {
full: 'Full Trust',
partial: 'Partial',
minimal: 'Minimal',
untrusted: 'Untrusted',
blocked: 'Blocked',
};
return labels[level] || level;
return level.charAt(0).toUpperCase() + level.slice(1);
}
}

View File

@@ -426,7 +426,7 @@ export class TrustAdminComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
// State
readonly loading = signal(false);
readonly loading = signal(true);
readonly refreshing = signal(false);
readonly error = signal<string | null>(null);
readonly overview = signal<TrustAdministrationOverview | null>(null);

View File

@@ -153,7 +153,7 @@ describe('AppSidebarComponent', () => {
expect(text).toContain('Bundles');
});
it('shows Operations children: Scheduled Jobs, Diagnostics, Signals, Offline Kit, Environments', () => {
it('shows Operations children: Scheduled Jobs, Signals, Offline Kit, Environments', () => {
setScopes([
StellaOpsScopes.UI_ADMIN,
StellaOpsScopes.ORCH_READ,
@@ -167,12 +167,26 @@ describe('AppSidebarComponent', () => {
const hrefs = links.map((link) => link.getAttribute('href'));
expect(hrefs).toContain('/ops/operations/jobengine');
expect(hrefs).toContain('/ops/operations/doctor');
expect(hrefs).toContain('/ops/operations/signals');
expect(hrefs).toContain('/ops/operations/offline-kit');
expect(hrefs).toContain('/ops/operations/environments');
});
it('shows Diagnostics under Setup', () => {
setScopes([
StellaOpsScopes.UI_ADMIN,
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.HEALTH_READ,
]);
const fixture = createComponent();
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
const hrefs = links.map((link) => link.getAttribute('href'));
expect(hrefs).toContain('/ops/operations/doctor');
});
it('does not show Notifications under Setup', () => {
setScopes([
StellaOpsScopes.UI_ADMIN,

View File

@@ -713,7 +713,6 @@ export class AppSidebarComponent implements AfterViewInit {
],
children: [
{ id: 'ops-jobs', label: 'Scheduled Jobs', route: '/ops/operations/jobengine', icon: 'clock' },
{ id: 'ops-diagnostics', label: 'Diagnostics', route: '/ops/operations/doctor', icon: 'stethoscope' },
{ id: 'ops-signals', label: 'Signals', route: '/ops/operations/signals', icon: 'radio' },
{ id: 'ops-offline-kit', label: 'Offline Kit', route: '/ops/operations/offline-kit', icon: 'download-cloud' },
{ id: 'ops-environments', label: 'Environments', route: '/ops/operations/environments', icon: 'globe' },
@@ -788,6 +787,13 @@ export class AppSidebarComponent implements AfterViewInit {
],
children: [
{ id: 'setup-topology', label: 'Topology', route: '/setup/topology/overview', icon: 'globe' },
{
id: 'setup-diagnostics',
label: 'Diagnostics',
route: '/ops/operations/doctor',
icon: 'stethoscope',
requireAnyScope: [StellaOpsScopes.HEALTH_READ, StellaOpsScopes.UI_ADMIN],
},
{ id: 'setup-integrations', label: 'Integrations', route: '/setup/integrations', icon: 'plug' },
{ id: 'setup-iam', label: 'Identity & Access', route: '/setup/identity-access', icon: 'user' },
{ id: 'setup-trust-signing', label: 'Trust & Signing', route: '/setup/trust-signing', icon: 'shield' },

View File

@@ -140,12 +140,12 @@ describe('FilterBarComponent', () => {
});
it('should show clear-all button only when active filters exist', () => {
component.activeFilters = [];
fixture.componentRef.setInput('activeFilters', []);
fixture.detectChanges();
let clearBtn = fixture.nativeElement.querySelector('.filter-bar__clear');
expect(clearBtn).toBeNull();
component.activeFilters = [{ key: 'severity', value: 'high', label: 'Severity: High' }];
fixture.componentRef.setInput('activeFilters', [{ key: 'severity', value: 'high', label: 'Severity: High' }]);
fixture.detectChanges();
clearBtn = fixture.nativeElement.querySelector('.filter-bar__clear');
expect(clearBtn).toBeTruthy();

View File

@@ -92,18 +92,21 @@ export interface ActiveFilter {
}
.filter-bar__top-row {
display: flex;
align-items: center;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 0.5rem;
}
.filter-bar__search {
position: relative;
flex: 1;
min-width: 200px;
min-width: 0;
width: 100%;
}
.filter-bar__search-btn {
align-self: stretch;
min-height: 2.5rem;
padding: 0.5rem 1rem;
border: 1px solid var(--color-brand-primary);
border-radius: var(--radius-md);
@@ -120,6 +123,29 @@ export interface ActiveFilter {
opacity: 0.9;
}
@media (max-width: 720px) {
.filter-bar__top-row {
grid-template-columns: 1fr;
}
.filter-bar__search {
width: 100%;
}
.filter-bar__search-btn {
width: 100%;
justify-content: center;
}
.filter-bar__active {
flex-wrap: wrap;
}
.filter-bar__clear {
margin-left: 0;
}
}
.filter-bar__search-icon {
position: absolute;
left: 0.75rem;
@@ -129,7 +155,9 @@ export interface ActiveFilter {
}
.filter-bar__search-input {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);

View File

@@ -21,6 +21,7 @@
"src/app/features/evidence-export/provenance-visualization.component.spec.ts",
"src/app/features/evidence-export/replay-controls.component.spec.ts",
"src/app/features/evidence-export/stella-bundle-export-button/stella-bundle-export-button.component.spec.ts",
"src/app/features/jobengine/jobengine-dashboard.component.spec.ts",
"src/app/features/platform/ops/platform-jobs-queues-page.component.spec.ts",
"src/app/features/policy-simulation/policy-simulation-direct-route-defaults.spec.ts",
"src/app/features/policy-simulation/policy-simulation-defaults.spec.ts",
@@ -29,6 +30,7 @@
"src/app/features/registry-admin/registry-admin.component.spec.ts",
"src/app/features/triage/services/ttfs-telemetry.service.spec.ts",
"src/app/features/triage/triage-workspace.component.spec.ts",
"src/app/shared/ui/filter-bar/filter-bar.component.spec.ts",
"src/app/features/watchlist/watchlist-page.component.spec.ts",
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts"
]