doctor and setup fixes

This commit is contained in:
master
2026-02-21 09:45:32 +02:00
parent 1ec797d5e8
commit 7e36c1f151
82 changed files with 5336 additions and 761 deletions

View File

@@ -23,6 +23,7 @@ import { AppShellComponent } from './layout/app-shell/app-shell.component';
import { BrandingService } from './core/branding/branding.service';
import { LegacyRouteTelemetryService } from './core/guards/legacy-route-telemetry.service';
import { LegacyUrlBannerComponent } from './shared/ui/legacy-url-banner/legacy-url-banner.component';
import { PlatformContextUrlSyncService } from './core/context/platform-context-url-sync.service';
@Component({
selector: 'app-root',
@@ -59,6 +60,7 @@ export class AppComponent {
private readonly consoleStore = inject(ConsoleSessionStore);
private readonly brandingService = inject(BrandingService);
private readonly legacyRouteTelemetry = inject(LegacyRouteTelemetryService);
private readonly contextUrlSync = inject(PlatformContextUrlSyncService);
private readonly destroyRef = inject(DestroyRef);
@@ -90,6 +92,9 @@ export class AppComponent {
// Initialize legacy route telemetry tracking (ROUTE-002)
this.legacyRouteTelemetry.initialize();
// Keep global scope in sync with route query parameters.
this.contextUrlSync.initialize();
}
readonly isAuthenticated = this.sessionStore.isAuthenticated;

View File

@@ -34,6 +34,8 @@ import { VULNERABILITY_API_BASE_URL, VulnerabilityHttpClient } from './core/api/
import { RISK_API, MockRiskApi } from './core/api/risk.client';
import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client';
import { AppConfigService } from './core/config/app-config.service';
import { DoctorTrendService } from './core/doctor/doctor-trend.service';
import { DoctorNotificationService } from './core/doctor/doctor-notification.service';
import { BackendProbeService } from './core/config/backend-probe.service';
import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
import { AuthSessionStore } from './core/auth/auth-session.store';
@@ -962,5 +964,13 @@ export const appConfig: ApplicationConfig = {
},
AocHttpClient,
{ provide: AOC_API, useExisting: AocHttpClient },
// Doctor background services
provideAppInitializer(() => {
inject(DoctorTrendService).start();
}),
provideAppInitializer(() => {
inject(DoctorNotificationService).start();
}),
],
};

View File

@@ -2,6 +2,7 @@ import { Routes } from '@angular/router';
import {
requireAuthGuard,
requireAnyScopeGuard,
requireOrchViewerGuard,
requireOrchOperatorGuard,
requirePolicyAuthorGuard,
@@ -11,26 +12,92 @@ import {
requirePolicyReviewOrApproveGuard,
requirePolicyViewerGuard,
requireAnalyticsViewerGuard,
StellaOpsScopes,
} from './core/auth';
import { requireConfigGuard } from './core/config/config.guard';
import { requireBackendsReachableGuard } from './core/config/backends-reachable.guard';
import { LEGACY_REDIRECT_ROUTES } from './routes/legacy-redirects.routes';
const requireMissionControlGuard = requireAnyScopeGuard(
[
StellaOpsScopes.UI_READ,
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.SCANNER_READ,
StellaOpsScopes.SBOM_READ,
],
'/console/profile',
);
const requireReleasesGuard = requireAnyScopeGuard(
[
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.RELEASE_WRITE,
StellaOpsScopes.RELEASE_PUBLISH,
],
'/console/profile',
);
const requireSecurityGuard = requireAnyScopeGuard(
[
StellaOpsScopes.SCANNER_READ,
StellaOpsScopes.SBOM_READ,
StellaOpsScopes.ADVISORY_READ,
StellaOpsScopes.VEX_READ,
StellaOpsScopes.EXCEPTION_READ,
StellaOpsScopes.FINDINGS_READ,
StellaOpsScopes.VULN_VIEW,
],
'/console/profile',
);
const requireEvidenceGuard = requireAnyScopeGuard(
[
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.POLICY_AUDIT,
StellaOpsScopes.AUTHORITY_AUDIT_READ,
StellaOpsScopes.SIGNER_READ,
StellaOpsScopes.VEX_EXPORT,
],
'/console/profile',
);
const requireTopologyGuard = requireAnyScopeGuard(
[
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.UI_ADMIN,
],
'/console/profile',
);
const requirePlatformGuard = requireAnyScopeGuard(
[
StellaOpsScopes.UI_ADMIN,
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.HEALTH_READ,
StellaOpsScopes.NOTIFY_VIEWER,
StellaOpsScopes.ORCH_OPERATE,
],
'/console/profile',
);
export const routes: Routes = [
// ========================================================================
// V2 CANONICAL DOMAIN ROUTES (SPRINT_20260218_006)
// Seven root domains per S00 spec freeze (docs/modules/ui/v2-rewire/source-of-truth.md).
// Old v1 routes redirect to these canonical paths via V1_ALIAS_REDIRECT_ROUTES below.
// V2 CANONICAL DOMAIN ROUTES
// Canonical operator roots per source-of-truth:
// Mission Control, Releases, Security, Evidence, Topology, Platform.
// Legacy roots (/operations, /integrations, /administration, etc.) remain alias-window routes.
// ========================================================================
// Domain 1: Dashboard (formerly Control Plane)
// Domain 1: Mission Control (path remains /dashboard)
{
path: '',
pathMatch: 'full',
title: 'Dashboard',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Dashboard' },
title: 'Mission Control',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireMissionControlGuard],
data: { breadcrumb: 'Mission Control' },
loadChildren: () =>
import('./routes/dashboard.routes').then(
(m) => m.DASHBOARD_ROUTES
@@ -38,9 +105,9 @@ export const routes: Routes = [
},
{
path: 'dashboard',
title: 'Dashboard',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
data: { breadcrumb: 'Dashboard' },
title: 'Mission Control',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireMissionControlGuard],
data: { breadcrumb: 'Mission Control' },
loadChildren: () =>
import('./routes/dashboard.routes').then(
(m) => m.DASHBOARD_ROUTES
@@ -56,7 +123,7 @@ export const routes: Routes = [
{
path: 'releases',
title: 'Releases',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireReleasesGuard],
data: { breadcrumb: 'Releases' },
loadChildren: () =>
import('./routes/releases.routes').then(
@@ -68,7 +135,7 @@ export const routes: Routes = [
{
path: 'security',
title: 'Security',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireSecurityGuard],
data: { breadcrumb: 'Security' },
loadChildren: () =>
import('./routes/security.routes').then(
@@ -80,7 +147,7 @@ export const routes: Routes = [
{
path: 'evidence',
title: 'Evidence',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireEvidenceGuard],
data: { breadcrumb: 'Evidence' },
loadChildren: () =>
import('./routes/evidence.routes').then(
@@ -88,14 +155,11 @@ export const routes: Routes = [
),
},
// Domain 5: Integrations (already canonical — kept as-is)
// /integrations already loaded below; no path change for this domain.
// Domain 6: Topology
// Domain 5: Topology
{
path: 'topology',
title: 'Topology',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireTopologyGuard],
data: { breadcrumb: 'Topology' },
loadChildren: () =>
import('./routes/topology.routes').then(
@@ -103,11 +167,11 @@ export const routes: Routes = [
),
},
// Domain 7: Platform
// Domain 6: Platform
{
path: 'platform',
title: 'Platform',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requirePlatformGuard],
data: { breadcrumb: 'Platform' },
loadChildren: () =>
import('./routes/platform.routes').then(
@@ -115,18 +179,18 @@ export const routes: Routes = [
),
},
// Domain 8: Administration (legacy root retained as alias to Platform Setup)
// Legacy root alias: Administration
{
path: 'administration',
pathMatch: 'full',
redirectTo: '/platform/setup',
},
// Domain 9: Operations (legacy alias root retained for migration window)
// Legacy root alias: Operations
{
path: 'operations',
title: 'Operations',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requirePlatformGuard],
data: { breadcrumb: 'Operations' },
loadChildren: () =>
import('./routes/operations.routes').then(
@@ -134,11 +198,11 @@ export const routes: Routes = [
),
},
// Domain 10: Administration deep-link compatibility surface
// Legacy deep-link compatibility surface: Administration
{
path: 'administration',
title: 'Administration',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requirePlatformGuard],
data: { breadcrumb: 'Administration' },
loadChildren: () =>
import('./routes/administration.routes').then(
@@ -173,7 +237,7 @@ export const routes: Routes = [
{
path: 'deployments',
pathMatch: 'full',
redirectTo: '/releases/activity',
redirectTo: '/releases/runs',
},
// Legacy Security alias
@@ -203,7 +267,7 @@ export const routes: Routes = [
{
path: 'platform-ops',
title: 'Operations',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requirePlatformGuard],
data: { breadcrumb: 'Operations' },
loadChildren: () =>
import('./routes/operations.routes').then(
@@ -222,12 +286,12 @@ export const routes: Routes = [
{
path: 'settings/release-control',
pathMatch: 'full',
redirectTo: '/topology',
redirectTo: '/topology/promotion-graph',
},
{
path: 'settings/release-control/environments',
pathMatch: 'full',
redirectTo: '/topology/environments',
redirectTo: '/topology/regions',
},
{
path: 'settings/release-control/targets',
@@ -750,7 +814,7 @@ export const routes: Routes = [
// Integration Hub (SPRINT_20251229_011_FE_integration_hub_ui)
{
path: 'integrations',
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard],
canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requirePlatformGuard],
loadChildren: () =>
import('./features/integration-hub/integration-hub.routes').then((m) => m.integrationHubRoutes),
},

View File

@@ -117,7 +117,7 @@ export class SearchClient {
type: 'cve' as SearchEntityType,
title: item.id,
subtitle: item.description?.substring(0, 100),
route: `/vulnerabilities/${item.id}`,
route: `/security/triage?cve=${encodeURIComponent(item.id)}`,
severity: item.severity?.toLowerCase() as SearchResult['severity'],
matchScore: 100,
}))
@@ -139,7 +139,7 @@ export class SearchClient {
type: 'artifact' as SearchEntityType,
title: `${item.repository}:${item.tag}`,
subtitle: item.digest.substring(0, 16),
route: `/triage/artifacts/${encodeURIComponent(item.digest)}`,
route: `/security/triage?artifact=${encodeURIComponent(item.digest)}`,
matchScore: 100,
}))
),
@@ -182,7 +182,7 @@ export class SearchClient {
title: `job-${item.id.substring(0, 8)}`,
subtitle: `${item.type} (${item.status})`,
description: item.artifactRef,
route: `/platform-ops/orchestrator/jobs/${item.id}`,
route: `/platform/ops/orchestrator/jobs/${item.id}`,
matchScore: 100,
}))
),
@@ -237,7 +237,7 @@ export class SearchClient {
type: 'vex' as SearchEntityType,
title: item.cveId,
subtitle: `${item.status} - ${item.product}`,
route: `/admin/vex-hub/${item.id}`,
route: `/security/disposition?statementId=${encodeURIComponent(item.id)}`,
matchScore: 100,
}))
),
@@ -259,7 +259,7 @@ export class SearchClient {
type: 'integration' as SearchEntityType,
title: item.name,
subtitle: `${item.type} (${item.status})`,
route: `/integrations/${item.id}`,
route: `/platform/integrations/${item.id}`,
matchScore: 100,
}))
),

View File

@@ -127,7 +127,7 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
shortcut: '>jobs',
description: 'Navigate to job list',
icon: 'workflow',
route: '/platform-ops/orchestrator/jobs',
route: '/platform/ops/jobs-queues',
keywords: ['jobs', 'orchestrator', 'list'],
},
{
@@ -145,7 +145,7 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
shortcut: '>settings',
description: 'Navigate to settings',
icon: 'settings',
route: '/console/profile',
route: '/platform/setup',
keywords: ['settings', 'config', 'preferences'],
},
{
@@ -154,8 +154,24 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
shortcut: '>health',
description: 'View platform health status',
icon: 'heart-pulse',
route: '/ops/health',
keywords: ['health', 'status', 'platform', 'ops'],
route: '/platform/ops/system-health',
keywords: ['health', 'status', 'platform', 'ops', 'doctor', 'system'],
},
{
id: 'doctor-quick',
label: 'Run Quick Health Check',
shortcut: '>doctor',
description: 'Run a quick Doctor diagnostics check',
icon: 'activity',
keywords: ['doctor', 'health', 'check', 'quick', 'diagnostic'],
},
{
id: 'doctor-full',
label: 'Run Full Diagnostics',
shortcut: '>diagnostics',
description: 'Run comprehensive Doctor diagnostics',
icon: 'search',
keywords: ['doctor', 'diagnostics', 'full', 'comprehensive'],
},
{
id: 'integrations',
@@ -163,16 +179,17 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
shortcut: '>integrations',
description: 'View and manage integrations',
icon: 'plug',
route: '/integrations',
route: '/platform/integrations',
keywords: ['integrations', 'connect', 'manage'],
},
];
export function filterQuickActions(query: string): QuickAction[] {
export function filterQuickActions(query: string, actions?: QuickAction[]): QuickAction[] {
const list = actions ?? DEFAULT_QUICK_ACTIONS;
const normalizedQuery = query.toLowerCase().replace(/^>/, '').trim();
if (!normalizedQuery) return DEFAULT_QUICK_ACTIONS;
if (!normalizedQuery) return list;
return DEFAULT_QUICK_ACTIONS.filter((action) =>
return list.filter((action) =>
action.keywords.some((kw) => kw.includes(normalizedQuery)) ||
action.label.toLowerCase().includes(normalizedQuery) ||
action.shortcut.toLowerCase().includes(normalizedQuery)

View File

@@ -14,16 +14,20 @@ export class GlobalContextHttpInterceptor implements HttpInterceptor {
}
let params = request.params;
const region = this.context.selectedRegions()[0];
const environment = this.context.selectedEnvironments()[0];
const regions = this.context.selectedRegions();
const environments = this.context.selectedEnvironments();
const timeWindow = this.context.timeWindow();
if (region && !params.has('region')) {
params = params.set('region', region);
if (regions.length > 0 && !params.has('regions') && !params.has('region')) {
params = params.set('regions', regions.join(','));
params = params.set('region', regions[0]);
}
if (environment && !params.has('environment')) {
params = params.set('environment', environment);
if (environments.length > 0 && !params.has('environments') && !params.has('environment')) {
params = params.set('environments', environments.join(','));
params = params.set('environment', environments[0]);
}
if (timeWindow && !params.has('timeWindow')) {
params = params.set('timeWindow', timeWindow);
}
@@ -37,6 +41,7 @@ export class GlobalContextHttpInterceptor implements HttpInterceptor {
url.includes('/api/v2/security') ||
url.includes('/api/v2/evidence') ||
url.includes('/api/v2/topology') ||
url.includes('/api/v2/platform') ||
url.includes('/api/v2/integrations')
);
}

View File

@@ -0,0 +1,161 @@
import { DestroyRef, Injectable, Injector, effect, inject } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { PlatformContextStore } from './platform-context.store';
@Injectable({ providedIn: 'root' })
export class PlatformContextUrlSyncService {
private readonly router = inject(Router);
private readonly context = inject(PlatformContextStore);
private readonly destroyRef = inject(DestroyRef);
private readonly injector = inject(Injector);
private initialized = false;
private syncingFromUrl = false;
private syncingToUrl = false;
initialize(): void {
if (this.initialized) {
return;
}
this.initialized = true;
this.context.initialize();
this.applyScopeFromUrl();
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef),
)
.subscribe(() => this.applyScopeFromUrl());
effect(
() => {
this.context.contextVersion();
if (!this.context.initialized() || this.syncingFromUrl) {
return;
}
const currentUrl = this.router.url;
if (!this.isScopeManagedPath(currentUrl)) {
return;
}
const currentTree = this.router.parseUrl(currentUrl);
const nextQuery = { ...currentTree.queryParams };
const patch = this.context.scopeQueryPatch();
this.applyPatch(nextQuery, patch);
if (this.queryEquals(currentTree.queryParams, nextQuery) || this.syncingToUrl) {
return;
}
this.syncingToUrl = true;
void this.router.navigate([], {
queryParams: nextQuery,
replaceUrl: true,
}).finally(() => {
this.syncingToUrl = false;
});
},
{ injector: this.injector },
);
}
private applyScopeFromUrl(): void {
if (this.syncingToUrl) {
return;
}
const currentUrl = this.router.url;
if (!this.isScopeManagedPath(currentUrl)) {
return;
}
const currentTree = this.router.parseUrl(currentUrl);
this.syncingFromUrl = true;
try {
this.context.applyScopeQueryParams(currentTree.queryParams as Record<string, unknown>);
} finally {
this.syncingFromUrl = false;
}
}
private applyPatch(
target: Record<string, unknown>,
patch: Record<string, string | null>,
): void {
for (const [key, value] of Object.entries(patch)) {
if (value === null || value.trim().length === 0) {
delete target[key];
} else {
target[key] = value;
}
}
}
private queryEquals(
left: Record<string, unknown>,
right: Record<string, unknown>,
): boolean {
return JSON.stringify(this.normalizeQuery(left)) === JSON.stringify(this.normalizeQuery(right));
}
private normalizeQuery(query: Record<string, unknown>): Record<string, string[]> {
const normalized: Record<string, string[]> = {};
for (const [key, value] of Object.entries(query)) {
if (value === null || value === undefined) {
continue;
}
if (Array.isArray(value)) {
normalized[key] = value
.map((entry) => String(entry ?? '').trim())
.filter((entry) => entry.length > 0)
.sort((a, b) => a.localeCompare(b));
continue;
}
const serialized = String(value).trim();
if (serialized.length > 0) {
normalized[key] = [serialized];
}
}
const sortedKeys = Object.keys(normalized).sort((a, b) => a.localeCompare(b));
const ordered: Record<string, string[]> = {};
for (const key of sortedKeys) {
ordered[key] = normalized[key];
}
return ordered;
}
private isScopeManagedPath(url: string): boolean {
const path = url.split('?')[0].toLowerCase();
if (
path.startsWith('/setup')
|| path.startsWith('/auth/')
|| path.startsWith('/welcome')
|| path.startsWith('/console/')
) {
return false;
}
return (
path === '/'
|| path.startsWith('/dashboard')
|| path.startsWith('/releases')
|| path.startsWith('/security')
|| path.startsWith('/evidence')
|| path.startsWith('/topology')
|| path.startsWith('/platform')
|| path.startsWith('/operations')
|| path.startsWith('/integrations')
|| path.startsWith('/administration')
);
}
}

View File

@@ -29,12 +29,22 @@ export interface PlatformContextPreferences {
}
const DEFAULT_TIME_WINDOW = '24h';
const REGION_QUERY_KEYS = ['regions', 'region'];
const ENVIRONMENT_QUERY_KEYS = ['environments', 'environment', 'env'];
const TIME_WINDOW_QUERY_KEYS = ['timeWindow', 'time'];
interface PlatformContextQueryState {
regions: string[];
environments: string[];
timeWindow: string;
}
@Injectable({ providedIn: 'root' })
export class PlatformContextStore {
private readonly http = inject(HttpClient);
private persistPaused = false;
private readonly apiDisabled = this.shouldDisableApiCalls();
private readonly initialQueryOverride = this.readScopeQueryFromLocation();
readonly regions = signal<PlatformContextRegion[]>([]);
readonly environments = signal<PlatformContextEnvironment[]>([]);
@@ -152,26 +162,104 @@ export class PlatformContextStore {
this.bumpContextVersion();
}
scopeQueryPatch(): Record<string, string | null> {
const regions = this.selectedRegions();
const environments = this.selectedEnvironments();
const timeWindow = this.timeWindow();
return {
regions: regions.length > 0 ? regions.join(',') : null,
environments: environments.length > 0 ? environments.join(',') : null,
timeWindow: timeWindow !== DEFAULT_TIME_WINDOW ? timeWindow : null,
};
}
applyScopeQueryParams(queryParams: Record<string, unknown>): void {
if (!this.initialized()) {
return;
}
const queryState = this.parseScopeQueryState(queryParams);
if (!queryState) {
return;
}
const allowedRegions = this.regions().map((item) => item.regionId);
const nextRegions = this.normalizeIds(queryState.regions, allowedRegions);
const nextTimeWindow = queryState.timeWindow || DEFAULT_TIME_WINDOW;
const regionsChanged = !this.arraysEqual(nextRegions, this.selectedRegions());
const timeChanged = nextTimeWindow !== this.timeWindow();
const preferredEnvironmentIds = queryState.environments.length > 0
? queryState.environments
: this.selectedEnvironments();
if (regionsChanged) {
this.selectedRegions.set(nextRegions);
this.timeWindow.set(nextTimeWindow);
this.loadEnvironments(nextRegions, preferredEnvironmentIds, true);
return;
}
if (queryState.environments.length > 0) {
const nextEnvironments = this.normalizeIds(
queryState.environments,
this.environments().map((item) => item.environmentId),
);
const environmentsChanged = !this.arraysEqual(nextEnvironments, this.selectedEnvironments());
if (environmentsChanged) {
this.selectedEnvironments.set(nextEnvironments);
}
if (timeChanged || environmentsChanged) {
this.timeWindow.set(nextTimeWindow);
this.persistPreferences();
this.bumpContextVersion();
}
return;
}
if (timeChanged) {
this.timeWindow.set(nextTimeWindow);
this.persistPreferences();
this.bumpContextVersion();
}
}
private loadPreferences(): void {
this.http
.get<PlatformContextPreferences>('/api/v2/context/preferences')
.pipe(take(1))
.subscribe({
next: (prefs) => {
const preferenceState: PlatformContextQueryState = {
regions: prefs?.regions ?? [],
environments: prefs?.environments ?? [],
timeWindow: (prefs?.timeWindow ?? DEFAULT_TIME_WINDOW).trim() || DEFAULT_TIME_WINDOW,
};
const hydrated = this.mergeWithInitialQueryOverride(preferenceState);
const preferredRegions = this.normalizeIds(
prefs?.regions ?? [],
hydrated.regions,
this.regions().map((item) => item.regionId),
);
this.selectedRegions.set(preferredRegions);
this.timeWindow.set((prefs?.timeWindow ?? DEFAULT_TIME_WINDOW).trim() || DEFAULT_TIME_WINDOW);
this.loadEnvironments(preferredRegions, prefs?.environments ?? [], false);
this.timeWindow.set(hydrated.timeWindow);
this.loadEnvironments(preferredRegions, hydrated.environments, false);
},
error: () => {
// Preferences are optional; continue with default empty context.
this.selectedRegions.set([]);
const fallbackState = this.mergeWithInitialQueryOverride({
regions: [],
environments: [],
timeWindow: DEFAULT_TIME_WINDOW,
});
const preferredRegions = this.normalizeIds(
fallbackState.regions,
this.regions().map((item) => item.regionId),
);
this.selectedRegions.set(preferredRegions);
this.selectedEnvironments.set([]);
this.timeWindow.set(DEFAULT_TIME_WINDOW);
this.loadEnvironments([], [], false);
this.timeWindow.set(fallbackState.timeWindow);
this.loadEnvironments(preferredRegions, fallbackState.environments, false);
},
});
}
@@ -257,6 +345,119 @@ export class PlatformContextStore {
this.persistPaused = false;
}
private mergeWithInitialQueryOverride(baseState: PlatformContextQueryState): PlatformContextQueryState {
const override = this.initialQueryOverride;
if (!override) {
return baseState;
}
return {
regions: override.regions.length > 0 ? override.regions : baseState.regions,
environments: override.environments.length > 0 ? override.environments : baseState.environments,
timeWindow: override.timeWindow || baseState.timeWindow,
};
}
private readScopeQueryFromLocation(): PlatformContextQueryState | null {
const location = (globalThis as { location?: { search?: string } }).location;
if (!location?.search) {
return null;
}
const params = new URLSearchParams(location.search);
const toRecord: Record<string, string | string[]> = {};
for (const [key, value] of params.entries()) {
if (toRecord[key] === undefined) {
toRecord[key] = value;
continue;
}
const existing = toRecord[key];
toRecord[key] = Array.isArray(existing) ? [...existing, value] : [existing, value];
}
return this.parseScopeQueryState(toRecord);
}
private parseScopeQueryState(queryParams: Record<string, unknown>): PlatformContextQueryState | null {
const regions = this.readQueryList(queryParams, REGION_QUERY_KEYS);
const environments = this.readQueryList(queryParams, ENVIRONMENT_QUERY_KEYS);
const timeWindow = this.readQueryValue(queryParams, TIME_WINDOW_QUERY_KEYS);
if (regions.length === 0 && environments.length === 0 && !timeWindow) {
return null;
}
return {
regions,
environments,
timeWindow: (timeWindow || DEFAULT_TIME_WINDOW).trim() || DEFAULT_TIME_WINDOW,
};
}
private readQueryList(queryParams: Record<string, unknown>, keys: readonly string[]): string[] {
const values: string[] = [];
for (const key of keys) {
const raw = queryParams[key];
if (raw === undefined || raw === null) {
continue;
}
if (Array.isArray(raw)) {
for (const value of raw) {
const text = String(value ?? '').trim();
if (!text) {
continue;
}
values.push(...text.split(',').map((token) => token.trim()).filter(Boolean));
}
continue;
}
const text = String(raw).trim();
if (!text) {
continue;
}
values.push(...text.split(',').map((token) => token.trim()).filter(Boolean));
}
const seen = new Set<string>();
const normalized: string[] = [];
for (const value of values) {
const lower = value.toLowerCase();
if (!seen.has(lower)) {
seen.add(lower);
normalized.push(lower);
}
}
return normalized;
}
private readQueryValue(queryParams: Record<string, unknown>, keys: readonly string[]): string | null {
for (const key of keys) {
const raw = queryParams[key];
if (raw === undefined || raw === null) {
continue;
}
if (Array.isArray(raw)) {
const first = raw.find((value) => String(value ?? '').trim().length > 0);
if (first !== undefined) {
return String(first).trim();
}
continue;
}
const value = String(raw).trim();
if (value.length > 0) {
return value;
}
}
return null;
}
private normalizeIds(values: string[], allowedValues: string[]): string[] {
const allowed = new Set(allowedValues.map((value) => value.toLowerCase()));
const deduped = new Map<string, string>();

View File

@@ -0,0 +1,117 @@
import { Injectable, inject, signal, DestroyRef } from '@angular/core';
import { Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DOCTOR_API, DoctorApi } from '../../features/doctor/services/doctor.client';
import { ToastService } from '../services/toast.service';
const LAST_SEEN_KEY = 'stellaops_doctor_last_seen_report';
const MUTED_KEY = 'stellaops_doctor_notifications_muted';
/**
* Proactive toast notification service for scheduled Doctor runs.
* Polls for new reports and shows toast when failures/warnings found.
*/
@Injectable({ providedIn: 'root' })
export class DoctorNotificationService {
private readonly api = inject<DoctorApi>(DOCTOR_API);
private readonly toast = inject(ToastService);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private intervalId: ReturnType<typeof setInterval> | null = null;
/** Whether notifications are muted. Persisted in localStorage. */
readonly muted = signal(this.loadMutedState());
/** Start polling with 10s initial delay, then every 60s. */
start(): void {
setTimeout(() => {
this.checkForNewReports();
this.intervalId = setInterval(() => this.checkForNewReports(), 60000);
}, 10000);
}
/** Toggle mute state. */
toggleMute(): void {
const newState = !this.muted();
this.muted.set(newState);
try {
localStorage.setItem(MUTED_KEY, JSON.stringify(newState));
} catch {
// localStorage unavailable
}
}
private checkForNewReports(): void {
if (this.muted()) return;
this.api.listReports(1, 0)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (response) => {
const reports = response.reports ?? [];
if (reports.length === 0) return;
const latest = reports[0];
const lastSeen = this.getLastSeenReportId();
if (latest.runId === lastSeen) return;
this.setLastSeenReportId(latest.runId);
// Only show toast for reports with failures or warnings
const summary = latest.summary;
if (!summary) return;
if (summary.failed === 0 && summary.warnings === 0) return;
const severity = summary.failed > 0 ? 'error' : 'warning';
const counts = [];
if (summary.failed > 0) counts.push(`${summary.failed} failed`);
if (summary.warnings > 0) counts.push(`${summary.warnings} warnings`);
this.toast.show({
type: severity === 'error' ? 'error' : 'warning',
title: 'Doctor Run Complete',
message: counts.join(', '),
duration: 10000,
action: {
label: 'View Details',
onClick: () => {
this.router.navigate(['/platform/ops/doctor'], {
queryParams: { runId: latest.runId },
});
},
},
});
},
error: () => {
// Silent — background service should not show errors
},
});
}
private getLastSeenReportId(): string | null {
try {
return localStorage.getItem(LAST_SEEN_KEY);
} catch {
return null;
}
}
private setLastSeenReportId(runId: string): void {
try {
localStorage.setItem(LAST_SEEN_KEY, runId);
} catch {
// localStorage unavailable
}
}
private loadMutedState(): boolean {
try {
return JSON.parse(localStorage.getItem(MUTED_KEY) ?? 'false');
} catch {
return false;
}
}
}

View File

@@ -0,0 +1,9 @@
export interface DoctorTrendPoint {
timestamp: string;
score: number;
}
export interface DoctorTrendResponse {
category: string;
points: DoctorTrendPoint[];
}

View File

@@ -0,0 +1,56 @@
import { Injectable, inject, signal, DestroyRef } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DOCTOR_API, DoctorApi } from '../../features/doctor/services/doctor.client';
import { DoctorTrendResponse } from './doctor-trend.models';
/**
* Service for fetching Doctor health trend data.
* Provides signals for sparkline rendering in the sidebar.
*/
@Injectable({ providedIn: 'root' })
export class DoctorTrendService {
private readonly api = inject<DoctorApi>(DOCTOR_API);
private readonly destroyRef = inject(DestroyRef);
private intervalId: ReturnType<typeof setInterval> | null = null;
/** Last 12 trend scores for the security category. */
readonly securityTrend = signal<number[]>([]);
/** Last 12 trend scores for the platform category. */
readonly platformTrend = signal<number[]>([]);
/** Start periodic trend fetching (60s interval). */
start(): void {
this.fetchTrends();
this.intervalId = setInterval(() => this.fetchTrends(), 60000);
}
/** Force immediate re-fetch. */
refresh(): void {
this.fetchTrends();
}
private fetchTrends(): void {
this.api.getTrends?.(['security', 'platform'], 12)
?.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (responses: DoctorTrendResponse[]) => {
for (const response of responses) {
const points = response.points.map((p) => p.score);
if (response.category === 'security') {
this.securityTrend.set(points);
} else if (response.category === 'platform') {
this.platformTrend.set(points);
}
}
},
error: () => {
// Graceful degradation: clear signals so sparklines disappear
this.securityTrend.set([]);
this.platformTrend.set([]);
},
});
}
}

View File

@@ -0,0 +1,3 @@
export * from './doctor-trend.models';
export * from './doctor-trend.service';
export * from './doctor-notification.service';

View File

@@ -1,199 +1,55 @@
/**
* Legacy Route Telemetry Service
* Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (ROUTE-002)
*
* Tracks usage of legacy routes to inform migration adoption and deprecation timeline.
* Listens to router events and detects when navigation originated from a legacy redirect.
* Tracks usage of legacy routes during the alias window by resolving legacy
* hits against the canonical redirect templates in `legacy-redirects.routes.ts`.
*/
import { Injectable, inject, DestroyRef, signal } from '@angular/core';
import { Router, NavigationEnd, NavigationStart, RoutesRecognized } from '@angular/router';
import { Router, NavigationEnd, NavigationStart } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter, pairwise, map } from 'rxjs';
import { filter } from 'rxjs';
import { TelemetryClient } from '../telemetry/telemetry.client';
import { AUTH_SERVICE, AuthService } from '../auth/auth.service';
import { LEGACY_REDIRECT_ROUTE_TEMPLATES } from '../../routes/legacy-redirects.routes';
/**
* Map of legacy route patterns to their new canonical paths.
* Used to detect when a route was accessed via legacy URL.
*/
const LEGACY_ROUTE_MAP: Record<string, string> = {
// Pack 22 root migration aliases
'release-control': '/releases',
'release-control/releases': '/releases',
'release-control/approvals': '/releases/approvals',
'release-control/runs': '/releases/activity',
'release-control/deployments': '/releases/activity',
'release-control/promotions': '/releases/activity',
'release-control/hotfixes': '/releases',
'release-control/regions': '/topology/regions',
'release-control/setup': '/topology',
interface CompiledLegacyRouteTemplate {
sourcePath: string;
targetTemplate: string;
regex: RegExp;
paramNames: string[];
}
'security-risk': '/security',
'security-risk/findings': '/security/findings',
'security-risk/vulnerabilities': '/security/vulnerabilities',
'security-risk/disposition': '/security/disposition',
'security-risk/sbom': '/security/sbom-explorer/graph',
'security-risk/sbom-lake': '/security/sbom-explorer/table',
'security-risk/vex': '/security/disposition',
'security-risk/exceptions': '/security/disposition',
'security-risk/advisory-sources': '/integrations/feeds',
interface PendingLegacyRoute {
oldPath: string;
expectedNewPath: string;
}
'evidence-audit': '/evidence',
'evidence-audit/packs': '/evidence/packs',
'evidence-audit/bundles': '/evidence/bundles',
'evidence-audit/evidence': '/evidence/evidence',
'evidence-audit/proofs': '/evidence/proofs',
'evidence-audit/audit-log': '/evidence/audit-log',
'evidence-audit/replay': '/evidence/replay',
const COMPILED_LEGACY_ROUTE_TEMPLATES: readonly CompiledLegacyRouteTemplate[] = [...LEGACY_REDIRECT_ROUTE_TEMPLATES]
.sort((left, right) => right.path.length - left.path.length)
.map((template) => {
const paramNames: string[] = [];
const sourcePath = template.path.replace(/^\/+/, '').replace(/\/+$/, '');
const regexPattern = sourcePath
.split('/')
.map((segment) => {
if (segment.startsWith(':')) {
const name = segment.slice(1);
paramNames.push(name);
return `(?<${name}>[^/]+)`;
}
return escapeRegex(segment);
})
.join('/');
'platform-ops': '/operations',
'platform-ops/data-integrity': '/operations/data-integrity',
'platform-ops/orchestrator': '/operations/orchestrator',
'platform-ops/health': '/operations/health',
'platform-ops/quotas': '/operations/quotas',
'platform-ops/feeds': '/operations/feeds',
'platform-ops/offline-kit': '/operations/offline-kit',
'platform-ops/agents': '/topology/agents',
// Home & Dashboard
'dashboard/sources': '/operations/feeds',
'home': '/',
// Analyze -> Security
'findings': '/security/findings',
'vulnerabilities': '/security/vulnerabilities',
'graph': '/security/sbom/graph',
'lineage': '/security/lineage',
'reachability': '/security/reachability',
'analyze/unknowns': '/security/unknowns',
'analyze/patch-map': '/security/patch-map',
// Triage -> Security + Policy
'triage/artifacts': '/security/artifacts',
'triage/audit-bundles': '/evidence',
'exceptions': '/policy/exceptions',
'risk': '/security/risk',
// Policy Studio -> Policy
'policy-studio/packs': '/policy/packs',
// VEX Hub -> Security
'admin/vex-hub': '/security/vex',
// Orchestrator -> Operations
'orchestrator': '/operations/orchestrator',
// Ops -> Operations
'ops/quotas': '/operations/quotas',
'ops/orchestrator/dead-letter': '/operations/dead-letter',
'ops/orchestrator/slo': '/operations/slo',
'ops/health': '/operations/health',
'ops/feeds': '/operations/feeds',
'ops/offline-kit': '/operations/offline-kit',
'ops/aoc': '/operations/aoc',
'ops/doctor': '/operations/doctor',
// Console -> Settings
'console/profile': '/settings/profile',
'console/status': '/operations/status',
'console/configuration': '/settings/integrations',
'console/admin/tenants': '/settings/admin/tenants',
'console/admin/users': '/settings/admin/users',
'console/admin/roles': '/settings/admin/roles',
'console/admin/clients': '/settings/admin/clients',
'console/admin/tokens': '/settings/admin/tokens',
'console/admin/branding': '/settings/admin/branding',
// Admin -> Settings
'admin/trust': '/settings/trust',
'admin/registries': '/settings/integrations/registries',
'admin/issuers': '/settings/trust/issuers',
'admin/notifications': '/settings/notifications',
'admin/audit': '/evidence/audit',
'admin/policy/governance': '/policy/governance',
'concelier/trivy-db-settings': '/settings/security-data/trivy',
// Integrations -> Settings
'integrations': '/settings/integrations',
'sbom-sources': '/settings/sbom-sources',
// Release Orchestrator -> Root
'release-orchestrator': '/',
'release-orchestrator/environments': '/environments',
'release-orchestrator/releases': '/releases',
'release-orchestrator/approvals': '/approvals',
'release-orchestrator/deployments': '/deployments',
'release-orchestrator/workflows': '/settings/workflows',
'release-orchestrator/evidence': '/evidence',
// Evidence
'evidence-packs': '/evidence/packs',
// Other
'ai-runs': '/operations/ai-runs',
'change-trace': '/evidence/change-trace',
'notify': '/operations/notifications',
};
/**
* Patterns for parameterized legacy routes.
* These use regex to match dynamic segments.
*/
const LEGACY_ROUTE_PATTERNS: Array<{ pattern: RegExp; oldPrefix: string; newPrefix: string }> = [
{ pattern: /^release-control\/releases\/([^/]+)$/, oldPrefix: 'release-control/releases/', newPrefix: '/releases/' },
{ pattern: /^release-control\/approvals\/([^/]+)$/, oldPrefix: 'release-control/approvals/', newPrefix: '/releases/approvals/' },
{ pattern: /^security-risk\/findings\/([^/]+)$/, oldPrefix: 'security-risk/findings/', newPrefix: '/security/findings/' },
{ pattern: /^security-risk\/vulnerabilities\/([^/]+)$/, oldPrefix: 'security-risk/vulnerabilities/', newPrefix: '/security/vulnerabilities/' },
{ pattern: /^evidence-audit\/packs\/([^/]+)$/, oldPrefix: 'evidence-audit/packs/', newPrefix: '/evidence/packs/' },
// Scan/finding details
{ pattern: /^findings\/([^/]+)$/, oldPrefix: 'findings/', newPrefix: '/security/scans/' },
{ pattern: /^scans\/([^/]+)$/, oldPrefix: 'scans/', newPrefix: '/security/scans/' },
{ pattern: /^vulnerabilities\/([^/]+)$/, oldPrefix: 'vulnerabilities/', newPrefix: '/security/vulnerabilities/' },
// Lineage with params
{ pattern: /^lineage\/([^/]+)\/compare$/, oldPrefix: 'lineage/', newPrefix: '/security/lineage/' },
{ pattern: /^compare\/([^/]+)$/, oldPrefix: 'compare/', newPrefix: '/security/lineage/compare/' },
// CVSS receipts
{ pattern: /^cvss\/receipts\/([^/]+)$/, oldPrefix: 'cvss/receipts/', newPrefix: '/evidence/receipts/cvss/' },
// Triage artifacts
{ pattern: /^triage\/artifacts\/([^/]+)$/, oldPrefix: 'triage/artifacts/', newPrefix: '/security/artifacts/' },
{ pattern: /^exceptions\/([^/]+)$/, oldPrefix: 'exceptions/', newPrefix: '/policy/exceptions/' },
// Policy packs
{ pattern: /^policy-studio\/packs\/([^/]+)/, oldPrefix: 'policy-studio/packs/', newPrefix: '/policy/packs/' },
// VEX Hub
{ pattern: /^admin\/vex-hub\/search\/detail\/([^/]+)$/, oldPrefix: 'admin/vex-hub/search/detail/', newPrefix: '/security/vex/search/detail/' },
{ pattern: /^admin\/vex-hub\/([^/]+)$/, oldPrefix: 'admin/vex-hub/', newPrefix: '/security/vex/' },
// Operations with page params
{ pattern: /^orchestrator\/([^/]+)$/, oldPrefix: 'orchestrator/', newPrefix: '/operations/orchestrator/' },
{ pattern: /^scheduler\/([^/]+)$/, oldPrefix: 'scheduler/', newPrefix: '/operations/scheduler/' },
{ pattern: /^ops\/quotas\/([^/]+)$/, oldPrefix: 'ops/quotas/', newPrefix: '/operations/quotas/' },
{ pattern: /^ops\/feeds\/([^/]+)$/, oldPrefix: 'ops/feeds/', newPrefix: '/operations/feeds/' },
// Console admin pages
{ pattern: /^console\/admin\/([^/]+)$/, oldPrefix: 'console/admin/', newPrefix: '/settings/admin/' },
// Admin trust pages
{ pattern: /^admin\/trust\/([^/]+)$/, oldPrefix: 'admin/trust/', newPrefix: '/settings/trust/' },
// Integrations
{ pattern: /^integrations\/activity$/, oldPrefix: 'integrations/activity', newPrefix: '/settings/integrations/activity' },
{ pattern: /^integrations\/([^/]+)$/, oldPrefix: 'integrations/', newPrefix: '/settings/integrations/' },
// Evidence packs
{ pattern: /^evidence-packs\/([^/]+)$/, oldPrefix: 'evidence-packs/', newPrefix: '/evidence/packs/' },
{ pattern: /^proofs\/([^/]+)$/, oldPrefix: 'proofs/', newPrefix: '/evidence/proofs/' },
// AI runs
{ pattern: /^ai-runs\/([^/]+)$/, oldPrefix: 'ai-runs/', newPrefix: '/operations/ai-runs/' },
];
return {
sourcePath,
targetTemplate: template.redirectTo,
regex: new RegExp(`^${regexPattern}$`),
paramNames,
};
});
export interface LegacyRouteHitEvent {
eventType: 'legacy_route_hit';
@@ -218,147 +74,126 @@ export class LegacyRouteTelemetryService {
private readonly authService = inject(AUTH_SERVICE) as AuthService;
private readonly destroyRef = inject(DestroyRef);
private pendingLegacyRoute: string | null = null;
private pendingLegacyRoute: PendingLegacyRoute | null = null;
private initialized = false;
/**
* Current legacy route info, if the page was accessed via a legacy URL.
* Used by the LegacyUrlBannerComponent to show the banner.
*/
readonly currentLegacyRoute = signal<LegacyRouteInfo | null>(null);
/**
* Initialize the telemetry service.
* Should be called once during app bootstrap.
*/
initialize(): void {
if (this.initialized) return;
if (this.initialized) {
return;
}
this.initialized = true;
// Track NavigationStart to capture the initial URL before redirect
this.router.events.pipe(
filter((e): e is NavigationStart => e instanceof NavigationStart),
takeUntilDestroyed(this.destroyRef)
).subscribe(event => {
const path = this.normalizePath(event.url);
if (this.isLegacyRoute(path)) {
this.pendingLegacyRoute = path;
}
});
this.router.events
.pipe(
filter((event): event is NavigationStart => event instanceof NavigationStart),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((event) => {
const path = this.normalizePath(event.url);
const resolved = this.resolveLegacyRedirect(path);
this.pendingLegacyRoute = resolved
? { oldPath: path, expectedNewPath: resolved }
: null;
});
// Track NavigationEnd to confirm the redirect completed
this.router.events.pipe(
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef)
).subscribe(event => {
if (this.pendingLegacyRoute) {
const oldPath = this.pendingLegacyRoute;
const newPath = this.normalizePath(event.urlAfterRedirects);
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
takeUntilDestroyed(this.destroyRef),
)
.subscribe((event) => {
if (!this.pendingLegacyRoute) {
return;
}
// Only emit if we actually redirected to a different path
if (oldPath !== newPath) {
this.emitLegacyRouteHit(oldPath, newPath);
const oldPath = this.pendingLegacyRoute.oldPath;
const resolvedPath = this.pendingLegacyRoute.expectedNewPath;
const redirectedPath = this.asAbsolutePath(this.normalizePath(event.urlAfterRedirects));
const pathChanged = oldPath !== this.normalizePath(event.urlAfterRedirects);
if (pathChanged) {
this.emitLegacyRouteHit(oldPath, redirectedPath || resolvedPath);
}
this.pendingLegacyRoute = null;
}
});
});
}
/**
* Check if a path matches a known legacy route.
*/
private isLegacyRoute(path: string): boolean {
// Check exact matches first
if (LEGACY_ROUTE_MAP[path]) {
return true;
}
// Check pattern matches
for (const { pattern } of LEGACY_ROUTE_PATTERNS) {
if (pattern.test(path)) {
return true;
}
}
return false;
clearCurrentLegacyRoute(): void {
this.currentLegacyRoute.set(null);
}
getLegacyRouteCount(): number {
return COMPILED_LEGACY_ROUTE_TEMPLATES.length;
}
private resolveLegacyRedirect(path: string): string | null {
for (const template of COMPILED_LEGACY_ROUTE_TEMPLATES) {
const match = template.regex.exec(path);
if (!match) {
continue;
}
let target = template.targetTemplate;
for (const name of template.paramNames) {
const value = match.groups?.[name];
if (value) {
target = target.replace(`:${name}`, value);
}
}
return this.asAbsolutePath(this.normalizePath(target));
}
return null;
}
/**
* Normalize a URL path by removing leading slash and query params.
*/
private normalizePath(url: string): string {
let path = url;
// Remove query string
const queryIndex = path.indexOf('?');
if (queryIndex !== -1) {
path = path.substring(0, queryIndex);
}
// Remove fragment
const fragmentIndex = path.indexOf('#');
if (fragmentIndex !== -1) {
path = path.substring(0, fragmentIndex);
}
// Remove leading slash
if (path.startsWith('/')) {
path = path.substring(1);
}
// Remove trailing slash
if (path.endsWith('/')) {
path = path.substring(0, path.length - 1);
}
path = path.replace(/^\/+/, '').replace(/\/+$/, '');
return path;
}
/**
* Emit telemetry event for legacy route hit.
*/
private asAbsolutePath(path: string): string {
if (!path) {
return '/';
}
return path.startsWith('/') ? path : `/${path}`;
}
private emitLegacyRouteHit(oldPath: string, newPath: string): void {
const user = this.authService.user();
// Set current legacy route info for banner
this.currentLegacyRoute.set({
oldPath: `/${oldPath}`,
newPath,
oldPath: this.asAbsolutePath(oldPath),
newPath: this.asAbsolutePath(newPath),
timestamp: Date.now(),
});
this.telemetry.emit('legacy_route_hit', {
oldPath: `/${oldPath}`,
newPath,
oldPath: this.asAbsolutePath(oldPath),
newPath: this.asAbsolutePath(newPath),
tenantId: user?.tenantId ?? null,
userId: user?.id ?? null,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown',
referrer: typeof document !== 'undefined' ? document.referrer : '',
});
// Also log to console in development
if (typeof console !== 'undefined') {
console.info(
`[LegacyRouteTelemetry] Legacy route hit: /${oldPath} -> ${newPath}`,
{ tenantId: user?.tenantId, userId: user?.id }
);
}
}
/**
* Clear the current legacy route info.
* Called when banner is dismissed.
*/
clearCurrentLegacyRoute(): void {
this.currentLegacyRoute.set(null);
}
/**
* Get statistics about legacy route usage.
* This is for debugging/admin purposes.
*/
getLegacyRouteCount(): number {
return Object.keys(LEGACY_ROUTE_MAP).length + LEGACY_ROUTE_PATTERNS.length;
}
}
function escapeRegex(input: string): string {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View File

@@ -82,6 +82,24 @@ export class ToastService {
return this.show({ type: 'info', title, message, ...options });
}
/** Update an existing toast in-place. */
update(id: string, options: Partial<ToastOptions>): void {
this._toasts.update(toasts =>
toasts.map(t => {
if (t.id !== id) return t;
return {
...t,
...(options.type != null && { type: options.type }),
...(options.title != null && { title: options.title }),
...(options.message !== undefined && { message: options.message }),
...(options.duration != null && { duration: options.duration }),
...(options.dismissible != null && { dismissible: options.dismissible }),
...(options.action !== undefined && { action: options.action }),
};
})
);
}
/** Dismiss a specific toast */
dismiss(id: string): void {
this._toasts.update(toasts => toasts.filter(t => t.id !== id));

View File

@@ -16,6 +16,11 @@
<button class="btn-icon-small" title="Re-run this check" (click)="onRerun($event)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
</button>
@if (wizardLink) {
<button class="btn-icon-small btn-fix-setup" title="Fix in Setup Wizard" (click)="onFixInSetup($event)">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
</button>
}
<span class="expand-indicator" [innerHTML]="expanded ? chevronUpSvg : chevronDownSvg"></span>
</div>
</div>

View File

@@ -4,6 +4,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
import { CheckResult } from '../../models/doctor.models';
import { RemediationPanelComponent } from '../remediation-panel/remediation-panel.component';
import { EvidenceViewerComponent } from '../evidence-viewer/evidence-viewer.component';
import { getWizardStepForCheck, buildWizardDeepLink } from '../../models/doctor-wizard-mapping';
@Component({
selector: 'st-check-result',
@@ -16,6 +17,7 @@ export class CheckResultComponent {
@Input() expanded = false;
@Input() fixEnabled = false;
@Output() rerun = new EventEmitter<void>();
@Output() fixInSetup = new EventEmitter<string>();
private readonly svgAttrs = 'xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"';
@@ -71,8 +73,21 @@ export class CheckResultComponent {
return `${(ms / 1000).toFixed(2)}s`;
}
get wizardLink(): string | null {
if (this.result.severity !== 'fail' && this.result.severity !== 'warn') return null;
const mapping = getWizardStepForCheck(this.result.checkId);
if (!mapping) return null;
return buildWizardDeepLink(mapping.stepId);
}
onRerun(event: Event): void {
event.stopPropagation();
this.rerun.emit();
}
onFixInSetup(event: Event): void {
event.stopPropagation();
const link = this.wizardLink;
if (link) this.fixInSetup.emit(link);
}
}

View File

@@ -0,0 +1,169 @@
import { Component, computed, inject, Input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { DoctorStore } from '../../services/doctor.store';
import { CheckResultComponent } from '../check-result/check-result.component';
/**
* Inline doctor checks strip for embedding on module pages.
* Shows a compact summary ("3 pass / 1 warn / 0 fail") with expand toggle
* to reveal individual check results.
*/
@Component({
selector: 'st-doctor-checks-inline',
standalone: true,
imports: [RouterLink, CheckResultComponent],
template: `
<div class="doctor-inline" [class.doctor-inline--expanded]="expanded">
<div class="doctor-inline__header" (click)="toggle()">
<div class="doctor-inline__title">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" 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 12h-4l-3 9L9 3l-3 9H2"/>
</svg>
<span>{{ heading || 'Health Checks' }}</span>
</div>
@if (summary(); as s) {
<div class="doctor-inline__counts">
<span class="count count--pass">{{ s.pass }} pass</span>
<span class="count-sep">/</span>
<span class="count count--warn">{{ s.warn }} warn</span>
<span class="count-sep">/</span>
<span class="count count--fail">{{ s.fail }} fail</span>
</div>
} @else {
<span class="doctor-inline__no-data">No report</span>
}
<svg class="doctor-inline__chevron" xmlns="http://www.w3.org/2000/svg" width="14" height="14"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
[style.transform]="expanded ? 'rotate(180deg)' : 'none'">
<polyline points="6 9 12 15 18 9"/>
</svg>
</div>
@if (expanded) {
<div class="doctor-inline__body">
@for (result of visibleResults(); track result.checkId) {
<st-check-result [result]="result" />
}
@if (results().length === 0) {
<p class="doctor-inline__empty">No checks for this category.</p>
}
<div class="doctor-inline__actions">
<button class="btn btn-sm btn-secondary" (click)="onQuickRun($event)"
[disabled]="store.isRunning()">
Run Quick Check
</button>
<a class="btn btn-sm btn-ghost"
routerLink="/platform/ops/doctor"
[queryParams]="{ category: category }">
Open Full Diagnostics
</a>
</div>
</div>
}
</div>
`,
styles: [`
.doctor-inline {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
overflow: hidden;
}
.doctor-inline__header {
display: flex;
align-items: center;
gap: .5rem;
padding: .5rem .65rem;
cursor: pointer;
user-select: none;
}
.doctor-inline__header:hover {
background: var(--color-surface-secondary);
}
.doctor-inline__title {
display: flex;
align-items: center;
gap: .35rem;
font-size: .78rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-heading);
}
.doctor-inline__counts {
display: flex;
align-items: center;
gap: .25rem;
margin-left: auto;
font-size: .72rem;
font-family: var(--font-family-mono);
}
.count--pass { color: var(--color-status-success); }
.count--warn { color: var(--color-status-warning); }
.count--fail { color: var(--color-status-error); }
.count-sep { color: var(--color-text-muted); }
.doctor-inline__no-data {
margin-left: auto;
font-size: .72rem;
color: var(--color-text-muted);
}
.doctor-inline__chevron {
color: var(--color-text-secondary);
transition: transform .15s ease;
flex-shrink: 0;
}
.doctor-inline__body {
border-top: 1px solid var(--color-border-primary);
padding: .5rem .65rem;
display: grid;
gap: .35rem;
}
.doctor-inline__empty {
margin: 0;
font-size: .74rem;
color: var(--color-text-muted);
}
.doctor-inline__actions {
display: flex;
gap: .5rem;
margin-top: .25rem;
}
.btn-sm {
font-size: .72rem;
padding: .25rem .5rem;
}
`],
})
export class DoctorChecksInlineComponent {
@Input({ required: true }) category!: string;
@Input() heading?: string;
@Input() autoRun = false;
@Input() maxResults = 5;
readonly store = inject(DoctorStore);
expanded = false;
readonly summary = computed(() => this.store.summaryByCategory(this.category));
readonly results = computed(() => this.store.resultsByCategory(this.category));
readonly visibleResults = computed(() => this.results().slice(0, this.maxResults));
toggle(): void {
this.expanded = !this.expanded;
if (this.expanded && this.autoRun && !this.store.hasReport() && !this.store.isRunning()) {
this.store.startRun({ mode: 'quick', categories: [this.category as any] });
}
}
onQuickRun(event: Event): void {
event.stopPropagation();
this.store.startRun({ mode: 'quick', categories: [this.category as any], includeRemediation: true });
}
}

View File

@@ -1,6 +1,7 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { DoctorStore } from './services/doctor.store';
import { CheckResult, DoctorCategory, DoctorSeverity, RunDoctorRequest } from './models/doctor.models';
@@ -23,6 +24,8 @@ import { AppConfigService } from '../../core/config/app-config.service';
export class DoctorDashboardComponent implements OnInit {
readonly store = inject(DoctorStore);
private readonly configService = inject(AppConfigService);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly fixEnabled = this.configService.config.doctor?.fixEnabled ?? false;
readonly showExportDialog = signal(false);
@@ -49,6 +52,16 @@ export class DoctorDashboardComponent implements OnInit {
// Load metadata on init
this.store.fetchPlugins();
this.store.fetchChecks();
// Apply category filter from query param
const category = this.route.snapshot.queryParamMap.get('category');
if (category) {
this.store.setCategoryFilter(category as DoctorCategory);
}
}
onFixInSetup(url: string): void {
this.router.navigateByUrl(url);
}
runQuickCheck(): void {

View File

@@ -9,6 +9,7 @@ export * from './services/doctor.store';
// Components
export * from './doctor-dashboard.component';
export * from './components/doctor-checks-inline/doctor-checks-inline.component';
export * from './components/summary-strip/summary-strip.component';
export * from './components/check-result/check-result.component';
export * from './components/remediation-panel/remediation-panel.component';

View File

@@ -0,0 +1,96 @@
import { SetupStepId } from '../../setup-wizard/models/setup-wizard.models';
/**
* Maps a Doctor check ID to its corresponding Setup Wizard step.
*/
export interface DoctorWizardMapping {
checkId: string;
stepId: SetupStepId;
label: string;
}
/**
* Mappings derived from DEFAULT_SETUP_STEPS[].validationChecks arrays
* and docs/setup/setup-wizard-doctor-contract.md.
*/
export const DOCTOR_WIZARD_MAPPINGS: DoctorWizardMapping[] = [
// database
{ checkId: 'check.database.connectivity', stepId: 'database', label: 'Database Connectivity' },
{ checkId: 'check.database.migrations', stepId: 'database', label: 'Database Migrations' },
// cache
{ checkId: 'check.cache.connectivity', stepId: 'cache', label: 'Cache Connectivity' },
{ checkId: 'check.cache.persistence', stepId: 'cache', label: 'Cache Persistence' },
// migrations
{ checkId: 'check.database.migrations.pending', stepId: 'migrations', label: 'Pending Migrations' },
{ checkId: 'check.database.migrations.version', stepId: 'migrations', label: 'Migration Version' },
// authority
{ checkId: 'check.authority.plugin.configured', stepId: 'authority', label: 'Authority Plugin Config' },
{ checkId: 'check.authority.plugin.connectivity', stepId: 'authority', label: 'Authority Connectivity' },
// users
{ checkId: 'check.users.superuser.exists', stepId: 'users', label: 'Superuser Exists' },
{ checkId: 'check.authority.bootstrap.exists', stepId: 'users', label: 'Bootstrap Account' },
// crypto
{ checkId: 'check.crypto.provider.configured', stepId: 'crypto', label: 'Crypto Provider Config' },
{ checkId: 'check.crypto.provider.available', stepId: 'crypto', label: 'Crypto Provider Available' },
// vault
{ checkId: 'check.integration.vault.connectivity', stepId: 'vault', label: 'Vault Connectivity' },
{ checkId: 'check.integration.vault.auth', stepId: 'vault', label: 'Vault Authentication' },
// registry
{ checkId: 'check.integration.registry.connectivity', stepId: 'registry', label: 'Registry Connectivity' },
{ checkId: 'check.integration.registry.auth', stepId: 'registry', label: 'Registry Authentication' },
// scm
{ checkId: 'check.integration.scm.connectivity', stepId: 'scm', label: 'SCM Connectivity' },
{ checkId: 'check.integration.scm.auth', stepId: 'scm', label: 'SCM Authentication' },
// sources
{ checkId: 'check.sources.feeds.configured', stepId: 'sources', label: 'Feed Sources Config' },
{ checkId: 'check.sources.feeds.connectivity', stepId: 'sources', label: 'Feed Sources Connectivity' },
// notify
{ checkId: 'check.notify.channel.configured', stepId: 'notify', label: 'Notification Channel Config' },
{ checkId: 'check.notify.channel.connectivity', stepId: 'notify', label: 'Notification Connectivity' },
// llm
{ checkId: 'check.ai.llm.config', stepId: 'llm', label: 'LLM Configuration' },
{ checkId: 'check.ai.provider.openai', stepId: 'llm', label: 'OpenAI Provider' },
{ checkId: 'check.ai.provider.claude', stepId: 'llm', label: 'Claude Provider' },
{ checkId: 'check.ai.provider.gemini', stepId: 'llm', label: 'Gemini Provider' },
// settingsstore
{ checkId: 'check.integration.settingsstore.connectivity', stepId: 'settingsstore', label: 'Settings Store Connectivity' },
{ checkId: 'check.integration.settingsstore.auth', stepId: 'settingsstore', label: 'Settings Store Auth' },
// environments
{ checkId: 'check.environments.defined', stepId: 'environments', label: 'Environments Defined' },
{ checkId: 'check.environments.promotion.path', stepId: 'environments', label: 'Promotion Path' },
// agents
{ checkId: 'check.agents.registered', stepId: 'agents', label: 'Agents Registered' },
{ checkId: 'check.agents.connectivity', stepId: 'agents', label: 'Agent Connectivity' },
// telemetry
{ checkId: 'check.telemetry.otlp.connectivity', stepId: 'telemetry', label: 'Telemetry OTLP Connectivity' },
];
/** Look up the wizard step mapping for a given Doctor check ID. */
export function getWizardStepForCheck(checkId: string): DoctorWizardMapping | undefined {
return DOCTOR_WIZARD_MAPPINGS.find((m) => m.checkId === checkId);
}
/** Get all Doctor check IDs associated with a given setup wizard step. */
export function getCheckIdsForStep(stepId: SetupStepId): string[] {
return DOCTOR_WIZARD_MAPPINGS.filter((m) => m.stepId === stepId).map((m) => m.checkId);
}
/** Build a deep-link URL to the setup wizard for a specific step in reconfigure mode. */
export function buildWizardDeepLink(stepId: SetupStepId): string {
return `/setup/wizard?step=${stepId}&mode=reconfigure`;
}

View File

@@ -0,0 +1,98 @@
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import { DoctorStore } from './doctor.store';
import { ToastService } from '../../../core/services/toast.service';
import { QuickAction } from '../../../core/api/search.models';
/**
* Service for running Doctor checks from the command palette.
* Provides quick actions and manages progress/result toast notifications.
*/
@Injectable({ providedIn: 'root' })
export class DoctorQuickCheckService {
private readonly store = inject(DoctorStore);
private readonly toast = inject(ToastService);
private readonly router = inject(Router);
/** Run a quick Doctor check with progress toast. */
runQuickCheck(): void {
const toastId = this.toast.show({
type: 'info',
title: 'Running Quick Health Check...',
message: 'Doctor diagnostics in progress',
duration: 0, // stays until updated
dismissible: true,
});
this.store.startRun({ mode: 'quick', includeRemediation: true });
// Watch for completion via polling the store state
const checkInterval = setInterval(() => {
const state = this.store.state();
if (state === 'completed') {
clearInterval(checkInterval);
const summary = this.store.summary();
const runId = this.store.currentRunId();
this.toast.update(toastId, {
type: summary && summary.failed > 0 ? 'error' : 'success',
title: 'Health Check Complete',
message: summary
? `${summary.passed} pass, ${summary.warnings} warn, ${summary.failed} fail`
: 'Check complete',
duration: 8000,
action: {
label: 'View Details',
onClick: () => {
this.router.navigate(['/platform/ops/doctor'], {
queryParams: runId ? { runId } : {},
});
},
},
});
} else if (state === 'error') {
clearInterval(checkInterval);
this.toast.update(toastId, {
type: 'error',
title: 'Health Check Failed',
message: this.store.error() ?? 'An error occurred',
duration: 8000,
});
}
}, 500);
// Safety timeout
setTimeout(() => clearInterval(checkInterval), 300000);
}
/** Run full diagnostics and navigate to Doctor dashboard. */
runFullDiagnostics(): void {
this.store.startRun({ mode: 'full', includeRemediation: true });
this.router.navigate(['/platform/ops/doctor']);
}
/** Get Doctor-specific quick actions with bound callbacks. */
getQuickActions(): QuickAction[] {
return [
{
id: 'doctor-quick',
label: 'Run Quick Health Check',
shortcut: '>doctor',
description: 'Run a quick Doctor diagnostics check',
icon: 'activity',
keywords: ['doctor', 'health', 'check', 'quick', 'diagnostic'],
action: () => this.runQuickCheck(),
},
{
id: 'doctor-full',
label: 'Run Full Diagnostics',
shortcut: '>diagnostics',
description: 'Run comprehensive Doctor diagnostics',
icon: 'search',
keywords: ['doctor', 'diagnostics', 'full', 'comprehensive'],
action: () => this.runFullDiagnostics(),
},
];
}
}

View File

@@ -0,0 +1,91 @@
import { Injectable, inject } from '@angular/core';
import { Router } from '@angular/router';
import { DoctorStore } from './doctor.store';
import { ToastService } from '../../../core/services/toast.service';
import { getCheckIdsForStep } from '../models/doctor-wizard-mapping';
import { SetupStepId } from '../../setup-wizard/models/setup-wizard.models';
/**
* Service for running Doctor re-checks after Setup Wizard step reconfiguration.
* Provides progress/result toast notifications for targeted check runs.
*/
@Injectable({ providedIn: 'root' })
export class DoctorRecheckService {
private readonly store = inject(DoctorStore);
private readonly toast = inject(ToastService);
private readonly router = inject(Router);
/**
* Run targeted Doctor checks for a specific setup wizard step.
* Shows progress toast and updates on completion.
*/
recheckForStep(stepId: SetupStepId): void {
const checkIds = getCheckIdsForStep(stepId);
if (checkIds.length === 0) return;
const toastId = this.toast.show({
type: 'info',
title: 'Running Re-check...',
message: `Verifying ${checkIds.length} check(s) for ${stepId}`,
duration: 0,
dismissible: true,
});
this.store.startRun({ mode: 'quick', includeRemediation: true, checkIds });
const checkInterval = setInterval(() => {
const state = this.store.state();
if (state === 'completed') {
clearInterval(checkInterval);
const summary = this.store.summary();
const runId = this.store.currentRunId();
this.toast.update(toastId, {
type: summary && summary.failed > 0 ? 'error' : 'success',
title: 'Re-check Complete',
message: summary
? `${summary.passed} pass, ${summary.warnings} warn, ${summary.failed} fail`
: 'Re-check complete',
duration: 8000,
action: {
label: 'View Details',
onClick: () => {
this.router.navigate(['/platform/ops/doctor'], {
queryParams: runId ? { runId } : {},
});
},
},
});
} else if (state === 'error') {
clearInterval(checkInterval);
this.toast.update(toastId, {
type: 'error',
title: 'Re-check Failed',
message: this.store.error() ?? 'An error occurred',
duration: 8000,
});
}
}, 500);
// Safety timeout
setTimeout(() => clearInterval(checkInterval), 300000);
}
/**
* Show a success toast offering a re-check after a wizard step completes.
*/
offerRecheck(stepId: SetupStepId, stepName: string): void {
this.toast.show({
type: 'success',
title: `${stepName} configured successfully`,
message: 'Run Doctor re-check to verify',
duration: 10000,
dismissible: true,
action: {
label: 'Run Re-check',
onClick: () => this.recheckForStep(stepId),
},
});
}
}

View File

@@ -1,6 +1,7 @@
import { Injectable, InjectionToken, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, delay } from 'rxjs';
import { DoctorTrendResponse } from '../../../core/doctor/doctor-trend.models';
import {
CheckListResponse,
CheckMetadata,
@@ -40,6 +41,9 @@ export interface DoctorApi {
/** Delete a report by ID. */
deleteReport(reportId: string): Observable<void>;
/** Get health trend data for sparklines. */
getTrends?(categories?: string[], limit?: number): Observable<DoctorTrendResponse[]>;
}
export const DOCTOR_API = new InjectionToken<DoctorApi>('DOCTOR_API');
@@ -94,6 +98,13 @@ export class HttpDoctorClient implements DoctorApi {
deleteReport(reportId: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/reports/${reportId}`);
}
getTrends(categories?: string[], limit?: number): Observable<DoctorTrendResponse[]> {
const params: Record<string, string> = {};
if (categories?.length) params['categories'] = categories.join(',');
if (limit != null) params['limit'] = limit.toString();
return this.http.get<DoctorTrendResponse[]>(`${this.baseUrl}/trends`, { params });
}
}
/**
@@ -319,4 +330,16 @@ export class MockDoctorClient implements DoctorApi {
deleteReport(reportId: string): Observable<void> {
return of(undefined).pipe(delay(50));
}
getTrends(categories?: string[], limit = 12): Observable<DoctorTrendResponse[]> {
const cats = categories ?? ['security', 'platform'];
const responses: DoctorTrendResponse[] = cats.map((category) => ({
category,
points: Array.from({ length: limit }, (_, i) => ({
timestamp: new Date(Date.now() - (limit - i) * 3600000).toISOString(),
score: 70 + Math.round(Math.random() * 25),
})),
}));
return of(responses).pipe(delay(100));
}
}

View File

@@ -316,6 +316,24 @@ export class DoctorStore {
this.errorSignal.set(message);
}
/** Get results filtered by category. */
resultsByCategory(category: string): CheckResult[] {
const report = this.reportSignal();
if (!report) return [];
return report.results.filter((r) => r.category === category);
}
/** Get summary counts for a category. */
summaryByCategory(category: string): { pass: number; warn: number; fail: number; total: number } {
const results = this.resultsByCategory(category);
return {
pass: results.filter((r) => r.severity === 'pass').length,
warn: results.filter((r) => r.severity === 'warn').length,
fail: results.filter((r) => r.severity === 'fail').length,
total: results.length,
};
}
/** Set category filter. */
setCategoryFilter(category: DoctorCategory | null): void {
this.categoryFilterSignal.set(category);

View File

@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { IntegrationService } from './integration.service';
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import {
Integration,
IntegrationType,
@@ -18,7 +19,7 @@ import {
*/
@Component({
selector: 'app-integration-list',
imports: [CommonModule, RouterModule, FormsModule],
imports: [CommonModule, RouterModule, FormsModule, DoctorChecksInlineComponent],
template: `
<div class="integration-list">
<header class="list-header">
@@ -44,6 +45,8 @@ import {
/>
</section>
<st-doctor-checks-inline category="integration" heading="Integration Health Checks" />
@if (loading) {
<div class="loading">Loading integrations...</div>
} @else if (integrations.length === 0) {

View File

@@ -0,0 +1,136 @@
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
PlatformHealthSummary,
SERVICE_STATE_COLORS,
SERVICE_STATE_TEXT_COLORS,
formatLatency,
formatErrorRate,
} from '../../../core/api/platform-health.models';
@Component({
selector: 'app-kpi-strip',
standalone: true,
imports: [CommonModule],
template: `
<section class="kpi-strip">
<div class="kpi-card">
<div class="kpi-label-row">
<span class="kpi-label">Services</span>
<span class="kpi-dot" [class]="SERVICE_STATE_COLORS[summary.overallState]"></span>
</div>
<p class="kpi-value">
@if (summary.totalServices != null) {
{{ summary.healthyCount ?? 0 }}/{{ summary.totalServices }}
} @else { — }
</p>
<p class="kpi-sub">Healthy</p>
</div>
<div class="kpi-card">
<span class="kpi-label">Avg Latency</span>
<p class="kpi-value">{{ formatLatency(summary.averageLatencyMs) }}</p>
<p class="kpi-sub">P95 across services</p>
</div>
<div class="kpi-card">
<span class="kpi-label">Error Rate</span>
<p class="kpi-value" [class]="getErrorRateColor(summary.averageErrorRate)">
{{ formatErrorRate(summary.averageErrorRate) }}
</p>
<p class="kpi-sub">Platform-wide</p>
</div>
<div class="kpi-card">
<span class="kpi-label">Incidents</span>
<p class="kpi-value" [class]="summary.activeIncidents > 0 ? 'text-error' : 'text-success'">
{{ summary.activeIncidents }}
</p>
<p class="kpi-sub">Active</p>
</div>
<div class="kpi-card">
<span class="kpi-label">Status</span>
<div class="kpi-status-row">
<span class="kpi-dot-lg" [class]="SERVICE_STATE_COLORS[summary.overallState]"></span>
<p class="kpi-value" [class]="SERVICE_STATE_TEXT_COLORS[summary.overallState]">
{{ summary.overallState | titlecase }}
</p>
</div>
</div>
</section>
`,
styles: [`
.kpi-strip {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 1rem;
}
.kpi-card {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1rem;
}
.kpi-label-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.kpi-label {
font-size: .875rem;
color: var(--color-text-secondary);
}
.kpi-value {
font-size: 1.5rem;
font-weight: var(--font-weight-bold);
color: var(--color-text-heading);
margin: 0;
}
.kpi-sub {
font-size: .75rem;
color: var(--color-text-muted);
margin: 0;
}
.kpi-dot {
width: .75rem;
height: .75rem;
border-radius: var(--radius-full);
}
.kpi-dot-lg {
width: 1rem;
height: 1rem;
border-radius: var(--radius-full);
}
.kpi-status-row {
display: flex;
align-items: center;
gap: .5rem;
margin-top: .25rem;
}
.text-error { color: var(--color-status-error); }
.text-success { color: var(--color-status-success); }
@media (max-width: 1024px) {
.kpi-strip { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 640px) {
.kpi-strip { grid-template-columns: repeat(2, 1fr); }
}
`],
})
export class KpiStripComponent {
@Input({ required: true }) summary!: PlatformHealthSummary;
readonly SERVICE_STATE_COLORS = SERVICE_STATE_COLORS;
readonly SERVICE_STATE_TEXT_COLORS = SERVICE_STATE_TEXT_COLORS;
readonly formatLatency = formatLatency;
readonly formatErrorRate = formatErrorRate;
getErrorRateColor(rate: number | null | undefined): string {
if (rate == null) return 'text-success';
if (rate >= 5) return 'text-error';
if (rate >= 1) return 'text-warning';
return 'text-success';
}
}

View File

@@ -0,0 +1,252 @@
import { Component, computed, Input, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { FormsModule } from '@angular/forms';
import {
ServiceHealth,
SERVICE_STATE_COLORS,
SERVICE_STATE_BG_LIGHT,
formatUptime,
formatLatency,
formatErrorRate,
} from '../../../core/api/platform-health.models';
@Component({
selector: 'app-service-health-grid',
standalone: true,
imports: [CommonModule, RouterModule, FormsModule],
template: `
<section class="service-grid-container" [class.service-grid-container--compact]="compact">
<div class="service-grid-header">
<h2>Service Health</h2>
<select [(ngModel)]="groupBy" class="group-select">
<option value="state">Group by State</option>
<option value="none">No Grouping</option>
</select>
</div>
<div class="service-grid-body">
@if ((services ?? []).length === 0) {
<p class="empty">No services available in current snapshot</p>
} @else if (groupBy() === 'state') {
@if (unhealthy().length > 0) {
<div class="state-group">
<h3 class="state-label state-label--unhealthy">Unhealthy ({{ unhealthy().length }})</h3>
<div class="cards" [class.cards--compact]="compact">
@for (svc of unhealthy(); track svc.name) {
<a [routerLink]="['/platform/ops/health-slo/services', svc.name]"
class="svc-card" [class]="getStateBg(svc.state)">
<div class="svc-card__head">
<span class="svc-card__name">{{ svc.displayName }}</span>
<span class="svc-card__dot" [class]="getStateColor(svc.state)"></span>
</div>
<div class="svc-card__stats">
<div><span class="stat-label">Uptime:</span> {{ formatUptime(svc.uptime) }}</div>
<div><span class="stat-label">P95:</span> {{ formatLatency(svc.latencyP95Ms) }}</div>
<div><span class="stat-label">Errors:</span> {{ formatErrorRate(svc.errorRate) }}</div>
<div><span class="stat-label">Checks:</span> {{ passingChecks(svc) }}/{{ (svc.checks ?? []).length }}</div>
</div>
</a>
}
</div>
</div>
}
@if (degraded().length > 0) {
<div class="state-group">
<h3 class="state-label state-label--degraded">Degraded ({{ degraded().length }})</h3>
<div class="cards" [class.cards--compact]="compact">
@for (svc of degraded(); track svc.name) {
<a [routerLink]="['/platform/ops/health-slo/services', svc.name]"
class="svc-card" [class]="getStateBg(svc.state)">
<div class="svc-card__head">
<span class="svc-card__name">{{ svc.displayName }}</span>
<span class="svc-card__dot" [class]="getStateColor(svc.state)"></span>
</div>
<div class="svc-card__stats">
<div><span class="stat-label">Uptime:</span> {{ formatUptime(svc.uptime) }}</div>
<div><span class="stat-label">P95:</span> {{ formatLatency(svc.latencyP95Ms) }}</div>
<div><span class="stat-label">Errors:</span> {{ formatErrorRate(svc.errorRate) }}</div>
<div><span class="stat-label">Checks:</span> {{ passingChecks(svc) }}/{{ (svc.checks ?? []).length }}</div>
</div>
</a>
}
</div>
</div>
}
@if (healthy().length > 0) {
<div class="state-group">
<h3 class="state-label state-label--healthy">Healthy ({{ healthy().length }})</h3>
<div class="cards" [class.cards--compact]="compact">
@for (svc of healthy(); track svc.name) {
<a [routerLink]="['/platform/ops/health-slo/services', svc.name]"
class="svc-card" [class]="getStateBg(svc.state)">
<div class="svc-card__head">
<span class="svc-card__name">{{ svc.displayName }}</span>
<span class="svc-card__dot" [class]="getStateColor(svc.state)"></span>
</div>
<div class="svc-card__stats">
<div><span class="stat-label">Uptime:</span> {{ formatUptime(svc.uptime) }}</div>
<div><span class="stat-label">P95:</span> {{ formatLatency(svc.latencyP95Ms) }}</div>
<div><span class="stat-label">Errors:</span> {{ formatErrorRate(svc.errorRate) }}</div>
<div><span class="stat-label">Checks:</span> {{ passingChecks(svc) }}/{{ (svc.checks ?? []).length }}</div>
</div>
</a>
}
</div>
</div>
}
} @else {
<div class="cards" [class.cards--compact]="compact">
@for (svc of services ?? []; track svc.name) {
<a [routerLink]="['/platform/ops/health-slo/services', svc.name]"
class="svc-card" [class]="getStateBg(svc.state)">
<div class="svc-card__head">
<span class="svc-card__name">{{ svc.displayName }}</span>
<span class="svc-card__dot" [class]="getStateColor(svc.state)"></span>
</div>
<div class="svc-card__stats">
<div><span class="stat-label">Uptime:</span> {{ formatUptime(svc.uptime) }}</div>
<div><span class="stat-label">P95:</span> {{ formatLatency(svc.latencyP95Ms) }}</div>
<div><span class="stat-label">Errors:</span> {{ formatErrorRate(svc.errorRate) }}</div>
<div><span class="stat-label">Checks:</span> {{ passingChecks(svc) }}/{{ (svc.checks ?? []).length }}</div>
</div>
</a>
}
</div>
}
</div>
</section>
`,
styles: [`
.service-grid-container {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
}
.service-grid-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--color-border-primary);
}
.service-grid-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-heading);
}
.group-select {
padding: .25rem .75rem;
font-size: .875rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-primary);
font-family: inherit;
}
.service-grid-body { padding: 1rem; }
.state-group { margin-bottom: 1rem; }
.state-group:last-child { margin-bottom: 0; }
.state-label {
font-size: .875rem;
font-weight: var(--font-weight-medium);
margin: 0 0 .5rem;
}
.state-label--unhealthy { color: var(--color-status-error); }
.state-label--degraded { color: var(--color-status-warning); }
.state-label--healthy { color: var(--color-status-success); }
.cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: .75rem;
}
.cards--compact {
grid-template-columns: repeat(2, 1fr);
}
.svc-card {
display: block;
padding: .75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
text-decoration: none;
color: inherit;
transition: box-shadow .15s;
}
.svc-card:hover { box-shadow: 0 4px 6px -1px rgba(0,0,0,.08); }
.svc-card__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: .5rem;
}
.svc-card__name {
font-weight: var(--font-weight-medium);
color: var(--color-text-heading);
}
.svc-card__dot {
width: .75rem;
height: .75rem;
border-radius: var(--radius-full);
}
.svc-card__stats {
display: grid;
grid-template-columns: 1fr 1fr;
gap: .25rem;
font-size: .75rem;
}
.stat-label { color: var(--color-text-muted); }
.empty {
text-align: center;
padding: 1.5rem;
font-size: .875rem;
color: var(--color-text-muted);
}
/* State backgrounds */
:host ::ng-deep .state-bg--healthy { background: rgba(34,197,94,.06); border-color: rgba(34,197,94,.2); }
:host ::ng-deep .state-bg--degraded { background: rgba(234,179,8,.06); border-color: rgba(234,179,8,.2); }
:host ::ng-deep .state-bg--unhealthy { background: rgba(239,68,68,.06); border-color: rgba(239,68,68,.2); }
:host ::ng-deep .state-bg--unknown { background: var(--color-surface-secondary); }
.service-grid-container--compact .service-grid-header { padding: .65rem; }
.service-grid-container--compact .service-grid-body { padding: .65rem; }
@media (max-width: 1024px) {
.cards { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 640px) {
.cards { grid-template-columns: 1fr; }
}
`],
})
export class ServiceHealthGridComponent {
@Input() services: ServiceHealth[] | null = [];
@Input() compact = false;
readonly groupBy = signal<'state' | 'none'>('state');
readonly formatUptime = formatUptime;
readonly formatLatency = formatLatency;
readonly formatErrorRate = formatErrorRate;
readonly healthy = computed(() =>
(this.services ?? []).filter((s) => s.state === 'healthy')
);
readonly degraded = computed(() =>
(this.services ?? []).filter((s) => s.state === 'degraded')
);
readonly unhealthy = computed(() =>
(this.services ?? []).filter((s) => s.state === 'unhealthy' || s.state === 'unknown')
);
passingChecks(svc: ServiceHealth): number {
return (svc.checks ?? []).filter((c) => c.status === 'pass').length;
}
getStateBg(state: string): string {
return SERVICE_STATE_BG_LIGHT[state as keyof typeof SERVICE_STATE_BG_LIGHT] ?? '';
}
getStateColor(state: string): string {
return SERVICE_STATE_COLORS[state as keyof typeof SERVICE_STATE_COLORS] ?? '';
}
}

View File

@@ -1,5 +1,6 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { DoctorChecksInlineComponent } from '../../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
interface WorkflowCard {
id: string;
@@ -12,7 +13,7 @@ interface WorkflowCard {
@Component({
selector: 'app-platform-ops-overview-page',
standalone: true,
imports: [RouterLink],
imports: [RouterLink, DoctorChecksInlineComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="ops-overview">
@@ -64,6 +65,8 @@ interface WorkflowCard {
</div>
</section>
<st-doctor-checks-inline category="core" heading="Core Platform Checks" />
<section class="ops-overview__secondary">
<h2>Secondary Operator Tools</h2>
<div class="ops-overview__links">

View File

@@ -9,6 +9,7 @@ import { PlatformContextStore } from '../../../../core/context/platform-context.
import { ReleaseManagementStore } from '../release.store';
import { getEvidencePostureLabel, getGateStatusLabel, getRiskTierLabel } from '../../../../core/api/release-management.models';
import type { ManagedRelease } from '../../../../core/api/release-management.models';
import { DegradedStateBannerComponent } from '../../../../shared/components/degraded-state-banner/degraded-state-banner.component';
interface PlatformListResponse<T> { items: T[]; total: number; limit: number; offset: number; }
interface PlatformItemResponse<T> { item: T; }
@@ -126,10 +127,14 @@ interface ReleaseRunAuditProjectionDto {
}>;
}
interface ReloadOptions {
background?: boolean;
}
@Component({
selector: 'app-release-detail',
standalone: true,
imports: [RouterLink, FormsModule],
imports: [RouterLink, FormsModule, DegradedStateBannerComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="workbench">
@@ -154,6 +159,32 @@ interface ReleaseRunAuditProjectionDto {
</div>
</header>
@if (mode() === 'run') {
<div class="live-sync">
<span class="live-sync__status">{{ liveSyncStatus() }}</span>
<span class="live-sync__time">
Last sync: {{ lastSyncAt() ? fmt(lastSyncAt()!) : 'n/a' }}
</span>
<button type="button" (click)="refreshNow()" [disabled]="refreshing()">
{{ refreshing() ? 'Refreshing...' : 'Refresh now' }}
</button>
</div>
}
@if (runSyncImpact(); as impact) {
<app-degraded-state-banner
[impact]="impact.impact"
[title]="impact.title"
[message]="impact.message"
[correlationId]="impact.correlationId"
[lastKnownGoodAt]="impact.lastKnownGoodAt"
[readOnly]="impact.readOnly"
[retryable]="true"
retryLabel="Retry run sync"
(retryRequested)="refreshNow()"
/>
}
<nav class="tabs">
@for (tab of tabs(); track tab.id) {
<a [routerLink]="[detailBasePath(), releaseId(), tab.id]" [class.active]="activeTab()===tab.id">{{ tab.label }}</a>
@@ -319,6 +350,7 @@ interface ReleaseRunAuditProjectionDto {
tr:last-child td{border-bottom:none}tr.sel{background:color-mix(in srgb,var(--color-brand-primary) 10%,transparent)}
button{border:1px solid var(--color-border-primary);border-radius:var(--radius-sm);background:var(--color-surface-primary);padding:.24rem .46rem;font-size:.72rem;cursor:pointer}
button.primary{border-color:var(--color-brand-primary);background:var(--color-brand-primary);color:var(--color-text-heading)} .banner{padding:.6rem;font-size:.8rem;color:var(--color-text-secondary)} .banner.error{color:var(--color-status-error-text)}
.live-sync{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary);padding:.42rem .55rem}.live-sync__status{font-size:.68rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--color-text-secondary)}.live-sync__time{font-size:.72rem;color:var(--color-text-secondary)}
@media (max-width: 980px){.split{grid-template-columns:1fr}}
`],
})
@@ -351,6 +383,10 @@ export class ReleaseDetailComponent {
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly refreshing = signal(false);
readonly lastSyncAt = signal<string | null>(null);
readonly syncError = signal<string | null>(null);
readonly syncFailureCount = signal(0);
readonly activeTab = signal<string>('timeline');
readonly releaseId = signal('');
@@ -474,6 +510,41 @@ export class ReleaseDetailComponent {
readonly getEvidencePostureLabel = getEvidencePostureLabel;
readonly getRiskTierLabel = getRiskTierLabel;
readonly modeLabel = computed(() => (this.mode() === 'version' ? 'Release Version' : 'Release Run'));
readonly runIsTerminal = computed(() => {
const run = this.runDetail();
if (!run) {
return false;
}
return this.isTerminalRun(run.status, run.outcome);
});
readonly liveSyncStatus = computed(() => {
if (this.refreshing()) {
return 'SYNCING';
}
if (this.syncFailureCount() > 0) {
return 'DEGRADED';
}
return 'LIVE';
});
readonly runSyncImpact = computed(() => {
if (this.mode() !== 'run' || this.syncFailureCount() === 0) {
return null;
}
const run = this.runDetail();
const gateVerdict = this.runGateDecision()?.verdict.toLowerCase() ?? '';
const blocking = Boolean(run?.blockedByDataIntegrity) || gateVerdict === 'block';
const impact = blocking ? 'BLOCKING' : 'DEGRADED';
return {
impact,
title: 'Run detail live sync degraded',
message: this.syncError() ?? 'Live refresh failed. Showing last-known-good run projection.',
correlationId: run?.correlationKey ?? null,
lastKnownGoodAt: this.lastSyncAt() ?? run?.updatedAt ?? null,
readOnly: true,
};
});
constructor() {
this.context.initialize();
@@ -489,13 +560,35 @@ export class ReleaseDetailComponent {
this.activeTab.set(this.normalizeTab(params.get('tab')));
this.selectedTimelineId.set(null);
this.selectedTargets.set(new Set<string>());
this.lastSyncAt.set(null);
this.syncError.set(null);
this.syncFailureCount.set(0);
if (id) this.reload(id);
});
effect(() => {
this.context.contextVersion();
const id = this.releaseId();
if (id) this.reload(id);
if (id) this.reload(id, { background: true });
});
effect((onCleanup) => {
if (this.mode() !== 'run' || this.runIsTerminal()) {
return;
}
const id = this.releaseId();
if (!id) {
return;
}
const handle = globalThis.setInterval(() => {
this.reload(id, { background: true });
}, 15000);
onCleanup(() => {
globalThis.clearInterval(handle);
});
});
}
@@ -507,6 +600,15 @@ export class ReleaseDetailComponent {
void this.router.navigate([this.detailBasePath(), id, normalized]);
}
refreshNow(): void {
const id = this.releaseId();
if (!id) {
return;
}
this.syncError.set(null);
this.reload(id, { background: true });
}
canPromote(): boolean { return this.preflightChecks().every((c) => c.status !== 'fail'); }
toggleTarget(targetId: string, event: Event): void {
@@ -521,7 +623,7 @@ export class ReleaseDetailComponent {
setBaseline(id: string): void { this.baselineId.set(id); this.loadDiff(); }
openFinding(): void { void this.router.navigate(['/security/triage'], { queryParams: { releaseId: this.releaseContextId() } }); }
createException(): void { void this.router.navigate(['/security/advisories-vex'], { queryParams: { releaseId: this.releaseContextId(), tab: 'exceptions' } }); }
createException(): void { void this.router.navigate(['/security/disposition'], { queryParams: { releaseId: this.releaseContextId(), tab: 'exceptions' } }); }
replayRun(): void { void this.router.navigate(['/evidence/verification/replay'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); }
exportRunEvidence(): void { void this.router.navigate(['/evidence/capsules'], { queryParams: { releaseId: this.releaseContextId(), runId: this.releaseId() } }); }
openAgentLogs(target: string): void { void this.router.navigate(['/platform/ops/jobs-queues'], { queryParams: { releaseId: this.releaseContextId(), target } }); }
@@ -539,19 +641,28 @@ export class ReleaseDetailComponent {
return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
private reload(entityId: string): void {
this.loading.set(true);
this.error.set(null);
private reload(entityId: string, options: ReloadOptions = {}): void {
const background = options.background ?? false;
if (background) {
if (this.loading() || this.refreshing()) {
return;
}
this.refreshing.set(true);
} else {
this.loading.set(true);
this.error.set(null);
}
if (this.mode() === 'run') {
this.loadRunWorkbench(entityId);
this.loadRunWorkbench(entityId, background);
return;
}
this.loadVersionWorkbench(entityId);
this.loadVersionWorkbench(entityId, background);
}
private loadVersionWorkbench(releaseId: string): void {
private loadVersionWorkbench(releaseId: string, background = false): void {
this.store.selectRelease(releaseId);
this.runDetail.set(null);
this.runTimeline.set(null);
@@ -586,16 +697,18 @@ export class ReleaseDetailComponent {
this.baselines.set(baseline);
if (!this.baselineId() && baseline.length > 0) this.baselineId.set(baseline[0].releaseId);
this.loadDiff();
this.loading.set(false);
this.completeVersionLoad(background);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load release workbench.');
this.loading.set(false);
this.completeVersionLoad(
background,
err instanceof Error ? err.message : 'Failed to load release workbench.',
);
},
});
}
private loadRunWorkbench(runId: string): void {
private loadRunWorkbench(runId: string, background = false): void {
const runBase = `/api/v2/releases/runs/${runId}`;
const runDetail$ = this.http.get<PlatformItemResponse<ReleaseRunDetailProjectionDto>>(runBase).pipe(map((r) => r.item), catchError(() => of(null)));
const timeline$ = this.http.get<PlatformItemResponse<ReleaseRunTimelineProjectionDto>>(`${runBase}/timeline`).pipe(map((r) => r.item), catchError(() => of(null)));
@@ -622,8 +735,7 @@ export class ReleaseDetailComponent {
}).pipe(take(1)).subscribe({
next: ({ runDetail, timeline, gate, approvals, deployments, securityInputs, evidence, rollback, replay, audit }) => {
if (!runDetail) {
this.error.set('Run detail is unavailable for this route.');
this.loading.set(false);
this.completeRunLoad(background, null, 'Run detail is unavailable for this route.');
return;
}
@@ -727,11 +839,14 @@ export class ReleaseDetailComponent {
environment: runDetail.targetEnvironment ?? 'global',
} satisfies SecuritySbomDiffRow)));
this.loading.set(false);
this.completeRunLoad(background, runDetail.updatedAt);
},
error: (err: unknown) => {
this.error.set(err instanceof Error ? err.message : 'Failed to load release run workbench.');
this.loading.set(false);
this.completeRunLoad(
background,
null,
err instanceof Error ? err.message : 'Failed to load release run workbench.',
);
},
});
}
@@ -968,5 +1083,93 @@ export class ReleaseDetailComponent {
return 'draft';
}
private completeVersionLoad(background: boolean, errorMessage?: string): void {
if (background) {
this.refreshing.set(false);
if (errorMessage) {
this.syncError.set(errorMessage);
this.syncFailureCount.update((count) => count + 1);
} else {
this.syncError.set(null);
this.syncFailureCount.set(0);
}
return;
}
if (errorMessage) {
this.error.set(errorMessage);
}
this.loading.set(false);
}
private completeRunLoad(
background: boolean,
syncedAt: string | null,
errorMessage?: string,
): void {
if (background) {
this.refreshing.set(false);
if (errorMessage) {
this.syncError.set(errorMessage);
this.syncFailureCount.update((count) => count + 1);
} else {
this.syncError.set(null);
this.syncFailureCount.set(0);
this.lastSyncAt.set(syncedAt ?? new Date().toISOString());
}
return;
}
if (errorMessage) {
this.error.set(errorMessage);
this.syncError.set(errorMessage);
this.syncFailureCount.update((count) => count + 1);
} else {
this.syncError.set(null);
this.syncFailureCount.set(0);
this.lastSyncAt.set(syncedAt ?? new Date().toISOString());
}
this.loading.set(false);
this.refreshing.set(false);
}
private isTerminalRun(status: string, outcome: string): boolean {
const normalizedStatus = status.toLowerCase();
const normalizedOutcome = outcome.toLowerCase();
const terminalStatuses = new Set([
'completed',
'succeeded',
'failed',
'rejected',
'blocked',
'cancelled',
'canceled',
'rolled_back',
'rollback_complete',
]);
const terminalOutcomes = new Set([
'deployed',
'success',
'failed',
'error',
'blocked',
'rejected',
'cancelled',
'canceled',
'rolled_back',
]);
if (terminalStatuses.has(normalizedStatus) || terminalOutcomes.has(normalizedOutcome)) {
return true;
}
if (normalizedStatus.includes('rollback') || normalizedOutcome.includes('rollback')) {
return true;
}
return false;
}
}

View File

@@ -1,6 +1,7 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import { forkJoin, of } from 'rxjs';
import { catchError, map, take } from 'rxjs/operators';
@@ -57,13 +58,13 @@ interface PlatformListResponse<T> {
@Component({
selector: 'app-security-risk-overview',
standalone: true,
imports: [RouterLink],
imports: [RouterLink, DoctorChecksInlineComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="overview">
<header class="page-header">
<div>
<h1>Security / Overview</h1>
<h1>Security / Posture</h1>
<p>Blocker-first posture for release decisions, freshness confidence, and disposition risk.</p>
</div>
<div class="scope">
@@ -117,6 +118,8 @@ interface PlatformListResponse<T> {
</article>
</section>
<st-doctor-checks-inline category="security" heading="Security Health Checks" />
<div class="grid">
<article class="panel">
<div class="panel-header">
@@ -127,7 +130,7 @@ interface PlatformListResponse<T> {
@for (blocker of topBlockers(); track blocker.findingId) {
<li>
<a [routerLink]="['/security/triage', blocker.findingId]">{{ blocker.cveId || blocker.findingId }}</a>
<span>{{ blocker.releaseName }} <20> {{ blocker.region || 'global' }}/{{ blocker.environment }}</span>
<span>{{ blocker.releaseName }} <20> {{ blocker.region || 'global' }}/{{ blocker.environment }}</span>
</li>
} @empty {
<li class="empty">No blockers in the selected scope.</li>
@@ -138,7 +141,7 @@ interface PlatformListResponse<T> {
<article class="panel">
<div class="panel-header">
<h3>Expiring Waivers</h3>
<a [routerLink]="['/security/advisories-vex']" [queryParams]="{ tab: 'vex-library' }">Disposition</a>
<a [routerLink]="['/security/disposition']" [queryParams]="{ tab: 'vex-library' }">Disposition</a>
</div>
<ul>
@for (waiver of expiringWaivers(); track waiver.findingId) {
@@ -158,14 +161,14 @@ interface PlatformListResponse<T> {
<a routerLink="/platform/integrations/feeds">Configure sources</a>
</div>
<p class="meta">
Conflicts: <strong>{{ conflictCount() }}</strong> <20>
Conflicts: <strong>{{ conflictCount() }}</strong> <20>
Unverified statements: <strong>{{ unresolvedVexCount() }}</strong>
</p>
<ul>
@for (provider of providerHealthRows(); track provider.sourceId) {
<li>
<span>{{ provider.sourceName }}</span>
<span>{{ provider.status }} <20> {{ provider.freshness }}</span>
<span>{{ provider.status }} <20> {{ provider.freshness }}</span>
</li>
} @empty {
<li class="empty">No provider health rows for current scope.</li>
@@ -176,10 +179,10 @@ interface PlatformListResponse<T> {
<article class="panel">
<div class="panel-header">
<h3>Supply-Chain Coverage</h3>
<a routerLink="/security/supply-chain-data/coverage">Coverage & Unknowns</a>
<a routerLink="/security/sbom/coverage">Coverage & Unknowns</a>
</div>
<p class="meta">
Reachability unknowns: <strong>{{ unknownReachabilityCount() }}</strong> <20>
Reachability unknowns: <strong>{{ unknownReachabilityCount() }}</strong> <20>
Stale SBOM rows: <strong>{{ sbomStaleCount() }}</strong>
</p>
<ul>
@@ -187,7 +190,7 @@ interface PlatformListResponse<T> {
<a [routerLink]="['/security/triage']" [queryParams]="{ reachability: 'unreachable' }">Inspect unknown/unreachable findings</a>
</li>
<li>
<a routerLink="/security/supply-chain-data/reachability">Open reachability coverage board</a>
<a routerLink="/security/reachability">Open reachability coverage board</a>
</li>
</ul>
</article>
@@ -448,4 +451,4 @@ export class SecurityRiskOverviewComponent {
if (environment) params = params.set('environment', environment);
return params;
}
}
}

View File

@@ -34,6 +34,7 @@ import {
SetupSession,
ExecuteStepRequest,
} from '../models/setup-wizard.models';
import { DoctorRecheckService } from '../../doctor/services/doctor-recheck.service';
@Component({
selector: 'app-setup-wizard',
@@ -1437,6 +1438,7 @@ export class SetupWizardComponent implements OnInit, OnDestroy {
private readonly api = inject(SetupWizardApiService);
private readonly router = inject(Router);
private readonly route = inject(ActivatedRoute);
private readonly doctorRecheck = inject(DoctorRecheckService);
readonly isReconfigureMode = signal(false);
readonly showAllSteps = signal(false);
@@ -2020,7 +2022,16 @@ export class SetupWizardComponent implements OnInit, OnDestroy {
private initializeWizard(): void {
this.state.loading.set(true);
const resumeStep = this.route.snapshot.queryParamMap.get('resume');
// Support deep-link from Doctor "Fix in Setup" button
const stepParam = this.route.snapshot.queryParamMap.get('step');
const modeParam = this.route.snapshot.queryParamMap.get('mode');
if (modeParam === 'reconfigure') {
this.isReconfigureMode.set(true);
}
const resumeStep = stepParam
?? this.route.snapshot.queryParamMap.get('resume');
const resumeStepId = resumeStep && this.validStepIds.has(resumeStep)
? (resumeStep as SetupStepId)
: null;
@@ -2111,6 +2122,9 @@ export class SetupWizardComponent implements OnInit, OnDestroy {
next: (result) => {
if (result.status === 'completed') {
this.state.markCurrentStepCompleted(result.appliedConfig);
if (this.isReconfigureMode()) {
this.doctorRecheck.offerRecheck(step.id, step.name);
}
} else if (result.status === 'failed') {
this.state.markCurrentStepFailed(result.error ?? 'Step execution failed');
}

View File

@@ -0,0 +1,366 @@
import { Component, inject, signal, computed, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { Subject, interval, takeUntil, startWith, switchMap, forkJoin } from 'rxjs';
import { PlatformHealthClient } from '../../core/api/platform-health.client';
import { PlatformHealthSummary, Incident } from '../../core/api/platform-health.models';
import { DoctorStore } from '../doctor/services/doctor.store';
import { KpiStripComponent } from '../platform-health/components/kpi-strip.component';
import { ServiceHealthGridComponent } from '../platform-health/components/service-health-grid.component';
import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import { SummaryStripComponent } from '../doctor/components/summary-strip/summary-strip.component';
import { CheckResultComponent } from '../doctor/components/check-result/check-result.component';
import {
INCIDENT_SEVERITY_COLORS,
} from '../../core/api/platform-health.models';
@Component({
selector: 'app-system-health-page',
standalone: true,
imports: [
CommonModule,
RouterModule,
KpiStripComponent,
ServiceHealthGridComponent,
DoctorChecksInlineComponent,
SummaryStripComponent,
CheckResultComponent,
],
template: `
<div class="system-health">
<header class="system-health__header">
<div>
<h1>System Health</h1>
<p class="subtitle">Unified view of platform services and diagnostics</p>
</div>
<div class="system-health__actions">
@if (autoRefreshActive()) {
<span class="auto-refresh-badge">Auto-refresh: 10s</span>
}
<button class="btn btn-secondary" (click)="refresh()" [disabled]="refreshing()">
<span [class.spin]="refreshing()">&#x21bb;</span> Refresh
</button>
<button class="btn btn-primary" (click)="runQuickDiagnostics()"
[disabled]="doctorStore.isRunning()">
Quick Diagnostics
</button>
</div>
</header>
<!-- Tabs -->
<nav class="system-health__tabs" role="tablist">
@for (tab of tabs; track tab.id) {
<button class="tab" [class.tab--active]="activeTab() === tab.id"
role="tab" [attr.aria-selected]="activeTab() === tab.id"
(click)="activeTab.set(tab.id)">
{{ tab.label }}
</button>
}
</nav>
@if (error()) {
<div class="error-banner">{{ error() }}</div>
}
<!-- Tab Content -->
@switch (activeTab()) {
@case ('overview') {
<div class="tab-content">
@if (summary()) {
<app-kpi-strip [summary]="summary()!" />
}
<div class="overview-grid">
<div class="overview-grid__services">
<app-service-health-grid [services]="summary()?.services ?? []" [compact]="true" />
</div>
<div class="overview-grid__doctor">
<h3>Top Diagnostic Issues</h3>
@if (doctorStore.failedResults().length > 0) {
@for (result of doctorStore.failedResults().slice(0, 5); track result.checkId) {
<st-check-result [result]="result" />
}
} @else if (doctorStore.hasReport()) {
<p class="no-issues">All checks passing.</p>
} @else {
<st-doctor-checks-inline category="core" heading="Core Platform" />
}
</div>
</div>
</div>
}
@case ('services') {
<div class="tab-content">
<app-service-health-grid [services]="summary()?.services ?? []" />
</div>
}
@case ('diagnostics') {
<div class="tab-content">
@if (doctorStore.summary(); as docSummary) {
<st-summary-strip
[summary]="docSummary"
[duration]="doctorStore.report()?.durationMs"
[overallSeverity]="doctorStore.report()?.overallSeverity" />
}
<div class="diagnostics-results">
@for (result of doctorStore.filteredResults(); track result.checkId) {
<st-check-result [result]="result" />
}
@if (doctorStore.filteredResults().length === 0 && !doctorStore.hasReport()) {
<p class="empty-state">No diagnostics run yet. Click "Quick Diagnostics" to start.</p>
}
</div>
</div>
}
@case ('incidents') {
<div class="tab-content">
@if (incidents().length > 0) {
<div class="incidents-timeline">
@for (incident of incidents(); track incident.id) {
<div class="incident-row">
<div class="incident-time">
{{ incident.startedAt | date:'shortTime' }}
</div>
<div class="incident-dot"
[class]="incident.state === 'active' ? 'incident-dot--active' : 'incident-dot--resolved'">
</div>
<div class="incident-content">
<div class="incident-head">
<span class="severity-badge"
[class]="INCIDENT_SEVERITY_COLORS[incident.severity]">
{{ incident.severity }}
</span>
<span class="incident-title">{{ incident.title }}</span>
@if (incident.state === 'resolved') {
<span class="resolved-badge">(Resolved)</span>
}
</div>
<p class="incident-desc">{{ incident.description }}</p>
<p class="incident-affected">
Affected: {{ incident.affectedServices.join(', ') }}
</p>
</div>
</div>
}
</div>
} @else {
<p class="empty-state">No incidents in the last 24 hours.</p>
}
</div>
}
}
</div>
`,
styles: [`
.system-health { display: grid; gap: .75rem; padding: 1.5rem; }
.system-health__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.system-health__header h1 { margin: 0; font-size: 1.5rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); }
.subtitle { margin: .2rem 0 0; font-size: .82rem; color: var(--color-text-secondary); }
.system-health__actions { display: flex; align-items: center; gap: .5rem; }
.auto-refresh-badge {
font-size: .72rem;
color: var(--color-text-muted);
padding: .2rem .5rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
}
.system-health__tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--color-border-primary);
}
.tab {
padding: .5rem 1rem;
font-size: .82rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-family: inherit;
}
.tab:hover { color: var(--color-text-primary); }
.tab--active {
color: var(--color-brand-primary);
border-bottom-color: var(--color-brand-primary);
}
.tab-content { display: grid; gap: .75rem; }
.error-banner {
padding: .65rem;
font-size: .8rem;
color: var(--color-status-error);
border: 1px solid rgba(239,68,68,.3);
border-radius: var(--radius-md);
background: rgba(239,68,68,.06);
}
.overview-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 1rem;
}
.overview-grid__doctor {
display: grid;
gap: .5rem;
align-content: start;
}
.overview-grid__doctor h3 {
margin: 0;
font-size: .88rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-heading);
}
.no-issues {
font-size: .8rem;
color: var(--color-status-success);
margin: 0;
}
.diagnostics-results { display: grid; gap: .35rem; }
.empty-state {
text-align: center;
padding: 2rem;
font-size: .85rem;
color: var(--color-text-muted);
}
.incidents-timeline { display: grid; gap: .75rem; }
.incident-row { display: flex; align-items: flex-start; gap: 1rem; }
.incident-time { font-size: .75rem; color: var(--color-text-muted); width: 4rem; padding-top: .25rem; }
.incident-dot {
width: .75rem;
height: .75rem;
border-radius: var(--radius-full);
margin-top: .375rem;
flex-shrink: 0;
}
.incident-dot--active { background: var(--color-status-error); }
.incident-dot--resolved { background: var(--color-text-muted); }
.incident-content { flex: 1; }
.incident-head { display: flex; align-items: center; gap: .5rem; }
.severity-badge {
padding: .1rem .4rem;
font-size: .7rem;
font-weight: var(--font-weight-medium);
border-radius: var(--radius-sm);
}
.incident-title { font-weight: var(--font-weight-medium); color: var(--color-text-heading); }
.resolved-badge { font-size: .75rem; color: var(--color-status-success); }
.incident-desc { margin: .25rem 0 0; font-size: .82rem; color: var(--color-text-secondary); }
.incident-affected { margin: .25rem 0 0; font-size: .75rem; color: var(--color-text-muted); }
.btn {
padding: .4rem .75rem;
font-size: .82rem;
border-radius: var(--radius-md);
cursor: pointer;
border: 1px solid var(--color-border-primary);
font-family: inherit;
display: inline-flex;
align-items: center;
gap: .35rem;
}
.btn-primary {
background: var(--color-brand-primary);
color: #fff;
border-color: var(--color-brand-primary);
}
.btn-secondary {
background: var(--color-surface-primary);
color: var(--color-text-primary);
}
.spin { display: inline-block; animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@media (max-width: 1024px) {
.overview-grid { grid-template-columns: 1fr; }
}
`],
})
export class SystemHealthPageComponent implements OnInit, OnDestroy {
private readonly healthClient = inject(PlatformHealthClient);
readonly doctorStore = inject(DoctorStore);
private readonly destroy$ = new Subject<void>();
readonly summary = signal<PlatformHealthSummary | null>(null);
readonly incidents = signal<Incident[]>([]);
readonly error = signal<string | null>(null);
readonly refreshing = signal(false);
readonly autoRefreshActive = signal(true);
readonly activeTab = signal<'overview' | 'services' | 'diagnostics' | 'incidents'>('overview');
readonly INCIDENT_SEVERITY_COLORS = INCIDENT_SEVERITY_COLORS;
readonly tabs = [
{ id: 'overview' as const, label: 'Overview' },
{ id: 'services' as const, label: 'Services' },
{ id: 'diagnostics' as const, label: 'Diagnostics' },
{ id: 'incidents' as const, label: 'Incidents' },
];
ngOnInit(): void {
interval(10000)
.pipe(
startWith(0),
takeUntil(this.destroy$),
switchMap(() => {
this.error.set(null);
return forkJoin({
summary: this.healthClient.getSummary(),
incidents: this.healthClient.getIncidents(24, true),
});
})
)
.subscribe({
next: ({ summary, incidents }) => {
this.summary.set(summary);
this.incidents.set(incidents.incidents ?? []);
this.error.set(null);
},
error: () => {
this.error.set('Unable to load platform health data. Try refreshing.');
},
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
refresh(): void {
this.refreshing.set(true);
this.error.set(null);
forkJoin({
summary: this.healthClient.getSummary(),
incidents: this.healthClient.getIncidents(24, true),
}).subscribe({
next: ({ summary, incidents }) => {
this.summary.set(summary);
this.incidents.set(incidents.incidents ?? []);
this.refreshing.set(false);
},
error: () => {
this.error.set('Unable to load platform health data. Try refreshing.');
this.refreshing.set(false);
},
});
}
runQuickDiagnostics(): void {
this.doctorStore.startRun({ mode: 'quick', includeRemediation: true });
}
}

View File

@@ -62,7 +62,7 @@ type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'secur
<div class="actions">
<a [routerLink]="['/topology/targets']" [queryParams]="{ environment: environmentId() }">Open Targets</a>
<a [routerLink]="['/topology/agents']" [queryParams]="{ environment: environmentId() }">Open Agents</a>
<a [routerLink]="['/releases/activity']" [queryParams]="{ environment: environmentId() }">Open Deployments</a>
<a [routerLink]="['/releases/runs']" [queryParams]="{ environment: environmentId() }">Open Runs</a>
<a [routerLink]="['/security/triage']" [queryParams]="{ environment: environmentId() }">Open Security Triage</a>
</div>
</article>
@@ -114,7 +114,7 @@ type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'secur
@case ('deployments') {
<article class="card">
<h2>Deployments</h2>
<h2>Runs</h2>
<table>
<thead>
<tr>
@@ -133,7 +133,7 @@ type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'secur
<td>{{ run.occurredAt }}</td>
</tr>
} @empty {
<tr><td colspan="4" class="muted">No deployment activity in this scope.</td></tr>
<tr><td colspan="4" class="muted">No run activity in this scope.</td></tr>
}
</tbody>
</table>
@@ -416,7 +416,7 @@ export class TopologyEnvironmentDetailPageComponent {
readonly tabs: Array<{ id: EnvironmentTab; label: string }> = [
{ id: 'overview', label: 'Overview' },
{ id: 'targets', label: 'Targets' },
{ id: 'deployments', label: 'Deployments' },
{ id: 'deployments', label: 'Runs' },
{ id: 'agents', label: 'Agents' },
{ id: 'security', label: 'Security' },
{ id: 'evidence', label: 'Evidence' },

View File

@@ -161,7 +161,7 @@ type RegionsView = 'region-first' | 'flat' | 'graph';
<a [routerLink]="['/topology/environments', selectedEnvironmentId(), 'posture']">Open Environment</a>
<a [routerLink]="['/topology/targets']" [queryParams]="{ environment: selectedEnvironmentId() }">Open Targets</a>
<a [routerLink]="['/topology/agents']" [queryParams]="{ environment: selectedEnvironmentId() }">Open Agents</a>
<a [routerLink]="['/releases/activity']" [queryParams]="{ environment: selectedEnvironmentId() }">Open Deployments</a>
<a [routerLink]="['/releases/runs']" [queryParams]="{ environment: selectedEnvironmentId() }">Open Runs</a>
</div>
</article>
}

View File

@@ -11,13 +11,14 @@ import {
import { Router, RouterLink, NavigationEnd } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AUTH_SERVICE, AuthService } from '../../core/auth';
import { AUTH_SERVICE, AuthService, StellaOpsScopes } from '../../core/auth';
import type { StellaOpsScope } from '../../core/auth';
import { APPROVAL_API } from '../../core/api/approval.client';
import type { ApprovalApi } from '../../core/api/approval.client';
import { SidebarNavGroupComponent } from './sidebar-nav-group.component';
import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
import { DoctorTrendService } from '../../core/doctor/doctor-trend.service';
/**
* Navigation structure for the shell.
@@ -29,6 +30,7 @@ export interface NavSection {
icon: string;
route: string;
badge$?: () => number | null;
sparklineData$?: () => number[];
children?: NavItem[];
requiredScopes?: readonly StellaOpsScope[];
requireAnyScope?: readonly StellaOpsScope[];
@@ -90,6 +92,7 @@ export interface NavSection {
[route]="section.route"
[children]="section.children"
[expanded]="expandedGroups().has(section.id)"
[sparklineData]="section.sparklineData$ ? section.sparklineData$() : []"
(expandedChange)="onGroupToggle(section.id, $event)"
></app-sidebar-nav-group>
} @else {
@@ -272,36 +275,77 @@ export class AppSidebarComponent {
private readonly authService = inject(AUTH_SERVICE) as AuthService;
private readonly destroyRef = inject(DestroyRef);
private readonly approvalApi = inject(APPROVAL_API, { optional: true }) as ApprovalApi | null;
private readonly doctorTrendService = inject(DoctorTrendService);
@Output() mobileClose = new EventEmitter<void>();
private readonly pendingApprovalsCount = signal(0);
/** Track which groups are expanded default open: Releases, Security, Platform. */
/** Track which groups are expanded - default open: Releases, Security, Platform. */
readonly expandedGroups = signal<Set<string>>(new Set(['releases', 'security', 'platform']));
/**
* Navigation sections — Pack 22 consolidated IA.
* Root modules: Dashboard, Releases, Security, Evidence, Topology, Platform.
* Navigation sections - canonical IA.
* Root modules: Mission Control, Releases, Security, Evidence, Topology, Platform.
*/
readonly navSections: NavSection[] = [
{
id: 'dashboard',
label: 'Dashboard',
label: 'Mission Control',
icon: 'dashboard',
route: '/dashboard',
requireAnyScope: [
StellaOpsScopes.UI_READ,
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.SCANNER_READ,
],
},
{
id: 'releases',
label: 'Releases',
icon: 'package',
route: '/releases',
requireAnyScope: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.RELEASE_WRITE,
StellaOpsScopes.RELEASE_PUBLISH,
],
children: [
{ id: 'rel-versions', label: 'Release Versions', route: '/releases/versions', icon: 'package' },
{ id: 'rel-runs', label: 'Release Runs', route: '/releases/runs', icon: 'clock' },
{ id: 'rel-approvals', label: 'Approvals Queue', route: '/releases/approvals', icon: 'check-circle', badge: 0 },
{
id: 'rel-versions',
label: 'Release Versions',
route: '/releases/versions',
icon: 'package',
requireAnyScope: [StellaOpsScopes.RELEASE_READ, StellaOpsScopes.RELEASE_WRITE],
},
{
id: 'rel-runs',
label: 'Release Runs',
route: '/releases/runs',
icon: 'clock',
requireAnyScope: [StellaOpsScopes.RELEASE_READ, StellaOpsScopes.RELEASE_WRITE],
},
{
id: 'rel-approvals',
label: 'Approvals Queue',
route: '/releases/approvals',
icon: 'check-circle',
badge: 0,
requireAnyScope: [
StellaOpsScopes.RELEASE_PUBLISH,
StellaOpsScopes.POLICY_REVIEW,
StellaOpsScopes.POLICY_APPROVE,
StellaOpsScopes.EXCEPTION_APPROVE,
],
},
{ id: 'rel-hotfix', label: 'Hotfix Lane', route: '/releases/hotfix', icon: 'zap' },
{ id: 'rel-create', label: 'Create Version', route: '/releases/versions/new', icon: 'settings' },
{
id: 'rel-create',
label: 'Create Version',
route: '/releases/versions/new',
icon: 'settings',
requireAnyScope: [StellaOpsScopes.RELEASE_WRITE, StellaOpsScopes.RELEASE_PUBLISH],
},
],
},
{
@@ -309,11 +353,22 @@ export class AppSidebarComponent {
label: 'Security',
icon: 'shield',
route: '/security',
sparklineData$: () => this.doctorTrendService.securityTrend(),
requireAnyScope: [
StellaOpsScopes.SCANNER_READ,
StellaOpsScopes.SBOM_READ,
StellaOpsScopes.ADVISORY_READ,
StellaOpsScopes.VEX_READ,
StellaOpsScopes.EXCEPTION_READ,
StellaOpsScopes.FINDINGS_READ,
StellaOpsScopes.VULN_VIEW,
],
children: [
{ id: 'sec-overview', label: 'Overview', route: '/security/overview', icon: 'chart' },
{ id: 'sec-overview', label: 'Posture', route: '/security/posture', icon: 'chart' },
{ id: 'sec-triage', label: 'Triage', route: '/security/triage', icon: 'list' },
{ id: 'sec-advisories', label: 'Advisories & VEX', route: '/security/advisories-vex', icon: 'shield-off' },
{ id: 'sec-supply-chain', label: 'Supply-Chain Data', route: '/security/supply-chain-data/lake', icon: 'graph' },
{ id: 'sec-disposition', label: 'Disposition Center', route: '/security/disposition', icon: 'shield-off' },
{ id: 'sec-sbom', label: 'SBOM', route: '/security/sbom/lake', icon: 'graph' },
{ id: 'sec-reachability', label: 'Reachability', route: '/security/reachability', icon: 'cpu' },
{ id: 'sec-reports', label: 'Reports', route: '/security/reports', icon: 'book-open' },
],
},
@@ -322,13 +377,19 @@ export class AppSidebarComponent {
label: 'Evidence',
icon: 'file-text',
route: '/evidence',
requireAnyScope: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.POLICY_AUDIT,
StellaOpsScopes.AUTHORITY_AUDIT_READ,
StellaOpsScopes.SIGNER_READ,
StellaOpsScopes.VEX_EXPORT,
],
children: [
{ id: 'ev-overview', label: 'Overview', route: '/evidence/overview', icon: 'home' },
{ id: 'ev-search', label: 'Search', route: '/evidence/search', icon: 'search' },
{ id: 'ev-capsules', label: 'Capsules', route: '/evidence/capsules', icon: 'archive' },
{ id: 'ev-verify', label: 'Verify & Replay', route: '/evidence/verify-replay', icon: 'refresh' },
{ id: 'ev-exports', label: 'Exports', route: '/evidence/exports', icon: 'download' },
{ id: 'ev-capsules', label: 'Decision Capsules', route: '/evidence/capsules', icon: 'archive' },
{ id: 'ev-verify', label: 'Replay & Verify', route: '/evidence/verification/replay', icon: 'refresh' },
{ id: 'ev-exports', label: 'Export Center', route: '/evidence/exports', icon: 'download' },
{ id: 'ev-audit', label: 'Audit Log', route: '/evidence/audit-log', icon: 'book-open' },
{ id: 'ev-trust', label: 'Trust & Signing', route: '/platform/setup/trust-signing', icon: 'shield' },
],
},
{
@@ -336,13 +397,20 @@ export class AppSidebarComponent {
label: 'Topology',
icon: 'server',
route: '/topology',
requireAnyScope: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.UI_ADMIN,
],
children: [
{ id: 'top-overview', label: 'Overview', route: '/topology/overview', icon: 'chart' },
{ id: 'top-regions', label: 'Regions & Environments', route: '/topology/regions', icon: 'globe' },
{ id: 'top-targets', label: 'Targets', route: '/topology/targets', icon: 'package' },
{ id: 'top-environments', label: 'Environment Posture', route: '/topology/environments', icon: 'list' },
{ id: 'top-targets', label: 'Targets / Runtimes', route: '/topology/targets', icon: 'package' },
{ id: 'top-hosts', label: 'Hosts', route: '/topology/hosts', icon: 'hard-drive' },
{ id: 'top-agents', label: 'Agents', route: '/topology/agents', icon: 'cpu' },
{ id: 'top-paths', label: 'Promotion Paths', route: '/topology/promotion-paths', icon: 'git-merge' },
{ id: 'top-paths', label: 'Promotion Graph', route: '/topology/promotion-graph', icon: 'git-merge' },
],
},
{
@@ -350,15 +418,22 @@ export class AppSidebarComponent {
label: 'Platform',
icon: 'settings',
route: '/platform',
sparklineData$: () => this.doctorTrendService.platformTrend(),
requireAnyScope: [
StellaOpsScopes.UI_ADMIN,
StellaOpsScopes.ORCH_READ,
StellaOpsScopes.ORCH_OPERATE,
StellaOpsScopes.HEALTH_READ,
StellaOpsScopes.NOTIFY_VIEWER,
],
children: [
{ id: 'plat-home', label: 'Overview', route: '/platform', icon: 'home' },
{ id: 'plat-ops', label: 'Ops', route: '/platform/ops', icon: 'activity' },
{ id: 'plat-jobs', label: 'Jobs & Queues', route: '/platform/ops/jobs-queues', icon: 'play' },
{ id: 'plat-integrity', label: 'Data Integrity', route: '/platform/ops/data-integrity', icon: 'shield' },
{ id: 'plat-health', label: 'Health & SLO', route: '/platform/ops/health-slo', icon: 'heart' },
{ id: 'plat-system-health', label: 'System Health', route: '/platform/ops/system-health', icon: 'heart' },
{ id: 'plat-feeds', label: 'Feeds & Airgap', route: '/platform/ops/feeds-airgap', icon: 'rss' },
{ id: 'plat-quotas', label: 'Quotas & Limits', route: '/platform/ops/quotas', icon: 'bar-chart' },
{ id: 'plat-diagnostics', label: 'Diagnostics', route: '/platform/ops/doctor', icon: 'alert' },
{ id: 'plat-integrations', label: 'Integrations', route: '/platform/integrations', icon: 'plug' },
{ id: 'plat-setup', label: 'Setup', route: '/platform/setup', icon: 'cog' },
],
@@ -379,6 +454,7 @@ export class AppSidebarComponent {
.subscribe((event) => {
if (event instanceof NavigationEnd) {
this.loadPendingApprovalsBadge();
this.doctorTrendService.refresh();
}
});
}
@@ -468,3 +544,4 @@ export class AppSidebarComponent {
});
}
}

View File

@@ -14,6 +14,7 @@ import { filter } from 'rxjs/operators';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
import { SidebarSparklineComponent } from './sidebar-sparkline.component';
/**
* SidebarNavGroupComponent - Collapsible navigation group for dark sidebar.
@@ -23,7 +24,7 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
@Component({
selector: 'app-sidebar-nav-group',
standalone: true,
imports: [SidebarNavItemComponent],
imports: [SidebarNavItemComponent, SidebarSparklineComponent],
template: `
<div class="nav-group" [class.nav-group--expanded]="expanded">
<!-- Group header -->
@@ -65,6 +66,9 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
</span>
<span class="nav-group__label">{{ label }}</span>
@if (sparklineData.length >= 2) {
<app-sidebar-sparkline [points]="sparklineData" />
}
<svg class="nav-group__chevron" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
@@ -202,6 +206,7 @@ export class SidebarNavGroupComponent implements OnInit {
@Input({ required: true }) route!: string;
@Input() children: NavItem[] = [];
@Input() expanded = false;
@Input() sparklineData: number[] = [];
@Output() expandedChange = new EventEmitter<boolean>();

View File

@@ -0,0 +1,58 @@
import { Component, computed, Input } from '@angular/core';
/**
* Tiny SVG sparkline for sidebar nav sections.
* Renders a 40x16px polyline from numeric data points.
*/
@Component({
selector: 'app-sidebar-sparkline',
standalone: true,
template: `
@if (polylinePoints()) {
<svg
class="sparkline"
width="40"
height="16"
viewBox="0 0 40 16"
preserveAspectRatio="none"
aria-hidden="true"
>
<polyline
[attr.points]="polylinePoints()"
fill="none"
stroke="var(--color-sidebar-sparkline, #f5a623)"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
}
`,
styles: [`
.sparkline {
flex-shrink: 0;
opacity: 0.7;
}
`],
})
export class SidebarSparklineComponent {
@Input() points: number[] = [];
readonly polylinePoints = computed(() => {
if (this.points.length < 2) return null;
const pts = this.points;
const min = Math.min(...pts);
const max = Math.max(...pts);
const range = max - min || 1;
const stepX = 40 / (pts.length - 1);
return pts
.map((val, i) => {
const x = i * stepX;
const y = 16 - ((val - min) / range) * 14 - 1; // 1px padding
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
});
}

View File

@@ -4,7 +4,9 @@ import {
Output,
EventEmitter,
inject,
computed,
ElementRef,
HostListener,
signal,
} from '@angular/core';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
@@ -51,13 +53,31 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
<app-global-search></app-global-search>
</div>
<!-- Context chips row -->
<div class="topbar__context">
<!-- Context chips row (desktop) -->
<div class="topbar__context topbar__context--desktop">
<app-context-chips></app-context-chips>
</div>
<!-- Right section: Tenant + User -->
<div class="topbar__right">
<!-- Scope controls (tablet/mobile) -->
<div class="topbar__scope-wrap">
<button
type="button"
class="topbar__scope-toggle"
[attr.aria-expanded]="scopePanelOpen()"
aria-haspopup="dialog"
(click)="toggleScopePanel()"
>
Scope
</button>
@if (scopePanelOpen()) {
<div class="topbar__scope-panel" role="dialog" aria-label="Global scope controls">
<app-context-chips></app-context-chips>
</div>
}
</div>
<!-- Tenant selector -->
@if (activeTenant()) {
<div class="topbar__tenant">
@@ -137,10 +157,56 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
gap: 0.5rem;
}
.topbar__scope-wrap {
display: none;
position: relative;
}
.topbar__scope-toggle {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-size: 0.6875rem;
font-family: var(--font-family-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
padding: 0.35rem 0.55rem;
cursor: pointer;
}
.topbar__scope-toggle:hover {
border-color: var(--color-border-secondary);
color: var(--color-text-primary);
}
.topbar__scope-toggle:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.topbar__scope-panel {
position: absolute;
right: 0;
top: calc(100% + 0.4rem);
z-index: 120;
min-width: 340px;
max-width: min(92vw, 420px);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
box-shadow: var(--shadow-dropdown);
padding: 0.6rem;
}
@media (max-width: 1199px) {
.topbar__context {
display: none;
}
.topbar__scope-wrap {
display: block;
}
}
.topbar__right {
@@ -160,6 +226,12 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
}
}
@media (max-width: 767px) {
.topbar__scope-panel {
right: -3.5rem;
}
}
.topbar__tenant-btn {
display: flex;
align-items: center;
@@ -199,9 +271,38 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
export class AppTopbarComponent {
private readonly sessionStore = inject(AuthSessionStore);
private readonly consoleStore = inject(ConsoleSessionStore);
private readonly elementRef = inject(ElementRef<HTMLElement>);
@Output() menuToggle = new EventEmitter<void>();
readonly isAuthenticated = this.sessionStore.isAuthenticated;
readonly activeTenant = this.consoleStore.selectedTenantId;
readonly scopePanelOpen = signal(false);
toggleScopePanel(): void {
this.scopePanelOpen.update((open) => !open);
}
closeScopePanel(): void {
this.scopePanelOpen.set(false);
}
@HostListener('document:keydown.escape')
onEscape(): void {
this.closeScopePanel();
}
@HostListener('document:click', ['$event'])
onDocumentClick(event: MouseEvent): void {
const target = event.target as Node | null;
if (!target) {
return;
}
const host = this.elementRef.nativeElement;
const insideScope = host.querySelector('.topbar__scope-wrap')?.contains(target) ?? false;
if (!insideScope) {
this.closeScopePanel();
}
}
}

View File

@@ -20,7 +20,7 @@ import { AUTH_SERVICE, AuthService, StellaOpsScopes } from '../../core/auth';
class="chip"
[class.chip--on]="isEnabled()"
[class.chip--off]="!isEnabled()"
routerLink="/evidence-audit/trust-signing"
routerLink="/platform/setup/trust-signing"
[attr.title]="tooltip()"
>
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
@@ -97,3 +97,4 @@ export class EvidenceModeChipComponent {
: 'Evidence signing scopes are not active for this session.'
);
}

View File

@@ -22,7 +22,7 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
class="chip"
[class.chip--fresh]="!isStale()"
[class.chip--stale]="isStale()"
routerLink="/platform-ops/feeds"
routerLink="/platform/ops/feeds-airgap"
[attr.title]="tooltip()"
>
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
@@ -122,3 +122,4 @@ export class FeedSnapshotChipComponent {
return `${freshness.message} (snapshot ${freshness.bundleCreatedAt}).`;
});
}

View File

@@ -19,7 +19,7 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
class="chip"
[class.chip--ok]="status() === 'ok'"
[class.chip--degraded]="status() === 'degraded'"
routerLink="/administration/offline"
routerLink="/platform/ops/offline-kit"
[attr.title]="tooltip()"
aria-live="polite"
>
@@ -112,3 +112,4 @@ export class OfflineStatusChipComponent {
return 'Online mode active with live backend connectivity.';
});
}

View File

@@ -19,7 +19,7 @@ import { PolicyPackStore } from '../../features/policy-studio/services/policy-pa
template: `
<a
class="chip"
routerLink="/settings/policy"
routerLink="/administration/policy-governance"
[attr.title]="tooltip()"
>
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
@@ -90,3 +90,4 @@ export class PolicyBaselineChipComponent {
return `Active policy baseline: ${activePack.name} ${activePack.version}. Click to manage policies.`;
});
}

View File

@@ -7,31 +7,23 @@ import {
HostListener,
ElementRef,
ViewChild,
OnInit,
OnDestroy,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { Subject, of } from 'rxjs';
import { catchError, debounceTime, distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
/**
* Search result item structure.
*/
export interface SearchResult {
id: string;
type: 'release' | 'digest' | 'cve' | 'environment' | 'target';
label: string;
sublabel?: string;
route: string;
}
import { SearchClient } from '../../core/api/search.client';
import type {
SearchResponse,
SearchResult as ApiSearchResult,
} from '../../core/api/search.models';
export type SearchResult = ApiSearchResult;
/**
* GlobalSearchComponent - Unified search across releases, digests, CVEs, environments, targets.
*
* Features:
* - Keyboard shortcut (Cmd/Ctrl+K) to open
* - Categorized results dropdown
* - Recent searches
* - Navigation on selection
*/
@Component({
selector: 'app-global-search',
standalone: true,
@@ -43,43 +35,37 @@ export interface SearchResult {
<circle cx="11" cy="11" r="8" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="21" y1="21" x2="16.65" y2="16.65" stroke="currentColor" stroke-width="2"/>
</svg>
<input
#searchInput
type="text"
class="search__input"
placeholder="Search releases, digests, CVEs..."
[(ngModel)]="query"
placeholder="Search runs, digests, CVEs, capsules, targets..."
[ngModel]="query()"
(ngModelChange)="onQueryChange($event)"
(focus)="onFocus()"
(blur)="onBlur()"
(keydown)="onKeydown($event)"
(input)="onSearch()"
aria-label="Global search"
aria-autocomplete="list"
[attr.aria-expanded]="showResults()"
aria-controls="search-results"
/>
/>
<kbd class="search__shortcut" aria-hidden="true">{{ shortcutLabel }}</kbd>
</div>
<!-- Results dropdown -->
@if (showResults()) {
<div
class="search__results"
id="search-results"
role="listbox"
tabindex="-1"
>
<div class="search__results" id="search-results" role="listbox" tabindex="-1">
@if (isLoading()) {
<div class="search__loading">Searching...</div>
} @else if (results().length === 0 && query().trim().length > 0) {
} @else if (query().trim().length >= 2 && groupedResults().length === 0) {
<div class="search__empty">No results found</div>
} @else {
} @else if (query().trim().length >= 2) {
@for (group of groupedResults(); track group.type) {
<div class="search__group">
<div class="search__group-label">{{ group.label }}</div>
@for (result of group.items; track result.id; let i = $index) {
<div class="search__group-label">{{ group.label }} ({{ group.totalCount }})</div>
@for (result of group.results; track result.id; let i = $index) {
<button
type="button"
class="search__result"
@@ -88,78 +74,86 @@ export interface SearchResult {
[attr.aria-selected]="selectedIndex() === getResultIndex(group.type, i)"
(click)="onSelect(result)"
(mouseenter)="selectedIndex.set(getResultIndex(group.type, i))"
>
>
<span class="search__result-icon">
@switch (result.type) {
@case ('release') {
@case ('artifact') {
<svg viewBox="0 0 24 24" width="16" height="16">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('digest') {
<svg viewBox="0 0 24 24" width="16" height="16">
<rect x="3" y="3" width="18" height="18" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="7" y1="8" x2="17" y2="8" stroke="currentColor" stroke-width="2"/>
<line x1="7" y1="12" x2="17" y2="12" stroke="currentColor" stroke-width="2"/>
<line x1="7" y1="16" x2="13" y2="16" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('cve') {
<svg viewBox="0 0 24 24" width="16" height="16">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('environment') {
@case ('policy') {
<svg viewBox="0 0 24 24" width="16" height="16">
<rect x="2" y="3" width="20" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="21" x2="16" y2="21" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="17" x2="12" y2="21" stroke="currentColor" stroke-width="2"/>
<path d="M12 2l8 4v6c0 5-3.5 9.5-8 10-4.5-.5-8-5-8-10V6l8-4z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('target') {
@case ('job') {
<svg viewBox="0 0 24 24" width="16" height="16">
<rect x="3" y="5" width="18" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M7 9h10M7 13h6" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('finding') {
<svg viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
<circle cx="12" cy="12" r="6" fill="none" stroke="currentColor" stroke-width="2"/>
<circle cx="12" cy="12" r="2" fill="currentColor"/>
<line x1="12" y1="8" x2="12" y2="12" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="16" x2="12.01" y2="16" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('vex') {
<svg viewBox="0 0 24 24" width="16" height="16">
<path d="M12 2l8 4v6c0 5-3.5 9.5-8 10-4.5-.5-8-5-8-10V6l8-4z" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="9 12 11 14 15 10" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
@case ('integration') {
<svg viewBox="0 0 24 24" width="16" height="16">
<path d="M7 7h4v4H7zM13 13h4v4h-4zM11 9h2v2h-2zM9 11h2v2H9zM13 11h2v2h-2z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
}
</span>
<span class="search__result-text">
<span class="search__result-label">{{ result.label }}</span>
@if (result.sublabel) {
<span class="search__result-sublabel">{{ result.sublabel }}</span>
<span class="search__result-label">{{ result.title }}</span>
@if (result.subtitle) {
<span class="search__result-sublabel">{{ result.subtitle }}</span>
}
</span>
</button>
}
</div>
}
<!-- Recent searches -->
@if (recentSearches().length > 0 && query().trim().length === 0) {
<div class="search__group">
<div class="search__group-label">Recent</div>
@for (recent of recentSearches(); track recent) {
<button
type="button"
class="search__result"
(click)="query.set(recent); onSearch()"
>
<svg class="search__result-icon" viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="12 6 12 12 16 14" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
<span class="search__result-label">{{ recent }}</span>
</button>
}
</div>
}
} @else {
<div class="search__group">
<div class="search__group-label">Recent</div>
@for (recent of recentSearches(); track recent; let i = $index) {
<button
type="button"
class="search__result"
[class.search__result--selected]="selectedIndex() === i"
(click)="selectRecent(recent)"
(mouseenter)="selectedIndex.set(i)"
>
<svg class="search__result-icon" viewBox="0 0 24 24" width="16" height="16">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="12 6 12 12 16 14" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
<span class="search__result-label">{{ recent }}</span>
</button>
} @empty {
<div class="search__empty">Type at least 2 characters</div>
}
</div>
}
</div>
}
</div>
`,
`,
styles: [`
.search {
position: relative;
@@ -314,57 +308,73 @@ export interface SearchResult {
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GlobalSearchComponent {
export class GlobalSearchComponent implements OnInit, OnDestroy {
private readonly router = inject(Router);
private readonly elementRef = inject(ElementRef);
private readonly searchClient = inject(SearchClient);
private readonly destroy$ = new Subject<void>();
private readonly searchTerms$ = new Subject<string>();
private readonly recentSearchStorageKey = 'stella-recent-searches';
@ViewChild('searchInput') searchInputRef!: ElementRef<HTMLInputElement>;
/** Show Ctrl+K on Windows/Linux, ⌘K on macOS */
readonly shortcutLabel = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘K' : 'Ctrl+K';
readonly query = signal('');
readonly isFocused = signal(false);
readonly isLoading = signal(false);
readonly results = signal<SearchResult[]>([]);
readonly selectedIndex = signal(0);
readonly searchResponse = signal<SearchResponse | null>(null);
readonly recentSearches = signal<string[]>([]);
readonly showResults = computed(() => this.isFocused() && (this.query().trim().length > 0 || this.recentSearches().length > 0));
readonly groupedResults = computed(() => this.searchResponse()?.groups ?? []);
readonly flatResults = computed(() => this.groupedResults().flatMap((group) => group.results));
readonly groupedResults = computed(() => {
const groups: { type: string; label: string; items: SearchResult[] }[] = [];
const resultsByType = new Map<string, SearchResult[]>();
ngOnInit(): void {
this.searchTerms$
.pipe(
debounceTime(200),
distinctUntilChanged(),
switchMap((term) => {
if (term.length < 2) {
this.searchResponse.set(null);
this.isLoading.set(false);
this.selectedIndex.set(0);
return of(null);
}
for (const result of this.results()) {
if (!resultsByType.has(result.type)) {
resultsByType.set(result.type, []);
}
resultsByType.get(result.type)!.push(result);
}
this.isLoading.set(true);
return this.searchClient.search(term).pipe(
catchError(() =>
of({
query: term,
groups: [],
totalCount: 0,
durationMs: 0,
} satisfies SearchResponse),
),
);
}),
takeUntil(this.destroy$),
)
.subscribe((response) => {
if (!response) {
return;
}
const typeLabels: Record<string, string> = {
release: 'Releases',
digest: 'Digests',
cve: 'Vulnerabilities',
environment: 'Environments',
target: 'Targets',
};
for (const [type, items] of resultsByType) {
groups.push({
type,
label: typeLabels[type] || type,
items,
this.searchResponse.set(response);
this.selectedIndex.set(0);
this.isLoading.set(false);
});
}
}
return groups;
});
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
@HostListener('document:keydown', ['$event'])
onGlobalKeydown(event: KeyboardEvent): void {
// Cmd/Ctrl+K to focus search
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault();
this.searchInputRef?.nativeElement?.focus();
@@ -377,31 +387,37 @@ export class GlobalSearchComponent {
}
onBlur(): void {
// Delay to allow click on results
setTimeout(() => {
this.isFocused.set(false);
}, 200);
}
onKeydown(event: KeyboardEvent): void {
const results = this.results();
const totalResults = results.length;
onQueryChange(value: string): void {
this.query.set(value);
this.selectedIndex.set(0);
this.searchTerms$.next(value.trim());
}
onKeydown(event: KeyboardEvent): void {
const count = this.getNavigableItemCount();
switch (event.key) {
case 'ArrowDown':
if (count === 0) {
return;
}
event.preventDefault();
this.selectedIndex.update((i) => (i + 1) % totalResults);
this.selectedIndex.update((index) => (index + 1) % count);
break;
case 'ArrowUp':
if (count === 0) {
return;
}
event.preventDefault();
this.selectedIndex.update((i) => (i - 1 + totalResults) % totalResults);
this.selectedIndex.update((index) => (index - 1 + count) % count);
break;
case 'Enter':
event.preventDefault();
const selected = results[this.selectedIndex()];
if (selected) {
this.onSelect(selected);
}
this.selectCurrent();
break;
case 'Escape':
this.isFocused.set(false);
@@ -410,93 +426,62 @@ export class GlobalSearchComponent {
}
}
onSearch(): void {
const q = this.query().trim();
if (q.length < 2) {
this.results.set([]);
return;
}
this.isLoading.set(true);
// TODO: Wire to actual search API
// Mock results for now
setTimeout(() => {
const mockResults: SearchResult[] = [];
// Match releases
if (q.startsWith('v') || q.match(/^\d/)) {
mockResults.push({
id: 'rel-1',
type: 'release',
label: `v1.2.5`,
sublabel: 'sha256:7aa...2f',
route: '/releases/v1.2.5',
});
}
// Match digests
if (q.startsWith('sha') || q.match(/^[0-9a-f]{6,}/i)) {
mockResults.push({
id: 'dig-1',
type: 'digest',
label: 'sha256:7aa...2f',
sublabel: 'Release v1.2.5',
route: '/releases/v1.2.5',
});
}
// Match CVEs
if (q.toUpperCase().startsWith('CVE') || q.match(/^\d{4}-\d+/)) {
mockResults.push({
id: 'cve-1',
type: 'cve',
label: 'CVE-2026-12345',
sublabel: 'Critical - Remote Code Execution',
route: '/security/vulnerabilities/CVE-2026-12345',
});
}
// Match environments
if (['dev', 'qa', 'stag', 'prod'].some((e) => e.includes(q.toLowerCase()))) {
mockResults.push({
id: 'env-1',
type: 'environment',
label: 'Production',
sublabel: '5 targets',
route: '/topology/regions',
});
}
this.results.set(mockResults);
this.selectedIndex.set(0);
this.isLoading.set(false);
}, 200);
}
onSelect(result: SearchResult): void {
this.saveRecentSearch(this.query());
this.query.set('');
this.selectedIndex.set(0);
this.searchResponse.set(null);
this.isFocused.set(false);
void this.router.navigate([result.route]);
void this.router.navigateByUrl(result.route);
}
getResultIndex(type: string, indexInGroup: number): number {
selectRecent(query: string): void {
this.query.set(query);
this.searchTerms$.next(query.trim());
}
getResultIndex(groupType: string, indexInGroup: number): number {
let offset = 0;
for (const group of this.groupedResults()) {
if (group.type === type) {
if (group.type === groupType) {
return offset + indexInGroup;
}
offset += group.items.length;
offset += group.results.length;
}
return 0;
}
private selectCurrent(): void {
if (this.query().trim().length >= 2) {
const selected = this.flatResults()[this.selectedIndex()];
if (selected) {
this.onSelect(selected);
}
return;
}
const recent = this.recentSearches()[this.selectedIndex()];
if (!recent) {
return;
}
this.selectRecent(recent);
}
private getNavigableItemCount(): number {
if (this.query().trim().length >= 2) {
return this.flatResults().length;
}
return this.recentSearches().length;
}
private loadRecentSearches(): void {
try {
const stored = localStorage.getItem('stella-recent-searches');
const stored = localStorage.getItem(this.recentSearchStorageKey);
if (stored) {
this.recentSearches.set(JSON.parse(stored));
const parsed = JSON.parse(stored);
this.recentSearches.set(Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : []);
} else {
this.recentSearches.set([]);
}
} catch {
this.recentSearches.set([]);
@@ -504,16 +489,17 @@ export class GlobalSearchComponent {
}
private saveRecentSearch(query: string): void {
if (!query.trim()) return;
const recent = this.recentSearches();
const updated = [query, ...recent.filter((r) => r !== query)].slice(0, 5);
this.recentSearches.set(updated);
const normalized = query.trim();
if (!normalized) {
return;
}
const next = [normalized, ...this.recentSearches().filter((item) => item !== normalized)].slice(0, 5);
this.recentSearches.set(next);
try {
localStorage.setItem('stella-recent-searches', JSON.stringify(updated));
localStorage.setItem(this.recentSearchStorageKey, JSON.stringify(next));
} catch {
// Ignore storage errors
// Ignore localStorage failures.
}
}
}

View File

@@ -7,8 +7,8 @@ import { Routes } from '@angular/router';
export const DASHBOARD_ROUTES: Routes = [
{
path: '',
title: 'Dashboard',
data: { breadcrumb: 'Dashboard' },
title: 'Mission Control',
data: { breadcrumb: 'Mission Control' },
loadComponent: () =>
import('../features/dashboard-v3/dashboard-v3.component').then(
(m) => m.DashboardV3Component

View File

@@ -4,7 +4,7 @@ export const EVIDENCE_ROUTES: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'overview',
redirectTo: 'capsules',
},
{
path: 'overview',
@@ -56,8 +56,8 @@ export const EVIDENCE_ROUTES: Routes = [
},
{
path: 'verification/replay',
title: 'Replay & Determinism',
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

@@ -60,7 +60,7 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
// ===========================================
{ path: 'release-control', redirectTo: '/releases', pathMatch: 'full' },
{ path: 'release-control/releases', redirectTo: '/releases', pathMatch: 'full' },
{ path: 'release-control/releases/:id', redirectTo: '/releases/:id', pathMatch: 'full' },
{ path: 'release-control/releases/:id', redirectTo: '/releases/runs/:id/timeline', pathMatch: 'full' },
{ path: 'release-control/approvals', redirectTo: '/releases/approvals', pathMatch: 'full' },
{ path: 'release-control/approvals/:id', redirectTo: '/releases/approvals/:id', pathMatch: 'full' },
{ path: 'release-control/runs', redirectTo: '/releases/runs', pathMatch: 'full' },
@@ -69,25 +69,25 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
{ path: 'release-control/hotfixes', redirectTo: '/releases/hotfix', pathMatch: 'full' },
{ path: 'release-control/regions', redirectTo: '/topology/regions', pathMatch: 'full' },
{ path: 'release-control/regions/:region', redirectTo: '/topology/regions', pathMatch: 'full' },
{ path: 'release-control/regions/:region/environments/:env', redirectTo: '/topology/environments', pathMatch: 'full' },
{ path: 'release-control/setup', redirectTo: '/platform/setup', pathMatch: 'full' },
{ path: 'release-control/setup/environments-paths', redirectTo: '/topology/promotion-paths', pathMatch: 'full' },
{ path: 'release-control/regions/:region/environments/:env', redirectTo: '/topology/environments/:env/posture', pathMatch: 'full' },
{ path: 'release-control/setup', redirectTo: '/topology/promotion-graph', pathMatch: 'full' },
{ path: 'release-control/setup/environments-paths', redirectTo: '/topology/promotion-graph', pathMatch: 'full' },
{ path: 'release-control/setup/targets-agents', redirectTo: '/topology/targets', pathMatch: 'full' },
{ path: 'release-control/setup/workflows', redirectTo: '/platform/setup/workflows-gates', pathMatch: 'full' },
{ path: 'release-control/governance', redirectTo: '/platform/setup/workflows-gates', pathMatch: 'full' },
{ path: 'release-control/setup/workflows', redirectTo: '/topology/workflows', pathMatch: 'full' },
{ path: 'release-control/governance', redirectTo: '/topology/workflows', pathMatch: 'full' },
{ path: 'security-risk', redirectTo: '/security', pathMatch: 'full' },
{ path: 'security-risk/findings', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'security-risk/findings/:findingId', redirectTo: '/security/triage/:findingId', pathMatch: 'full' },
{ path: 'security-risk/vulnerabilities', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'security-risk/vulnerabilities/:vulnId', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'security-risk/sbom', redirectTo: '/security/supply-chain-data/graph', pathMatch: 'full' },
{ path: 'security-risk/sbom-lake', redirectTo: '/security/supply-chain-data/lake', pathMatch: 'full' },
{ path: 'security-risk/vex', redirectTo: '/security/advisories-vex', pathMatch: 'full' },
{ path: 'security-risk/exceptions', redirectTo: '/security/advisories-vex', pathMatch: 'full' },
{ path: 'security-risk/sbom', redirectTo: '/security/sbom/graph', pathMatch: 'full' },
{ path: 'security-risk/sbom-lake', redirectTo: '/security/sbom/lake', pathMatch: 'full' },
{ path: 'security-risk/vex', redirectTo: '/security/disposition', pathMatch: 'full' },
{ path: 'security-risk/exceptions', redirectTo: '/security/disposition', pathMatch: 'full' },
{ path: 'security-risk/advisory-sources', redirectTo: '/platform/integrations/feeds', pathMatch: 'full' },
{ path: 'evidence-audit', redirectTo: '/evidence/overview', pathMatch: 'full' },
{ path: 'evidence-audit', redirectTo: '/evidence/capsules', pathMatch: 'full' },
{ path: 'evidence-audit/packs', redirectTo: '/evidence/capsules', pathMatch: 'full' },
{ path: 'evidence-audit/packs/:packId', redirectTo: '/evidence/capsules/:packId', pathMatch: 'full' },
{ path: 'evidence-audit/bundles', redirectTo: '/evidence/exports', pathMatch: 'full' },
@@ -116,23 +116,23 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
// ===========================================
{ path: 'findings', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'findings/:scanId', redirectTo: '/security/scans/:scanId', pathMatch: 'full' },
{ path: 'security/sbom', redirectTo: '/security/supply-chain-data/graph', pathMatch: 'full' },
{ path: 'security/vex', redirectTo: '/security/advisories-vex', pathMatch: 'full' },
{ path: 'security/exceptions', redirectTo: '/security/advisories-vex', pathMatch: 'full' },
{ path: 'security/sbom', redirectTo: '/security/sbom/graph', pathMatch: 'full' },
{ path: 'security/vex', redirectTo: '/security/disposition', pathMatch: 'full' },
{ path: 'security/exceptions', redirectTo: '/security/disposition', pathMatch: 'full' },
{ path: 'security/advisory-sources', redirectTo: '/platform/integrations/feeds', pathMatch: 'full' },
{ path: 'scans/:scanId', redirectTo: '/security/scans/:scanId', pathMatch: 'full' },
{ path: 'vulnerabilities', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'vulnerabilities/:vulnId', redirectTo: '/security/triage', pathMatch: 'full' },
{ path: 'graph', redirectTo: '/security/supply-chain-data/graph', pathMatch: 'full' },
{ path: 'graph', redirectTo: '/security/sbom/graph', pathMatch: 'full' },
{ path: 'lineage', redirectTo: '/security/lineage', pathMatch: 'full' },
{ path: 'lineage/:artifact/compare', redirectTo: '/security/lineage/:artifact/compare', pathMatch: 'full' },
{ path: 'lineage/compare', redirectTo: '/security/lineage/compare', pathMatch: 'full' },
{ path: 'compare/:currentId', redirectTo: '/security/lineage/compare/:currentId', pathMatch: 'full' },
{ path: 'reachability', redirectTo: '/security/findings', pathMatch: 'full' },
{ path: 'reachability', redirectTo: '/security/reachability', pathMatch: 'full' },
{ path: 'analyze/unknowns', redirectTo: '/security/unknowns', pathMatch: 'full' },
{ path: 'analyze/patch-map', redirectTo: '/security/patch-map', pathMatch: 'full' },
{ path: 'analytics', redirectTo: '/security/supply-chain-data/lake', pathMatch: 'full' },
{ path: 'analytics/sbom-lake', redirectTo: '/security/supply-chain-data/lake', pathMatch: 'full' },
{ path: 'analytics', redirectTo: '/security/sbom/lake', pathMatch: 'full' },
{ path: 'analytics/sbom-lake', redirectTo: '/security/sbom/lake', pathMatch: 'full' },
{ path: 'cvss/receipts/:receiptId', redirectTo: '/evidence/receipts/cvss/:receiptId', pathMatch: 'full' },
// ===========================================
@@ -214,14 +214,18 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
// ===========================================
// Integrations -> Integrations
// ===========================================
{ path: 'sbom-sources', redirectTo: '/integrations/sbom-sources', pathMatch: 'full' },
{ path: 'sbom-sources', redirectTo: '/platform/integrations/sbom-sources', pathMatch: 'full' },
// ===========================================
// Settings -> canonical v2 domains
// ===========================================
{ path: 'settings/integrations', redirectTo: '/platform/integrations', pathMatch: 'full' },
{ path: 'settings/integrations/:id', redirectTo: '/platform/integrations/:id', pathMatch: 'full' },
{ path: 'settings/release-control', redirectTo: '/platform/setup', pathMatch: 'full' },
{ path: 'settings/release-control', redirectTo: '/topology/promotion-graph', pathMatch: 'full' },
{ path: 'settings/release-control/environments', redirectTo: '/topology/regions', pathMatch: 'full' },
{ path: 'settings/release-control/targets', redirectTo: '/topology/targets', pathMatch: 'full' },
{ path: 'settings/release-control/agents', redirectTo: '/topology/agents', pathMatch: 'full' },
{ path: 'settings/release-control/workflows', redirectTo: '/topology/workflows', pathMatch: 'full' },
{ path: 'settings/trust', redirectTo: '/platform/setup/trust-signing', pathMatch: 'full' },
{ path: 'settings/trust/:page', redirectTo: '/platform/setup/trust-signing', pathMatch: 'full' },
{ path: 'settings/policy', redirectTo: '/administration/policy-governance', pathMatch: 'full' },
@@ -241,8 +245,8 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
{ path: 'release-orchestrator/releases', redirectTo: '/releases', pathMatch: 'full' },
{ path: 'release-orchestrator/approvals', redirectTo: '/releases/approvals', pathMatch: 'full' },
{ path: 'release-orchestrator/deployments', redirectTo: '/releases/runs', pathMatch: 'full' },
{ path: 'release-orchestrator/workflows', redirectTo: '/platform/setup/workflows-gates', pathMatch: 'full' },
{ path: 'release-orchestrator/evidence', redirectTo: '/evidence/overview', pathMatch: 'full' },
{ path: 'release-orchestrator/workflows', redirectTo: '/topology/workflows', pathMatch: 'full' },
{ path: 'release-orchestrator/evidence', redirectTo: '/evidence/capsules', pathMatch: 'full' },
// ===========================================
// Evidence -> Evidence & Audit

View File

@@ -38,6 +38,15 @@ export const OPERATIONS_ROUTES: Routes = [
(m) => m.dataIntegrityRoutes,
),
},
{
path: 'system-health',
title: 'System Health',
data: { breadcrumb: 'System Health' },
loadComponent: () =>
import('../features/system-health/system-health-page.component').then(
(m) => m.SystemHealthPageComponent,
),
},
{
path: 'health-slo',
title: 'Health & SLO',

View File

@@ -4,12 +4,12 @@ export const SECURITY_ROUTES: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'overview',
redirectTo: 'posture',
},
{
path: 'overview',
title: 'Security Overview',
data: { breadcrumb: 'Overview' },
path: 'posture',
title: 'Security Posture',
data: { breadcrumb: 'Posture' },
loadComponent: () =>
import('../features/security-risk/security-risk-overview.component').then(
(m) => m.SecurityRiskOverviewComponent,
@@ -34,23 +34,23 @@ export const SECURITY_ROUTES: Routes = [
),
},
{
path: 'advisories-vex',
title: 'Advisories & VEX',
data: { breadcrumb: 'Advisories & VEX' },
path: 'disposition',
title: 'Disposition Center',
data: { breadcrumb: 'Disposition Center' },
loadComponent: () =>
import('../features/security/security-disposition-page.component').then(
(m) => m.SecurityDispositionPageComponent,
),
},
{
path: 'supply-chain-data',
path: 'sbom',
pathMatch: 'full',
redirectTo: 'supply-chain-data/lake',
redirectTo: 'sbom/lake',
},
{
path: 'supply-chain-data/:mode',
title: 'Supply-Chain Data',
data: { breadcrumb: 'Supply-Chain Data' },
path: 'sbom/:mode',
title: 'SBOM',
data: { breadcrumb: 'SBOM' },
loadComponent: () =>
import('../features/security/security-sbom-explorer-page.component').then(
(m) => m.SecuritySbomExplorerPageComponent,
@@ -68,9 +68,9 @@ export const SECURITY_ROUTES: Routes = [
// Canonical compatibility aliases.
{
path: 'posture',
path: 'overview',
pathMatch: 'full',
redirectTo: 'overview',
redirectTo: 'posture',
},
{
path: 'findings',
@@ -83,34 +83,28 @@ export const SECURITY_ROUTES: Routes = [
redirectTo: 'triage/:findingId',
},
{
path: 'disposition',
path: 'advisories-vex',
pathMatch: 'full',
redirectTo: 'advisories-vex',
},
{
path: 'sbom',
pathMatch: 'full',
redirectTo: 'supply-chain-data/lake',
},
{
path: 'sbom/:mode',
pathMatch: 'full',
redirectTo: 'supply-chain-data/:mode',
redirectTo: 'disposition',
},
{
path: 'reachability',
pathMatch: 'full',
redirectTo: 'triage',
title: 'Reachability',
data: { breadcrumb: 'Reachability' },
loadComponent: () =>
import('../features/reachability/reachability-center.component').then(
(m) => m.ReachabilityCenterComponent,
),
},
{
path: 'vex',
pathMatch: 'full',
redirectTo: 'advisories-vex',
redirectTo: 'disposition',
},
{
path: 'exceptions',
pathMatch: 'full',
redirectTo: 'advisories-vex',
redirectTo: 'disposition',
},
{
path: 'advisory-sources',
@@ -132,12 +126,22 @@ export const SECURITY_ROUTES: Routes = [
{
path: 'sbom-explorer',
pathMatch: 'full',
redirectTo: 'supply-chain-data/lake',
redirectTo: 'sbom/lake',
},
{
path: 'sbom-explorer/:mode',
pathMatch: 'full',
redirectTo: 'supply-chain-data/:mode',
redirectTo: 'sbom/:mode',
},
{
path: 'supply-chain-data',
pathMatch: 'full',
redirectTo: 'sbom/lake',
},
{
path: 'supply-chain-data/:mode',
pathMatch: 'full',
redirectTo: 'sbom/:mode',
},
{

View File

@@ -105,11 +105,11 @@ export const TOPOLOGY_ROUTES: Routes = [
),
},
{
path: 'promotion-paths',
title: 'Topology Promotion Paths',
path: 'promotion-graph',
title: 'Topology Promotion Graph',
data: {
breadcrumb: 'Promotion Paths',
title: 'Promotion Paths',
breadcrumb: 'Promotion Graph',
title: 'Promotion Graph',
description: 'Promotion path configurations and gate ownership.',
},
loadComponent: () =>
@@ -117,6 +117,11 @@ export const TOPOLOGY_ROUTES: Routes = [
(m) => m.TopologyPromotionPathsPageComponent,
),
},
{
path: 'promotion-paths',
pathMatch: 'full',
redirectTo: 'promotion-graph',
},
{
path: 'workflows',
title: 'Topology Workflows',

View File

@@ -29,6 +29,7 @@ import {
addRecentSearch,
clearRecentSearches,
} from '../../../core/api/search.models';
import { DoctorQuickCheckService } from '../../../features/doctor/services/doctor-quick-check.service';
@Component({
selector: 'app-command-palette',
@@ -203,6 +204,7 @@ import {
export class CommandPaletteComponent implements OnInit, OnDestroy {
private readonly searchClient = inject(SearchClient);
private readonly router = inject(Router);
private readonly doctorQuickCheck = inject(DoctorQuickCheckService);
private readonly destroy$ = new Subject<void>();
private readonly searchQuery$ = new Subject<string>();
@@ -215,15 +217,23 @@ export class CommandPaletteComponent implements OnInit, OnDestroy {
searchResponse = signal<SearchResponse | null>(null);
recentSearches = signal<RecentSearch[]>([]);
quickActions = DEFAULT_QUICK_ACTIONS;
quickActions: QuickAction[] = DEFAULT_QUICK_ACTIONS;
readonly highlightMatch = highlightMatch;
isActionMode = computed(() => this.query.startsWith('>'));
filteredActions = computed(() => filterQuickActions(this.query));
filteredActions = computed(() => filterQuickActions(this.query, this.quickActions));
private flatResults: SearchResult[] = [];
ngOnInit(): void {
// Merge Doctor quick actions (with bound callbacks) into the actions list
const doctorActions = this.doctorQuickCheck.getQuickActions();
const doctorIds = new Set(doctorActions.map((a) => a.id));
this.quickActions = [
...DEFAULT_QUICK_ACTIONS.filter((a) => !doctorIds.has(a.id)),
...doctorActions,
];
this.recentSearches.set(getRecentSearches());
this.searchQuery$.pipe(debounceTime(200), distinctUntilChanged(), takeUntil(this.destroy$))
.subscribe((query) => {
@@ -345,7 +355,15 @@ export class CommandPaletteComponent implements OnInit, OnDestroy {
viewAllResults(type: string): void {
this.close();
const routes: Record<string, string> = { cve: '/vulnerabilities', artifact: '/triage/artifacts', policy: '/policy-studio/packs', job: '/platform-ops/orchestrator/jobs', finding: '/findings', vex: '/admin/vex-hub', integration: '/integrations' };
const routes: Record<string, string> = {
cve: '/security/triage',
artifact: '/security/triage',
policy: '/policy-studio/packs',
job: '/platform/ops/jobs-queues',
finding: '/security/triage',
vex: '/security/disposition',
integration: '/platform/integrations',
};
if (routes[type]) this.router.navigate([routes[type]], { queryParams: { q: this.query } });
}
}

View File

@@ -0,0 +1,168 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, computed, signal } from '@angular/core';
type ImpactLevel = 'BLOCKING' | 'DEGRADED' | 'INFO';
@Component({
selector: 'app-degraded-state-banner',
standalone: true,
template: `
<section class="impact" [class]="'impact impact--' + impactTone()">
<header class="impact__header">
<span class="impact__badge">{{ impact }}</span>
<h3>{{ title }}</h3>
</header>
<p class="impact__message">{{ message }}</p>
<div class="impact__meta">
@if (lastKnownGoodAt) {
<span>Last known good: {{ formatTime(lastKnownGoodAt) }}</span>
}
@if (readOnly) {
<span>Mode: read-only fallback</span>
}
</div>
<div class="impact__actions">
@if (retryable) {
<button type="button" (click)="retryRequested.emit()">{{ retryLabel }}</button>
}
@if (correlationId) {
<button type="button" (click)="copyCorrelationId()">
{{ copied() ? 'Copied' : 'Copy Correlation ID' }}
</button>
<code>{{ correlationId }}</code>
}
</div>
</section>
`,
styles: [`
.impact {
border: 1px solid var(--color-border-primary);
border-left-width: 4px;
border-radius: var(--radius-md);
padding: 0.55rem 0.65rem;
background: var(--color-surface-primary);
display: grid;
gap: 0.35rem;
}
.impact--blocking {
border-left-color: var(--color-status-error-text);
background: color-mix(in srgb, var(--color-status-error-bg) 55%, var(--color-surface-primary));
}
.impact--degraded {
border-left-color: var(--color-status-warning-text);
background: color-mix(in srgb, var(--color-status-warning-bg) 55%, var(--color-surface-primary));
}
.impact--info {
border-left-color: var(--color-status-info-text);
background: color-mix(in srgb, var(--color-status-info-bg) 55%, var(--color-surface-primary));
}
.impact__header {
display: flex;
align-items: center;
gap: 0.45rem;
}
.impact__header h3 {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-primary);
}
.impact__badge {
font-size: 0.65rem;
letter-spacing: 0.05em;
text-transform: uppercase;
border: 1px solid currentColor;
border-radius: var(--radius-full);
padding: 0.04rem 0.4rem;
font-weight: 700;
}
.impact__message {
margin: 0;
font-size: 0.74rem;
color: var(--color-text-secondary);
}
.impact__meta {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
font-size: 0.68rem;
color: var(--color-text-secondary);
}
.impact__actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
}
.impact__actions button {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-size: 0.7rem;
padding: 0.18rem 0.5rem;
cursor: pointer;
}
.impact__actions code {
font-size: 0.65rem;
color: var(--color-text-secondary);
word-break: break-all;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DegradedStateBannerComponent {
@Input() impact: ImpactLevel = 'INFO';
@Input() title = 'Service impact';
@Input() message = 'Some supporting services are degraded.';
@Input() correlationId: string | null = null;
@Input() lastKnownGoodAt: string | null = null;
@Input() readOnly = false;
@Input() retryable = true;
@Input() retryLabel = 'Retry';
@Output() readonly retryRequested = new EventEmitter<void>();
readonly copied = signal(false);
readonly impactTone = computed(() => this.impact.toLowerCase());
async copyCorrelationId(): Promise<void> {
if (!this.correlationId) {
return;
}
try {
await navigator.clipboard.writeText(this.correlationId);
this.copied.set(true);
setTimeout(() => this.copied.set(false), 1500);
} catch {
this.copied.set(false);
}
}
formatTime(value: string): string {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) {
return value;
}
return parsed.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
}

View File

@@ -0,0 +1,117 @@
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, Routes, provideRouter } from '@angular/router';
import { PlatformContextStore } from '../../app/core/context/platform-context.store';
import { PlatformContextUrlSyncService } from '../../app/core/context/platform-context-url-sync.service';
@Component({
standalone: true,
template: '',
})
class DummyComponent {}
async function settleRouter(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function waitForCondition(predicate: () => boolean): Promise<void> {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (predicate()) {
return;
}
await settleRouter();
}
}
describe('PlatformContextUrlSyncService', () => {
let router: Router;
let service: PlatformContextUrlSyncService;
let contextStore: {
initialize: jasmine.Spy;
initialized: ReturnType<typeof signal<boolean>>;
contextVersion: ReturnType<typeof signal<number>>;
scopeQueryPatch: jasmine.Spy;
applyScopeQueryParams: jasmine.Spy;
};
beforeEach(async () => {
contextStore = {
initialize: jasmine.createSpy('initialize'),
initialized: signal(true),
contextVersion: signal(0),
scopeQueryPatch: jasmine.createSpy('scopeQueryPatch').and.returnValue({
regions: 'us-east',
environments: 'prod',
timeWindow: '7d',
}),
applyScopeQueryParams: jasmine.createSpy('applyScopeQueryParams'),
};
const routes: Routes = [
{ path: '', component: DummyComponent },
{ path: 'dashboard', component: DummyComponent },
{ path: 'security', component: DummyComponent },
{ path: 'setup', component: DummyComponent },
{ path: '**', component: DummyComponent },
];
await TestBed.configureTestingModule({
imports: [DummyComponent],
providers: [
provideRouter(routes),
{ provide: PlatformContextStore, useValue: contextStore },
],
}).compileComponents();
router = TestBed.inject(Router);
service = TestBed.inject(PlatformContextUrlSyncService);
service.initialize();
router.initialNavigation();
});
it('hydrates scope from URL query parameters on scope-managed routes', async () => {
await router.navigateByUrl('/security?regions=eu-west&environments=stage&timeWindow=30d');
await settleRouter();
expect(contextStore.applyScopeQueryParams).toHaveBeenCalled();
const latestCall = contextStore.applyScopeQueryParams.calls.mostRecent();
expect(latestCall).toBeDefined();
if (!latestCall) {
return;
}
expect(latestCall.args[0]).toEqual(
jasmine.objectContaining({
regions: 'eu-west',
environments: 'stage',
timeWindow: '30d',
}),
);
});
it('persists scope query parameters to URL when context changes', async () => {
await router.navigateByUrl('/dashboard');
await settleRouter();
contextStore.contextVersion.update((value) => value + 1);
await waitForCondition(() => router.url.includes('regions=us-east'));
expect(router.url).toContain('/dashboard');
expect(router.url).toContain('regions=us-east');
expect(router.url).toContain('environments=prod');
expect(router.url).toContain('timeWindow=7d');
});
it('skips setup route from scope sync management', async () => {
await router.navigateByUrl('/setup?regions=us-east');
await settleRouter();
contextStore.applyScopeQueryParams.calls.reset();
contextStore.contextVersion.update((value) => value + 1);
await settleRouter();
expect(contextStore.applyScopeQueryParams).not.toHaveBeenCalled();
expect(router.url).toBe('/setup?regions=us-east');
});
});

View File

@@ -0,0 +1,121 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { DoctorChecksInlineComponent } from '../../app/features/doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import { DoctorStore } from '../../app/features/doctor/services/doctor.store';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
import { CheckResult, DoctorReport } from '../../app/features/doctor/models/doctor.models';
function buildMockReport(): DoctorReport {
return {
runId: 'run-1',
status: 'completed',
startedAt: '2026-02-20T10:00:00Z',
completedAt: '2026-02-20T10:01:00Z',
durationMs: 60000,
overallSeverity: 'warn',
summary: { passed: 2, info: 0, warnings: 1, failed: 1, skipped: 0, total: 4 },
results: [
{ checkId: 'check.security.tls', pluginId: 'security-tls', severity: 'pass', diagnosis: 'TLS OK', category: 'security', durationMs: 100, executedAt: '2026-02-20T10:00:01Z', likelyCauses: [], evidence: { description: '', data: {} } } as CheckResult,
{ checkId: 'check.security.certs', pluginId: 'security-certs', severity: 'warn', diagnosis: 'Cert expiring', category: 'security', durationMs: 200, executedAt: '2026-02-20T10:00:02Z', likelyCauses: [], evidence: { description: '', data: {} } } as CheckResult,
{ checkId: 'check.core.config', pluginId: 'core-config', severity: 'fail', diagnosis: 'Config missing', category: 'core', durationMs: 50, executedAt: '2026-02-20T10:00:03Z', likelyCauses: [], evidence: { description: '', data: {} } } as CheckResult,
{ checkId: 'check.core.db', pluginId: 'core-db', severity: 'pass', diagnosis: 'DB OK', category: 'core', durationMs: 150, executedAt: '2026-02-20T10:00:04Z', likelyCauses: [], evidence: { description: '', data: {} } } as CheckResult,
],
};
}
const mockDoctorApi = {
listChecks: () => ({ subscribe: () => {} }),
listPlugins: () => ({ subscribe: () => {} }),
startRun: () => ({ subscribe: () => {} }),
getRunResult: () => ({ subscribe: () => {} }),
streamRunProgress: () => ({ subscribe: () => {} }),
listReports: () => ({ subscribe: () => {} }),
deleteReport: () => ({ subscribe: () => {} }),
};
describe('DoctorChecksInlineComponent', () => {
let fixture: ComponentFixture<DoctorChecksInlineComponent>;
let component: DoctorChecksInlineComponent;
let store: DoctorStore;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DoctorChecksInlineComponent],
providers: [
provideRouter([]),
DoctorStore,
{ provide: DOCTOR_API, useValue: mockDoctorApi },
],
}).compileComponents();
store = TestBed.inject(DoctorStore);
fixture = TestBed.createComponent(DoctorChecksInlineComponent);
component = fixture.componentInstance;
component.category = 'security';
fixture.detectChanges();
});
it('should create the component', () => {
expect(component).toBeTruthy();
});
it('should show zero counts when no report is loaded', () => {
const summary = component.summary();
expect(summary.pass).toBe(0);
expect(summary.warn).toBe(0);
expect(summary.fail).toBe(0);
expect(summary.total).toBe(0);
});
it('should show correct summary counts for the category', () => {
const report = buildMockReport();
(store as any).reportSignal.set(report);
fixture.detectChanges();
const summary = component.summary();
expect(summary.pass).toBe(1);
expect(summary.warn).toBe(1);
expect(summary.fail).toBe(0);
expect(summary.total).toBe(2);
});
it('should filter results by category', () => {
const report = buildMockReport();
(store as any).reportSignal.set(report);
fixture.detectChanges();
const results = component.results();
expect(results.length).toBe(2);
expect(results.every((r) => r.category === 'security')).toBeTrue();
});
it('should toggle expanded state on toggle()', () => {
expect(component.expanded).toBeFalse();
component.toggle();
expect(component.expanded).toBeTrue();
component.toggle();
expect(component.expanded).toBeFalse();
});
it('should limit visible results to maxResults', () => {
component.maxResults = 1;
const report = buildMockReport();
(store as any).reportSignal.set(report);
fixture.detectChanges();
expect(component.visibleResults().length).toBe(1);
});
it('should show results for core category when category is core', () => {
component.category = 'core';
const report = buildMockReport();
(store as any).reportSignal.set(report);
fixture.detectChanges();
const summary = component.summary();
expect(summary.pass).toBe(1);
expect(summary.fail).toBe(1);
expect(summary.total).toBe(2);
});
});

View File

@@ -0,0 +1,119 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { DoctorNotificationService } from '../../app/core/doctor/doctor-notification.service';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
import { ToastService } from '../../app/core/services/toast.service';
describe('DoctorNotificationService', () => {
let service: DoctorNotificationService;
let toastService: ToastService;
let mockApi: any;
beforeEach(() => {
vi.useFakeTimers();
// Clear localStorage
localStorage.removeItem('stellaops_doctor_last_seen_report');
localStorage.removeItem('stellaops_doctor_notifications_muted');
mockApi = {
listChecks: () => of({ checks: [], total: 0 }),
listPlugins: () => of({ plugins: [], total: 0 }),
startRun: () => of({ runId: 'test' }),
getRunResult: () => of({}),
streamRunProgress: () => of(),
listReports: vi.fn().mockReturnValue(of({ reports: [], total: 0 })),
deleteReport: () => of(),
};
TestBed.configureTestingModule({
providers: [
provideRouter([]),
DoctorNotificationService,
ToastService,
{ provide: DOCTOR_API, useValue: mockApi },
],
});
service = TestBed.inject(DoctorNotificationService);
toastService = TestBed.inject(ToastService);
});
afterEach(() => {
vi.useRealTimers();
localStorage.removeItem('stellaops_doctor_last_seen_report');
localStorage.removeItem('stellaops_doctor_notifications_muted');
});
it('should start unmuted by default', () => {
expect(service.muted()).toBeFalse();
});
it('should toggle mute state', () => {
service.toggleMute();
expect(service.muted()).toBeTrue();
expect(localStorage.getItem('stellaops_doctor_notifications_muted')).toBe('true');
service.toggleMute();
expect(service.muted()).toBeFalse();
});
it('should not show toast when no reports exist', () => {
spyOn(toastService, 'show');
service.start();
vi.advanceTimersByTime(10000);
expect(mockApi.listReports).toHaveBeenCalled();
expect(toastService.show).not.toHaveBeenCalled();
});
it('should show toast when new report has failures', () => {
const report = {
runId: 'run-new',
summary: { passed: 3, info: 0, warnings: 0, failed: 2, skipped: 0, total: 5 },
};
mockApi.listReports.mockReturnValue(of({ reports: [report], total: 1 }));
spyOn(toastService, 'show');
service.start();
vi.advanceTimersByTime(10000);
expect(toastService.show).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'error',
title: 'Doctor Run Complete',
})
);
});
it('should not show toast for same report twice', () => {
const report = {
runId: 'run-1',
summary: { passed: 3, info: 0, warnings: 1, failed: 0, skipped: 0, total: 4 },
};
mockApi.listReports.mockReturnValue(of({ reports: [report], total: 1 }));
localStorage.setItem('stellaops_doctor_last_seen_report', 'run-1');
spyOn(toastService, 'show');
service.start();
vi.advanceTimersByTime(10000);
expect(toastService.show).not.toHaveBeenCalled();
});
it('should not show toast for passing reports', () => {
const report = {
runId: 'run-pass',
summary: { passed: 5, info: 0, warnings: 0, failed: 0, skipped: 0, total: 5 },
};
mockApi.listReports.mockReturnValue(of({ reports: [report], total: 1 }));
spyOn(toastService, 'show');
service.start();
vi.advanceTimersByTime(10000);
expect(toastService.show).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,107 @@
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { DoctorQuickCheckService } from '../../app/features/doctor/services/doctor-quick-check.service';
import { DoctorStore } from '../../app/features/doctor/services/doctor.store';
import { ToastService } from '../../app/core/services/toast.service';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
import { of } from 'rxjs';
describe('DoctorQuickCheckService', () => {
let service: DoctorQuickCheckService;
let store: DoctorStore;
let toastService: ToastService;
let router: Router;
let mockApi: any;
beforeEach(() => {
mockApi = {
listChecks: () => of({ checks: [], total: 0 }),
listPlugins: () => of({ plugins: [], total: 0 }),
startRun: () => of({ runId: 'quick-run-1' }),
getRunResult: () => of({}),
streamRunProgress: () => of(),
listReports: () => of({ reports: [], total: 0 }),
deleteReport: () => of(),
};
TestBed.configureTestingModule({
providers: [
provideRouter([]),
DoctorQuickCheckService,
DoctorStore,
ToastService,
{ provide: DOCTOR_API, useValue: mockApi },
],
});
service = TestBed.inject(DoctorQuickCheckService);
store = TestBed.inject(DoctorStore);
toastService = TestBed.inject(ToastService);
router = TestBed.inject(Router);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should return two quick actions', () => {
const actions = service.getQuickActions();
expect(actions.length).toBe(2);
expect(actions[0].id).toBe('doctor-quick');
expect(actions[1].id).toBe('doctor-full');
});
it('should have bound action callbacks on quick actions', () => {
const actions = service.getQuickActions();
expect(actions[0].action).toBeDefined();
expect(actions[1].action).toBeDefined();
});
it('should show progress toast on runQuickCheck', () => {
spyOn(toastService, 'show').and.returnValue('toast-1');
spyOn(store, 'startRun');
service.runQuickCheck();
expect(toastService.show).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'info',
title: 'Running Quick Health Check...',
duration: 0,
})
);
expect(store.startRun).toHaveBeenCalledWith(
jasmine.objectContaining({ mode: 'quick', includeRemediation: true })
);
});
it('should start full run and navigate on runFullDiagnostics', () => {
spyOn(store, 'startRun');
spyOn(router, 'navigate');
service.runFullDiagnostics();
expect(store.startRun).toHaveBeenCalledWith(
jasmine.objectContaining({ mode: 'full', includeRemediation: true })
);
expect(router.navigate).toHaveBeenCalledWith(['/platform/ops/doctor']);
});
it('quick actions should have correct keywords', () => {
const actions = service.getQuickActions();
const quickAction = actions.find((a) => a.id === 'doctor-quick')!;
const fullAction = actions.find((a) => a.id === 'doctor-full')!;
expect(quickAction.keywords).toContain('doctor');
expect(quickAction.keywords).toContain('health');
expect(fullAction.keywords).toContain('diagnostics');
expect(fullAction.keywords).toContain('comprehensive');
});
it('quick actions should have correct shortcuts', () => {
const actions = service.getQuickActions();
expect(actions[0].shortcut).toBe('>doctor');
expect(actions[1].shortcut).toBe('>diagnostics');
});
});

View File

@@ -0,0 +1,122 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { of } from 'rxjs';
import { DoctorRecheckService } from '../../app/features/doctor/services/doctor-recheck.service';
import { DoctorStore } from '../../app/features/doctor/services/doctor.store';
import { ToastService } from '../../app/core/services/toast.service';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
describe('DoctorRecheckService', () => {
let service: DoctorRecheckService;
let store: DoctorStore;
let toastService: ToastService;
let router: Router;
let mockApi: any;
beforeEach(() => {
mockApi = {
listChecks: () => of({ checks: [], total: 0 }),
listPlugins: () => of({ plugins: [], total: 0 }),
startRun: () => of({ runId: 'recheck-run-1' }),
getRunResult: () => of({}),
streamRunProgress: () => of(),
listReports: () => of({ reports: [], total: 0 }),
deleteReport: () => of(),
};
TestBed.configureTestingModule({
providers: [
provideRouter([]),
DoctorRecheckService,
DoctorStore,
ToastService,
{ provide: DOCTOR_API, useValue: mockApi },
],
});
service = TestBed.inject(DoctorRecheckService);
store = TestBed.inject(DoctorStore);
toastService = TestBed.inject(ToastService);
router = TestBed.inject(Router);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('recheckForStep', () => {
it('should start a run with checkIds for the given step', () => {
spyOn(toastService, 'show').and.returnValue('toast-1');
spyOn(store, 'startRun');
service.recheckForStep('database');
expect(store.startRun).toHaveBeenCalledWith(
jasmine.objectContaining({
mode: 'quick',
includeRemediation: true,
checkIds: ['check.database.connectivity', 'check.database.migrations'],
})
);
});
it('should show progress toast', () => {
spyOn(toastService, 'show').and.returnValue('toast-1');
spyOn(store, 'startRun');
service.recheckForStep('cache');
expect(toastService.show).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'info',
title: 'Running Re-check...',
duration: 0,
})
);
});
it('should not start a run for welcome step (no checks)', () => {
spyOn(store, 'startRun');
service.recheckForStep('welcome');
expect(store.startRun).not.toHaveBeenCalled();
});
});
describe('offerRecheck', () => {
it('should show success toast with re-check action', () => {
spyOn(toastService, 'show').and.returnValue('toast-1');
service.offerRecheck('database', 'Database');
expect(toastService.show).toHaveBeenCalledWith(
jasmine.objectContaining({
type: 'success',
title: 'Database configured successfully',
duration: 10000,
action: jasmine.objectContaining({ label: 'Run Re-check' }),
})
);
});
it('should trigger recheckForStep when action is clicked', () => {
let capturedAction: any;
spyOn(toastService, 'show').and.callFake((opts: any) => {
capturedAction = opts.action;
return 'toast-1';
});
spyOn(store, 'startRun');
service.offerRecheck('authority', 'Authority');
capturedAction.onClick();
expect(store.startRun).toHaveBeenCalledWith(
jasmine.objectContaining({
checkIds: ['check.authority.plugin.configured', 'check.authority.plugin.connectivity'],
})
);
});
});
});

View File

@@ -0,0 +1,77 @@
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { DoctorTrendService } from '../../app/core/doctor/doctor-trend.service';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
import { DoctorTrendResponse } from '../../app/core/doctor/doctor-trend.models';
describe('DoctorTrendService', () => {
let service: DoctorTrendService;
let mockApi: any;
const mockTrends: DoctorTrendResponse[] = [
{
category: 'security',
points: [
{ timestamp: '2026-02-20T09:00:00Z', score: 80 },
{ timestamp: '2026-02-20T10:00:00Z', score: 85 },
{ timestamp: '2026-02-20T11:00:00Z', score: 90 },
],
},
{
category: 'platform',
points: [
{ timestamp: '2026-02-20T09:00:00Z', score: 70 },
{ timestamp: '2026-02-20T10:00:00Z', score: 75 },
],
},
];
beforeEach(() => {
mockApi = {
listChecks: () => of({ checks: [], total: 0 }),
listPlugins: () => of({ plugins: [], total: 0 }),
startRun: () => of({ runId: 'test' }),
getRunResult: () => of({}),
streamRunProgress: () => of(),
listReports: () => of({ reports: [], total: 0 }),
deleteReport: () => of(),
getTrends: jasmine.createSpy('getTrends').and.returnValue(of(mockTrends)),
};
TestBed.configureTestingModule({
providers: [
DoctorTrendService,
{ provide: DOCTOR_API, useValue: mockApi },
],
});
service = TestBed.inject(DoctorTrendService);
});
it('should initialize with empty trends', () => {
expect(service.securityTrend()).toEqual([]);
expect(service.platformTrend()).toEqual([]);
});
it('should populate trends on refresh()', () => {
service.refresh();
expect(mockApi.getTrends).toHaveBeenCalledWith(['security', 'platform'], 12);
expect(service.securityTrend()).toEqual([80, 85, 90]);
expect(service.platformTrend()).toEqual([70, 75]);
});
it('should clear trends on error', () => {
// First populate
service.refresh();
expect(service.securityTrend().length).toBe(3);
// Now error
mockApi.getTrends.and.returnValue(throwError(() => new Error('Network error')));
service.refresh();
expect(service.securityTrend()).toEqual([]);
expect(service.platformTrend()).toEqual([]);
});
});

View File

@@ -0,0 +1,106 @@
import {
DOCTOR_WIZARD_MAPPINGS,
getWizardStepForCheck,
getCheckIdsForStep,
buildWizardDeepLink,
} from '../../app/features/doctor/models/doctor-wizard-mapping';
describe('DoctorWizardMapping', () => {
describe('DOCTOR_WIZARD_MAPPINGS', () => {
it('should contain mappings for all infrastructure steps', () => {
const stepIds = new Set(DOCTOR_WIZARD_MAPPINGS.map((m) => m.stepId));
expect(stepIds.has('database')).toBeTrue();
expect(stepIds.has('cache')).toBeTrue();
expect(stepIds.has('migrations')).toBeTrue();
expect(stepIds.has('authority')).toBeTrue();
expect(stepIds.has('users')).toBeTrue();
});
it('should contain mappings for integration steps', () => {
const stepIds = new Set(DOCTOR_WIZARD_MAPPINGS.map((m) => m.stepId));
expect(stepIds.has('vault')).toBeTrue();
expect(stepIds.has('registry')).toBeTrue();
expect(stepIds.has('scm')).toBeTrue();
expect(stepIds.has('sources')).toBeTrue();
expect(stepIds.has('notify')).toBeTrue();
});
it('should not contain welcome step', () => {
const stepIds = new Set(DOCTOR_WIZARD_MAPPINGS.map((m) => m.stepId));
expect(stepIds.has('welcome')).toBeFalse();
});
it('should have unique check IDs', () => {
const ids = DOCTOR_WIZARD_MAPPINGS.map((m) => m.checkId);
expect(new Set(ids).size).toBe(ids.length);
});
});
describe('getWizardStepForCheck', () => {
it('should return mapping for known check', () => {
const result = getWizardStepForCheck('check.database.connectivity');
expect(result).toBeDefined();
expect(result!.stepId).toBe('database');
expect(result!.label).toBe('Database Connectivity');
});
it('should return undefined for unknown check', () => {
expect(getWizardStepForCheck('check.nonexistent')).toBeUndefined();
});
it('should return correct step for vault auth check', () => {
const result = getWizardStepForCheck('check.integration.vault.auth');
expect(result).toBeDefined();
expect(result!.stepId).toBe('vault');
});
it('should return correct step for telemetry check', () => {
const result = getWizardStepForCheck('check.telemetry.otlp.connectivity');
expect(result).toBeDefined();
expect(result!.stepId).toBe('telemetry');
});
});
describe('getCheckIdsForStep', () => {
it('should return check IDs for database step', () => {
const ids = getCheckIdsForStep('database');
expect(ids).toContain('check.database.connectivity');
expect(ids).toContain('check.database.migrations');
expect(ids.length).toBe(2);
});
it('should return check IDs for llm step', () => {
const ids = getCheckIdsForStep('llm');
expect(ids).toContain('check.ai.llm.config');
expect(ids).toContain('check.ai.provider.openai');
expect(ids).toContain('check.ai.provider.claude');
expect(ids).toContain('check.ai.provider.gemini');
expect(ids.length).toBe(4);
});
it('should return empty array for welcome step', () => {
const ids = getCheckIdsForStep('welcome');
expect(ids.length).toBe(0);
});
it('should return single check for telemetry step', () => {
const ids = getCheckIdsForStep('telemetry');
expect(ids.length).toBe(1);
expect(ids[0]).toBe('check.telemetry.otlp.connectivity');
});
});
describe('buildWizardDeepLink', () => {
it('should build correct deep link for database', () => {
expect(buildWizardDeepLink('database')).toBe('/setup/wizard?step=database&mode=reconfigure');
});
it('should build correct deep link for authority', () => {
expect(buildWizardDeepLink('authority')).toBe('/setup/wizard?step=authority&mode=reconfigure');
});
it('should build correct deep link for telemetry', () => {
expect(buildWizardDeepLink('telemetry')).toBe('/setup/wizard?step=telemetry&mode=reconfigure');
});
});
});

View File

@@ -1,21 +1,58 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { of } from 'rxjs';
import { GlobalSearchComponent, SearchResult } from '../../app/layout/global-search/global-search.component';
import { SearchClient } from '../../app/core/api/search.client';
import {
GlobalSearchComponent,
SearchResult,
} from '../../app/layout/global-search/global-search.component';
describe('GlobalSearchComponent', () => {
let fixture: ComponentFixture<GlobalSearchComponent>;
let component: GlobalSearchComponent;
let router: { navigate: jasmine.Spy };
let router: { navigateByUrl: jasmine.Spy };
let searchClient: { search: jasmine.Spy };
beforeEach(async () => {
router = {
navigate: jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)),
navigateByUrl: jasmine.createSpy('navigateByUrl').and.returnValue(Promise.resolve(true)),
};
searchClient = {
search: jasmine.createSpy('search').and.returnValue(
of({
query: 'CVE-2026',
groups: [
{
type: 'cve',
label: 'CVEs',
totalCount: 1,
hasMore: false,
results: [
{
id: 'cve-1',
type: 'cve',
title: 'CVE-2026-12345',
subtitle: 'Critical',
route: '/security/triage?cve=CVE-2026-12345',
matchScore: 100,
},
],
},
],
totalCount: 1,
durationMs: 4,
}),
),
};
await TestBed.configureTestingModule({
imports: [GlobalSearchComponent],
providers: [{ provide: Router, useValue: router }],
providers: [
{ provide: Router, useValue: router },
{ provide: SearchClient, useValue: searchClient },
],
}).compileComponents();
localStorage.clear();
@@ -28,43 +65,39 @@ describe('GlobalSearchComponent', () => {
localStorage.clear();
});
async function waitForDebounce(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 240));
}
it('renders the global search input and shortcut hint', () => {
const text = fixture.nativeElement.textContent as string;
const input = fixture.nativeElement.querySelector('input[aria-label="Global search"]') as HTMLInputElement;
expect(input).toBeTruthy();
expect(input.placeholder).toContain('Search releases');
expect(input.placeholder).toContain('Search runs');
expect(text).toContain('K');
});
it('produces categorized results for matching query terms', async () => {
component.query.set('CVE-2026');
component.onSearch();
expect(component.isLoading()).toBeTrue();
await new Promise((resolve) => setTimeout(resolve, 220));
it('queries SearchClient and renders grouped results', async () => {
component.onFocus();
component.onQueryChange('CVE-2026');
await waitForDebounce();
fixture.detectChanges();
expect(component.isLoading()).toBeFalse();
expect(component.results().length).toBeGreaterThan(0);
expect(component.results().some((result) => result.type === 'cve')).toBeTrue();
expect(component.groupedResults().some((group) => group.type === 'cve')).toBeTrue();
expect(searchClient.search).toHaveBeenCalledWith('CVE-2026');
expect(component.groupedResults().length).toBe(1);
expect(component.groupedResults()[0].type).toBe('cve');
expect(component.flatResults().length).toBe(1);
});
it('clears results when the query is shorter than two characters', () => {
component.results.set([
{
id: 'existing',
type: 'release',
label: 'v1.0.0',
route: '/releases/v1.0.0',
},
]);
it('does not query API for terms shorter than two characters', async () => {
component.onFocus();
component.onQueryChange('a');
await waitForDebounce();
fixture.detectChanges();
component.query.set('a');
component.onSearch();
expect(component.results()).toEqual([]);
expect(searchClient.search).not.toHaveBeenCalled();
expect(component.searchResponse()).toBeNull();
});
it('navigates to selected result and persists recent search', () => {
@@ -72,14 +105,15 @@ describe('GlobalSearchComponent', () => {
const result: SearchResult = {
id: 'cve-1',
type: 'cve',
label: 'CVE-2026-12345',
sublabel: 'Critical',
route: '/security/vulnerabilities/CVE-2026-12345',
title: 'CVE-2026-12345',
subtitle: 'Critical',
route: '/security/triage?cve=CVE-2026-12345',
matchScore: 100,
};
component.onSelect(result);
expect(router.navigate).toHaveBeenCalledWith(['/security/vulnerabilities/CVE-2026-12345']);
expect(router.navigateByUrl).toHaveBeenCalledWith('/security/triage?cve=CVE-2026-12345');
const stored = JSON.parse(localStorage.getItem('stella-recent-searches') ?? '[]') as string[];
expect(stored[0]).toBe('CVE-2026');
});

View File

@@ -0,0 +1,65 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { SidebarSparklineComponent } from '../../app/layout/app-sidebar/sidebar-sparkline.component';
describe('SidebarSparklineComponent', () => {
let fixture: ComponentFixture<SidebarSparklineComponent>;
let component: SidebarSparklineComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SidebarSparklineComponent],
}).compileComponents();
fixture = TestBed.createComponent(SidebarSparklineComponent);
component = fixture.componentInstance;
});
it('should render nothing when points has fewer than 2 items', () => {
component.points = [50];
fixture.detectChanges();
const svg = fixture.nativeElement.querySelector('svg');
expect(svg).toBeNull();
});
it('should render nothing when points is empty', () => {
component.points = [];
fixture.detectChanges();
const svg = fixture.nativeElement.querySelector('svg');
expect(svg).toBeNull();
});
it('should render SVG when points has 2 or more items', () => {
component.points = [50, 75, 60, 90];
fixture.detectChanges();
const svg = fixture.nativeElement.querySelector('svg');
expect(svg).toBeTruthy();
expect(svg.getAttribute('width')).toBe('40');
expect(svg.getAttribute('height')).toBe('16');
});
it('should generate correct polyline points', () => {
component.points = [0, 100];
fixture.detectChanges();
const polyline = fixture.nativeElement.querySelector('polyline');
expect(polyline).toBeTruthy();
const pointsAttr = polyline.getAttribute('points');
expect(pointsAttr).toBeTruthy();
// First point should be at x=0, last at x=40
expect(pointsAttr).toContain('0.0');
expect(pointsAttr).toContain('40.0');
});
it('should handle flat data (all same values)', () => {
component.points = [50, 50, 50];
fixture.detectChanges();
const polyline = fixture.nativeElement.querySelector('polyline');
expect(polyline).toBeTruthy();
});
});

View File

@@ -0,0 +1,87 @@
import { TestBed } from '@angular/core/testing';
import { Router, Routes, provideRouter } from '@angular/router';
import { Component } from '@angular/core';
import { AUTH_SERVICE } from '../../app/core/auth';
import { LegacyRouteTelemetryService } from '../../app/core/guards/legacy-route-telemetry.service';
import { TelemetryClient } from '../../app/core/telemetry/telemetry.client';
import {
LEGACY_REDIRECT_ROUTES,
LEGACY_REDIRECT_ROUTE_TEMPLATES,
} from '../../app/routes/legacy-redirects.routes';
@Component({
standalone: true,
template: '',
})
class DummyComponent {}
describe('LegacyRouteTelemetryService', () => {
let service: LegacyRouteTelemetryService;
let router: Router;
let telemetry: { emit: jasmine.Spy };
beforeEach(async () => {
telemetry = {
emit: jasmine.createSpy('emit'),
};
const routes: Routes = [
...LEGACY_REDIRECT_ROUTES,
{ path: 'platform/ops/health-slo', component: DummyComponent },
{ path: 'security/triage', component: DummyComponent },
{ path: 'topology/regions', component: DummyComponent },
{ path: '**', component: DummyComponent },
];
await TestBed.configureTestingModule({
imports: [DummyComponent],
providers: [
provideRouter(routes),
{ provide: TelemetryClient, useValue: telemetry },
{
provide: AUTH_SERVICE,
useValue: {
user: () => ({ id: 'user-1', tenantId: 'tenant-1' }),
},
},
],
}).compileComponents();
router = TestBed.inject(Router);
service = TestBed.inject(LegacyRouteTelemetryService);
service.initialize();
router.initialNavigation();
});
it('tracks route map size from canonical legacy redirect templates', () => {
expect(service.getLegacyRouteCount()).toBe(LEGACY_REDIRECT_ROUTE_TEMPLATES.length);
});
it('emits legacy_route_hit telemetry for redirecting legacy URLs', async () => {
await router.navigateByUrl('/ops/health?tab=slo');
expect(telemetry.emit).toHaveBeenCalledTimes(1);
expect(telemetry.emit).toHaveBeenCalledWith(
'legacy_route_hit',
jasmine.objectContaining({
oldPath: '/ops/health',
newPath: '/platform/ops/health-slo',
tenantId: 'tenant-1',
userId: 'user-1',
}),
);
expect(service.currentLegacyRoute()).toEqual(
jasmine.objectContaining({
oldPath: '/ops/health',
newPath: '/platform/ops/health-slo',
}),
);
});
it('does not emit telemetry for canonical URLs', async () => {
await router.navigateByUrl('/platform/ops/health-slo');
expect(telemetry.emit).not.toHaveBeenCalled();
});
});

View File

@@ -10,6 +10,7 @@ import { of } from 'rxjs';
import { AppSidebarComponent } from '../../app/layout/app-sidebar/app-sidebar.component';
import { AUTH_SERVICE } from '../../app/core/auth';
import { APPROVAL_API } from '../../app/core/api/approval.client';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
const CANONICAL_DOMAIN_IDS = [
'dashboard',
@@ -30,7 +31,7 @@ const CANONICAL_DOMAIN_ROUTES = [
] as const;
const EXPECTED_SECTION_LABELS: Record<string, string> = {
dashboard: 'Dashboard',
dashboard: 'Mission Control',
releases: 'Releases',
security: 'Security',
evidence: 'Evidence',
@@ -75,6 +76,7 @@ describe('AppSidebarComponent nav model (navigation)', () => {
provideRouter([]),
{ provide: AUTH_SERVICE, useValue: authSpy },
{ provide: APPROVAL_API, useValue: approvalApiSpy },
{ provide: DOCTOR_API, useValue: {} },
],
}).compileComponents();
@@ -127,7 +129,7 @@ describe('AppSidebarComponent nav model (navigation)', () => {
const capsules = evidence.children?.find((child) => child.id === 'ev-capsules');
const verify = evidence.children?.find((child) => child.id === 'ev-verify');
expect(capsules?.route).toBe('/evidence/capsules');
expect(verify?.route).toBe('/evidence/verify-replay');
expect(verify?.route).toBe('/evidence/verification/replay');
});
it('Platform group owns ops/integrations/setup shortcuts', () => {

View File

@@ -11,6 +11,7 @@ import { SECURITY_ROUTES } from '../../app/routes/security.routes';
import { TOPOLOGY_ROUTES } from '../../app/routes/topology.routes';
import { integrationHubRoutes } from '../../app/features/integration-hub/integration-hub.routes';
import { PLATFORM_SETUP_ROUTES } from '../../app/features/platform/setup/platform-setup.routes';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
function joinPath(prefix: string, path: string | undefined): string | null {
if (path === undefined) return null;
@@ -50,6 +51,7 @@ describe('AppSidebarComponent route integrity (navigation)', () => {
providers: [
provideRouter([]),
{ provide: AUTH_SERVICE, useValue: authSpy },
{ provide: DOCTOR_API, useValue: {} },
],
}).compileComponents();
@@ -74,7 +76,7 @@ describe('AppSidebarComponent route integrity (navigation)', () => {
for (const path of collectConcretePaths('/platform/ops', OPERATIONS_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/platform/integrations', integrationHubRoutes)) allowed.add(path);
for (const path of collectConcretePaths('/platform/setup', PLATFORM_SETUP_ROUTES)) allowed.add(path);
allowed.add('/security/supply-chain-data/lake');
allowed.add('/security/sbom/lake');
for (const section of component.navSections) {
expect(allowed.has(section.route)).toBeTrue();
@@ -93,17 +95,18 @@ describe('AppSidebarComponent route integrity (navigation)', () => {
for (const path of collectConcretePaths('/platform/ops', OPERATIONS_ROUTES)) allowed.add(path);
for (const path of collectConcretePaths('/platform/integrations', integrationHubRoutes)) allowed.add(path);
for (const path of collectConcretePaths('/platform/setup', PLATFORM_SETUP_ROUTES)) allowed.add(path);
allowed.add('/security/supply-chain-data/lake');
allowed.add('/security/sbom/lake');
const required = [
'/releases/versions',
'/releases/runs',
'/security/triage',
'/security/advisories-vex',
'/security/supply-chain-data/lake',
'/security/disposition',
'/security/sbom/lake',
'/evidence/capsules',
'/evidence/verify-replay',
'/evidence/verification/replay',
'/topology/agents',
'/topology/promotion-graph',
'/platform/ops/jobs-queues',
'/platform/ops/feeds-airgap',
'/platform/integrations/runtime-hosts',

View File

@@ -0,0 +1,136 @@
import { HttpClient } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
import { BehaviorSubject, of } from 'rxjs';
import { signal } from '@angular/core';
import { PlatformContextStore } from '../../app/core/context/platform-context.store';
import { ReleaseDetailComponent } from '../../app/features/release-orchestrator/releases/release-detail/release-detail.component';
import { ReleaseManagementStore } from '../../app/features/release-orchestrator/releases/release.store';
describe('ReleaseDetailComponent live refresh contract', () => {
let component: ReleaseDetailComponent;
beforeEach(async () => {
const routeData$ = new BehaviorSubject({ semanticObject: 'run' });
const paramMap$ = new BehaviorSubject(convertToParamMap({}));
const contextStore = {
initialize: jasmine.createSpy('initialize'),
contextVersion: signal(0),
selectedRegions: signal<string[]>([]),
selectedEnvironments: signal<string[]>([]),
};
const releaseStore = {
selectRelease: jasmine.createSpy('selectRelease'),
selectedRelease: signal(null),
};
await TestBed.configureTestingModule({
imports: [ReleaseDetailComponent],
providers: [
provideRouter([]),
{
provide: ActivatedRoute,
useValue: {
data: routeData$.asObservable(),
paramMap: paramMap$.asObservable(),
},
},
{ provide: HttpClient, useValue: { get: jasmine.createSpy('get').and.returnValue(of(null)) } },
{ provide: PlatformContextStore, useValue: contextStore },
{ provide: ReleaseManagementStore, useValue: releaseStore },
],
}).compileComponents();
component = TestBed.createComponent(ReleaseDetailComponent).componentInstance;
});
it('marks terminal run states correctly', () => {
component.runDetail.set({
runId: 'run-1',
releaseId: 'rel-1',
releaseName: 'api',
releaseSlug: 'api',
releaseType: 'standard',
releaseVersionId: 'ver-1',
releaseVersionNumber: 1,
releaseVersionDigest: 'sha256:abc',
lane: 'standard',
status: 'running',
outcome: 'in_progress',
targetEnvironment: 'stage',
targetRegion: 'us-east',
scopeSummary: 'stage->prod',
requestedAt: '2026-02-20T10:00:00Z',
updatedAt: '2026-02-20T10:01:00Z',
needsApproval: false,
blockedByDataIntegrity: false,
correlationKey: 'corr-1',
statusRow: {
runStatus: 'running',
gateStatus: 'pass',
approvalStatus: 'approved',
dataTrustStatus: 'healthy',
},
});
expect(component.runIsTerminal()).toBeFalse();
component.runDetail.update((run) => ({
...run!,
status: 'completed',
outcome: 'deployed',
}));
expect(component.runIsTerminal()).toBeTrue();
});
it('derives degraded run impact with blocking severity when sync failures occur', () => {
component.runDetail.set({
runId: 'run-2',
releaseId: 'rel-2',
releaseName: 'billing',
releaseSlug: 'billing',
releaseType: 'hotfix',
releaseVersionId: 'ver-2',
releaseVersionNumber: 2,
releaseVersionDigest: 'sha256:def',
lane: 'hotfix',
status: 'running',
outcome: 'in_progress',
targetEnvironment: 'prod',
targetRegion: 'eu-west',
scopeSummary: 'stage->prod',
requestedAt: '2026-02-20T11:00:00Z',
updatedAt: '2026-02-20T11:01:00Z',
needsApproval: true,
blockedByDataIntegrity: true,
correlationKey: 'corr-2',
statusRow: {
runStatus: 'running',
gateStatus: 'block',
approvalStatus: 'pending',
dataTrustStatus: 'stale',
},
});
component.runGateDecision.set({
runId: 'run-2',
verdict: 'block',
blockers: ['stale-feeds'],
riskBudgetDelta: 42,
});
component.syncError.set('Live refresh failed');
component.syncFailureCount.set(2);
expect(component.liveSyncStatus()).toBe('DEGRADED');
expect(component.runSyncImpact()).toEqual(
jasmine.objectContaining({
impact: 'BLOCKING',
correlationId: 'corr-2',
readOnly: true,
}),
);
});
});

View File

@@ -21,11 +21,13 @@ function paramTokens(path: string): string[] {
describe('Legacy Route Migration Framework (routes)', () => {
it('maps every legacy redirect target to a defined top-level route segment', () => {
const topLevelSegments = new Set([
'release-control',
'security-risk',
'evidence-audit',
'dashboard',
'releases',
'security',
'evidence',
'topology',
'platform',
'integrations',
'platform-ops',
'administration',
]);
@@ -38,13 +40,16 @@ describe('Legacy Route Migration Framework (routes)', () => {
}
});
it('preserves route parameter placeholders in redirect definitions', () => {
it('does not introduce unknown route parameter placeholders in redirect definitions', () => {
for (const route of LEGACY_REDIRECT_ROUTE_TEMPLATES) {
if (!route.path) continue;
expect(
paramTokens(route.redirectTo),
`Redirect parameter mismatch for ${route.path} -> ${route.redirectTo}`
).toEqual(paramTokens(route.path));
const sourceTokens = paramTokens(route.path);
for (const targetToken of paramTokens(route.redirectTo)) {
expect(
sourceTokens,
`Redirect parameter mismatch for ${route.path} -> ${route.redirectTo}`
).toContain(targetToken);
}
}
});
@@ -54,9 +59,9 @@ describe('Legacy Route Migration Framework (routes)', () => {
beforeEach(async () => {
const testRoutes: Routes = [
...LEGACY_REDIRECT_ROUTES,
{ path: 'platform-ops/health', component: DummyRouteTargetComponent },
{ path: 'security-risk/artifacts/:artifactId', component: DummyRouteTargetComponent },
{ path: 'release-control/regions', component: DummyRouteTargetComponent },
{ path: 'platform/ops/health-slo', component: DummyRouteTargetComponent },
{ path: 'security/artifacts/:artifactId', component: DummyRouteTargetComponent },
{ path: 'topology/regions', component: DummyRouteTargetComponent },
{ path: '**', component: DummyRouteTargetComponent },
];
@@ -71,17 +76,17 @@ describe('Legacy Route Migration Framework (routes)', () => {
it('redirects legacy operations paths to platform ops canonical paths', async () => {
await router.navigateByUrl('/ops/health');
expect(router.url).toBe('/platform-ops/health');
expect(router.url).toBe('/platform/ops/health-slo');
});
it('preserves route params and query params when redirecting triage artifact detail', async () => {
await router.navigateByUrl('/triage/artifacts/artifact-123?tab=evidence');
expect(router.url).toBe('/security-risk/artifacts/artifact-123?tab=evidence');
expect(router.url).toBe('/security/artifacts/artifact-123?tab=evidence');
});
it('redirects release orchestrator environments to release control domain', async () => {
it('redirects release orchestrator environments to topology domain', async () => {
await router.navigateByUrl('/release-orchestrator/environments');
expect(router.url).toBe('/release-control/regions');
expect(router.url).toBe('/topology/regions');
});
});
});

View File

@@ -9,6 +9,7 @@ import { setupWizardRoutes } from '../../app/features/setup-wizard/setup-wizard.
import { SetupWizardApiService } from '../../app/features/setup-wizard/services/setup-wizard-api.service';
import { SetupWizardStateService } from '../../app/features/setup-wizard/services/setup-wizard-state.service';
import { SetupSession } from '../../app/features/setup-wizard/models/setup-wizard.models';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
const sessionFixture: SetupSession = {
sessionId: 'session-1',
@@ -120,6 +121,16 @@ describe('setup-wizard-live-api-wiring behavior', () => {
} as any;
beforeEach(async () => {
const mockDoctorApi = {
listChecks: () => of({ checks: [], total: 0 }),
listPlugins: () => of({ plugins: [], total: 0 }),
startRun: () => of({ runId: 'test' }),
getRunResult: () => of({}),
streamRunProgress: () => of(),
listReports: () => of({ reports: [], total: 0 }),
deleteReport: () => of(),
};
await TestBed.configureTestingModule({
imports: [SetupWizardComponent],
providers: [
@@ -137,6 +148,7 @@ describe('setup-wizard-live-api-wiring behavior', () => {
navigate: jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)),
},
},
{ provide: DOCTOR_API, useValue: mockDoctorApi },
],
})
.overrideComponent(SetupWizardComponent, {

View File

@@ -0,0 +1,88 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { of } from 'rxjs';
import { SystemHealthPageComponent } from '../../app/features/system-health/system-health-page.component';
import { PlatformHealthClient } from '../../app/core/api/platform-health.client';
import { DoctorStore } from '../../app/features/doctor/services/doctor.store';
import { DOCTOR_API } from '../../app/features/doctor/services/doctor.client';
const mockHealthClient = {
getSummary: () => of({
totalServices: 5,
healthyCount: 4,
degradedCount: 1,
unhealthyCount: 0,
unknownCount: 0,
overallState: 'healthy',
averageLatencyMs: 45,
averageErrorRate: 0.1,
activeIncidents: 0,
lastUpdated: '2026-02-20T10:00:00Z',
services: [],
}),
getDependencyGraph: () => of({ nodes: [], edges: [] }),
getIncidents: () => of({ incidents: [] }),
};
const mockDoctorApi = {
listChecks: () => of({ checks: [] }),
listPlugins: () => of({ plugins: [] }),
startRun: () => of({ runId: 'test' }),
getRunResult: () => of({}),
streamRunProgress: () => of(),
listReports: () => of({ reports: [] }),
deleteReport: () => of(),
};
describe('SystemHealthPageComponent', () => {
let fixture: ComponentFixture<SystemHealthPageComponent>;
let component: SystemHealthPageComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SystemHealthPageComponent],
providers: [
provideRouter([]),
{ provide: PlatformHealthClient, useValue: mockHealthClient },
DoctorStore,
{ provide: DOCTOR_API, useValue: mockDoctorApi },
],
}).compileComponents();
fixture = TestBed.createComponent(SystemHealthPageComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create the component', () => {
expect(component).toBeTruthy();
});
it('should default to overview tab', () => {
expect(component.activeTab()).toBe('overview');
});
it('should switch tabs', () => {
component.activeTab.set('services');
expect(component.activeTab()).toBe('services');
component.activeTab.set('diagnostics');
expect(component.activeTab()).toBe('diagnostics');
component.activeTab.set('incidents');
expect(component.activeTab()).toBe('incidents');
});
it('should have 4 tabs defined', () => {
expect(component.tabs.length).toBe(4);
expect(component.tabs.map(t => t.id)).toEqual(['overview', 'services', 'diagnostics', 'incidents']);
});
it('should trigger quick diagnostics', () => {
const store = TestBed.inject(DoctorStore);
spyOn(store, 'startRun');
component.runQuickDiagnostics();
expect(store.startRun).toHaveBeenCalledWith({ mode: 'quick', includeRemediation: true });
});
});

View File

@@ -28,6 +28,11 @@ describe('TOPOLOGY_ROUTES dedicated pages', () => {
expect(await loadComponentName('targets')).toContain('TopologyTargetsPageComponent');
expect(await loadComponentName('hosts')).toContain('TopologyHostsPageComponent');
expect(await loadComponentName('agents')).toContain('TopologyAgentsPageComponent');
expect(await loadComponentName('promotion-paths')).toContain('TopologyPromotionPathsPageComponent');
expect(await loadComponentName('promotion-graph')).toContain('TopologyPromotionPathsPageComponent');
});
it('keeps promotion-paths as an alias redirect', () => {
const alias = TOPOLOGY_ROUTES.find((item) => item.path === 'promotion-paths');
expect(alias?.redirectTo).toBe('promotion-graph');
});
});