Align live titles and trust setup overview
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
99
src/Web/StellaOps.Web/src/app/core/api/trust.client.spec.ts
Normal file
99
src/Web/StellaOps.Web/src/app/core/api/trust.client.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] },
|
||||
},
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user