merge: harden derived shared ui components

This commit is contained in:
master
2026-03-08 23:50:53 +02:00
110 changed files with 6104 additions and 66953 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 285 KiB

View File

@@ -1,101 +0,0 @@
const { chromium } = require('playwright');
const session = {
subjectId: 'user-author',
tenant: 'tenant-default',
scopes: [
'ui.read',
'policy:read',
'policy:author',
'policy:simulate',
'advisory-ai:view',
'advisory-ai:operate',
'findings:read',
'vex:read',
'admin',
],
};
(async () => {
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--no-proxy-server'],
});
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
const navHistory = [];
const httpErrors = [];
const failures = [];
let currentUrl = '';
page.on('framenavigated', (frame) => {
if (frame !== page.mainFrame()) {
return;
}
const entry = `${new Date().toISOString()} ${frame.url()}`;
navHistory.push(entry);
console.log('[nav]', entry);
});
page.on('response', (response) => {
if (response.status() < 400) {
return;
}
const request = response.request();
const entry = `${response.status()} ${request.method()} ${response.url()}`;
httpErrors.push(entry);
console.log('[http-error]', entry);
});
page.on('requestfailed', (request) => {
const entry = `${request.method()} ${request.url()} :: ${request.failure()?.errorText ?? 'failed'}`;
failures.push(entry);
console.log('[requestfailed]', entry);
});
page.on('console', (msg) => {
if (msg.type() === 'error') {
console.log('[console-error]', msg.text());
}
});
await page.addInitScript((stubSession) => {
window.__stellaopsTestSession = stubSession;
}, session);
const target = process.argv[2] ?? 'https://stella-ops.local/';
console.log('[goto]', target);
try {
await page.goto(target, { waitUntil: 'commit', timeout: 20000 });
} catch (error) {
console.log('[goto-error]', error.message);
}
for (let i = 0; i < 20; i += 1) {
const url = page.url();
if (url !== currentUrl) {
currentUrl = url;
console.log('[url-change]', url);
}
await page.waitForTimeout(1000);
}
const searchInputCount = await page
.evaluate(() => document.querySelectorAll('app-global-search input[type="text"]').length)
.catch(() => -1);
console.log('[final-url]', page.url());
console.log('[title]', await page.title().catch(() => '<title unavailable>'));
console.log('[search-input-count]', searchInputCount);
console.log('[nav-count]', navHistory.length);
console.log('[http-error-count]', httpErrors.length);
console.log('[failed-request-count]', failures.length);
await page.screenshot({ path: 'output/playwright/stella-ops-local-load-check-viewport.png' });
await browser.close();
})();

View File

@@ -1,66 +0,0 @@
const { chromium } = require('playwright');
const session = {
subjectId: 'user-author',
tenant: 'tenant-default',
scopes: ['ui.read', 'policy:read', 'policy:author', 'policy:simulate', 'advisory:search', 'advisory:read', 'search:read', 'findings:read', 'vex:read', 'admin'],
};
(async () => {
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
page.on('requestfailed', (request) => {
const url = request.url();
if (url.includes('/search')) {
console.log('[requestfailed]', request.method(), url, request.failure()?.errorText);
}
});
page.on('response', (response) => {
const url = response.url();
if (
url.includes('/api/v1/search/query') ||
url.includes('/api/v1/advisory-ai/search') ||
url.includes('/api/v1/advisory-ai/search/analytics')
) {
const req = response.request();
console.log('[response]', req.method(), response.status(), url);
}
});
await page.addInitScript((stubSession) => {
window.__stellaopsTestSession = stubSession;
}, session);
const url = process.argv[2] || 'https://stella-ops.local/';
console.log('[goto]', url);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForTimeout(2000);
const count = await page.evaluate(() => document.querySelectorAll('app-global-search input[type="text"]').length);
console.log('[search-input-count]', count);
if (count === 0) {
console.log('[page-url]', page.url());
console.log('[title]', await page.title());
await page.screenshot({ path: 'output/playwright/header-search-repro-no-input.png', fullPage: true });
await browser.close();
process.exit(1);
}
await page.click('app-global-search input[type="text"]', { timeout: 15000 });
await page.fill('app-global-search input[type="text"]', 'critical findings', { timeout: 15000 });
await page.waitForTimeout(3000);
const results = await page.evaluate(() => document.querySelectorAll('app-entity-card').length);
const emptyText = await page.locator('.search__empty').allTextContents();
const degradedVisible = await page.locator('.search__degraded-banner').isVisible().catch(() => false);
console.log('[entity-cards]', results);
console.log('[empty-text]', emptyText.join(' | '));
console.log('[degraded-banner]', degradedVisible);
await page.screenshot({ path: 'output/playwright/header-search-repro-live.png', fullPage: true });
await browser.close();
})();

View File

@@ -1,66 +0,0 @@
const { chromium } = require('playwright');
const session = {
subjectId: 'user-author',
tenant: 'tenant-default',
scopes: ['ui.read','policy:read','policy:author','policy:simulate','advisory:search','advisory:read','search:read','findings:read','vex:read','admin']
};
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
page.on('requestfailed', (request) => {
const url = request.url();
if (url.includes('/search')) {
console.log('[requestfailed]', request.method(), url, request.failure()?.errorText);
}
});
page.on('response', (response) => {
const url = response.url();
if (
url.includes('/api/v1/search/query') ||
url.includes('/api/v1/advisory-ai/search') ||
url.includes('/api/v1/advisory-ai/search/analytics')
) {
const req = response.request();
console.log('[response]', req.method(), response.status(), url);
}
});
await page.addInitScript((stubSession) => {
window.__stellaopsTestSession = stubSession;
}, session);
const url = process.argv[2] || 'https://stella-ops.local:10000/';
console.log('[goto]', url);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForTimeout(2000);
const count = await page.evaluate(() => document.querySelectorAll('app-global-search input[type="text"]').length);
console.log('[search-input-count]', count);
if (count === 0) {
console.log('[page-url]', page.url());
console.log('[title]', await page.title());
await page.screenshot({ path: 'output/playwright/header-search-repro-no-input.png', fullPage: true });
await browser.close();
process.exit(1);
}
await page.click('app-global-search input[type="text"]', { timeout: 15000 });
await page.fill('app-global-search input[type="text"]', 'critical findings', { timeout: 15000 });
await page.waitForTimeout(3000);
const results = await page.evaluate(() => document.querySelectorAll('app-entity-card').length);
const emptyText = await page.locator('.search__empty').allTextContents();
const degradedVisible = await page.locator('.search__degraded-banner').isVisible().catch(() => false);
console.log('[entity-cards]', results);
console.log('[empty-text]', emptyText.join(' | '));
console.log('[degraded-banner]', degradedVisible);
await page.screenshot({ path: 'output/playwright/header-search-repro.png', fullPage: true });
await browser.close();
})();

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -637,7 +637,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'identity-providers',
label: 'Identity Providers',
route: '/settings/identity-providers',
route: '/administration/identity-providers',
icon: 'id-card',
requiredScopes: ['ui.admin'],
tooltip: 'Configure external identity providers (LDAP, SAML, OIDC)',

View File

@@ -17,10 +17,11 @@ import { firstValueFrom } from 'rxjs';
import { NOTIFIER_API, NotifierApi } from '../../../core/api/notifier.client';
import { NotifierDeliveryStats, NotifierDelivery } from '../../../core/api/notifier.models';
import { MetricCardComponent } from '../../../shared/ui/metric-card/metric-card.component';
@Component({
selector: 'app-delivery-analytics',
imports: [FormsModule],
imports: [FormsModule, MetricCardComponent],
template: `
<div class="delivery-analytics">
<header class="section-header">
@@ -60,60 +61,41 @@ import { NotifierDeliveryStats, NotifierDelivery } from '../../../core/api/notif
</div>
</div>
<div class="metric-card">
<div class="metric-header">
<span class="metric-icon sent-icon">S</span>
<span class="metric-label">Total Sent</span>
</div>
<div class="metric-value">{{ formatNumber(stats()!.totalSent) }}</div>
<div class="metric-trend">
<span class="trend-text">Delivered successfully</span>
</div>
</div>
<app-metric-card
label="Total Sent"
[value]="formatNumber(stats()!.totalSent)"
severity="healthy"
subtitle="Delivered successfully"
/>
<div class="metric-card">
<div class="metric-header">
<span class="metric-icon failed-icon">F</span>
<span class="metric-label">Failed</span>
</div>
<div class="metric-value failed">{{ formatNumber(stats()!.totalFailed) }}</div>
<div class="metric-trend">
<span class="trend-text">Require attention</span>
</div>
</div>
<app-metric-card
label="Failed"
[value]="formatNumber(stats()!.totalFailed)"
[severity]="stats()!.totalFailed > 0 ? 'critical' : 'healthy'"
subtitle="Require attention"
/>
<div class="metric-card">
<div class="metric-header">
<span class="metric-icon pending-icon">P</span>
<span class="metric-label">Pending</span>
</div>
<div class="metric-value pending">{{ formatNumber(stats()!.totalPending) }}</div>
<div class="metric-trend">
<span class="trend-text">In queue</span>
</div>
</div>
<app-metric-card
label="Pending"
[value]="formatNumber(stats()!.totalPending)"
[severity]="stats()!.totalPending > 50 ? 'warning' : 'healthy'"
subtitle="In queue"
/>
<div class="metric-card">
<div class="metric-header">
<span class="metric-icon throttled-icon">T</span>
<span class="metric-label">Throttled</span>
</div>
<div class="metric-value throttled">{{ formatNumber(stats()!.totalThrottled) }}</div>
<div class="metric-trend">
<span class="trend-text">Rate limited</span>
</div>
</div>
<app-metric-card
label="Throttled"
[value]="formatNumber(stats()!.totalThrottled)"
[severity]="stats()!.totalThrottled > 0 ? 'warning' : 'healthy'"
subtitle="Rate limited"
/>
<div class="metric-card">
<div class="metric-header">
<span class="metric-icon latency-icon">L</span>
<span class="metric-label">Avg Latency</span>
</div>
<div class="metric-value">{{ stats()!.avgDeliveryTimeMs }}ms</div>
<div class="metric-trend">
<span class="trend-text">Average delivery time</span>
</div>
</div>
<app-metric-card
label="Avg Latency"
[value]="stats()!.avgDeliveryTimeMs"
unit="ms"
deltaDirection="up-is-bad"
subtitle="Average delivery time"
/>
</section>
<!-- Channel Breakdown -->

View File

@@ -1,14 +1,23 @@
// Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer
import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core';
// Updated: SPRINT_20260308_029_FE - Adopt canonical timeline-list (FE-TLD-003)
import { Component, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { AuditLogClient } from '../../core/api/audit-log.client';
import { AuditTimelineEntry } from '../../core/api/audit-log.models';
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component';
function mapActionToKind(action: string): TimelineEventKind {
const lower = action.toLowerCase();
if (lower.includes('create') || lower.includes('approve') || lower.includes('success')) return 'success';
if (lower.includes('delete') || lower.includes('revoke') || lower.includes('fail')) return 'error';
if (lower.includes('update') || lower.includes('modify')) return 'warning';
return 'info';
}
@Component({
selector: 'app-audit-timeline-search',
imports: [RouterModule, FormsModule],
imports: [RouterModule, FormsModule, TimelineListComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="timeline-page">
@@ -32,36 +41,25 @@ import { AuditTimelineEntry } from '../../core/api/audit-log.models';
</button>
</div>
@if (entries().length > 0) {
<div class="timeline">
@for (entry of entries(); track entry.timestamp) {
<div class="timeline-entry">
<div class="timeline-marker">
<div class="marker-dot"></div>
<div class="marker-line"></div>
</div>
<div class="entry-content">
<div class="entry-time">{{ formatTime(entry.timestamp) }}</div>
@if (entry.clusterSize && entry.clusterSize > 1) {
<div class="cluster-badge">{{ entry.clusterSize }} events</div>
}
<div class="entry-events">
@for (event of entry.events; track event.id) {
<div class="event-item" [routerLink]="['/evidence/audit-log/events', event.id]">
<span class="badge module" [class]="event.module">{{ event.module }}</span>
<span class="badge action" [class]="event.action">{{ event.action }}</span>
<span class="actor">{{ event.actor.name }}</span>
<span class="desc">{{ event.description }}</span>
</div>
}
</div>
</div>
<!-- Canonical Timeline -->
<app-timeline-list
[events]="timelineEvents()"
[loading]="searching()"
[groupByDate]="true"
[emptyMessage]="searched() && !searching() ? 'No events found matching your search.' : 'Enter a search query to find audit events.'"
ariaLabel="Audit timeline search results"
>
<ng-template #eventContent let-event>
@if (event.metadata && event.metadata['module']) {
<div class="event-badges">
<span class="badge badge--module" [attr.data-module]="event.metadata['module']">{{ event.metadata['module'] }}</span>
@if (event.metadata['action']) {
<span class="badge badge--action">{{ event.metadata['action'] }}</span>
}
</div>
}
</div>
} @else if (searched() && !searching()) {
<div class="no-results">No events found matching your search.</div>
}
</ng-template>
</app-timeline-list>
</div>
`,
styles: [`
@@ -69,36 +67,27 @@ import { AuditTimelineEntry } from '../../core/api/audit-log.models';
.page-header { margin-bottom: 1.5rem; }
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
h1 { margin: 0 0 0.25rem; }
.description { color: var(--color-text-secondary); margin: 0; }
.search-bar { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1rem; margin-bottom: 2rem; }
.search-bar { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1rem; margin-bottom: 1.5rem; }
.search-bar input[type="text"] { flex: 1; min-width: 250px; padding: 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); font-size: 1rem; }
.date-filters { display: flex; align-items: center; gap: 0.5rem; }
.date-filters input { padding: 0.5rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); }
.date-filters span { color: var(--color-text-secondary); }
.btn-primary { background: var(--color-brand-primary); color: var(--color-text-heading); border: none; padding: 0.75rem 1.5rem; border-radius: var(--radius-sm); cursor: pointer; }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
.timeline { position: relative; }
.timeline-entry { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
.timeline-marker { display: flex; flex-direction: column; align-items: center; width: 20px; }
.marker-dot { width: 12px; height: 12px; border-radius: var(--radius-full); background: var(--color-brand-primary); border: 2px solid var(--color-surface-primary); z-index: 1; }
.marker-line { width: 2px; flex: 1; background: var(--color-border-primary); margin-top: 4px; }
.timeline-entry:last-child .marker-line { display: none; }
.entry-content { flex: 1; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1rem; }
.entry-time { font-family: monospace; font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
.cluster-badge { display: inline-block; background: var(--color-surface-elevated); padding: 0.15rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.75rem; margin-bottom: 0.5rem; }
.entry-events { display: flex; flex-direction: column; gap: 0.5rem; }
.event-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; background: var(--color-surface-elevated); border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s; }
.event-item:hover { background: var(--color-status-info-bg); }
.badge { display: inline-block; padding: 0.1rem 0.35rem; border-radius: var(--radius-sm); font-size: 0.7rem; text-transform: uppercase; }
.badge.module { background: var(--color-surface-primary); }
.badge.module.policy { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.badge.module.authority { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.badge.module.vex { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.badge.action { background: var(--color-surface-primary); }
.actor { font-size: 0.8rem; color: var(--color-text-secondary); }
.desc { font-size: 0.85rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.no-results { text-align: center; padding: 3rem; color: var(--color-text-secondary); }
.event-badges { display: flex; gap: 0.25rem; margin-top: 0.25rem; flex-wrap: wrap; }
.badge {
display: inline-block; padding: 0.0625rem 0.35rem;
border-radius: var(--radius-sm); font-size: 0.6875rem; text-transform: uppercase;
}
.badge--module { background: var(--color-surface-secondary); color: var(--color-text-secondary); }
.badge--module[data-module="policy"] { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.badge--module[data-module="authority"] { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.badge--module[data-module="vex"] { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.badge--action { background: var(--color-surface-secondary); color: var(--color-text-secondary); }
`]
})
export class AuditTimelineSearchComponent {
@@ -112,6 +101,44 @@ export class AuditTimelineSearchComponent {
startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
endDate = new Date().toISOString().split('T')[0];
/** Map audit timeline entries to canonical TimelineEvent[]. */
readonly timelineEvents = computed<TimelineEvent[]>(() => {
const result: TimelineEvent[] = [];
for (const entry of this.entries()) {
if (entry.clusterSize && entry.clusterSize > 1) {
// Cluster: render as a single summary event
const firstEvent = entry.events[0];
result.push({
id: entry.clusterId ?? entry.timestamp,
timestamp: entry.timestamp,
title: `${entry.clusterSize} events`,
description: entry.events.map(e => `[${e.module}] ${e.description}`).join(' | '),
actor: firstEvent?.actor?.name,
eventKind: firstEvent ? mapActionToKind(firstEvent.action) : 'info',
icon: 'summarize',
metadata: firstEvent ? { module: firstEvent.module, action: firstEvent.action } : undefined,
expandable: entry.events.length > 1 ? entry.events.map(e =>
`${e.timestamp} [${e.module}/${e.action}] ${e.actor.name}: ${e.description}`
).join('\n') : undefined,
});
} else {
// Individual events
for (const event of entry.events) {
result.push({
id: event.id,
timestamp: event.timestamp ?? entry.timestamp,
title: event.description,
actor: event.actor?.name,
eventKind: mapActionToKind(event.action),
icon: 'event_note',
metadata: { module: event.module, action: event.action },
});
}
}
}
return result;
});
search(): void {
if (!this.query.trim()) return;
this.searching.set(true);
@@ -124,8 +151,4 @@ export class AuditTimelineSearchComponent {
error: () => this.searching.set(false),
});
}
formatTime(ts: string): string {
return new Date(ts).toLocaleString();
}
}

View File

@@ -1,4 +1,5 @@
// Sprint: SPRINT_20251229_030_FE - Dead-Letter Management UI
// Sprint 027: Adopted canonical ContextHeaderComponent
import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core';
import { RouterModule } from '@angular/router';
@@ -14,18 +15,20 @@ import {
BatchReplayProgress,
ERROR_CODE_REFERENCES,
} from '../../core/api/deadletter.models';
import { ContextHeaderComponent } from '../../shared/ui/context-header/context-header.component';
@Component({
selector: 'app-deadletter-dashboard',
imports: [RouterModule, FormsModule],
imports: [RouterModule, FormsModule, ContextHeaderComponent],
template: `
<div class="deadletter-dashboard">
<header class="page-header">
<div class="header-content">
<h1>Dead-Letter Queue Management</h1>
<p class="subtitle">Failed job recovery and diagnostics</p>
</div>
<div class="header-actions">
<app-context-header
eyebrow="Ops / Execution"
title="Dead-Letter Queue Management"
subtitle="Failed job recovery and diagnostics"
testId="deadletter-dashboard-header"
>
<div header-actions class="header-actions">
<button class="btn btn-secondary" (click)="exportData()">
<span class="icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/></svg></span>
Export CSV
@@ -42,7 +45,7 @@ import {
<span class="icon" [class.spinning]="refreshing()" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 3v6h-6"/></svg></span>
</button>
</div>
</header>
</app-context-header>
<!-- Batch Progress Banner -->
@if (batchProgress()) {
@@ -410,23 +413,6 @@ import {
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.page-header h1 {
margin: 0;
font-size: 1.75rem;
}
.subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
}
.header-actions {
display: flex;
gap: 0.5rem;

View File

@@ -5,14 +5,23 @@
* Detailed view of a single evidence packet with contents and verification.
*/
import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import {
VerificationSummaryComponent,
EvidencePayloadComponent,
} from '../../shared/ui/witness/index';
import type {
VerificationSummaryData,
EvidencePayloadData,
} from '../../shared/ui/witness/index';
@Component({
selector: 'app-evidence-packet-page',
standalone: true,
imports: [CommonModule, RouterLink],
imports: [CommonModule, RouterLink, VerificationSummaryComponent, EvidencePayloadComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="evidence-packet">
@@ -125,30 +134,16 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
</div>
}
@case ('verify') {
<div class="panel">
<h3>Verification</h3>
<div class="verification-status">
@if (packet().verified) {
<div class="verification-result verification-result--success">
<span class="verification-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
<div>
<strong>Signature Valid</strong>
<p>Verified against trusted key: ops-signing-key-2026</p>
</div>
</div>
} @else {
<div class="verification-result verification-result--pending">
<span class="verification-icon">?</span>
<div>
<strong>Not Yet Verified</strong>
<p>Click verify to check signature</p>
</div>
</div>
}
<div class="panel verify-panel" data-testid="verify-tab">
<app-verification-summary [data]="verificationSummary()" />
<div class="verify-action-row">
<button type="button" class="btn btn--primary" (click)="runVerification()">
Run Verification
</button>
</div>
<button type="button" class="btn btn--primary" (click)="runVerification()">
Run Verification
</button>
<app-evidence-payload [data]="evidencePayload()" />
</div>
}
@case ('proof-chain') {
@@ -340,6 +335,9 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
.btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.btn--primary { background: var(--color-brand-primary); border: none; color: var(--color-text-heading); }
.btn--secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); color: var(--color-text-primary); }
.verify-panel { display: grid; gap: 1rem; }
.verify-action-row { display: flex; gap: 0.75rem; }
`]
})
export class EvidencePacketPageComponent implements OnInit {
@@ -381,6 +379,38 @@ export class EvidencePacketPageComponent implements OnInit {
{ id: '5', type: 'Promotion', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>', hash: 'sha256:m3n4o5...', time: '2h ago' },
];
// ---------------------------------------------------------------------------
// Derived proof-inspection section data (SPRINT-031 FE-WVD-003)
// ---------------------------------------------------------------------------
readonly verificationSummary = computed((): VerificationSummaryData => {
const p = this.packet();
const pType: string = p.type;
return {
id: p.id,
typeLabel: pType.charAt(0).toUpperCase() + pType.slice(1),
typeBadge: pType === 'attestation' ? 'attestation' : pType === 'exception' ? 'bundle' : 'receipt',
status: p.verified ? 'verified' : p.signed ? 'unverified' : 'pending',
createdAt: p.createdAt,
source: p.environment ?? undefined,
};
});
readonly evidencePayload = computed((): EvidencePayloadData => {
const p = this.packet();
return {
evidenceId: p.id,
rawContent: JSON.stringify(p, null, 2),
metadata: {
bundleDigest: p.bundleDigest,
releaseVersion: p.releaseVersion,
environment: p.environment,
signed: p.signed,
verified: p.verified,
},
};
});
ngOnInit(): void {
this.route.params.subscribe(params => {
this.packetId.set(params['packetId'] || '');

View File

@@ -1,35 +1,40 @@
// Offline Kit Component
// Sprint 026: Offline Kit Integration
// Sprint 027: Adopted canonical ContextHeaderComponent
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { RouterModule } from '@angular/router';
import { OfflineModeService } from '../../core/services/offline-mode.service';
import { ContextHeaderComponent } from '../../shared/ui/context-header/context-header.component';
@Component({
selector: 'app-offline-kit',
imports: [RouterModule],
imports: [RouterModule, ContextHeaderComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="offline-kit-layout">
<header class="page-header">
<div class="header-content">
<h1>Offline Kit Management</h1>
<p class="subtitle">Manage offline bundles, verify audit packages, and configure air-gap operation</p>
<div class="page-shortcuts">
<a routerLink="/ops/operations/feeds-airgap">Feeds & Airgap</a>
<a routerLink="/evidence/exports">Evidence Exports</a>
<a routerLink="/evidence/verify-replay">Verify & Replay</a>
<a routerLink="/setup/trust-signing">Trust & Signing</a>
</div>
</div>
<div class="header-status">
<app-context-header
eyebrow="Ops / Feeds & Airgap"
title="Offline Kit Management"
subtitle="Manage offline bundles, verify audit packages, and configure air-gap operation"
[chips]="[isOffline() ? 'Offline' : 'Online']"
testId="offline-kit-header"
>
<div header-actions class="header-status">
<div class="connection-status" [class.offline]="isOffline()">
<span class="status-dot"></span>
<span class="status-text">{{ isOffline() ? 'Offline' : 'Online' }}</span>
</div>
</div>
</header>
</app-context-header>
<div class="page-shortcuts">
<a routerLink="/ops/operations/feeds-airgap">Feeds & Airgap</a>
<a routerLink="/evidence/exports">Evidence Exports</a>
<a routerLink="/evidence/verify-replay">Verify & Replay</a>
<a routerLink="/setup/trust-signing">Trust & Signing</a>
</div>
<nav class="tab-nav">
<a routerLink="dashboard" routerLinkActive="active" class="tab-link">
@@ -76,28 +81,8 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
background: var(--color-surface-primary);
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1.5rem 2rem;
border-bottom: 1px solid var(--color-border-primary);
}
.header-content h1 {
font-size: 1.5rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-heading);
margin: 0 0 0.25rem;
}
.subtitle {
font-size: 0.875rem;
color: var(--color-text-secondary);
margin: 0;
}
.page-shortcuts {
padding: 0 2rem;
display: flex;
gap: 0.45rem;
flex-wrap: wrap;

View File

@@ -11,6 +11,7 @@ import {
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { UnifiedSearchClient } from '../../../core/api/unified-search.client';
import { MetricCardComponent } from '../../../shared/ui/metric-card/metric-card.component';
import type {
SearchQualityAlert,
SearchQualityTrendPoint,
@@ -22,6 +23,7 @@ import type {
@Component({
selector: 'app-search-quality-dashboard',
standalone: true,
imports: [MetricCardComponent],
template: `
<div class="sqd">
<div class="sqd__header">
@@ -42,26 +44,30 @@ import type {
<!-- Summary metrics cards -->
<div class="sqd__metrics">
<div class="sqd__metric-card">
<div class="sqd__metric-value">{{ metrics()?.totalSearches ?? 0 }}</div>
<div class="sqd__metric-label">Total Searches</div>
</div>
<div class="sqd__metric-card">
<div class="sqd__metric-value" [class.sqd__metric-value--warn]="(metrics()?.zeroResultRate ?? 0) > 10">
{{ metrics()?.zeroResultRate ?? 0 }}%
</div>
<div class="sqd__metric-label">Zero-Result Rate</div>
</div>
<div class="sqd__metric-card">
<div class="sqd__metric-value">{{ metrics()?.avgResultCount ?? 0 }}</div>
<div class="sqd__metric-label">Avg Results / Query</div>
</div>
<div class="sqd__metric-card">
<div class="sqd__metric-value" [class.sqd__metric-value--good]="(metrics()?.feedbackScore ?? 0) > 70">
{{ metrics()?.feedbackScore ?? 0 }}%
</div>
<div class="sqd__metric-label">Feedback Score (Helpful)</div>
</div>
<app-metric-card
label="Total Searches"
[value]="metrics()?.totalSearches ?? 0"
deltaDirection="up-is-good"
/>
<app-metric-card
label="Zero-Result Rate"
[value]="(metrics()?.zeroResultRate ?? 0)"
unit="%"
deltaDirection="up-is-bad"
[severity]="(metrics()?.zeroResultRate ?? 0) > 10 ? 'warning' : undefined"
/>
<app-metric-card
label="Avg Results / Query"
[value]="metrics()?.avgResultCount ?? 0"
deltaDirection="up-is-good"
/>
<app-metric-card
label="Feedback Score (Helpful)"
[value]="(metrics()?.feedbackScore ?? 0)"
unit="%"
deltaDirection="up-is-good"
[severity]="(metrics()?.feedbackScore ?? 0) > 70 ? 'healthy' : undefined"
/>
</div>
<!-- Zero-result alerts table -->
@@ -291,34 +297,7 @@ import type {
}
}
.sqd__metric-card {
padding: 1rem 1.25rem;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
text-align: center;
}
.sqd__metric-value {
font-size: 1.75rem;
font-weight: 700;
color: #111827;
line-height: 1.2;
}
.sqd__metric-value--warn {
color: #dc2626;
}
.sqd__metric-value--good {
color: #16a34a;
}
.sqd__metric-label {
font-size: 0.75rem;
color: #6b7280;
margin-top: 0.25rem;
}
/* metric-card styling handled by the canonical MetricCardComponent */
.sqd__section {
margin-bottom: 2rem;

View File

@@ -9,23 +9,25 @@ import {
PackVersionRow,
} from './models/pack-registry-browser.models';
import { PackRegistryBrowserService } from './services/pack-registry-browser.service';
import { ContextHeaderComponent } from '../../shared/ui/context-header/context-header.component';
@Component({
selector: 'app-pack-registry-browser',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, ContextHeaderComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="pack-registry-page">
<header class="page-header">
<div>
<h1>Pack Registry Browser</h1>
<p>Browse TaskRunner packs, inspect DSSE signature state, and run compatibility-checked installs and upgrades.</p>
</div>
<button type="button" class="refresh-btn" (click)="refresh()" [disabled]="loading()">
<app-context-header
eyebrow="Ops / Execution"
title="Pack Registry Browser"
subtitle="Browse TaskRunner packs, inspect DSSE signature state, and run compatibility-checked installs and upgrades."
testId="pack-registry-header"
>
<button header-actions type="button" class="refresh-btn" (click)="refresh()" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
</header>
</app-context-header>
<section class="kpi-grid" aria-label="Pack registry summary">
<article class="kpi-card">
@@ -209,24 +211,6 @@ import { PackRegistryBrowserService } from './services/pack-registry-browser.ser
gap: 1rem;
}
.page-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
h1 {
margin: 0;
font-size: 1.6rem;
line-height: 1.2;
}
.page-header p {
margin: 0.4rem 0 0;
color: var(--color-text-secondary);
}
.refresh-btn {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);

View File

@@ -1,56 +1,85 @@
// Sprint: SPRINT_20251229_032_FE - Platform Health Dashboard
// Updated: SPRINT_20260308_029_FE - Adopt canonical timeline-list (FE-TLD-003)
import { Component, inject, signal, computed, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { PlatformHealthClient } from '../../core/api/platform-health.client';
import {
Incident,
IncidentSeverity,
INCIDENT_SEVERITY_COLORS,
} from '../../core/api/platform-health.models';
import { healthSloPath } from '../platform/ops/operations-paths';
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component';
function mapSeverityToKind(severity: IncidentSeverity, state: string): TimelineEventKind {
if (state === 'resolved') return 'success';
switch (severity) {
case 'critical': return 'critical';
case 'warning': return 'warning';
case 'info': return 'info';
default: return 'neutral';
}
}
function mapSeverityToIcon(severity: IncidentSeverity, state: string): string {
if (state === 'resolved') return 'check_circle';
switch (severity) {
case 'critical': return 'error';
case 'warning': return 'warning';
case 'info': return 'info';
default: return 'radio_button_unchecked';
}
}
function buildExpandablePayload(incident: Incident): string | undefined {
const parts: string[] = [];
if (incident.rootCauseSuggestion) {
parts.push(`Suggested Root Cause: ${incident.rootCauseSuggestion}`);
}
if (incident.correlatedEvents.length > 0) {
parts.push(`Correlated Events (${incident.correlatedEvents.length}):`);
for (const evt of incident.correlatedEvents) {
parts.push(` ${evt.timestamp} [${evt.service}] ${evt.description}`);
}
}
return parts.length > 0 ? parts.join('\n') : undefined;
}
@Component({
selector: 'app-incident-timeline',
imports: [CommonModule, RouterModule, FormsModule],
imports: [RouterModule, FormsModule, TimelineListComponent],
template: `
<div class="incident-timeline p-6">
<header class="mb-6">
<div class="flex items-center gap-2 text-sm text-gray-500 mb-2">
<a [routerLink]="healthOverviewPath" class="hover:text-blue-600">Platform Health</a>
<div class="incident-timeline">
<header class="page-header">
<div class="breadcrumb">
<a [routerLink]="healthOverviewPath">Platform Health</a>
<span>/</span>
<span>Incidents</span>
</div>
<div class="flex items-center justify-between">
<div class="header-row">
<div>
<h1 class="text-2xl font-bold text-gray-900">Incident Timeline</h1>
<p class="text-gray-600 mt-1">Correlated incidents with root-cause analysis</p>
<h1>Incident Timeline</h1>
<p class="subtitle">Correlated incidents with root-cause analysis</p>
</div>
<div class="flex items-center gap-3">
<div class="header-actions">
<select
[(ngModel)]="hoursBack"
(ngModelChange)="loadIncidents()"
class="px-3 py-2 text-sm border rounded-md"
>
<option [value]="6">Last 6 hours</option>
<option [value]="24">Last 24 hours</option>
<option [value]="72">Last 3 days</option>
<option [value]="168">Last 7 days</option>
</select>
<label class="flex items-center gap-2 text-sm">
<label class="checkbox-label">
<input
type="checkbox"
[(ngModel)]="includeResolved"
(ngModelChange)="loadIncidents()"
class="rounded"
/>
Include resolved
</label>
<button
(click)="exportReport()"
class="px-3 py-2 text-sm border rounded-md hover:bg-gray-50"
>
<button type="button" class="btn-secondary" (click)="exportReport()">
Export Report
</button>
</div>
@@ -58,154 +87,160 @@ import { healthSloPath } from '../platform/ops/operations-paths';
</header>
<!-- Summary Cards -->
<section class="grid grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg border p-4">
<span class="text-gray-600 text-sm">Total Incidents</span>
<p class="text-2xl font-bold text-gray-900">{{ incidents().length }}</p>
<section class="summary-cards">
<div class="summary-card">
<span class="summary-label">Total Incidents</span>
<p class="summary-value">{{ incidents().length }}</p>
</div>
<div class="bg-white rounded-lg border p-4">
<span class="text-gray-600 text-sm">Active</span>
<p class="text-2xl font-bold text-red-600">{{ activeCount() }}</p>
<div class="summary-card">
<span class="summary-label">Active</span>
<p class="summary-value summary-value--error">{{ activeCount() }}</p>
</div>
<div class="bg-white rounded-lg border p-4">
<span class="text-gray-600 text-sm">Critical</span>
<p class="text-2xl font-bold text-red-600">{{ criticalCount() }}</p>
<div class="summary-card">
<span class="summary-label">Critical</span>
<p class="summary-value summary-value--error">{{ criticalCount() }}</p>
</div>
<div class="bg-white rounded-lg border p-4">
<span class="text-gray-600 text-sm">Resolved</span>
<p class="text-2xl font-bold text-green-600">{{ resolvedCount() }}</p>
<div class="summary-card">
<span class="summary-label">Resolved</span>
<p class="summary-value summary-value--success">{{ resolvedCount() }}</p>
</div>
</section>
<!-- Filters -->
<section class="bg-white rounded-lg border p-4 mb-6">
<div class="flex items-center gap-4">
<select
[(ngModel)]="severityFilter"
class="px-3 py-2 text-sm border rounded-md"
>
<option value="all">All Severities</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
<select
[(ngModel)]="stateFilter"
class="px-3 py-2 text-sm border rounded-md"
>
<option value="all">All States</option>
<option value="active">Active</option>
<option value="resolved">Resolved</option>
</select>
<input
type="text"
[(ngModel)]="searchQuery"
placeholder="Search incidents..."
class="px-3 py-2 text-sm border rounded-md flex-1"
/>
</div>
<section class="filter-section">
<select [(ngModel)]="severityFilter">
<option value="all">All Severities</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
<select [(ngModel)]="stateFilter">
<option value="all">All States</option>
<option value="active">Active</option>
<option value="resolved">Resolved</option>
</select>
<input
type="text"
[(ngModel)]="searchQuery"
placeholder="Search incidents..."
class="search-input"
/>
</section>
<!-- Timeline -->
<section class="bg-white rounded-lg border">
<div class="divide-y">
@for (incident of filteredIncidents(); track incident.id) {
<div class="p-4" [class.bg-red-50]="incident.state === 'active'">
<div class="flex items-start gap-4">
<!-- Timeline marker -->
<div class="flex flex-col items-center">
<span
class="w-4 h-4 rounded-full"
[class]="incident.state === 'active' ? 'bg-red-500' : 'bg-gray-400'"
></span>
<div class="w-0.5 h-full bg-gray-200 mt-2"></div>
</div>
<!-- Content -->
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<span
class="px-2 py-0.5 text-xs font-medium rounded"
[class]="INCIDENT_SEVERITY_COLORS[incident.severity]"
>
{{ incident.severity | uppercase }}
</span>
<span class="font-medium text-gray-900">{{ incident.title }}</span>
@if (incident.state === 'resolved') {
<span class="px-2 py-0.5 text-xs rounded bg-green-100 text-green-800">
Resolved
</span>
}
</div>
<p class="text-gray-600 mb-2">{{ incident.description }}</p>
<!-- Affected Services -->
<div class="flex items-center gap-2 mb-2">
<span class="text-sm text-gray-500">Affected:</span>
@for (service of incident.affectedServices; track service) {
<span class="px-2 py-0.5 text-xs bg-gray-100 rounded">{{ service }}</span>
}
</div>
<!-- Root Cause -->
@if (incident.rootCauseSuggestion) {
<div class="p-3 bg-blue-50 border border-blue-200 rounded mb-2">
<p class="text-sm text-blue-800">
<span class="font-medium">Suggested Root Cause:</span>
{{ incident.rootCauseSuggestion }}
</p>
</div>
<!-- Canonical Timeline -->
<section class="timeline-section">
<app-timeline-list
[events]="timelineEvents()"
[loading]="loading()"
[groupByDate]="true"
[emptyMessage]="loading() ? 'Loading incidents...' : 'No incidents found for the selected time range'"
ariaLabel="Incident timeline"
>
<ng-template #eventContent let-event>
@if (getIncidentForEvent(event.id); as incident) {
@if (incident.affectedServices.length > 0) {
<div class="affected-services">
<span class="affected-label">Affected:</span>
@for (service of incident.affectedServices; track service) {
<span class="service-chip">{{ service }}</span>
}
<!-- Correlated Events -->
@if (incident.correlatedEvents.length > 0) {
<details class="mt-2">
<summary class="text-sm text-blue-600 cursor-pointer hover:underline">
View {{ incident.correlatedEvents.length }} correlated events
</summary>
<div class="mt-2 pl-4 border-l-2 border-gray-200 space-y-2">
@for (event of incident.correlatedEvents; track event.timestamp) {
<div class="text-sm">
<span class="text-gray-500">{{ event.timestamp | date:'shortTime' }}</span>
<span class="text-gray-700 ml-2">{{ event.service }}:</span>
<span class="text-gray-600">{{ event.description }}</span>
</div>
}
</div>
</details>
}
<!-- Timestamps -->
<div class="mt-3 text-xs text-gray-500 flex items-center gap-4">
<span>Started: {{ incident.startedAt | date:'medium' }}</span>
@if (incident.resolvedAt) {
<span>Resolved: {{ incident.resolvedAt | date:'medium' }}</span>
}
@if (incident.duration) {
<span>Duration: {{ incident.duration }}</span>
}
</div>
</div>
</div>
</div>
} @empty {
<div class="p-8 text-center text-gray-500">
@if (loading()) {
Loading incidents...
} @else {
No incidents found for the selected time range
}
</div>
}
</div>
@if (incident.state === 'resolved') {
<span class="resolved-badge">Resolved</span>
}
@if (incident.resolvedAt) {
<span class="duration-info">Duration: {{ incident.duration ?? 'N/A' }}</span>
}
}
</ng-template>
</app-timeline-list>
</section>
</div>
`,
styles: [`
.incident-timeline {
padding: 1.5rem;
min-height: 100vh;
background: var(--color-surface-primary);
display: grid;
gap: 1.25rem;
}
/* Header */
.page-header { display: grid; gap: 0.5rem; }
.breadcrumb { font-size: 0.8125rem; color: var(--color-text-secondary); display: flex; gap: 0.35rem; align-items: center; }
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
h1 { margin: 0; font-size: 1.375rem; font-weight: var(--font-weight-semibold); color: var(--color-text-heading); }
.subtitle { margin: 0.125rem 0 0; color: var(--color-text-secondary); font-size: 0.8125rem; }
.header-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.header-actions { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
.header-actions select, .btn-secondary {
padding: 0.375rem 0.75rem; font-size: 0.8125rem;
border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
background: var(--color-surface-primary); color: var(--color-text-primary); cursor: pointer;
}
.btn-secondary:hover { background: var(--color-surface-secondary); }
.checkbox-label { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; color: var(--color-text-secondary); }
/* Summary cards */
.summary-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; }
.summary-card {
border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg);
background: var(--color-surface-primary); padding: 0.75rem 1rem;
}
.summary-label { font-size: 0.75rem; color: var(--color-text-secondary); }
.summary-value { margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); }
.summary-value--error { color: var(--color-status-error-text); }
.summary-value--success { color: var(--color-status-success-text); }
/* Filters */
.filter-section {
display: flex; align-items: center; gap: 0.75rem;
border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg);
background: var(--color-surface-primary); padding: 0.75rem 1rem;
}
.filter-section select {
padding: 0.375rem 0.75rem; font-size: 0.8125rem;
border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
background: var(--color-surface-primary);
}
.search-input {
flex: 1; padding: 0.375rem 0.75rem; font-size: 0.8125rem;
border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
background: var(--color-surface-primary);
}
/* Timeline section */
.timeline-section {
border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg);
background: var(--color-surface-primary); padding: 1rem;
}
/* Domain-specific chips inside content projection */
.affected-services { display: flex; align-items: center; gap: 0.25rem; margin-top: 0.25rem; flex-wrap: wrap; }
.affected-label { font-size: 0.6875rem; color: var(--color-text-secondary); }
.service-chip {
padding: 0.0625rem 0.375rem; font-size: 0.6875rem;
background: var(--color-surface-secondary); border-radius: var(--radius-sm);
color: var(--color-text-primary);
}
.resolved-badge {
display: inline-block; margin-top: 0.25rem;
padding: 0.0625rem 0.375rem; font-size: 0.6875rem;
background: var(--color-status-success-bg); color: var(--color-status-success-text);
border-radius: var(--radius-sm);
}
.duration-info {
display: inline-block; margin-top: 0.25rem; margin-left: 0.5rem;
font-size: 0.6875rem; color: var(--color-text-secondary);
}
@media (max-width: 768px) {
.summary-cards { grid-template-columns: repeat(2, 1fr); }
.header-row { flex-direction: column; }
.filter-section { flex-wrap: wrap; }
}
`]
})
@@ -222,10 +257,7 @@ export class IncidentTimelineComponent implements OnInit {
stateFilter = signal<'all' | 'active' | 'resolved'>('all');
searchQuery = signal('');
// Expose constants
readonly INCIDENT_SEVERITY_COLORS = INCIDENT_SEVERITY_COLORS;
// Computed
// Computed counts
activeCount = computed(() => this.incidents().filter((i) => i.state === 'active').length);
resolvedCount = computed(() => this.incidents().filter((i) => i.state === 'resolved').length);
criticalCount = computed(() => this.incidents().filter((i) => i.severity === 'critical').length);
@@ -247,6 +279,26 @@ export class IncidentTimelineComponent implements OnInit {
});
});
/** Map filtered incidents to canonical TimelineEvent[]. */
readonly timelineEvents = computed<TimelineEvent[]>(() => {
return this.filteredIncidents().map((incident) => ({
id: incident.id,
timestamp: incident.startedAt,
title: `[${incident.severity.toUpperCase()}] ${incident.title}`,
description: incident.description,
actor: undefined,
eventKind: mapSeverityToKind(incident.severity, incident.state),
icon: mapSeverityToIcon(incident.severity, incident.state),
metadata: incident.duration ? { duration: incident.duration } : undefined,
expandable: buildExpandablePayload(incident),
}));
});
/** Lookup incident by event ID for content projection. */
getIncidentForEvent(eventId: string): Incident | undefined {
return this.filteredIncidents().find((i) => i.id === eventId);
}
ngOnInit(): void {
this.loadIncidents();
}

View File

@@ -269,6 +269,19 @@
</article>
</section>
<!-- Derived proof-inspection sections (SPRINT-031 FE-WVD-003) -->
<section class="proof-inspection-grid" data-testid="proof-inspection-sections">
@if (verificationSummary(); as summary) {
<app-verification-summary [data]="summary" />
}
<app-signature-inspector [signatures]="signatureDataList()" />
@if (evidencePayload(); as payload) {
<app-evidence-payload [data]="payload" />
}
</section>
<app-poe-drawer
[open]="showPoe() && !!proofArtifact()"
[poeArtifact]="proofArtifact()"

View File

@@ -292,6 +292,11 @@ h2 {
}
}
.proof-inspection-grid {
display: grid;
gap: 0.85rem;
}
@media (max-width: 640px) {
.path-row {
grid-template-columns: 1fr;

View File

@@ -27,6 +27,17 @@ import {
findWitnessFixture,
} from './reachability-fixtures';
import {
VerificationSummaryComponent,
SignatureInspectorComponent,
EvidencePayloadComponent,
} from '../../shared/ui/witness/index';
import type {
VerificationSummaryData,
SignatureData,
EvidencePayloadData,
} from '../../shared/ui/witness/index';
interface WitnessPathRow {
readonly id: string;
readonly symbol: string;
@@ -40,7 +51,13 @@ type MessageType = 'success' | 'error';
@Component({
selector: 'app-witness-page',
standalone: true,
imports: [CommonModule, PoEDrawerComponent],
imports: [
CommonModule,
PoEDrawerComponent,
VerificationSummaryComponent,
SignatureInspectorComponent,
EvidencePayloadComponent,
],
templateUrl: './witness-page.component.html',
styleUrls: ['./witness-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -156,6 +173,58 @@ export class WitnessPageComponent {
);
});
// ---------------------------------------------------------------------------
// Derived proof-inspection section data (SPRINT-031 FE-WVD-003)
// Maps Reachability domain data to the shared witness section inputs.
// ---------------------------------------------------------------------------
readonly verificationSummary = computed((): VerificationSummaryData | null => {
const w = this.witness();
if (!w) return null;
return {
id: w.witnessId,
typeLabel: 'Witness',
typeBadge: 'witness',
status: w.signature?.verified ? 'verified'
: w.signature ? 'unverified'
: 'pending',
confidenceTier: w.confidenceTier,
confidenceScore: w.confidenceScore,
createdAt: w.observedAt,
source: w.evidence.analysisMethod,
};
});
readonly signatureDataList = computed((): readonly SignatureData[] => {
const w = this.witness();
if (!w?.signature) return [];
return [{
id: w.signature.keyId,
algorithm: w.signature.algorithm,
keyId: w.signature.keyId,
value: w.signature.signature,
timestamp: w.signature.verifiedAt,
verified: w.signature.verified ?? false,
}];
});
readonly evidencePayload = computed((): EvidencePayloadData | null => {
const w = this.witness();
if (!w) return null;
return {
evidenceId: w.witnessId,
rawContent: JSON.stringify(w, null, 2),
metadata: {
scanId: w.scanId,
vulnId: w.vulnId,
confidenceTier: w.confidenceTier,
isReachable: w.isReachable,
pathHash: w.pathHash ?? 'n/a',
analysisMethod: w.evidence.analysisMethod,
},
};
});
constructor() {
combineLatest([this.route.paramMap, this.route.queryParamMap])
.pipe(takeUntilDestroyed(this.destroyRef))

View File

@@ -1,5 +1,6 @@
// Registry Admin Component
// Sprint 023: Registry Admin UI
// Sprint 027: Adopted canonical ContextHeaderComponent
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core';
@@ -11,38 +12,37 @@ import {
RegistryAdminHttpService,
} from '../../core/api/registry-admin.client';
import { PlanRuleDto } from '../../core/api/registry-admin.models';
import { ContextHeaderComponent } from '../../shared/ui/context-header/context-header.component';
type TabType = 'plans' | 'audit';
@Component({
selector: 'app-registry-admin',
imports: [RouterModule],
imports: [RouterModule, ContextHeaderComponent],
providers: [
{ provide: REGISTRY_ADMIN_API, useClass: RegistryAdminHttpService },
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="registry-admin">
<header class="registry-admin__header">
<div class="registry-admin__title-row">
<div>
<h1 class="registry-admin__title">Registry Token Service</h1>
<p class="registry-admin__subtitle">
Manage access plans, repository scopes, and allowlists
</p>
<app-context-header
eyebrow="Setup / Integrations"
title="Registry Token Service"
subtitle="Manage access plans, repository scopes, and allowlists"
[chips]="headerChips()"
testId="registry-admin-header"
>
<div header-actions class="registry-admin__stats">
<div class="stat-card">
<span class="stat-value">{{ totalPlans() }}</span>
<span class="stat-label">Plans</span>
</div>
<div class="registry-admin__stats">
<div class="stat-card">
<span class="stat-value">{{ totalPlans() }}</span>
<span class="stat-label">Plans</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ enabledPlans() }}</span>
<span class="stat-label">Enabled</span>
</div>
<div class="stat-card">
<span class="stat-value">{{ enabledPlans() }}</span>
<span class="stat-label">Enabled</span>
</div>
</div>
</header>
</app-context-header>
<nav class="registry-admin__tabs" role="tablist">
<a
@@ -90,30 +90,6 @@ type TabType = 'plans' | 'audit';
padding: 1.5rem;
}
.registry-admin__header {
margin-bottom: 1.5rem;
}
.registry-admin__title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
}
.registry-admin__title {
font-size: 1.5rem;
font-weight: var(--font-weight-semibold);
margin: 0 0 0.25rem;
color: var(--color-surface-secondary);
}
.registry-admin__subtitle {
font-size: 0.875rem;
color: var(--color-text-muted);
margin: 0;
}
.registry-admin__stats {
display: flex;
gap: 1rem;
@@ -199,6 +175,14 @@ export class RegistryAdminComponent implements OnInit {
readonly totalPlans = computed(() => this.plans().length);
readonly enabledPlans = computed(() => this.plans().filter((p) => p.enabled).length);
readonly headerChips = computed(() => {
const total = this.totalPlans();
const enabled = this.enabledPlans();
if (!total) {
return [];
}
return [`${enabled}/${total} enabled`];
});
ngOnInit(): void {
this.loadDashboard();

View File

@@ -1,82 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-release-control-governance-hub',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="governance-hub">
<header class="header">
<h1>Governance</h1>
<p>Policy and exception controls anchored under Release Control.</p>
</header>
<div class="cards">
<a routerLink="/ops/policy/baselines" class="card">
<h2>Policy Baselines</h2>
<p>Environment-scoped baseline definitions and lock rules.</p>
</a>
<a routerLink="/ops/policy/gates" class="card">
<h2>Governance Rules</h2>
<p>Rule catalog for release control gate enforcement.</p>
</a>
<a routerLink="/ops/policy/simulation" class="card">
<h2>Policy Simulation</h2>
<p>Dry-run policy evaluations before production rollout.</p>
</a>
<a routerLink="/ops/policy/waivers" class="card">
<h2>Exception Workflow</h2>
<p>Exception requests, approvals, and expiry management.</p>
</a>
</div>
</section>
`,
styles: [
`
.governance-hub {
display: grid;
gap: 0.8rem;
}
.header h1 {
margin: 0 0 0.2rem;
font-size: 1.4rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.84rem;
}
.cards {
display: grid;
gap: 0.7rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.72rem 0.8rem;
text-decoration: none;
color: inherit;
}
.card h2 {
margin: 0 0 0.25rem;
font-size: 0.95rem;
}
.card p {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
`,
],
})
export class ReleaseControlGovernanceHubComponent {}

View File

@@ -1,59 +0,0 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
@Component({
selector: 'app-release-control-governance-section',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="governance-section">
<header>
<h1>{{ sectionTitle() }}</h1>
<p>This governance area is scaffolded and ready for backend contract binding.</p>
</header>
<p class="note">Canonical location: Release Control &gt; Governance.</p>
<a routerLink="/ops/policy">Back to Governance Hub</a>
</section>
`,
styles: [
`
.governance-section {
display: grid;
gap: 0.7rem;
}
h1 {
margin: 0 0 0.2rem;
font-size: 1.35rem;
}
p {
margin: 0;
font-size: 0.82rem;
color: var(--color-text-secondary);
}
.note {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.55rem 0.65rem;
}
a {
color: var(--color-brand-primary);
text-decoration: none;
font-size: 0.82rem;
}
`,
],
})
export class ReleaseControlGovernanceSectionComponent {
private readonly route = inject(ActivatedRoute);
readonly sectionTitle = signal(
(this.route.snapshot.data['sectionTitle'] as string | undefined) ?? 'Governance'
);
}

View File

@@ -1,49 +0,0 @@
import { Routes } from '@angular/router';
export const RELEASE_CONTROL_GOVERNANCE_ROUTES: Routes = [
{
path: '',
title: 'Governance',
data: { breadcrumb: 'Governance' },
loadComponent: () =>
import('./release-control-governance-hub.component').then(
(m) => m.ReleaseControlGovernanceHubComponent
),
},
{
path: 'baselines',
title: 'Policy Baselines',
data: { breadcrumb: 'Policy Baselines', sectionTitle: 'Policy Baselines' },
loadComponent: () =>
import('./release-control-governance-section.component').then(
(m) => m.ReleaseControlGovernanceSectionComponent
),
},
{
path: 'rules',
title: 'Governance Rules',
data: { breadcrumb: 'Governance Rules', sectionTitle: 'Governance Rules' },
loadComponent: () =>
import('./release-control-governance-section.component').then(
(m) => m.ReleaseControlGovernanceSectionComponent
),
},
{
path: 'simulation',
title: 'Policy Simulation',
data: { breadcrumb: 'Policy Simulation', sectionTitle: 'Policy Simulation' },
loadComponent: () =>
import('./release-control-governance-section.component').then(
(m) => m.ReleaseControlGovernanceSectionComponent
),
},
{
path: 'exceptions',
title: 'Exception Workflow',
data: { breadcrumb: 'Exception Workflow', sectionTitle: 'Exception Workflow' },
loadComponent: () =>
import('./release-control-governance-section.component').then(
(m) => m.ReleaseControlGovernanceSectionComponent
),
},
];

View File

@@ -1,129 +0,0 @@
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
interface EnvironmentNode {
id: string;
stage: string;
status: string;
}
@Component({
selector: 'app-region-detail',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="region-detail">
<header class="header">
<h1>{{ regionLabel() }} Region</h1>
<p>Pipeline posture by environment with promotion flow context.</p>
</header>
<section class="summary">
<article>
<span>Total environments</span>
<strong>{{ environments.length }}</strong>
</article>
<article>
<span>Overall health</span>
<strong>DEGRADED</strong>
</article>
<article>
<span>SBOM posture</span>
<strong>WARN</strong>
</article>
</section>
<section class="pipeline">
@for (env of environments; track env.id) {
<a class="pipeline-node" [routerLink]="['/releases/environments', regionLabel(), 'environments', env.id]">
<h2>{{ env.id }}</h2>
<p>{{ env.stage }}</p>
<p>Status: {{ env.status }}</p>
</a>
}
</section>
</section>
`,
styles: [
`
.region-detail {
display: grid;
gap: 0.9rem;
}
.header h1 {
margin: 0 0 0.2rem;
font-size: 1.42rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.84rem;
}
.summary {
display: grid;
gap: 0.65rem;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
}
.summary article {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.6rem 0.7rem;
}
.summary span {
display: block;
font-size: 0.72rem;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.summary strong {
font-size: 1.08rem;
}
.pipeline {
display: grid;
gap: 0.7rem;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
}
.pipeline-node {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.65rem 0.75rem;
text-decoration: none;
color: inherit;
}
.pipeline-node h2 {
margin: 0 0 0.2rem;
font-size: 0.98rem;
}
.pipeline-node p {
margin: 0.14rem 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
}
`,
],
})
export class RegionDetailComponent {
private readonly route = inject(ActivatedRoute);
readonly regionLabel = signal(this.route.snapshot.paramMap.get('region') ?? 'global');
readonly environments: EnvironmentNode[] = [
{ id: 'dev', stage: 'Development', status: 'HEALTHY' },
{ id: 'stage', stage: 'Staging', status: 'HEALTHY' },
{ id: 'prod', stage: 'Production', status: 'DEGRADED' },
];
}

View File

@@ -1,99 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
interface RegionCard {
id: string;
name: string;
envCount: number;
health: string;
sbomPosture: string;
}
@Component({
selector: 'app-regions-overview',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="regions-overview">
<header class="header">
<h1>Regions & Environments</h1>
<p>Region-first release control posture with environment health and SBOM coverage context.</p>
</header>
<div class="cards">
@for (region of regions; track region.id) {
<a class="card" [routerLink]="['/releases/environments', region.id]">
<h2>{{ region.name }}</h2>
<p>Environments: {{ region.envCount }}</p>
<p>Health: {{ region.health }}</p>
<p>SBOM posture: {{ region.sbomPosture }}</p>
</a>
}
</div>
</section>
`,
styles: [
`
.regions-overview {
display: grid;
gap: 0.9rem;
}
.header h1 {
margin: 0 0 0.2rem;
font-size: 1.45rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.84rem;
}
.cards {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.75rem 0.85rem;
text-decoration: none;
color: inherit;
}
.card h2 {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.card p {
margin: 0.15rem 0;
font-size: 0.82rem;
color: var(--color-text-secondary);
}
`,
],
})
export class RegionsOverviewComponent {
readonly regions: RegionCard[] = [
{
id: 'global',
name: 'Global',
envCount: 4,
health: 'DEGRADED',
sbomPosture: 'WARN',
},
{
id: 'eu-west',
name: 'EU West',
envCount: 3,
health: 'HEALTHY',
sbomPosture: 'OK',
},
];
}

View File

@@ -1,168 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
interface SetupArea {
title: string;
description: string;
route: string;
}
@Component({
selector: 'app-release-control-setup-home',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="setup-home">
<header class="header">
<h1>Release Control Setup</h1>
<p>
Canonical setup hub for environments, promotion paths, targets, agents, workflows, and
bundle templates.
</p>
</header>
<p class="state-banner">
Read-only structural mode: setup contracts are shown with deterministic placeholders until
backend setup APIs are wired.
</p>
<section class="areas" aria-label="Setup areas">
@for (area of areas; track area.route) {
<a class="card" [routerLink]="area.route">
<h2>{{ area.title }}</h2>
<p>{{ area.description }}</p>
</a>
}
</section>
<section class="legacy-map" aria-label="Legacy setup aliases">
<h2>Legacy Setup Aliases</h2>
<ul>
<li><code>/settings/release-control</code> redirects to <code>/release-control/setup</code></li>
<li>
<code>/settings/release-control/environments</code> redirects to
<code>/release-control/setup/environments-paths</code>
</li>
<li>
<code>/settings/release-control/targets</code> and <code>/settings/release-control/agents</code>
redirect to <code>/release-control/setup/targets-agents</code>
</li>
<li>
<code>/settings/release-control/workflows</code> redirects to
<code>/release-control/setup/workflows</code>
</li>
</ul>
</section>
</section>
`,
styles: [`
.setup-home {
padding: 1.5rem;
max-width: 1100px;
margin: 0 auto;
display: grid;
gap: 1rem;
}
.header h1 {
margin: 0 0 0.25rem;
font-size: 1.6rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary, #667085);
font-size: 0.92rem;
}
.state-banner {
margin: 0;
border: 1px solid var(--color-status-warning-border, #facc15);
background: var(--color-status-warning-bg, #fffbeb);
color: var(--color-status-warning-text, #854d0e);
border-radius: var(--radius-md, 8px);
padding: 0.7rem 0.85rem;
font-size: 0.86rem;
}
.areas {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 0.8rem;
}
.card {
display: block;
border: 1px solid var(--color-border-primary, #e4e7ec);
background: var(--color-surface-primary, #fff);
border-radius: var(--radius-md, 8px);
padding: 0.9rem;
text-decoration: none;
color: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.card:hover {
border-color: var(--color-brand-primary, #2563eb);
box-shadow: 0 3px 10px rgba(15, 23, 42, 0.08);
}
.card h2 {
margin: 0 0 0.25rem;
font-size: 1rem;
}
.card p {
margin: 0;
color: var(--color-text-secondary, #667085);
font-size: 0.84rem;
line-height: 1.4;
}
.legacy-map {
border: 1px solid var(--color-border-primary, #e4e7ec);
border-radius: var(--radius-md, 8px);
padding: 0.9rem;
background: var(--color-surface-primary, #fff);
}
.legacy-map h2 {
margin: 0 0 0.6rem;
font-size: 0.95rem;
}
.legacy-map ul {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.45rem;
color: var(--color-text-secondary, #667085);
font-size: 0.83rem;
}
`],
})
export class ReleaseControlSetupHomeComponent {
readonly areas: SetupArea[] = [
{
title: 'Environments and Promotion Paths',
description: 'Define environment hierarchy and promotion routes (Dev -> Stage -> Prod).',
route: '/release-control/setup/environments-paths',
},
{
title: 'Targets and Agents',
description: 'Track runtime targets and execution agents used by release deployments.',
route: '/release-control/setup/targets-agents',
},
{
title: 'Workflows',
description: 'Review workflow templates and promotion execution steps before activation.',
route: '/release-control/setup/workflows',
},
{
title: 'Bundle Templates',
description: 'Manage default bundle composition templates and validation requirements.',
route: '/release-control/setup/bundle-templates',
},
];
}

View File

@@ -1,133 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-setup-bundle-templates',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="page">
<header class="header">
<a routerLink="/ops/platform-setup" class="back-link">Back to Setup</a>
<h1>Bundle Templates</h1>
<p>Template presets for bundle composition, validation gates, and release metadata policy.</p>
</header>
<section class="panel">
<h2>Template Catalog</h2>
<table aria-label="Bundle templates">
<thead>
<tr>
<th>Template</th>
<th>Required Sections</th>
<th>Validation Profile</th>
<th>Default Use</th>
</tr>
</thead>
<tbody>
<tr><td>service-platform</td><td>digest, config, changelog, evidence</td><td>strict</td><td>platform releases</td></tr>
<tr><td>edge-hotfix</td><td>digest, changelog, evidence</td><td>fast-track</td><td>hotfix bundle</td></tr>
<tr><td>regional-rollout</td><td>digest, config, promotion path, evidence</td><td>risk-aware</td><td>multi-region rollout</td></tr>
</tbody>
</table>
</section>
<section class="panel">
<h2>Template Rules</h2>
<ul>
<li>Template controls required builder sections before bundle version materialization.</li>
<li>Validation profile maps to policy and advisory confidence requirements.</li>
<li>Template changes apply only to newly created bundle versions (immutability preserved).</li>
</ul>
</section>
<section class="panel links">
<h2>Related Surfaces</h2>
<a routerLink="/releases/bundles/create">Open Bundle Builder</a>
<a routerLink="/releases/bundles">Open Bundle Catalog</a>
</section>
</section>
`,
styles: [`
.page {
padding: 1.5rem;
max-width: 980px;
margin: 0 auto;
display: grid;
gap: 1rem;
}
.back-link {
font-size: 0.84rem;
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
}
.header h1 {
margin: 0.25rem 0 0.2rem;
font-size: 1.4rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary, #667085);
}
.panel {
border: 1px solid var(--color-border-primary, #e4e7ec);
border-radius: var(--radius-md, 8px);
padding: 0.85rem;
background: var(--color-surface-primary, #fff);
}
.panel h2 {
margin: 0 0 0.6rem;
font-size: 0.96rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
}
th, td {
text-align: left;
padding: 0.45rem 0.35rem;
border-top: 1px solid var(--color-border-primary, #e4e7ec);
}
th {
border-top: 0;
font-size: 0.73rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
ul {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.45rem;
color: var(--color-text-secondary, #667085);
font-size: 0.85rem;
}
.links {
display: grid;
gap: 0.5rem;
}
.links a {
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
font-size: 0.86rem;
}
`],
})
export class SetupBundleTemplatesComponent {}

View File

@@ -1,133 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-setup-environments-paths',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="page">
<header class="header">
<a routerLink="/ops/platform-setup" class="back-link">Back to Setup</a>
<h1>Environments and Promotion Paths</h1>
<p>Release Control-owned environment graph and allowed promotion flows.</p>
</header>
<section class="panel">
<h2>Environment Inventory</h2>
<table aria-label="Environment inventory">
<thead>
<tr>
<th>Environment</th>
<th>Region</th>
<th>Risk Tier</th>
<th>Promotion Entry</th>
</tr>
</thead>
<tbody>
<tr><td>dev-us-east</td><td>us-east</td><td>low</td><td>yes</td></tr>
<tr><td>stage-eu-west</td><td>eu-west</td><td>medium</td><td>yes</td></tr>
<tr><td>prod-eu-west</td><td>eu-west</td><td>high</td><td>yes</td></tr>
</tbody>
</table>
</section>
<section class="panel">
<h2>Promotion Path Rules</h2>
<ul>
<li><code>dev-*</code> can promote to <code>stage-*</code> with approval gates.</li>
<li><code>stage-*</code> can promote to <code>prod-*</code> only with policy + ops gate pass.</li>
<li>Cross-region promotion requires an explicit path definition and target parity checks.</li>
</ul>
</section>
<section class="panel links">
<h2>Related Surfaces</h2>
<a routerLink="/releases/environments">Open Regions and Environments</a>
<a routerLink="/releases/approvals">Open Promotions</a>
</section>
</section>
`,
styles: [`
.page {
padding: 1.5rem;
max-width: 980px;
margin: 0 auto;
display: grid;
gap: 1rem;
}
.back-link {
font-size: 0.84rem;
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
}
.header h1 {
margin: 0.25rem 0 0.2rem;
font-size: 1.4rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary, #667085);
}
.panel {
border: 1px solid var(--color-border-primary, #e4e7ec);
border-radius: var(--radius-md, 8px);
padding: 0.85rem;
background: var(--color-surface-primary, #fff);
}
.panel h2 {
margin: 0 0 0.6rem;
font-size: 0.96rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
}
th, td {
text-align: left;
padding: 0.45rem 0.35rem;
border-top: 1px solid var(--color-border-primary, #e4e7ec);
}
th {
border-top: 0;
font-size: 0.73rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
ul {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.45rem;
color: var(--color-text-secondary, #667085);
font-size: 0.85rem;
}
.links {
display: grid;
gap: 0.5rem;
}
.links a {
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
font-size: 0.86rem;
}
`],
})
export class SetupEnvironmentsPathsComponent {}

View File

@@ -1,136 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-setup-targets-agents',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="page">
<header class="header">
<a routerLink="/ops/platform-setup" class="back-link">Back to Setup</a>
<h1>Targets and Agents</h1>
<p>Release Control deployment execution topology with ownership split to Integrations.</p>
</header>
<section class="panel">
<h2>Deployment Targets</h2>
<table aria-label="Deployment targets">
<thead>
<tr>
<th>Target</th>
<th>Runtime</th>
<th>Region</th>
<th>Agent Group</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr><td>edge-gateway-prod</td><td>vm</td><td>eu-west</td><td>agent-eu</td><td>ready</td></tr>
<tr><td>payments-core-stage</td><td>nomad</td><td>us-east</td><td>agent-us</td><td>ready</td></tr>
<tr><td>billing-svc-prod</td><td>ecs</td><td>eu-west</td><td>agent-eu</td><td>degraded</td></tr>
</tbody>
</table>
</section>
<section class="panel">
<h2>Agent Coverage</h2>
<ul>
<li><strong>agent-eu</strong>: 42 targets, heartbeat every 20s, upgrade window Fri 23:00 UTC.</li>
<li><strong>agent-us</strong>: 35 targets, heartbeat every 20s, upgrade window Sat 01:00 UTC.</li>
<li><strong>agent-apac</strong>: 18 targets, on-call watch enabled, runtime drift checks active.</li>
</ul>
</section>
<section class="panel links">
<h2>Ownership Links</h2>
<a routerLink="/integrations/hosts">
Connector connectivity and credentials are managed in Integrations > Targets / Runtimes
</a>
<a routerLink="/platform-ops/agents">Operational status and diagnostics are managed in Platform Ops > Agents</a>
</section>
</section>
`,
styles: [`
.page {
padding: 1.5rem;
max-width: 980px;
margin: 0 auto;
display: grid;
gap: 1rem;
}
.back-link {
font-size: 0.84rem;
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
}
.header h1 {
margin: 0.25rem 0 0.2rem;
font-size: 1.4rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary, #667085);
}
.panel {
border: 1px solid var(--color-border-primary, #e4e7ec);
border-radius: var(--radius-md, 8px);
padding: 0.85rem;
background: var(--color-surface-primary, #fff);
}
.panel h2 {
margin: 0 0 0.6rem;
font-size: 0.96rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
}
th, td {
text-align: left;
padding: 0.45rem 0.35rem;
border-top: 1px solid var(--color-border-primary, #e4e7ec);
}
th {
border-top: 0;
font-size: 0.73rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
ul {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.45rem;
color: var(--color-text-secondary, #667085);
font-size: 0.85rem;
}
.links {
display: grid;
gap: 0.5rem;
}
.links a {
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
font-size: 0.86rem;
}
`],
})
export class SetupTargetsAgentsComponent {}

View File

@@ -1,133 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-setup-workflows',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="page">
<header class="header">
<a routerLink="/ops/platform-setup" class="back-link">Back to Setup</a>
<h1>Workflows</h1>
<p>Release Control workflow definitions for promotion orchestration and approval sequencing.</p>
</header>
<section class="panel">
<h2>Workflow Catalog</h2>
<table aria-label="Workflow catalog">
<thead>
<tr>
<th>Workflow</th>
<th>Path</th>
<th>Gate Profile</th>
<th>Rollback</th>
</tr>
</thead>
<tbody>
<tr><td>standard-blue-green</td><td>dev -> stage -> prod</td><td>strict-prod</td><td>auto</td></tr>
<tr><td>canary-regional</td><td>stage -> prod-canary -> prod</td><td>risk-aware</td><td>manual</td></tr>
<tr><td>hotfix-fast-track</td><td>stage -> prod</td><td>expedited</td><td>manual</td></tr>
</tbody>
</table>
</section>
<section class="panel">
<h2>Execution Constraints</h2>
<ul>
<li>All workflows require a bundle version digest and resolved inputs before promotion launch.</li>
<li>Approval checkpoints inherit policy gates from Administration policy governance baseline.</li>
<li>Run timeline evidence checkpoints are mandatory for promotion completion.</li>
</ul>
</section>
<section class="panel links">
<h2>Related Surfaces</h2>
<a routerLink="/administration/workflows">Open legacy workflow editor surface</a>
<a routerLink="/releases/runs">Open Run Timeline</a>
</section>
</section>
`,
styles: [`
.page {
padding: 1.5rem;
max-width: 980px;
margin: 0 auto;
display: grid;
gap: 1rem;
}
.back-link {
font-size: 0.84rem;
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
}
.header h1 {
margin: 0.25rem 0 0.2rem;
font-size: 1.4rem;
}
.header p {
margin: 0;
color: var(--color-text-secondary, #667085);
}
.panel {
border: 1px solid var(--color-border-primary, #e4e7ec);
border-radius: var(--radius-md, 8px);
padding: 0.85rem;
background: var(--color-surface-primary, #fff);
}
.panel h2 {
margin: 0 0 0.6rem;
font-size: 0.96rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.84rem;
}
th, td {
text-align: left;
padding: 0.45rem 0.35rem;
border-top: 1px solid var(--color-border-primary, #e4e7ec);
}
th {
border-top: 0;
font-size: 0.73rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary, #667085);
}
ul {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.45rem;
color: var(--color-text-secondary, #667085);
font-size: 0.85rem;
}
.links {
display: grid;
gap: 0.5rem;
}
.links a {
color: var(--color-brand-primary, #2563eb);
text-decoration: none;
font-size: 0.86rem;
}
`],
})
export class SetupWorkflowsComponent {}

View File

@@ -1,10 +1,11 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { take } from 'rxjs';
import { PlatformContextStore } from '../../core/context/platform-context.store';
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component';
interface ReleaseActivityProjection {
activityId: string;
@@ -24,10 +25,28 @@ interface PlatformListResponse<T> {
count: number;
}
function deriveOutcomeKind(status: string): TimelineEventKind {
const lower = status.toLowerCase();
if (lower.includes('published') || lower.includes('approved') || lower.includes('deployed')) return 'success';
if (lower.includes('blocked') || lower.includes('rejected') || lower.includes('failed')) return 'error';
if (lower.includes('pending_approval')) return 'warning';
return 'info';
}
function deriveOutcomeIcon(status: string): string {
const lower = status.toLowerCase();
if (lower.includes('published') || lower.includes('deployed')) return 'rocket_launch';
if (lower.includes('approved')) return 'check_circle';
if (lower.includes('blocked') || lower.includes('rejected')) return 'block';
if (lower.includes('failed')) return 'error';
if (lower.includes('pending_approval')) return 'pending';
return 'play_circle';
}
@Component({
selector: 'app-releases-activity',
standalone: true,
imports: [RouterLink, FormsModule],
imports: [RouterLink, FormsModule, TimelineListComponent],
template: `
<section class="activity">
<header>
@@ -97,49 +116,83 @@ interface PlatformListResponse<T> {
@if (loading()) {
<div class="banner">Loading release runs...</div>
} @else {
@if (viewMode() === 'correlations') {
<div class="clusters">
@for (cluster of correlationClusters(); track cluster.key) {
<article>
<h3>{{ cluster.key }}</h3>
<p>{{ cluster.count }} events · {{ cluster.releases }} release version(s)</p>
<p>{{ cluster.environments }}</p>
</article>
} @empty {
<div class="banner">No run correlations match the current filters.</div>
}
</div>
} @else {
<table>
<thead>
<tr>
<th>Run</th>
<th>Release Version</th>
<th>Lane</th>
<th>Outcome</th>
<th>Environment</th>
<th>Needs Approval</th>
<th>Data Integrity</th>
<th>When</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows(); track row.activityId) {
<tr>
<td><a [routerLink]="['/releases/runs', row.releaseId, 'summary']">{{ row.activityId }}</a></td>
<td>{{ row.releaseName }}</td>
<td>{{ deriveLane(row) }}</td>
<td>{{ deriveOutcome(row) }}</td>
<td>{{ row.targetRegion || '-' }}/{{ row.targetEnvironment || '-' }}</td>
<td>{{ deriveNeedsApproval(row) ? 'yes' : 'no' }}</td>
<td>{{ deriveDataIntegrity(row) }}</td>
<td>{{ formatDate(row.occurredAt) }}</td>
</tr>
@switch (viewMode()) {
@case ('timeline') {
<!-- Canonical timeline rendering -->
<div class="timeline-container">
<app-timeline-list
[events]="timelineEvents()"
[loading]="loading()"
[groupByDate]="true"
emptyMessage="No runs match the active filters."
ariaLabel="Release activity timeline"
>
<ng-template #eventContent let-event>
@if (event.metadata) {
<div class="run-meta">
@if (event.metadata['lane']) {
<span class="run-chip">{{ event.metadata['lane'] }}</span>
}
@if (event.metadata['environment']) {
<span class="run-chip">{{ event.metadata['environment'] }}</span>
}
@if (event.metadata['outcome']) {
<span class="run-chip run-chip--outcome" [attr.data-outcome]="event.metadata['outcome']">{{ event.metadata['outcome'] }}</span>
}
@if (event.evidenceLink) {
<a class="run-link" [routerLink]="event.evidenceLink">View run</a>
}
</div>
}
</ng-template>
</app-timeline-list>
</div>
}
@case ('correlations') {
<div class="clusters">
@for (cluster of correlationClusters(); track cluster.key) {
<article>
<h3>{{ cluster.key }}</h3>
<p>{{ cluster.count }} events · {{ cluster.releases }} release version(s)</p>
<p>{{ cluster.environments }}</p>
</article>
} @empty {
<tr><td colspan="8">No runs match the active filters.</td></tr>
<div class="banner">No run correlations match the current filters.</div>
}
</tbody>
</table>
</div>
}
@default {
<table>
<thead>
<tr>
<th>Run</th>
<th>Release Version</th>
<th>Lane</th>
<th>Outcome</th>
<th>Environment</th>
<th>Needs Approval</th>
<th>Data Integrity</th>
<th>When</th>
</tr>
</thead>
<tbody>
@for (row of filteredRows(); track row.activityId) {
<tr>
<td><a [routerLink]="['/releases/runs', row.releaseId, 'summary']">{{ row.activityId }}</a></td>
<td>{{ row.releaseName }}</td>
<td>{{ deriveLane(row) }}</td>
<td>{{ deriveOutcome(row) }}</td>
<td>{{ row.targetRegion || '-' }}/{{ row.targetEnvironment || '-' }}</td>
<td>{{ deriveNeedsApproval(row) ? 'yes' : 'no' }}</td>
<td>{{ deriveDataIntegrity(row) }}</td>
<td>{{ formatDate(row.occurredAt) }}</td>
</tr>
} @empty {
<tr><td colspan="8">No runs match the active filters.</td></tr>
}
</tbody>
</table>
}
}
}
</section>
@@ -155,6 +208,18 @@ interface PlatformListResponse<T> {
.banner{padding:.7rem;font-size:.8rem;color:var(--color-text-secondary)} .banner.error{color:var(--color-status-error-text)}
table{width:100%;border-collapse:collapse}th,td{border-bottom:1px solid var(--color-border-primary);padding:.4rem .5rem;font-size:.72rem;text-align:left;vertical-align:top}th{font-size:.66rem;color:var(--color-text-secondary);text-transform:uppercase}
tr:last-child td{border-bottom:none}.clusters{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.45rem}.clusters article{padding:.55rem}.clusters h3{margin:0;font-size:.82rem}.clusters p{margin:.2rem 0;color:var(--color-text-secondary);font-size:.74rem}
/* Timeline container */
.timeline-container{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary);padding:.75rem}
/* Domain-specific run metadata */
.run-meta{display:flex;gap:.25rem;margin-top:.25rem;align-items:center;flex-wrap:wrap}
.run-chip{padding:.0625rem .35rem;font-size:.66rem;border-radius:var(--radius-sm);background:var(--color-surface-secondary);color:var(--color-text-secondary)}
.run-chip--outcome[data-outcome="success"]{background:var(--color-status-success-bg);color:var(--color-status-success-text)}
.run-chip--outcome[data-outcome="failed"]{background:var(--color-status-error-bg);color:var(--color-status-error-text)}
.run-chip--outcome[data-outcome="in_progress"]{background:var(--color-status-info-bg);color:var(--color-status-info-text)}
.run-link{font-size:.7rem;color:var(--color-brand-primary);text-decoration:none;margin-left:.25rem}
.run-link:hover{text-decoration:underline}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -202,6 +267,25 @@ export class ReleasesActivityComponent {
return rows;
});
/** Map filtered rows to canonical TimelineEvent[] for the timeline view mode. */
readonly timelineEvents = computed<TimelineEvent[]>(() => {
return this.filteredRows().map((row) => ({
id: row.activityId,
timestamp: row.occurredAt,
title: `${row.releaseName} - ${row.eventType}`,
description: `${row.targetRegion ?? '-'}/${row.targetEnvironment ?? '-'} · ${row.status}`,
actor: row.actorId || undefined,
eventKind: deriveOutcomeKind(row.status),
icon: deriveOutcomeIcon(row.status),
evidenceLink: `/releases/runs/${row.releaseId}/summary`,
metadata: {
lane: this.deriveLane(row),
environment: `${row.targetRegion ?? '-'}/${row.targetEnvironment ?? '-'}`,
outcome: this.deriveOutcome(row),
},
}));
});
readonly correlationClusters = computed(() => {
const map = new Map<string, { key: string; count: number; releaseSet: Set<string>; envSet: Set<string> }>();
for (const row of this.filteredRows()) {

View File

@@ -1,20 +1,16 @@
/**
* Settings Page Component (Shell)
* Sprint: SPRINT_20260118_002_FE_settings_consolidation (SETTINGS-001)
* Settings Page Component (Personal Preferences Shell)
* Sprint: SPRINT_20260308_026_FE_settings_information_architecture_rationalization
*
* Shell page with sidebar navigation for all settings sections.
* The Settings shell now owns only personal user preferences.
* Admin, tenant, and operations configuration have been rehomed
* to their canonical owners (Setup, Administration, Ops).
*/
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterOutlet } from '@angular/router';
/**
* Settings Page Component (Shell)
*
* Navigation is handled by the global sidebar.
* This shell provides the content area for settings sub-routes.
*/
@Component({
selector: 'app-settings-page',
imports: [RouterOutlet],

View File

@@ -1,12 +1,17 @@
/**
* Settings Routes
* Sprint: SPRINT_20260118_002_FE_settings_consolidation
* Settings Routes — Personal Preferences Shell
* Sprint: SPRINT_20260308_026_FE_settings_information_architecture_rationalization
*
* Settings now owns only personal user preferences (appearance, language,
* layout, AI assistant). All admin, tenant, and operations configuration
* leaves have been rehomed to their canonical owners with backward-compatible
* redirects preserved for legacy bookmarks.
*/
import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
function redirectToCanonicalSetup(path: string) {
function redirectToCanonical(path: string) {
return ({
params,
queryParams,
@@ -38,125 +43,15 @@ export const SETTINGS_ROUTES: Routes = [
import('./settings-page.component').then(m => m.SettingsPageComponent),
data: {},
children: [
// -----------------------------------------------------------------------
// Personal preferences (canonical owner: Settings)
// -----------------------------------------------------------------------
{
path: '',
title: 'Integrations',
title: 'User Preferences',
loadComponent: () =>
import('./integrations/integrations-settings-page.component').then(m => m.IntegrationsSettingsPageComponent),
data: { breadcrumb: 'Integrations' },
},
{
path: 'integrations',
title: 'Integrations',
loadComponent: () =>
import('./integrations/integrations-settings-page.component').then(m => m.IntegrationsSettingsPageComponent),
data: { breadcrumb: 'Integrations' },
},
{
path: 'integrations/:id',
title: 'Integration Detail',
loadComponent: () =>
import('./integrations/integration-detail-page.component').then(m => m.IntegrationDetailPageComponent),
data: { breadcrumb: 'Integration Detail' },
},
{
path: 'configuration-pane',
title: 'Configuration Pane',
loadComponent: () =>
import('../configuration-pane/components/configuration-pane.component').then(m => m.ConfigurationPaneComponent),
data: { breadcrumb: 'Configuration Pane' },
},
{
path: 'release-control',
title: 'Release Control',
loadComponent: () =>
import('./release-control/release-control-settings-page.component').then(m => m.ReleaseControlSettingsPageComponent),
data: { breadcrumb: 'Release Control' },
},
{
path: 'trust',
title: 'Trust & Signing',
redirectTo: redirectToCanonicalSetup('/setup/trust-signing'),
pathMatch: 'full' as const,
},
{
path: 'trust/issuers',
title: 'Trust & Signing',
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/issuers'),
pathMatch: 'full' as const,
},
{
path: 'trust/:page/:child',
title: 'Trust & Signing',
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/:page/:child'),
pathMatch: 'full' as const,
},
{
path: 'trust/:page',
title: 'Trust & Signing',
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/:page'),
pathMatch: 'full' as const,
},
{
path: 'trust-signing',
title: 'Trust & Signing',
redirectTo: redirectToCanonicalSetup('/setup/trust-signing'),
pathMatch: 'full' as const,
},
{
path: 'trust-signing/:page',
title: 'Trust & Signing',
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/:page'),
pathMatch: 'full' as const,
},
{
path: 'trust-signing/:page/:child',
title: 'Trust & Signing',
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/:page/:child'),
data: { breadcrumb: 'Trust & Signing' },
pathMatch: 'full' as const,
},
{
path: 'security-data',
title: 'Security Data',
loadComponent: () =>
import('./security-data/security-data-settings-page.component').then(m => m.SecurityDataSettingsPageComponent),
data: { breadcrumb: 'Security Data' },
},
{
path: 'admin',
title: 'Identity & Access',
loadComponent: () =>
import('./admin/admin-settings-page.component').then(m => m.AdminSettingsPageComponent),
data: { breadcrumb: 'Identity & Access' },
},
{
path: 'admin/:page',
title: 'Identity & Access',
loadComponent: () =>
import('./admin/admin-settings-page.component').then(m => m.AdminSettingsPageComponent),
data: { breadcrumb: 'Identity & Access' },
},
{
path: 'branding',
title: 'Tenant & Branding',
loadComponent: () =>
import('./branding/branding-settings-page.component').then(m => m.BrandingSettingsPageComponent),
data: { breadcrumb: 'Tenant & Branding' },
},
{
path: 'usage',
title: 'Usage & Limits',
loadComponent: () =>
import('./usage/usage-settings-page.component').then(m => m.UsageSettingsPageComponent),
data: { breadcrumb: 'Usage & Limits' },
},
{
path: 'notifications',
title: 'Notifications',
loadComponent: () =>
import('./notifications/notifications-settings-page.component').then(m => m.NotificationsSettingsPageComponent),
data: { breadcrumb: 'Notifications' },
import('./user-preferences/user-preferences-page.component').then(m => m.UserPreferencesPageComponent),
data: { breadcrumb: 'User Preferences' },
},
{
path: 'user-preferences',
@@ -165,12 +60,15 @@ export const SETTINGS_ROUTES: Routes = [
import('./user-preferences/user-preferences-page.component').then(m => m.UserPreferencesPageComponent),
data: { breadcrumb: 'User Preferences' },
},
// -----------------------------------------------------------------------
// Merged personal preference leaves (redirects to user-preferences)
// -----------------------------------------------------------------------
{
path: 'language',
title: 'Language',
loadComponent: () =>
import('./language/language-settings-page.component').then(m => m.LanguageSettingsPageComponent),
data: { breadcrumb: 'Language' },
redirectTo: 'user-preferences',
pathMatch: 'full' as const,
},
{
path: 'ai-preferences',
@@ -178,35 +76,143 @@ export const SETTINGS_ROUTES: Routes = [
redirectTo: 'user-preferences',
pathMatch: 'full' as const,
},
// -----------------------------------------------------------------------
// Admin/tenant config redirects -> canonical administration owner
// -----------------------------------------------------------------------
{
path: 'policy',
title: 'Policy Governance',
loadComponent: () =>
import('./policy/policy-governance-settings-page.component').then(m => m.PolicyGovernanceSettingsPageComponent),
data: { breadcrumb: 'Policy Governance' },
path: 'admin',
title: 'Identity & Access',
redirectTo: redirectToCanonical('/administration/admin'),
pathMatch: 'full' as const,
},
{
path: 'offline',
title: 'Offline Settings',
loadComponent: () =>
import('../offline-kit/components/offline-dashboard.component').then(m => m.OfflineDashboardComponent),
data: { breadcrumb: 'Offline Settings' },
path: 'admin/:page',
title: 'Identity & Access',
redirectTo: redirectToCanonical('/administration/admin/:page'),
pathMatch: 'full' as const,
},
{
path: 'branding',
title: 'Tenant & Branding',
redirectTo: redirectToCanonical('/console/admin/branding'),
pathMatch: 'full' as const,
},
{
path: 'usage',
title: 'Usage & Limits',
redirectTo: redirectToCanonical('/setup/usage'),
pathMatch: 'full' as const,
},
{
path: 'notifications',
title: 'Notifications',
redirectTo: redirectToCanonical('/setup/notifications'),
pathMatch: 'full' as const,
},
{
path: 'identity-providers',
title: 'Identity Providers',
loadComponent: () =>
import('./identity-providers/identity-providers-settings-page.component').then(
(m) => m.IdentityProvidersSettingsPageComponent,
),
data: { breadcrumb: 'Identity Providers' },
redirectTo: redirectToCanonical('/administration/identity-providers'),
pathMatch: 'full' as const,
},
{
path: 'system',
title: 'System',
loadComponent: () =>
import('./system/system-settings-page.component').then(m => m.SystemSettingsPageComponent),
data: { breadcrumb: 'System' },
redirectTo: redirectToCanonical('/administration/system'),
pathMatch: 'full' as const,
},
{
path: 'security-data',
title: 'Security Data',
redirectTo: redirectToCanonical('/administration/security-data'),
pathMatch: 'full' as const,
},
// -----------------------------------------------------------------------
// Operations config redirects -> canonical ops/setup owners
// -----------------------------------------------------------------------
{
path: 'integrations',
title: 'Integrations',
redirectTo: redirectToCanonical('/setup/integrations'),
pathMatch: 'full' as const,
},
{
path: 'integrations/:id',
title: 'Integration Detail',
redirectTo: redirectToCanonical('/setup/integrations/:id'),
pathMatch: 'full' as const,
},
{
path: 'policy',
title: 'Policy Governance',
redirectTo: redirectToCanonical('/ops/policy/governance'),
pathMatch: 'full' as const,
},
{
path: 'offline',
title: 'Offline Settings',
redirectTo: redirectToCanonical('/administration/offline'),
pathMatch: 'full' as const,
},
{
path: 'release-control',
title: 'Release Control',
redirectTo: redirectToCanonical('/setup/topology/environments'),
pathMatch: 'full' as const,
},
{
path: 'configuration-pane',
title: 'Configuration Pane',
redirectTo: redirectToCanonical('/ops/platform-setup'),
pathMatch: 'full' as const,
},
// -----------------------------------------------------------------------
// Trust redirects (already existed, preserved)
// -----------------------------------------------------------------------
{
path: 'trust',
title: 'Trust & Signing',
redirectTo: redirectToCanonical('/setup/trust-signing'),
pathMatch: 'full' as const,
},
{
path: 'trust/issuers',
title: 'Trust & Signing',
redirectTo: redirectToCanonical('/setup/trust-signing/issuers'),
pathMatch: 'full' as const,
},
{
path: 'trust/:page/:child',
title: 'Trust & Signing',
redirectTo: redirectToCanonical('/setup/trust-signing/:page/:child'),
pathMatch: 'full' as const,
},
{
path: 'trust/:page',
title: 'Trust & Signing',
redirectTo: redirectToCanonical('/setup/trust-signing/:page'),
pathMatch: 'full' as const,
},
{
path: 'trust-signing',
title: 'Trust & Signing',
redirectTo: redirectToCanonical('/setup/trust-signing'),
pathMatch: 'full' as const,
},
{
path: 'trust-signing/:page',
title: 'Trust & Signing',
redirectTo: redirectToCanonical('/setup/trust-signing/:page'),
pathMatch: 'full' as const,
},
{
path: 'trust-signing/:page/:child',
title: 'Trust & Signing',
redirectTo: redirectToCanonical('/setup/trust-signing/:page/:child'),
pathMatch: 'full' as const,
},
],
},

View File

@@ -3,11 +3,12 @@ import { CommonModule } from '@angular/common';
import { HostProbeHealth, ProbeHealthState, SignalsRuntimeDashboardViewModel } from './models/signals-runtime-dashboard.models';
import { SignalsRuntimeDashboardService } from './services/signals-runtime-dashboard.service';
import { MetricCardComponent } from '../../shared/ui/metric-card/metric-card.component';
@Component({
selector: 'app-signals-runtime-dashboard',
standalone: true,
imports: [CommonModule],
imports: [CommonModule, MetricCardComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="signals-page">
@@ -27,21 +28,26 @@ import { SignalsRuntimeDashboardService } from './services/signals-runtime-dashb
@if (vm(); as dashboard) {
<section class="metrics-grid" aria-label="Signal runtime metrics">
<article class="metric-card">
<h2>Signals / sec</h2>
<p>{{ dashboard.metrics.signalsPerSecond | number:'1.0-2' }}</p>
<small>Last hour events: {{ dashboard.metrics.lastHourCount }}</small>
</article>
<article class="metric-card">
<h2>Error rate</h2>
<p>{{ dashboard.metrics.errorRatePercent | number:'1.0-2' }}%</p>
<small>Total signals: {{ dashboard.metrics.totalSignals }}</small>
</article>
<article class="metric-card">
<h2>Avg latency</h2>
<p>{{ dashboard.metrics.averageLatencyMs | number:'1.0-0' }} ms</p>
<small>Gateway-backed when available</small>
</article>
<app-metric-card
label="Signals / sec"
[value]="(dashboard.metrics.signalsPerSecond | number:'1.0-2') ?? '--'"
deltaDirection="up-is-good"
[subtitle]="'Last hour events: ' + dashboard.metrics.lastHourCount"
/>
<app-metric-card
label="Error rate"
[value]="(dashboard.metrics.errorRatePercent | number:'1.0-2') ?? '--'"
unit="%"
deltaDirection="up-is-bad"
[subtitle]="'Total signals: ' + dashboard.metrics.totalSignals"
/>
<app-metric-card
label="Avg latency"
[value]="(dashboard.metrics.averageLatencyMs | number:'1.0-0') ?? '--'"
unit="ms"
deltaDirection="up-is-bad"
subtitle="Gateway-backed when available"
/>
</section>
<section class="summary-grid">
@@ -174,31 +180,7 @@ import { SignalsRuntimeDashboardService } from './services/signals-runtime-dashb
gap: 0.75rem;
}
.metric-card {
border-radius: var(--radius-xl);
border: 1px solid var(--color-surface-secondary);
background: var(--color-surface-primary);
padding: 0.9rem;
}
.metric-card h2 {
margin: 0;
font-size: 0.95rem;
color: var(--color-text-secondary);
font-weight: var(--font-weight-semibold);
}
.metric-card p {
margin: 0.4rem 0 0.2rem;
font-size: 1.8rem;
font-weight: var(--font-weight-bold);
color: var(--color-text-heading);
}
.metric-card small {
color: var(--color-text-secondary);
font-size: 0.78rem;
}
/* metric-card styling handled by the canonical MetricCardComponent */
.summary-grid {
display: grid;

View File

@@ -19,10 +19,11 @@ import {
import { KeyDetailPanelComponent } from './key-detail-panel.component';
import { KeyExpiryWarningComponent } from './key-expiry-warning.component';
import { KeyRotationWizardComponent } from './key-rotation-wizard.component';
import { ListDetailShellComponent } from '../../shared/ui/list-detail-shell/list-detail-shell.component';
@Component({
selector: 'app-signing-key-dashboard',
imports: [CommonModule, FormsModule, KeyDetailPanelComponent, KeyExpiryWarningComponent, KeyRotationWizardComponent],
imports: [CommonModule, FormsModule, KeyDetailPanelComponent, KeyExpiryWarningComponent, KeyRotationWizardComponent, ListDetailShellComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="key-dashboard">
@@ -86,8 +87,14 @@ import { KeyRotationWizardComponent } from './key-rotation-wizard.component';
}
</div>
<!-- Keys Table -->
<div class="key-dashboard__table-container">
<!-- Keys Table + Detail Shell -->
<app-list-detail-shell
[detailVisible]="!!selectedKey()"
[collapsible]="true"
detailWidth="28rem"
(detailClosed)="selectedKey.set(null)"
>
<div shell-primary class="key-dashboard__table-container">
@if (loading()) {
<div class="key-dashboard__loading">Loading signing keys...</div>
} @else if (error()) {
@@ -242,17 +249,20 @@ import { KeyRotationWizardComponent } from './key-rotation-wizard.component';
}
</div>
<!-- Detail Panel -->
@if (selectedKey()) {
<app-key-detail-panel
[key]="selectedKey()!"
(close)="selectedKey.set(null)"
(rotateKey)="openRotationWizard($event)"
(revokeKey)="onRevokeKeyById($event)"
></app-key-detail-panel>
}
<!-- Detail Panel (projected into shell-detail slot) -->
@if (selectedKey()) {
<aside shell-detail>
<app-key-detail-panel
[key]="selectedKey()!"
(close)="selectedKey.set(null)"
(rotateKey)="openRotationWizard($event)"
(revokeKey)="onRevokeKeyById($event)"
></app-key-detail-panel>
</aside>
}
</app-list-detail-shell>
<!-- Rotation Wizard -->
<!-- Rotation Wizard (outside shell — overlays both panes) -->
@if (rotatingKey()) {
<app-key-rotation-wizard
[key]="rotatingKey()!"

View File

@@ -1,13 +0,0 @@
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: BUSL-1.1
import { Routes } from '@angular/router';
export const workflowVisualizationRoutes: Routes = [
{
path: '',
loadComponent: () =>
import('./components/workflow-visualizer/workflow-visualizer.component').then(
(m) => m.WorkflowVisualizerComponent
),
},
];

View File

@@ -351,11 +351,15 @@ export const ADMINISTRATION_ROUTES: Routes = [
pathMatch: 'full',
},
// Legacy alias: /administration/identity-providers → /settings/identity-providers
// Identity Providers — canonical owner (rehomed from /settings/identity-providers)
{
path: 'identity-providers',
redirectTo: '/settings/identity-providers',
pathMatch: 'full',
title: 'Identity Providers',
data: { breadcrumb: 'Identity Providers' },
loadComponent: () =>
import('../features/settings/identity-providers/identity-providers-settings-page.component').then(
(m) => m.IdentityProvidersSettingsPageComponent
),
},
// A7 — System

View File

@@ -0,0 +1,208 @@
/**
* Context Header Component Tests
* Sprint 027: Canonical header contract verification
*/
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ContextHeaderComponent, HeadingLevel } from './context-header.component';
/* ------------------------------------------------------------------ */
/* Test host: exercises all inputs, output, and content projection */
/* ------------------------------------------------------------------ */
@Component({
standalone: true,
imports: [ContextHeaderComponent],
template: `
<app-context-header
[eyebrow]="eyebrow"
[title]="title"
[subtitle]="subtitle"
[contextNote]="contextNote"
[chips]="chips"
[backLabel]="backLabel"
[headingLevel]="headingLevel"
[testId]="testId"
(backClick)="backClicked = true"
>
<button header-actions data-testid="projected-action">Do Something</button>
</app-context-header>
`,
})
class ContextHeaderTestHostComponent {
eyebrow = '';
title = 'Test Page';
subtitle = '';
contextNote = '';
chips: readonly string[] = [];
backLabel: string | null = null;
headingLevel: HeadingLevel = 1;
testId: string | null = null;
backClicked = false;
}
describe('ContextHeaderComponent', () => {
let fixture: ComponentFixture<ContextHeaderTestHostComponent>;
let host: ContextHeaderTestHostComponent;
let el: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ContextHeaderTestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(ContextHeaderTestHostComponent);
host = fixture.componentInstance;
el = fixture.nativeElement;
fixture.detectChanges();
});
/* ---- Title rendering ---- */
it('renders the title as an h1 by default', () => {
const h1 = el.querySelector('h1');
expect(h1).toBeTruthy();
expect(h1!.textContent!.trim()).toBe('Test Page');
});
it('does not render eyebrow, subtitle, or note when empty', () => {
expect(el.querySelector('.context-header__eyebrow')).toBeFalsy();
expect(el.querySelector('.context-header__subtitle')).toBeFalsy();
expect(el.querySelector('.context-header__note')).toBeFalsy();
});
/* ---- Eyebrow/subtitle display ---- */
it('renders eyebrow text when provided', () => {
host.eyebrow = 'Ops / Policy';
fixture.detectChanges();
const eyebrow = el.querySelector('.context-header__eyebrow');
expect(eyebrow).toBeTruthy();
expect(eyebrow!.textContent!.trim()).toBe('Ops / Policy');
});
it('renders subtitle and context note when provided', () => {
host.subtitle = 'A brief description';
host.contextNote = 'Additional operational context';
fixture.detectChanges();
expect(el.querySelector('.context-header__subtitle')!.textContent!.trim()).toBe('A brief description');
expect(el.querySelector('.context-header__note')!.textContent!.trim()).toBe('Additional operational context');
});
/* ---- Chips ---- */
it('renders chips when provided', () => {
host.chips = ['running', 'prod', 'v2.1'];
fixture.detectChanges();
const chips = el.querySelectorAll('.context-header__chip');
expect(chips.length).toBe(3);
expect(chips[0].textContent!.trim()).toBe('running');
expect(chips[1].textContent!.trim()).toBe('prod');
expect(chips[2].textContent!.trim()).toBe('v2.1');
});
it('does not render chips container when empty', () => {
host.chips = [];
fixture.detectChanges();
expect(el.querySelector('.context-header__chips')).toBeFalsy();
});
it('marks chip container with role=list for accessibility', () => {
host.chips = ['status'];
fixture.detectChanges();
const container = el.querySelector('.context-header__chips');
expect(container!.getAttribute('role')).toBe('list');
expect(container!.getAttribute('aria-label')).toBe('Context chips');
const chip = el.querySelector('.context-header__chip');
expect(chip!.getAttribute('role')).toBe('listitem');
});
/* ---- Back action behavior ---- */
it('hides back button when backLabel is null', () => {
host.backLabel = null;
fixture.detectChanges();
expect(el.querySelector('.context-header__return')).toBeFalsy();
});
it('renders back button and emits backClick when clicked', () => {
host.backLabel = 'Return to Findings';
fixture.detectChanges();
const button = el.querySelector('.context-header__return') as HTMLButtonElement;
expect(button).toBeTruthy();
expect(button.textContent).toContain('Return to Findings');
expect(button.getAttribute('aria-label')).toBe('Navigate back: Return to Findings');
button.click();
fixture.detectChanges();
expect(host.backClicked).toBeTrue();
});
/* ---- Action slot projection ---- */
it('projects content into the header-actions slot', () => {
const projected = el.querySelector('[data-testid="projected-action"]');
expect(projected).toBeTruthy();
expect(projected!.textContent!.trim()).toBe('Do Something');
});
/* ---- Heading levels (accessibility) ---- */
it('renders h2 when headingLevel is 2', () => {
host.headingLevel = 2;
fixture.detectChanges();
expect(el.querySelector('h1')).toBeFalsy();
expect(el.querySelector('h2')).toBeTruthy();
expect(el.querySelector('h2')!.textContent!.trim()).toBe('Test Page');
});
it('renders h3 when headingLevel is 3', () => {
host.headingLevel = 3;
fixture.detectChanges();
expect(el.querySelector('h1')).toBeFalsy();
expect(el.querySelector('h3')).toBeTruthy();
expect(el.querySelector('h3')!.textContent!.trim()).toBe('Test Page');
});
/* ---- Test ID ---- */
it('sets data-testid on the header element when provided', () => {
host.testId = 'my-page-header';
fixture.detectChanges();
const header = el.querySelector('header');
expect(header!.getAttribute('data-testid')).toBe('my-page-header');
});
it('does not set data-testid when testId is null', () => {
host.testId = null;
fixture.detectChanges();
const header = el.querySelector('header');
expect(header!.getAttribute('data-testid')).toBeNull();
});
/* ---- Responsive behavior (structural check) ---- */
it('renders the header with flex layout between copy and actions', () => {
const header = el.querySelector('.context-header') as HTMLElement;
expect(header).toBeTruthy();
const copy = el.querySelector('.context-header__copy');
const actions = el.querySelector('.context-header__actions');
expect(copy).toBeTruthy();
expect(actions).toBeTruthy();
});
});

View File

@@ -1,22 +1,47 @@
/**
* Context Header Component
*
* Canonical page header primitive for Stella Ops. Serves both simple
* admin/settings pages (title + subtitle + actions) and richer operational
* pages (eyebrow, chips, back action, context note).
*
* Replaces the deprecated PageHeaderComponent (SPRINT-027).
*/
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
/** Allowed heading element levels for the title. */
export type HeadingLevel = 1 | 2 | 3;
@Component({
selector: 'app-context-header',
standalone: true,
template: `
<header class="context-header">
<header
class="context-header"
[attr.data-testid]="testId"
>
<div class="context-header__copy">
@if (eyebrow) {
<p class="context-header__eyebrow">{{ eyebrow }}</p>
}
<div class="context-header__title-row">
<h1 class="context-header__title">{{ title }}</h1>
@switch (headingLevel) {
@case (2) {
<h2 class="context-header__title">{{ title }}</h2>
}
@case (3) {
<h3 class="context-header__title">{{ title }}</h3>
}
@default {
<h1 class="context-header__title">{{ title }}</h1>
}
}
@if (chips.length) {
<div class="context-header__chips" aria-label="Context chips">
<div class="context-header__chips" role="list" aria-label="Context chips">
@for (chip of chips; track chip) {
<span class="context-header__chip">{{ chip }}</span>
<span class="context-header__chip" role="listitem">{{ chip }}</span>
}
</div>
}
@@ -36,13 +61,15 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
<button
type="button"
class="context-header__return"
[attr.aria-label]="'Navigate back: ' + backLabel"
(click)="backClick.emit()"
>
<span class="context-header__return-arrow" aria-hidden="true">&larr;</span>
{{ backLabel }}
</button>
}
<ng-content select="[header-actions]"></ng-content>
<ng-content select="[header-actions],[secondary-actions],[primary-actions]"></ng-content>
</div>
</header>
`,
@@ -80,6 +107,7 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
margin: 0;
color: var(--color-text-heading, var(--color-text-primary));
font-size: 1.6rem;
font-weight: var(--font-weight-semibold, 600);
}
.context-header__subtitle,
@@ -89,6 +117,10 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
line-height: 1.45;
}
.context-header__subtitle {
font-size: 0.875rem;
}
.context-header__note {
font-size: 0.92rem;
}
@@ -120,6 +152,9 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
}
.context-header__return {
display: inline-flex;
align-items: center;
gap: 0.4rem;
border: 1px solid var(--color-border-primary);
border-radius: 0.75rem;
background: var(--color-surface-secondary, var(--color-surface-primary));
@@ -129,6 +164,15 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
padding: 0.6rem 0.9rem;
}
.context-header__return:hover {
background: var(--color-surface-tertiary, var(--color-surface-secondary));
}
.context-header__return-arrow {
font-size: 1rem;
line-height: 1;
}
@media (max-width: 860px) {
.context-header {
display: grid;
@@ -142,12 +186,36 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ContextHeaderComponent {
/** Contextual eyebrow label shown above the title (e.g. breadcrumb path). */
@Input() eyebrow = '';
/** Primary heading text (required for meaningful display). */
@Input() title = '';
/** Short description displayed below the title. */
@Input() subtitle = '';
/** Extended contextual note displayed below the subtitle. */
@Input() contextNote = '';
/** Status or context chips displayed inline with the title. */
@Input() chips: readonly string[] = [];
/**
* Label for the back/return button. When null or empty, the button is hidden.
* Use for contextual navigation (e.g. "Return to Findings").
*/
@Input() backLabel: string | null = null;
/**
* Semantic heading level (1, 2, or 3). Defaults to 1 (h1).
* Use 2 for pages nested inside a shell that already provides an h1.
*/
@Input() headingLevel: HeadingLevel = 1;
/** Optional test identifier for the header element. */
@Input() testId: string | null = null;
/** Emitted when the user clicks the back/return button. */
@Output() readonly backClick = new EventEmitter<void>();
}

View File

@@ -8,6 +8,7 @@
// Layout primitives
export * from './page-header/page-header.component';
export * from './context-header/context-header.component';
/** @deprecated Use ContextHeaderComponent instead */
export * from './context-drawer-host/context-drawer-host.component';
export * from './filter-bar/filter-bar.component';
export * from './list-detail-shell/list-detail-shell.component';
@@ -18,9 +19,12 @@ export * from './context-route-state/context-route-state';
// Data display
export * from './status-badge/status-badge.component';
export * from './metric-card/metric-card.component';
export { MetricCardComponent, DeltaDirection, MetricSeverity } from './metric-card/metric-card.component';
export * from './timeline-list/timeline-list.component';
// Witness/evidence proof-inspection sections
export * from './witness/index';
// Utility
export * from './empty-state/empty-state.component';
export * from './inline-code/inline-code.component';

View File

@@ -0,0 +1,176 @@
/**
* @file list-detail-shell.component.spec.ts
* @sprint SPRINT_20260308_030_FE_split_pane_list_detail_shell_consolidation (FE-SPL-004)
* @description Unit tests for the canonical ListDetailShellComponent
*/
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ListDetailShellComponent } from './list-detail-shell.component';
/**
* Test host that wraps ListDetailShellComponent with projected content.
*/
@Component({
standalone: true,
imports: [ListDetailShellComponent],
template: `
<app-list-detail-shell
[detailVisible]="detailVisible"
[detailWidth]="detailWidth"
[collapsible]="collapsible"
(detailClosed)="onDetailClosed()"
>
<div shell-primary data-testid="primary">Primary content</div>
<div shell-detail data-testid="detail">Detail content</div>
</app-list-detail-shell>
`,
})
class TestHostComponent {
detailVisible = false;
detailWidth = '24rem';
collapsible = false;
detailClosedCount = 0;
onDetailClosed(): void {
this.detailClosedCount++;
this.detailVisible = false;
}
}
describe('ListDetailShellComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
});
it('should create', () => {
fixture.detectChanges();
expect(host).toBeTruthy();
});
it('should render the primary pane', () => {
fixture.detectChanges();
const primary = fixture.nativeElement.querySelector('[data-testid="primary"]');
expect(primary).toBeTruthy();
expect(primary.textContent).toContain('Primary content');
});
it('should hide detail pane when detailVisible is false', () => {
host.detailVisible = false;
fixture.detectChanges();
const detail = fixture.nativeElement.querySelector('[data-testid="detail"]');
expect(detail).toBeNull();
});
it('should show detail pane when detailVisible is true', () => {
host.detailVisible = true;
fixture.detectChanges();
const detail = fixture.nativeElement.querySelector('[data-testid="detail"]');
expect(detail).toBeTruthy();
expect(detail.textContent).toContain('Detail content');
});
it('should apply the --with-detail CSS class when detail is visible', () => {
host.detailVisible = true;
fixture.detectChanges();
const shell = fixture.nativeElement.querySelector('.list-detail-shell');
expect(shell.classList).toContain('list-detail-shell--with-detail');
});
it('should not apply the --with-detail CSS class when detail is hidden', () => {
host.detailVisible = false;
fixture.detectChanges();
const shell = fixture.nativeElement.querySelector('.list-detail-shell');
expect(shell.classList).not.toContain('list-detail-shell--with-detail');
});
it('should set custom detail width via CSS variable', () => {
host.detailVisible = true;
host.detailWidth = '32rem';
fixture.detectChanges();
const shell = fixture.nativeElement.querySelector('.list-detail-shell') as HTMLElement;
expect(shell.style.getPropertyValue('--list-detail-shell-detail-width')).toBe('32rem');
});
it('should not show toggle button when collapsible is false', () => {
host.detailVisible = true;
host.collapsible = false;
fixture.detectChanges();
const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle');
expect(toggle).toBeNull();
});
it('should show toggle button when collapsible is true and detail is visible', () => {
host.detailVisible = true;
host.collapsible = true;
fixture.detectChanges();
const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle');
expect(toggle).toBeTruthy();
expect(toggle.getAttribute('aria-label')).toBe('Close detail panel');
});
it('should not show toggle button when collapsible is true but detail is hidden', () => {
host.detailVisible = false;
host.collapsible = true;
fixture.detectChanges();
const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle');
expect(toggle).toBeNull();
});
it('should emit detailClosed when toggle button is clicked', () => {
host.detailVisible = true;
host.collapsible = true;
fixture.detectChanges();
const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle') as HTMLButtonElement;
toggle.click();
fixture.detectChanges();
expect(host.detailClosedCount).toBe(1);
});
it('should hide detail pane after toggle is clicked and host reacts', () => {
host.detailVisible = true;
host.collapsible = true;
fixture.detectChanges();
const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle') as HTMLButtonElement;
toggle.click();
fixture.detectChanges();
const detail = fixture.nativeElement.querySelector('[data-testid="detail"]');
expect(detail).toBeNull();
});
it('should render detail pane with complementary role for accessibility', () => {
host.detailVisible = true;
fixture.detectChanges();
const detailContainer = fixture.nativeElement.querySelector('.list-detail-shell__detail');
expect(detailContainer.getAttribute('role')).toBe('complementary');
});
it('should have focus-visible style support on toggle button', () => {
host.detailVisible = true;
host.collapsible = true;
fixture.detectChanges();
const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle') as HTMLButtonElement;
// Verify button is focusable (type="button" element)
expect(toggle.tagName.toLowerCase()).toBe('button');
expect(toggle.getAttribute('type')).toBe('button');
});
it('should use the default detail width of 24rem', () => {
host.detailVisible = true;
fixture.detectChanges();
const shell = fixture.nativeElement.querySelector('.list-detail-shell') as HTMLElement;
expect(shell.style.getPropertyValue('--list-detail-shell-detail-width')).toBe('24rem');
});
});

View File

@@ -1,4 +1,30 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
/**
* ListDetailShellComponent — canonical master-detail layout primitive.
*
* Consolidates the former SplitPaneComponent behavior into a single shell
* so the codebase has one truthful master-detail layout.
*
* Content projection slots:
* [shell-primary] — the list / primary pane (always rendered)
* [shell-detail] — the detail / secondary pane (conditionally rendered)
*
* Inputs:
* detailVisible — whether the detail pane is shown
* detailWidth — CSS value for the detail pane width (default 24rem)
* collapsible — show a toggle button between panes (default false)
*
* Outputs:
* detailClosed — emits when the user clicks the collapse toggle to hide detail
*
* Sprint: SPRINT_20260308_030_FE_split_pane_list_detail_shell_consolidation
*/
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
} from '@angular/core';
@Component({
selector: 'app-list-detail-shell',
@@ -13,8 +39,38 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
<ng-content select="[shell-primary]"></ng-content>
</div>
@if (detailVisible && collapsible) {
<button
type="button"
class="list-detail-shell__toggle"
(click)="onToggleDetail()"
[attr.aria-label]="'Close detail panel'"
aria-controls="list-detail-shell-detail"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
}
@if (detailVisible) {
<div class="list-detail-shell__detail">
<div
class="list-detail-shell__detail"
id="list-detail-shell-detail"
role="complementary"
[attr.aria-label]="'Detail panel'"
>
<ng-content select="[shell-detail]"></ng-content>
</div>
}
@@ -28,13 +84,64 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
}
.list-detail-shell--with-detail {
grid-template-columns: minmax(0, 1.7fr) minmax(20rem, var(--list-detail-shell-detail-width, 24rem));
grid-template-columns: minmax(0, 1.7fr) auto minmax(20rem, var(--list-detail-shell-detail-width, 24rem));
align-items: start;
}
.list-detail-shell__primary,
.list-detail-shell__detail {
min-width: 0;
overflow: auto;
}
.list-detail-shell__detail {
animation: list-detail-shell-slide-in 0.2s ease-out;
}
@keyframes list-detail-shell-slide-in {
from {
opacity: 0;
transform: translateX(1rem);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.list-detail-shell__toggle {
align-self: start;
position: sticky;
top: 0.5rem;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 2rem;
margin: 0 -0.25rem;
padding: 0;
background: var(--color-surface-primary, #fff);
border: 1px solid var(--color-border-primary, #e0e0e0);
border-radius: var(--radius-sm, 4px);
color: var(--color-text-secondary, #666);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.list-detail-shell__toggle:hover {
background: var(--color-nav-hover, #f5f5f5);
color: var(--color-text-primary, #333);
}
.list-detail-shell__toggle:focus-visible {
outline: 2px solid var(--color-status-info, #2196f3);
outline-offset: 2px;
}
/* When collapsible is false (no toggle button), keep the two-column grid */
.list-detail-shell--with-detail:not(:has(.list-detail-shell__toggle)) {
grid-template-columns: minmax(0, 1.7fr) minmax(20rem, var(--list-detail-shell-detail-width, 24rem));
}
@media (max-width: 1100px) {
@@ -42,6 +149,15 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
.list-detail-shell--with-detail {
grid-template-columns: minmax(0, 1fr);
}
.list-detail-shell__toggle {
display: none;
}
.list-detail-shell__detail {
border-top: 1px solid var(--color-border-primary, #e0e0e0);
padding-top: 1rem;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -49,4 +165,11 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
export class ListDetailShellComponent {
@Input() detailVisible = false;
@Input() detailWidth = '24rem';
@Input() collapsible = false;
@Output() readonly detailClosed = new EventEmitter<void>();
onToggleDetail(): void {
this.detailClosed.emit();
}
}

View File

@@ -0,0 +1,489 @@
/**
* Metric Card Component Tests
* Sprint: SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation (FE-MCD-004)
*
* Covers:
* - Normal rendering with all inputs
* - Delta direction semantics (up-is-good vs up-is-bad vs neutral)
* - Loading / empty / error states
* - Severity accent rendering
* - Accessibility (ARIA labels)
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MetricCardComponent, DeltaDirection, MetricSeverity } from './metric-card.component';
describe('MetricCardComponent', () => {
let fixture: ComponentFixture<MetricCardComponent>;
let component: MetricCardComponent;
let el: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [MetricCardComponent],
}).compileComponents();
fixture = TestBed.createComponent(MetricCardComponent);
component = fixture.componentInstance;
el = fixture.nativeElement;
});
// ------------------------------------------------------------------
// Normal rendering
// ------------------------------------------------------------------
describe('normal rendering', () => {
it('should render label and value', () => {
component.label = 'Total Scans';
component.value = 1234;
fixture.detectChanges();
expect(el.querySelector('.metric-card__label')?.textContent?.trim()).toBe('Total Scans');
expect(el.querySelector('.metric-card__value')?.textContent?.trim()).toBe('1,234');
});
it('should render string values as-is', () => {
component.label = 'Status';
component.value = 'Healthy';
fixture.detectChanges();
expect(el.querySelector('.metric-card__value')?.textContent?.trim()).toBe('Healthy');
});
it('should render unit when provided', () => {
component.label = 'Latency';
component.value = 42;
component.unit = 'ms';
fixture.detectChanges();
expect(el.querySelector('.metric-card__unit')?.textContent?.trim()).toBe('ms');
});
it('should not render unit when not provided', () => {
component.label = 'Count';
component.value = 10;
fixture.detectChanges();
expect(el.querySelector('.metric-card__unit')).toBeNull();
});
it('should render subtitle when provided', () => {
component.label = 'Error Rate';
component.value = '0.5%';
component.subtitle = 'Platform-wide';
fixture.detectChanges();
expect(el.querySelector('.metric-card__subtitle')?.textContent?.trim()).toBe('Platform-wide');
});
it('should not render subtitle when not provided', () => {
component.label = 'Count';
component.value = 5;
fixture.detectChanges();
expect(el.querySelector('.metric-card__subtitle')).toBeNull();
});
it('should recompute derived output when inputs change after first render', () => {
fixture.componentRef.setInput('label', 'Latency');
fixture.componentRef.setInput('value', 150);
fixture.componentRef.setInput('delta', 5);
fixture.componentRef.setInput('deltaDirection', 'up-is-bad');
fixture.detectChanges();
expect(el.querySelector('.metric-card__value')?.textContent?.trim()).toBe('150');
expect(el.querySelector('.metric-card__delta')?.textContent?.trim()).toContain('+5%');
expect(el.querySelector('.metric-card__delta')?.classList.contains('metric-card__delta--bad')).toBe(true);
fixture.componentRef.setInput('value', 95);
fixture.componentRef.setInput('delta', -3);
fixture.componentRef.setInput('deltaDirection', 'up-is-good');
fixture.detectChanges();
expect(el.querySelector('.metric-card__value')?.textContent?.trim()).toBe('95');
expect(el.querySelector('.metric-card__delta')?.textContent?.trim()).toContain('-3%');
expect(el.querySelector('.metric-card__delta')?.classList.contains('metric-card__delta--bad')).toBe(true);
expect(el.querySelector('.metric-card')?.getAttribute('aria-label')).toContain('Latency: 95, -3%');
});
});
// ------------------------------------------------------------------
// Delta display
// ------------------------------------------------------------------
describe('delta display', () => {
it('should show positive delta with + sign and up arrow', () => {
component.label = 'Throughput';
component.value = 200;
component.delta = 12.5;
fixture.detectChanges();
const deltaEl = el.querySelector('.metric-card__delta');
expect(deltaEl?.textContent?.trim()).toContain('+12.5%');
// Should have an up arrow SVG
const svg = deltaEl?.querySelector('svg');
expect(svg).toBeTruthy();
expect(deltaEl?.querySelector('polyline')?.getAttribute('points')).toContain('17,11');
});
it('should show negative delta with - sign and down arrow', () => {
component.label = 'Errors';
component.value = 3;
component.delta = -8;
fixture.detectChanges();
const deltaEl = el.querySelector('.metric-card__delta');
expect(deltaEl?.textContent?.trim()).toContain('-8%');
// Should have a down arrow SVG
expect(deltaEl?.querySelector('polyline')?.getAttribute('points')).toContain('7,13');
});
it('should show zero delta without arrow', () => {
component.label = 'Stable';
component.value = 100;
component.delta = 0;
fixture.detectChanges();
const deltaEl = el.querySelector('.metric-card__delta');
expect(deltaEl?.textContent?.trim()).toContain('0%');
expect(deltaEl?.querySelector('svg')).toBeNull();
});
it('should not render delta when undefined', () => {
component.label = 'Simple';
component.value = 42;
fixture.detectChanges();
expect(el.querySelector('.metric-card__delta')).toBeNull();
});
it('should format integer delta without decimal', () => {
component.label = 'Test';
component.value = 10;
component.delta = 5;
fixture.detectChanges();
expect(el.querySelector('.metric-card__delta')?.textContent?.trim()).toContain('+5%');
});
it('should format fractional delta with one decimal', () => {
component.label = 'Test';
component.value = 10;
component.delta = 5.7;
fixture.detectChanges();
expect(el.querySelector('.metric-card__delta')?.textContent?.trim()).toContain('+5.7%');
});
});
// ------------------------------------------------------------------
// Delta direction semantics
// ------------------------------------------------------------------
describe('delta direction semantics', () => {
it('up-is-good: positive delta should be green (--good class)', () => {
component.label = 'Uptime';
component.value = '99.9%';
component.delta = 2;
component.deltaDirection = 'up-is-good';
fixture.detectChanges();
const deltaEl = el.querySelector('.metric-card__delta');
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(true);
expect(deltaEl?.classList.contains('metric-card__delta--bad')).toBe(false);
});
it('up-is-good: negative delta should be red (--bad class)', () => {
component.label = 'Uptime';
component.value = '97%';
component.delta = -3;
component.deltaDirection = 'up-is-good';
fixture.detectChanges();
const deltaEl = el.querySelector('.metric-card__delta');
expect(deltaEl?.classList.contains('metric-card__delta--bad')).toBe(true);
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(false);
});
it('up-is-bad: positive delta should be red (--bad class)', () => {
component.label = 'Error Rate';
component.value = '5.2%';
component.delta = 1.5;
component.deltaDirection = 'up-is-bad';
fixture.detectChanges();
const deltaEl = el.querySelector('.metric-card__delta');
expect(deltaEl?.classList.contains('metric-card__delta--bad')).toBe(true);
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(false);
});
it('up-is-bad: negative delta should be green (--good class)', () => {
component.label = 'Vulnerabilities';
component.value = 12;
component.delta = -20;
component.deltaDirection = 'up-is-bad';
fixture.detectChanges();
const deltaEl = el.querySelector('.metric-card__delta');
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(true);
expect(deltaEl?.classList.contains('metric-card__delta--bad')).toBe(false);
});
it('neutral: positive delta should be gray (--neutral class)', () => {
component.label = 'Signals';
component.value = 450;
component.delta = 10;
component.deltaDirection = 'neutral';
fixture.detectChanges();
const deltaEl = el.querySelector('.metric-card__delta');
expect(deltaEl?.classList.contains('metric-card__delta--neutral')).toBe(true);
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(false);
expect(deltaEl?.classList.contains('metric-card__delta--bad')).toBe(false);
});
it('neutral: negative delta should be gray (--neutral class)', () => {
component.label = 'Signals';
component.value = 400;
component.delta = -5;
component.deltaDirection = 'neutral';
fixture.detectChanges();
const deltaEl = el.querySelector('.metric-card__delta');
expect(deltaEl?.classList.contains('metric-card__delta--neutral')).toBe(true);
});
it('zero delta should always be neutral', () => {
component.label = 'Count';
component.value = 10;
component.delta = 0;
component.deltaDirection = 'up-is-good';
fixture.detectChanges();
const deltaEl = el.querySelector('.metric-card__delta');
expect(deltaEl?.classList.contains('metric-card__delta--neutral')).toBe(true);
});
it('defaults to up-is-good when deltaDirection is not set', () => {
component.label = 'Default';
component.value = 50;
component.delta = 5;
fixture.detectChanges();
const deltaEl = el.querySelector('.metric-card__delta');
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(true);
});
});
// ------------------------------------------------------------------
// Severity state
// ------------------------------------------------------------------
describe('severity state', () => {
const severities: MetricSeverity[] = ['healthy', 'warning', 'critical', 'unknown'];
for (const sev of severities) {
it(`should apply --${sev} class when severity is '${sev}'`, () => {
component.label = 'Health';
component.value = sev;
component.severity = sev;
fixture.detectChanges();
const card = el.querySelector('.metric-card');
expect(card?.classList.contains(`metric-card--${sev}`)).toBe(true);
});
}
it('should not apply severity class when severity is undefined', () => {
component.label = 'No Sev';
component.value = 42;
fixture.detectChanges();
const card = el.querySelector('.metric-card');
expect(card?.classList.contains('metric-card--healthy')).toBe(false);
expect(card?.classList.contains('metric-card--warning')).toBe(false);
expect(card?.classList.contains('metric-card--critical')).toBe(false);
expect(card?.classList.contains('metric-card--unknown')).toBe(false);
});
});
// ------------------------------------------------------------------
// Loading state
// ------------------------------------------------------------------
describe('loading state', () => {
it('should render skeleton placeholders when loading', () => {
component.label = 'Loading';
component.value = 0;
component.loading = true;
fixture.detectChanges();
const card = el.querySelector('.metric-card');
expect(card?.classList.contains('metric-card--loading')).toBe(true);
const skeletons = el.querySelectorAll('.metric-card__skeleton');
expect(skeletons.length).toBeGreaterThan(0);
});
it('should not render actual value when loading', () => {
component.label = 'Loading';
component.value = 999;
component.loading = true;
fixture.detectChanges();
// Value should not appear in the rendered output
expect(el.textContent).not.toContain('999');
});
});
// ------------------------------------------------------------------
// Empty state
// ------------------------------------------------------------------
describe('empty state', () => {
it('should render -- value when empty', () => {
component.label = 'Empty Metric';
component.value = 0;
component.empty = true;
fixture.detectChanges();
expect(el.querySelector('.metric-card--empty')).toBeTruthy();
expect(el.querySelector('.metric-card__value--empty')?.textContent?.trim()).toBe('--');
});
it('should still render subtitle when empty with subtitle', () => {
component.label = 'Empty';
component.value = 0;
component.empty = true;
component.subtitle = 'No data available';
fixture.detectChanges();
expect(el.querySelector('.metric-card__subtitle')?.textContent?.trim()).toBe('No data available');
});
});
// ------------------------------------------------------------------
// Error state
// ------------------------------------------------------------------
describe('error state', () => {
it('should render error state with message', () => {
component.label = 'Broken Metric';
component.value = 0;
component.error = 'Service unavailable';
fixture.detectChanges();
expect(el.querySelector('.metric-card--error')).toBeTruthy();
expect(el.querySelector('.metric-card__value--error')?.textContent?.trim()).toBe('--');
expect(el.querySelector('.metric-card__subtitle--error')?.textContent?.trim()).toBe('Service unavailable');
});
it('should show label even in error state', () => {
component.label = 'Error Label';
component.value = 0;
component.error = 'Timeout';
fixture.detectChanges();
expect(el.querySelector('.metric-card__label')?.textContent?.trim()).toBe('Error Label');
});
});
// ------------------------------------------------------------------
// Accessibility
// ------------------------------------------------------------------
describe('accessibility', () => {
it('should have role="group" on the card container', () => {
component.label = 'Test';
component.value = 42;
fixture.detectChanges();
expect(el.querySelector('.metric-card')?.getAttribute('role')).toBe('group');
});
it('should have a composite aria-label with value and unit', () => {
component.label = 'Latency';
component.value = 150;
component.unit = 'ms';
fixture.detectChanges();
const ariaLabel = el.querySelector('.metric-card')?.getAttribute('aria-label');
expect(ariaLabel).toContain('Latency');
expect(ariaLabel).toContain('150');
expect(ariaLabel).toContain('ms');
});
it('should include delta in aria-label when present', () => {
component.label = 'Rate';
component.value = 10;
component.delta = 5;
fixture.detectChanges();
const ariaLabel = el.querySelector('.metric-card')?.getAttribute('aria-label');
expect(ariaLabel).toContain('+5%');
});
it('should include severity in aria-label when present', () => {
component.label = 'Health';
component.value = 'Good';
component.severity = 'healthy';
fixture.detectChanges();
const ariaLabel = el.querySelector('.metric-card')?.getAttribute('aria-label');
expect(ariaLabel).toContain('healthy');
});
it('should indicate loading in aria-label', () => {
component.label = 'Loading';
component.value = 0;
component.loading = true;
fixture.detectChanges();
expect(el.querySelector('.metric-card')?.getAttribute('aria-label')).toContain('loading');
});
it('should indicate error in aria-label', () => {
component.label = 'Broken';
component.value = 0;
component.error = 'Connection lost';
fixture.detectChanges();
const ariaLabel = el.querySelector('.metric-card')?.getAttribute('aria-label');
expect(ariaLabel).toContain('error');
expect(ariaLabel).toContain('Connection lost');
});
it('should indicate no data in aria-label when empty', () => {
component.label = 'Empty';
component.value = 0;
component.empty = true;
fixture.detectChanges();
expect(el.querySelector('.metric-card')?.getAttribute('aria-label')).toContain('no data');
});
it('should have aria-label on delta badge with favorable/unfavorable', () => {
component.label = 'Good';
component.value = 99;
component.delta = 5;
component.deltaDirection = 'up-is-good';
fixture.detectChanges();
const deltaAriaLabel = el.querySelector('.metric-card__delta')?.getAttribute('aria-label');
expect(deltaAriaLabel).toContain('favorable');
});
it('should label unfavorable delta in up-is-bad mode', () => {
component.label = 'Bad';
component.value = 50;
component.delta = 10;
component.deltaDirection = 'up-is-bad';
fixture.detectChanges();
const deltaAriaLabel = el.querySelector('.metric-card__delta')?.getAttribute('aria-label');
expect(deltaAriaLabel).toContain('unfavorable');
});
});
});

View File

@@ -1,12 +1,31 @@
/**
* Metric Card Component
* Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (SHARED-010)
* Metric Card Component - Canonical KPI Card
* Sprint: SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation (FE-MCD-002)
*
* Displays a metric with label, value, and optional delta.
* Reusable KPI card for dashboard grids. Supports:
* - Semantic delta with configurable direction (up-is-good / up-is-bad / neutral)
* - Severity/health state coloring (healthy / warning / critical / unknown)
* - Optional unit display
* - Loading, empty, and error states
* - Dense dashboard grid responsiveness
* - ARIA accessibility
*/
import { Component, Input, ChangeDetectionStrategy, computed } from '@angular/core';
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
/**
* Controls color semantics for delta values.
* - 'up-is-good': positive delta = green, negative = red (e.g., uptime, throughput)
* - 'up-is-bad': positive delta = red, negative = green (e.g., error rate, latency, vulnerabilities)
* - 'neutral': delta is always neutral gray, no good/bad implication
*/
export type DeltaDirection = 'up-is-good' | 'up-is-bad' | 'neutral';
/**
* Health/severity state for the card.
* Applied as a left-border accent and optional value color.
*/
export type MetricSeverity = 'healthy' | 'warning' | 'critical' | 'unknown';
@Component({
selector: 'app-metric-card',
@@ -14,38 +33,116 @@ import { Component, Input, ChangeDetectionStrategy, computed } from '@angular/co
imports: [],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="metric-card">
<div class="metric-card__header">
<span class="metric-card__label">{{ label }}</span>
@if (delta !== undefined && delta !== null) {
<span
class="metric-card__delta"
[class.metric-card__delta--positive]="delta > 0"
[class.metric-card__delta--negative]="delta < 0"
>
{{ deltaDisplay() }}
</span>
<div
class="metric-card"
[class.metric-card--loading]="loading"
[class.metric-card--empty]="empty"
[class.metric-card--error]="error"
[class.metric-card--healthy]="severity === 'healthy'"
[class.metric-card--warning]="severity === 'warning'"
[class.metric-card--critical]="severity === 'critical'"
[class.metric-card--unknown]="severity === 'unknown'"
[attr.role]="'group'"
[attr.aria-label]="ariaLabel()"
>
@if (loading) {
<div class="metric-card__header">
<span class="metric-card__label metric-card__skeleton">&nbsp;</span>
</div>
<div class="metric-card__value metric-card__skeleton">&nbsp;</div>
<div class="metric-card__subtitle metric-card__skeleton">&nbsp;</div>
} @else if (error) {
<div class="metric-card__header">
<span class="metric-card__label">{{ label }}</span>
</div>
<div class="metric-card__value metric-card__value--error">--</div>
<div class="metric-card__subtitle metric-card__subtitle--error">{{ error }}</div>
} @else if (empty) {
<div class="metric-card__header">
<span class="metric-card__label">{{ label }}</span>
</div>
<div class="metric-card__value metric-card__value--empty">--</div>
@if (subtitle) {
<div class="metric-card__subtitle">{{ subtitle }}</div>
}
} @else {
<div class="metric-card__header">
<span class="metric-card__label">{{ label }}</span>
@if (delta !== undefined && delta !== null) {
<span
class="metric-card__delta"
[class]="deltaColorClass()"
[attr.aria-label]="deltaAriaLabel()"
>
@if (deltaIcon() === 'up') {
<svg class="metric-card__delta-icon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<polyline points="17,11 12,6 7,11"></polyline>
<line x1="12" y1="6" x2="12" y2="18"></line>
</svg>
} @else if (deltaIcon() === 'down') {
<svg class="metric-card__delta-icon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<polyline points="7,13 12,18 17,13"></polyline>
<line x1="12" y1="18" x2="12" y2="6"></line>
</svg>
}
{{ deltaDisplay() }}
</span>
}
</div>
<div class="metric-card__value-row">
<span class="metric-card__value">{{ formattedValue() }}</span>
@if (unit) {
<span class="metric-card__unit">{{ unit }}</span>
}
</div>
@if (subtitle) {
<div class="metric-card__subtitle">{{ subtitle }}</div>
}
</div>
<div class="metric-card__value">{{ value }}</div>
@if (subtitle) {
<div class="metric-card__subtitle">{{ subtitle }}</div>
}
</div>
`,
styles: [`
:host {
display: block;
min-width: 0;
}
.metric-card {
padding: 1rem;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.metric-card:hover {
border-color: var(--color-border-secondary);
}
/* Severity accents */
.metric-card--healthy {
border-left: 3px solid var(--color-status-success);
}
.metric-card--warning {
border-left: 3px solid var(--color-status-warning);
}
.metric-card--critical {
border-left: 3px solid var(--color-status-error);
}
.metric-card--unknown {
border-left: 3px solid var(--color-text-muted);
}
/* Header */
.metric-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
gap: 0.5rem;
}
.metric-card__label {
@@ -54,23 +151,51 @@ import { Component, Input, ChangeDetectionStrategy, computed } from '@angular/co
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.025em;
line-height: 1.4;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Delta badge */
.metric-card__delta {
display: inline-flex;
align-items: center;
gap: 0.125rem;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
font-weight: var(--font-weight-semibold);
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
white-space: nowrap;
flex-shrink: 0;
}
.metric-card__delta--positive {
background: var(--color-severity-low-bg);
color: var(--color-status-success-text);
.metric-card__delta-icon {
flex-shrink: 0;
}
.metric-card__delta--negative {
background: var(--color-severity-critical-bg);
color: var(--color-status-error-text);
.metric-card__delta--good {
background: var(--color-status-success-bg, rgba(34, 197, 94, 0.1));
color: var(--color-status-success-text, #16a34a);
}
.metric-card__delta--bad {
background: var(--color-status-error-bg, rgba(239, 68, 68, 0.1));
color: var(--color-status-error-text, #dc2626);
}
.metric-card__delta--neutral {
background: var(--color-surface-tertiary, rgba(107, 114, 128, 0.1));
color: var(--color-text-secondary, #6b7280);
}
/* Value row */
.metric-card__value-row {
display: flex;
align-items: baseline;
gap: 0.25rem;
flex-wrap: wrap;
}
.metric-card__value {
@@ -78,24 +203,182 @@ import { Component, Input, ChangeDetectionStrategy, computed } from '@angular/co
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
line-height: 1.2;
letter-spacing: -0.01em;
}
.metric-card__value--empty {
color: var(--color-text-muted);
}
.metric-card__value--error {
color: var(--color-status-error);
}
.metric-card__unit {
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
/* Subtitle */
.metric-card__subtitle {
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--color-text-secondary);
line-height: 1.4;
}
.metric-card__subtitle--error {
color: var(--color-status-error-text);
}
/* Loading skeleton */
.metric-card--loading .metric-card__skeleton {
background: var(--color-skeleton-base, rgba(107, 114, 128, 0.15));
border-radius: var(--radius-sm);
animation: metric-card-pulse 1.5s ease-in-out infinite;
color: transparent;
display: block;
}
.metric-card--loading .metric-card__label.metric-card__skeleton {
width: 60%;
min-height: 0.875rem;
}
.metric-card--loading .metric-card__value.metric-card__skeleton {
width: 40%;
min-height: 2rem;
}
.metric-card--loading .metric-card__subtitle.metric-card__skeleton {
width: 80%;
min-height: 0.875rem;
margin-top: 0.25rem;
}
@keyframes metric-card-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Responsive dense grids */
@media (max-width: 640px) {
.metric-card__value {
font-size: 1.5rem;
}
}
`]
})
export class MetricCardComponent {
@Input() label!: string;
@Input() value!: string | number;
/** Metric label / name */
@Input({ required: true }) label!: string;
/** Current metric value */
@Input({ required: true }) value!: string | number;
/** Optional display unit (e.g., 'ms', '%', '/hr', 'GB') */
@Input() unit?: string;
/** Percentage delta change. Sign determines arrow direction. */
@Input() delta?: number;
/**
* Controls color semantics for delta values.
* - 'up-is-good': positive delta = green (e.g., uptime, throughput, healthy count)
* - 'up-is-bad': positive delta = red (e.g., error rate, latency, vulnerability count)
* - 'neutral': delta always shown in gray
*
* Default: 'up-is-good'
*/
@Input() deltaDirection: DeltaDirection = 'up-is-good';
/** Optional health/severity state. Renders a colored left-border accent. */
@Input() severity?: MetricSeverity;
/** Supporting context line below the value */
@Input() subtitle?: string;
deltaDisplay = computed(() => {
/** Show loading skeleton */
@Input() loading = false;
/** Show empty/no-data state */
@Input() empty = false;
/** Error message string. When truthy, renders error state. */
@Input() error?: string;
/** Formatted display value with locale-aware number formatting */
formattedValue(): string {
const val = this.value;
if (typeof val === 'string') return val;
if (typeof val === 'number') {
if (Number.isFinite(val)) {
return val.toLocaleString();
}
return '--';
}
return String(val);
}
/** Delta display text: "+12.3%" or "-5.1%" */
deltaDisplay(): string {
if (this.delta === undefined || this.delta === null) return '';
const sign = this.delta > 0 ? '+' : '';
return `${sign}${this.delta}%`;
});
const abs = Math.abs(this.delta);
const formatted = abs % 1 === 0 ? abs.toString() : abs.toFixed(1);
const sign = this.delta > 0 ? '+' : this.delta < 0 ? '-' : '';
return `${sign}${formatted}%`;
}
/** Arrow direction based on delta sign */
deltaIcon(): 'up' | 'down' | null {
if (this.delta === undefined || this.delta === null || this.delta === 0) return null;
return this.delta > 0 ? 'up' : 'down';
}
/** CSS class for delta badge based on direction semantics */
deltaColorClass(): string {
const base = 'metric-card__delta';
if (this.delta === undefined || this.delta === null || this.delta === 0) {
return `${base} ${base}--neutral`;
}
if (this.deltaDirection === 'neutral') {
return `${base} ${base}--neutral`;
}
const isPositive = this.delta > 0;
const isGood =
(this.deltaDirection === 'up-is-good' && isPositive) ||
(this.deltaDirection === 'up-is-bad' && !isPositive);
return `${base} ${isGood ? `${base}--good` : `${base}--bad`}`;
}
/** Composite ARIA label for the entire card */
ariaLabel(): string {
if (this.loading) return `${this.label}: loading`;
if (this.error) return `${this.label}: error, ${this.error}`;
if (this.empty) return `${this.label}: no data`;
const val = this.formattedValue();
const unitStr = this.unit ? ` ${this.unit}` : '';
const deltaStr = this.delta != null ? `, ${this.deltaDisplay()}` : '';
const severityStr = this.severity ? `, ${this.severity}` : '';
return `${this.label}: ${val}${unitStr}${deltaStr}${severityStr}`;
}
/** ARIA label specifically for the delta badge */
deltaAriaLabel(): string {
if (this.delta === undefined || this.delta === null) return '';
const display = this.deltaDisplay();
if (this.deltaDirection === 'neutral') return `Change: ${display}`;
const isPositive = this.delta > 0;
const isGood =
(this.deltaDirection === 'up-is-good' && isPositive) ||
(this.deltaDirection === 'up-is-bad' && !isPositive);
return `Change: ${display}, ${isGood ? 'favorable' : 'unfavorable'}`;
}
}

View File

@@ -0,0 +1,42 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PageHeaderComponent } from './page-header.component';
@Component({
standalone: true,
imports: [PageHeaderComponent],
template: `
<app-page-header title="Compatibility Title" subtitle="Compatibility Subtitle">
<button secondary-actions data-testid="secondary-action">Secondary</button>
<button primary-actions data-testid="primary-action">Primary</button>
</app-page-header>
`,
})
class PageHeaderHostComponent {}
describe('PageHeaderComponent', () => {
let fixture: ComponentFixture<PageHeaderHostComponent>;
let el: HTMLElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PageHeaderHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(PageHeaderHostComponent);
el = fixture.nativeElement;
fixture.detectChanges();
});
it('forwards legacy action slots into the context-header action area', () => {
const actions = el.querySelector('.context-header__actions');
expect(actions?.querySelector('[data-testid="secondary-action"]')?.textContent?.trim()).toBe('Secondary');
expect(actions?.querySelector('[data-testid="primary-action"]')?.textContent?.trim()).toBe('Primary');
});
it('forwards title and subtitle to the canonical context header', () => {
expect(el.querySelector('.context-header__title')?.textContent?.trim()).toBe('Compatibility Title');
expect(el.querySelector('.context-header__subtitle')?.textContent?.trim()).toBe('Compatibility Subtitle');
});
});

View File

@@ -1,76 +1,39 @@
/**
* Page Header Component
* Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (SHARED-010)
*
* Consistent page header with title, subtitle, and actions.
* @deprecated Use ContextHeaderComponent instead. This component is a
* compatibility wrapper retained for any remaining references. It will
* be removed in a future cleanup sprint.
*
* Migration guide:
* <app-page-header title="T" subtitle="S">
* <button primary-actions>Action</button>
* </app-page-header>
*
* becomes:
*
* <app-context-header title="T" subtitle="S">
* <button header-actions>Action</button>
* </app-context-header>
*/
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { ContextHeaderComponent } from '../context-header/context-header.component';
@Component({
selector: 'app-page-header',
standalone: true,
imports: [],
imports: [ContextHeaderComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<header class="page-header">
<div class="page-header__content">
<h1 class="page-header__title">{{ title }}</h1>
@if (subtitle) {
<p class="page-header__subtitle">{{ subtitle }}</p>
}
</div>
<div class="page-header__actions">
<ng-content select="[secondary-actions]"></ng-content>
<ng-content select="[primary-actions]"></ng-content>
</div>
</header>
<app-context-header
[title]="title"
[subtitle]="subtitle"
testId="page-header-compat"
>
<ng-content select="[secondary-actions]" header-actions></ng-content>
<ng-content select="[primary-actions]" header-actions></ng-content>
</app-context-header>
`,
styles: [`
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 2rem;
margin-bottom: 1.5rem;
}
.page-header__content {
flex: 1;
}
.page-header__title {
margin: 0 0 0.375rem;
font-size: 1.5rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.page-header__subtitle {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.page-header__actions {
display: flex;
gap: 0.75rem;
flex-shrink: 0;
}
@media (max-width: 768px) {
.page-header {
flex-direction: column;
gap: 1rem;
}
.page-header__actions {
width: 100%;
justify-content: flex-start;
}
}
`]
})
export class PageHeaderComponent {
@Input() title!: string;

View File

@@ -3,6 +3,10 @@
* Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (SHARED-010)
*
* Left list + right details layout.
*
* @deprecated Use `ListDetailShellComponent` instead. The list-detail shell is the
* canonical master-detail layout primitive as of SPRINT_20260308_030. This component
* is retained only for backward compatibility and will be removed in a future sprint.
*/
import { Component, Input, ChangeDetectionStrategy, signal } from '@angular/core';

View File

@@ -0,0 +1,433 @@
/**
* Timeline List Component Tests
* Sprint: SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation (FE-TLD-004)
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from './timeline-list.component';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const NOW = new Date('2026-03-08T12:00:00.000Z');
function minutesAgo(n: number): string {
return new Date(NOW.getTime() - n * 60_000).toISOString();
}
function hoursAgo(n: number): string {
return new Date(NOW.getTime() - n * 3_600_000).toISOString();
}
function daysAgo(n: number): string {
return new Date(NOW.getTime() - n * 86_400_000).toISOString();
}
const SAMPLE_EVENTS: TimelineEvent[] = [
{
id: 'evt-1',
timestamp: minutesAgo(5),
title: 'Scan completed',
description: 'Container image scan finished',
actor: 'scanner-worker-01',
eventKind: 'success',
icon: 'check_circle',
metadata: { imageDigest: 'sha256:abc123' },
},
{
id: 'evt-2',
timestamp: hoursAgo(3),
title: 'Policy evaluated',
eventKind: 'info',
icon: 'policy',
},
{
id: 'evt-3',
timestamp: daysAgo(2),
title: 'Finding created',
description: 'CVE-2024-12345 detected',
eventKind: 'error',
icon: 'error',
evidenceLink: '/findings/CVE-2024-12345',
expandable: '{"cve":"CVE-2024-12345","cvss":9.8}',
},
{
id: 'evt-4',
timestamp: daysAgo(2),
title: 'Attestation created',
eventKind: 'warning',
icon: 'verified',
metadata: { sigAlgo: 'ECDSA-P256' },
},
{
id: 'evt-5',
timestamp: daysAgo(5),
title: 'Cache hit',
eventKind: 'neutral',
},
];
// Test host component to set inputs
@Component({
standalone: true,
imports: [TimelineListComponent],
template: `
<app-timeline-list
[events]="events"
[loading]="loading"
[groupByDate]="groupByDate"
[emptyMessage]="emptyMessage"
[ariaLabel]="ariaLabel"
/>
`,
})
class TestHostComponent {
events: TimelineEvent[] = [];
loading = false;
groupByDate = false;
emptyMessage = 'No events to display';
ariaLabel = 'Test timeline';
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('TimelineListComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
let el: HTMLElement;
beforeEach(async () => {
// Patch Date.now for deterministic relative-time output
vi.useFakeTimers({ now: NOW });
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
el = fixture.nativeElement;
});
afterEach(() => {
vi.useRealTimers();
});
// -----------------------------------------------------------------------
// Rendering basics
// -----------------------------------------------------------------------
it('should create', () => {
fixture.detectChanges();
expect(el.querySelector('.timeline')).toBeTruthy();
});
it('should render empty state when no events', () => {
fixture.detectChanges();
const emptyEl = el.querySelector('.timeline__empty');
expect(emptyEl).toBeTruthy();
expect(emptyEl!.textContent).toContain('No events to display');
});
it('should render custom empty message', () => {
host.emptyMessage = 'Nothing here.';
fixture.detectChanges();
expect(el.querySelector('.timeline__empty-text')!.textContent).toContain('Nothing here.');
});
it('should render events with titles', () => {
host.events = SAMPLE_EVENTS;
fixture.detectChanges();
const titles = el.querySelectorAll('.timeline__title');
expect(titles.length).toBe(5);
expect(titles[0].textContent).toContain('Scan completed');
expect(titles[2].textContent).toContain('Finding created');
});
// -----------------------------------------------------------------------
// Severity marker colors
// -----------------------------------------------------------------------
it('should apply correct severity marker classes', () => {
host.events = SAMPLE_EVENTS;
fixture.detectChanges();
const markers = el.querySelectorAll('.timeline__marker');
expect(markers[0].classList.contains('timeline__marker--success')).toBe(true);
expect(markers[1].classList.contains('timeline__marker--info')).toBe(true);
expect(markers[2].classList.contains('timeline__marker--error')).toBe(true);
expect(markers[3].classList.contains('timeline__marker--warning')).toBe(true);
expect(markers[4].classList.contains('timeline__marker--neutral')).toBe(true);
});
it('should render critical marker with distinct styling', () => {
host.events = [{
id: 'crit-1',
timestamp: minutesAgo(1),
title: 'Critical breach',
eventKind: 'critical',
}];
fixture.detectChanges();
const marker = el.querySelector('.timeline__marker');
expect(marker!.classList.contains('timeline__marker--critical')).toBe(true);
});
// -----------------------------------------------------------------------
// Timestamp formatting
// -----------------------------------------------------------------------
it('should display relative time for events less than 24h old', () => {
host.events = [
{ id: 't-1', timestamp: minutesAgo(5), title: '5m event' },
{ id: 't-2', timestamp: hoursAgo(3), title: '3h event' },
];
fixture.detectChanges();
const times = el.querySelectorAll('.timeline__time');
expect(times[0].textContent).toContain('5m ago');
expect(times[1].textContent).toContain('3h ago');
});
it('should display "Just now" for very recent events', () => {
host.events = [{ id: 't-now', timestamp: NOW.toISOString(), title: 'Now event' }];
fixture.detectChanges();
expect(el.querySelector('.timeline__time')!.textContent).toContain('Just now');
});
it('should display absolute UTC time for events older than 24h', () => {
host.events = [{ id: 't-old', timestamp: daysAgo(2), title: 'Old event' }];
fixture.detectChanges();
const timeText = el.querySelector('.timeline__time')!.textContent!.trim();
expect(timeText).toContain('UTC');
expect(timeText).toMatch(/\d{4}-\d{2}-\d{2}/);
});
it('should refresh relative time when flat-mode events change', () => {
host.groupByDate = false;
host.events = [{ id: 't-flat', timestamp: minutesAgo(5), title: 'Flat event' }];
fixture.detectChanges();
expect(el.querySelector('.timeline__time')!.textContent).toContain('5m ago');
vi.setSystemTime(new Date(NOW.getTime() + 10 * 60_000));
host.events = [{ ...host.events[0] }];
fixture.detectChanges();
expect(el.querySelector('.timeline__time')!.textContent).toContain('15m ago');
});
it('should show full ISO timestamp in title attribute (tooltip)', () => {
host.events = [{ id: 't-tip', timestamp: minutesAgo(10), title: 'Tooltip event' }];
fixture.detectChanges();
const timeEl = el.querySelector('.timeline__time') as HTMLElement;
expect(timeEl.getAttribute('title')).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
// -----------------------------------------------------------------------
// Expandable detail sections
// -----------------------------------------------------------------------
it('should show expand button for events with expandable content', () => {
host.events = [SAMPLE_EVENTS[2]]; // evt-3 has expandable
fixture.detectChanges();
const btn = el.querySelector('.timeline__expand-btn');
expect(btn).toBeTruthy();
expect(btn!.textContent).toContain('Show details');
});
it('should toggle expandable section on click', () => {
host.events = [SAMPLE_EVENTS[2]];
fixture.detectChanges();
const btn = el.querySelector('.timeline__expand-btn') as HTMLButtonElement;
expect(el.querySelector('.timeline__expandable')).toBeNull();
btn.click();
fixture.detectChanges();
expect(el.querySelector('.timeline__expandable')).toBeTruthy();
expect(el.querySelector('.timeline__expandable-content')!.textContent).toContain('CVE-2024-12345');
expect(btn.textContent).toContain('Hide details');
btn.click();
fixture.detectChanges();
expect(el.querySelector('.timeline__expandable')).toBeNull();
});
it('should not show expand button for events without expandable content', () => {
host.events = [SAMPLE_EVENTS[0]]; // evt-1 has no expandable
fixture.detectChanges();
expect(el.querySelector('.timeline__expand-btn')).toBeNull();
});
// -----------------------------------------------------------------------
// Optional fields
// -----------------------------------------------------------------------
it('should render actor when provided', () => {
host.events = [SAMPLE_EVENTS[0]]; // has actor
fixture.detectChanges();
const actor = el.querySelector('.timeline__actor');
expect(actor).toBeTruthy();
expect(actor!.textContent).toContain('scanner-worker-01');
});
it('should not render actor when not provided', () => {
host.events = [SAMPLE_EVENTS[1]]; // no actor
fixture.detectChanges();
expect(el.querySelector('.timeline__actor')).toBeNull();
});
it('should render description when provided', () => {
host.events = [SAMPLE_EVENTS[0]];
fixture.detectChanges();
const desc = el.querySelector('.timeline__description');
expect(desc).toBeTruthy();
expect(desc!.textContent).toContain('Container image scan finished');
});
it('should render evidence link when provided', () => {
host.events = [SAMPLE_EVENTS[2]]; // has evidenceLink
fixture.detectChanges();
const link = el.querySelector('.timeline__evidence-link') as HTMLAnchorElement;
expect(link).toBeTruthy();
expect(link.getAttribute('href')).toBe('/findings/CVE-2024-12345');
});
it('should render metadata chips when provided', () => {
host.events = [SAMPLE_EVENTS[0]]; // has metadata
fixture.detectChanges();
const chips = el.querySelectorAll('.timeline__meta-chip');
expect(chips.length).toBe(1);
expect(chips[0].querySelector('.timeline__meta-key')!.textContent).toContain('imageDigest');
expect(chips[0].querySelector('.timeline__meta-value')!.textContent).toContain('sha256:abc123');
});
it('should render material icon when provided', () => {
host.events = [SAMPLE_EVENTS[0]]; // has icon
fixture.detectChanges();
const icon = el.querySelector('.timeline__icon');
expect(icon).toBeTruthy();
expect(icon!.textContent).toContain('check_circle');
});
// -----------------------------------------------------------------------
// Date grouping
// -----------------------------------------------------------------------
it('should group events by date when groupByDate is true', () => {
host.events = SAMPLE_EVENTS;
host.groupByDate = true;
fixture.detectChanges();
const dateHeaders = el.querySelectorAll('.timeline__date-label');
// Events span 3 different days: today (~5m ago, ~3h ago), 2 days ago (2 events), 5 days ago
expect(dateHeaders.length).toBe(3);
});
it('should not group events when groupByDate is false', () => {
host.events = SAMPLE_EVENTS;
host.groupByDate = false;
fixture.detectChanges();
expect(el.querySelector('.timeline__date-group')).toBeNull();
const items = el.querySelectorAll('.timeline__item');
expect(items.length).toBe(5);
});
// -----------------------------------------------------------------------
// Loading state
// -----------------------------------------------------------------------
it('should show loading skeleton when loading is true', () => {
host.loading = true;
fixture.detectChanges();
const skeleton = el.querySelector('.timeline--loading');
expect(skeleton).toBeTruthy();
expect(el.querySelectorAll('.timeline__skeleton-item').length).toBe(5);
});
it('should show sr-only loading text for screen readers', () => {
host.loading = true;
fixture.detectChanges();
const srOnly = el.querySelector('.sr-only');
expect(srOnly).toBeTruthy();
expect(srOnly!.textContent).toContain('Loading timeline');
});
it('should not show loading skeleton when loading is false', () => {
host.loading = false;
fixture.detectChanges();
expect(el.querySelector('.timeline--loading')).toBeNull();
});
// -----------------------------------------------------------------------
// Accessibility
// -----------------------------------------------------------------------
it('should have role="feed" on the timeline container', () => {
host.events = SAMPLE_EVENTS;
fixture.detectChanges();
const timeline = el.querySelector('[role="feed"]');
expect(timeline).toBeTruthy();
});
it('should apply custom ariaLabel', () => {
host.ariaLabel = 'Incident feed';
host.events = SAMPLE_EVENTS;
fixture.detectChanges();
const timeline = el.querySelector('[role="feed"]');
expect(timeline!.getAttribute('aria-label')).toBe('Incident feed');
});
it('should have role="article" on each event item', () => {
host.events = SAMPLE_EVENTS;
fixture.detectChanges();
const articles = el.querySelectorAll('[role="article"]');
expect(articles.length).toBe(5);
});
it('should have aria-label on each event item matching title', () => {
host.events = [SAMPLE_EVENTS[0]];
fixture.detectChanges();
const article = el.querySelector('[role="article"]');
expect(article!.getAttribute('aria-label')).toBe('Scan completed');
});
it('should have aria-expanded on expand button', () => {
host.events = [SAMPLE_EVENTS[2]]; // has expandable
fixture.detectChanges();
const btn = el.querySelector('.timeline__expand-btn');
expect(btn!.getAttribute('aria-expanded')).toBe('false');
(btn as HTMLButtonElement).click();
fixture.detectChanges();
expect(btn!.getAttribute('aria-expanded')).toBe('true');
});
it('should have datetime attribute on time elements', () => {
host.events = [SAMPLE_EVENTS[0]];
fixture.detectChanges();
const timeEl = el.querySelector('.timeline__time');
expect(timeEl!.getAttribute('datetime')).toMatch(/\d{4}-\d{2}-\d{2}T/);
});
// -----------------------------------------------------------------------
// Vertical connector line
// -----------------------------------------------------------------------
it('should not render connector line on the last item', () => {
host.events = SAMPLE_EVENTS.slice(0, 2);
fixture.detectChanges();
const items = el.querySelectorAll('.timeline__item');
expect(items[1].classList.contains('timeline__item--last')).toBe(true);
});
// -----------------------------------------------------------------------
// Default eventKind fallback
// -----------------------------------------------------------------------
it('should default to neutral marker when eventKind is not provided', () => {
host.events = [{ id: 'no-kind', timestamp: minutesAgo(1), title: 'No kind' }];
fixture.detectChanges();
const marker = el.querySelector('.timeline__marker');
expect(marker!.classList.contains('timeline__marker--neutral')).toBe(true);
});
});

View File

@@ -1,71 +1,349 @@
/**
* Timeline List Component
* Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (SHARED-010)
* Sprint: SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation (FE-TLD-001..002)
*
* Chronological event timeline display.
* Canonical audit-grade event-stream timeline.
*
* Features:
* - Vertical timeline with colored severity markers
* - Deterministic UTC ISO-8601 timestamp formatting
* - Relative time for <24h, absolute for older, full ISO on hover
* - Expandable detail sections for event payloads
* - Optional actor/source metadata
* - Date grouping when events span multiple days
* - Loading skeleton and empty states
* - Accessibility (role="feed", aria-labels)
* - Content projection via ng-template for domain-specific rendering
*/
import { Component, Input, ChangeDetectionStrategy, ContentChild, TemplateRef } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
Component,
ChangeDetectionStrategy,
ContentChild,
TemplateRef,
computed,
input,
signal,
} from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
// ---------------------------------------------------------------------------
// Canonical Event Model (FE-TLD-001)
// ---------------------------------------------------------------------------
/**
* Severity / event kind for timeline events.
* Determines marker color and visual weight.
*/
export type TimelineEventKind = 'info' | 'success' | 'warning' | 'error' | 'critical' | 'neutral';
/**
* Canonical timeline event model for all audit/evidence/release chronology surfaces.
*
* Time display rules:
* - Relative time for events < 24h old (e.g. "5m ago", "3h ago")
* - Absolute UTC ISO-8601 for events >= 24h old (e.g. "2026-03-08 14:23 UTC")
* - Full ISO-8601 timestamp always available via tooltip on hover
* - Events are grouped by date when spanning multiple days
*/
export interface TimelineEvent {
id: string;
timestamp: Date | string;
title: string;
description?: string;
icon?: string;
type?: 'success' | 'warning' | 'error' | 'info' | 'neutral';
/** Unique event identifier. */
readonly id: string;
/** ISO-8601 UTC timestamp. */
readonly timestamp: string;
/** Event summary (single line). */
readonly title: string;
/** Optional detail text. */
readonly description?: string;
/** Who or what caused this event (user, service, system). */
readonly actor?: string;
/** Event severity / kind. Defaults to 'neutral'. */
readonly eventKind?: TimelineEventKind;
/** Material icon name (optional). */
readonly icon?: string;
/** Link to related evidence (optional URL). */
readonly evidenceLink?: string;
/** Arbitrary key-value metadata. */
readonly metadata?: Record<string, string>;
/** Expandable detail payload (rendered in collapsible section). */
readonly expandable?: string;
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
interface DateGroup {
dateLabel: string;
events: TimelineEvent[];
}
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
/**
* Format a timestamp for display.
* - < 1 minute: "Just now"
* - < 60 minutes: "Xm ago"
* - < 24 hours: "Xh ago"
* - >= 24 hours: "YYYY-MM-DD HH:mm UTC"
*/
function formatDisplayTime(iso: string, now: Date): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
const diffMs = now.getTime() - date.getTime();
if (diffMs < 0) {
// Future event: show absolute
return formatAbsoluteUtc(date);
}
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMs / 3600000);
if (diffHours < 24) return `${diffHours}h ago`;
return formatAbsoluteUtc(date);
}
function formatAbsoluteUtc(date: Date): string {
const y = date.getUTCFullYear();
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
const d = String(date.getUTCDate()).padStart(2, '0');
const hh = String(date.getUTCHours()).padStart(2, '0');
const mm = String(date.getUTCMinutes()).padStart(2, '0');
return `${y}-${m}-${d} ${hh}:${mm} UTC`;
}
function toIsoFull(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toISOString();
}
function toDateKey(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return 'Unknown';
return date.toLocaleDateString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@Component({
selector: 'app-timeline-list',
standalone: true,
imports: [CommonModule],
imports: [NgTemplateOutlet],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="timeline">
@for (event of events; track event.id; let last = $last) {
<div class="timeline__item" [class.timeline__item--last]="last">
<div class="timeline__marker" [class]="'timeline__marker--' + (event.type || 'neutral')">
@if (event.icon) {
<span>{{ event.icon }}</span>
}
</div>
<div class="timeline__content">
<div class="timeline__header">
<span class="timeline__title">{{ event.title }}</span>
<time class="timeline__time">{{ formatTime(event.timestamp) }}</time>
<!-- Loading skeleton -->
@if (loading()) {
<div class="timeline timeline--loading" role="status" aria-label="Loading timeline events">
@for (i of skeletonRows; track i) {
<div class="timeline__skeleton-item">
<div class="timeline__skeleton-marker"></div>
<div class="timeline__skeleton-content">
<div class="timeline__skeleton-title"></div>
<div class="timeline__skeleton-desc"></div>
</div>
@if (event.description) {
<p class="timeline__description">{{ event.description }}</p>
}
@if (eventTemplate) {
<ng-container *ngTemplateOutlet="eventTemplate; context: { $implicit: event }"></ng-container>
}
</div>
}
<span class="sr-only">Loading timeline...</span>
</div>
} @else {
<div
class="timeline"
role="feed"
[attr.aria-label]="ariaLabel()"
[attr.aria-busy]="false"
>
@if (groupByDate()) {
<!-- Grouped by date -->
@for (group of dateGroups(); track group.dateLabel) {
<div class="timeline__date-group">
<div class="timeline__date-header" role="separator">
<span class="timeline__date-label">{{ group.dateLabel }}</span>
</div>
@for (event of group.events; track event.id; let last = $last; let idx = $index) {
<ng-container
*ngTemplateOutlet="eventRow; context: { $implicit: event, last: last && $last }"
></ng-container>
}
</div>
} @empty {
<ng-container *ngTemplateOutlet="emptyState"></ng-container>
}
} @else {
<!-- Flat list -->
@for (event of renderedEvents(); track event.id; let last = $last) {
<ng-container
*ngTemplateOutlet="eventRow; context: { $implicit: event, last: last }"
></ng-container>
} @empty {
<ng-container *ngTemplateOutlet="emptyState"></ng-container>
}
}
</div>
}
<!-- Event row template -->
<ng-template #eventRow let-event let-last="last">
<article
class="timeline__item"
[class.timeline__item--last]="last"
role="article"
[attr.aria-label]="event.title"
>
<div
class="timeline__marker"
[class]="'timeline__marker--' + (event.eventKind || 'neutral')"
aria-hidden="true"
>
@if (event.icon) {
<span class="timeline__icon material-symbols-outlined">{{ event.icon }}</span>
}
</div>
} @empty {
<div class="timeline__empty">No events to display</div>
}
</div>
<div class="timeline__content">
<div class="timeline__header">
<span class="timeline__title">{{ event.title }}</span>
<time
class="timeline__time"
[attr.datetime]="toIsoFull(event.timestamp)"
[title]="toIsoFull(event.timestamp)"
>{{ formatTime(event.timestamp) }}</time>
</div>
@if (event.actor) {
<span class="timeline__actor">{{ event.actor }}</span>
}
@if (event.description) {
<p class="timeline__description">{{ event.description }}</p>
}
@if (event.evidenceLink) {
<a
class="timeline__evidence-link"
[href]="event.evidenceLink"
target="_blank"
rel="noopener"
>View evidence</a>
}
@if (event.metadata && hasKeys(event.metadata)) {
<div class="timeline__metadata">
@for (entry of objectEntries(event.metadata); track entry[0]) {
<span class="timeline__meta-chip">
<span class="timeline__meta-key">{{ entry[0] }}</span>
<span class="timeline__meta-value">{{ entry[1] }}</span>
</span>
}
</div>
}
@if (event.expandable) {
<button
type="button"
class="timeline__expand-btn"
[attr.aria-expanded]="isExpanded(event.id)"
(click)="toggleExpand(event.id)"
>
{{ isExpanded(event.id) ? 'Hide details' : 'Show details' }}
</button>
@if (isExpanded(event.id)) {
<div class="timeline__expandable" role="region" aria-label="Event details">
<pre class="timeline__expandable-content">{{ event.expandable }}</pre>
</div>
}
}
@if (eventTemplate) {
<ng-container
*ngTemplateOutlet="eventTemplate; context: { $implicit: event }"
></ng-container>
}
</div>
</article>
</ng-template>
<!-- Empty state template -->
<ng-template #emptyState>
<div class="timeline__empty" role="status">
<span class="timeline__empty-icon material-symbols-outlined" aria-hidden="true">event_busy</span>
<p class="timeline__empty-text">{{ emptyMessage() }}</p>
</div>
</ng-template>
`,
styles: [`
/* ------------------------------------------------------------------ */
/* Shared timeline primitive */
/* ------------------------------------------------------------------ */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.timeline {
position: relative;
}
/* ---- Date group header ---- */
.timeline__date-group {
margin-bottom: 0.25rem;
}
.timeline__date-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0 0.5rem 0.25rem;
}
.timeline__date-header::after {
content: '';
flex: 1;
height: 1px;
background: var(--color-border-primary);
}
.timeline__date-label {
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
white-space: nowrap;
}
/* ---- Event item ---- */
.timeline__item {
display: flex;
gap: 1rem;
gap: 0.75rem;
position: relative;
padding-bottom: 1.5rem;
padding-bottom: 1.25rem;
}
.timeline__item::before {
content: '';
position: absolute;
left: 0.5rem;
top: 1.25rem;
left: 0.5625rem;
top: 1.375rem;
bottom: 0;
width: 2px;
background: var(--color-border-primary);
@@ -75,6 +353,7 @@ export interface TimelineEvent {
display: none;
}
/* ---- Marker ---- */
.timeline__marker {
flex-shrink: 0;
width: 1.25rem;
@@ -87,6 +366,11 @@ export interface TimelineEvent {
z-index: 1;
}
.timeline__icon {
font-size: 0.75rem;
line-height: 1;
}
.timeline__marker--success {
background: var(--color-severity-low-bg);
color: var(--color-status-success-text);
@@ -102,6 +386,12 @@ export interface TimelineEvent {
color: var(--color-status-error-text);
}
.timeline__marker--critical {
background: var(--color-severity-critical-bg);
color: var(--color-status-error-text);
box-shadow: 0 0 0 2px var(--color-status-error-text);
}
.timeline__marker--info {
background: var(--color-severity-info-bg);
color: var(--color-status-info-text);
@@ -112,6 +402,7 @@ export interface TimelineEvent {
color: var(--color-text-secondary);
}
/* ---- Content ---- */
.timeline__content {
flex: 1;
min-width: 0;
@@ -131,40 +422,265 @@ export interface TimelineEvent {
}
.timeline__time {
font-size: 0.75rem;
font-size: 0.6875rem;
font-family: 'JetBrains Mono', 'SF Mono', monospace;
color: var(--color-text-secondary);
white-space: nowrap;
cursor: help;
}
.timeline__actor {
display: inline-block;
font-size: 0.6875rem;
color: var(--color-text-secondary);
margin-top: 0.125rem;
}
.timeline__actor::before {
content: 'by ';
}
.timeline__description {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
color: var(--color-text-secondary);
line-height: 1.4;
}
.timeline__evidence-link {
display: inline-block;
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--color-brand-primary);
text-decoration: none;
}
.timeline__evidence-link:hover {
text-decoration: underline;
}
/* ---- Metadata chips ---- */
.timeline__metadata {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.375rem;
}
.timeline__meta-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.0625rem 0.375rem;
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
font-size: 0.6875rem;
}
.timeline__meta-key {
color: var(--color-text-secondary);
}
.timeline__meta-value {
color: var(--color-text-primary);
font-family: 'JetBrains Mono', 'SF Mono', monospace;
}
/* ---- Expandable ---- */
.timeline__expand-btn {
display: inline-block;
margin-top: 0.375rem;
padding: 0;
border: none;
background: none;
font-size: 0.75rem;
color: var(--color-brand-primary);
cursor: pointer;
}
.timeline__expand-btn:hover {
text-decoration: underline;
}
.timeline__expand-btn:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
.timeline__expandable {
margin-top: 0.375rem;
padding: 0.5rem 0.75rem;
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
}
.timeline__expandable-content {
margin: 0;
font-family: 'JetBrains Mono', 'SF Mono', monospace;
font-size: 0.75rem;
color: var(--color-text-primary);
white-space: pre-wrap;
word-break: break-word;
}
/* ---- Empty ---- */
.timeline__empty {
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2.5rem 1rem;
text-align: center;
}
.timeline__empty-icon {
font-size: 2rem;
color: var(--color-text-muted);
}
.timeline__empty-text {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
/* ---- Loading skeleton ---- */
.timeline--loading .timeline__skeleton-item {
display: flex;
gap: 0.75rem;
padding-bottom: 1.25rem;
}
.timeline__skeleton-marker {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
border-radius: var(--radius-full);
background: var(--color-surface-secondary);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
.timeline__skeleton-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.timeline__skeleton-title {
width: 60%;
height: 0.875rem;
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
.timeline__skeleton-desc {
width: 40%;
height: 0.625rem;
border-radius: var(--radius-sm);
background: var(--color-surface-secondary);
animation: skeleton-pulse 1.5s ease-in-out infinite;
animation-delay: 0.2s;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
`]
})
export class TimelineListComponent {
@Input() events: TimelineEvent[] = [];
/** Events to display. */
readonly events = input<TimelineEvent[]>([]);
/** Whether to show loading skeleton. */
readonly loading = input<boolean>(false);
/** Whether to group events by date (when events span multiple days). */
readonly groupByDate = input<boolean>(false);
/** Empty state message. */
readonly emptyMessage = input<string>('No events to display');
/** Accessible label for the feed container. */
readonly ariaLabel = input<string>('Event timeline');
/** Optional content projection template for domain-specific rendering. */
@ContentChild('eventContent') eventTemplate?: TemplateRef<unknown>;
formatTime(timestamp: Date | string): string {
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
/** Expanded event IDs. */
private readonly expandedIds = signal<Set<string>>(new Set());
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
/** Skeleton row count for loading state. */
readonly skeletonRows = [0, 1, 2, 3, 4];
/** Cached "now" for consistent relative time within a render cycle. */
private renderNow = new Date();
/** Flat and grouped modes share the same render clock refresh path. */
readonly renderedEvents = computed<TimelineEvent[]>(() => {
const evts = this.events();
this.renderNow = new Date();
return evts;
});
/** Computed date groups for grouped display. */
readonly dateGroups = computed<DateGroup[]>(() => {
const evts = this.renderedEvents();
if (!evts.length) return [];
const groups = new Map<string, TimelineEvent[]>();
for (const evt of evts) {
const key = toDateKey(evt.timestamp);
const arr = groups.get(key) ?? [];
arr.push(evt);
groups.set(key, arr);
}
return Array.from(groups.entries()).map(([dateLabel, events]) => ({
dateLabel,
events,
}));
});
// Template helper: format display time
formatTime(iso: string): string {
return formatDisplayTime(iso, this.renderNow);
}
// Template helper: full ISO timestamp for tooltip
toIsoFull(iso: string): string {
return toIsoFull(iso);
}
// Template helper: check if metadata has keys
hasKeys(obj: Record<string, string>): boolean {
return Object.keys(obj).length > 0;
}
// Template helper: Object.entries for template iteration
objectEntries(obj: Record<string, string>): [string, string][] {
return Object.entries(obj);
}
// Expand/collapse
isExpanded(id: string): boolean {
return this.expandedIds().has(id);
}
toggleExpand(id: string): void {
this.expandedIds.update(ids => {
const next = new Set(ids);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
}

View File

@@ -0,0 +1,133 @@
/**
* Attestation Detail Component Tests
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-004)
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, signal } from '@angular/core';
import { AttestationDetailComponent } from './attestation-detail.component';
import type { AttestationData } from './witness.models';
@Component({
standalone: true,
imports: [AttestationDetailComponent],
template: `<app-attestation-detail [data]="data()" />`,
})
class TestHostComponent {
data = signal<AttestationData | null>(null);
}
describe('AttestationDetailComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('should render the attestation detail section', () => {
const section = fixture.nativeElement.querySelector('[data-testid="attestation-detail"]');
expect(section).toBeTruthy();
});
it('should show empty state when no data', () => {
const empty = fixture.nativeElement.querySelector('[data-testid="attestation-empty"]');
expect(empty).toBeTruthy();
expect(empty.textContent).toContain('No attestation data available');
});
it('should display predicate type', () => {
host.data.set({
predicateType: 'https://in-toto.io/attestation/vulns/v0.1',
subjectName: 'registry.example.com/app:v1.2',
subjectDigests: [
{ algorithm: 'sha256', hash: 'abc123def456' },
],
predicate: { scanner: 'grype', version: '0.72' },
});
fixture.detectChanges();
const predicateType = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-type"]');
expect(predicateType).toBeTruthy();
expect(predicateType.textContent).toContain('https://in-toto.io/attestation/vulns/v0.1');
});
it('should display subject name and digests', () => {
host.data.set({
predicateType: 'https://in-toto.io/attestation/sbom/v0.1',
subjectName: 'registry.example.com/app:v1.2',
subjectDigests: [
{ algorithm: 'sha256', hash: 'abc123' },
{ algorithm: 'sha512', hash: 'def456' },
],
predicate: {},
});
fixture.detectChanges();
const subject = fixture.nativeElement.querySelector('[data-testid="attestation-subject"]');
expect(subject).toBeTruthy();
expect(subject.textContent).toContain('registry.example.com/app:v1.2');
const digestRows = fixture.nativeElement.querySelectorAll('.digest-row');
expect(digestRows.length).toBe(2);
expect(digestRows[0].textContent).toContain('sha256');
expect(digestRows[0].textContent).toContain('abc123');
expect(digestRows[1].textContent).toContain('sha512');
});
it('should toggle predicate JSON on click', () => {
host.data.set({
predicateType: 'https://in-toto.io/attestation/sbom/v0.1',
subjectName: 'app:v1',
subjectDigests: [],
predicate: { scanner: 'grype', version: '0.72' },
});
fixture.detectChanges();
// Initially hidden
let predicateJson = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-json"]');
expect(predicateJson).toBeNull();
// Click toggle
const toggle = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-toggle"]');
expect(toggle).toBeTruthy();
toggle.click();
fixture.detectChanges();
// Now visible
predicateJson = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-json"]');
expect(predicateJson).toBeTruthy();
expect(predicateJson.textContent).toContain('grype');
expect(predicateJson.textContent).toContain('0.72');
});
it('should hide predicate JSON when toggled off', () => {
host.data.set({
predicateType: 'type',
subjectName: 'subj',
subjectDigests: [],
predicate: { key: 'value' },
});
fixture.detectChanges();
const toggle = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-toggle"]');
toggle.click();
fixture.detectChanges();
// Verify visible
expect(fixture.nativeElement.querySelector('[data-testid="attestation-predicate-json"]')).toBeTruthy();
// Toggle off
toggle.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="attestation-predicate-json"]')).toBeNull();
});
});

View File

@@ -0,0 +1,219 @@
/**
* Attestation Detail Component
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-002)
*
* Composable section displaying attestation statement type, subject digests,
* and predicate payload. Embeddable in Reachability witness and Evidence
* proof surfaces.
*/
import {
Component,
ChangeDetectionStrategy,
input,
signal,
} from '@angular/core';
import type { AttestationData } from './witness.models';
@Component({
selector: 'app-attestation-detail',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="attestation-detail" data-testid="attestation-detail">
<h3 class="section-title">Attestation</h3>
@if (!data()) {
<div class="empty-state" data-testid="attestation-empty">
No attestation data available.
</div>
} @else {
<dl class="attestation-grid">
<dt>Predicate Type</dt>
<dd><code data-testid="attestation-predicate-type">{{ data()!.predicateType }}</code></dd>
<dt>Subject</dt>
<dd>
<div class="subject-info">
<span class="subject-name" data-testid="attestation-subject">{{ data()!.subjectName }}</span>
<div class="subject-digests">
@for (digest of data()!.subjectDigests; track digest.algorithm) {
<div class="digest-row">
<span class="digest-algorithm">{{ digest.algorithm }}:</span>
<code class="digest-value">{{ digest.hash }}</code>
</div>
}
</div>
</div>
</dd>
</dl>
<!-- Predicate JSON (collapsible) -->
<div class="predicate-section">
<button
type="button"
class="predicate-toggle"
(click)="showPredicate.set(!showPredicate())"
[attr.aria-expanded]="showPredicate()"
data-testid="attestation-predicate-toggle"
>
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true"
[class.rotated]="showPredicate()"
>
<polyline points="9 18 15 12 9 6"/>
</svg>
Predicate Data
</button>
@if (showPredicate()) {
<pre class="predicate-json" data-testid="attestation-predicate-json">{{ predicateJson() }}</pre>
}
</div>
}
</section>
`,
styles: [`
.attestation-detail {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-secondary);
padding: 1rem;
}
.section-title {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.empty-state {
padding: 1rem;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.attestation-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.5rem 1rem;
margin: 0 0 0.75rem 0;
}
.attestation-grid dt {
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.attestation-grid dd {
margin: 0;
}
code {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.8125rem;
background: var(--color-severity-none-bg);
padding: 0.1rem 0.35rem;
border-radius: var(--radius-sm);
}
.subject-info {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.subject-name {
font-weight: var(--font-weight-medium);
font-size: 0.875rem;
}
.subject-digests {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.digest-row {
display: flex;
gap: 0.5rem;
align-items: baseline;
}
.digest-algorithm {
font-size: 0.72rem;
color: var(--color-text-muted);
min-width: 55px;
}
.digest-value {
font-size: 0.72rem;
word-break: break-all;
}
.predicate-section {
border-top: 1px solid var(--color-border-primary);
padding-top: 0.65rem;
}
.predicate-toggle {
display: inline-flex;
align-items: center;
gap: 0.35rem;
background: none;
border: none;
color: var(--color-brand-primary);
cursor: pointer;
font-size: 0.82rem;
font-weight: var(--font-weight-medium);
padding: 0;
}
.predicate-toggle svg {
transition: transform 0.15s ease;
}
.predicate-toggle svg.rotated {
transform: rotate(90deg);
}
.predicate-json {
background: var(--color-text-heading);
color: var(--color-severity-none-bg);
padding: 0.85rem;
border-radius: var(--radius-md);
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.78rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
margin: 0.5rem 0 0;
}
`],
})
export class AttestationDetailComponent {
/** Attestation data to display (null when not available). */
readonly data = input<AttestationData | null>(null);
/** Whether the predicate JSON is expanded. */
readonly showPredicate = signal(false);
/** Formatted predicate JSON. */
readonly predicateJson = () => {
const d = this.data();
if (!d) return '';
try {
return JSON.stringify(d.predicate, null, 2);
} catch {
return String(d.predicate);
}
};
}

View File

@@ -0,0 +1,117 @@
/**
* Evidence Payload Component Tests
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-004)
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, signal } from '@angular/core';
import { EvidencePayloadComponent } from './evidence-payload.component';
import type { EvidencePayloadData } from './witness.models';
@Component({
standalone: true,
imports: [EvidencePayloadComponent],
template: `<app-evidence-payload [data]="data()" />`,
})
class TestHostComponent {
data = signal<EvidencePayloadData>({
evidenceId: 'ev-001',
rawContent: '{"type":"attestation","verified":true}',
metadata: {
source: 'scanner',
version: '0.72',
},
});
}
describe('EvidencePayloadComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('should render the evidence payload section', () => {
const section = fixture.nativeElement.querySelector('[data-testid="evidence-payload"]');
expect(section).toBeTruthy();
});
it('should show the "Show Raw Content" button initially', () => {
const showBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-show"]');
expect(showBtn).toBeTruthy();
expect(showBtn.textContent).toContain('Show Raw Content');
});
it('should show raw content when toggled', () => {
const showBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-show"]');
showBtn.click();
fixture.detectChanges();
const raw = fixture.nativeElement.querySelector('[data-testid="evidence-payload-raw"]');
expect(raw).toBeTruthy();
expect(raw.textContent).toContain('attestation');
expect(raw.textContent).toContain('true');
});
it('should have copy and download action buttons', () => {
const copyBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-copy"]');
const downloadBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-download"]');
expect(copyBtn).toBeTruthy();
expect(downloadBtn).toBeTruthy();
expect(copyBtn.textContent).toContain('Copy');
expect(downloadBtn.textContent).toContain('Download');
});
it('should display metadata section when metadata is provided', () => {
const metadata = fixture.nativeElement.querySelector('[data-testid="evidence-payload-metadata"]');
expect(metadata).toBeTruthy();
expect(metadata.textContent).toContain('scanner');
expect(metadata.textContent).toContain('0.72');
});
it('should not display metadata when empty', () => {
host.data.set({
evidenceId: 'ev-002',
rawContent: '{}',
metadata: {},
});
fixture.detectChanges();
const metadata = fixture.nativeElement.querySelector('[data-testid="evidence-payload-metadata"]');
expect(metadata).toBeNull();
});
it('should not display metadata when undefined', () => {
host.data.set({
evidenceId: 'ev-003',
rawContent: '{}',
});
fixture.detectChanges();
const metadata = fixture.nativeElement.querySelector('[data-testid="evidence-payload-metadata"]');
expect(metadata).toBeNull();
});
it('should hide raw content when toggled off', () => {
// Show
const showBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-show"]');
showBtn.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="evidence-payload-raw"]')).toBeTruthy();
// Hide via the "Hide raw content" link
const hideBtn = fixture.nativeElement.querySelector('.btn-link');
hideBtn.click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="evidence-payload-raw"]')).toBeNull();
});
});

View File

@@ -0,0 +1,245 @@
/**
* Evidence Payload Component
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-002)
*
* Composable section for viewing, copying, and downloading raw evidence
* JSON payloads and metadata. Embeddable in Reachability witness and
* Evidence proof views.
*/
import {
Component,
ChangeDetectionStrategy,
input,
signal,
computed,
} from '@angular/core';
import type { EvidencePayloadData } from './witness.models';
@Component({
selector: 'app-evidence-payload',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="evidence-payload" data-testid="evidence-payload">
<div class="section-header">
<h3 class="section-title">Raw Evidence</h3>
<div class="section-actions">
<button
type="button"
class="btn-action"
(click)="copyPayload()"
data-testid="evidence-payload-copy"
>
@if (copied()) {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true"
style="display:inline;vertical-align:middle">
<polyline points="20 6 9 17 4 12"/>
</svg>
Copied
} @else {
Copy
}
</button>
<button
type="button"
class="btn-action"
(click)="downloadPayload()"
data-testid="evidence-payload-download"
>
Download
</button>
</div>
</div>
<!-- Raw Content (collapsible) -->
@if (showRaw()) {
<div class="raw-wrapper">
<pre class="raw-content" data-testid="evidence-payload-raw">{{ data().rawContent }}</pre>
<button
type="button"
class="btn-link"
(click)="showRaw.set(false)"
>
Hide raw content
</button>
</div>
} @else {
<button
type="button"
class="show-raw-btn"
(click)="showRaw.set(true)"
data-testid="evidence-payload-show"
>
Show Raw Content
</button>
}
<!-- Metadata (when available) -->
@if (hasMetadata()) {
<div class="metadata-section">
<h4 class="metadata-title">Metadata</h4>
<pre class="metadata-json" data-testid="evidence-payload-metadata">{{ metadataJson() }}</pre>
</div>
}
</section>
`,
styles: [`
.evidence-payload {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-secondary);
padding: 1rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.section-title {
margin: 0;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.section-actions {
display: flex;
gap: 0.4rem;
}
.btn-action {
padding: 0.35rem 0.65rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-primary);
font-size: 0.78rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.btn-action:hover {
background: var(--color-severity-none-bg);
}
.show-raw-btn {
width: 100%;
padding: 0.55rem 0.85rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-size: 0.82rem;
cursor: pointer;
}
.show-raw-btn:hover {
background: var(--color-severity-none-bg);
}
.raw-wrapper {
display: grid;
gap: 0.35rem;
}
.raw-content,
.metadata-json {
background: var(--color-text-heading);
color: var(--color-severity-none-bg);
padding: 0.85rem;
border-radius: var(--radius-md);
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.78rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
max-height: 350px;
overflow-y: auto;
margin: 0;
}
.btn-link {
background: none;
border: none;
color: var(--color-brand-primary);
cursor: pointer;
padding: 0;
font-size: 0.75rem;
text-decoration: underline;
justify-self: start;
}
.metadata-section {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border-primary);
}
.metadata-title {
margin: 0 0 0.5rem 0;
font-size: 0.78rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
}
`],
})
export class EvidencePayloadComponent {
/** Payload data to display. */
readonly data = input.required<EvidencePayloadData>();
/** Whether the raw content is expanded. */
readonly showRaw = signal(false);
/** Whether copy feedback is active. */
readonly copied = signal(false);
/** Whether metadata is non-empty. */
readonly hasMetadata = computed(() => {
const meta = this.data().metadata;
return meta != null && Object.keys(meta).length > 0;
});
/** Formatted metadata JSON. */
readonly metadataJson = computed(() => {
const meta = this.data().metadata;
if (!meta) return '';
try {
return JSON.stringify(meta, null, 2);
} catch {
return String(meta);
}
});
/** Copy raw content to clipboard. */
copyPayload(): void {
const content = this.data().rawContent;
navigator.clipboard.writeText(content).then(() => {
this.copied.set(true);
setTimeout(() => this.copied.set(false), 2000);
});
}
/** Download raw content as JSON file. */
downloadPayload(): void {
const content = this.data().rawContent;
const id = this.data().evidenceId;
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `evidence-${id}.json`;
anchor.click();
URL.revokeObjectURL(url);
}
}

View File

@@ -0,0 +1,21 @@
/**
* Shared Witness/Evidence Proof-Inspection Sections
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation
*
* Reusable composable sections derived from the orphan WitnessViewerComponent.
* Designed for embedding in mounted Reachability and Evidence surfaces.
*/
export { VerificationSummaryComponent } from './verification-summary.component';
export { SignatureInspectorComponent } from './signature-inspector.component';
export { AttestationDetailComponent } from './attestation-detail.component';
export { EvidencePayloadComponent } from './evidence-payload.component';
export type {
VerificationSummaryData,
SignatureData,
AttestationData,
EvidencePayloadData,
VerificationStatus,
ConfidenceTier,
} from './witness.models';

View File

@@ -0,0 +1,153 @@
/**
* Signature Inspector Component Tests
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-004)
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, signal } from '@angular/core';
import { SignatureInspectorComponent } from './signature-inspector.component';
import type { SignatureData } from './witness.models';
@Component({
standalone: true,
imports: [SignatureInspectorComponent],
template: `<app-signature-inspector [signatures]="signatures()" />`,
})
class TestHostComponent {
signatures = signal<readonly SignatureData[]>([]);
}
describe('SignatureInspectorComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('should render the signature inspector section', () => {
const section = fixture.nativeElement.querySelector('[data-testid="signature-inspector"]');
expect(section).toBeTruthy();
});
it('should show empty state when no signatures', () => {
const empty = fixture.nativeElement.querySelector('[data-testid="signature-empty"]');
expect(empty).toBeTruthy();
expect(empty.textContent).toContain('No signatures available');
});
it('should render a verified signature card', () => {
host.signatures.set([{
id: 'sig-001',
algorithm: 'ECDSA-P256',
keyId: 'key-abc-123',
value: 'MEUCIQD+base64signaturevaluehere==',
timestamp: '2026-03-08T10:30:00Z',
verified: true,
issuer: 'Stella Ops CA',
}]);
fixture.detectChanges();
const card = fixture.nativeElement.querySelector('[data-testid="signature-card-sig-001"]');
expect(card).toBeTruthy();
expect(card.classList.contains('signature-card--verified')).toBe(true);
expect(card.textContent).toContain('Verified');
expect(card.textContent).toContain('ECDSA-P256');
expect(card.textContent).toContain('key-abc-123');
expect(card.textContent).toContain('Stella Ops CA');
});
it('should render an unverified signature card', () => {
host.signatures.set([{
id: 'sig-002',
algorithm: 'Ed25519',
keyId: 'key-def-456',
value: 'shortval',
verified: false,
}]);
fixture.detectChanges();
const card = fixture.nativeElement.querySelector('[data-testid="signature-card-sig-002"]');
expect(card).toBeTruthy();
expect(card.classList.contains('signature-card--verified')).toBe(false);
expect(card.textContent).toContain('Unverified');
expect(card.textContent).toContain('Ed25519');
});
it('should render multiple signature cards', () => {
host.signatures.set([
{
id: 'sig-a',
algorithm: 'ECDSA-P256',
keyId: 'key-1',
value: 'sig-value-a',
verified: true,
},
{
id: 'sig-b',
algorithm: 'RSA-PSS',
keyId: 'key-2',
value: 'sig-value-b',
verified: false,
},
]);
fixture.detectChanges();
const cards = fixture.nativeElement.querySelectorAll('.signature-card');
expect(cards.length).toBe(2);
});
it('should truncate long signature values', () => {
const longSig = 'A'.repeat(100);
host.signatures.set([{
id: 'sig-long',
algorithm: 'ECDSA-P256',
keyId: 'key-long',
value: longSig,
verified: true,
}]);
fixture.detectChanges();
const sigValue = fixture.nativeElement.querySelector('.signature-value');
expect(sigValue).toBeTruthy();
// Truncated: 16 chars + '...' + 16 chars = 35 chars, not 100
expect(sigValue.textContent!.length).toBeLessThan(100);
expect(sigValue.textContent).toContain('...');
});
it('should show copy button for long signatures', () => {
host.signatures.set([{
id: 'sig-copy',
algorithm: 'ECDSA-P256',
keyId: 'key-copy',
value: 'A'.repeat(100),
verified: true,
}]);
fixture.detectChanges();
const copyBtn = fixture.nativeElement.querySelector('[data-testid="copy-sig-sig-copy"]');
expect(copyBtn).toBeTruthy();
expect(copyBtn.textContent).toContain('Copy full');
});
it('should not show copy button for short signatures', () => {
host.signatures.set([{
id: 'sig-short',
algorithm: 'ECDSA-P256',
keyId: 'key-short',
value: 'short',
verified: true,
}]);
fixture.detectChanges();
const copyBtn = fixture.nativeElement.querySelector('[data-testid="copy-sig-sig-short"]');
expect(copyBtn).toBeNull();
});
});

View File

@@ -0,0 +1,265 @@
/**
* Signature Inspector Component
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-002)
*
* Composable section showing signature details: algorithm, key ID, verification
* result, and truncated/expandable signature value. Embeddable in mounted
* Reachability witness detail and Evidence packet views.
*/
import {
Component,
ChangeDetectionStrategy,
input,
signal,
} from '@angular/core';
import type { SignatureData } from './witness.models';
@Component({
selector: 'app-signature-inspector',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="signature-inspector" data-testid="signature-inspector">
<h3 class="section-title">
Signatures
@if (signatures().length) {
<span class="section-count">({{ signatures().length }})</span>
}
</h3>
@if (signatures().length === 0) {
<div class="empty-state" data-testid="signature-empty">
No signatures available for this evidence.
</div>
} @else {
<div class="signatures-list">
@for (sig of signatures(); track sig.id) {
<div
class="signature-card"
[class.signature-card--verified]="sig.verified"
[attr.data-testid]="'signature-card-' + sig.id"
>
<div class="signature-card__header">
<span class="signature-card__status">
@if (sig.verified) {
<span class="verified-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<polyline points="20 6 9 17 4 12"/>
</svg>
</span>
Verified
} @else {
<span class="unverified-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
</svg>
</span>
Unverified
}
</span>
<span class="signature-card__algorithm">{{ sig.algorithm }}</span>
</div>
<dl class="signature-card__details">
<dt>Key ID</dt>
<dd><code>{{ sig.keyId }}</code></dd>
@if (sig.issuer) {
<dt>Issuer</dt>
<dd>{{ sig.issuer }}</dd>
}
@if (sig.timestamp) {
<dt>Timestamp</dt>
<dd>{{ formatDate(sig.timestamp) }}</dd>
}
<dt>Signature</dt>
<dd class="signature-value-cell">
<code class="signature-value">{{ truncateSignature(sig.value) }}</code>
@if (sig.value.length > 40) {
<button
type="button"
class="btn-link btn-small"
(click)="copySignature(sig.value)"
[attr.data-testid]="'copy-sig-' + sig.id"
>
{{ copiedId() === sig.id ? 'Copied' : 'Copy full' }}
</button>
}
</dd>
</dl>
</div>
}
</div>
}
</section>
`,
styles: [`
.signature-inspector {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-secondary);
padding: 1rem;
}
.section-title {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.section-count {
font-weight: var(--font-weight-regular, 400);
color: var(--color-text-muted);
}
.empty-state {
padding: 1rem;
color: var(--color-text-secondary);
font-size: 0.875rem;
}
.signatures-list {
display: grid;
gap: 0.75rem;
}
.signature-card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
padding: 0.85rem;
background: var(--color-surface-primary);
}
.signature-card--verified {
border-color: var(--color-severity-low-border);
background: var(--color-severity-low-bg);
}
.signature-card__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.6rem;
}
.signature-card__status {
display: flex;
align-items: center;
gap: 0.25rem;
font-weight: var(--font-weight-medium);
font-size: 0.875rem;
}
.verified-icon {
color: var(--color-status-success-text);
}
.unverified-icon {
color: var(--color-text-muted);
}
.signature-card__algorithm {
background: var(--color-border-primary);
padding: 0.125rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
}
.signature-card__details {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.35rem 0.85rem;
margin: 0;
font-size: 0.875rem;
}
.signature-card__details dt {
color: var(--color-text-muted);
}
.signature-card__details dd {
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
code {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.8125rem;
}
.signature-value {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.signature-value-cell {
flex-wrap: wrap;
}
.btn-link {
background: none;
border: none;
color: var(--color-brand-primary);
cursor: pointer;
padding: 0;
font-size: 0.75rem;
text-decoration: underline;
}
.btn-small {
font-size: 0.72rem;
}
`],
})
export class SignatureInspectorComponent {
/** List of signatures to display. */
readonly signatures = input.required<readonly SignatureData[]>();
/** Track which signature was just copied. */
readonly copiedId = signal<string | null>(null);
truncateSignature(value: string): string {
if (value.length <= 40) return value;
return `${value.slice(0, 16)}...${value.slice(-16)}`;
}
copySignature(value: string): void {
navigator.clipboard.writeText(value).then(() => {
// Find the matching signature for feedback
const sig = this.signatures().find(s => s.value === value);
if (sig) {
this.copiedId.set(sig.id);
setTimeout(() => this.copiedId.set(null), 2000);
}
});
}
formatDate(isoDate: string): string {
try {
return new Intl.DateTimeFormat('en-US', {
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
month: 'short',
timeZone: 'UTC',
year: 'numeric',
}).format(new Date(isoDate));
} catch {
return isoDate;
}
}
}

View File

@@ -0,0 +1,144 @@
/**
* Verification Summary Component Tests
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-004)
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Component, signal } from '@angular/core';
import { VerificationSummaryComponent } from './verification-summary.component';
import type { VerificationSummaryData } from './witness.models';
@Component({
standalone: true,
imports: [VerificationSummaryComponent],
template: `<app-verification-summary [data]="data()" />`,
})
class TestHostComponent {
data = signal<VerificationSummaryData>({
id: 'witness-001',
typeLabel: 'Witness',
typeBadge: 'witness',
status: 'verified',
confidenceTier: 'confirmed',
confidenceScore: 0.95,
createdAt: '2026-03-08T10:00:00Z',
source: 'static',
});
}
describe('VerificationSummaryComponent', () => {
let fixture: ComponentFixture<TestHostComponent>;
let host: TestHostComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestHostComponent);
host = fixture.componentInstance;
fixture.detectChanges();
});
it('should render the verification summary section', () => {
const section = fixture.nativeElement.querySelector('[data-testid="verification-summary"]');
expect(section).toBeTruthy();
});
it('should display verified status badge', () => {
const badge = fixture.nativeElement.querySelector('[data-testid="verification-status-verified"]');
expect(badge).toBeTruthy();
expect(badge.textContent).toContain('Verified');
});
it('should display the evidence ID', () => {
const mono = fixture.nativeElement.querySelector('.summary-mono');
expect(mono).toBeTruthy();
expect(mono.textContent).toContain('witness-001');
});
it('should display confidence tier badge', () => {
const badge = fixture.nativeElement.querySelector('.confidence-badge--confirmed');
expect(badge).toBeTruthy();
expect(badge.textContent).toContain('Confirmed Reachable');
expect(badge.textContent).toContain('95%');
});
it('should display type badge', () => {
const badge = fixture.nativeElement.querySelector('.type-badge--witness');
expect(badge).toBeTruthy();
expect(badge.textContent).toContain('Witness');
});
it('should display failed status correctly', () => {
host.data.set({
id: 'test-002',
typeLabel: 'Attestation',
typeBadge: 'attestation',
status: 'failed',
});
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('[data-testid="verification-status-failed"]');
expect(badge).toBeTruthy();
expect(badge.textContent).toContain('Failed');
});
it('should display pending status correctly', () => {
host.data.set({
id: 'test-003',
typeLabel: 'Bundle',
typeBadge: 'bundle',
status: 'pending',
});
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('[data-testid="verification-status-pending"]');
expect(badge).toBeTruthy();
expect(badge.textContent).toContain('Pending');
});
it('should display unverified status correctly', () => {
host.data.set({
id: 'test-004',
typeLabel: 'Signature',
typeBadge: 'signature',
status: 'unverified',
});
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('[data-testid="verification-status-unverified"]');
expect(badge).toBeTruthy();
expect(badge.textContent).toContain('Unverified');
});
it('should not display confidence section when tier is undefined', () => {
host.data.set({
id: 'test-005',
typeLabel: 'Receipt',
typeBadge: 'receipt',
status: 'verified',
});
fixture.detectChanges();
const confidenceBadge = fixture.nativeElement.querySelector('.confidence-badge');
expect(confidenceBadge).toBeNull();
});
it('should not display source when not provided', () => {
host.data.set({
id: 'test-006',
typeLabel: 'Witness',
typeBadge: 'witness',
status: 'verified',
});
fixture.detectChanges();
const items = fixture.nativeElement.querySelectorAll('.summary-item');
const labels = Array.from(items).map((el: any) =>
el.querySelector('.summary-label')?.textContent?.trim()
);
expect(labels).not.toContain('Source');
});
});

View File

@@ -0,0 +1,313 @@
/**
* Verification Summary Component
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-002)
*
* Composable section showing pass/fail verification status, confidence tier badge,
* evidence type, and creation metadata. Designed for embedding in mounted
* Reachability and Evidence surfaces -- not as a standalone page.
*/
import {
Component,
ChangeDetectionStrategy,
input,
computed,
} from '@angular/core';
import type {
VerificationSummaryData,
VerificationStatus,
ConfidenceTier,
} from './witness.models';
@Component({
selector: 'app-verification-summary',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="verification-summary" data-testid="verification-summary">
<h3 class="section-title">Verification Summary</h3>
<div class="summary-grid">
<!-- Status -->
<div class="summary-item">
<span class="summary-label">Status</span>
<span
class="status-badge"
[class]="'status-badge--' + data().status"
[attr.data-testid]="'verification-status-' + data().status"
>
@if (data().status === 'verified') {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true"
style="display:inline;vertical-align:middle">
<polyline points="20 6 9 17 4 12"/>
</svg>
} @else if (data().status === 'failed') {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true"
style="display:inline;vertical-align:middle">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
} @else if (data().status === 'pending') {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true"
style="display:inline;vertical-align:middle">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
} @else {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true"
style="display:inline;vertical-align:middle">
<circle cx="12" cy="12" r="10"/>
</svg>
}
{{ statusLabel() }}
</span>
</div>
<!-- Evidence Type -->
<div class="summary-item">
<span class="summary-label">Type</span>
<span class="type-badge" [class]="'type-badge--' + data().typeBadge">
{{ data().typeLabel }}
</span>
</div>
<!-- Confidence Tier (when present) -->
@if (data().confidenceTier) {
<div class="summary-item">
<span class="summary-label">Confidence</span>
<span class="confidence-badge" [class]="'confidence-badge--' + data().confidenceTier">
{{ confidenceLabel() }}
@if (data().confidenceScore != null) {
<span class="confidence-score">({{ confidencePercent() }})</span>
}
</span>
</div>
}
<!-- Created -->
@if (data().createdAt) {
<div class="summary-item">
<span class="summary-label">Created</span>
<span class="summary-value">{{ formatDate(data().createdAt!) }}</span>
</div>
}
<!-- Source -->
@if (data().source) {
<div class="summary-item">
<span class="summary-label">Source</span>
<span class="summary-value">{{ data().source }}</span>
</div>
}
<!-- ID -->
<div class="summary-item">
<span class="summary-label">Evidence ID</span>
<code class="summary-mono">{{ data().id }}</code>
</div>
</div>
</section>
`,
styles: [`
.verification-summary {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-secondary);
padding: 1rem;
}
.section-title {
margin: 0 0 0.75rem 0;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.summary-item {
display: grid;
gap: 0.2rem;
}
.summary-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
}
.summary-value {
font-size: 0.875rem;
}
.summary-mono {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 0.8125rem;
word-break: break-all;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
width: fit-content;
}
.status-badge--verified {
background: var(--color-severity-low-bg);
color: var(--color-status-success-text);
}
.status-badge--unverified {
background: var(--color-severity-none-bg);
color: var(--color-text-secondary);
}
.status-badge--failed {
background: var(--color-severity-critical-bg);
color: var(--color-status-error-text);
}
.status-badge--pending {
background: var(--color-severity-medium-bg);
color: var(--color-status-warning-text);
}
.type-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
text-transform: uppercase;
width: fit-content;
}
.type-badge--attestation {
background: var(--color-severity-info-bg);
color: var(--color-status-info-text);
}
.type-badge--signature {
background: var(--color-status-excepted-bg);
color: var(--color-status-excepted);
}
.type-badge--receipt {
background: var(--color-severity-low-bg);
color: var(--color-status-success-text);
}
.type-badge--bundle {
background: var(--color-severity-high-bg);
color: var(--color-severity-high);
}
.type-badge--witness {
background: var(--color-severity-info-bg);
color: var(--color-status-info-text);
}
.confidence-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
width: fit-content;
}
.confidence-badge--confirmed {
background: var(--color-severity-critical-bg);
color: var(--color-status-error-text);
}
.confidence-badge--likely {
background: var(--color-severity-high-bg);
color: var(--color-severity-high);
}
.confidence-badge--present {
background: var(--color-severity-none-bg);
color: var(--color-text-secondary);
}
.confidence-badge--unreachable {
background: var(--color-severity-low-bg);
color: var(--color-status-success-text);
}
.confidence-badge--unknown {
background: var(--color-severity-info-bg);
color: var(--color-status-info-text);
}
.confidence-score {
opacity: 0.8;
font-size: 0.7rem;
}
`],
})
export class VerificationSummaryComponent {
/** Verification summary data to display. */
readonly data = input.required<VerificationSummaryData>();
readonly statusLabel = computed(() => {
const labels: Record<VerificationStatus, string> = {
verified: 'Verified',
unverified: 'Unverified',
failed: 'Failed',
pending: 'Pending',
};
return labels[this.data().status] ?? this.data().status;
});
readonly confidenceLabel = computed(() => {
const labels: Record<ConfidenceTier, string> = {
confirmed: 'Confirmed Reachable',
likely: 'Likely Reachable',
present: 'Present',
unreachable: 'Unreachable',
unknown: 'Unknown',
};
return labels[this.data().confidenceTier!] ?? this.data().confidenceTier;
});
readonly confidencePercent = computed(() => {
const score = this.data().confidenceScore;
if (score == null) return '';
return `${Math.round(score * 100)}%`;
});
formatDate(isoDate: string): string {
try {
return new Intl.DateTimeFormat('en-US', {
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
month: 'short',
timeZone: 'UTC',
year: 'numeric',
}).format(new Date(isoDate));
} catch {
return isoDate;
}
}
}

View File

@@ -0,0 +1,82 @@
/**
* Shared witness/evidence proof-inspection models.
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation
*
* Domain types used by the derived proof-inspection sections.
* These are intentionally presentation-level models that both the
* Reachability and Evidence features can map their domain data into.
*/
/** Verification status for an evidence artifact. */
export type VerificationStatus = 'verified' | 'unverified' | 'failed' | 'pending';
/** Confidence tier for reachability assessment (mirrors witness.models). */
export type ConfidenceTier = 'confirmed' | 'likely' | 'present' | 'unreachable' | 'unknown';
/**
* Input data for the verification summary section.
*/
export interface VerificationSummaryData {
/** Unique identifier for the evidence or witness. */
readonly id: string;
/** Human-readable label for the evidence type. */
readonly typeLabel: string;
/** CSS class suffix for the type badge (e.g., 'attestation', 'signature'). */
readonly typeBadge: string;
/** Current verification status. */
readonly status: VerificationStatus;
/** Confidence tier (if available). */
readonly confidenceTier?: ConfidenceTier;
/** Confidence score 0.0-1.0 (if available). */
readonly confidenceScore?: number;
/** When the evidence was created or observed. */
readonly createdAt?: string;
/** Evidence source identifier. */
readonly source?: string;
}
/**
* Input data for the signature inspector section.
*/
export interface SignatureData {
/** Signature identifier. */
readonly id: string;
/** Cryptographic algorithm (e.g., ECDSA-P256, Ed25519). */
readonly algorithm: string;
/** Key identifier. */
readonly keyId: string;
/** Truncated or full signature value. */
readonly value: string;
/** Timestamp of signature. */
readonly timestamp?: string;
/** Whether the signature has been verified. */
readonly verified: boolean;
/** Issuer of the signing key (optional). */
readonly issuer?: string;
}
/**
* Input data for the attestation detail section.
*/
export interface AttestationData {
/** In-toto predicate type URI. */
readonly predicateType: string;
/** Subject name. */
readonly subjectName: string;
/** Subject digests (algorithm -> hash). */
readonly subjectDigests: ReadonlyArray<{ readonly algorithm: string; readonly hash: string }>;
/** Predicate payload (arbitrary JSON). */
readonly predicate: Record<string, unknown>;
}
/**
* Input data for the evidence payload section.
*/
export interface EvidencePayloadData {
/** Evidence identifier for naming downloads. */
readonly evidenceId: string;
/** Raw content to display (JSON string or raw text). */
readonly rawContent: string;
/** Metadata key-value pairs to display. */
readonly metadata?: Record<string, unknown>;
}

View File

@@ -1,72 +0,0 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { ReleaseControlSetupHomeComponent } from '../../app/features/release-control/setup/release-control-setup-home.component';
import { SetupBundleTemplatesComponent } from '../../app/features/release-control/setup/setup-bundle-templates.component';
import { SetupEnvironmentsPathsComponent } from '../../app/features/release-control/setup/setup-environments-paths.component';
import { SetupTargetsAgentsComponent } from '../../app/features/release-control/setup/setup-targets-agents.component';
import { SetupWorkflowsComponent } from '../../app/features/release-control/setup/setup-workflows.component';
describe('Release Control setup components (release-control)', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
ReleaseControlSetupHomeComponent,
SetupEnvironmentsPathsComponent,
SetupTargetsAgentsComponent,
SetupWorkflowsComponent,
SetupBundleTemplatesComponent,
],
providers: [provideRouter([])],
}).compileComponents();
});
it('setup home renders required setup areas', () => {
const fixture = TestBed.createComponent(ReleaseControlSetupHomeComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Release Control Setup');
expect(text).toContain('Environments and Promotion Paths');
expect(text).toContain('Targets and Agents');
expect(text).toContain('Workflows');
expect(text).toContain('Bundle Templates');
});
it('environments and paths page renders inventory and path rules', () => {
const fixture = TestBed.createComponent(SetupEnvironmentsPathsComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Environment Inventory');
expect(text).toContain('Promotion Path Rules');
});
it('targets and agents page renders ownership links', () => {
const fixture = TestBed.createComponent(SetupTargetsAgentsComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Targets and Agents');
expect(text).toContain('Integrations > Targets / Runtimes');
expect(text).toContain('Platform Ops > Agents');
});
it('workflows page renders workflow catalog and run timeline link', () => {
const fixture = TestBed.createComponent(SetupWorkflowsComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Workflow Catalog');
expect(text).toContain('Open Run Timeline');
});
it('bundle templates page renders template catalog and builder link', () => {
const fixture = TestBed.createComponent(SetupBundleTemplatesComponent);
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Template Catalog');
expect(text).toContain('Open Bundle Builder');
});
});

View File

@@ -0,0 +1,196 @@
/**
* Settings IA Rationalization Tests
* Sprint: SPRINT_20260308_026_FE_settings_information_architecture_rationalization
*
* Verifies that the Settings shell now owns only personal preferences,
* admin/tenant/ops leaves redirect to canonical owners, and legacy
* bookmarks resolve through controlled redirects.
*/
import { SETTINGS_ROUTES } from '../../app/features/settings/settings.routes';
describe('Settings IA rationalization', () => {
const root = SETTINGS_ROUTES[0];
const children = root?.children ?? [];
// ---------------------------------------------------------------------------
// Personal preferences are the canonical default
// ---------------------------------------------------------------------------
it('defaults to user-preferences as the settings landing page', () => {
const defaultRoute = children.find((r) => r.path === '');
expect(defaultRoute).toBeDefined();
expect(defaultRoute?.title).toBe('User Preferences');
expect(typeof defaultRoute?.loadComponent).toBe('function');
});
it('mounts user-preferences as a named route', () => {
const route = children.find((r) => r.path === 'user-preferences');
expect(route).toBeDefined();
expect(route?.title).toBe('User Preferences');
expect(typeof route?.loadComponent).toBe('function');
});
// ---------------------------------------------------------------------------
// Merged personal preference leaves redirect to user-preferences
// ---------------------------------------------------------------------------
it('redirects /settings/language to user-preferences', () => {
const route = children.find((r) => r.path === 'language');
expect(route).toBeDefined();
expect(route?.redirectTo).toBe('user-preferences');
});
it('redirects /settings/ai-preferences to user-preferences', () => {
const route = children.find((r) => r.path === 'ai-preferences');
expect(route).toBeDefined();
expect(route?.redirectTo).toBe('user-preferences');
});
// ---------------------------------------------------------------------------
// Admin/tenant config leaves redirect to canonical administration
// ---------------------------------------------------------------------------
it('redirects /settings/admin to canonical administration', () => {
const route = children.find((r) => r.path === 'admin');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
expect(route?.pathMatch).toBe('full');
});
it('redirects /settings/admin/:page to canonical administration', () => {
const route = children.find((r) => r.path === 'admin/:page');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/branding to canonical console admin branding', () => {
const route = children.find((r) => r.path === 'branding');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/identity-providers to canonical administration', () => {
const route = children.find((r) => r.path === 'identity-providers');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/system to canonical administration', () => {
const route = children.find((r) => r.path === 'system');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/security-data to canonical administration', () => {
const route = children.find((r) => r.path === 'security-data');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
// ---------------------------------------------------------------------------
// Operations config leaves redirect to canonical setup/ops
// ---------------------------------------------------------------------------
it('redirects /settings/integrations to canonical setup', () => {
const route = children.find((r) => r.path === 'integrations');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/integrations/:id to canonical setup', () => {
const route = children.find((r) => r.path === 'integrations/:id');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/usage to canonical setup', () => {
const route = children.find((r) => r.path === 'usage');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/notifications to canonical setup', () => {
const route = children.find((r) => r.path === 'notifications');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/policy to canonical ops policy governance', () => {
const route = children.find((r) => r.path === 'policy');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/offline to canonical administration', () => {
const route = children.find((r) => r.path === 'offline');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/release-control to canonical setup topology', () => {
const route = children.find((r) => r.path === 'release-control');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
it('redirects /settings/configuration-pane to canonical ops platform-setup', () => {
const route = children.find((r) => r.path === 'configuration-pane');
expect(route).toBeDefined();
expect(typeof route?.redirectTo).toBe('function');
});
// ---------------------------------------------------------------------------
// Trust redirects preserved from previous sprint
// ---------------------------------------------------------------------------
it('preserves trust/* redirects to /setup/trust-signing', () => {
const trustRoot = children.find((r) => r.path === 'trust');
expect(trustRoot).toBeDefined();
expect(typeof trustRoot?.redirectTo).toBe('function');
const trustIssuers = children.find((r) => r.path === 'trust/issuers');
expect(trustIssuers).toBeDefined();
const trustPage = children.find((r) => r.path === 'trust/:page');
expect(trustPage).toBeDefined();
const trustPageChild = children.find((r) => r.path === 'trust/:page/:child');
expect(trustPageChild).toBeDefined();
});
it('preserves trust-signing/* redirects to /setup/trust-signing', () => {
const ts = children.find((r) => r.path === 'trust-signing');
expect(ts).toBeDefined();
expect(typeof ts?.redirectTo).toBe('function');
const tsPage = children.find((r) => r.path === 'trust-signing/:page');
expect(tsPage).toBeDefined();
const tsPageChild = children.find((r) => r.path === 'trust-signing/:page/:child');
expect(tsPageChild).toBeDefined();
});
// ---------------------------------------------------------------------------
// No loadComponent routes remain for admin/ops pages
// ---------------------------------------------------------------------------
it('contains no loadComponent routes for admin/ops leaves', () => {
const adminOpsLeaves = [
'integrations', 'integrations/:id', 'admin', 'admin/:page',
'branding', 'usage', 'notifications', 'security-data', 'policy',
'offline', 'system', 'identity-providers', 'release-control',
'configuration-pane',
];
for (const path of adminOpsLeaves) {
const route = children.find((r) => r.path === path);
if (route) {
expect(route.loadComponent).toBeUndefined();
}
}
});
// ---------------------------------------------------------------------------
// Route count validation
// ---------------------------------------------------------------------------
it('has the expected number of child routes', () => {
// 2 personal preference routes + 2 merged redirects + 8 admin redirects
// + 6 ops redirects + 7 trust redirects = 25
expect(children.length).toBe(25);
});
});

View File

@@ -2,7 +2,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { routes } from '../../app/app.routes';
import { ADVISORY_AI_API, type AdvisoryAiApi } from '../../app/core/api/advisory-ai.client';
import type { RemediationPrSettings } from '../../app/core/api/advisory-ai.models';
import { RemediationPrSettingsComponent } from '../../app/features/settings/remediation-pr-settings.component';
@@ -23,46 +22,33 @@ describe('unified-settings-page behavior', () => {
localStorage.removeItem('stellaops.remediation-pr.preferences');
});
it('declares canonical /administration route and keeps /settings redirect alias', () => {
const settingsAlias = routes.find((route) => route.path === 'settings');
expect(settingsAlias).toBeDefined();
expect(settingsAlias?.redirectTo).toBe('/administration');
const administrationRoute = routes.find((route) => route.path === 'administration');
expect(administrationRoute).toBeDefined();
expect(typeof administrationRoute?.loadChildren).toBe('function');
it('mounts personal preferences as the settings default and redirects admin leaves', () => {
const root = SETTINGS_ROUTES.find((route) => route.path === '');
expect(root).toBeDefined();
const childPaths = (root?.children ?? []).map((child) => child.path);
expect(childPaths).toEqual([
'',
'integrations',
'integrations/:id',
'configuration-pane',
'release-control',
'trust',
'trust/:page',
'security-data',
'admin',
'admin/:page',
'branding',
'usage',
'notifications',
'ai-preferences',
'policy',
'offline',
'system',
]);
// The default route is now user-preferences, not integrations
const defaultChild = (root?.children ?? []).find((child) => child.path === '');
expect(defaultChild?.title).toBe('User Preferences');
expect(typeof defaultChild?.loadComponent).toBe('function');
const brandingRoute = (root?.children ?? []).find((child) => child.path === 'branding');
expect(brandingRoute?.title).toBe('Tenant & Branding');
expect(brandingRoute?.data?.['breadcrumb']).toBe('Tenant & Branding');
// user-preferences is also available as a named route
expect(childPaths).toContain('user-preferences');
const offlineRoute = (root?.children ?? []).find((child) => child.path === 'offline');
expect(offlineRoute?.title).toBe('Offline Settings');
expect(offlineRoute?.data?.['breadcrumb']).toBe('Offline Settings');
// Admin/ops leaves are redirects, not loadComponent pages
const adminRedirects = ['admin', 'branding', 'integrations', 'notifications', 'usage', 'system', 'offline', 'policy', 'security-data', 'identity-providers'];
for (const path of adminRedirects) {
const route = (root?.children ?? []).find((child) => child.path === path);
expect(route).toBeDefined();
expect(route?.loadComponent).toBeUndefined();
}
// Language and ai-preferences redirect to user-preferences
const langRoute = (root?.children ?? []).find((child) => child.path === 'language');
expect(langRoute?.redirectTo).toBe('user-preferences');
const aiRoute = (root?.children ?? []).find((child) => child.path === 'ai-preferences');
expect(aiRoute?.redirectTo).toBe('user-preferences');
});
it('renders settings shell container', async () => {

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svgjs="http://svgjs.com/svgjs" xmlns:xlink="http://www.w3.org/1999/xlink" width="164" height="164" version="1.1"><svg xmlns="http://www.w3.org/2000/svg" width="164" height="164" fill="none" viewBox="0 0 164 164"><path fill="#FF4785" d="M22.467 147.762 17.5 15.402a8.062 8.062 0 0 1 7.553-8.35L137.637.016a8.061 8.061 0 0 1 8.565 8.047v144.23a8.063 8.063 0 0 1-8.424 8.054l-107.615-4.833a8.062 8.062 0 0 1-7.695-7.752Z"/><path fill="#fff" fill-rule="evenodd" d="m128.785.57-15.495.968-.755 18.172a1.203 1.203 0 0 0 1.928 1.008l7.06-5.354 5.962 4.697a1.202 1.202 0 0 0 1.946-.987L128.785.569Zm-12.059 60.856c-2.836 2.203-23.965 3.707-23.965.57.447-11.969-4.912-12.494-7.889-12.494-2.828 0-7.59.855-7.59 7.267 0 6.534 6.96 10.223 15.13 14.553 11.607 6.15 25.654 13.594 25.654 32.326 0 17.953-14.588 27.871-33.194 27.871-19.201 0-35.981-7.769-34.086-34.702.744-3.163 25.156-2.411 25.156 0-.298 11.114 2.232 14.383 8.633 14.383 4.912 0 7.144-2.708 7.144-7.267 0-6.9-7.252-10.973-15.595-15.657C64.827 81.933 51.53 74.468 51.53 57.34c0-17.098 11.76-28.497 32.747-28.497 20.988 0 32.449 11.224 32.449 32.584Z" clip-rule="evenodd"/></svg><style>@media (prefers-color-scheme:light){:root{filter:none}}</style></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,177 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>@storybook/angular - Storybook</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<style>
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./sb-common-assets/nunito-sans-regular.woff2') format('woff2');
}
@font-face {
font-family: 'Nunito Sans';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('./sb-common-assets/nunito-sans-italic.woff2') format('woff2');
}
@font-face {
font-family: 'Nunito Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./sb-common-assets/nunito-sans-bold.woff2') format('woff2');
}
@font-face {
font-family: 'Nunito Sans';
font-style: italic;
font-weight: 700;
font-display: swap;
src: url('./sb-common-assets/nunito-sans-bold-italic.woff2') format('woff2');
}
</style>
<link href="./sb-manager/runtime.js" rel="modulepreload" />
<link href="./sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/essentials-controls-1/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/essentials-actions-2/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/essentials-docs-3/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/essentials-backgrounds-4/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/essentials-viewport-5/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/essentials-toolbars-6/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/essentials-measure-7/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/essentials-outline-8/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/a11y-9/manager-bundle.js" rel="modulepreload" />
<link href="./sb-addons/interactions-10/manager-bundle.js" rel="modulepreload" />
<style>
#storybook-root[hidden] {
display: none !important;
}
</style>
</head>
<body>
<div id="root"></div>
<script>
window['FEATURES'] = {
"argTypeTargetsV7": true,
"legacyDecoratorFileOrder": false,
"disallowImplicitActionsInRenderV8": true
};
window['REFS'] = {};
window['LOGLEVEL'] = "info";
window['DOCS_OPTIONS'] = {
"defaultName": "Docs",
"autodocs": "tag"
};
window['CONFIG_TYPE'] = "PRODUCTION";
window['TAGS_OPTIONS'] = {
"dev-only": {
"excludeFromDocsStories": true
},
"docs-only": {
"excludeFromSidebar": true
},
"test-only": {
"excludeFromSidebar": true,
"excludeFromDocsStories": true
}
};
window['STORYBOOK_RENDERER'] = "angular";
window['STORYBOOK_BUILDER'] = "@storybook/builder-webpack5";
window['STORYBOOK_FRAMEWORK'] = "@storybook/angular";
</script>
<script type="module">
import './sb-manager/globals-runtime.js';
import './sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js';
import './sb-addons/essentials-controls-1/manager-bundle.js';
import './sb-addons/essentials-actions-2/manager-bundle.js';
import './sb-addons/essentials-docs-3/manager-bundle.js';
import './sb-addons/essentials-backgrounds-4/manager-bundle.js';
import './sb-addons/essentials-viewport-5/manager-bundle.js';
import './sb-addons/essentials-toolbars-6/manager-bundle.js';
import './sb-addons/essentials-measure-7/manager-bundle.js';
import './sb-addons/essentials-outline-8/manager-bundle.js';
import './sb-addons/a11y-9/manager-bundle.js';
import './sb-addons/interactions-10/manager-bundle.js';
import './sb-manager/runtime.js';
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"generatedAt":1770045794032,"userSince":1770045051724,"hasCustomBabel":false,"hasCustomWebpack":false,"hasStaticDirs":false,"hasStorybookEslint":false,"refCount":0,"testPackages":{"@axe-core/playwright":"4.8.4","@playwright/test":"1.56.1","@types/jasmine":"5.1.12","jasmine-core":"5.1.2","karma":"6.4.4","karma-chrome-launcher":"3.2.0","karma-coverage":"2.2.1","karma-jasmine":"5.1.0","karma-jasmine-html-reporter":"2.1.0"},"hasRouterPackage":true,"packageManager":{"type":"npm","agent":"npm"},"preview":{"usesGlobals":true},"framework":{"name":"@storybook/angular","options":{}},"builder":"@storybook/builder-webpack5","renderer":"@storybook/angular","portableStoriesFileCount":0,"applicationFileCount":58,"storybookVersion":"8.6.14","storybookVersionSpecifier":"^8.6.14","language":"typescript","storybookPackages":{"@chromatic-com/storybook":{"version":"5.0.0"},"@storybook/angular":{"version":"8.6.14"},"@storybook/test":{"version":"8.6.14"},"storybook":{"version":"8.6.14"}},"addons":{"@storybook/addon-essentials":{"version":"8.6.14"},"@storybook/addon-a11y":{"version":"8.6.14"},"@storybook/addon-interactions":{"version":"8.6.14"}}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +0,0 @@
try{
(()=>{var T=__STORYBOOK_API__,{ActiveTabs:h,Consumer:g,ManagerContext:f,Provider:v,RequestResponseError:A,addons:n,combineParameters:x,controlOrMetaKey:P,controlOrMetaSymbol:k,eventMatchesShortcut:M,eventToShortcut:R,experimental_MockUniversalStore:C,experimental_UniversalStore:U,experimental_requestResponse:w,experimental_useUniversalStore:B,isMacLike:E,isShortcutTaken:I,keyToSymbol:K,merge:N,mockChannel:G,optionOrAltSymbol:L,shortcutMatchesShortcut:Y,shortcutToHumanString:q,types:D,useAddonState:F,useArgTypes:H,useArgs:j,useChannel:V,useGlobalTypes:z,useGlobals:J,useParameter:Q,useSharedState:W,useStoryPrepared:X,useStorybookApi:Z,useStorybookState:$}=__STORYBOOK_API__;var S=(()=>{let e;return typeof window<"u"?e=window:typeof globalThis<"u"?e=globalThis:typeof window<"u"?e=window:typeof self<"u"?e=self:e={},e})(),c="tag-filters",p="static-filter";n.register(c,e=>{let u=Object.entries(S.TAGS_OPTIONS??{}).reduce((t,r)=>{let[o,i]=r;return i.excludeFromSidebar&&(t[o]=!0),t},{});e.experimental_setFilter(p,t=>{let r=t.tags??[];return(r.includes("dev")||t.type==="docs")&&r.filter(o=>u[o]).length===0})});})();
}catch(e){ console.error("[Storybook] One of your manager-entries failed: " + import.meta.url, e); }

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svgjs="http://svgjs.com/svgjs" xmlns:xlink="http://www.w3.org/1999/xlink" width="164" height="164" version="1.1"><svg xmlns="http://www.w3.org/2000/svg" width="164" height="164" fill="none" viewBox="0 0 164 164"><path fill="#FF4785" d="M22.467 147.762 17.5 15.402a8.062 8.062 0 0 1 7.553-8.35L137.637.016a8.061 8.061 0 0 1 8.565 8.047v144.23a8.063 8.063 0 0 1-8.424 8.054l-107.615-4.833a8.062 8.062 0 0 1-7.695-7.752Z"/><path fill="#fff" fill-rule="evenodd" d="m128.785.57-15.495.968-.755 18.172a1.203 1.203 0 0 0 1.928 1.008l7.06-5.354 5.962 4.697a1.202 1.202 0 0 0 1.946-.987L128.785.569Zm-12.059 60.856c-2.836 2.203-23.965 3.707-23.965.57.447-11.969-4.912-12.494-7.889-12.494-2.828 0-7.59.855-7.59 7.267 0 6.534 6.96 10.223 15.13 14.553 11.607 6.15 25.654 13.594 25.654 32.326 0 17.953-14.588 27.871-33.194 27.871-19.201 0-35.981-7.769-34.086-34.702.744-3.163 25.156-2.411 25.156 0-.298 11.114 2.232 14.383 8.633 14.383 4.912 0 7.144-2.708 7.144-7.267 0-6.9-7.252-10.973-15.595-15.657C64.827 81.933 51.53 74.468 51.53 57.34c0-17.098 11.76-28.497 32.747-28.497 20.988 0 32.449 11.224 32.449 32.584Z" clip-rule="evenodd"/></svg><style>@media (prefers-color-scheme:light){:root{filter:none}}</style></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1,48 +0,0 @@
import ESM_COMPAT_Module from "node:module";
import { fileURLToPath as ESM_COMPAT_fileURLToPath } from 'node:url';
import { dirname as ESM_COMPAT_dirname } from 'node:path';
const __filename = ESM_COMPAT_fileURLToPath(import.meta.url);
const __dirname = ESM_COMPAT_dirname(__filename);
const require = ESM_COMPAT_Module.createRequire(import.meta.url);
// src/manager/globals/globals.ts
var _ = {
react: "__REACT__",
"react-dom": "__REACT_DOM__",
"react-dom/client": "__REACT_DOM_CLIENT__",
"@storybook/icons": "__STORYBOOK_ICONS__",
"storybook/internal/manager-api": "__STORYBOOK_API__",
"@storybook/manager-api": "__STORYBOOK_API__",
"@storybook/core/manager-api": "__STORYBOOK_API__",
"storybook/internal/components": "__STORYBOOK_COMPONENTS__",
"@storybook/components": "__STORYBOOK_COMPONENTS__",
"@storybook/core/components": "__STORYBOOK_COMPONENTS__",
"storybook/internal/channels": "__STORYBOOK_CHANNELS__",
"@storybook/channels": "__STORYBOOK_CHANNELS__",
"@storybook/core/channels": "__STORYBOOK_CHANNELS__",
"storybook/internal/core-errors": "__STORYBOOK_CORE_EVENTS__",
"@storybook/core-events": "__STORYBOOK_CORE_EVENTS__",
"@storybook/core/core-events": "__STORYBOOK_CORE_EVENTS__",
"storybook/internal/manager-errors": "__STORYBOOK_CORE_EVENTS_MANAGER_ERRORS__",
"@storybook/core-events/manager-errors": "__STORYBOOK_CORE_EVENTS_MANAGER_ERRORS__",
"@storybook/core/manager-errors": "__STORYBOOK_CORE_EVENTS_MANAGER_ERRORS__",
"storybook/internal/router": "__STORYBOOK_ROUTER__",
"@storybook/router": "__STORYBOOK_ROUTER__",
"@storybook/core/router": "__STORYBOOK_ROUTER__",
"storybook/internal/theming": "__STORYBOOK_THEMING__",
"@storybook/theming": "__STORYBOOK_THEMING__",
"@storybook/core/theming": "__STORYBOOK_THEMING__",
"storybook/internal/theming/create": "__STORYBOOK_THEMING_CREATE__",
"@storybook/theming/create": "__STORYBOOK_THEMING_CREATE__",
"@storybook/core/theming/create": "__STORYBOOK_THEMING_CREATE__",
"storybook/internal/client-logger": "__STORYBOOK_CLIENT_LOGGER__",
"@storybook/client-logger": "__STORYBOOK_CLIENT_LOGGER__",
"@storybook/core/client-logger": "__STORYBOOK_CLIENT_LOGGER__",
"storybook/internal/types": "__STORYBOOK_TYPES__",
"@storybook/types": "__STORYBOOK_TYPES__",
"@storybook/core/types": "__STORYBOOK_TYPES__"
}, o = Object.keys(_);
export {
o as globalPackages,
_ as globalsNameReferenceMap
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,33 +0,0 @@
import ESM_COMPAT_Module from "node:module";
import { fileURLToPath as ESM_COMPAT_fileURLToPath } from 'node:url';
import { dirname as ESM_COMPAT_dirname } from 'node:path';
const __filename = ESM_COMPAT_fileURLToPath(import.meta.url);
const __dirname = ESM_COMPAT_dirname(__filename);
const require = ESM_COMPAT_Module.createRequire(import.meta.url);
// src/preview/globals/globals.ts
var _ = {
"@storybook/global": "__STORYBOOK_MODULE_GLOBAL__",
"storybook/internal/channels": "__STORYBOOK_MODULE_CHANNELS__",
"@storybook/channels": "__STORYBOOK_MODULE_CHANNELS__",
"@storybook/core/channels": "__STORYBOOK_MODULE_CHANNELS__",
"storybook/internal/client-logger": "__STORYBOOK_MODULE_CLIENT_LOGGER__",
"@storybook/client-logger": "__STORYBOOK_MODULE_CLIENT_LOGGER__",
"@storybook/core/client-logger": "__STORYBOOK_MODULE_CLIENT_LOGGER__",
"storybook/internal/core-events": "__STORYBOOK_MODULE_CORE_EVENTS__",
"@storybook/core-events": "__STORYBOOK_MODULE_CORE_EVENTS__",
"@storybook/core/core-events": "__STORYBOOK_MODULE_CORE_EVENTS__",
"storybook/internal/preview-errors": "__STORYBOOK_MODULE_CORE_EVENTS_PREVIEW_ERRORS__",
"@storybook/core-events/preview-errors": "__STORYBOOK_MODULE_CORE_EVENTS_PREVIEW_ERRORS__",
"@storybook/core/preview-errors": "__STORYBOOK_MODULE_CORE_EVENTS_PREVIEW_ERRORS__",
"storybook/internal/preview-api": "__STORYBOOK_MODULE_PREVIEW_API__",
"@storybook/preview-api": "__STORYBOOK_MODULE_PREVIEW_API__",
"@storybook/core/preview-api": "__STORYBOOK_MODULE_PREVIEW_API__",
"storybook/internal/types": "__STORYBOOK_MODULE_TYPES__",
"@storybook/types": "__STORYBOOK_MODULE_TYPES__",
"@storybook/core/types": "__STORYBOOK_MODULE_TYPES__"
}, O = Object.keys(_);
export {
O as globalPackages,
_ as globalsNameReferenceMap
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,58 +0,0 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true, args: ['--disable-dev-shm-usage'] });
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
const events = [];
const push = (kind, payload) => events.push({ ts: new Date().toISOString(), kind, ...payload, page: page.url() });
page.on('console', msg => {
if (msg.type() === 'error') {
push('console_error', { text: msg.text() });
}
});
page.on('requestfailed', request => {
const url = request.url();
if (/\.(css|js|map|png|jpg|jpeg|svg|woff2?)($|\?)/i.test(url)) return;
push('request_failed', {
method: request.method(),
url,
error: request.failure()?.errorText ?? 'unknown'
});
});
page.on('response', response => {
const url = response.url();
if (/\.(css|js|map|png|jpg|jpeg|svg|woff2?)($|\?)/i.test(url)) return;
if (response.status() >= 400) {
push('response_error', { status: response.status(), method: response.request().method(), url });
}
});
await page.goto('https://stella-ops.local/welcome', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1200);
const cta = page.locator('button.cta').first();
if (await cta.count()) {
await cta.click({ force: true, noWaitAfter: true });
await page.waitForTimeout(1000);
}
if (page.url().includes('/connect/authorize')) {
await page.locator('input[name="username"]').first().fill('admin');
await page.locator('input[name="password"]').first().fill('Admin@Stella2026!');
await page.locator('button[type="submit"], button:has-text("Sign In")').first().click();
await page.waitForURL(url => !url.toString().includes('/connect/authorize'), { timeout: 20000 });
await page.waitForTimeout(1200);
}
for (const path of ['/evidence/proof-chains', '/policy/packs']) {
await page.goto(`https://stella-ops.local${path}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(6000);
}
console.log(JSON.stringify(events, null, 2));
await browser.close();
})();

View File

@@ -1,49 +0,0 @@
const { chromium } = require('playwright');
const BASE='https://stella-ops.local';
const USER='admin';
const PASS='Admin@Stella2026!';
(async () => {
const browser = await chromium.launch({ headless: true, args:['--disable-dev-shm-usage'] });
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport:{width:1511,height:864} });
const page = await ctx.newPage();
const failed=[];
const responses=[];
page.on('requestfailed', req => {
const url=req.url();
if (/\.(css|js|map|png|jpg|jpeg|svg|woff2?)($|\?)/i.test(url)) return;
failed.push({ url, method:req.method(), error:req.failure()?.errorText || 'unknown', page: page.url() });
});
page.on('response', res => {
const url=res.url();
if (/\.(css|js|map|png|jpg|jpeg|svg|woff2?)($|\?)/i.test(url)) return;
if (res.status() >= 400) {
responses.push({ status: res.status(), method: res.request().method(), url, page: page.url() });
}
});
await page.goto(`${BASE}/welcome`, { waitUntil:'domcontentloaded' });
await page.waitForTimeout(1200);
const cta = page.locator('button.cta').first();
if (await cta.count()) {
await cta.click({ force:true, noWaitAfter:true });
await page.waitForTimeout(1200);
}
if (page.url().includes('/connect/authorize')) {
await page.locator('input[name="username"]').first().fill(USER);
await page.locator('input[name="password"]').first().fill(PASS);
await page.locator('button[type="submit"], button:has-text("Sign In")').first().click();
await page.waitForURL(url => !url.toString().includes('/connect/authorize'), { timeout: 20000 });
await page.waitForTimeout(1200);
}
for (const p of ['/security/exceptions','/evidence/proof-chains']) {
await page.goto(`${BASE}${p}`, { waitUntil:'domcontentloaded' });
await page.waitForTimeout(2200);
}
await browser.close();
console.log(JSON.stringify({ failed, responses }, null, 2));
})();

View File

@@ -1,65 +0,0 @@
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true, args: ['--disable-dev-shm-usage'] });
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
const failed = [];
const websockets = [];
page.on('requestfailed', request => {
const url = request.url();
if (/\.(css|js|map|png|jpg|jpeg|svg|woff2?)($|\?)/i.test(url)) {
return;
}
failed.push({
url,
method: request.method(),
error: request.failure()?.errorText ?? 'unknown',
page: page.url(),
});
});
page.on('websocket', socket => {
const record = { url: socket.url(), events: [] };
websockets.push(record);
socket.on('framesent', () => record.events.push('sent'));
socket.on('framereceived', () => record.events.push('recv'));
socket.on('close', () => record.events.push('close'));
});
page.on('console', msg => {
if (msg.type() === 'error') {
console.log('console-error', msg.text(), '@', page.url());
}
});
await page.goto('https://stella-ops.local/welcome', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1200);
const cta = page.locator('button.cta').first();
if (await cta.count()) {
await cta.click({ force: true, noWaitAfter: true });
await page.waitForTimeout(1200);
}
if (page.url().includes('/connect/authorize')) {
await page.locator('input[name="username"]').first().fill('admin');
await page.locator('input[name="password"]').first().fill('Admin@Stella2026!');
await page.locator('button[type="submit"], button:has-text("Sign In")').first().click();
await page.waitForURL(url => !url.toString().includes('/connect/authorize'), { timeout: 20000 });
await page.waitForTimeout(1200);
}
for (const path of ['/security/exceptions', '/evidence/proof-chains']) {
await page.goto(`https://stella-ops.local${path}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
}
const filteredFailed = failed.filter(item => !item.url.includes('/connect/authorize?'));
console.log(JSON.stringify({ filteredFailed, websockets }, null, 2));
await browser.close();
})();