merge: harden derived shared ui components
|
Before Width: | Height: | Size: 285 KiB |
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 79 KiB |
@@ -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)',
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'] || '');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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()"
|
||||
|
||||
@@ -292,6 +292,11 @@ h2 {
|
||||
}
|
||||
}
|
||||
|
||||
.proof-inspection-grid {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.path-row {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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 > 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'
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -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' },
|
||||
];
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()!"
|
||||
|
||||
@@ -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
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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">←</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>();
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"> </span>
|
||||
</div>
|
||||
<div class="metric-card__value metric-card__skeleton"> </div>
|
||||
<div class="metric-card__subtitle metric-card__skeleton"> </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'}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
21
src/Web/StellaOps.Web/src/app/shared/ui/witness/index.ts
Normal 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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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"}}}
|
||||
@@ -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); }
|
||||
@@ -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 |
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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));
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||