From e884b4bddd647a274ddd10632fb597f8d315c45a Mon Sep 17 00:00:00 2001 From: master <> Date: Sun, 15 Mar 2026 14:21:18 +0200 Subject: [PATCH] Repair first-time user reporting truthfulness journeys --- ...er_operator_journey_grouped_remediation.md | 3 +- ...time-user-reporting-truthfulness-check.mjs | 291 ++++++++++++++++++ .../scripts/live-full-core-audit.mjs | 5 + .../evidence-export.routes.spec.ts | 11 + .../evidence-export/evidence-export.routes.ts | 4 +- .../evidence-pack-list.component.spec.ts | 42 +++ .../evidence-pack-list.component.ts | 2 +- .../integration-hub.component.spec.ts | 188 ++++------- .../integration-hub.component.ts | 226 +++++++++++--- .../security-risk-overview.component.ts | 8 +- .../security-reports-page.component.spec.ts | 71 +++++ .../security-reports-page.component.ts | 6 +- .../system-settings-page.component.spec.ts | 33 ++ .../system/system-settings-page.component.ts | 30 +- .../unknowns-dashboard.component.spec.ts | 90 ++++++ .../unknowns-dashboard.component.ts | 174 ++++++++--- .../StellaOps.Web/tsconfig.spec.features.json | 6 + 17 files changed, 948 insertions(+), 242 deletions(-) create mode 100644 src/Web/StellaOps.Web/scripts/live-first-time-user-reporting-truthfulness-check.mjs create mode 100644 src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-export.routes.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/settings/system/system-settings-page.component.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/unknowns-tracking/unknowns-dashboard.component.spec.ts diff --git a/docs/implplan/SPRINT_20260315_006_Web_first_time_user_operator_journey_grouped_remediation.md b/docs/implplan/SPRINT_20260315_006_Web_first_time_user_operator_journey_grouped_remediation.md index 14dc14901..39fe2884f 100644 --- a/docs/implplan/SPRINT_20260315_006_Web_first_time_user_operator_journey_grouped_remediation.md +++ b/docs/implplan/SPRINT_20260315_006_Web_first_time_user_operator_journey_grouped_remediation.md @@ -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 operator’s first-time setup and release-confidence journey is now the primary quality bar; broad green route sweeps are supporting evidence only. diff --git a/src/Web/StellaOps.Web/scripts/live-first-time-user-reporting-truthfulness-check.mjs b/src/Web/StellaOps.Web/scripts/live-first-time-user-reporting-truthfulness-check.mjs new file mode 100644 index 000000000..e07e4241b --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-first-time-user-reporting-truthfulness-check.mjs @@ -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®ions=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('�')) { + 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(); diff --git a/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs b/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs index 47f97d65e..1f76560e6 100644 --- a/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs +++ b/src/Web/StellaOps.Web/scripts/live-full-core-audit.mjs @@ -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', diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-export.routes.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-export.routes.spec.ts new file mode 100644 index 000000000..1036ca0bc --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-export.routes.spec.ts @@ -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'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-export.routes.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-export.routes.ts index f4d14e770..194a3caed 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-export.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-export.routes.ts @@ -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 diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.spec.ts new file mode 100644 index 000000000..bdc0576ba --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.spec.ts @@ -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; + let evidenceApi: jasmine.SpyObj; + + beforeEach(async () => { + evidenceApi = jasmine.createSpyObj('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(); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts index 5a8d846b1..cb0f68dbf 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts @@ -34,7 +34,7 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/
-

Decision Capsules

+

Decision Capsules

Browse signed evidence packs that explain release, policy, and operator decisions.

@if (runId) { diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.spec.ts index 459ec37f8..d1d9b1d1d 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.spec.ts @@ -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; let component: IntegrationHubComponent; let mockIntegrationService: jasmine.SpyObj; + 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, + ).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', }); }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts index efd847b71..b13b7abf9 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts @@ -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: `
+
+
+

Integrations

+

+ Connect the external systems Stella Ops depends on, then verify them from the same setup surface. +

+
+
+ {{ configuredConnectorCount() }} + configured connectors +
+
+ +
+
+

Suggested Setup Order

+

Start with the connectors that unblock releases and evidence, then add operator conveniences.

+
+
    +
  1. + Registries + Connect the container sources that release versions, promotions, and policy checks depend on. +
  2. +
  3. + Source Control + Wire repository and commit metadata before relying on release evidence and drift context. +
  4. +
  5. + CI/CD + Capture pipeline runs and deployment triggers for release confidence. +
  6. +
  7. + Advisory & VEX Sources + Keep security posture, exceptions, and freshness checks truthful. +
  8. +
  9. + Secrets + Finish by wiring vaults and credentials used by downstream integrations. +
  10. +
+
+ @@ -48,10 +100,17 @@ import { IntegrationType } from './integration.models';

Recent Activity

-

Activity stream is coming soon

-

- Connector timeline events will appear here once integration telemetry wiring is complete. -

+ @if (configuredConnectorCount() === 0) { +

No integration activity recorded yet

+

+ Add your first connector, run Test Connection, or open the full activity timeline after the next sync. +

+ } @else { +

Use the activity timeline for connector event history

+

+ The hub summary shows configured connectors. Open View Activity for test, sync, and health events per integration. +

+ }
`, @@ -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({ 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): void { + this.stats.update((current) => ({ ...current, ...update })); } addIntegration(): void { diff --git a/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts index 419ef4d43..de5cb6f01 100644 --- a/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts @@ -131,7 +131,7 @@ interface PlatformListResponse { @for (blocker of topBlockers(); track blocker.findingId) {
  • {{ blocker.cveId || blocker.findingId }} - {{ blocker.releaseName }} � {{ blocker.region || 'global' }}/{{ blocker.environment }} + {{ blocker.releaseName }} - {{ blocker.region || 'global' }}/{{ blocker.environment }}
  • } @empty {
  • No blockers in the selected scope.
  • @@ -162,14 +162,14 @@ interface PlatformListResponse { Configure sources

    - Conflicts: {{ conflictCount() }} � + Conflicts: {{ conflictCount() }} - Unverified statements: {{ unresolvedVexCount() }}

      @for (provider of providerHealthRows(); track provider.sourceId) {
    • {{ provider.sourceName }} - {{ provider.status }} � {{ provider.freshness }} + {{ provider.status }} - {{ provider.freshness }}
    • } @empty {
    • No provider health rows for current scope.
    • @@ -183,7 +183,7 @@ interface PlatformListResponse { Coverage & Unknowns

      - Reachability unknowns: {{ unknownReachabilityCount() }} � + Reachability unknowns: {{ unknownReachabilityCount() }} - Stale SBOM rows: {{ sbomStaleCount() }}

        diff --git a/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.spec.ts new file mode 100644 index 000000000..198f46a5e --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.spec.ts @@ -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: `
        Risk posture report surface
        `, +}) +class StubSecurityRiskOverviewComponent {} + +@Component({ + selector: 'app-security-disposition-page', + standalone: true, + template: `
        VEX ledger report surface
        `, +}) +class StubSecurityDispositionPageComponent {} + +@Component({ + selector: 'app-export-center', + standalone: true, + template: `
        Evidence export report surface
        `, +}) +class StubExportCenterComponent {} + +describe('SecurityReportsPageComponent', () => { + let fixture: ComponentFixture; + + 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; + + 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'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts index e0cd98f4c..a36edc43f 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts @@ -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: `
        @@ -46,7 +46,7 @@ type ReportTab = 'risk' | 'vex' | 'evidence'; @switch (activeTab()) { @case ('risk') {
        - +
        } @case ('vex') { diff --git a/src/Web/StellaOps.Web/src/app/features/settings/system/system-settings-page.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/settings/system/system-settings-page.component.spec.ts new file mode 100644 index 000000000..f592df986 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/settings/system/system-settings-page.component.spec.ts @@ -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; + + 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) + .map((link) => link.getAttribute('href')); + + expect(hrefs).toContain('/ops/operations/system-health'); + expect(hrefs).toContain('/ops/operations/doctor'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/settings/system/system-settings-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/system/system-settings-page.component.ts index 29f5615f0..5e77f0c26 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/system/system-settings-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/system/system-settings-page.component.ts @@ -13,16 +13,15 @@ import { RouterLink } from '@angular/router'; changeDetection: ChangeDetectionStrategy.OnPush, template: `
        -

        System

        -

        System health, diagnostics, and administrative tools (Admin only)

        +

        System Settings

        +

        Use the live health and diagnostics workspaces below to validate readiness. This setup route is a handoff, not a health verdict.

        -

        Health Check

        -

        View system health and component status.

        -
        - - All systems operational +

        Live Health

        +

        Open the live health surface to inspect service status, incidents, and the latest platform checks for the current scope.

        +
        + This setup page does not assert that the platform is healthy on its own.
        View Details
        @@ -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; diff --git a/src/Web/StellaOps.Web/src/app/features/unknowns-tracking/unknowns-dashboard.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/unknowns-tracking/unknowns-dashboard.component.spec.ts new file mode 100644 index 000000000..4d295eb19 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/unknowns-tracking/unknowns-dashboard.component.spec.ts @@ -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; + let unknownsClient: jasmine.SpyObj; + + 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', ['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.'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/features/unknowns-tracking/unknowns-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/unknowns-tracking/unknowns-dashboard.component.ts index 6f6e699b0..30524959c 100644 --- a/src/Web/StellaOps.Web/src/app/features/unknowns-tracking/unknowns-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/unknowns-tracking/unknowns-dashboard.component.ts @@ -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 {
        -

        Unknowns Tracking

        -

        Identify and resolve unknown components

        +

        Unknowns

        +

        Investigate components the scanner could not identify yet and resolve them with evidence-backed matches.

        - +
        + @if (error()) { + + } + + @if (loading() && !stats()) { +
        + Loading unknowns and summary data... +
        + } + @if (stats()) {
        @@ -69,49 +88,132 @@ import {
        -
        - - - - - - - - - - - - - @for (unknown of unknowns(); track unknown.id) { - - - - - - - - - } @empty { - - } - -
        TypeComponentArtifactConfidenceStatusActions
        {{ UNKNOWN_TYPE_LABELS[unknown.type] }}

        {{ unknown.name }}

        {{ unknown.path }}

        {{ unknown.artifactRef }}{{ unknown.confidence ?? '-' }}%{{ unknown.status }}Identify
        No unknowns found
        -
        + @if (!error()) { +
        + @if (loading() && unknowns().length === 0) { +
        Loading unknowns...
        + } @else { + + + + + + + + + + + + + @for (unknown of unknowns(); track unknown.id) { + + + + + + + + + } @empty { + + + + } + +
        TypeComponentArtifactConfidenceStatusActions
        {{ UNKNOWN_TYPE_LABELS[unknown.type] }}

        {{ unknown.name }}

        {{ unknown.path }}

        {{ unknown.artifactRef }}{{ unknown.confidence ?? '-' }}%{{ unknown.status }}Identify
        + No unknowns matched the current filters. +
        + } +
        + }
        `, - 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([]); stats = signal(null); + error = signal(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); + }, + }); + } } diff --git a/src/Web/StellaOps.Web/tsconfig.spec.features.json b/src/Web/StellaOps.Web/tsconfig.spec.features.json index e1bbe1037..9b37d1b49 100644 --- a/src/Web/StellaOps.Web/tsconfig.spec.features.json +++ b/src/Web/StellaOps.Web/tsconfig.spec.features.json @@ -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",