Repair first-time identity, trust, and integrations operator journeys

Identity/Trust: replace developer jargon with operator-facing language
on trust overview, trust admin summary, and trust analytics. Add context-
aware error handling (404/503 vs generic) for fresh-install guidance.
Add navigation cards for Watchlist and Analytics in trust overview grid.

Integrations: replace raw alert() calls in test-connection and health-
check actions with inline feedback banners using Angular signals. Add
dismissible error banner for delete failures on integration detail.

Supporting fixes: admin notifications, evidence audit, replay controls,
notify panel, sidebar, route ownership, offline-kit, reachability,
topology, and platform feeds components hardened with tests and
operator-facing empty states.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
master
2026-03-15 13:35:56 +02:00
parent 0c723b4e07
commit ab14636f85
38 changed files with 1143 additions and 84 deletions

View File

@@ -0,0 +1,314 @@
#!/usr/bin/env node
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const webRoot = path.resolve(__dirname, '..');
const outputDir = path.join(webRoot, 'output', 'playwright');
const outputPath = path.join(outputDir, 'live-first-time-user-ux-remediation-check.json');
const authStatePath = path.join(outputDir, 'live-first-time-user-ux-remediation-check.state.json');
const authReportPath = path.join(outputDir, 'live-first-time-user-ux-remediation-check.auth.json');
const baseUrl = process.env.STELLAOPS_FRONTDOOR_BASE_URL?.trim() || 'https://stella-ops.local';
const scopeQuery = 'tenant=demo-prod&regions=us-east&environments=stage&timeWindow=7d';
function buildUrl(route) {
const url = new URL(route, baseUrl);
const scopedParams = new URLSearchParams(scopeQuery);
for (const [key, value] of scopedParams.entries()) {
url.searchParams.set(key, value);
}
return url.toString();
}
function cleanText(value) {
return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '';
}
function isStaticAsset(url) {
return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?|ico)(?:$|\?)/i.test(url);
}
function isNavigationAbort(errorText = '') {
return /aborted|net::err_abort/i.test(errorText);
}
function createRuntime() {
return {
consoleErrors: [],
pageErrors: [],
requestFailures: [],
responseErrors: [],
};
}
function attachRuntime(page, runtime) {
page.on('console', (message) => {
if (message.type() === 'error') {
runtime.consoleErrors.push({ page: page.url(), text: message.text() });
}
});
page.on('pageerror', (error) => {
if (page.url() === 'about:blank' && String(error).includes('sessionStorage')) {
return;
}
runtime.pageErrors.push({
page: page.url(),
text: error instanceof Error ? error.message : String(error),
});
});
page.on('requestfailed', (request) => {
const errorText = request.failure()?.errorText ?? 'unknown';
if (isStaticAsset(request.url()) || isNavigationAbort(errorText)) {
return;
}
runtime.requestFailures.push({
page: page.url(),
method: request.method(),
url: request.url(),
error: errorText,
});
});
page.on('response', (response) => {
if (isStaticAsset(response.url())) {
return;
}
if (response.status() >= 400) {
runtime.responseErrors.push({
page: page.url(),
method: response.request().method(),
status: response.status(),
url: response.url(),
});
}
});
}
async function settle(page, ms = 1250) {
await page.waitForLoadState('domcontentloaded', { timeout: 20_000 }).catch(() => {});
await page.waitForTimeout(ms);
}
async function headingText(page) {
const headings = page.locator('h1, main h1, main h2, [data-testid="page-title"], .page-title');
const count = await headings.count().catch(() => 0);
for (let index = 0; index < Math.min(count, 6); index += 1) {
const text = cleanText(await headings.nth(index).innerText().catch(() => ''));
if (text) {
return text;
}
}
return '';
}
async function bodyText(page) {
return cleanText(await page.locator('body').innerText().catch(() => ''));
}
async function visibleAlerts(page) {
return page
.locator('[role="alert"], .error-banner, .warning-banner, .banner, .toast, .notification')
.evaluateAll((nodes) =>
nodes
.map((node) => (node.textContent || '').replace(/\s+/g, ' ').trim())
.filter(Boolean),
)
.catch(() => []);
}
async function capture(page, key, extra = {}) {
return {
key,
url: page.url(),
title: await page.title().catch(() => ''),
heading: await headingText(page),
alerts: await visibleAlerts(page),
...extra,
};
}
async function hrefs(page, selector = 'a') {
return page.locator(selector).evaluateAll((nodes) =>
nodes
.map((node) => node.getAttribute('href'))
.filter((value) => typeof value === 'string'),
).catch(() => []);
}
async function runCheck(page, key, route, evaluate) {
await page.goto(buildUrl(route), { waitUntil: 'domcontentloaded', timeout: 30_000 });
await settle(page);
const result = await evaluate();
return {
key,
route,
...result,
};
}
async function main() {
await mkdir(outputDir, { recursive: true });
const authReport = await authenticateFrontdoor({
statePath: authStatePath,
reportPath: authReportPath,
});
const browser = await chromium.launch({
headless: true,
args: ['--ignore-certificate-errors', '--disable-dev-shm-usage'],
});
const context = await createAuthenticatedContext(browser, authReport, {
statePath: authStatePath,
});
const runtime = createRuntime();
context.on('page', (page) => attachRuntime(page, runtime));
const page = await context.newPage();
attachRuntime(page, runtime);
const results = [];
results.push(await runCheck(page, 'security-posture-canonical', '/security', async () => {
const heading = await headingText(page);
return {
ok: heading === 'Security Posture',
snapshot: await capture(page, 'security-posture-canonical'),
};
}));
results.push(await runCheck(page, 'security-posture-alias', '/security/posture?uxAlias=1#posture-check', async () => {
const parsed = new URL(page.url());
const heading = await headingText(page);
return {
ok: parsed.pathname === '/security'
&& parsed.searchParams.get('uxAlias') === '1'
&& parsed.hash === '#posture-check'
&& heading === 'Security Posture',
snapshot: await capture(page, 'security-posture-alias'),
};
}));
results.push(await runCheck(page, 'replay-and-verify', '/evidence/verify-replay', async () => {
const heading = await headingText(page);
return {
ok: heading === 'Replay & Verify',
snapshot: await capture(page, 'replay-and-verify'),
};
}));
results.push(await runCheck(page, 'release-health', '/releases/health', async () => {
const heading = await headingText(page);
return {
ok: heading === 'Release Health',
snapshot: await capture(page, 'release-health'),
};
}));
results.push(await runCheck(page, 'setup-guided-path', '/setup', async () => {
const text = await bodyText(page);
const links = await hrefs(page);
return {
ok: text.includes('Start guided setup')
&& links.some((href) => href.includes('/setup-wizard/wizard'))
&& links.some((href) => href.includes('/setup/notifications')),
snapshot: await capture(page, 'setup-guided-path', { links: links.slice(0, 30) }),
};
}));
results.push(await runCheck(page, 'setup-notifications-ownership', '/setup/notifications', async () => {
const text = await bodyText(page);
const links = await hrefs(page);
return {
ok: text.includes('Setup-owned notification studio')
&& links.some((href) => href.includes('/ops/operations/notifications'))
&& links.some((href) => href.startsWith('/setup-wizard/wizard')),
snapshot: await capture(page, 'setup-notifications-ownership', { links: links.slice(0, 30) }),
};
}));
results.push(await runCheck(page, 'operations-notifications-ownership', '/ops/operations/notifications', async () => {
const text = await bodyText(page);
const heading = await headingText(page);
const links = await hrefs(page);
return {
ok: heading === 'Notification Operations'
&& text.includes('Ownership and setup')
&& links.includes('/setup/notifications'),
snapshot: await capture(page, 'operations-notifications-ownership', { links: links.slice(0, 30) }),
};
}));
results.push(await runCheck(page, 'evidence-overview-operator-mode', '/evidence/overview', async () => {
const text = await bodyText(page);
return {
ok: !text.includes('State mode:')
&& text.includes('Operator mode keeps the action path concise.')
&& text.includes('Auditor mode expands provenance and proof detail'),
snapshot: await capture(page, 'evidence-overview-operator-mode'),
};
}));
results.push(await runCheck(page, 'sidebar-label-alignment', '/mission-control/board', async () => {
const text = cleanText(await page.locator('aside.sidebar').innerText().catch(() => ''));
const links = await hrefs(page, 'aside.sidebar a');
return {
ok: text.includes('Security Posture')
&& text.includes('Release Health')
&& links.includes('/security')
&& links.includes('/releases/health')
&& !links.includes('/security/posture'),
snapshot: await capture(page, 'sidebar-label-alignment', { links }),
};
}));
const failedChecks = results.filter((result) => !result.ok);
const runtimeIssueCount =
runtime.consoleErrors.length
+ runtime.pageErrors.length
+ runtime.requestFailures.length
+ runtime.responseErrors.length;
const summary = {
generatedAtUtc: new Date().toISOString(),
failedCheckCount: failedChecks.length,
runtimeIssueCount,
results,
runtime,
};
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
await context.close();
await browser.close();
if (failedChecks.length > 0 || runtimeIssueCount > 0) {
process.exitCode = 1;
}
}
main().catch(async (error) => {
const summary = {
generatedAtUtc: new Date().toISOString(),
fatalError: error instanceof Error ? error.message : String(error),
};
await mkdir(outputDir, { recursive: true });
await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
console.error(error);
process.exitCode = 1;
});

View File

@@ -82,6 +82,11 @@ const suites = [
script: 'live-releases-deployments-check.mjs',
reportPath: path.join(outputDir, 'live-releases-deployments-check.json'),
},
{
name: 'release-create-journey',
script: 'live-release-create-journey.mjs',
reportPath: path.join(outputDir, 'live-release-create-journey.json'),
},
{
name: 'release-confidence-journey',
script: 'live-release-confidence-journey.mjs',

View File

@@ -89,6 +89,7 @@ describe('EnvironmentPosturePageComponent', () => {
expect(component.environmentLabel()).toBe('Development');
expect(component.regionLabel()).toBe('4 regions');
expect(component.error()).toBeNull();
expect((fixture.nativeElement.querySelector('h1') as HTMLElement)?.textContent).toContain('Release Health');
});
it('shows an explicit guidance message when no environment context is available', () => {

View File

@@ -5,6 +5,7 @@
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of, throwError } from 'rxjs';
import { AdminNotificationsComponent } from './admin-notifications.component';
import { NOTIFY_API } from '../../core/api/notify.client';
@@ -23,7 +24,7 @@ describe('AdminNotificationsComponent', () => {
displayName: 'Security Team Slack',
type: 'Slack',
enabled: true,
config: { target: '#security-alerts' },
config: { secretRef: 'notify/slack-security', target: '#security-alerts' },
createdAt: '2025-01-01T00:00:00Z',
},
{
@@ -32,7 +33,7 @@ describe('AdminNotificationsComponent', () => {
name: 'email-ops',
type: 'Email',
enabled: false,
config: { target: 'ops@example.com' },
config: { secretRef: 'notify/email-ops', target: 'ops@example.com' },
createdAt: '2025-01-01T00:00:00Z',
},
];
@@ -44,7 +45,7 @@ describe('AdminNotificationsComponent', () => {
name: 'Critical Alerts',
enabled: true,
match: { eventKinds: ['vulnerability.detected'], minSeverity: 'critical' },
actions: [{ channel: 'chn-1', digest: 'instant' }],
actions: [{ actionId: 'action-1', channel: 'chn-1', digest: 'instant', enabled: true }],
createdAt: '2025-01-01T00:00:00Z',
},
];
@@ -54,23 +55,37 @@ describe('AdminNotificationsComponent', () => {
deliveryId: 'dlv-1',
tenantId: 'tenant-1',
ruleId: 'rule-1',
channelId: 'chn-1',
eventKind: 'vulnerability.detected',
target: '#security-alerts',
actionId: 'action-1',
eventId: 'event-1',
kind: 'vulnerability.detected',
status: 'Sent',
attempts: 1,
rendered: {
channelType: 'Slack',
format: 'Slack',
target: '#security-alerts',
title: 'Security alert',
body: 'Body',
},
attempts: [{ timestamp: '2025-12-29T10:00:00Z', status: 'Succeeded' }],
createdAt: '2025-12-29T10:00:00Z',
},
{
deliveryId: 'dlv-2',
tenantId: 'tenant-1',
ruleId: 'rule-1',
channelId: 'chn-1',
eventKind: 'vulnerability.detected',
target: '#security-alerts',
actionId: 'action-1',
eventId: 'event-2',
kind: 'vulnerability.detected',
status: 'Failed',
attempts: 3,
errorMessage: 'Connection timeout',
statusReason: 'Connection timeout',
rendered: {
channelType: 'Slack',
format: 'Slack',
target: '#security-alerts',
title: 'Security alert',
body: 'Body',
},
attempts: [{ timestamp: '2025-12-29T09:00:00Z', status: 'Failed', reason: 'Connection timeout' }],
createdAt: '2025-12-29T09:00:00Z',
},
];
@@ -82,6 +97,7 @@ describe('AdminNotificationsComponent', () => {
title: 'Critical Escalation',
severity: 'critical',
status: 'open',
eventIds: ['event-1'],
escalationLevel: 1,
createdAt: '2025-12-29T08:00:00Z',
},
@@ -115,7 +131,7 @@ describe('AdminNotificationsComponent', () => {
await TestBed.configureTestingModule({
imports: [AdminNotificationsComponent],
providers: [{ provide: NOTIFY_API, useValue: mockApi }],
providers: [provideRouter([]), { provide: NOTIFY_API, useValue: mockApi }],
}).compileComponents();
fixture = TestBed.createComponent(AdminNotificationsComponent);
@@ -187,6 +203,19 @@ describe('AdminNotificationsComponent', () => {
expect(component.loading()).toBe(false);
});
it('renders ownership guidance back to the operator console', async () => {
await component.ngOnInit();
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
expect(text).toContain('Setup-owned notification studio');
expect(
links.some((link) => link.getAttribute('href')?.includes('/ops/operations/notifications'))
).toBeTrue();
});
});
describe('computed properties', () => {
@@ -337,18 +366,30 @@ describe('AdminNotificationsComponent', () => {
await component.ngOnInit();
});
it('should acknowledge incident', async () => {
mockApi.acknowledgeIncident.and.returnValue(of({ success: true }));
it('should not acknowledge incident without an ack token', async () => {
await component.acknowledgeIncident(mockIncidents[0]);
expect(mockApi.acknowledgeIncident).toHaveBeenCalledWith('inc-1', { note: 'Acknowledged from UI' });
expect(mockApi.acknowledgeIncident).not.toHaveBeenCalled();
expect(component.error()).toContain('acknowledgement token');
});
it('should acknowledge incident when the live contract exposes an ack token', async () => {
mockApi.acknowledgeIncident.and.returnValue(of({ success: true }));
const acknowledgeableIncident = { ...mockIncidents[0], ackToken: 'ack-1' } as NotifyIncident & { ackToken: string };
await component.acknowledgeIncident(acknowledgeableIncident);
expect(mockApi.acknowledgeIncident).toHaveBeenCalledWith('inc-1', {
ackToken: 'ack-1',
note: 'Acknowledged from UI',
});
});
it('should handle acknowledge error', async () => {
mockApi.acknowledgeIncident.and.returnValue(throwError(() => new Error('Failed')));
const acknowledgeableIncident = { ...mockIncidents[0], ackToken: 'ack-1' } as NotifyIncident & { ackToken: string };
await component.acknowledgeIncident(mockIncidents[0]);
await component.acknowledgeIncident(acknowledgeableIncident);
expect(component.error()).toBe('Failed to acknowledge incident');
});

View File

@@ -44,6 +44,22 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
<p class="subtitle">Configure channels, rules, templates, and view delivery audit trail</p>
</header>
<section class="owner-banner">
<div>
<strong>Setup-owned notification studio</strong>
<p>
Use this surface for channel lifecycle, routing policy, templates, and delivery governance.
Use the Operations notifications console for live delivery checks, quick tests, and runtime review.
</p>
</div>
<div class="owner-banner__actions">
<a routerLink="/ops/operations/notifications" class="btn-secondary">Open operator console</a>
<a routerLink="/setup-wizard/wizard" [queryParams]="{ step: 'notifications', mode: 'reconfigure' }" class="btn-secondary">
Re-run notifications setup
</a>
</div>
</section>
<!-- Summary Stats -->
<div class="stats-row">
<div class="stat-card">
@@ -328,7 +344,14 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
<td>Level {{ incident.escalationLevel || 1 }}</td>
<td>{{ incident.createdAt | date:'short' }}</td>
<td>
<button class="btn-icon" (click)="acknowledgeIncident(incident)">Acknowledge</button>
<button
class="btn-icon"
[disabled]="!hasAckToken(incident)"
[title]="hasAckToken(incident) ? 'Acknowledge incident' : 'Acknowledgement requires a token from the live incident contract.'"
(click)="acknowledgeIncident(incident)"
>
Acknowledge
</button>
</td>
</tr>
}
@@ -407,6 +430,21 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
.page-header h1 { margin: 0; font-size: 1.75rem; }
.subtitle { color: var(--color-text-secondary); margin-top: 0.25rem; }
.owner-banner {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
padding: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
}
.owner-banner strong { display: block; margin-bottom: 0.35rem; }
.owner-banner p { margin: 0; color: var(--color-text-secondary); max-width: 64ch; }
.owner-banner__actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.stats-row { display: flex; gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
.stat-card {
flex: 1; min-width: 100px; padding: 1rem; border-radius: var(--radius-lg);
@@ -489,6 +527,10 @@ type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incid
.config-section h3 { margin: 0 0 0.5rem; font-size: 1rem; }
.section-desc { color: var(--color-text-secondary); font-size: 0.875rem; margin: 0 0 1rem; }
.config-item { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem; background: var(--color-surface-secondary); border-radius: var(--radius-sm); margin-bottom: 0.5rem; }
@media (max-width: 900px) {
.owner-banner { flex-direction: column; }
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -566,7 +608,7 @@ export class AdminNotificationsComponent implements OnInit {
const filter = this.deliveryFilter();
const options = filter === 'all' ? { limit: 50 } : { status: filter as any, limit: 50 };
const response = await firstValueFrom(this.api.listDeliveries(options));
this.deliveries.set(response.items ?? []);
this.deliveries.set([...(response.items ?? [])]);
} catch (err) {
this.error.set('Failed to load deliveries');
} finally {
@@ -577,7 +619,7 @@ export class AdminNotificationsComponent implements OnInit {
async refreshIncidents(): Promise<void> {
try {
const response = await firstValueFrom(this.api.listIncidents());
this.incidents.set(response.items ?? []);
this.incidents.set([...(response.items ?? [])]);
} catch (err) {
this.error.set('Failed to load incidents');
}
@@ -630,9 +672,22 @@ export class AdminNotificationsComponent implements OnInit {
}
}
hasAckToken(incident: NotifyIncident): boolean {
return Boolean((incident as NotifyIncident & { ackToken?: string }).ackToken);
}
async acknowledgeIncident(incident: NotifyIncident): Promise<void> {
const ackToken = (incident as NotifyIncident & { ackToken?: string }).ackToken;
if (!ackToken) {
this.error.set('Incident acknowledgment is unavailable until the live contract exposes an acknowledgement token.');
return;
}
try {
await firstValueFrom(this.api.acknowledgeIncident(incident.incidentId, { note: 'Acknowledged from UI' }));
await firstValueFrom(this.api.acknowledgeIncident(incident.incidentId, {
ackToken,
note: 'Acknowledged from UI',
}));
await this.refreshIncidents();
} catch (err) {
this.error.set('Failed to acknowledge incident');

View File

@@ -275,6 +275,19 @@ describe('NotificationDashboardComponent', () => {
expect(compiled.textContent).toContain('Notification Administration');
});
it('renders ownership guidance back to setup and operations', () => {
const compiled = fixture.nativeElement as HTMLElement;
const links = Array.from(compiled.querySelectorAll('a')) as HTMLAnchorElement[];
expect(compiled.textContent).toContain('Setup-owned notification studio');
expect(
links.some((link) => link.getAttribute('href')?.includes('/ops/operations/notifications'))
).toBeTrue();
expect(
links.some((link) => link.getAttribute('href')?.includes('/setup-wizard/wizard'))
).toBeTrue();
});
it('should display stats overview', () => {
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.stats-overview')).toBeTruthy();
@@ -322,10 +335,10 @@ describe('NotificationDashboardComponent', () => {
const navLinks = fixture.debugElement
.queryAll(By.directive(RouterLink))
.map((debugElement) => debugElement.injector.get(RouterLink));
.map((debugElement) => debugElement.injector.get(RouterLink))
.filter((link) => link.queryParamsHandling === 'merge');
expect(navLinks.length).toBe(10);
expect(navLinks.every((link) => link.queryParamsHandling === 'merge')).toBeTrue();
});
it('should display sub-navigation when delivery tab is active', () => {
@@ -342,10 +355,10 @@ describe('NotificationDashboardComponent', () => {
const navLinks = fixture.debugElement
.queryAll(By.directive(RouterLink))
.map((debugElement) => debugElement.injector.get(RouterLink));
.map((debugElement) => debugElement.injector.get(RouterLink))
.filter((link) => link.queryParamsHandling === 'merge');
expect(navLinks.length).toBe(8);
expect(navLinks.every((link) => link.queryParamsHandling === 'merge')).toBeTrue();
});
it('should display error banner when error exists', () => {

View File

@@ -56,6 +56,26 @@ interface ConfigSubTab {
</div>
</header>
<section class="owner-banner">
<div>
<strong>Setup-owned notification studio</strong>
<p>
Use this setup surface for channel lifecycle, routing policy, templates, throttles, and escalation design.
Use the Operations notifications console for live delivery checks, quick tests, and runtime review.
</p>
</div>
<div class="owner-banner__actions">
<a routerLink="/ops/operations/notifications" class="btn btn-secondary">Open operator console</a>
<a
routerLink="/setup-wizard/wizard"
[queryParams]="{ step: 'notifications', mode: 'reconfigure' }"
class="btn btn-secondary"
>
Re-run notifications setup
</a>
</div>
</section>
<!-- Statistics Overview -->
<section class="stats-overview" [class.loading]="loadingStats()">
<div class="stat-card">
@@ -197,6 +217,35 @@ interface ConfigSubTab {
gap: 0.5rem;
}
.owner-banner {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
padding: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
}
.owner-banner strong {
display: block;
margin-bottom: 0.35rem;
}
.owner-banner p {
margin: 0;
color: var(--color-text-secondary);
max-width: 64ch;
}
.owner-banner__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
/* Statistics Overview */
.stats-overview {
display: grid;
@@ -465,6 +514,10 @@ interface ConfigSubTab {
padding: 1rem;
}
.owner-banner {
flex-direction: column;
}
.stats-overview {
grid-template-columns: repeat(2, 1fr);
}

View File

@@ -0,0 +1,32 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { AdministrationOverviewComponent } from './administration-overview.component';
describe('AdministrationOverviewComponent', () => {
let fixture: ComponentFixture<AdministrationOverviewComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AdministrationOverviewComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(AdministrationOverviewComponent);
fixture.detectChanges();
});
it('surfaces a guided first-time setup path', () => {
const text = fixture.nativeElement.textContent as string;
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
expect(text).toContain('First-Time Setup Path');
expect(text).toContain('Start guided setup');
expect(
links.some((link) => link.getAttribute('href')?.includes('/setup-wizard/wizard'))
).toBeTrue();
expect(
links.some((link) => link.getAttribute('href')?.includes('/setup/notifications'))
).toBeTrue();
});
});

View File

@@ -24,7 +24,7 @@ interface SetupCard {
<div>
<h1 class="admin-overview__title">Setup</h1>
<p class="admin-overview__subtitle">
Manage topology, identity, tenants, notifications, and system controls.
Set up identity, trust, integrations, topology, notifications, and system controls without leaving the product.
</p>
</div>
<div class="admin-overview__meta">
@@ -59,6 +59,18 @@ interface SetupCard {
</div>
<aside class="admin-overview__aside">
<section class="admin-overview__aside-section">
<h2 class="admin-overview__section-heading">First-Time Setup Path</h2>
<div class="admin-overview__quick-actions">
<a routerLink="/setup-wizard/wizard">Start guided setup</a>
<a routerLink="/setup/identity-access">1. Identity &amp; Access</a>
<a routerLink="/setup/trust-signing">2. Trust &amp; Signing</a>
<a routerLink="/setup/integrations">3. Integrations</a>
<a routerLink="/setup/topology/overview">4. Topology</a>
<a routerLink="/setup/notifications">5. Notifications</a>
</div>
</section>
<section class="admin-overview__aside-section">
<h2 class="admin-overview__section-heading">Quick Actions</h2>
<div class="admin-overview__quick-actions">
@@ -72,7 +84,7 @@ interface SetupCard {
<h2 class="admin-overview__section-heading">Suggested Next Steps</h2>
<ul class="admin-overview__links admin-overview__links--compact">
<li>Validate environment mapping and target ownership.</li>
<li>Verify notification channels before first production promotion.</li>
<li>Finish notifications setup before the first production promotion.</li>
<li>Confirm audit trail visibility for approvers.</li>
</ul>
</section>

View File

@@ -30,6 +30,14 @@ describe('EvidenceAuditOverviewComponent (persona visibility)', () => {
expect(toggle).toBeTruthy();
});
it('removes developer-only state mode toggles from the operator overview', () => {
service.setMode('operator');
fixture.detectChanges();
expect(fixture.nativeElement.textContent).not.toContain('State mode:');
expect(fixture.nativeElement.textContent).toContain('Operator mode keeps the action path concise.');
});
it('should hide audit events and proof chains in operator mode', () => {
service.setMode('operator');
fixture.detectChanges();

View File

@@ -42,17 +42,13 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty';
<p class="overview-subtitle">
Retrieve, verify, export, and audit evidence for every release, bundle, environment, and approval decision.
</p>
<p class="overview-hint">
Operator mode keeps the action path concise. Auditor mode expands provenance and proof detail for formal review.
</p>
</div>
<stella-view-mode-toggle />
</header>
<section class="mode-toggle" aria-label="Evidence home state mode">
<span>State mode:</span>
<button type="button" [class.active]="mode() === 'normal'" (click)="setMode('normal')">Normal</button>
<button type="button" [class.active]="mode() === 'degraded'" (click)="setMode('degraded')">Degraded</button>
<button type="button" [class.active]="mode() === 'empty'" (click)="setMode('empty')">Empty</button>
</section>
@if (isDegraded()) {
<section class="state-banner" role="status">
Evidence index is degraded. Replay and export links remain available, but latest-pack metrics
@@ -232,6 +228,13 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty';
margin: 0.35rem 0 0;
}
.overview-hint {
margin: 0.45rem 0 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
max-width: 60ch;
}
/* Section titles */
.section-title {
font-size: 0.85rem;

View File

@@ -64,7 +64,7 @@ describe('ReplayControlsComponent', () => {
it('should display page header', () => {
fixture.detectChanges();
const header = fixture.nativeElement.querySelector('.page-header h1');
expect(header.textContent).toBe('Verdict Replay');
expect(header.textContent).toBe('Replay & Verify');
});
describe('Request Replay Form', () => {

View File

@@ -26,8 +26,8 @@ import { QuickVerifyDrawerComponent, VerifyResult } from '../../shared/component
template: `
<div class="replay-controls">
<header class="page-header">
<h1>Verdict Replay</h1>
<p>Re-evaluate verdicts for determinism verification and audit trails.</p>
<h1>Replay & Verify</h1>
<p>Re-run decision evidence to confirm determinism and compare results before release or audit sign-off.</p>
@if (releaseId() || runId()) {
<p class="replay-context">
Context:

View File

@@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, inject, NgZone, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, inject, NgZone, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { timeout } from 'rxjs';
@@ -122,6 +122,12 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
<button class="btn-secondary" (click)="editIntegration()">Edit Integration</button>
<button class="btn-danger" (click)="deleteIntegration()">Delete Integration</button>
</div>
@if (deleteError()) {
<div class="delete-error" role="alert">
{{ deleteError() }}
<button type="button" class="delete-error__dismiss" (click)="deleteError.set(null)">Dismiss</button>
</div>
}
</div>
}
@case ('scopes-rules') {
@@ -416,6 +422,28 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event
color: var(--color-text-secondary);
}
.loading { text-align: center; padding: 3rem; color: var(--color-text-secondary); }
.delete-error {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-top: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid rgba(239, 68, 68, 0.35);
border-radius: var(--radius-md);
background: rgba(239, 68, 68, 0.08);
color: var(--color-status-error);
}
.delete-error__dismiss {
border: none;
background: transparent;
cursor: pointer;
color: inherit;
text-decoration: underline;
font-size: 0.82rem;
}
`]
})
export class IntegrationDetailComponent implements OnInit {
@@ -438,6 +466,7 @@ export class IntegrationDetailComponent implements OnInit {
checking = false;
lastTestResult?: TestConnectionResponse;
lastHealthResult?: IntegrationHealthResponse;
readonly deleteError = signal<string | null>(null);
readonly scopeRules = [
'Read scope required for release and evidence queries.',
'Write scope required only for connector mutation operations.',
@@ -578,7 +607,7 @@ export class IntegrationDetailComponent implements OnInit {
void this.router.navigate(this.integrationCommands());
},
error: (err) => {
alert('Failed to delete integration: ' + err.message);
this.deleteError.set('Failed to delete integration: ' + (err?.message || 'Unknown error'));
},
});
}

View File

@@ -1,7 +1,7 @@
import { Component, input } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, Router, provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { of, throwError } from 'rxjs';
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import { IntegrationService } from './integration.service';
@@ -110,4 +110,111 @@ describe('IntegrationListComponent', () => {
queryParamsHandling: 'merge',
});
});
it('shows inline success feedback instead of alert on successful test-connection', () => {
integrationService.testConnection.and.returnValue(of({
integrationId: 'int-1',
success: true,
message: 'Connector responded successfully.',
duration: 'PT0.12S',
testedAt: '2026-03-15T10:00:00Z',
}));
component.testConnection(component.integrations[0]);
expect(component.actionFeedback()).toContain('Connection successful');
expect(component.actionFeedbackTone()).toBe('success');
});
it('shows inline error feedback instead of alert on failed test-connection', () => {
integrationService.testConnection.and.returnValue(of({
integrationId: 'int-1',
success: false,
message: 'Timeout after 5 seconds.',
duration: 'PT5S',
testedAt: '2026-03-15T10:00:00Z',
}));
component.testConnection(component.integrations[0]);
expect(component.actionFeedback()).toContain('Connection failed');
expect(component.actionFeedbackTone()).toBe('error');
});
it('shows inline error feedback when test-connection throws', () => {
integrationService.testConnection.and.returnValue(throwError(() => new Error('Network unreachable')));
component.testConnection(component.integrations[0]);
expect(component.actionFeedback()).toContain('Failed to test connection');
expect(component.actionFeedbackTone()).toBe('error');
});
it('shows inline feedback instead of alert on health check', () => {
integrationService.getHealth.and.returnValue(of({
integrationId: 'int-1',
status: HealthStatus.Healthy,
message: 'All endpoints reachable.',
checkedAt: '2026-03-15T10:00:00Z',
}));
component.checkHealth(component.integrations[0]);
expect(component.actionFeedback()).toContain('Healthy');
expect(component.actionFeedbackTone()).toBe('success');
});
it('shows inline error feedback when health check reports unhealthy', () => {
integrationService.getHealth.and.returnValue(of({
integrationId: 'int-1',
status: HealthStatus.Unhealthy,
message: 'Connection refused.',
checkedAt: '2026-03-15T10:00:00Z',
}));
component.checkHealth(component.integrations[0]);
expect(component.actionFeedback()).toContain('Unhealthy');
expect(component.actionFeedbackTone()).toBe('error');
});
it('shows inline error feedback when health check throws', () => {
integrationService.getHealth.and.returnValue(throwError(() => new Error('Service unavailable')));
component.checkHealth(component.integrations[0]);
expect(component.actionFeedback()).toContain('Failed to check health');
expect(component.actionFeedbackTone()).toBe('error');
});
it('renders feedback banner in the DOM when actionFeedback is set', () => {
integrationService.testConnection.and.returnValue(of({
integrationId: 'int-1',
success: true,
message: 'OK',
duration: 'PT0.05S',
testedAt: '2026-03-15T10:00:00Z',
}));
component.testConnection(component.integrations[0]);
fixture.detectChanges();
const banner = fixture.nativeElement.querySelector('.action-feedback') as HTMLElement;
expect(banner).toBeTruthy();
expect(banner.textContent).toContain('Connection successful');
});
it('removes feedback banner when dismissed', () => {
component.actionFeedback.set('Test message');
component.actionFeedbackTone.set('success');
fixture.detectChanges();
const dismissBtn = fixture.nativeElement.querySelector('.action-feedback__close') as HTMLButtonElement;
expect(dismissBtn).toBeTruthy();
dismissBtn.click();
fixture.detectChanges();
const banner = fixture.nativeElement.querySelector('.action-feedback');
expect(banner).toBeFalsy();
});
});

View File

@@ -1,4 +1,4 @@
import { ChangeDetectorRef, Component, inject, NgZone, OnInit } from '@angular/core';
import { ChangeDetectorRef, Component, inject, NgZone, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
@@ -52,6 +52,13 @@ import {
<st-doctor-checks-inline category="integration" heading="Integration Health Checks" />
@if (actionFeedback()) {
<div class="action-feedback" [class.action-feedback--error]="actionFeedbackTone() === 'error'" role="status">
{{ actionFeedback() }}
<button type="button" class="action-feedback__close" (click)="actionFeedback.set(null)">Dismiss</button>
</div>
}
@if (loading) {
<div class="loading">Loading integrations...</div>
} @else if (loadErrorMessage) {
@@ -248,6 +255,34 @@ import {
color: var(--color-text-secondary);
}
.action-feedback {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--color-status-success-border);
border-radius: var(--radius-md);
background: rgba(74, 222, 128, 0.1);
color: var(--color-status-success-text);
margin-bottom: 1rem;
}
.action-feedback--error {
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.08);
color: var(--color-status-error);
}
.action-feedback__close {
border: none;
background: transparent;
cursor: pointer;
color: inherit;
text-decoration: underline;
font-size: 0.82rem;
}
.error-state {
display: grid;
gap: 0.75rem;
@@ -309,6 +344,8 @@ export class IntegrationListComponent implements OnInit {
totalCount = 0;
totalPages = 1;
loadErrorMessage: string | null = null;
readonly actionFeedback = signal<string | null>(null);
readonly actionFeedbackTone = signal<'success' | 'error'>('success');
private integrationType?: IntegrationType;
@@ -360,11 +397,18 @@ export class IntegrationListComponent implements OnInit {
testConnection(integration: Integration): void {
this.integrationService.testConnection(integration.id).subscribe({
next: (result) => {
alert(result.success ? `Connection successful: ${result.message || 'Connector responded successfully.'}` : `Connection failed: ${result.message || 'Unknown error'}`);
if (result.success) {
this.actionFeedbackTone.set('success');
this.actionFeedback.set(`Connection successful: ${result.message || 'Connector responded successfully.'}`);
} else {
this.actionFeedbackTone.set('error');
this.actionFeedback.set(`Connection failed: ${result.message || 'Unknown error'}`);
}
this.loadIntegrations();
},
error: (err) => {
alert('Failed to test connection: ' + err.message);
this.actionFeedbackTone.set('error');
this.actionFeedback.set('Failed to test connection: ' + (err?.message || 'Unknown error'));
},
});
}
@@ -372,11 +416,14 @@ export class IntegrationListComponent implements OnInit {
checkHealth(integration: Integration): void {
this.integrationService.getHealth(integration.id).subscribe({
next: (result) => {
alert(`Health: ${getHealthStatusLabel(result.status)} - ${result.message || 'No detail returned.'}`);
const isError = result.status === HealthStatus.Unhealthy;
this.actionFeedbackTone.set(isError ? 'error' : 'success');
this.actionFeedback.set(`Health: ${getHealthStatusLabel(result.status)} \u2014 ${result.message || 'No detail returned.'}`);
this.loadIntegrations();
},
error: (err) => {
alert('Failed to check health: ' + err.message);
this.actionFeedbackTone.set('error');
this.actionFeedback.set('Failed to check health: ' + (err?.message || 'Unknown error'));
},
});
}

View File

@@ -2,8 +2,8 @@
<header class="notify-panel__header">
<div>
<p class="eyebrow">Notifications</p>
<h1>Notify control plane</h1>
<p>Manage channels, routing rules, deliveries, and preview payloads offline.</p>
<h1>Notification Operations</h1>
<p>Monitor delivery health, test channels, and verify live notification outcomes without leaving operations.</p>
</div>
<button
type="button"
@@ -15,6 +15,24 @@
</button>
</header>
<section class="notify-card">
<header class="notify-card__header">
<div>
<h2>Ownership and setup</h2>
<p>
Use Setup Notifications for channel lifecycle, templates, throttles, and policy changes.
Use this Operations console for runtime validation, quick tests, and live delivery review.
</p>
</div>
</header>
<div class="notify-actions">
<a class="ghost-button" routerLink="/setup/notifications">Open setup notifications</a>
<a class="ghost-button" routerLink="/setup-wizard/wizard" [queryParams]="{ step: 'notifications', mode: 'reconfigure' }">
Re-run notifications setup
</a>
</div>
</section>
<section class="notify-card">
<header class="notify-card__header">
<div>

View File

@@ -1,4 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { NOTIFY_API } from '../../core/api/notify.client';
import { MockNotifyApiService } from '../../testing/mock-notify-api.service';
@@ -12,6 +13,7 @@ describe('NotifyPanelComponent', () => {
await TestBed.configureTestingModule({
imports: [NotifyPanelComponent],
providers: [
provideRouter([]),
MockNotifyApiService,
{ provide: NOTIFY_API, useExisting: MockNotifyApiService },
],
@@ -78,4 +80,16 @@ describe('NotifyPanelComponent', () => {
links.some((link) => link.getAttribute('href')?.includes('/setup/trust-signing/watchlist/alerts'))
).toBeTrue();
});
it('links back to the setup-owned notifications studio', () => {
const text = fixture.nativeElement.textContent as string;
const links = Array.from(
fixture.nativeElement.querySelectorAll('a')
) as HTMLAnchorElement[];
expect(text).toContain('Ownership and setup');
expect(
links.some((link) => link.getAttribute('href')?.includes('/setup/notifications'))
).toBeTrue();
});
});

View File

@@ -32,7 +32,7 @@ import { ContextHeaderComponent } from '../../shared/ui/context-header/context-h
<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="/evidence/verify-replay">Replay & Verify</a>
<a routerLink="/setup/trust-signing">Trust & Signing</a>
</div>

View File

@@ -124,7 +124,7 @@ type FeedsAirgapAction = 'import' | 'export' | null;
<div class="panel__links">
<a [routerLink]="OPERATIONS_PATHS.offlineKit + '/bundles'">Open Offline Kit Bundles</a>
<a routerLink="/evidence/exports">Export Evidence Bundle</a>
<a routerLink="/evidence/verify-replay">Open Verify & Replay</a>
<a routerLink="/evidence/verify-replay">Open Replay & Verify</a>
</div>
}
@if (tab() === 'version-locks') {

View File

@@ -348,7 +348,7 @@ export class ReachabilityCenterComponent implements OnInit {
return 'Triage';
}
if (returnTo.includes('/evidence/verify-replay')) {
return 'Verify & Replay';
return 'Replay & Verify';
}
return 'Previous workspace';
}

View File

@@ -363,7 +363,7 @@ export class WitnessPageComponent {
return 'Triage';
}
if (returnTo.includes('/evidence/verify-replay')) {
return 'Verify & Replay';
return 'Replay & Verify';
}
if (returnTo.includes('/releases/runs')) {
return 'Release run';

View File

@@ -48,7 +48,7 @@ interface EvidenceCapsuleRow {
template: `
<section class="posture">
<header>
<h1>Environment Posture</h1>
<h1>Release Health</h1>
<p>{{ environmentLabel() }} · {{ regionLabel() }}</p>
</header>

View File

@@ -92,6 +92,16 @@ describe('TrustAdminComponent', () => {
expect(component.activeTab()).toBe('overview');
});
it('renders operator-facing summary card labels instead of developer jargon', () => {
fixture.detectChanges();
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Active keys used for evidence and release signing');
expect(text).toContain('Publishers allowed to contribute trusted content');
expect(text).toContain('Tracked for expiry, chain integrity, and revocation');
expect(text).not.toContain('Administration inventory projection');
});
it('preserves scope query params on every trust shell tab', () => {
fixture.detectChanges();

View File

@@ -75,7 +75,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
<span class="summary-card__value">{{ overview()!.inventory.keys }}</span>
<span class="summary-card__label">Signing Keys</span>
<span class="summary-card__detail">
Keys available for current signing workflows
Active keys used for evidence and release signing
</span>
</div>
</div>
@@ -86,7 +86,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
<span class="summary-card__value">{{ overview()!.inventory.issuers }}</span>
<span class="summary-card__label">Trusted Issuers</span>
<span class="summary-card__detail">
Publishers currently allowed to influence trust
Publishers allowed to contribute trusted content
</span>
</div>
</div>
@@ -97,7 +97,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [
<span class="summary-card__value">{{ overview()!.inventory.certificates }}</span>
<span class="summary-card__label">Certificates</span>
<span class="summary-card__detail">
Certificate expiry and revocation stay visible here
Tracked for expiry, chain integrity, and revocation
</span>
</div>
</div>

View File

@@ -189,11 +189,29 @@ describe('TrustAnalyticsComponent', () => {
expect(component.unacknowledgedAlerts()).toEqual([]);
});
it('surfaces load failures', () => {
it('surfaces a helpful error message when analytics APIs fail', () => {
mockTrustApi.getAnalyticsSummary.and.returnValue(throwError(() => new Error('analytics unavailable')));
fixture.detectChanges();
expect(component.error()).toBe('Failed to load analytics data. Please try again.');
expect(component.error()).toContain('Failed to load trust analytics');
expect(component.error()).toContain('fresh install');
});
it('surfaces a specific message for 404 backend errors', () => {
mockTrustApi.getAnalyticsSummary.and.returnValue(throwError(() => ({ status: 404 })));
fixture.detectChanges();
expect(component.error()).toContain('not yet available');
expect(component.error()).toContain('accumulates');
});
it('surfaces a specific message for 503 backend errors', () => {
mockTrustApi.getAnalyticsSummary.and.returnValue(throwError(() => ({ status: 503 })));
fixture.detectChanges();
expect(component.error()).toContain('not yet available');
});
});

View File

@@ -1231,8 +1231,18 @@ export class TrustAnalyticsComponent implements OnInit, OnDestroy {
this.issuerReliabilityAnalytics.set(issuerReliability);
},
error: (err) => {
console.error('Failed to load analytics:', err);
this.error.set('Failed to load analytics data. Please try again.');
const status = (err as { status?: number })?.status;
if (status === 404 || status === 503) {
this.error.set(
'Trust analytics data is not yet available. The analytics backend accumulates verification and issuer data over time. ' +
'Retry after trust operations (signing, issuer registration, certificate verification) have been exercised.'
);
} else {
this.error.set(
'Failed to load trust analytics. This can happen on a fresh install before the analytics service has processed trust events. ' +
'Use the Refresh button to retry, or check the trust signing keys and issuers tabs to confirm the trust backend is reachable.'
);
}
},
});
}

View File

@@ -0,0 +1,54 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { TrustOverviewComponent } from './trust-overview.component';
describe('TrustOverviewComponent', () => {
let component: TrustOverviewComponent;
let fixture: ComponentFixture<TrustOverviewComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TrustOverviewComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(TrustOverviewComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('renders operator-facing heading instead of developer jargon', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Trust & Signing Overview');
expect(text).not.toContain('Administration Overview');
expect(text).not.toContain('administration trust-signing projection');
});
it('presents all trust surfaces as navigation cards', () => {
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
const hrefs = links.map((link) => link.getAttribute('href'));
expect(hrefs).toContain('/keys');
expect(hrefs).toContain('/issuers');
expect(hrefs).toContain('/certificates');
expect(hrefs).toContain('/watchlist');
expect(hrefs).toContain('/analytics');
expect(hrefs).toContain('/evidence/overview');
});
it('describes issuers in operator language', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Register, block, and review publisher trust levels');
expect(text).not.toContain('canonical setup shell');
});
it('describes certificates in operator language', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Track certificate expiry');
expect(text).not.toContain('enrollment state');
});
});

View File

@@ -9,11 +9,10 @@ import { RouterLink } from '@angular/router';
template: `
<section class="trust-overview">
<div class="trust-overview__panel">
<h2>Administration Overview</h2>
<h2>Trust & Signing Overview</h2>
<p>
This workspace is anchored to the live administration trust-signing projection exposed by the
rebuilt platform. Use the tabs to move into specific inventory surfaces as they are aligned to the
current backend contracts.
Manage signing keys, trusted issuers, and certificates from one workspace.
Use the tabs above to open specific trust surfaces.
</p>
</div>
@@ -26,19 +25,31 @@ import { RouterLink } from '@angular/router';
<article class="trust-overview__card">
<h3>Trusted Issuers</h3>
<p>Inspect issuer onboarding and trust policy configuration from the canonical setup shell.</p>
<p>Register, block, and review publisher trust levels before relying on external advisory content.</p>
<a routerLink="issuers">Open issuer inventory</a>
</article>
<article class="trust-overview__card">
<h3>Certificates</h3>
<p>Check certificate enrollment state and follow evidence consumers that depend on the trust chain.</p>
<p>Track certificate expiry, verify chain integrity, and revoke certificates when trust must be withdrawn.</p>
<a routerLink="certificates">Open certificate inventory</a>
</article>
<article class="trust-overview__card">
<h3>Watchlist</h3>
<p>Monitor trust events, create alerts for key expiry or issuer changes, and tune thresholds.</p>
<a routerLink="watchlist">Open watchlist</a>
</article>
<article class="trust-overview__card">
<h3>Analytics</h3>
<p>Review verification success rates, issuer reliability, and failure trends over time.</p>
<a routerLink="analytics">Open trust analytics</a>
</article>
<article class="trust-overview__card">
<h3>Evidence</h3>
<p>Cross-check trust-signing outputs against evidence and replay flows before promotions.</p>
<p>Cross-check trust-signing outputs against evidence and replay results before promoting releases.</p>
<a routerLink="/evidence/overview">Open evidence overview</a>
</article>
</div>

View File

@@ -128,15 +128,17 @@ describe('AppSidebarComponent', () => {
expect(hrefs).toContain('/releases/health');
});
it('shows Security Posture under Vulnerabilities section', () => {
it('shows the security posture landing under the Security Posture section', () => {
setScopes([
StellaOpsScopes.SCANNER_READ,
]);
const fixture = createComponent();
const text = fixture.nativeElement.textContent as string;
const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[];
const hrefs = links.map((link) => link.getAttribute('href'));
expect(hrefs).toContain('/security/posture');
expect(text).toContain('Security Posture');
expect(hrefs).toContain('/security');
});
it('shows Audit section with Logs and Bundles', () => {

View File

@@ -664,7 +664,7 @@ export class AppSidebarComponent implements AfterViewInit {
},
{
id: 'rel-health',
label: 'Health',
label: 'Release Health',
route: '/releases/health',
icon: 'activity',
},
@@ -724,7 +724,7 @@ export class AppSidebarComponent implements AfterViewInit {
// ── Security & Audit ─────────────────────────────────────────────
{
id: 'vulnerabilities',
label: 'Vulnerabilities',
label: 'Security Posture',
icon: 'shield',
route: '/security',
menuGroupId: 'security-audit',
@@ -740,12 +740,12 @@ export class AppSidebarComponent implements AfterViewInit {
StellaOpsScopes.VULN_VIEW,
],
children: [
{ id: 'sec-posture', label: 'Posture', route: '/security', icon: 'shield' },
{ id: 'sec-triage', label: 'Triage', route: '/triage/artifacts', icon: 'list' },
{ id: 'sec-supply-chain', label: 'Supply-Chain Data', route: '/security/supply-chain-data', icon: 'graph' },
{ id: 'sec-reachability', label: 'Reachability', route: '/security/reachability', icon: 'cpu' },
{ id: 'sec-unknowns', label: 'Unknowns', route: '/security/unknowns', icon: 'help-circle' },
{ id: 'sec-reports', label: 'Reports', route: '/security/reports', icon: 'book-open' },
{ id: 'sec-posture', label: 'Security Posture', route: '/security/posture', icon: 'shield' },
],
},
{

View File

@@ -59,6 +59,14 @@ describe('EVIDENCE_ROUTES', () => {
expect(paths).toContain('audit-log');
});
it('uses Replay & Verify as the canonical replay route label', () => {
const replayRoute = EVIDENCE_ROUTES.find((r) => r.path === 'verify-replay');
expect(replayRoute).toBeDefined();
expect(replayRoute?.title).toBe('Replay & Verify');
expect(replayRoute?.data?.['breadcrumb']).toBe('Replay & Verify');
});
it('should use loadChildren for lazy-loaded thread and workspace routes', () => {
const lazyRoutes = ['threads', 'workspaces/auditor', 'workspaces/developer'];
for (const path of lazyRoutes) {

View File

@@ -12,7 +12,7 @@ import { Routes } from '@angular/router';
* /evidence/workspaces/developer/:artifactDigest - Developer workspace lens
* /evidence/capsules - Decision capsule list
* /evidence/capsules/:capsuleId - Decision capsule detail
* /evidence/verify-replay - Verify & Replay
* /evidence/verify-replay - Replay & Verify
* /evidence/proofs - Proof chains
* /evidence/exports - Evidence exports
* /evidence/proof-chain - Proof chain (alias)
@@ -76,8 +76,8 @@ export const EVIDENCE_ROUTES: Routes = [
},
{
path: 'verify-replay',
title: 'Verify & Replay',
data: { breadcrumb: 'Verify & Replay' },
title: 'Replay & Verify',
data: { breadcrumb: 'Replay & Verify' },
loadComponent: () =>
import('../features/evidence-export/replay-controls.component').then((m) => m.ReplayControlsComponent),
},

View File

@@ -6,6 +6,7 @@ import { SETTINGS_ROUTES } from '../features/settings/settings.routes';
import { OPERATIONS_ROUTES } from './operations.routes';
import { RELEASES_ROUTES } from './releases.routes';
import { LEGACY_REDIRECT_ROUTE_TEMPLATES } from './legacy-redirects.routes';
import { SECURITY_RISK_ROUTES } from './security-risk.routes';
import { SETUP_ROUTES } from './setup.routes';
type RedirectFn = Exclude<NonNullable<Route['redirectTo']>, string>;
@@ -87,11 +88,32 @@ describe('Route surface ownership', () => {
const environmentDetailRoute = RELEASES_ROUTES.find((route) => route.path === 'environments/:environmentId');
expect(healthRoute?.title).toBe('Release Health');
expect(healthRoute?.data?.['breadcrumb']).toBe('Release Health');
expect(typeof healthRoute?.loadComponent).toBe('function');
expect(typeof environmentsRoute?.loadComponent).toBe('function');
expect(typeof environmentDetailRoute?.loadComponent).toBe('function');
});
it('redirects the legacy security posture alias to the canonical security landing', () => {
const postureRoute = SECURITY_RISK_ROUTES.find((route) => route.path === 'posture');
const redirect = postureRoute?.redirectTo;
if (typeof redirect !== 'function') {
throw new Error('security posture alias must expose a redirect function.');
}
expect(
invokeRedirect(redirect, {
params: {},
queryParams: {
tenant: 'demo-prod',
regions: 'us-east',
environments: 'stage',
},
}),
).toBe('/security?tenant=demo-prod&regions=us-east&environments=stage');
});
it('redirects hotfix creation aliases into the canonical release creation workflow', () => {
const hotfixCreateRoute = RELEASES_ROUTES.find((route) => route.path === 'hotfixes/new');
const redirect = hotfixCreateRoute?.redirectTo;

View File

@@ -5,7 +5,7 @@
import { inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
function redirectToTriageWorkspace(path: string) {
function redirectWithQueryAndFragment(path: string) {
return ({ queryParams, fragment }: { queryParams: Record<string, string>; fragment?: string | null }) => {
const router = inject(Router);
const target = router.parseUrl(path);
@@ -42,8 +42,8 @@ function redirectToDecisioning(path: string) {
export const SECURITY_RISK_ROUTES: Routes = [
{
path: '',
title: 'Risk Overview',
data: { breadcrumb: 'Risk Overview' },
title: 'Security Posture',
data: { breadcrumb: 'Security Posture' },
loadComponent: () =>
import('../features/security-risk/security-risk-overview.component').then(
(m) => m.SecurityRiskOverviewComponent
@@ -52,11 +52,9 @@ export const SECURITY_RISK_ROUTES: Routes = [
{
path: 'posture',
title: 'Security Posture',
data: { breadcrumb: 'Posture' },
loadComponent: () =>
import('../features/security-risk/security-risk-overview.component').then(
(m) => m.SecurityRiskOverviewComponent
),
data: { breadcrumb: 'Security Posture' },
pathMatch: 'full',
redirectTo: redirectWithQueryAndFragment('/security'),
},
{
path: 'triage',
@@ -352,7 +350,7 @@ export const SECURITY_RISK_ROUTES: Routes = [
title: 'Artifacts',
data: { breadcrumb: 'Artifacts' },
pathMatch: 'full',
redirectTo: redirectToTriageWorkspace('/triage/artifacts'),
redirectTo: redirectWithQueryAndFragment('/triage/artifacts'),
},
{
path: 'artifacts/:artifactId',

View File

@@ -18,7 +18,10 @@
"src/app/features/admin-notifications/components/notification-rule-list.component.spec.ts",
"src/app/features/admin-notifications/components/notification-dashboard.component.spec.ts",
"src/app/features/admin-notifications/components/channel-management.component.spec.ts",
"src/app/features/admin-notifications/admin-notifications.component.spec.ts",
"src/app/features/administration/administration-overview.component.spec.ts",
"src/app/features/audit-log/audit-log-dashboard.component.spec.ts",
"src/app/features/evidence-audit/evidence-audit-overview.component.spec.ts",
"src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.spec.ts",
"src/app/features/deploy-diff/pages/deploy-diff.page.spec.ts",
"src/app/features/deploy-diff/services/deploy-diff.service.spec.ts",
@@ -35,6 +38,7 @@
"src/app/features/policy-simulation/policy-simulation-direct-route-defaults.spec.ts",
"src/app/features/policy-simulation/policy-simulation-defaults.spec.ts",
"src/app/features/policy-simulation/simulation-dashboard.component.spec.ts",
"src/app/features/notify/notify-panel.component.spec.ts",
"src/app/features/reachability/reachability-center.component.spec.ts",
"src/app/features/releases/release-ops-overview-page.component.spec.ts",
"src/app/features/registry-admin/components/plan-audit.component.spec.ts",
@@ -51,6 +55,10 @@
"src/app/shared/ui/filter-bar/filter-bar.component.spec.ts",
"src/app/features/watchlist/watchlist-page.component.spec.ts",
"src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts",
"src/app/layout/app-sidebar/app-sidebar.component.spec.ts",
"src/app/routes/evidence.routes.spec.ts",
"src/app/routes/route-surface-ownership.spec.ts",
"src/app/core/testing/environment-posture-page.component.spec.ts",
"src/tests/settings/admin-settings-page.component.spec.ts"
]
}