Repair first-time user reporting truthfulness journeys

This commit is contained in:
master
2026-03-15 14:21:18 +02:00
parent b565e55942
commit e884b4bddd
17 changed files with 948 additions and 242 deletions

View File

@@ -84,7 +84,7 @@ Completion criteria:
- [ ] Retained Playwright journeys cover keys, issuers, certificates, analytics, and destructive-action confirmations.
### FTU-OPS-005 - Align onboarding, context, empty states, and naming across the product
Status: TODO
Status: DOING
Dependency: FTU-OPS-001
Owners: Product Manager, Architect, Developer, Documentation author
Task description:
@@ -154,6 +154,7 @@ Completion criteria:
| 2026-03-15 | Recorded the product and architecture decisions for the first grouped implementation batch: upgrade the setup identity surface to expose the real Authority admin contract, replace trust prompt-based actions with modal workflows, and stop relying on dead trust analytics endpoints. | Product / Architect |
| 2026-03-15 | Shipped the grouped identity/trust operator batch on the current live stack: scope catalog and role detail, truthful user and tenant lifecycle actions, in-app trust create/block/unblock/verify/revoke workflows, and derived trust analytics that no longer call dead endpoints. Focused backend/frontend test slices passed before live retest. | Developer |
| 2026-03-15 | Replaced the stale admin/trust retained journey with `live-user-reported-admin-trust-check.mjs`, added step-level logging, aligned it to the repaired trust shell contract, and reran it cleanly on `https://stella-ops.local` with `failedCheckCount=0`. | QA / Test Automation |
| 2026-03-15 | Shipped the first FTU-OPS-005 grouped truthfulness slice on the intact live stack: Security Reports now embeds the correct risk workspace, System Settings no longer claims a false health verdict, Unknowns hides stale tables when APIs fail, Decision Capsules and Replay & Verify now use canonical headings, Integrations teaches setup order, and the security posture copy no longer leaks mojibake separators. Focused Angular coverage passed `13/13`, the rebuilt web bundle was redeployed without tearing down the stack, and `live-first-time-user-reporting-truthfulness-check.mjs` now passes with `failedCheckCount=0` and `runtimeIssueCount=0`. | Developer / QA |
## Decisions & Risks
- Decision: the operators first-time setup and release-confidence journey is now the primary quality bar; broad green route sweeps are supporting evidence only.

View File

@@ -0,0 +1,291 @@
#!/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-first-time-user-reporting-truthfulness-check.json');
const authStatePath = path.join(outputDir, 'live-first-time-user-reporting-truthfulness-check.state.json');
const authReportPath = path.join(outputDir, 'live-first-time-user-reporting-truthfulness-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}`;
}
function cleanText(value) {
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
}
async function settle(page, ms = 1000) {
await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
await page.waitForTimeout(ms);
}
async function navigate(page, route, ms = 1250) {
await page.goto(buildUrl(route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
await settle(page, ms);
}
async function bannerText(page) {
return page
.locator('.banner, .error-banner, .unknowns-dashboard__error, .state-block--error, [role="alert"]')
.first()
.textContent()
.then(cleanText)
.catch(() => '');
}
async function pageHeading(page) {
return page.locator('h1').first().textContent().then(cleanText).catch(() => '');
}
async function collectRuntime(page, runtime) {
page.on('console', (message) => {
const text = cleanText(message.text());
if (
message.type() === 'error'
&& text !== 'Failed to load resource: the server responded with a status of 500 (Internal Server Error)'
&& !text.includes('/api/v1/scanner/unknowns')
) {
runtime.consoleErrors.push(text);
}
});
page.on('pageerror', (error) => {
runtime.pageErrors.push(cleanText(error.message));
});
page.on('response', async (response) => {
if (response.status() >= 500 && !response.url().includes('/api/v1/scanner/unknowns')) {
runtime.responseErrors.push(`${response.status()} ${response.url()}`);
}
});
page.on('requestfailed', (request) => {
const errorText = request.failure()?.errorText || 'request failed';
if (errorText === 'net::ERR_ABORTED') {
return;
}
runtime.requestFailures.push(`${errorText} ${request.url()}`);
});
}
async function main() {
await mkdir(outputDir, { recursive: true });
const runtime = {
consoleErrors: [],
pageErrors: [],
requestFailures: [],
responseErrors: [],
};
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();
page.setDefaultTimeout(20_000);
page.setDefaultNavigationTimeout(30_000);
await collectRuntime(page, runtime);
const results = [];
const failures = [];
try {
await navigate(page, '/security/reports');
await page.waitForFunction(() => document.body.textContent?.includes('Risk Posture'), { timeout: 20_000 }).catch(() => {});
results.push({
key: 'security-reports-risk-tab',
route: '/security/reports',
ok: true,
snapshot: {
heading: await pageHeading(page),
banner: await bannerText(page),
hasRiskPosture: await page.locator('text=Risk Posture').count().then((count) => count > 0),
hasArtifactWorkspace: await page.locator('text=Artifact workspace').count().then((count) => count > 0),
},
});
await page.getByRole('tab', { name: 'VEX Ledger', exact: true }).click();
await settle(page, 1250);
results.push({
key: 'security-reports-vex-tab',
route: '/security/reports?tab=vex',
ok: true,
snapshot: {
tabText: await page.locator('.tab-content').textContent().then(cleanText).catch(() => ''),
},
});
await page.getByRole('tab', { name: 'Evidence Export', exact: true }).click();
await page
.locator('.security-reports .report-panel .export-center')
.waitFor({ state: 'visible', timeout: 20_000 })
.catch(() => {});
await settle(page, 750);
results.push({
key: 'security-reports-evidence-tab',
route: '/security/reports?tab=evidence',
ok: true,
snapshot: {
tabText: await page.locator('.security-reports .report-panel').textContent().then(cleanText).catch(() => ''),
},
});
await navigate(page, '/setup/system');
results.push({
key: 'setup-system-truthfulness',
route: '/setup/system',
ok: true,
snapshot: {
heading: await pageHeading(page),
text: await page.locator('body').textContent().then(cleanText).catch(() => ''),
},
});
await navigate(page, '/setup/integrations');
results.push({
key: 'setup-integrations-guidance',
route: '/setup/integrations',
ok: true,
snapshot: {
heading: await pageHeading(page),
text: await page.locator('body').textContent().then(cleanText).catch(() => ''),
},
});
await navigate(page, '/evidence/capsules');
results.push({
key: 'decision-capsules-heading',
route: '/evidence/capsules',
ok: true,
snapshot: {
heading: await pageHeading(page),
},
});
await navigate(page, '/evidence/exports/replay');
results.push({
key: 'replay-route-naming',
route: '/evidence/exports/replay',
ok: true,
snapshot: {
heading: await pageHeading(page),
},
});
await navigate(page, '/security');
results.push({
key: 'security-posture-heading',
route: '/security',
ok: true,
snapshot: {
heading: await pageHeading(page),
text: await page.locator('body').textContent().then(cleanText).catch(() => ''),
},
});
await page.route('**/api/v1/scanner/unknowns**', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'forced-test-failure' }),
});
});
await navigate(page, '/security/unknowns', 1500);
results.push({
key: 'security-unknowns-error-state',
route: '/security/unknowns',
ok: true,
snapshot: {
heading: await pageHeading(page),
banner: await bannerText(page),
},
});
} finally {
await page.close().catch(() => {});
await context.close().catch(() => {});
await browser.close().catch(() => {});
}
const riskReport = results.find((entry) => entry.key === 'security-reports-risk-tab');
if (!riskReport?.snapshot?.hasRiskPosture || riskReport?.snapshot?.hasArtifactWorkspace) {
failures.push('Security Reports risk tab is not rendering the risk posture workspace cleanly.');
}
const vexReport = results.find((entry) => entry.key === 'security-reports-vex-tab');
if (!cleanText(vexReport?.snapshot?.tabText ?? '').includes('Providers')) {
failures.push('Security Reports VEX tab did not render the VEX ledger workspace.');
}
const evidenceReport = results.find((entry) => entry.key === 'security-reports-evidence-tab');
if (!cleanText(evidenceReport?.snapshot?.tabText ?? '').includes('Export Center')) {
failures.push('Security Reports evidence tab did not render the export workspace.');
}
const systemRoute = results.find((entry) => entry.key === 'setup-system-truthfulness');
const systemText = cleanText(systemRoute?.snapshot?.text ?? '');
if (!systemText.includes('handoff, not a health verdict') || systemText.includes('All systems operational')) {
failures.push('Setup System still presents a false health verdict.');
}
const integrations = results.find((entry) => entry.key === 'setup-integrations-guidance');
const integrationsText = cleanText(integrations?.snapshot?.text ?? '');
if (
cleanText(integrations?.snapshot?.heading ?? '') !== 'Integrations'
|| !integrationsText.includes('Suggested Setup Order')
|| integrationsText.includes('coming soon')
) {
failures.push('Setup Integrations is still missing truthful first-time-user guidance.');
}
const capsules = results.find((entry) => entry.key === 'decision-capsules-heading');
if (cleanText(capsules?.snapshot?.heading ?? '') !== 'Decision Capsules') {
failures.push('Decision Capsules no longer exposes a primary H1 heading.');
}
const replay = results.find((entry) => entry.key === 'replay-route-naming');
if (cleanText(replay?.snapshot?.heading ?? '') !== 'Replay & Verify') {
failures.push('Evidence replay naming is still inconsistent with the canonical route contract.');
}
const security = results.find((entry) => entry.key === 'security-posture-heading');
const securityText = cleanText(security?.snapshot?.text ?? '');
if (cleanText(security?.snapshot?.heading ?? '') !== 'Security Posture' || securityText.includes('�') || securityText.includes('<27>')) {
failures.push('Security posture still exposes inconsistent naming or mojibake separators.');
}
const unknowns = results.find((entry) => entry.key === 'security-unknowns-error-state');
if (!cleanText(unknowns?.snapshot?.banner ?? '').includes('Unknowns data is unavailable')) {
failures.push('Security Unknowns did not surface a truthful error state when the APIs failed.');
}
const report = {
generatedAtUtc: new Date().toISOString(),
baseUrl,
failedCheckCount: failures.length,
runtimeIssueCount:
runtime.consoleErrors.length + runtime.pageErrors.length + runtime.requestFailures.length + runtime.responseErrors.length,
results,
runtime,
failures,
};
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
if (failures.length > 0) {
process.exitCode = 1;
}
}
await main();

View File

@@ -62,6 +62,11 @@ const suites = [
script: 'live-user-reported-admin-trust-check.mjs',
reportPath: path.join(outputDir, 'live-user-reported-admin-trust-check.json'),
},
{
name: 'first-time-user-reporting-truthfulness-check',
script: 'live-first-time-user-reporting-truthfulness-check.mjs',
reportPath: path.join(outputDir, 'live-first-time-user-reporting-truthfulness-check.json'),
},
{
name: 'changed-surfaces',
script: 'live-frontdoor-changed-surfaces.mjs',

View File

@@ -0,0 +1,11 @@
import { evidenceExportRoutes } from './evidence-export.routes';
describe('evidenceExportRoutes', () => {
it('keeps the replay child route aligned with the canonical Replay & Verify contract', () => {
const replayRoute = evidenceExportRoutes.find((route) => route.path === 'replay');
expect(replayRoute).toBeDefined();
expect(replayRoute?.title).toBe('Replay & Verify');
expect(replayRoute?.data?.['breadcrumb']).toBe('Replay & Verify');
});
});

View File

@@ -36,8 +36,8 @@ export const evidenceExportRoutes: Routes = [
},
{
path: 'replay',
title: 'Verdict Replay',
data: { breadcrumb: 'Verdict Replay' },
title: 'Replay & Verify',
data: { breadcrumb: 'Replay & Verify' },
loadComponent: () =>
import('./replay-controls.component').then(
(m) => m.ReplayControlsComponent

View File

@@ -0,0 +1,42 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { EVIDENCE_PACK_API, type EvidencePackApi } from '../../core/api/evidence-pack.client';
import { EvidencePackListComponent } from './evidence-pack-list.component';
describe('EvidencePackListComponent', () => {
let fixture: ComponentFixture<EvidencePackListComponent>;
let evidenceApi: jasmine.SpyObj<EvidencePackApi>;
beforeEach(async () => {
evidenceApi = jasmine.createSpyObj<EvidencePackApi>('EvidencePackApi', [
'list',
'listByRun',
'get',
'create',
'sign',
'verify',
'export',
]);
evidenceApi.list.and.returnValue(of({ count: 0, packs: [] }));
evidenceApi.listByRun.and.returnValue(of({ count: 0, packs: [] }));
await TestBed.configureTestingModule({
imports: [EvidencePackListComponent],
providers: [
provideRouter([]),
{ provide: EVIDENCE_PACK_API, useValue: evidenceApi },
],
}).compileComponents();
fixture = TestBed.createComponent(EvidencePackListComponent);
fixture.detectChanges();
});
it('treats decision capsules as a page-level route surface with a primary heading', () => {
const heading = fixture.nativeElement.querySelector('h1.list-title') as HTMLElement | null;
expect(heading?.textContent).toContain('Decision Capsules');
expect(fixture.nativeElement.querySelector('h2.list-title')).toBeNull();
});
});

View File

@@ -34,7 +34,7 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/
<!-- Header with filters -->
<header class="list-header">
<div>
<h2 class="list-title">Decision Capsules</h2>
<h1 class="list-title">Decision Capsules</h1>
<p class="list-subtitle">Browse signed evidence packs that explain release, policy, and operator decisions.</p>
</div>
@if (runId) {

View File

@@ -1,27 +1,28 @@
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { IntegrationHubComponent } from './integration-hub.component';
import { IntegrationService } from './integration.service';
import { IntegrationType, IntegrationListResponse } from './integration.models';
import { IntegrationListResponse, IntegrationType } from './integration.models';
describe('IntegrationHubComponent', () => {
let fixture: ComponentFixture<IntegrationHubComponent>;
let component: IntegrationHubComponent;
let mockIntegrationService: jasmine.SpyObj<IntegrationService>;
let router: Router;
let route: ActivatedRoute;
const mockListResponse = (totalCount: number): IntegrationListResponse => ({
items: [],
totalCount,
page: 1,
pageSize: 1,
hasMore: false,
totalPages: 1,
});
beforeEach(async () => {
mockIntegrationService = jasmine.createSpyObj('IntegrationService', ['list']);
// Default mock responses
mockIntegrationService.list.and.callFake((params) => {
switch (params?.type) {
case IntegrationType.Registry:
@@ -34,6 +35,8 @@ describe('IntegrationHubComponent', () => {
return of(mockListResponse(8));
case IntegrationType.FeedMirror:
return of(mockListResponse(4));
case IntegrationType.RepoSource:
return of(mockListResponse(1));
default:
return of(mockListResponse(0));
}
@@ -46,154 +49,67 @@ describe('IntegrationHubComponent', () => {
fixture = TestBed.createComponent(IntegrationHubComponent);
component = fixture.componentInstance;
router = TestBed.inject(Router);
route = TestBed.inject(ActivatedRoute);
});
it('should create', () => {
it('creates the hub', () => {
expect(component).toBeTruthy();
});
it('should display page header', () => {
it('renders first-time-user setup guidance instead of a coming-soon placeholder', async () => {
fixture.detectChanges();
const header = fixture.nativeElement.querySelector('.hub-header h1');
expect(header.textContent).toBe('Integration Hub');
});
it('should display subtitle', () => {
await fixture.whenStable();
fixture.detectChanges();
const subtitle = fixture.nativeElement.querySelector('.subtitle');
expect(subtitle.textContent).toContain('Manage registries');
const text = fixture.nativeElement.textContent;
expect(text).toContain('Integrations');
expect(text).toContain('Suggested Setup Order');
expect(text).toContain('Use the activity timeline for connector event history');
expect(text).not.toContain('coming soon');
});
describe('Navigation Tiles', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('loads connector totals for all setup categories', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
it('should display all navigation tiles', () => {
const tiles = fixture.nativeElement.querySelectorAll('.nav-tile');
expect(tiles.length).toBe(5);
});
it('should display registries tile', () => {
const tile = fixture.nativeElement.querySelector('a[routerLink="registries"]');
expect(tile).toBeTruthy();
expect(tile.textContent).toContain('Registries');
});
it('should display SCM tile', () => {
const tile = fixture.nativeElement.querySelector('a[routerLink="scm"]');
expect(tile).toBeTruthy();
expect(tile.textContent).toContain('SCM');
});
it('should display CI/CD tile', () => {
const tile = fixture.nativeElement.querySelector('a[routerLink="ci"]');
expect(tile).toBeTruthy();
expect(tile.textContent).toContain('CI/CD');
});
it('should display Hosts tile', () => {
const tile = fixture.nativeElement.querySelector('a[routerLink="hosts"]');
expect(tile).toBeTruthy();
expect(tile.textContent).toContain('Hosts');
});
it('should display Feeds tile', () => {
const tile = fixture.nativeElement.querySelector('a[routerLink="feeds"]');
expect(tile).toBeTruthy();
expect(tile.textContent).toContain('Feeds');
});
expect(mockIntegrationService.list).toHaveBeenCalledTimes(6);
expect(component.stats().registries).toBe(5);
expect(component.stats().scm).toBe(3);
expect(component.stats().ci).toBe(2);
expect(component.stats().runtimeHosts).toBe(8);
expect(component.stats().advisorySources).toBe(4);
expect(component.stats().vexSources).toBe(4);
expect(component.stats().secrets).toBe(1);
expect(component.configuredConnectorCount()).toBe(27);
});
describe('Stats Loading', () => {
it('should load stats on init', fakeAsync(() => {
fixture.detectChanges();
tick();
it('keeps the suggested setup-order links on the canonical integrations routes', async () => {
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
expect(mockIntegrationService.list).toHaveBeenCalledTimes(5);
}));
const hrefs = Array.from(
fixture.nativeElement.querySelectorAll('.setup-order__list a') as NodeListOf<HTMLAnchorElement>,
).map((link) => link.getAttribute('href'));
it('should display registry count', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
expect(component.stats.registries).toBe(5);
}));
it('should display SCM count', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
expect(component.stats.scm).toBe(3);
}));
it('should display CI count', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
expect(component.stats.ci).toBe(2);
}));
it('should display hosts count', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
expect(component.stats.hosts).toBe(8);
}));
it('should display feeds count', fakeAsync(() => {
fixture.detectChanges();
tick();
fixture.detectChanges();
expect(component.stats.feeds).toBe(4);
}));
expect(hrefs).toContain('/registries');
expect(hrefs).toContain('/scm');
expect(hrefs).toContain('/ci');
expect(hrefs).toContain('/advisory-vex-sources');
expect(hrefs).toContain('/secrets');
});
describe('Actions', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('navigates to the onboarding flow from the primary action', async () => {
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
it('should display Add Integration button', () => {
const btn = fixture.nativeElement.querySelector('.btn-primary');
expect(btn).toBeTruthy();
expect(btn.textContent).toContain('Add Integration');
});
component.addIntegration();
it('should display View Activity link', () => {
const link = fixture.nativeElement.querySelector('a[routerLink="activity"]');
expect(link).toBeTruthy();
expect(link.textContent).toContain('View Activity');
});
it('should handle add integration click', () => {
const consoleSpy = spyOn(console, 'log');
component.addIntegration();
expect(consoleSpy).toHaveBeenCalledWith('Add integration clicked');
});
});
describe('Recent Activity Section', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should display Recent Activity heading', () => {
const heading = fixture.nativeElement.querySelector('.hub-summary h2');
expect(heading.textContent).toBe('Recent Activity');
});
it('should display placeholder text', () => {
const placeholder = fixture.nativeElement.querySelector('.placeholder');
expect(placeholder).toBeTruthy();
expect(placeholder.textContent).toContain('coming soon');
expect(navigateSpy).toHaveBeenCalledWith(['onboarding'], {
relativeTo: route,
queryParamsHandling: 'merge',
});
});
});

View File

@@ -1,43 +1,95 @@
import { ChangeDetectorRef, Component, NgZone, inject } from '@angular/core';
import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { IntegrationService } from './integration.service';
import { IntegrationType } from './integration.models';
interface IntegrationHubStats {
registries: number;
scm: number;
ci: number;
runtimeHosts: number;
advisorySources: number;
vexSources: number;
secrets: number;
}
@Component({
selector: 'app-integration-hub',
standalone: true,
imports: [RouterModule],
template: `
<section class="integration-hub">
<header class="hub-header">
<div>
<h1>Integrations</h1>
<p class="hub-subtitle">
Connect the external systems Stella Ops depends on, then verify them from the same setup surface.
</p>
</div>
<div class="hub-summary" aria-live="polite">
<strong>{{ configuredConnectorCount() }}</strong>
<span>configured connectors</span>
</div>
</header>
<section class="setup-order" aria-labelledby="integration-setup-order">
<div class="setup-order__header">
<h2 id="integration-setup-order">Suggested Setup Order</h2>
<p>Start with the connectors that unblock releases and evidence, then add operator conveniences.</p>
</div>
<ol class="setup-order__list">
<li>
<a routerLink="registries">Registries</a>
<span>Connect the container sources that release versions, promotions, and policy checks depend on.</span>
</li>
<li>
<a routerLink="scm">Source Control</a>
<span>Wire repository and commit metadata before relying on release evidence and drift context.</span>
</li>
<li>
<a routerLink="ci">CI/CD</a>
<span>Capture pipeline runs and deployment triggers for release confidence.</span>
</li>
<li>
<a routerLink="advisory-vex-sources">Advisory &amp; VEX Sources</a>
<span>Keep security posture, exceptions, and freshness checks truthful.</span>
</li>
<li>
<a routerLink="secrets">Secrets</a>
<span>Finish by wiring vaults and credentials used by downstream integrations.</span>
</li>
</ol>
</section>
<nav class="tiles">
<a routerLink="registries" class="tile">
<span>Registries</span>
<strong>{{ stats.registries }}</strong>
<strong>{{ stats().registries }}</strong>
</a>
<a routerLink="scm" class="tile">
<span>SCM</span>
<strong>{{ stats.scm }}</strong>
<strong>{{ stats().scm }}</strong>
</a>
<a routerLink="ci" class="tile">
<span>CI/CD</span>
<strong>{{ stats.ci }}</strong>
<strong>{{ stats().ci }}</strong>
</a>
<a routerLink="runtime-hosts" class="tile">
<span>Runtimes / Hosts</span>
<strong>{{ stats.runtimeHosts }}</strong>
<strong>{{ stats().runtimeHosts }}</strong>
</a>
<a routerLink="advisory-vex-sources" class="tile">
<span>Advisory Sources</span>
<strong>{{ stats.advisorySources }}</strong>
<strong>{{ stats().advisorySources }}</strong>
</a>
<a routerLink="advisory-vex-sources" class="tile">
<span>VEX Sources</span>
<strong>{{ stats.vexSources }}</strong>
<strong>{{ stats().vexSources }}</strong>
</a>
<a routerLink="secrets" class="tile">
<span>Secrets</span>
<strong>{{ stats.secrets }}</strong>
<strong>{{ stats().secrets }}</strong>
</a>
</nav>
@@ -48,10 +100,17 @@ import { IntegrationType } from './integration.models';
<section class="activity" aria-live="polite">
<h2>Recent Activity</h2>
<p class="activity__title">Activity stream is coming soon</p>
<p class="activity__text">
Connector timeline events will appear here once integration telemetry wiring is complete.
</p>
@if (configuredConnectorCount() === 0) {
<p class="activity__title">No integration activity recorded yet</p>
<p class="activity__text">
Add your first connector, run Test Connection, or open the full activity timeline after the next sync.
</p>
} @else {
<p class="activity__title">Use the activity timeline for connector event history</p>
<p class="activity__text">
The hub summary shows configured connectors. Open View Activity for test, sync, and health events per integration.
</p>
}
</section>
</section>
`,
@@ -64,6 +123,89 @@ import { IntegrationType } from './integration.models';
padding: 1rem 0;
}
.hub-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.9rem 1rem;
}
.hub-header h1 {
margin: 0;
font-size: 1.4rem;
font-weight: var(--font-weight-semibold);
}
.hub-subtitle {
margin: 0.3rem 0 0;
font-size: 0.85rem;
color: var(--color-text-secondary);
max-width: 58ch;
}
.hub-summary {
display: grid;
gap: 0.1rem;
min-width: 120px;
text-align: right;
}
.hub-summary strong {
font-size: 1.4rem;
color: var(--color-text-heading);
}
.hub-summary span {
font-size: 0.74rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.setup-order {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.9rem 1rem;
display: grid;
gap: 0.8rem;
}
.setup-order__header h2 {
margin: 0;
font-size: 0.95rem;
}
.setup-order__header p {
margin: 0.25rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
.setup-order__list {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.55rem;
}
.setup-order__list li {
display: grid;
gap: 0.15rem;
color: var(--color-text-secondary);
font-size: 0.78rem;
}
.setup-order__list a {
color: var(--color-brand-primary);
font-weight: var(--font-weight-semibold);
text-decoration: none;
}
.tiles {
display: grid;
gap: 0.55rem;
@@ -134,16 +276,24 @@ import { IntegrationType } from './integration.models';
font-size: 0.76rem;
color: var(--color-text-secondary);
}
@media (max-width: 720px) {
.hub-header {
display: grid;
}
.hub-summary {
text-align: left;
}
}
`],
})
export class IntegrationHubComponent {
private readonly integrationService = inject(IntegrationService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly cdr = inject(ChangeDetectorRef);
private readonly zone = inject(NgZone);
stats = {
readonly stats = signal<IntegrationHubStats>({
registries: 0,
scm: 0,
ci: 0,
@@ -151,7 +301,11 @@ export class IntegrationHubComponent {
advisorySources: 0,
vexSources: 0,
secrets: 0,
};
});
configuredConnectorCount(): number {
return Object.values(this.stats()).reduce((sum, value) => sum + value, 0);
}
constructor() {
this.loadStats();
@@ -159,45 +313,33 @@ export class IntegrationHubComponent {
private loadStats(): void {
this.integrationService.list({ type: IntegrationType.Registry, pageSize: 1 }).subscribe({
next: (res) => this.commitUiUpdate(() => (this.stats.registries = res.totalCount)),
error: () => this.commitUiUpdate(() => (this.stats.registries = 0)),
next: (res) => this.updateStats({ registries: res.totalCount }),
error: () => this.updateStats({ registries: 0 }),
});
this.integrationService.list({ type: IntegrationType.Scm, pageSize: 1 }).subscribe({
next: (res) => this.commitUiUpdate(() => (this.stats.scm = res.totalCount)),
error: () => this.commitUiUpdate(() => (this.stats.scm = 0)),
next: (res) => this.updateStats({ scm: res.totalCount }),
error: () => this.updateStats({ scm: 0 }),
});
this.integrationService.list({ type: IntegrationType.CiCd, pageSize: 1 }).subscribe({
next: (res) => this.commitUiUpdate(() => (this.stats.ci = res.totalCount)),
error: () => this.commitUiUpdate(() => (this.stats.ci = 0)),
next: (res) => this.updateStats({ ci: res.totalCount }),
error: () => this.updateStats({ ci: 0 }),
});
this.integrationService.list({ type: IntegrationType.RuntimeHost, pageSize: 1 }).subscribe({
next: (res) => this.commitUiUpdate(() => (this.stats.runtimeHosts = res.totalCount)),
error: () => this.commitUiUpdate(() => (this.stats.runtimeHosts = 0)),
next: (res) => this.updateStats({ runtimeHosts: res.totalCount }),
error: () => this.updateStats({ runtimeHosts: 0 }),
});
this.integrationService.list({ type: IntegrationType.FeedMirror, pageSize: 1 }).subscribe({
next: (res) => {
this.commitUiUpdate(() => {
this.stats.advisorySources = res.totalCount;
this.stats.vexSources = res.totalCount;
});
},
error: () =>
this.commitUiUpdate(() => {
this.stats.advisorySources = 0;
this.stats.vexSources = 0;
}),
next: (res) => this.updateStats({ advisorySources: res.totalCount, vexSources: res.totalCount }),
error: () => this.updateStats({ advisorySources: 0, vexSources: 0 }),
});
this.integrationService.list({ type: IntegrationType.RepoSource, pageSize: 1 }).subscribe({
next: (res) => this.commitUiUpdate(() => (this.stats.secrets = res.totalCount)),
error: () => this.commitUiUpdate(() => (this.stats.secrets = 0)),
next: (res) => this.updateStats({ secrets: res.totalCount }),
error: () => this.updateStats({ secrets: 0 }),
});
}
private commitUiUpdate(update: () => void): void {
this.zone.run(() => {
update();
this.cdr.detectChanges();
});
private updateStats(update: Partial<IntegrationHubStats>): void {
this.stats.update((current) => ({ ...current, ...update }));
}
addIntegration(): void {

View File

@@ -131,7 +131,7 @@ interface PlatformListResponse<T> {
@for (blocker of topBlockers(); track blocker.findingId) {
<li>
<a [routerLink]="['/security/triage', blocker.findingId]" queryParamsHandling="merge">{{ blocker.cveId || blocker.findingId }}</a>
<span>{{ blocker.releaseName }} <EFBFBD> {{ blocker.region || 'global' }}/{{ blocker.environment }}</span>
<span>{{ blocker.releaseName }} - {{ blocker.region || 'global' }}/{{ blocker.environment }}</span>
</li>
} @empty {
<li class="empty">No blockers in the selected scope.</li>
@@ -162,14 +162,14 @@ interface PlatformListResponse<T> {
<a routerLink="/ops/integrations/advisory-vex-sources" queryParamsHandling="merge">Configure sources</a>
</div>
<p class="meta">
Conflicts: <strong>{{ conflictCount() }}</strong> <EFBFBD>
Conflicts: <strong>{{ conflictCount() }}</strong> -
Unverified statements: <strong>{{ unresolvedVexCount() }}</strong>
</p>
<ul>
@for (provider of providerHealthRows(); track provider.sourceId) {
<li>
<span>{{ provider.sourceName }}</span>
<span>{{ provider.status }} <EFBFBD> {{ provider.freshness }}</span>
<span>{{ provider.status }} - {{ provider.freshness }}</span>
</li>
} @empty {
<li class="empty">No provider health rows for current scope.</li>
@@ -183,7 +183,7 @@ interface PlatformListResponse<T> {
<a routerLink="/security/sbom/coverage">Coverage & Unknowns</a>
</div>
<p class="meta">
Reachability unknowns: <strong>{{ unknownReachabilityCount() }}</strong> <EFBFBD>
Reachability unknowns: <strong>{{ unknownReachabilityCount() }}</strong> -
Stale SBOM rows: <strong>{{ sbomStaleCount() }}</strong>
</p>
<ul>

View File

@@ -0,0 +1,71 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ExportCenterComponent } from '../evidence-export/export-center.component';
import { SecurityRiskOverviewComponent } from '../security-risk/security-risk-overview.component';
import { SecurityDispositionPageComponent } from './security-disposition-page.component';
import { SecurityReportsPageComponent } from './security-reports-page.component';
@Component({
selector: 'app-security-risk-overview',
standalone: true,
template: `<div data-testid="risk-report-surface">Risk posture report surface</div>`,
})
class StubSecurityRiskOverviewComponent {}
@Component({
selector: 'app-security-disposition-page',
standalone: true,
template: `<div data-testid="vex-report-surface">VEX ledger report surface</div>`,
})
class StubSecurityDispositionPageComponent {}
@Component({
selector: 'app-export-center',
standalone: true,
template: `<div data-testid="evidence-export-surface">Evidence export report surface</div>`,
})
class StubExportCenterComponent {}
describe('SecurityReportsPageComponent', () => {
let fixture: ComponentFixture<SecurityReportsPageComponent>;
beforeEach(async () => {
TestBed.overrideComponent(SecurityReportsPageComponent, {
remove: {
imports: [SecurityRiskOverviewComponent, SecurityDispositionPageComponent, ExportCenterComponent],
},
add: {
imports: [
StubSecurityRiskOverviewComponent,
StubSecurityDispositionPageComponent,
StubExportCenterComponent,
],
},
});
await TestBed.configureTestingModule({
imports: [SecurityReportsPageComponent],
}).compileComponents();
fixture = TestBed.createComponent(SecurityReportsPageComponent);
fixture.detectChanges();
});
it('defaults to the risk report workspace instead of the triage workspace', () => {
expect(fixture.nativeElement.textContent).toContain('Risk posture report surface');
expect(fixture.nativeElement.textContent).not.toContain('Artifact workspace');
});
it('switches between the embedded report workspaces', () => {
const buttons = fixture.nativeElement.querySelectorAll('button[role="tab"]') as NodeListOf<HTMLButtonElement>;
buttons[1].click();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('VEX ledger report surface');
buttons[2].click();
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Evidence export report surface');
});
});

View File

@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { ExportCenterComponent } from '../evidence-export/export-center.component';
import { TriageWorkspaceComponent } from '../triage/triage-workspace.component';
import { SecurityRiskOverviewComponent } from '../security-risk/security-risk-overview.component';
import { SecurityDispositionPageComponent } from './security-disposition-page.component';
type ReportTab = 'risk' | 'vex' | 'evidence';
@@ -9,7 +9,7 @@ type ReportTab = 'risk' | 'vex' | 'evidence';
@Component({
selector: 'app-security-reports-page',
standalone: true,
imports: [TriageWorkspaceComponent, SecurityDispositionPageComponent, ExportCenterComponent],
imports: [SecurityRiskOverviewComponent, SecurityDispositionPageComponent, ExportCenterComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="security-reports">
@@ -46,7 +46,7 @@ type ReportTab = 'risk' | 'vex' | 'evidence';
@switch (activeTab()) {
@case ('risk') {
<section class="report-panel">
<app-triage-workspace></app-triage-workspace>
<app-security-risk-overview></app-security-risk-overview>
</section>
}
@case ('vex') {

View File

@@ -0,0 +1,33 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { SystemSettingsPageComponent } from './system-settings-page.component';
describe('SystemSettingsPageComponent', () => {
let fixture: ComponentFixture<SystemSettingsPageComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SystemSettingsPageComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(SystemSettingsPageComponent);
fixture.detectChanges();
});
it('renders system settings as a truthful handoff instead of a false health verdict', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('System Settings');
expect(text).toContain('handoff, not a health verdict');
expect(text).not.toContain('All systems operational');
});
it('links operators to the live health and diagnostics surfaces', () => {
const hrefs = Array.from(fixture.nativeElement.querySelectorAll('a') as NodeListOf<HTMLAnchorElement>)
.map((link) => link.getAttribute('href'));
expect(hrefs).toContain('/ops/operations/system-health');
expect(hrefs).toContain('/ops/operations/doctor');
});
});

View File

@@ -13,16 +13,15 @@ import { RouterLink } from '@angular/router';
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="system-settings">
<h1 class="page-title">System</h1>
<p class="page-subtitle">System health, diagnostics, and administrative tools (Admin only)</p>
<h1 class="page-title">System Settings</h1>
<p class="page-subtitle">Use the live health and diagnostics workspaces below to validate readiness. This setup route is a handoff, not a health verdict.</p>
<div class="settings-grid">
<section class="settings-section">
<h2>Health Check</h2>
<p>View system health and component status.</p>
<div class="health-status">
<span class="health-indicator health-indicator--ok"></span>
<span>All systems operational</span>
<h2>Live Health</h2>
<p>Open the live health surface to inspect service status, incidents, and the latest platform checks for the current scope.</p>
<div class="truth-note">
This setup page does not assert that the platform is healthy on its own.
</div>
<a class="btn btn--secondary" routerLink="/ops/operations/system-health">View Details</a>
</section>
@@ -64,18 +63,15 @@ import { RouterLink } from '@angular/router';
}
.settings-section h2 { margin: 0 0 0.5rem; font-size: 1rem; font-weight: var(--font-weight-semibold); }
.settings-section p { margin: 0 0 1rem; font-size: 0.875rem; color: var(--color-text-secondary); }
.health-status {
display: flex;
align-items: center;
gap: 0.5rem;
.truth-note {
margin-bottom: 1rem;
padding: 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.health-indicator {
width: 10px;
height: 10px;
border-radius: var(--radius-full);
}
.health-indicator--ok { background: var(--color-status-success); }
.btn {
display: inline-flex;
align-items: center;

View File

@@ -0,0 +1,90 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { UnknownsClient } from '../../core/api/unknowns.client';
import type { UnknownStats } from '../../core/api/unknowns.models';
import { UnknownsDashboardComponent } from './unknowns-dashboard.component';
describe('UnknownsDashboardComponent', () => {
let fixture: ComponentFixture<UnknownsDashboardComponent>;
let unknownsClient: jasmine.SpyObj<UnknownsClient>;
const statsFixture: UnknownStats = {
total: 2,
byType: {
binary: 1,
symbol: 1,
package: 0,
file: 0,
license: 0,
},
byStatus: {
open: 2,
pending: 0,
resolved: 0,
unresolvable: 0,
},
resolutionRate: 0,
avgConfidence: 74,
lastUpdated: '2026-03-15T12:00:00Z',
};
beforeEach(async () => {
unknownsClient = jasmine.createSpyObj<UnknownsClient>('UnknownsClient', ['list', 'getStats']);
unknownsClient.list.and.returnValue(
of({
items: [
{
id: 'unknown-1',
type: 'binary',
name: 'openssl',
path: '/usr/lib/libssl.so',
artifactDigest: 'sha256:artifact-a',
artifactRef: 'registry.example/app@sha256:artifact-a',
sha256: 'sha256:file-a',
status: 'open',
confidence: 92,
createdAt: '2026-03-15T10:00:00Z',
updatedAt: '2026-03-15T11:00:00Z',
},
],
total: 1,
}),
);
unknownsClient.getStats.and.returnValue(of(statsFixture));
await TestBed.configureTestingModule({
imports: [UnknownsDashboardComponent],
providers: [
provideRouter([]),
{ provide: UnknownsClient, useValue: unknownsClient },
],
}).compileComponents();
fixture = TestBed.createComponent(UnknownsDashboardComponent);
});
it('loads unknown rows and summary stats through one truthful refresh flow', () => {
fixture.detectChanges();
expect(unknownsClient.list).toHaveBeenCalledWith({});
expect(unknownsClient.getStats).toHaveBeenCalledTimes(1);
expect(fixture.nativeElement.textContent).toContain('Unknowns');
expect(fixture.nativeElement.textContent).toContain('openssl');
expect(fixture.nativeElement.textContent).toContain('2');
});
it('surfaces an error state instead of an empty success-looking table when the APIs fail', () => {
unknownsClient.list.and.returnValue(throwError(() => new Error('scanner backend 500')));
unknownsClient.getStats.and.returnValue(throwError(() => new Error('scanner backend 500')));
fixture = TestBed.createComponent(UnknownsDashboardComponent);
fixture.detectChanges();
const error = fixture.nativeElement.querySelector('.unknowns-dashboard__error') as HTMLElement | null;
expect(error?.textContent).toContain('Unknowns data is unavailable');
expect(error?.textContent).toContain('scanner backend 500');
expect(fixture.nativeElement.textContent).not.toContain('No unknowns matched the current filters.');
});
});

View File

@@ -1,5 +1,6 @@
// Sprint: SPRINT_20251229_033_FE - Unknowns Tracking UI
import { Component, inject, signal, OnInit } from '@angular/core';
import { forkJoin } from 'rxjs';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
@@ -21,13 +22,31 @@ import {
<header class="mb-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Unknowns Tracking</h1>
<p class="text-gray-600 mt-1">Identify and resolve unknown components</p>
<h1 class="text-2xl font-bold text-gray-900">Unknowns</h1>
<p class="text-gray-600 mt-1">Investigate components the scanner could not identify yet and resolve them with evidence-backed matches.</p>
</div>
<button (click)="refresh()" class="px-4 py-2 bg-blue-600 text-white rounded-md">Refresh</button>
<button (click)="refresh()" class="px-4 py-2 bg-blue-600 text-white rounded-md" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
</div>
</header>
@if (error()) {
<section class="unknowns-dashboard__error" role="alert">
<div>
<h2>Unknowns data is unavailable</h2>
<p>{{ error() }}</p>
</div>
<button type="button" (click)="refresh()">Retry</button>
</section>
}
@if (loading() && !stats()) {
<section class="unknowns-dashboard__state" aria-live="polite">
Loading unknowns and summary data...
</section>
}
@if (stats()) {
<section class="grid grid-cols-5 gap-4 mb-6">
<div class="bg-white rounded-lg border p-4">
@@ -69,49 +88,132 @@ import {
</div>
</section>
<section class="bg-white rounded-lg border">
<table class="w-full">
<thead class="bg-gray-50 border-b">
<tr>
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Type</th>
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Component</th>
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Artifact</th>
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Confidence</th>
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Status</th>
<th class="text-right px-4 py-3 text-sm font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody class="divide-y">
@for (unknown of unknowns(); track unknown.id) {
<tr class="hover:bg-gray-50">
<td class="px-4 py-3"><span class="px-2 py-1 text-xs bg-gray-100 rounded">{{ UNKNOWN_TYPE_LABELS[unknown.type] }}</span></td>
<td class="px-4 py-3"><p class="font-medium">{{ unknown.name }}</p><p class="text-xs text-gray-500">{{ unknown.path }}</p></td>
<td class="px-4 py-3 text-sm">{{ unknown.artifactRef }}</td>
<td class="px-4 py-3"><span [class]="getConfidenceColor(unknown.confidence ?? 0)">{{ unknown.confidence ?? '-' }}%</span></td>
<td class="px-4 py-3"><span class="px-2 py-1 text-xs rounded" [class]="UNKNOWN_STATUS_COLORS[unknown.status]">{{ unknown.status }}</span></td>
<td class="px-4 py-3 text-right"><a [routerLink]="[unknown.id]" class="text-blue-600 hover:underline">Identify</a></td>
</tr>
} @empty {
<tr><td colspan="6" class="px-4 py-8 text-center text-gray-500">No unknowns found</td></tr>
}
</tbody>
</table>
</section>
@if (!error()) {
<section class="bg-white rounded-lg border">
@if (loading() && unknowns().length === 0) {
<div class="unknowns-dashboard__state">Loading unknowns...</div>
} @else {
<table class="w-full">
<thead class="bg-gray-50 border-b">
<tr>
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Type</th>
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Component</th>
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Artifact</th>
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Confidence</th>
<th class="text-left px-4 py-3 text-sm font-medium text-gray-600">Status</th>
<th class="text-right px-4 py-3 text-sm font-medium text-gray-600">Actions</th>
</tr>
</thead>
<tbody class="divide-y">
@for (unknown of unknowns(); track unknown.id) {
<tr class="hover:bg-gray-50">
<td class="px-4 py-3"><span class="px-2 py-1 text-xs bg-gray-100 rounded">{{ UNKNOWN_TYPE_LABELS[unknown.type] }}</span></td>
<td class="px-4 py-3"><p class="font-medium">{{ unknown.name }}</p><p class="text-xs text-gray-500">{{ unknown.path }}</p></td>
<td class="px-4 py-3 text-sm">{{ unknown.artifactRef }}</td>
<td class="px-4 py-3"><span [class]="getConfidenceColor(unknown.confidence ?? 0)">{{ unknown.confidence ?? '-' }}%</span></td>
<td class="px-4 py-3"><span class="px-2 py-1 text-xs rounded" [class]="UNKNOWN_STATUS_COLORS[unknown.status]">{{ unknown.status }}</span></td>
<td class="px-4 py-3 text-right"><a [routerLink]="[unknown.id]" class="text-blue-600 hover:underline">Identify</a></td>
</tr>
} @empty {
<tr>
<td colspan="6" class="px-4 py-8 text-center text-gray-500">
No unknowns matched the current filters.
</td>
</tr>
}
</tbody>
</table>
}
</section>
}
</div>
`,
styles: [`.unknowns-dashboard { min-height: 100vh; background: var(--color-surface-primary); }`]
styles: [`
.unknowns-dashboard { min-height: 100vh; background: var(--color-surface-primary); }
.unknowns-dashboard__error {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 1rem 1.25rem;
border: 1px solid rgba(239, 68, 68, 0.35);
border-radius: var(--radius-lg);
background: rgba(239, 68, 68, 0.08);
color: var(--color-status-error-text, #991b1b);
}
.unknowns-dashboard__error h2,
.unknowns-dashboard__error p {
margin: 0;
}
.unknowns-dashboard__error p {
font-size: 0.875rem;
}
.unknowns-dashboard__error button {
border: 1px solid currentColor;
border-radius: var(--radius-md);
background: transparent;
color: inherit;
cursor: pointer;
padding: 0.5rem 0.9rem;
white-space: nowrap;
}
.unknowns-dashboard__state {
margin-bottom: 1rem;
padding: 1rem 1.25rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
}
`]
})
export class UnknownsDashboardComponent implements OnInit {
private readonly client = inject(UnknownsClient);
unknowns = signal<Unknown[]>([]);
stats = signal<UnknownStats | null>(null);
error = signal<string | null>(null);
loading = signal(false);
filter: UnknownFilter = {};
readonly UNKNOWN_TYPE_LABELS = UNKNOWN_TYPE_LABELS;
readonly UNKNOWN_STATUS_COLORS = UNKNOWN_STATUS_COLORS;
readonly getConfidenceColor = getConfidenceColor;
ngOnInit(): void { this.loadUnknowns(); this.loadStats(); }
loadUnknowns(): void { this.client.list(this.filter).subscribe({ next: (r) => this.unknowns.set(r.items) }); }
loadStats(): void { this.client.getStats().subscribe({ next: (s) => this.stats.set(s) }); }
refresh(): void { this.loadUnknowns(); this.loadStats(); }
ngOnInit(): void {
this.refresh();
}
loadUnknowns(): void {
this.refresh();
}
loadStats(): void {
this.refresh();
}
refresh(): void {
this.loading.set(true);
this.error.set(null);
forkJoin({
list: this.client.list(this.filter),
stats: this.client.getStats(),
}).subscribe({
next: ({ list, stats }) => {
this.unknowns.set(list.items);
this.stats.set(stats);
this.loading.set(false);
},
error: (err: unknown) => {
this.unknowns.set([]);
this.stats.set(null);
this.error.set(
err instanceof Error
? `The scanner unknowns APIs returned an error. ${err.message}`
: 'The scanner unknowns APIs returned an error. Retry the request or verify the scanner service.',
);
this.loading.set(false);
},
});
}
}

View File

@@ -22,6 +22,8 @@
"src/app/features/administration/administration-overview.component.spec.ts",
"src/app/features/audit-log/audit-log-dashboard.component.spec.ts",
"src/app/features/evidence-audit/evidence-audit-overview.component.spec.ts",
"src/app/features/evidence-export/evidence-export.routes.spec.ts",
"src/app/features/evidence-pack/evidence-pack-list.component.spec.ts",
"src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts",
"src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts",
"src/app/features/deploy-diff/services/deploy-diff.service.spec.ts",
@@ -41,8 +43,11 @@
"src/app/features/notify/notify-panel.component.spec.ts",
"src/app/features/reachability/reachability-center.component.spec.ts",
"src/app/features/releases/release-ops-overview-page.component.spec.ts",
"src/app/features/integration-hub/integration-hub.component.spec.ts",
"src/app/features/security/security-reports-page.component.spec.ts",
"src/app/features/registry-admin/components/plan-audit.component.spec.ts",
"src/app/features/registry-admin/registry-admin.component.spec.ts",
"src/app/features/settings/system/system-settings-page.component.spec.ts",
"src/app/features/trust-admin/certificate-inventory.component.spec.ts",
"src/app/features/trust-admin/issuer-trust-list.component.spec.ts",
"src/app/features/trust-admin/trust-admin.component.spec.ts",
@@ -54,6 +59,7 @@
"src/app/features/vex-hub/vex-hub-source-contract.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/unknowns-tracking/unknowns-dashboard.component.spec.ts",
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts",
"src/app/layout/app-sidebar/app-sidebar.component.spec.ts",
"src/app/routes/evidence.routes.spec.ts",