doctor and setup fixes
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface DoctorTrendPoint {
|
||||
timestamp: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface DoctorTrendResponse {
|
||||
category: string;
|
||||
points: DoctorTrendPoint[];
|
||||
}
|
||||
@@ -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([]);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
3
src/Web/StellaOps.Web/src/app/core/doctor/index.ts
Normal file
3
src/Web/StellaOps.Web/src/app/core/doctor/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './doctor-trend.models';
|
||||
export * from './doctor-trend.service';
|
||||
export * from './doctor-notification.service';
|
||||
@@ -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, '\\$&');
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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] ?? '';
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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()">↻</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 });
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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(' ');
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}).`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user