Align live titles and trust setup overview

This commit is contained in:
master
2026-03-09 11:20:19 +02:00
parent 29fec722df
commit 49d1c57597
16 changed files with 604 additions and 156 deletions

View File

@@ -54,6 +54,7 @@ Completion criteria:
| 2026-03-09 | Fixed a harness defect in the shared auth/session model: the original live sweep restored `sessionStorage` only in the login tab, so every freshly opened route page was unauthenticated and falsely redirected to `/welcome`. Moved session seeding into `createAuthenticatedContext(...)` and reused the helper from the other live scripts. | Developer |
| 2026-03-09 | Ran the authenticated 106-route sweep against the rebuilt stack. After removing redirect/copy false positives, the real live backlog is 19 failing routes: reachability; feeds-airgap; jobengine; quotas; dead-letter; aoc; signals; packs; ai-runs; notifications; status; sbom-sources; policy simulation; policy trust-weights; policy staleness; policy audit; setup/platform trust-signing; and setup notifications. | Developer |
| 2026-03-09 | Expanded the canonical live sweep inventory to include the revived release-investigation, evidence-thread, and registry-admin routes so future frontdoor passes cover those pages as first-class surfaces instead of leaving them to ad hoc follow-up scripts. | Developer |
| 2026-03-09 | After the full image rebuild and the next web-only repair pass, reran the authenticated 111-route sweep. The live backlog moved to 24 failing routes, with the earlier title regressions and feeds-airgap issue cleared while new backend/runtime failures remained concentrated in analytics, JobEngine, integrations, policy governance, notifications, and trust authorization. | Developer |
## Decisions & Risks
- Decision: keep this sprint focused on broad route-level live verification and action inventory, not on fixing specific route defects before the rebuilt stack is actually exercised.

View File

@@ -0,0 +1,79 @@
# Sprint 20260309-009 - FE Live Contract Alignment for Titles, Trust, and Feeds
## Topic & Scope
- Repair the live frontdoor defects that are caused by frontend contract drift rather than backend outages: route titles being overwritten after branding loads, the feeds-airgap page advertising a blocking incident by default, and trust-signing pages still calling retired `/api/v1/trust/*` endpoints.
- Keep this iteration focused on canonical route correctness for `/security/*`, `/ops/operations/feeds-airgap`, `/ops/platform-setup/trust-signing`, and `/setup/trust-signing` on the rebuilt `https://stella-ops.local` stack.
- Working directory: `src/Web/StellaOps.Web`.
- Allowed coordination edits: `docs/implplan/SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md`, `docs/modules/platform/architecture-overview.md`, `docs/technical/architecture/console-branding.md`, `docs/api/console/samples/console-status-sample.json`.
- Expected evidence: focused frontend unit tests, rebuilt web bundle synced into compose, and authenticated live Playwright rechecks for the repaired routes.
## Dependencies & Concurrency
- Depends on `SPRINT_20260309_002_FE_live_frontdoor_canonical_route_sweep.md` for the current authenticated failure inventory and on the completed full-stack rebuild baseline.
- Safe parallelism: stay inside `src/Web/StellaOps.Web/**`; do not edit backend services or router configuration in this sprint.
## Documentation Prerequisites
- `AGENTS.md`
- `src/Web/StellaOps.Web/AGENTS.md`
- `docs/code-of-conduct/CODE_OF_CONDUCT.md`
- `docs/qa/feature-checks/FLOW.md`
- `docs/technical/architecture/console-branding.md`
## Delivery Tracker
### FE-CONTRACT-009-001 - Stop branding from clobbering route titles
Status: DONE
Dependency: none
Owners: Developer, Test Automation
Task description:
- Remove the direct `document.title` overwrite from branding application and re-apply Angular route titles after branding changes so canonical route titles remain stable on live navigations.
- Add focused tests proving branding refreshes preserve route-derived titles instead of collapsing to the bare brand string.
Completion criteria:
- [ ] Branding updates no longer overwrite route titles after navigation.
- [ ] Focused frontend tests cover the route-title preservation path.
- [ ] Live `/security/advisories-vex`, `/security/sbom-lake`, and `/security/reachability` pass the title expectation checks in the authenticated sweep.
### FE-CONTRACT-009-002 - Align trust-signing UI with live administration endpoints
Status: DOING
Dependency: FE-CONTRACT-009-001
Owners: Developer, Test Automation
Task description:
- Replace the retired `/api/v1/trust/*` assumptions used by the trust-signing shell and default key dashboard with adapter logic over the live `/api/v1/administration/trust-signing*` endpoints.
- Preserve operator-visible capabilities on the base shell and key inventory route without relying on dead frontdoor paths.
Completion criteria:
- [ ] `TrustHttpService` no longer requests `/api/v1/trust/*` during canonical trust-signing page loads.
- [ ] Focused frontend tests prove the trust adapter maps live administration responses into the shell and key-dashboard view models.
- [ ] Live `/ops/platform-setup/trust-signing` and `/setup/trust-signing` render without 404 response errors.
### FE-CONTRACT-009-003 - Replace the static feeds-airgap blocking incident baseline
Status: DONE
Dependency: FE-CONTRACT-009-002
Owners: Developer, QA
Task description:
- Remove the hardcoded blocking incident state from the static feeds-airgap page baseline so the canonical route reflects a healthy control-plane default unless live health data says otherwise.
- Keep the airgap actions and cross-links intact while making the summary/status copy consistent with a clean demo bootstrap.
Completion criteria:
- [ ] The page no longer renders a blocking incident banner by default.
- [ ] Operator actions and tab flows still work after the content refresh.
- [ ] Live `/ops/operations/feeds-airgap` passes the canonical route sweep.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-09 | Sprint created after the full rebuild and authenticated 111-route sweep isolated three frontend-owned defect families: branding/title races, a hardcoded feeds-airgap blocking incident, and stale trust-signing API wiring against retired `/api/v1/trust/*` routes. | Developer |
| 2026-03-09 | Removed the branding title overwrite, added route-title reapplication in the root shell, rebuilt/synced the web bundle, and confirmed the live sweep now passes `/security/advisories-vex`, `/security/reachability`, and `/ops/operations/feeds-airgap`. | Developer |
| 2026-03-09 | Rebased trust-signing base routes onto an overview-first shell backed by the live administration projection and removed the old `/api/v1/trust/dashboard` 404 path. Live trust routes still fail, but now on a real `403` from `/api/v1/administration/trust-signing`, which narrows the remaining defect to authorization/policy alignment. | Developer |
## Decisions & Risks
- Decision: treat these defects as frontend contract-alignment work first because the live stack rebuild already proved the failures reproduce after a clean redeploy.
- Risk: the trust-signing shell expects richer models than the live administration endpoints currently expose, so the adapter layer must preserve deterministic behavior without inventing backend-only actions that do not exist.
- Decision: keep the feeds-airgap page static for this sprint but move it to a healthy baseline rather than fabricating a live incident in the default control-plane state.
- Decision: switch the base trust-signing route to an overview-first shell because the rebuilt platform exposes an administration projection, while the prior default key dashboard depended on richer retired endpoints that no longer exist.
- Risk: trust-signing remains blocked by a live `403` even after frontend contract alignment; the next iteration needs to inspect demo scopes and platform authorization, not just web routing.
## Next Checkpoints
- 2026-03-09: land the branding/title preservation fix with focused tests.
- 2026-03-09: land the trust-signing contract adapter and recheck the live setup routes.
- 2026-03-09: refresh the feeds-airgap baseline content and rerun the authenticated Playwright slice.

View File

@@ -56,7 +56,8 @@ If Authority is unreachable, the UI uses the static defaults.
## 6. UI Application
- Branding service fetches `/console/branding` after login.
- Applies CSS variables on `document.documentElement`.
- Updates header/logo assets and document title.
- Updates header/logo assets and provides the brand suffix used by the router title strategy.
- Route titles remain owned by Angular route metadata so a late branding refresh cannot collapse the browser title to the bare tenant brand.
- Supports theme-specific overrides using `data-theme` selectors.
## 7. Audit and Offline
@@ -68,4 +69,3 @@ If Authority is unreachable, the UI uses the static defaults.
- `docs/UI_GUIDE.md`
- `docs/modules/ui/architecture.md`
- `docs/modules/authority/architecture.md`

View File

@@ -1,6 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideRouter } from '@angular/router';
import { provideRouter, TitleStrategy } from '@angular/router';
import { of } from 'rxjs';
import { AppComponent } from './app.component';
@@ -19,6 +19,7 @@ import { DoctorTrendService } from './core/doctor/doctor-trend.service';
import { DoctorNotificationService } from './core/doctor/doctor-notification.service';
import { PlatformContextUrlSyncService } from './core/context/platform-context-url-sync.service';
import { AUTHORITY_CONSOLE_API } from './core/api/authority-console.client';
import { BrandingService } from './core/branding/branding.service';
class AuthorityAuthServiceStub {
beginLogin = jasmine.createSpy('beginLogin');
@@ -51,10 +52,15 @@ class PlatformContextUrlSyncServiceStub {
readonly initialize = jasmine.createSpy('initialize');
}
class TitleStrategyStub {
readonly updateTitle = jasmine.createSpy('updateTitle');
}
describe('AppComponent', () => {
let doctorTrend: DoctorTrendServiceStub;
let doctorNotification: DoctorNotificationServiceStub;
let contextUrlSync: PlatformContextUrlSyncServiceStub;
let titleStrategy: TitleStrategyStub;
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -124,6 +130,7 @@ describe('AppComponent', () => {
{ provide: DoctorTrendService, useClass: DoctorTrendServiceStub },
{ provide: DoctorNotificationService, useClass: DoctorNotificationServiceStub },
{ provide: PlatformContextUrlSyncService, useClass: PlatformContextUrlSyncServiceStub },
{ provide: TitleStrategy, useClass: TitleStrategyStub },
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
]
@@ -132,6 +139,7 @@ describe('AppComponent', () => {
doctorTrend = TestBed.inject(DoctorTrendService) as unknown as DoctorTrendServiceStub;
doctorNotification = TestBed.inject(DoctorNotificationService) as unknown as DoctorNotificationServiceStub;
contextUrlSync = TestBed.inject(PlatformContextUrlSyncService) as unknown as PlatformContextUrlSyncServiceStub;
titleStrategy = TestBed.inject(TitleStrategy) as unknown as TitleStrategyStub;
});
it('creates the root component', () => {
@@ -210,6 +218,23 @@ describe('AppComponent', () => {
expect(doctorTrend.stop).toHaveBeenCalledTimes(1);
expect(doctorNotification.stop).toHaveBeenCalledTimes(1);
});
it('reapplies the route title when branding changes', () => {
const fixture = TestBed.createComponent(AppComponent);
const brandingService = TestBed.inject(BrandingService);
fixture.detectChanges();
titleStrategy.updateTitle.calls.reset();
brandingService.applyBranding({
tenantId: 'tenant-1',
title: 'Tenant One Console',
themeTokens: {},
});
fixture.detectChanges();
expect(titleStrategy.updateTitle).toHaveBeenCalled();
});
});
function buildSession(): AuthSession {

View File

@@ -8,7 +8,7 @@ import {
inject,
ViewChild,
} from '@angular/core';
import { Router, RouterLink, RouterOutlet, NavigationEnd } from '@angular/router';
import { Router, RouterLink, RouterOutlet, NavigationEnd, TitleStrategy } from '@angular/router';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { filter, map, startWith, take } from 'rxjs/operators';
@@ -61,6 +61,7 @@ export class AppComponent {
];
private readonly router = inject(Router);
private readonly titleStrategy = inject(TitleStrategy);
private readonly auth = inject(AuthorityAuthService);
private readonly sessionStore = inject(AuthSessionStore);
private readonly consoleStore = inject(ConsoleSessionStore);
@@ -104,6 +105,13 @@ export class AppComponent {
// Initialize branding on app start
this.brandingService.fetchBranding().subscribe();
effect(() => {
this.brandingService.currentBranding();
this.brandingService.isLoaded();
this.currentUrl();
this.titleStrategy.updateTitle(this.router.routerState.snapshot);
});
// Attempt to silently restore the auth session if the user was
// previously logged in (session cookie may still be active at the Authority).
void this.auth.trySilentRefresh();

View File

@@ -0,0 +1,99 @@
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { TrustHttpService } from './trust.client';
describe('TrustHttpService', () => {
let service: TrustHttpService;
let httpMock: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [HttpClientTestingModule],
providers: [TrustHttpService],
});
service = TestBed.inject(TrustHttpService);
httpMock = TestBed.inject(HttpTestingController);
});
afterEach(() => {
httpMock.verify();
});
it('maps the live administration overview projection', () => {
service.getAdministrationOverview().subscribe((overview) => {
expect(overview.inventory).toEqual({
keys: 14,
issuers: 7,
certificates: 23,
});
expect(overview.signals).toEqual([
{
signalId: 'audit-log',
status: 'healthy',
message: 'Audit log ingestion is current.',
},
{
signalId: 'certificate-expiry',
status: 'warning',
message: '1 certificate expires within 10 days.',
},
]);
expect(overview.evidenceConsumerPath).toBe('/evidence-audit/proofs');
});
const req = httpMock.expectOne('/api/v1/administration/trust-signing');
expect(req.request.method).toBe('GET');
req.flush({
inventory: {
keys: 14,
issuers: 7,
certificates: 23,
},
signals: [
{
signalId: 'audit-log',
status: 'healthy',
message: 'Audit log ingestion is current.',
},
{
signalId: 'certificate-expiry',
status: 'warning',
message: '1 certificate expires within 10 days.',
},
],
legacyAliases: [],
evidenceConsumerPath: '/evidence-audit/proofs',
});
});
it('builds the trust shell summary from the administration overview', () => {
service.getDashboardSummary().subscribe((summary) => {
expect(summary.keys.total).toBe(4);
expect(summary.issuers.total).toBe(3);
expect(summary.certificates.total).toBe(2);
expect(summary.certificates.expiringSoon).toBe(1);
expect(summary.expiryAlerts).toEqual([]);
});
const req = httpMock.expectOne('/api/v1/administration/trust-signing');
expect(req.request.method).toBe('GET');
req.flush({
inventory: {
keys: 4,
issuers: 3,
certificates: 2,
},
signals: [
{
signalId: 'certificate-expiry',
status: 'warning',
message: '1 certificate expires within 10 days.',
},
],
legacyAliases: [],
evidenceConsumerPath: '/evidence/overview',
});
});
});

View File

@@ -6,7 +6,7 @@
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, of, delay } from 'rxjs';
import { Observable, of, delay, map } from 'rxjs';
import {
SigningKey,
@@ -23,6 +23,7 @@ import {
CertificateChain,
CertificateExpiryAlert,
TrustAuditEvent,
TrustAdministrationOverview,
TrustDashboardSummary,
ListKeysParams,
ListIssuersParams,
@@ -53,6 +54,7 @@ import {
export interface TrustApi {
// Dashboard
getDashboardSummary(): Observable<TrustDashboardSummary>;
getAdministrationOverview(): Observable<TrustAdministrationOverview>;
// Keys
listKeys(params?: ListKeysParams): Observable<PagedResult<SigningKey>>;
@@ -97,6 +99,25 @@ export interface TrustApi {
export const TRUST_API = new InjectionToken<TrustApi>('TRUST_API');
interface AdministrationTrustOverviewDto {
inventory?: {
keys?: number;
issuers?: number;
certificates?: number;
};
signals?: Array<{
signalId?: string;
status?: string;
message?: string;
}>;
legacyAliases?: Array<{
legacyPath?: string;
canonicalPath?: string;
mode?: string;
}>;
evidenceConsumerPath?: string | null;
}
// ============================================================================
// HTTP Implementation
// ============================================================================
@@ -105,9 +126,63 @@ export const TRUST_API = new InjectionToken<TrustApi>('TRUST_API');
export class TrustHttpService implements TrustApi {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/trust';
private readonly administrationBaseUrl = '/api/v1/administration/trust-signing';
getDashboardSummary(): Observable<TrustDashboardSummary> {
return this.http.get<TrustDashboardSummary>(`${this.baseUrl}/dashboard`);
return this.getAdministrationOverview().pipe(
map((overview) => ({
keys: {
total: overview.inventory.keys,
active: 0,
expiringSoon: 0,
expired: 0,
revoked: 0,
pendingRotation: 0,
},
issuers: {
total: overview.inventory.issuers,
fullTrust: 0,
partialTrust: 0,
minimalTrust: 0,
untrusted: 0,
blocked: 0,
averageTrustScore: 0,
},
certificates: {
total: overview.inventory.certificates,
valid: 0,
expiringSoon: overview.signals.filter((signal) => signal.signalId === 'certificate-expiry').length,
expired: 0,
revoked: 0,
invalidChains: 0,
},
recentEvents: [],
expiryAlerts: [],
}))
);
}
getAdministrationOverview(): Observable<TrustAdministrationOverview> {
return this.http.get<AdministrationTrustOverviewDto>(this.administrationBaseUrl).pipe(
map((dto) => ({
inventory: {
keys: dto.inventory?.keys ?? 0,
issuers: dto.inventory?.issuers ?? 0,
certificates: dto.inventory?.certificates ?? 0,
},
signals: (dto.signals ?? []).map((signal) => ({
signalId: signal.signalId?.trim() || 'unknown',
status: this.normalizeAdministrationSignalStatus(signal.status),
message: signal.message?.trim() || 'No details provided.',
})),
legacyAliases: (dto.legacyAliases ?? []).map((alias) => ({
legacyPath: alias.legacyPath?.trim() || '',
canonicalPath: alias.canonicalPath?.trim() || '',
mode: alias.mode?.trim() || 'redirect',
})),
evidenceConsumerPath: dto.evidenceConsumerPath?.trim() || '/evidence/overview',
}))
);
}
// Keys
@@ -285,6 +360,15 @@ export class TrustHttpService implements TrustApi {
return this.http.post<void>(`${this.baseUrl}/analytics/alerts/${alertId}/acknowledge`, {});
}
private normalizeAdministrationSignalStatus(status: string | undefined): 'healthy' | 'warning' | 'critical' | 'unknown' {
const normalized = status?.trim().toLowerCase();
if (normalized === 'healthy' || normalized === 'warning' || normalized === 'critical') {
return normalized;
}
return 'unknown';
}
private buildParams(params: Record<string, unknown>): HttpParams {
let httpParams = new HttpParams();
for (const [key, value] of Object.entries(params)) {
@@ -1338,4 +1422,41 @@ export class MockTrustApiService implements TrustApi {
acknowledgeAnalyticsAlert(alertId: string): Observable<void> {
return of(undefined).pipe(delay(100));
}
getAdministrationOverview(): Observable<TrustAdministrationOverview> {
const overview: TrustAdministrationOverview = {
inventory: {
keys: this.mockKeys.length,
issuers: this.mockIssuers.length,
certificates: this.mockCertificates.length,
},
signals: [
{
signalId: 'audit-log',
status: 'healthy',
message: 'Audit ingestion is current.',
},
{
signalId: 'certificate-expiry',
status: 'warning',
message: 'One certificate expires within 10 days.',
},
{
signalId: 'transparency-log',
status: 'healthy',
message: 'Transparency log witness is reachable.',
},
],
legacyAliases: [
{
legacyPath: '/admin/issuers',
canonicalPath: '/setup/trust-signing/issuers',
mode: 'redirect',
},
],
evidenceConsumerPath: '/evidence/overview',
};
return of(overview).pipe(delay(120));
}
}

View File

@@ -262,6 +262,29 @@ export interface TrustDashboardSummary {
readonly expiryAlerts: readonly (KeyExpiryAlert | CertificateExpiryAlert)[];
}
export interface TrustAdministrationOverview {
readonly inventory: {
readonly keys: number;
readonly issuers: number;
readonly certificates: number;
};
readonly signals: readonly TrustAdministrationSignal[];
readonly legacyAliases: readonly TrustAdministrationRouteAlias[];
readonly evidenceConsumerPath: string;
}
export interface TrustAdministrationSignal {
readonly signalId: string;
readonly status: 'healthy' | 'warning' | 'critical' | 'unknown';
readonly message: string;
}
export interface TrustAdministrationRouteAlias {
readonly legacyPath: string;
readonly canonicalPath: string;
readonly mode: string;
}
export interface KeysSummary {
readonly total: number;
readonly active: number;

View File

@@ -109,4 +109,16 @@ describe('BrandingService', () => {
expect(service.currentBranding()!.title).toBe('Test Title');
expect(service.isLoaded()).toBe(true);
});
it('does not overwrite the current route title when branding is applied', () => {
document.title = 'Reachability - Stella Ops Dashboard';
service.applyBranding({
tenantId: 'default',
title: 'Tenant Console',
themeTokens: {},
});
expect(document.title).toBe('Reachability - Stella Ops Dashboard');
});
});

View File

@@ -74,11 +74,6 @@ export class BrandingService {
applyBranding(branding: BrandingConfiguration): void {
this.currentBranding.set(branding);
// Apply document title
if (branding.title) {
document.title = branding.title;
}
// Apply favicon
if (branding.faviconUrl) {
this.updateFavicon(branding.faviconUrl);

View File

@@ -61,18 +61,18 @@ type FeedsAirgapAction = 'import' | 'export' | null;
<section class="summary">
<span>Mirrors 2</span>
<span>Synced 1</span>
<span>Stale 1</span>
<span>Errors 1</span>
<span>Synced 2</span>
<span>Stale 0</span>
<span>Errors 0</span>
<span>Storage 12.4 GB</span>
</section>
<section class="status-banner">
<strong>Feeds degraded</strong>
<span>Impact: BLOCKING</span>
<span>Mode: last-known-good snapshot (read-only)</span>
<code>corr-feed-8841</code>
<a [routerLink]="feedsFreshnessPath">Open incident</a>
<section class="status-banner status-banner--healthy">
<strong>Feeds healthy</strong>
<span>Impact: none</span>
<span>Mode: live mirrors (read-write)</span>
<code>corr-feed-ok</code>
<a [routerLink]="feedsFreshnessPath">Open freshness details</a>
</section>
<article class="panel">
@@ -92,10 +92,10 @@ type FeedsAirgapAction = 'import' | 'export' | null;
<tr>
<td>NVD Mirror</td>
<td>https://nvd.nist.gov</td>
<td>08:10 UTC</td>
<td>Stale 3h12m</td>
<td>WARN</td>
<td>BLOCKING</td>
<td>11:58 UTC</td>
<td>Fresh</td>
<td>OK</td>
<td>INFO</td>
</tr>
<tr>
<td>OSV Mirror</td>
@@ -216,10 +216,7 @@ type FeedsAirgapAction = 'import' | 'export' | null;
}
.status-banner {
border: 1px solid var(--color-status-warning-text);
border-radius: var(--radius-md);
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
padding: 0.45rem 0.55rem;
display: flex;
gap: 0.35rem;
@@ -228,6 +225,12 @@ type FeedsAirgapAction = 'import' | 'export' | null;
font-size: 0.73rem;
}
.status-banner--healthy {
border: 1px solid rgba(16, 185, 129, 0.35);
background: rgba(16, 185, 129, 0.12);
color: var(--color-status-success-border);
}
.status-banner code {
font-size: 0.68rem;
border: 1px solid var(--color-border-primary);

View File

@@ -3,7 +3,7 @@ import { provideRouter, Router } from '@angular/router';
import { of } from 'rxjs';
import { TRUST_API, type TrustApi } from '../../core/api/trust.client';
import type { TrustDashboardSummary } from '../../core/api/trust.models';
import type { TrustAdministrationOverview } from '../../core/api/trust.models';
import { TrustAdminComponent } from './trust-admin.component';
describe('TrustAdminComponent', () => {
@@ -12,51 +12,33 @@ describe('TrustAdminComponent', () => {
let router: Router;
let trustApi: jasmine.SpyObj<TrustApi>;
const dashboardSummaryFixture: TrustDashboardSummary = {
keys: {
total: 12,
active: 9,
expiringSoon: 2,
expired: 1,
revoked: 0,
pendingRotation: 1,
const overviewFixture: TrustAdministrationOverview = {
inventory: {
keys: 12,
issuers: 8,
certificates: 5,
},
issuers: {
total: 8,
fullTrust: 3,
partialTrust: 3,
minimalTrust: 1,
untrusted: 1,
blocked: 0,
averageTrustScore: 86.4,
},
certificates: {
total: 5,
valid: 4,
expiringSoon: 1,
expired: 0,
revoked: 0,
invalidChains: 0,
},
recentEvents: [],
expiryAlerts: [
signals: [
{
keyId: 'key-001',
keyName: 'Attestation Key',
expiresAt: '2026-03-01T00:00:00Z',
daysUntilExpiry: 19,
severity: 'warning',
purpose: 'attestation',
suggestedAction: 'Rotate key',
signalId: 'certificate-expiry',
status: 'warning',
message: 'One certificate expires within 10 days.',
},
{
signalId: 'transparency-log',
status: 'healthy',
message: 'Transparency log witness is reachable.',
},
],
legacyAliases: [],
evidenceConsumerPath: '/evidence/overview',
};
beforeEach(async () => {
trustApi = jasmine.createSpyObj<TrustApi>('TrustApi', [
'getDashboardSummary',
'getAdministrationOverview',
]);
trustApi.getDashboardSummary.and.returnValue(of(dashboardSummaryFixture));
trustApi.getAdministrationOverview.and.returnValue(of(overviewFixture));
await TestBed.configureTestingModule({
imports: [TrustAdminComponent],
@@ -71,19 +53,19 @@ describe('TrustAdminComponent', () => {
component = fixture.componentInstance;
});
it('loads the trust dashboard summary on init', () => {
it('loads the trust administration overview on init', () => {
fixture.detectChanges();
expect(trustApi.getDashboardSummary).toHaveBeenCalledTimes(1);
expect(component.summary()).toEqual(dashboardSummaryFixture);
expect(trustApi.getAdministrationOverview).toHaveBeenCalledTimes(1);
expect(component.overview()).toEqual(overviewFixture);
expect(component.alertCount()).toBe(1);
});
it('renders the watchlist tab in the trust shell', () => {
it('renders the overview tab in the trust shell', () => {
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Watchlist');
expect(text).toContain('Overview');
expect(text).toContain('Trust Management');
});
@@ -97,4 +79,15 @@ describe('TrustAdminComponent', () => {
expect(component.activeTab()).toBe('watchlist');
});
it('uses the overview tab on the base trust-signing route', () => {
Object.defineProperty(router, 'url', {
configurable: true,
get: () => '/setup/trust-signing',
});
fixture.detectChanges();
expect(component.activeTab()).toBe('overview');
});
});

View File

@@ -11,10 +11,11 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter } from 'rxjs';
import { TRUST_API, TrustApi } from '../../core/api/trust.client';
import { TrustDashboardSummary } from '../../core/api/trust.models';
import { TrustAdministrationOverview } from '../../core/api/trust.models';
import { GlossaryTooltipDirective } from '../../shared/directives/glossary-tooltip.directive';
export type TrustAdminTab =
| 'overview'
| 'keys'
| 'issuers'
| 'certificates'
@@ -24,6 +25,7 @@ export type TrustAdminTab =
| 'incidents'
| 'analytics';
const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
'overview',
'keys',
'issuers',
'certificates',
@@ -65,16 +67,15 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
<div class="trust-admin__loading">Loading dashboard summary...</div>
} @else if (error()) {
<div class="trust-admin__error">{{ error() }}</div>
} @else if (summary()) {
} @else if (overview()) {
<div class="trust-admin__summary">
<div class="summary-card">
<div class="summary-card__icon summary-card__icon--keys">K</div>
<div class="summary-card__content">
<span class="summary-card__value">{{ summary()!.keys.total }}</span>
<span class="summary-card__value">{{ overview()!.inventory.keys }}</span>
<span class="summary-card__label">Signing Keys</span>
<span class="summary-card__detail">
{{ summary()!.keys.active }} active,
{{ summary()!.keys.expiringSoon }} expiring soon
Administration inventory projection
</span>
</div>
</div>
@@ -82,10 +83,10 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
<div class="summary-card">
<div class="summary-card__icon summary-card__icon--issuers">I</div>
<div class="summary-card__content">
<span class="summary-card__value">{{ summary()!.issuers.total }}</span>
<span class="summary-card__value">{{ overview()!.inventory.issuers }}</span>
<span class="summary-card__label">Trusted Issuers</span>
<span class="summary-card__detail">
Avg score: {{ summary()!.issuers.averageTrustScore | number:'1.1-1' }}
Routed from live administration projection
</span>
</div>
</div>
@@ -93,11 +94,10 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
<div class="summary-card">
<div class="summary-card__icon summary-card__icon--certs">C</div>
<div class="summary-card__content">
<span class="summary-card__value">{{ summary()!.certificates.total }}</span>
<span class="summary-card__value">{{ overview()!.inventory.certificates }}</span>
<span class="summary-card__label">Certificates</span>
<span class="summary-card__detail">
{{ summary()!.certificates.valid }} valid,
{{ summary()!.certificates.expiringSoon }} expiring
Evidence and issuer trust consumers stay linked
</span>
</div>
</div>
@@ -106,7 +106,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
<div class="summary-card__icon summary-card__icon--alerts">!</div>
<div class="summary-card__content">
<span class="summary-card__value">{{ alertCount() }}</span>
<span class="summary-card__label">Expiry Alerts</span>
<span class="summary-card__label">Attention Signals</span>
<span class="summary-card__detail">
{{ criticalAlertCount() }} critical
</span>
@@ -117,6 +117,16 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
</header>
<nav class="trust-admin__tabs" role="tablist">
<a
class="trust-admin__tab"
[class.trust-admin__tab--active]="activeTab() === 'overview'"
routerLink="overview"
[queryParams]="{}"
role="tab"
[attr.aria-selected]="activeTab() === 'overview'"
>
Overview
</a>
<a
class="trust-admin__tab"
[class.trust-admin__tab--active]="activeTab() === 'keys'"
@@ -125,8 +135,8 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
[attr.aria-selected]="activeTab() === 'keys'"
>
Signing Keys
@if (summary()?.keys?.expiringSoon) {
<span class="tab-badge tab-badge--warning">{{ summary()?.keys?.expiringSoon }}</span>
@if (warningSignalCount()) {
<span class="tab-badge tab-badge--warning">{{ warningSignalCount() }}</span>
}
</a>
<a
@@ -137,9 +147,6 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
[attr.aria-selected]="activeTab() === 'issuers'"
>
Trusted Issuers
@if (summary()?.issuers?.blocked) {
<span class="tab-badge tab-badge--danger">{{ summary()?.issuers?.blocked }}</span>
}
</a>
<a
class="trust-admin__tab"
@@ -149,8 +156,8 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
[attr.aria-selected]="activeTab() === 'certificates'"
>
Certificates
@if (summary()?.certificates?.expiringSoon) {
<span class="tab-badge tab-badge--warning">{{ summary()?.certificates?.expiringSoon }}</span>
@if (warningSignalCount()) {
<span class="tab-badge tab-badge--warning">{{ warningSignalCount() }}</span>
}
</a>
<a
@@ -422,16 +429,19 @@ export class TrustAdminComponent implements OnInit {
readonly loading = signal(false);
readonly refreshing = signal(false);
readonly error = signal<string | null>(null);
readonly summary = signal<TrustDashboardSummary | null>(null);
readonly activeTab = signal<TrustAdminTab>('keys');
readonly overview = signal<TrustAdministrationOverview | null>(null);
readonly activeTab = signal<TrustAdminTab>('overview');
readonly workspaceLabel = signal<'Setup' | 'Administration'>('Setup');
// Computed
readonly alertCount = computed(() => this.summary()?.expiryAlerts?.length ?? 0);
readonly alertCount = computed(() =>
(this.overview()?.signals ?? []).filter((signal) => signal.status !== 'healthy').length
);
readonly criticalAlertCount = computed(() =>
(this.summary()?.expiryAlerts ?? []).filter(
a => a.severity === 'critical'
).length
(this.overview()?.signals ?? []).filter((signal) => signal.status === 'critical').length
);
readonly warningSignalCount = computed(() =>
(this.overview()?.signals ?? []).filter((signal) => signal.status === 'warning').length
);
ngOnInit(): void {
@@ -452,9 +462,9 @@ export class TrustAdminComponent implements OnInit {
this.loading.set(true);
this.error.set(null);
this.trustApi.getDashboardSummary().subscribe({
next: (summary) => {
this.summary.set(summary);
this.trustApi.getAdministrationOverview().subscribe({
next: (overview) => {
this.overview.set(overview);
this.loading.set(false);
},
error: (err) => {
@@ -467,9 +477,9 @@ export class TrustAdminComponent implements OnInit {
refreshDashboard(): void {
this.refreshing.set(true);
this.error.set(null);
this.trustApi.getDashboardSummary().subscribe({
next: (summary) => {
this.summary.set(summary);
this.trustApi.getAdministrationOverview().subscribe({
next: (overview) => {
this.overview.set(overview);
this.error.set(null);
this.refreshing.set(false);
},
@@ -485,14 +495,16 @@ export class TrustAdminComponent implements OnInit {
const routeRoot = segments[0];
const path = segments.includes('watchlist')
? 'watchlist'
: segments.at(-1) ?? 'keys';
: segments.at(-1) === 'trust-signing'
? 'overview'
: segments.at(-1) ?? 'overview';
this.workspaceLabel.set(routeRoot === 'administration' ? 'Administration' : 'Setup');
if (TRUST_ADMIN_TABS.includes(path as TrustAdminTab)) {
this.activeTab.set(path as TrustAdminTab);
} else {
this.activeTab.set('keys');
this.activeTab.set('overview');
}
}
}

View File

@@ -30,8 +30,16 @@ export const trustAdminRoutes: Routes = [
{
path: '',
loadComponent: () =>
import('./signing-key-dashboard.component').then(
(m) => m.SigningKeyDashboardComponent
import('./trust-overview.component').then(
(m) => m.TrustOverviewComponent
),
data: { requiredScopes: [StellaOpsScopes.SIGNER_READ] },
},
{
path: 'overview',
loadComponent: () =>
import('./trust-overview.component').then(
(m) => m.TrustOverviewComponent
),
data: { requiredScopes: [StellaOpsScopes.SIGNER_READ] },
},

View File

@@ -0,0 +1,92 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-trust-overview',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="trust-overview">
<div class="trust-overview__panel">
<h2>Administration Overview</h2>
<p>
This workspace is anchored to the live administration trust-signing projection exposed by the
rebuilt platform. Use the tabs to move into specific inventory surfaces as they are aligned to the
current backend contracts.
</p>
</div>
<div class="trust-overview__grid">
<article class="trust-overview__card">
<h3>Signing Keys</h3>
<p>Review the currently registered signing keys and rotation controls.</p>
<a routerLink="keys">Open key inventory</a>
</article>
<article class="trust-overview__card">
<h3>Trusted Issuers</h3>
<p>Inspect issuer onboarding and trust policy configuration from the canonical setup shell.</p>
<a routerLink="issuers">Open issuer inventory</a>
</article>
<article class="trust-overview__card">
<h3>Certificates</h3>
<p>Check certificate enrollment state and follow evidence consumers that depend on the trust chain.</p>
<a routerLink="certificates">Open certificate inventory</a>
</article>
<article class="trust-overview__card">
<h3>Evidence</h3>
<p>Cross-check trust-signing outputs against evidence and replay flows before promotions.</p>
<a routerLink="/evidence/overview">Open evidence overview</a>
</article>
</div>
</section>
`,
styles: [`
.trust-overview {
display: grid;
gap: 1rem;
padding: 1.25rem;
}
.trust-overview__panel,
.trust-overview__card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 1rem 1.1rem;
}
.trust-overview__panel h2,
.trust-overview__card h3 {
margin: 0 0 0.45rem;
}
.trust-overview__panel p,
.trust-overview__card p {
margin: 0;
color: var(--color-text-secondary);
line-height: 1.5;
}
.trust-overview__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.85rem;
}
.trust-overview__card {
display: grid;
gap: 0.65rem;
}
.trust-overview__card a {
color: var(--color-brand-primary);
text-decoration: none;
font-weight: var(--font-weight-medium);
}
`],
})
export class TrustOverviewComponent {}

View File

@@ -3,57 +3,30 @@ import { provideRouter, Router } from '@angular/router';
import { of, throwError } from 'rxjs';
import { TRUST_API, type TrustApi } from '../../app/core/api/trust.client';
import type { TrustDashboardSummary } from '../../app/core/api/trust.models';
import type { TrustAdministrationOverview } from '../../app/core/api/trust.models';
import { TrustAdminComponent } from '../../app/features/trust-admin/trust-admin.component';
import { trustAdminRoutes } from '../../app/features/trust-admin/trust-admin.routes';
const dashboardSummaryFixture: TrustDashboardSummary = {
keys: {
total: 12,
active: 9,
expiringSoon: 2,
expired: 1,
revoked: 0,
pendingRotation: 1,
const overviewFixture: TrustAdministrationOverview = {
inventory: {
keys: 12,
issuers: 8,
certificates: 5,
},
issuers: {
total: 8,
fullTrust: 3,
partialTrust: 3,
minimalTrust: 1,
untrusted: 1,
blocked: 0,
averageTrustScore: 86.4,
},
certificates: {
total: 5,
valid: 4,
expiringSoon: 1,
expired: 0,
revoked: 0,
invalidChains: 0,
},
recentEvents: [],
expiryAlerts: [
signals: [
{
keyId: 'key-001',
keyName: 'Attestation Key',
expiresAt: '2026-03-01T00:00:00Z',
daysUntilExpiry: 19,
severity: 'warning',
purpose: 'attestation',
suggestedAction: 'Rotate key',
signalId: 'certificate-expiry',
status: 'warning',
message: 'One certificate expires within 10 days.',
},
{
certificateId: 'cert-001',
certificateName: 'Gateway mTLS',
certificateType: 'mtls_server',
expiresAt: '2026-02-20T00:00:00Z',
daysUntilExpiry: 9,
severity: 'critical',
affectedServices: ['gateway'],
signalId: 'transparency-log',
status: 'critical',
message: 'Witness connection is unavailable.',
},
],
legacyAliases: [],
evidenceConsumerPath: '/evidence/overview',
};
describe('trust-scoring-dashboard-ui behavior', () => {
@@ -63,8 +36,8 @@ describe('trust-scoring-dashboard-ui behavior', () => {
let router: Router;
beforeEach(async () => {
trustApi = jasmine.createSpyObj('TrustApi', ['getDashboardSummary']) as jasmine.SpyObj<TrustApi>;
trustApi.getDashboardSummary.and.returnValue(of(dashboardSummaryFixture));
trustApi = jasmine.createSpyObj('TrustApi', ['getAdministrationOverview']) as jasmine.SpyObj<TrustApi>;
trustApi.getAdministrationOverview.and.returnValue(of(overviewFixture));
await TestBed.configureTestingModule({
imports: [TrustAdminComponent],
@@ -79,13 +52,14 @@ describe('trust-scoring-dashboard-ui behavior', () => {
component = fixture.componentInstance;
});
it('declares trust-admin routes for keys, issuers, certificates, watchlist, audit, airgap, incidents, and analytics', () => {
it('declares trust-admin routes for overview, keys, issuers, certificates, watchlist, audit, airgap, incidents, and analytics', () => {
const root = trustAdminRoutes.find((route) => route.path === '');
expect(root).toBeDefined();
const childPaths = (root?.children ?? []).map((child) => child.path);
expect(childPaths).toEqual([
'',
'overview',
'keys',
'issuers',
'certificates',
@@ -100,11 +74,11 @@ describe('trust-scoring-dashboard-ui behavior', () => {
]);
});
it('loads dashboard summary and computes deterministic alert counters', () => {
it('loads the administration overview and computes deterministic alert counters', () => {
fixture.detectChanges();
expect(trustApi.getDashboardSummary).toHaveBeenCalledTimes(1);
expect(component.summary()).toEqual(dashboardSummaryFixture);
expect(trustApi.getAdministrationOverview).toHaveBeenCalledTimes(1);
expect(component.overview()).toEqual(overviewFixture);
expect(component.alertCount()).toBe(2);
expect(component.criticalAlertCount()).toBe(1);
@@ -127,14 +101,17 @@ describe('trust-scoring-dashboard-ui behavior', () => {
(component as any).setActiveTabFromUrl('/setup/trust-signing/watchlist/alerts?scope=tenant');
expect(component.activeTab()).toBe('watchlist');
(component as any).setActiveTabFromUrl('/setup/trust-signing');
expect(component.activeTab()).toBe('overview');
(component as any).setActiveTabFromUrl('/admin/trust/not-a-tab');
expect(component.activeTab()).toBe('keys');
expect(component.activeTab()).toBe('overview');
});
it('clears stale error state when refresh succeeds after a failed load', () => {
trustApi.getDashboardSummary.and.returnValues(
trustApi.getAdministrationOverview.and.returnValues(
throwError(() => new Error('Dashboard unavailable')),
of(dashboardSummaryFixture)
of(overviewFixture)
);
fixture.detectChanges();
@@ -143,8 +120,8 @@ describe('trust-scoring-dashboard-ui behavior', () => {
component.refreshDashboard();
expect(component.error()).toBeNull();
expect(component.summary()).toEqual(dashboardSummaryFixture);
expect(component.overview()).toEqual(overviewFixture);
expect(component.refreshing()).toBeFalse();
expect(trustApi.getDashboardSummary).toHaveBeenCalledTimes(2);
expect(trustApi.getAdministrationOverview).toHaveBeenCalledTimes(2);
});
});