doctor enhancements, setup, enhancements, ui functionality and design consolidation and , test projects fixes , product advisory attestation/rekor and delta verfications enhancements

This commit is contained in:
master
2026-01-19 09:02:59 +02:00
parent 8c4bf54aed
commit 17419ba7c4
809 changed files with 170738 additions and 12244 deletions

View File

@@ -2,6 +2,20 @@ import type { Preview } from '@storybook/angular';
import '../src/styles.scss';
export const globalTypes = {
theme: {
name: 'Theme',
description: 'Global theme for components',
defaultValue: 'light',
toolbar: {
icon: 'paintbrush',
items: [
{ value: 'light', title: 'Light', icon: 'sun' },
{ value: 'dark', title: 'Dark', icon: 'moon' },
{ value: 'system', title: 'System', icon: 'browser' },
],
showName: true,
},
},
reduceMotion: {
name: 'Reduced Motion',
description: 'Toggle reduced-motion mode for motion tokens',
@@ -40,11 +54,26 @@ const preview: Preview = {
decorators: [
(story, context) => {
const root = document.documentElement;
// Handle theme switching
const theme = context.globals.theme || 'light';
if (theme === 'system') {
// Follow system preference
root.removeAttribute('data-theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
root.style.colorScheme = prefersDark ? 'dark' : 'light';
} else {
root.setAttribute('data-theme', theme);
root.style.colorScheme = theme;
}
// Handle reduced motion
if (context.globals.reduceMotion) {
root.dataset.reduceMotion = '1';
} else {
root.dataset.reduceMotion = '0';
}
return story();
},
],

View File

@@ -9,6 +9,14 @@
setup. See the <a routerLink="/welcome">welcome</a> page for details.
</div>
</section>
<!-- Legacy URL Banner (ROUTE-003) -->
@if (legacyRouteInfo(); as legacy) {
<app-legacy-url-banner
[canonicalUrl]="legacy.newPath"
[oldUrl]="legacy.oldPath"
(dismissed)="onLegacyBannerDismissed()"
></app-legacy-url-banner>
}
<header class="app-header">
<a class="app-brand" routerLink="/">StellaOps Dashboard</a>

View File

@@ -1,10 +1,14 @@
/**
* App Component Styles
* Migrated to design system tokens
*/
:host {
display: block;
min-height: 100vh;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI',
sans-serif;
color: var(--color-text-primary, #0f172a);
background-color: var(--color-surface-secondary, #f8fafc);
font-family: var(--font-family-base);
color: var(--color-text-primary);
background-color: var(--color-surface-secondary);
}
.app-shell {
@@ -14,15 +18,15 @@
}
.quickstart-banner {
background: var(--color-status-warning-bg, #fef3c7);
color: var(--color-status-warning-text, #92400e);
padding: 0.75rem 1.5rem;
font-size: 0.9rem;
border-bottom: 1px solid var(--color-status-warning-border, #fcd34d);
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
padding: var(--space-3) var(--space-6);
font-size: var(--font-size-sm);
border-bottom: 1px solid var(--color-status-warning-border);
a {
color: inherit;
font-weight: 600;
font-weight: var(--font-weight-semibold);
text-decoration: underline;
}
}
@@ -30,24 +34,24 @@
.app-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.5rem;
background: var(--color-header-bg, linear-gradient(90deg, #0f172a 0%, #1e293b 45%, #4328b7 100%));
color: var(--color-header-text, #f8fafc);
box-shadow: var(--shadow-md, 0 2px 8px rgba(15, 23, 42, 0.2));
gap: var(--space-4);
padding: var(--space-3) var(--space-6);
background: var(--color-header-bg);
color: var(--color-header-text);
box-shadow: var(--shadow-md);
// Navigation takes remaining space
app-navigation-menu {
flex: 1;
display: flex;
justify-content: flex-start;
margin-left: 1rem;
margin-left: var(--space-4);
}
}
.app-brand {
font-size: 1.125rem;
font-weight: 600;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
letter-spacing: 0.02em;
color: inherit;
text-decoration: none;
@@ -61,15 +65,15 @@
.app-auth {
display: flex;
align-items: center;
gap: 0.75rem;
gap: var(--space-3);
flex-shrink: 0;
.app-tenant {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background-color: var(--color-surface-tertiary, rgba(255, 255, 255, 0.1));
border-radius: 0.25rem;
color: var(--color-header-text-muted, rgba(248, 250, 252, 0.8));
font-size: var(--font-size-xs);
padding: var(--space-1) var(--space-2);
background-color: rgba(255, 255, 255, 0.1);
border-radius: var(--radius-xs);
color: var(--color-header-text-muted);
@media (max-width: 768px) {
display: none;
@@ -79,48 +83,54 @@
.app-fresh {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.2rem 0.6rem;
border-radius: 9999px;
gap: var(--space-1);
padding: var(--space-0-5) var(--space-2-5);
border-radius: var(--radius-full);
font-size: 0.7rem;
font-weight: 600;
font-weight: var(--font-weight-semibold);
letter-spacing: 0.03em;
background-color: var(--color-fresh-active-bg, rgba(20, 184, 166, 0.16));
color: var(--color-fresh-active-text, #0f766e);
background-color: var(--color-fresh-active-bg);
color: var(--color-fresh-active-text);
@media (max-width: 768px) {
display: none;
}
&.app-fresh--stale {
background-color: var(--color-fresh-stale-bg, rgba(249, 115, 22, 0.16));
color: var(--color-fresh-stale-text, #c2410c);
background-color: var(--color-fresh-stale-bg);
color: var(--color-fresh-stale-text);
}
}
&__signin {
appearance: none;
border: none;
border-radius: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: var(--radius-sm);
padding: var(--space-2) var(--space-4);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
color: var(--color-surface-inverse, #0f172a);
color: var(--color-surface-inverse);
background-color: rgba(248, 250, 252, 0.9);
transition: transform 150ms ease, background-color 150ms ease;
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
background-color var(--motion-duration-fast) var(--motion-ease-default);
&:hover,
&:focus-visible {
background-color: var(--color-accent-yellow, #facc15);
background-color: var(--color-accent-yellow);
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
}
}
.app-content {
flex: 1;
padding: 1.5rem 1.5rem 2rem;
padding: var(--space-6) var(--space-6) var(--space-8);
max-width: 1200px;
width: 100%;
margin: 0 auto;
@@ -128,13 +138,13 @@
// Breadcrumb styling
app-breadcrumb {
display: block;
margin-bottom: 0.75rem;
margin-bottom: var(--space-3);
}
}
// Page container with transition animations
.page-container {
animation: page-fade-in var(--motion-duration-slow, 250ms) var(--motion-ease-spring, ease-out);
animation: page-fade-in var(--motion-duration-slow) var(--motion-ease-spring);
}
@keyframes page-fade-in {
@@ -153,4 +163,12 @@
.page-container {
animation: none;
}
.app-auth__signin {
transition: none;
&:hover {
transform: none;
}
}
}

View File

@@ -20,6 +20,8 @@ import { ToastContainerComponent } from './shared/components/toast/toast-contain
import { BreadcrumbComponent } from './shared/components/breadcrumb/breadcrumb.component';
import { KeyboardShortcutsComponent } from './shared/components/keyboard-shortcuts/keyboard-shortcuts.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';
@Component({
selector: 'app-root',
@@ -34,6 +36,7 @@ import { BrandingService } from './core/branding/branding.service';
ToastContainerComponent,
BreadcrumbComponent,
KeyboardShortcutsComponent,
LegacyUrlBannerComponent,
],
templateUrl: './app.component.html',
styleUrl: './app.component.scss',
@@ -46,10 +49,14 @@ export class AppComponent {
private readonly consoleStore = inject(ConsoleSessionStore);
private readonly config = inject(AppConfigService);
private readonly brandingService = inject(BrandingService);
private readonly legacyRouteTelemetry = inject(LegacyRouteTelemetryService);
constructor() {
// Initialize branding on app start
this.brandingService.fetchBranding().subscribe();
// Initialize legacy route telemetry tracking (ROUTE-002)
this.legacyRouteTelemetry.initialize();
}
readonly isAuthenticated = this.sessionStore.isAuthenticated;
@@ -70,6 +77,9 @@ export class AppComponent {
() => this.config.config.quickstartMode ?? false
);
// Legacy route info for banner (ROUTE-003)
readonly legacyRouteInfo = this.legacyRouteTelemetry.currentLegacyRoute;
// Routes where breadcrumb should not be shown (home, auth pages)
private readonly hideBreadcrumbRoutes = ['/', '/welcome', '/callback', '/silent-refresh'];
@@ -91,4 +101,8 @@ export class AppComponent {
const returnUrl = this.router.url === '/' ? undefined : this.router.url;
void this.auth.beginLogin(returnUrl);
}
onLegacyBannerDismissed(): void {
this.legacyRouteTelemetry.clearCurrentLegacyRoute();
}
}

View File

@@ -11,12 +11,73 @@ import {
requirePolicyViewerGuard,
} from './core/auth';
import { LEGACY_REDIRECT_ROUTES } from './routes/legacy-redirects.routes';
export const routes: Routes = [
// Home Dashboard - default landing page
// ========================================================================
// NEW SHELL NAVIGATION ROUTES (SPRINT_20260118_001_FE)
// Control Plane is the new default landing page
// ========================================================================
// Control Plane - new default landing page
{
path: '',
pathMatch: 'full',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/control-plane/control-plane.routes').then(
(m) => m.CONTROL_PLANE_ROUTES
),
},
// Approvals - promotion decision cockpit
{
path: 'approvals',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/approvals/approvals.routes').then(
(m) => m.APPROVALS_ROUTES
),
},
// Security - consolidated security analysis (SEC-005, SEC-006)
{
path: 'security',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/security/security.routes').then(
(m) => m.SECURITY_ROUTES
),
},
// Policy - governance and exceptions (SEC-007)
{
path: 'policy',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/policy/policy.routes').then(
(m) => m.POLICY_ROUTES
),
},
// Settings - consolidated configuration (SPRINT_20260118_002)
{
path: 'settings',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/settings/settings.routes').then(
(m) => m.SETTINGS_ROUTES
),
},
// ========================================================================
// LEGACY ROUTES (to be migrated/removed in future sprints)
// ========================================================================
// Legacy Home Dashboard - redirects or will be removed
{
path: 'home',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadComponent: () =>
import('./features/home/home-dashboard.component').then(
(m) => m.HomeDashboardComponent
@@ -464,6 +525,13 @@ export const routes: Routes = [
loadChildren: () =>
import('./features/doctor/doctor.routes').then((m) => m.DOCTOR_ROUTES),
},
// Ops - Agent Fleet (SPRINT_20260118_023_FE)
{
path: 'ops/agents',
canMatch: [() => import('./core/auth/auth.guard').then((m) => m.requireAuthGuard)],
loadChildren: () =>
import('./features/agents/agents.routes').then((m) => m.AGENTS_ROUTES),
},
// Analyze - Unknowns Tracking (SPRINT_20251229_033)
{
path: 'analyze/unknowns',
@@ -534,6 +602,12 @@ export const routes: Routes = [
loadChildren: () =>
import('./features/configuration-pane/configuration-pane.routes').then((m) => m.CONFIGURATION_PANE_ROUTES),
},
// ==========================================================================
// LEGACY REDIRECT ROUTES
// Redirects for renamed/consolidated routes to prevent bookmark breakage
// ==========================================================================
...LEGACY_REDIRECT_ROUTES,
// Fallback for unknown routes
{
path: '**',

View File

@@ -16,6 +16,13 @@ import {
PathNode,
GateInfo,
CallPathNode,
// Sprint: SPRINT_20260118_020_FE_witness_visualization (TASK-020-006)
RuntimeTracesResponse,
RuntimeTimeline,
TimelineOptions,
WitnessComparisonData,
ComparisonPathStep,
ComparisonMetrics,
} from './witness.models';
export interface WitnessApi {
@@ -56,6 +63,27 @@ export interface WitnessApi {
* Export witnesses as SARIF.
*/
exportSarif(scanId: string): Observable<Blob>;
// Sprint: SPRINT_20260118_020_FE_witness_visualization (TASK-020-006)
// Methods for comparison visualization using existing backend APIs
/**
* Get runtime traces for a finding.
* Backend: GET /api/v1/findings/{findingId}/runtime/traces
*/
getRuntimeTraces(findingId: string): Observable<RuntimeTracesResponse>;
/**
* Get runtime timeline for a finding.
* Backend: GET /api/v1/findings/{findingId}/runtime-timeline
*/
getWitnessTimeline(findingId: string, options?: TimelineOptions): Observable<RuntimeTimeline>;
/**
* Get comparison metrics for a finding by combining static witness data with runtime traces.
* This combines the witness data with runtime traces to produce comparison metrics.
*/
getComparisonMetrics(findingId: string): Observable<WitnessComparisonData>;
}
export const WITNESS_API = new InjectionToken<WitnessApi>('WITNESS_API');
@@ -118,6 +146,122 @@ export class WitnessHttpClient implements WitnessApi {
responseType: 'blob',
});
}
// Sprint: SPRINT_20260118_020_FE_witness_visualization (TASK-020-006)
// API methods for comparison visualization using existing backend endpoints
getRuntimeTraces(findingId: string): Observable<RuntimeTracesResponse> {
return this.http.get<RuntimeTracesResponse>(
`/api/v1/findings/${findingId}/runtime/traces`
);
}
getWitnessTimeline(findingId: string, options?: TimelineOptions): Observable<RuntimeTimeline> {
let params = new HttpParams();
if (options?.windowStart) {
params = params.set('from', options.windowStart);
}
if (options?.windowEnd) {
params = params.set('to', options.windowEnd);
}
if (options?.bucketHours) {
params = params.set('bucketHours', options.bucketHours.toString());
}
return this.http.get<RuntimeTimeline>(
`/api/v1/findings/${findingId}/runtime-timeline`,
{ params }
);
}
getComparisonMetrics(findingId: string): Observable<WitnessComparisonData> {
// Combine static witness data with runtime traces to produce comparison metrics
// This is a client-side aggregation using the existing APIs
return this.getRuntimeTraces(findingId).pipe(
map((tracesResponse) => this.buildComparisonData(findingId, tracesResponse))
);
}
/**
* Build comparison data from runtime traces.
* Transforms backend RuntimeTracesResponse into frontend WitnessComparisonData.
*/
private buildComparisonData(
findingId: string,
traces: RuntimeTracesResponse
): WitnessComparisonData {
// Collect all unique symbols from runtime traces
const runtimeSymbols = new Map<string, { hitCount: number; lastSeen: string }>();
for (const trace of traces.traces) {
for (const frame of trace.callPath) {
const existing = runtimeSymbols.get(frame.symbol);
if (existing) {
existing.hitCount += trace.hitCount;
if (trace.lastSeen > existing.lastSeen) {
existing.lastSeen = trace.lastSeen;
}
} else {
runtimeSymbols.set(frame.symbol, {
hitCount: trace.hitCount,
lastSeen: trace.lastSeen,
});
}
}
}
// Build comparison path steps
// Note: In a real implementation, we would also fetch static witness data
// and merge it with runtime data. For now, we use runtime traces as the baseline.
const pathSteps: ComparisonPathStep[] = [];
let nodeIndex = 0;
for (const trace of traces.traces) {
for (const frame of trace.callPath) {
// Check if we already have this symbol
const existing = pathSteps.find((s) => s.symbol === frame.symbol);
if (!existing) {
const runtimeData = runtimeSymbols.get(frame.symbol);
pathSteps.push({
nodeId: `node-${nodeIndex++}`,
symbol: frame.symbol,
file: frame.file,
line: frame.line,
// Static analysis data would come from witness API
// For now, mark as having static data if we have runtime (since we detected it)
inStatic: true,
inRuntime: !!runtimeData,
runtimeInvocations: runtimeData?.hitCount,
lastObserved: runtimeData?.lastSeen,
});
}
}
}
// Calculate metrics
const confirmedSteps = pathSteps.filter((s) => s.inStatic && s.inRuntime).length;
const staticOnlySteps = pathSteps.filter((s) => s.inStatic && !s.inRuntime).length;
const runtimeOnlySteps = pathSteps.filter((s) => !s.inStatic && s.inRuntime).length;
const totalSteps = pathSteps.length;
const confirmationRate = totalSteps > 0
? Math.round((confirmedSteps / totalSteps) * 100)
: 0;
const metrics: ComparisonMetrics = {
totalSteps,
confirmedSteps,
staticOnlySteps,
runtimeOnlySteps,
confirmationRate,
};
return {
claimId: findingId,
packageName: 'Unknown', // Would be populated from finding data
pathSteps,
metrics,
generatedAt: new Date().toISOString(),
};
}
}
// Mock data for development
@@ -285,4 +429,99 @@ export class WitnessMockClient implements WitnessApi {
delay(100)
);
}
// Sprint: SPRINT_20260118_020_FE_witness_visualization (TASK-020-006)
// Mock implementations for comparison visualization
getRuntimeTraces(findingId: string): Observable<RuntimeTracesResponse> {
const mockTraces: RuntimeTracesResponse = {
findingId,
collectionActive: true,
collectionStarted: '2026-01-15T10:00:00Z',
summary: {
totalHits: 1542,
uniquePaths: 3,
posture: 'activeTracing',
lastHit: '2026-01-18T14:30:00Z',
directPathObserved: true,
productionTraffic: false,
containerCount: 2,
},
traces: [
{
id: 'trace-001',
vulnerableFunction: 'JsonConvert.DeserializeObject<T>',
isDirectPath: true,
hitCount: 1200,
firstSeen: '2026-01-15T10:05:00Z',
lastSeen: '2026-01-18T14:30:00Z',
containerId: 'container-abc123',
containerName: 'api-server-1',
callPath: [
{ symbol: 'UserController.GetUser', file: 'Controllers/UserController.cs', line: 42, isEntryPoint: true },
{ symbol: 'UserService.GetUserById', file: 'Services/UserService.cs', line: 88 },
{ symbol: 'JsonConvert.DeserializeObject<User>', isVulnerableFunction: true },
],
},
{
id: 'trace-002',
vulnerableFunction: 'JsonConvert.DeserializeObject<T>',
isDirectPath: false,
hitCount: 342,
firstSeen: '2026-01-16T08:00:00Z',
lastSeen: '2026-01-18T12:15:00Z',
containerId: 'container-def456',
containerName: 'api-server-2',
callPath: [
{ symbol: 'AdminController.ListUsers', file: 'Controllers/AdminController.cs', line: 25, isEntryPoint: true },
{ symbol: 'UserRepository.GetAll', file: 'Repositories/UserRepository.cs', line: 44 },
{ symbol: 'JsonConvert.DeserializeObject<List<User>>', isVulnerableFunction: true },
],
},
],
};
return of(mockTraces).pipe(delay(200));
}
getWitnessTimeline(findingId: string, options?: TimelineOptions): Observable<RuntimeTimeline> {
const mockTimeline: RuntimeTimeline = {
findingId,
totalObservations: 1542,
firstObservation: '2026-01-15T10:05:00Z',
lastObservation: '2026-01-18T14:30:00Z',
buckets: [
{ start: '2026-01-15T00:00:00Z', end: '2026-01-16T00:00:00Z', observationCount: 450, uniquePaths: 2, containerCount: 1 },
{ start: '2026-01-16T00:00:00Z', end: '2026-01-17T00:00:00Z', observationCount: 512, uniquePaths: 3, containerCount: 2 },
{ start: '2026-01-17T00:00:00Z', end: '2026-01-18T00:00:00Z', observationCount: 380, uniquePaths: 2, containerCount: 2 },
{ start: '2026-01-18T00:00:00Z', end: '2026-01-19T00:00:00Z', observationCount: 200, uniquePaths: 2, containerCount: 2 },
],
};
return of(mockTimeline).pipe(delay(200));
}
getComparisonMetrics(findingId: string): Observable<WitnessComparisonData> {
const mockComparison: WitnessComparisonData = {
claimId: findingId,
cveId: 'CVE-2024-12345',
packageName: 'Newtonsoft.Json',
packageVersion: '12.0.3',
pathSteps: [
{ nodeId: 'n1', symbol: 'UserController.GetUser', file: 'Controllers/UserController.cs', line: 42, inStatic: true, inRuntime: true, runtimeInvocations: 1200, lastObserved: '2026-01-18T14:30:00Z' },
{ nodeId: 'n2', symbol: 'UserService.GetUserById', file: 'Services/UserService.cs', line: 88, inStatic: true, inRuntime: true, runtimeInvocations: 1200, lastObserved: '2026-01-18T14:30:00Z' },
{ nodeId: 'n3', symbol: 'JsonConvert.DeserializeObject<User>', inStatic: true, inRuntime: true, runtimeInvocations: 1542, lastObserved: '2026-01-18T14:30:00Z' },
{ nodeId: 'n4', symbol: 'AdminController.ListUsers', file: 'Controllers/AdminController.cs', line: 25, inStatic: true, inRuntime: true, runtimeInvocations: 342, lastObserved: '2026-01-18T12:15:00Z' },
{ nodeId: 'n5', symbol: 'UserRepository.GetAll', file: 'Repositories/UserRepository.cs', line: 44, inStatic: true, inRuntime: true, runtimeInvocations: 342, lastObserved: '2026-01-18T12:15:00Z' },
{ nodeId: 'n6', symbol: 'DataAccessLayer.Query', file: 'Data/DataAccessLayer.cs', line: 112, inStatic: true, inRuntime: false },
],
metrics: {
totalSteps: 6,
confirmedSteps: 5,
staticOnlySteps: 1,
runtimeOnlySteps: 0,
confirmationRate: 83,
},
generatedAt: new Date().toISOString(),
};
return of(mockComparison).pipe(delay(250));
}
}

View File

@@ -80,6 +80,7 @@ export interface ReachabilityWitness {
/**
* Runtime evidence metadata for dynamic analysis results.
* Sprint: SPRINT_20260112_013_FE_witness_ui_wiring (FE-WIT-002)
* Updated: SPRINT_20260118_020_FE_witness_visualization (TASK-020-001)
*/
export interface RuntimeEvidenceMetadata {
/** Whether runtime data is available. */
@@ -99,8 +100,67 @@ export interface RuntimeEvidenceMetadata {
/** URI to full runtime trace if available. */
traceUri?: string;
// Sprint: SPRINT_20260118_020_FE_witness_visualization (TASK-020-001)
// Extended runtime evidence fields
/** Observation type: "static" | "runtime" | "confirmed" (both static and runtime). */
observationType?: 'static' | 'runtime' | 'confirmed';
/** Rekor transparency log index for signed runtime witnesses. */
rekorLogIndex?: number;
/** URI to Rekor transparency log entry for runtime witness. */
rekorLogUri?: string;
/** Container/pod context where observation was made. */
containerContext?: ContainerContext;
/** Observation staleness: hours since last observation. */
staleAfterHours?: number;
/** Whether the observation is considered stale. */
isStale?: boolean;
}
/**
* Container context for runtime observations.
* Sprint: SPRINT_20260118_020_FE_witness_visualization (TASK-020-001)
*/
export interface ContainerContext {
/** Container ID where observation was made. */
containerId?: string;
/** Container image digest. */
imageDigest?: string;
/** Kubernetes pod name (if applicable). */
podName?: string;
/** Kubernetes namespace (if applicable). */
namespace?: string;
/** Node name where pod is running. */
nodeName?: string;
/** Environment name (e.g., "staging", "production"). */
environment?: string;
}
/** Observation type labels for display. */
export const OBSERVATION_TYPE_LABELS: Record<string, string> = {
static: 'Static Analysis',
runtime: 'Runtime Observed',
confirmed: 'Confirmed',
};
/** Observation type colors for badges. */
export const OBSERVATION_TYPE_COLORS: Record<string, string> = {
static: '#6c757d', // Gray
runtime: '#fd7e14', // Orange
confirmed: '#28a745', // Green
};
/**
* Node in a call path.
*/
@@ -283,3 +343,233 @@ export const VEX_RECOMMENDATIONS: Record<ConfidenceTier, string> = {
unreachable: 'not_affected',
unknown: 'under_investigation',
};
// =============================================================================
// Runtime Traces & Timeline Models
// Sprint: SPRINT_20260118_020_FE_witness_visualization (TASK-020-006)
// These models map to existing backend APIs:
// - GET /api/v1/findings/{findingId}/runtime/traces
// - GET /api/v1/findings/{findingId}/runtime-timeline
// =============================================================================
/**
* Runtime observation posture.
*/
export type RuntimePosture = 'none' | 'passive' | 'activeTracing' | 'ebpfDeep' | 'fullInstrumentation';
/**
* Summary of runtime observations.
*/
export interface ObservationSummary {
/** Total hit count across all traces. */
totalHits: number;
/** Number of unique call paths. */
uniquePaths: number;
/** Observation posture. */
posture: RuntimePosture;
/** Last hit timestamp. */
lastHit?: string;
/** Whether a direct path to vulnerable function was observed. */
directPathObserved: boolean;
/** Whether production traffic was observed. */
productionTraffic: boolean;
/** Number of containers with observations. */
containerCount: number;
}
/**
* Stack frame in a call path.
*/
export interface StackFrame {
/** Function/method symbol. */
symbol: string;
/** Source file path. */
file?: string;
/** Line number. */
line?: number;
/** Whether this is an entry point. */
isEntryPoint?: boolean;
/** Whether this is the vulnerable function. */
isVulnerableFunction?: boolean;
/** Confidence score for this frame. */
confidence?: number;
}
/**
* A function trace showing a call path to a vulnerable function.
*/
export interface FunctionTrace {
/** Trace identifier. */
id: string;
/** Vulnerable function symbol. */
vulnerableFunction: string;
/** Whether this is a direct path. */
isDirectPath: boolean;
/** Number of times this path was hit. */
hitCount: number;
/** First observation timestamp. */
firstSeen: string;
/** Last observation timestamp. */
lastSeen: string;
/** Container ID where observed. */
containerId?: string;
/** Container name. */
containerName?: string;
/** Call path (stack frames). */
callPath: StackFrame[];
}
/**
* Response containing runtime traces for a finding.
*/
export interface RuntimeTracesResponse {
/** Finding this evidence is for. */
findingId: string;
/** Whether collection is currently active. */
collectionActive: boolean;
/** When collection started. */
collectionStarted?: string;
/** Summary of observations. */
summary: ObservationSummary;
/** Function traces. */
traces: FunctionTrace[];
}
/**
* Breakdown of RTS score components.
*/
export interface RtsBreakdown {
/** Score based on observation quality (0.0 to 1.0). */
observationScore: number;
/** Factor based on recency of observations (0.0 to 1.0). */
recencyFactor: number;
/** Factor based on data quality (0.0 to 1.0). */
qualityFactor: number;
}
/**
* Runtime Trustworthiness Score.
*/
export interface RtsScore {
/** Aggregate score (0.0 to 1.0). */
score: number;
/** Score breakdown. */
breakdown: RtsBreakdown;
/** When the score was computed. */
computedAt: string;
}
/**
* Response containing RTS score for a finding.
*/
export interface RtsScoreResponse {
/** Finding ID. */
findingId: string;
/** RTS score. */
score: RtsScore;
}
/**
* Timeline options for querying runtime timeline.
*/
export interface TimelineOptions {
/** Window start. */
windowStart?: string;
/** Window end. */
windowEnd?: string;
/** Bucket size in hours. */
bucketHours?: number;
}
/**
* Timeline bucket representing observations in a time window.
*/
export interface TimelineBucket {
/** Bucket start timestamp. */
start: string;
/** Bucket end timestamp. */
end: string;
/** Number of observations in this bucket. */
observationCount: number;
/** Number of unique paths observed. */
uniquePaths: number;
/** Number of containers with observations. */
containerCount: number;
}
/**
* Runtime timeline for a finding.
*/
export interface RuntimeTimeline {
/** Finding ID. */
findingId: string;
/** Timeline buckets. */
buckets: TimelineBucket[];
/** Total observation count. */
totalObservations: number;
/** First observation timestamp. */
firstObservation?: string;
/** Last observation timestamp. */
lastObservation?: string;
}
// =============================================================================
// Comparison Models
// Sprint: SPRINT_20260118_020_FE_witness_visualization (TASK-020-006)
// These models support the WitnessComparisonComponent
// =============================================================================
/**
* Path step in comparison.
*/
export interface ComparisonPathStep {
nodeId: string;
symbol: string;
file?: string;
line?: number;
package?: string;
/** Whether step was found in static analysis. */
inStatic: boolean;
/** Whether step was observed at runtime. */
inRuntime: boolean;
/** Number of runtime invocations (if runtime observed). */
runtimeInvocations?: number;
/** Last observed timestamp (if runtime observed). */
lastObserved?: string;
}
/**
* Comparison summary metrics.
*/
export interface ComparisonMetrics {
/** Total number of path steps. */
totalSteps: number;
/** Steps confirmed by both static and runtime. */
confirmedSteps: number;
/** Steps only in static analysis (not runtime observed). */
staticOnlySteps: number;
/** Steps only in runtime (unexpected paths). */
runtimeOnlySteps: number;
/** Percentage of paths that are confirmed. */
confirmationRate: number;
}
/**
* Comparison data for witness comparison view.
*/
export interface WitnessComparisonData {
/** Vulnerability or claim identifier. */
claimId: string;
/** CVE ID if applicable. */
cveId?: string;
/** Package affected. */
packageName: string;
/** Package version. */
packageVersion?: string;
/** Path steps with comparison data. */
pathSteps: ComparisonPathStep[];
/** Summary metrics. */
metrics: ComparisonMetrics;
/** When comparison was generated. */
generatedAt: string;
}

View File

@@ -0,0 +1,320 @@
/**
* 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.
*/
import { Injectable, inject, DestroyRef, signal } from '@angular/core';
import { Router, NavigationEnd, NavigationStart, RoutesRecognized } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { filter, pairwise, map } from 'rxjs';
import { TelemetryClient } from '../telemetry/telemetry.client';
import { AUTH_SERVICE, AuthService } from '../auth/auth.service';
/**
* 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> = {
// 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': '/settings/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 }> = [
// 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/' },
];
export interface LegacyRouteHitEvent {
eventType: 'legacy_route_hit';
oldPath: string;
newPath: string;
tenantId: string | null;
userId: string | null;
userAgent: string;
referrer: string;
}
export interface LegacyRouteInfo {
oldPath: string;
newPath: string;
timestamp: number;
}
@Injectable({ providedIn: 'root' })
export class LegacyRouteTelemetryService {
private readonly router = inject(Router);
private readonly telemetry = inject(TelemetryClient);
private readonly authService = inject(AUTH_SERVICE) as AuthService;
private readonly destroyRef = inject(DestroyRef);
private pendingLegacyRoute: string | 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;
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;
}
});
// 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);
// Only emit if we actually redirected to a different path
if (oldPath !== newPath) {
this.emitLegacyRouteHit(oldPath, newPath);
}
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;
}
/**
* 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);
}
return path;
}
/**
* Emit telemetry event for legacy route hit.
*/
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,
timestamp: Date.now(),
});
this.telemetry.emit('legacy_route_hit', {
oldPath: `/${oldPath}`,
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;
}
}

View File

@@ -0,0 +1,562 @@
/**
* Agent Detail Page Component Tests
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-003 - Create Agent Detail page
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { AgentDetailPageComponent } from './agent-detail-page.component';
import { AgentStore } from './services/agent.store';
import { Agent, AgentHealthResult, AgentTask } from './models/agent.models';
import { signal, WritableSignal } from '@angular/core';
describe('AgentDetailPageComponent', () => {
let component: AgentDetailPageComponent;
let fixture: ComponentFixture<AgentDetailPageComponent>;
let mockStore: jasmine.SpyObj<Partial<AgentStore>> & {
isLoading: WritableSignal<boolean>;
error: WritableSignal<string | null>;
selectedAgent: WritableSignal<Agent | null>;
agentHealth: WritableSignal<AgentHealthResult[]>;
agentTasks: WritableSignal<AgentTask[]>;
};
let router: Router;
const createMockAgent = (overrides: Partial<Agent> = {}): Agent => ({
id: 'agent-123',
name: 'test-agent',
displayName: 'Test Agent',
environment: 'production',
version: '2.5.0',
status: 'online',
lastHeartbeat: new Date().toISOString(),
registeredAt: '2026-01-01T00:00:00Z',
resources: {
cpuPercent: 45,
memoryPercent: 60,
diskPercent: 35,
},
activeTasks: 3,
taskQueueDepth: 2,
capacityPercent: 65,
tags: ['primary', 'scanner'],
certificate: {
thumbprint: 'abc123',
subject: 'CN=test-agent',
issuer: 'CN=Stella CA',
notBefore: '2026-01-01T00:00:00Z',
notAfter: '2027-01-01T00:00:00Z',
isExpired: false,
daysUntilExpiry: 348,
},
config: {
maxConcurrentTasks: 10,
heartbeatIntervalSeconds: 30,
taskTimeoutSeconds: 3600,
autoUpdate: true,
logLevel: 'info',
},
...overrides,
});
const mockHealthChecks: AgentHealthResult[] = [
{ checkId: 'c1', checkName: 'Connectivity', status: 'pass', message: 'OK', lastChecked: new Date().toISOString() },
{ checkId: 'c2', checkName: 'Memory', status: 'warn', message: 'High usage', lastChecked: new Date().toISOString() },
];
const mockTasks: AgentTask[] = [
{ taskId: 't1', taskType: 'scan', status: 'running', startedAt: '2026-01-18T10:00:00Z', progress: 50 },
{ taskId: 't2', taskType: 'deploy', status: 'completed', startedAt: '2026-01-18T09:00:00Z', completedAt: '2026-01-18T09:30:00Z' },
];
beforeEach(async () => {
mockStore = {
isLoading: signal(false),
error: signal(null),
selectedAgent: signal(createMockAgent()),
agentHealth: signal(mockHealthChecks),
agentTasks: signal(mockTasks),
selectAgent: jasmine.createSpy('selectAgent'),
fetchAgentHealth: jasmine.createSpy('fetchAgentHealth'),
fetchAgentTasks: jasmine.createSpy('fetchAgentTasks'),
executeAction: jasmine.createSpy('executeAction'),
} as any;
await TestBed.configureTestingModule({
imports: [AgentDetailPageComponent, RouterTestingModule],
providers: [
{ provide: AgentStore, useValue: mockStore },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ agentId: 'agent-123' }),
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(AgentDetailPageComponent);
component = fixture.componentInstance;
router = TestBed.inject(Router);
});
describe('initialization', () => {
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should select agent from route param', () => {
fixture.detectChanges();
expect(mockStore.selectAgent).toHaveBeenCalledWith('agent-123');
});
});
describe('breadcrumb', () => {
it('should display breadcrumb', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.breadcrumb')).toBeTruthy();
});
it('should show agent name in breadcrumb', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('test-agent');
});
it('should have link back to fleet', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement;
const backLink = compiled.querySelector('.breadcrumb__link');
expect(backLink.textContent).toContain('Agent Fleet');
});
});
describe('header', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should display agent display name', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.detail-header__title h1').textContent).toContain('Test Agent');
});
it('should display agent ID', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('agent-123');
});
it('should display status indicator', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.status-indicator')).toBeTruthy();
});
it('should display run diagnostics button', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Run Diagnostics');
});
it('should display actions dropdown', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Actions');
});
});
describe('tags', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should display environment tag', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('production');
});
it('should display version tag', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('v2.5.0');
});
it('should display custom tags', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('primary');
expect(compiled.textContent).toContain('scanner');
});
});
describe('tabs', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should display all tabs', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Overview');
expect(compiled.textContent).toContain('Health');
expect(compiled.textContent).toContain('Tasks');
expect(compiled.textContent).toContain('Logs');
expect(compiled.textContent).toContain('Configuration');
});
it('should default to overview tab', () => {
expect(component.activeTab()).toBe('overview');
});
it('should switch tabs', () => {
component.setActiveTab('health');
expect(component.activeTab()).toBe('health');
});
it('should mark active tab', () => {
const compiled = fixture.nativeElement;
const activeTab = compiled.querySelector('.tab--active');
expect(activeTab.textContent).toContain('Overview');
});
it('should have correct ARIA attributes', () => {
const compiled = fixture.nativeElement;
const tabs = compiled.querySelectorAll('.tab');
tabs.forEach((tab: HTMLElement) => {
expect(tab.getAttribute('role')).toBe('tab');
});
});
});
describe('overview tab', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should display status', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Status');
expect(compiled.textContent).toContain('Online');
});
it('should display capacity', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Capacity');
expect(compiled.textContent).toContain('65%');
});
it('should display active tasks count', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Active Tasks');
expect(compiled.textContent).toContain('3');
});
it('should display resource meters', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Resource Utilization');
expect(compiled.textContent).toContain('CPU');
expect(compiled.textContent).toContain('Memory');
expect(compiled.textContent).toContain('Disk');
});
it('should display certificate info', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Certificate');
expect(compiled.textContent).toContain('CN=test-agent');
});
it('should display agent information', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Agent Information');
expect(compiled.textContent).toContain('agent-123');
});
});
describe('health tab', () => {
beforeEach(() => {
component.setActiveTab('health');
fixture.detectChanges();
});
it('should display health tab component', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('st-agent-health-tab')).toBeTruthy();
});
it('should run health checks on request', () => {
component.onRunHealthChecks();
expect(mockStore.fetchAgentHealth).toHaveBeenCalledWith('agent-123');
});
});
describe('tasks tab', () => {
beforeEach(() => {
component.setActiveTab('tasks');
fixture.detectChanges();
});
it('should display tasks tab component', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('st-agent-tasks-tab')).toBeTruthy();
});
it('should load more tasks on request', () => {
component.onLoadMoreTasks();
expect(mockStore.fetchAgentTasks).toHaveBeenCalledWith('agent-123');
});
});
describe('config tab', () => {
beforeEach(() => {
component.setActiveTab('config');
fixture.detectChanges();
});
it('should display configuration', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Configuration');
});
it('should display max concurrent tasks', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Max Concurrent Tasks');
expect(compiled.textContent).toContain('10');
});
it('should display heartbeat interval', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Heartbeat Interval');
expect(compiled.textContent).toContain('30s');
});
it('should display auto update status', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Auto Update');
expect(compiled.textContent).toContain('Enabled');
});
});
describe('actions menu', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should toggle actions menu', () => {
expect(component.showActionsMenu()).toBe(false);
component.toggleActionsMenu();
expect(component.showActionsMenu()).toBe(true);
component.toggleActionsMenu();
expect(component.showActionsMenu()).toBe(false);
});
it('should show menu items when open', () => {
component.showActionsMenu.set(true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Restart Agent');
expect(compiled.textContent).toContain('Renew Certificate');
expect(compiled.textContent).toContain('Drain Tasks');
expect(compiled.textContent).toContain('Resume Tasks');
expect(compiled.textContent).toContain('Remove Agent');
});
});
describe('action execution', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should set pending action when executing', () => {
component.executeAction('restart');
expect(component.pendingAction()).toBe('restart');
expect(component.showActionsMenu()).toBe(false);
});
it('should show action modal when pending action set', () => {
component.pendingAction.set('restart');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('st-agent-action-modal')).toBeTruthy();
});
it('should execute action on confirm', fakeAsync(() => {
component.onActionConfirmed('restart');
expect(component.isExecutingAction()).toBe(true);
tick(1500);
expect(component.isExecutingAction()).toBe(false);
expect(component.pendingAction()).toBeNull();
expect(mockStore.executeAction).toHaveBeenCalled();
}));
it('should clear pending action on cancel', () => {
component.pendingAction.set('restart');
component.onActionCancelled();
expect(component.pendingAction()).toBeNull();
});
});
describe('action feedback', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should show success feedback', () => {
component.showFeedback('success', 'Action completed');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.action-toast--success')).toBeTruthy();
expect(compiled.textContent).toContain('Action completed');
});
it('should show error feedback', () => {
component.showFeedback('error', 'Action failed');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.action-toast--error')).toBeTruthy();
});
it('should auto-dismiss feedback', fakeAsync(() => {
component.showFeedback('success', 'Test');
expect(component.actionFeedback()).not.toBeNull();
tick(5000);
expect(component.actionFeedback()).toBeNull();
}));
it('should clear feedback manually', () => {
component.showFeedback('success', 'Test');
component.clearActionFeedback();
expect(component.actionFeedback()).toBeNull();
});
});
describe('computed values', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should compute agent from store', () => {
expect(component.agent()).toBeTruthy();
expect(component.agent()?.id).toBe('agent-123');
});
it('should compute status color', () => {
expect(component.statusColor()).toContain('success');
});
it('should compute status label', () => {
expect(component.statusLabel()).toBe('Online');
});
it('should compute heartbeat label', () => {
expect(component.heartbeatLabel()).toBeTruthy();
});
});
describe('loading state', () => {
it('should show loading spinner when loading', () => {
mockStore.isLoading.set(true);
mockStore.selectedAgent.set(null);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.loading-state')).toBeTruthy();
});
});
describe('error state', () => {
it('should show error message', () => {
mockStore.error.set('Failed to load agent');
mockStore.selectedAgent.set(null);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Failed to load agent');
});
it('should show back to fleet link on error', () => {
mockStore.error.set('Error');
mockStore.selectedAgent.set(null);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Back to Fleet');
});
});
describe('certificate warning', () => {
it('should show warning for expiring certificate', () => {
mockStore.selectedAgent.set(createMockAgent({
certificate: {
thumbprint: 'abc',
subject: 'CN=test',
issuer: 'CN=CA',
notBefore: '2026-01-01T00:00:00Z',
notAfter: '2026-02-15T00:00:00Z',
isExpired: false,
daysUntilExpiry: 25,
},
}));
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('25 days remaining');
});
it('should apply warning class for expiring certificate', () => {
mockStore.selectedAgent.set(createMockAgent({
certificate: {
thumbprint: 'abc',
subject: 'CN=test',
issuer: 'CN=CA',
notBefore: '2026-01-01T00:00:00Z',
notAfter: '2026-02-15T00:00:00Z',
isExpired: false,
daysUntilExpiry: 25,
},
}));
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.section-card--warning')).toBeTruthy();
});
});
describe('formatDate', () => {
it('should format ISO date string', () => {
const result = component.formatDate('2026-01-18T14:30:00Z');
expect(result).toContain('Jan');
expect(result).toContain('18');
expect(result).toContain('2026');
});
});
describe('getCapacityColor', () => {
it('should return capacity color', () => {
const color = component.getCapacityColor(80);
expect(color).toBeTruthy();
expect(color).toContain('high');
});
});
describe('diagnostics navigation', () => {
beforeEach(() => {
fixture.detectChanges();
spyOn(router, 'navigate');
});
it('should navigate to doctor with agent filter', () => {
component.runDiagnostics();
expect(router.navigate).toHaveBeenCalledWith(['/ops/doctor'], { queryParams: { agent: 'agent-123' } });
});
});
});

View File

@@ -0,0 +1,932 @@
/**
* Agent Detail Page Component
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-003 - Create Agent Detail page
*
* Detailed view for individual agent with tabs for health, tasks, logs, and config.
*/
import { Component, OnInit, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { AgentStore } from './services/agent.store';
import {
Agent,
AgentAction,
AgentTask,
getStatusColor,
getStatusLabel,
getCapacityColor,
formatHeartbeat,
} from './models/agent.models';
import { AgentHealthTabComponent } from './components/agent-health-tab/agent-health-tab.component';
import { AgentTasksTabComponent } from './components/agent-tasks-tab/agent-tasks-tab.component';
import { AgentActionModalComponent } from './components/agent-action-modal/agent-action-modal.component';
type DetailTab = 'overview' | 'health' | 'tasks' | 'logs' | 'config';
@Component({
selector: 'st-agent-detail-page',
standalone: true,
imports: [CommonModule, RouterLink, AgentHealthTabComponent, AgentTasksTabComponent, AgentActionModalComponent],
template: `
<div class="agent-detail-page">
<!-- Breadcrumb -->
<nav class="breadcrumb" aria-label="Breadcrumb">
<a routerLink="/ops/agents" class="breadcrumb__link">Agent Fleet</a>
<span class="breadcrumb__separator" aria-hidden="true">/</span>
<span class="breadcrumb__current">{{ agent()?.name || 'Agent' }}</span>
</nav>
@if (store.isLoading()) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading agent details...</p>
</div>
} @else if (store.error()) {
<div class="error-state">
<p class="error-state__message">{{ store.error() }}</p>
<a routerLink="/ops/agents" class="btn btn--secondary">Back to Fleet</a>
</div>
} @else if (agent(); as agentData) {
<!-- Header -->
<header class="detail-header">
<div class="detail-header__info">
<div class="detail-header__status">
<span
class="status-indicator"
[style.background-color]="statusColor()"
[title]="statusLabel()"
></span>
</div>
<div class="detail-header__title">
<h1>{{ agentData.displayName || agentData.name }}</h1>
<code class="detail-header__id">{{ agentData.id }}</code>
</div>
</div>
<div class="detail-header__actions">
<button
type="button"
class="btn btn--secondary"
(click)="runDiagnostics()"
>
Run Diagnostics
</button>
<div class="actions-dropdown">
<button
type="button"
class="btn btn--secondary"
(click)="toggleActionsMenu()"
>
Actions
<span aria-hidden="true">&#9662;</span>
</button>
@if (showActionsMenu()) {
<div class="actions-dropdown__menu">
<button type="button" (click)="executeAction('restart')">
Restart Agent
</button>
<button type="button" (click)="executeAction('renew-certificate')">
Renew Certificate
</button>
<button type="button" (click)="executeAction('drain')">
Drain Tasks
</button>
<button type="button" (click)="executeAction('resume')">
Resume Tasks
</button>
<hr />
<button
type="button"
class="danger"
(click)="executeAction('remove')"
>
Remove Agent
</button>
</div>
}
</div>
</div>
</header>
<!-- Tags -->
<div class="detail-tags">
<span class="tag tag--env">{{ agentData.environment }}</span>
<span class="tag tag--version">v{{ agentData.version }}</span>
@if (agentData.tags) {
@for (tag of agentData.tags; track tag) {
<span class="tag">{{ tag }}</span>
}
}
</div>
<!-- Tabs -->
<nav class="tabs" role="tablist">
@for (tab of tabs; track tab.id) {
<button
type="button"
role="tab"
class="tab"
[class.tab--active]="activeTab() === tab.id"
[attr.aria-selected]="activeTab() === tab.id"
(click)="setActiveTab(tab.id)"
>
{{ tab.label }}
</button>
}
</nav>
<!-- Tab Content -->
<div class="tab-content" role="tabpanel">
@switch (activeTab()) {
@case ('overview') {
<section class="overview-section">
<!-- Quick Stats -->
<div class="stats-grid">
<div class="stat-card">
<span class="stat-card__label">Status</span>
<span class="stat-card__value" [style.color]="statusColor()">
{{ statusLabel() }}
</span>
</div>
<div class="stat-card">
<span class="stat-card__label">Last Heartbeat</span>
<span class="stat-card__value">{{ heartbeatLabel() }}</span>
</div>
<div class="stat-card">
<span class="stat-card__label">Capacity</span>
<span class="stat-card__value">{{ agentData.capacityPercent }}%</span>
</div>
<div class="stat-card">
<span class="stat-card__label">Active Tasks</span>
<span class="stat-card__value">{{ agentData.activeTasks }}</span>
</div>
<div class="stat-card">
<span class="stat-card__label">Queue Depth</span>
<span class="stat-card__value">{{ agentData.taskQueueDepth }}</span>
</div>
</div>
<!-- Resources -->
<div class="section-card">
<h2 class="section-card__title">Resource Utilization</h2>
<div class="resource-meters">
<div class="resource-meter">
<div class="resource-meter__header">
<span>CPU</span>
<span>{{ agentData.resources.cpuPercent }}%</span>
</div>
<div class="resource-meter__bar">
<div
class="resource-meter__fill"
[style.width.%]="agentData.resources.cpuPercent"
[style.background-color]="getCapacityColor(agentData.resources.cpuPercent)"
></div>
</div>
</div>
<div class="resource-meter">
<div class="resource-meter__header">
<span>Memory</span>
<span>{{ agentData.resources.memoryPercent }}%</span>
</div>
<div class="resource-meter__bar">
<div
class="resource-meter__fill"
[style.width.%]="agentData.resources.memoryPercent"
[style.background-color]="getCapacityColor(agentData.resources.memoryPercent)"
></div>
</div>
</div>
<div class="resource-meter">
<div class="resource-meter__header">
<span>Disk</span>
<span>{{ agentData.resources.diskPercent }}%</span>
</div>
<div class="resource-meter__bar">
<div
class="resource-meter__fill"
[style.width.%]="agentData.resources.diskPercent"
[style.background-color]="getCapacityColor(agentData.resources.diskPercent)"
></div>
</div>
</div>
</div>
</div>
<!-- Certificate -->
@if (agentData.certificate) {
<div class="section-card" [class.section-card--warning]="agentData.certificate.daysUntilExpiry <= 30">
<h2 class="section-card__title">Certificate</h2>
<dl class="detail-list">
<dt>Subject</dt>
<dd>{{ agentData.certificate.subject }}</dd>
<dt>Issuer</dt>
<dd>{{ agentData.certificate.issuer }}</dd>
<dt>Thumbprint</dt>
<dd><code>{{ agentData.certificate.thumbprint }}</code></dd>
<dt>Valid From</dt>
<dd>{{ formatDate(agentData.certificate.notBefore) }}</dd>
<dt>Valid To</dt>
<dd>
{{ formatDate(agentData.certificate.notAfter) }}
@if (agentData.certificate.daysUntilExpiry <= 30) {
<span class="warning-badge">
{{ agentData.certificate.daysUntilExpiry }} days remaining
</span>
}
</dd>
</dl>
</div>
}
<!-- Metadata -->
<div class="section-card">
<h2 class="section-card__title">Agent Information</h2>
<dl class="detail-list">
<dt>Agent ID</dt>
<dd><code>{{ agentData.id }}</code></dd>
<dt>Name</dt>
<dd>{{ agentData.name }}</dd>
<dt>Environment</dt>
<dd>{{ agentData.environment }}</dd>
<dt>Version</dt>
<dd>{{ agentData.version }}</dd>
<dt>Registered</dt>
<dd>{{ formatDate(agentData.registeredAt) }}</dd>
</dl>
</div>
</section>
}
@case ('health') {
<section class="health-section">
<st-agent-health-tab
[checks]="store.agentHealth()"
[isRunning]="isRunningHealth()"
(runChecks)="onRunHealthChecks()"
(rerunCheck)="onRerunHealthCheck($event)"
/>
</section>
}
@case ('tasks') {
<section class="tasks-section">
<st-agent-tasks-tab
[tasks]="store.agentTasks()"
[hasMoreTasks]="false"
(viewDetails)="onViewTaskDetails($event)"
(loadMore)="onLoadMoreTasks()"
/>
</section>
}
@case ('logs') {
<section class="logs-section">
<p class="placeholder">Log stream coming in future sprint</p>
</section>
}
@case ('config') {
<section class="config-section">
@if (agentData.config) {
<div class="section-card">
<h2 class="section-card__title">Configuration</h2>
<dl class="detail-list">
<dt>Max Concurrent Tasks</dt>
<dd>{{ agentData.config.maxConcurrentTasks }}</dd>
<dt>Heartbeat Interval</dt>
<dd>{{ agentData.config.heartbeatIntervalSeconds }}s</dd>
<dt>Task Timeout</dt>
<dd>{{ agentData.config.taskTimeoutSeconds }}s</dd>
<dt>Auto Update</dt>
<dd>{{ agentData.config.autoUpdate ? 'Enabled' : 'Disabled' }}</dd>
<dt>Log Level</dt>
<dd>{{ agentData.config.logLevel }}</dd>
</dl>
</div>
}
</section>
}
}
</div>
}
<!-- Action Confirmation Modal -->
@if (pendingAction()) {
<st-agent-action-modal
[action]="pendingAction()!"
[agent]="agent()"
[visible]="!!pendingAction()"
[isSubmitting]="isExecutingAction()"
(confirm)="onActionConfirmed($event)"
(cancel)="onActionCancelled()"
/>
}
<!-- Action Feedback Toast -->
@if (actionFeedback(); as feedback) {
<div
class="action-toast"
[class.action-toast--success]="feedback.type === 'success'"
[class.action-toast--error]="feedback.type === 'error'"
role="alert"
>
<span class="action-toast__icon" aria-hidden="true">
@if (feedback.type === 'success') { &#10003; }
@else { &#10007; }
</span>
<span class="action-toast__message">{{ feedback.message }}</span>
<button
type="button"
class="action-toast__close"
(click)="clearActionFeedback()"
aria-label="Dismiss"
>
&times;
</button>
</div>
}
</div>
`,
styles: [`
.agent-detail-page {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
/* Breadcrumb */
.breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
font-size: 0.875rem;
}
.breadcrumb__link {
color: var(--primary, #3b82f6);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.breadcrumb__separator {
color: var(--text-muted, #9ca3af);
}
.breadcrumb__current {
color: var(--text-secondary, #6b7280);
}
/* Header */
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.detail-header__info {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.status-indicator {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
margin-top: 0.5rem;
}
.detail-header__title h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #111827);
}
.detail-header__id {
font-size: 0.8125rem;
color: var(--text-muted, #9ca3af);
}
.detail-header__actions {
display: flex;
gap: 0.5rem;
}
/* Tags */
.detail-tags {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.tag {
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
background: var(--surface-secondary, #f3f4f6);
color: var(--text-secondary, #6b7280);
}
.tag--env {
background: var(--tag-env-bg, #dbeafe);
color: var(--tag-env-text, #1e40af);
}
.tag--version {
background: var(--tag-version-bg, #e5e7eb);
color: var(--tag-version-text, #374151);
}
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--border-default, #e5e7eb);
margin-bottom: 1.5rem;
}
.tab {
padding: 0.75rem 1.25rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary, #6b7280);
cursor: pointer;
transition: all 0.15s;
&:hover {
color: var(--text-primary, #111827);
}
&--active {
color: var(--primary, #3b82f6);
border-bottom-color: var(--primary, #3b82f6);
}
}
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
padding: 1rem;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
text-align: center;
}
.stat-card__label {
display: block;
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
text-transform: uppercase;
margin-bottom: 0.25rem;
}
.stat-card__value {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #111827);
}
/* Section Card */
.section-card {
padding: 1.25rem;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
margin-bottom: 1.5rem;
&--warning {
border-left: 3px solid var(--status-warning, #f59e0b);
}
}
.section-card__title {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #111827);
}
/* Resource Meters */
.resource-meters {
display: grid;
gap: 1rem;
}
.resource-meter__header {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
margin-bottom: 0.375rem;
}
.resource-meter__bar {
height: 8px;
background: var(--surface-secondary, #f3f4f6);
border-radius: 4px;
overflow: hidden;
}
.resource-meter__fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s;
}
/* Detail List */
.detail-list {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
margin: 0;
}
.detail-list dt {
font-weight: 500;
color: var(--text-secondary, #6b7280);
font-size: 0.8125rem;
}
.detail-list dd {
margin: 0;
font-size: 0.8125rem;
color: var(--text-primary, #111827);
}
.detail-list code {
font-size: 0.75rem;
background: var(--surface-secondary, #f3f4f6);
padding: 0.125rem 0.375rem;
border-radius: 3px;
}
.warning-badge {
display: inline-block;
margin-left: 0.5rem;
padding: 0.125rem 0.5rem;
background: var(--warning-bg, #fef3c7);
color: var(--warning-text, #92400e);
border-radius: 4px;
font-size: 0.75rem;
}
/* Actions Dropdown */
.actions-dropdown {
position: relative;
}
.actions-dropdown__menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.25rem;
min-width: 180px;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 100;
overflow: hidden;
button {
display: block;
width: 100%;
padding: 0.625rem 1rem;
background: none;
border: none;
text-align: left;
font-size: 0.875rem;
cursor: pointer;
&:hover {
background: var(--surface-hover, #f3f4f6);
}
&.danger {
color: var(--status-error, #ef4444);
}
}
hr {
margin: 0.25rem 0;
border: none;
border-top: 1px solid var(--border-default, #e5e7eb);
}
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
}
.btn--secondary {
background: var(--surface-primary, #ffffff);
border-color: var(--border-default, #e5e7eb);
color: var(--text-primary, #111827);
&:hover {
background: var(--surface-hover, #f3f4f6);
}
}
/* States */
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 4rem;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-default, #e5e7eb);
border-top-color: var(--primary, #3b82f6);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-state__message {
color: var(--status-error, #ef4444);
margin-bottom: 1rem;
}
.placeholder {
color: var(--text-muted, #9ca3af);
font-style: italic;
text-align: center;
padding: 2rem;
}
/* Action Toast */
.action-toast {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 1000;
animation: slide-in-toast 0.3s ease-out;
}
@keyframes slide-in-toast {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.action-toast--success {
border-left: 3px solid var(--status-success, #10b981);
}
.action-toast--error {
border-left: 3px solid var(--status-error, #ef4444);
}
.action-toast__icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 0.875rem;
font-weight: 600;
}
.action-toast--success .action-toast__icon {
background: rgba(16, 185, 129, 0.1);
color: var(--status-success, #10b981);
}
.action-toast--error .action-toast__icon {
background: rgba(239, 68, 68, 0.1);
color: var(--status-error, #ef4444);
}
.action-toast__message {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #111827);
}
.action-toast__close {
margin-left: 0.5rem;
padding: 0.25rem;
background: none;
border: none;
font-size: 1.25rem;
color: var(--text-muted, #9ca3af);
cursor: pointer;
&:hover {
color: var(--text-primary, #111827);
}
}
`],
})
interface ActionFeedback {
type: 'success' | 'error';
message: string;
}
export class AgentDetailPageComponent implements OnInit {
readonly store = inject(AgentStore);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
readonly activeTab = signal<DetailTab>('overview');
readonly showActionsMenu = signal(false);
readonly isRunningHealth = signal(false);
readonly pendingAction = signal<AgentAction | null>(null);
readonly isExecutingAction = signal(false);
readonly actionFeedback = signal<ActionFeedback | null>(null);
private feedbackTimeout: ReturnType<typeof setTimeout> | null = null;
readonly tabs: { id: DetailTab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'health', label: 'Health' },
{ id: 'tasks', label: 'Tasks' },
{ id: 'logs', label: 'Logs' },
{ id: 'config', label: 'Configuration' },
];
readonly agent = computed(() => this.store.selectedAgent());
readonly statusColor = computed(() =>
this.agent() ? getStatusColor(this.agent()!.status) : ''
);
readonly statusLabel = computed(() =>
this.agent() ? getStatusLabel(this.agent()!.status) : ''
);
readonly heartbeatLabel = computed(() =>
this.agent() ? formatHeartbeat(this.agent()!.lastHeartbeat) : ''
);
ngOnInit(): void {
const agentId = this.route.snapshot.paramMap.get('agentId');
if (agentId) {
this.store.selectAgent(agentId);
}
}
setActiveTab(tab: DetailTab): void {
this.activeTab.set(tab);
}
toggleActionsMenu(): void {
this.showActionsMenu.update((v) => !v);
}
runDiagnostics(): void {
const agentId = this.agent()?.id;
if (agentId) {
// Navigate to doctor with agent filter
this.router.navigate(['/ops/doctor'], { queryParams: { agent: agentId } });
}
}
executeAction(action: AgentAction): void {
this.showActionsMenu.set(false);
// Show confirmation modal for all actions
this.pendingAction.set(action);
}
onActionConfirmed(action: AgentAction): void {
const agentId = this.agent()?.id;
if (!agentId) {
this.pendingAction.set(null);
return;
}
this.isExecutingAction.set(true);
// Execute the action via store
this.store.executeAction({ agentId, action });
// Simulate API response (in real app, store would track this)
setTimeout(() => {
this.isExecutingAction.set(false);
this.pendingAction.set(null);
// Show success feedback
const actionLabels: Record<AgentAction, string> = {
restart: 'Agent restart initiated',
'renew-certificate': 'Certificate renewal started',
drain: 'Agent is now draining tasks',
resume: 'Agent is now accepting tasks',
remove: 'Agent removed successfully',
};
this.showFeedback('success', actionLabels[action] || 'Action completed');
}, 1500);
}
onActionCancelled(): void {
this.pendingAction.set(null);
}
showFeedback(type: 'success' | 'error', message: string): void {
// Clear any existing timeout
if (this.feedbackTimeout) {
clearTimeout(this.feedbackTimeout);
}
this.actionFeedback.set({ type, message });
// Auto-dismiss after 5 seconds
this.feedbackTimeout = setTimeout(() => {
this.actionFeedback.set(null);
}, 5000);
}
clearActionFeedback(): void {
if (this.feedbackTimeout) {
clearTimeout(this.feedbackTimeout);
}
this.actionFeedback.set(null);
}
formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
getCapacityColor(percent: number): string {
return getCapacityColor(percent);
}
// Health tab methods
onRunHealthChecks(): void {
const agentId = this.agent()?.id;
if (agentId) {
this.isRunningHealth.set(true);
this.store.fetchAgentHealth(agentId);
// Simulated delay for demo - in real app, health endpoint handles this
setTimeout(() => this.isRunningHealth.set(false), 2000);
}
}
onRerunHealthCheck(checkId: string): void {
console.log('Re-running health check:', checkId);
// Would call specific health check API
this.onRunHealthChecks();
}
// Tasks tab methods
onViewTaskDetails(task: AgentTask): void {
// Could open a drawer or navigate to task detail page
console.log('View task details:', task.taskId);
}
onLoadMoreTasks(): void {
const agentId = this.agent()?.id;
if (agentId) {
// Would call paginated tasks API
this.store.fetchAgentTasks(agentId);
}
}
}

View File

@@ -0,0 +1,541 @@
/**
* Agent Fleet Dashboard Component Tests
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-001 - Create Agent Fleet dashboard page
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { AgentFleetDashboardComponent } from './agent-fleet-dashboard.component';
import { AgentStore } from './services/agent.store';
import { Agent, AgentStatus } from './models/agent.models';
import { signal, WritableSignal } from '@angular/core';
describe('AgentFleetDashboardComponent', () => {
let component: AgentFleetDashboardComponent;
let fixture: ComponentFixture<AgentFleetDashboardComponent>;
let mockStore: jasmine.SpyObj<Partial<AgentStore>> & {
agents: WritableSignal<Agent[]>;
filteredAgents: WritableSignal<Agent[]>;
isLoading: WritableSignal<boolean>;
error: WritableSignal<string | null>;
summary: WritableSignal<any>;
selectedAgentId: WritableSignal<string | null>;
lastRefresh: WritableSignal<string | null>;
uniqueEnvironments: WritableSignal<string[]>;
uniqueVersions: WritableSignal<string[]>;
isRealtimeConnected: WritableSignal<boolean>;
realtimeConnectionStatus: WritableSignal<string>;
};
let router: Router;
const createMockAgent = (overrides: Partial<Agent> = {}): Agent => ({
id: `agent-${Math.random().toString(36).substr(2, 9)}`,
name: 'test-agent',
environment: 'production',
version: '2.5.0',
status: 'online',
lastHeartbeat: new Date().toISOString(),
registeredAt: '2026-01-01T00:00:00Z',
resources: {
cpuPercent: 45,
memoryPercent: 60,
diskPercent: 35,
},
activeTasks: 3,
taskQueueDepth: 2,
capacityPercent: 65,
...overrides,
});
const mockSummary = {
totalAgents: 10,
onlineAgents: 7,
degradedAgents: 2,
offlineAgents: 1,
totalCapacityPercent: 55,
totalActiveTasks: 25,
certificatesExpiringSoon: 1,
};
beforeEach(async () => {
mockStore = {
agents: signal<Agent[]>([]),
filteredAgents: signal<Agent[]>([]),
isLoading: signal(false),
error: signal(null),
summary: signal(mockSummary),
selectedAgentId: signal(null),
lastRefresh: signal(null),
uniqueEnvironments: signal(['development', 'staging', 'production']),
uniqueVersions: signal(['2.4.0', '2.5.0']),
isRealtimeConnected: signal(false),
realtimeConnectionStatus: signal('disconnected'),
fetchAgents: jasmine.createSpy('fetchAgents'),
fetchSummary: jasmine.createSpy('fetchSummary'),
enableRealtime: jasmine.createSpy('enableRealtime'),
disableRealtime: jasmine.createSpy('disableRealtime'),
reconnectRealtime: jasmine.createSpy('reconnectRealtime'),
startAutoRefresh: jasmine.createSpy('startAutoRefresh'),
stopAutoRefresh: jasmine.createSpy('stopAutoRefresh'),
setSearchFilter: jasmine.createSpy('setSearchFilter'),
setStatusFilter: jasmine.createSpy('setStatusFilter'),
setEnvironmentFilter: jasmine.createSpy('setEnvironmentFilter'),
setVersionFilter: jasmine.createSpy('setVersionFilter'),
clearFilters: jasmine.createSpy('clearFilters'),
} as any;
await TestBed.configureTestingModule({
imports: [AgentFleetDashboardComponent, RouterTestingModule],
providers: [{ provide: AgentStore, useValue: mockStore }],
}).compileComponents();
fixture = TestBed.createComponent(AgentFleetDashboardComponent);
component = fixture.componentInstance;
router = TestBed.inject(Router);
});
describe('initialization', () => {
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should fetch agents on init', () => {
fixture.detectChanges();
expect(mockStore.fetchAgents).toHaveBeenCalled();
});
it('should fetch summary on init', () => {
fixture.detectChanges();
expect(mockStore.fetchSummary).toHaveBeenCalled();
});
it('should enable realtime on init', () => {
fixture.detectChanges();
expect(mockStore.enableRealtime).toHaveBeenCalled();
});
it('should start auto refresh on init', () => {
fixture.detectChanges();
expect(mockStore.startAutoRefresh).toHaveBeenCalledWith(60000);
});
it('should stop auto refresh on destroy', () => {
fixture.detectChanges();
component.ngOnDestroy();
expect(mockStore.stopAutoRefresh).toHaveBeenCalled();
});
it('should disable realtime on destroy', () => {
fixture.detectChanges();
component.ngOnDestroy();
expect(mockStore.disableRealtime).toHaveBeenCalled();
});
});
describe('page header', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should display page title', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Agent Fleet');
});
it('should display subtitle', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Monitor and manage');
});
it('should have refresh button', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Refresh');
});
it('should have add agent button', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Add Agent');
});
});
describe('realtime status', () => {
it('should show disconnected status', () => {
mockStore.realtimeConnectionStatus.set('disconnected');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Disconnected');
});
it('should show connected status', () => {
mockStore.isRealtimeConnected.set(true);
mockStore.realtimeConnectionStatus.set('connected');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Live');
});
it('should show connecting status', () => {
mockStore.realtimeConnectionStatus.set('connecting');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Connecting');
});
it('should show retry button on error', () => {
mockStore.realtimeConnectionStatus.set('error');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Retry');
});
it('should reconnect on retry click', () => {
mockStore.realtimeConnectionStatus.set('error');
fixture.detectChanges();
component.reconnectRealtime();
expect(mockStore.reconnectRealtime).toHaveBeenCalled();
});
});
describe('KPI strip', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should display total agents count', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('10');
expect(compiled.textContent).toContain('Total Agents');
});
it('should display online agents count', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('7');
expect(compiled.textContent).toContain('Online');
});
it('should display degraded agents count', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Degraded');
});
it('should display offline agents count', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Offline');
});
it('should display average capacity', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('55%');
expect(compiled.textContent).toContain('Avg Capacity');
});
it('should display certificates expiring', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Certs Expiring');
});
});
describe('filtering', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should have search input', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.search-input')).toBeTruthy();
});
it('should update search filter on input', () => {
const event = { target: { value: 'test-search' } } as unknown as Event;
component.onSearchInput(event);
expect(component.searchQuery()).toBe('test-search');
expect(mockStore.setSearchFilter).toHaveBeenCalledWith('test-search');
});
it('should display status filter chips', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Online');
expect(compiled.textContent).toContain('Degraded');
expect(compiled.textContent).toContain('Offline');
});
it('should toggle status filter', () => {
component.toggleStatusFilter('online');
expect(component.selectedStatuses()).toContain('online');
expect(mockStore.setStatusFilter).toHaveBeenCalled();
});
it('should remove status from filter when already selected', () => {
component.toggleStatusFilter('online');
component.toggleStatusFilter('online');
expect(component.selectedStatuses()).not.toContain('online');
});
it('should check if status is selected', () => {
component.selectedStatuses.set(['online', 'degraded']);
expect(component.isStatusSelected('online')).toBe(true);
expect(component.isStatusSelected('offline')).toBe(false);
});
it('should display environment filter', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Environment');
});
it('should display version filter', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Version');
});
it('should update environment filter', () => {
const event = { target: { value: 'production' } } as unknown as Event;
component.onEnvironmentChange(event);
expect(component.selectedEnvironment()).toBe('production');
expect(mockStore.setEnvironmentFilter).toHaveBeenCalledWith(['production']);
});
it('should update version filter', () => {
const event = { target: { value: '2.5.0' } } as unknown as Event;
component.onVersionChange(event);
expect(component.selectedVersion()).toBe('2.5.0');
expect(mockStore.setVersionFilter).toHaveBeenCalledWith(['2.5.0']);
});
it('should clear all filters', () => {
component.searchQuery.set('test');
component.selectedEnvironment.set('prod');
component.selectedStatuses.set(['online']);
component.clearFilters();
expect(component.searchQuery()).toBe('');
expect(component.selectedEnvironment()).toBe('');
expect(component.selectedStatuses()).toEqual([]);
expect(mockStore.clearFilters).toHaveBeenCalled();
});
it('should detect active filters', () => {
expect(component.hasActiveFilters()).toBe(false);
component.searchQuery.set('test');
expect(component.hasActiveFilters()).toBe(true);
});
});
describe('view modes', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should default to grid view', () => {
expect(component.viewMode()).toBe('grid');
});
it('should switch to heatmap view', () => {
component.setViewMode('heatmap');
expect(component.viewMode()).toBe('heatmap');
});
it('should switch to table view', () => {
component.setViewMode('table');
expect(component.viewMode()).toBe('table');
});
it('should display view toggle buttons', () => {
const compiled = fixture.nativeElement;
const viewBtns = compiled.querySelectorAll('.view-btn');
expect(viewBtns.length).toBe(3);
});
it('should mark active view button', () => {
const compiled = fixture.nativeElement;
const activeBtn = compiled.querySelector('.view-btn--active');
expect(activeBtn).toBeTruthy();
});
});
describe('loading state', () => {
it('should show loading spinner when loading', () => {
mockStore.isLoading.set(true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.loading-state')).toBeTruthy();
expect(compiled.querySelector('.spinner')).toBeTruthy();
});
it('should hide loading state when not loading', () => {
mockStore.isLoading.set(false);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.loading-state')).toBeNull();
});
});
describe('error state', () => {
it('should show error message', () => {
mockStore.error.set('Failed to fetch agents');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Failed to fetch agents');
});
it('should show try again button', () => {
mockStore.error.set('Error');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Try Again');
});
});
describe('empty state', () => {
it('should show empty state when no agents', () => {
mockStore.filteredAgents.set([]);
mockStore.isLoading.set(false);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.empty-state')).toBeTruthy();
});
it('should show filter-specific empty message', () => {
mockStore.filteredAgents.set([]);
mockStore.agents.set([createMockAgent()]);
component.searchQuery.set('test');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('No agents match your filters');
});
it('should show add agent button when no agents at all', () => {
mockStore.filteredAgents.set([]);
mockStore.agents.set([]);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Add Your First Agent');
});
});
describe('agent grid', () => {
beforeEach(() => {
const agents = [
createMockAgent({ id: 'a1', name: 'agent-1' }),
createMockAgent({ id: 'a2', name: 'agent-2' }),
];
mockStore.filteredAgents.set(agents);
mockStore.agents.set(agents);
fixture.detectChanges();
});
it('should display agent count', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('2 of 2 agents');
});
it('should display agent grid', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.agent-grid')).toBeTruthy();
});
});
describe('navigation', () => {
beforeEach(() => {
fixture.detectChanges();
spyOn(router, 'navigate');
});
it('should navigate to agent detail on click', () => {
const agent = createMockAgent({ id: 'agent-123' });
component.onAgentClick(agent);
expect(router.navigate).toHaveBeenCalledWith(['/ops/agents', 'agent-123']);
});
it('should navigate to onboarding wizard', () => {
component.openOnboardingWizard();
expect(router.navigate).toHaveBeenCalledWith(['/ops/agents/onboard']);
});
});
describe('refresh', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should fetch agents on refresh', () => {
component.refresh();
expect(mockStore.fetchAgents).toHaveBeenCalledTimes(2); // init + refresh
});
it('should fetch summary on refresh', () => {
component.refresh();
expect(mockStore.fetchSummary).toHaveBeenCalledTimes(2);
});
});
describe('last refresh time', () => {
it('should display last refresh time', () => {
mockStore.lastRefresh.set('2026-01-18T12:00:00Z');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Last updated');
});
it('should format refresh time', () => {
const result = component.formatRefreshTime('2026-01-18T14:30:00Z');
expect(result).toContain(':');
});
});
describe('accessibility', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should have aria-label on KPI section', () => {
const compiled = fixture.nativeElement;
const kpiSection = compiled.querySelector('.kpi-strip');
expect(kpiSection.getAttribute('aria-label')).toBe('Fleet metrics');
});
it('should have aria-label on agent grid', () => {
mockStore.filteredAgents.set([createMockAgent()]);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const grid = compiled.querySelector('.agent-grid');
expect(grid.getAttribute('aria-label')).toBe('Agent list');
});
it('should have aria-labels on view toggle buttons', () => {
const compiled = fixture.nativeElement;
const viewBtns = compiled.querySelectorAll('.view-btn');
viewBtns.forEach((btn: HTMLElement) => {
expect(btn.getAttribute('aria-label')).toBeTruthy();
});
});
});
});

View File

@@ -0,0 +1,799 @@
/**
* Agent Fleet Dashboard Component
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-001 - Create Agent Fleet dashboard page
*
* Main dashboard for viewing and managing the agent fleet.
*/
import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { AgentStore } from './services/agent.store';
import { Agent, AgentStatus, getStatusColor, getStatusLabel } from './models/agent.models';
import { AgentCardComponent } from './components/agent-card/agent-card.component';
import { CapacityHeatmapComponent } from './components/capacity-heatmap/capacity-heatmap.component';
import { FleetComparisonComponent } from './components/fleet-comparison/fleet-comparison.component';
type ViewMode = 'grid' | 'heatmap' | 'table';
@Component({
selector: 'st-agent-fleet-dashboard',
standalone: true,
imports: [CommonModule, FormsModule, AgentCardComponent, CapacityHeatmapComponent, FleetComparisonComponent],
template: `
<div class="agent-fleet-dashboard">
<!-- Page Header -->
<header class="page-header">
<div class="page-header__title-section">
<h1 class="page-header__title">Agent Fleet</h1>
<p class="page-header__subtitle">Monitor and manage release orchestration agents</p>
</div>
<div class="page-header__actions">
<!-- Real-time connection status -->
<div class="realtime-status" [class.realtime-status--connected]="store.isRealtimeConnected()">
<span
class="realtime-status__indicator"
[class.realtime-status__indicator--connected]="store.isRealtimeConnected()"
[class.realtime-status__indicator--connecting]="store.realtimeConnectionStatus() === 'connecting' || store.realtimeConnectionStatus() === 'reconnecting'"
[class.realtime-status__indicator--error]="store.realtimeConnectionStatus() === 'error'"
></span>
<span class="realtime-status__label">
@switch (store.realtimeConnectionStatus()) {
@case ('connected') { Live }
@case ('connecting') { Connecting... }
@case ('reconnecting') { Reconnecting... }
@case ('error') { Offline }
@default { Disconnected }
}
</span>
@if (store.realtimeConnectionStatus() === 'error') {
<button type="button" class="btn btn--text btn--small" (click)="reconnectRealtime()">
Retry
</button>
}
</div>
<button type="button" class="btn btn--secondary" (click)="refresh()">
<span class="btn__icon" aria-hidden="true">&#8635;</span>
Refresh
</button>
<button type="button" class="btn btn--primary" (click)="openOnboardingWizard()">
<span class="btn__icon" aria-hidden="true">+</span>
Add Agent
</button>
</div>
</header>
<!-- KPI Strip -->
@if (store.summary(); as summary) {
<section class="kpi-strip" aria-label="Fleet metrics">
<div class="kpi-card">
<span class="kpi-card__value">{{ summary.totalAgents }}</span>
<span class="kpi-card__label">Total Agents</span>
</div>
<div class="kpi-card kpi-card--success">
<span class="kpi-card__value">{{ summary.onlineAgents }}</span>
<span class="kpi-card__label">Online</span>
</div>
<div class="kpi-card kpi-card--warning">
<span class="kpi-card__value">{{ summary.degradedAgents }}</span>
<span class="kpi-card__label">Degraded</span>
</div>
<div class="kpi-card kpi-card--danger">
<span class="kpi-card__value">{{ summary.offlineAgents }}</span>
<span class="kpi-card__label">Offline</span>
</div>
<div class="kpi-card">
<span class="kpi-card__value">{{ summary.totalCapacityPercent }}%</span>
<span class="kpi-card__label">Avg Capacity</span>
</div>
<div class="kpi-card">
<span class="kpi-card__value">{{ summary.totalActiveTasks }}</span>
<span class="kpi-card__label">Active Tasks</span>
</div>
@if (summary.certificatesExpiringSoon > 0) {
<div class="kpi-card kpi-card--warning">
<span class="kpi-card__value">{{ summary.certificatesExpiringSoon }}</span>
<span class="kpi-card__label">Certs Expiring</span>
</div>
}
</section>
}
<!-- Filters & Search -->
<section class="filter-bar">
<div class="filter-bar__search">
<input
type="search"
class="search-input"
placeholder="Search agents by name or ID..."
[value]="searchQuery()"
(input)="onSearchInput($event)"
/>
</div>
<div class="filter-bar__filters">
<!-- Status Filter -->
<div class="filter-group">
<label class="filter-group__label">Status</label>
<div class="filter-chips">
@for (status of statusOptions; track status.value) {
<button
type="button"
class="filter-chip"
[class.filter-chip--active]="isStatusSelected(status.value)"
[style.--chip-color]="status.color"
(click)="toggleStatusFilter(status.value)"
>
{{ status.label }}
</button>
}
</div>
</div>
<!-- Environment Filter -->
@if (store.uniqueEnvironments().length > 1) {
<div class="filter-group">
<label class="filter-group__label">Environment</label>
<select
class="filter-select"
[value]="selectedEnvironment()"
(change)="onEnvironmentChange($event)"
>
<option value="">All Environments</option>
@for (env of store.uniqueEnvironments(); track env) {
<option [value]="env">{{ env }}</option>
}
</select>
</div>
}
<!-- Version Filter -->
@if (store.uniqueVersions().length > 1) {
<div class="filter-group">
<label class="filter-group__label">Version</label>
<select
class="filter-select"
[value]="selectedVersion()"
(change)="onVersionChange($event)"
>
<option value="">All Versions</option>
@for (version of store.uniqueVersions(); track version) {
<option [value]="version">v{{ version }}</option>
}
</select>
</div>
}
<button
type="button"
class="btn btn--text"
(click)="clearFilters()"
[disabled]="!hasActiveFilters()"
>
Clear Filters
</button>
</div>
</section>
<!-- View Toggle -->
<div class="view-controls">
<span class="view-controls__count">
{{ store.filteredAgents().length }} of {{ store.agents().length }} agents
</span>
<div class="view-controls__toggle">
<button
type="button"
class="view-btn"
[class.view-btn--active]="viewMode() === 'grid'"
(click)="setViewMode('grid')"
aria-label="Grid view"
title="Card grid"
>
<span aria-hidden="true">&#9638;&#9638;</span>
</button>
<button
type="button"
class="view-btn"
[class.view-btn--active]="viewMode() === 'heatmap'"
(click)="setViewMode('heatmap')"
aria-label="Heatmap view"
title="Capacity heatmap"
>
<span aria-hidden="true">&#9632;</span>
</button>
<button
type="button"
class="view-btn"
[class.view-btn--active]="viewMode() === 'table'"
(click)="setViewMode('table')"
aria-label="Table view"
title="Comparison table"
>
<span aria-hidden="true">&#9776;</span>
</button>
</div>
</div>
<!-- Loading State -->
@if (store.isLoading()) {
<div class="loading-state">
<div class="spinner"></div>
<p>Loading agents...</p>
</div>
}
<!-- Error State -->
@if (store.error()) {
<div class="error-state">
<p class="error-state__message">{{ store.error() }}</p>
<button type="button" class="btn btn--secondary" (click)="refresh()">Try Again</button>
</div>
}
<!-- Content Views -->
@if (!store.isLoading() && !store.error()) {
@if (store.filteredAgents().length > 0) {
<!-- Grid View -->
@if (viewMode() === 'grid') {
<section class="agent-grid" aria-label="Agent list">
@for (agent of store.filteredAgents(); track agent.id) {
<st-agent-card
[agent]="agent"
[selected]="store.selectedAgentId() === agent.id"
(cardClick)="onAgentClick($event)"
(menuClick)="onAgentMenuClick($event)"
/>
}
</section>
}
<!-- Heatmap View -->
@if (viewMode() === 'heatmap') {
<st-capacity-heatmap
[agents]="store.filteredAgents()"
(agentClick)="onAgentClick($event)"
/>
}
<!-- Table View -->
@if (viewMode() === 'table') {
<st-fleet-comparison
[agents]="store.filteredAgents()"
(viewAgent)="onAgentClick($event)"
/>
}
} @else {
<div class="empty-state">
@if (hasActiveFilters()) {
<p class="empty-state__message">No agents match your filters</p>
<button type="button" class="btn btn--secondary" (click)="clearFilters()">
Clear Filters
</button>
} @else {
<p class="empty-state__message">No agents registered yet</p>
<button type="button" class="btn btn--primary" (click)="openOnboardingWizard()">
Add Your First Agent
</button>
}
</div>
}
}
<!-- Last Refresh -->
@if (store.lastRefresh()) {
<footer class="page-footer">
<span class="page-footer__refresh">Last updated: {{ formatRefreshTime(store.lastRefresh()!) }}</span>
</footer>
}
</div>
`,
styles: [`
.agent-fleet-dashboard {
padding: 1.5rem;
max-width: 1600px;
margin: 0 auto;
}
/* Page Header */
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.page-header__title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #111827);
}
.page-header__subtitle {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: var(--text-secondary, #6b7280);
}
.page-header__actions {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Real-time Status */
.realtime-status {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background: var(--surface-secondary, #f9fafb);
border-radius: 9999px;
font-size: 0.75rem;
color: var(--text-secondary, #6b7280);
}
.realtime-status--connected {
background: rgba(16, 185, 129, 0.1);
color: var(--status-success, #10b981);
}
.realtime-status__indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--text-muted, #9ca3af);
}
.realtime-status__indicator--connected {
background: var(--status-success, #10b981);
animation: pulse-connected 2s infinite;
}
.realtime-status__indicator--connecting {
background: var(--status-warning, #f59e0b);
animation: pulse-connecting 1s infinite;
}
.realtime-status__indicator--error {
background: var(--status-error, #ef4444);
}
@keyframes pulse-connected {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes pulse-connecting {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.6; transform: scale(0.8); }
}
.realtime-status__label {
font-weight: 500;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
border: 1px solid transparent;
}
.btn--primary {
background: var(--primary, #3b82f6);
color: white;
&:hover {
background: var(--primary-hover, #2563eb);
}
}
.btn--secondary {
background: var(--surface-primary, #ffffff);
border-color: var(--border-default, #e5e7eb);
color: var(--text-primary, #111827);
&:hover {
background: var(--surface-hover, #f3f4f6);
}
}
.btn--text {
background: transparent;
color: var(--primary, #3b82f6);
padding: 0.5rem;
&:hover {
background: var(--surface-hover, #f3f4f6);
}
&:disabled {
color: var(--text-muted, #9ca3af);
cursor: not-allowed;
}
}
.btn__icon {
font-size: 1rem;
}
/* KPI Strip */
.kpi-strip {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.kpi-card {
flex: 1;
min-width: 120px;
padding: 1rem;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
text-align: center;
&--success {
border-left: 3px solid var(--status-success, #10b981);
}
&--warning {
border-left: 3px solid var(--status-warning, #f59e0b);
}
&--danger {
border-left: 3px solid var(--status-error, #ef4444);
}
}
.kpi-card__value {
display: block;
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary, #111827);
}
.kpi-card__label {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
text-transform: uppercase;
letter-spacing: 0.02em;
}
/* Filter Bar */
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem;
background: var(--surface-secondary, #f9fafb);
border-radius: 8px;
margin-bottom: 1rem;
}
.filter-bar__search {
flex: 1;
min-width: 200px;
}
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 6px;
font-size: 0.875rem;
&:focus {
outline: none;
border-color: var(--primary, #3b82f6);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
}
.filter-bar__filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-group__label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #6b7280);
}
.filter-chips {
display: flex;
gap: 0.375rem;
}
.filter-chip {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
border: 1px solid var(--border-default, #e5e7eb);
background: var(--surface-primary, #ffffff);
color: var(--text-secondary, #6b7280);
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: var(--chip-color, var(--primary, #3b82f6));
}
&--active {
background: var(--chip-color, var(--primary, #3b82f6));
border-color: var(--chip-color, var(--primary, #3b82f6));
color: white;
}
}
.filter-select {
padding: 0.375rem 0.75rem;
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 6px;
font-size: 0.8125rem;
background: var(--surface-primary, #ffffff);
&:focus {
outline: none;
border-color: var(--primary, #3b82f6);
}
}
/* View Controls */
.view-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.view-controls__count {
font-size: 0.8125rem;
color: var(--text-secondary, #6b7280);
}
.view-controls__toggle {
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: var(--surface-secondary, #f3f4f6);
border-radius: 6px;
}
.view-btn {
padding: 0.375rem 0.5rem;
border: none;
background: transparent;
border-radius: 4px;
cursor: pointer;
color: var(--text-muted, #9ca3af);
font-size: 1rem;
&:hover {
color: var(--text-primary, #111827);
}
&--active {
background: var(--surface-primary, #ffffff);
color: var(--text-primary, #111827);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
}
/* Agent Grid */
.agent-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
/* States */
.loading-state,
.error-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
text-align: center;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--border-default, #e5e7eb);
border-top-color: var(--primary, #3b82f6);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-state__message {
color: var(--status-error, #ef4444);
margin-bottom: 1rem;
}
.empty-state__message {
color: var(--text-secondary, #6b7280);
margin-bottom: 1rem;
}
/* Footer */
.page-footer {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-default, #e5e7eb);
text-align: center;
}
.page-footer__refresh {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
}
/* Responsive */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
gap: 1rem;
}
.page-header__actions {
width: 100%;
}
.kpi-strip {
flex-wrap: wrap;
}
.kpi-card {
min-width: calc(50% - 0.5rem);
}
.filter-bar {
flex-direction: column;
}
.filter-bar__filters {
width: 100%;
}
}
`],
})
export class AgentFleetDashboardComponent implements OnInit, OnDestroy {
readonly store = inject(AgentStore);
private readonly router = inject(Router);
// Local state
readonly searchQuery = signal('');
readonly selectedEnvironment = signal('');
readonly selectedVersion = signal('');
readonly viewMode = signal<ViewMode>('grid');
readonly selectedStatuses = signal<AgentStatus[]>([]);
readonly statusOptions = [
{ value: 'online' as AgentStatus, label: 'Online', color: getStatusColor('online') },
{ value: 'degraded' as AgentStatus, label: 'Degraded', color: getStatusColor('degraded') },
{ value: 'offline' as AgentStatus, label: 'Offline', color: getStatusColor('offline') },
];
readonly hasActiveFilters = computed(() => {
return (
this.searchQuery() !== '' ||
this.selectedEnvironment() !== '' ||
this.selectedVersion() !== '' ||
this.selectedStatuses().length > 0
);
});
ngOnInit(): void {
this.store.fetchAgents();
this.store.fetchSummary();
// Enable real-time updates with WebSocket fallback to polling
this.store.enableRealtime();
// Keep polling as fallback (longer interval when real-time is connected)
this.store.startAutoRefresh(60000);
}
ngOnDestroy(): void {
this.store.stopAutoRefresh();
this.store.disableRealtime();
}
refresh(): void {
this.store.fetchAgents();
this.store.fetchSummary();
}
reconnectRealtime(): void {
this.store.reconnectRealtime();
}
onSearchInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchQuery.set(input.value);
this.store.setSearchFilter(input.value);
}
toggleStatusFilter(status: AgentStatus): void {
const current = this.selectedStatuses();
const updated = current.includes(status)
? current.filter((s) => s !== status)
: [...current, status];
this.selectedStatuses.set(updated);
this.store.setStatusFilter(updated);
}
isStatusSelected(status: AgentStatus): boolean {
return this.selectedStatuses().includes(status);
}
onEnvironmentChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedEnvironment.set(select.value);
this.store.setEnvironmentFilter(select.value ? [select.value] : []);
}
onVersionChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedVersion.set(select.value);
this.store.setVersionFilter(select.value ? [select.value] : []);
}
clearFilters(): void {
this.searchQuery.set('');
this.selectedEnvironment.set('');
this.selectedVersion.set('');
this.selectedStatuses.set([]);
this.store.clearFilters();
}
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
onAgentClick(agent: Agent): void {
this.router.navigate(['/ops/agents', agent.id]);
}
onAgentMenuClick(event: { agent: Agent; event: MouseEvent }): void {
// TODO: Open context menu with actions
console.log('Menu clicked for agent:', event.agent.id);
}
openOnboardingWizard(): void {
this.router.navigate(['/ops/agents/onboard']);
}
formatRefreshTime(timestamp: string): string {
return new Date(timestamp).toLocaleTimeString();
}
}

View File

@@ -0,0 +1,472 @@
/**
* Agent Onboard Wizard Component Tests
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-010 - Add agent onboarding wizard
*/
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { Router } from '@angular/router';
import { RouterTestingModule } from '@angular/router/testing';
import { AgentOnboardWizardComponent } from './agent-onboard-wizard.component';
describe('AgentOnboardWizardComponent', () => {
let component: AgentOnboardWizardComponent;
let fixture: ComponentFixture<AgentOnboardWizardComponent>;
let router: Router;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AgentOnboardWizardComponent, RouterTestingModule],
}).compileComponents();
fixture = TestBed.createComponent(AgentOnboardWizardComponent);
component = fixture.componentInstance;
router = TestBed.inject(Router);
fixture.detectChanges();
});
describe('rendering', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
it('should display wizard title', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Add New Agent');
});
it('should display back to fleet link', () => {
const compiled = fixture.nativeElement;
const backLink = compiled.querySelector('.wizard-header__back');
expect(backLink).toBeTruthy();
expect(backLink.textContent).toContain('Back to Fleet');
});
it('should display progress steps', () => {
const compiled = fixture.nativeElement;
const steps = compiled.querySelectorAll('.progress-step');
expect(steps.length).toBe(5);
});
it('should show step labels', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Environment');
expect(compiled.textContent).toContain('Configure');
expect(compiled.textContent).toContain('Install');
expect(compiled.textContent).toContain('Verify');
expect(compiled.textContent).toContain('Complete');
});
});
describe('wizard navigation', () => {
it('should start at environment step', () => {
expect(component.currentStep()).toBe('environment');
});
it('should disable previous button on first step', () => {
const compiled = fixture.nativeElement;
const prevBtn = compiled.querySelector('.wizard-footer .btn--secondary');
expect(prevBtn.disabled).toBe(true);
});
it('should disable next button until environment selected', () => {
const compiled = fixture.nativeElement;
const nextBtn = compiled.querySelector('.wizard-footer .btn--primary');
expect(nextBtn.disabled).toBe(true);
});
it('should enable next button when environment selected', () => {
component.selectEnvironment('production');
fixture.detectChanges();
const compiled = fixture.nativeElement;
const nextBtn = compiled.querySelector('.wizard-footer .btn--primary');
expect(nextBtn.disabled).toBe(false);
});
it('should move to next step', () => {
component.selectEnvironment('production');
component.nextStep();
expect(component.currentStep()).toBe('configure');
});
it('should move to previous step', () => {
component.selectEnvironment('production');
component.nextStep();
component.previousStep();
expect(component.currentStep()).toBe('environment');
});
it('should mark completed steps', () => {
component.selectEnvironment('production');
component.nextStep();
fixture.detectChanges();
expect(component.isStepCompleted('environment')).toBe(true);
expect(component.isStepCompleted('configure')).toBe(false);
});
it('should apply active class to current step', () => {
const compiled = fixture.nativeElement;
const activeStep = compiled.querySelector('.progress-step--active');
expect(activeStep).toBeTruthy();
});
});
describe('environment step', () => {
it('should display environment options', () => {
const compiled = fixture.nativeElement;
const envOptions = compiled.querySelectorAll('.env-option');
expect(envOptions.length).toBe(3);
});
it('should display environment names and descriptions', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Development');
expect(compiled.textContent).toContain('Staging');
expect(compiled.textContent).toContain('Production');
});
it('should select environment on click', () => {
const compiled = fixture.nativeElement;
const prodOption = compiled.querySelectorAll('.env-option')[2];
prodOption.click();
fixture.detectChanges();
expect(component.selectedEnvironment()).toBe('production');
});
it('should apply selected class to chosen environment', () => {
component.selectEnvironment('staging');
fixture.detectChanges();
const compiled = fixture.nativeElement;
const selectedOption = compiled.querySelector('.env-option--selected');
expect(selectedOption).toBeTruthy();
expect(selectedOption.textContent).toContain('Staging');
});
});
describe('configure step', () => {
beforeEach(() => {
component.selectEnvironment('production');
component.nextStep();
fixture.detectChanges();
});
it('should display configuration form', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Configure Agent');
});
it('should have agent name input', () => {
const compiled = fixture.nativeElement;
const nameInput = compiled.querySelector('#agentName');
expect(nameInput).toBeTruthy();
});
it('should have max tasks input', () => {
const compiled = fixture.nativeElement;
const maxTasksInput = compiled.querySelector('#maxTasks');
expect(maxTasksInput).toBeTruthy();
});
it('should update agent name on input', () => {
const event = { target: { value: 'my-new-agent' } } as unknown as Event;
component.onNameInput(event);
expect(component.agentName()).toBe('my-new-agent');
});
it('should update max tasks on input', () => {
const event = { target: { value: '25' } } as unknown as Event;
component.onMaxTasksInput(event);
expect(component.maxTasks()).toBe(25);
});
it('should disable next until name entered', () => {
expect(component.canProceed()).toBe(false);
});
it('should enable next when name entered', () => {
component.agentName.set('test-agent');
fixture.detectChanges();
expect(component.canProceed()).toBe(true);
});
});
describe('install step', () => {
beforeEach(() => {
component.selectEnvironment('production');
component.nextStep();
component.agentName.set('my-agent');
component.nextStep();
fixture.detectChanges();
});
it('should display install instructions', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Install Agent');
expect(compiled.textContent).toContain('Run the following command');
});
it('should display docker command', () => {
const compiled = fixture.nativeElement;
const command = compiled.querySelector('.install-command pre');
expect(command).toBeTruthy();
expect(command.textContent).toContain('docker run');
});
it('should include agent name in command', () => {
const command = component.installCommand();
expect(command).toContain('my-agent');
});
it('should include environment in command', () => {
const command = component.installCommand();
expect(command).toContain('production');
});
it('should have copy button', () => {
const compiled = fixture.nativeElement;
const copyBtn = compiled.querySelector('.copy-btn');
expect(copyBtn).toBeTruthy();
expect(copyBtn.textContent).toContain('Copy');
});
it('should display requirements', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Requirements');
expect(compiled.textContent).toContain('Docker');
expect(compiled.textContent).toContain('Network access');
});
it('should always allow proceeding from install step', () => {
expect(component.canProceed()).toBe(true);
});
});
describe('copy command', () => {
beforeEach(() => {
component.selectEnvironment('production');
component.nextStep();
component.agentName.set('my-agent');
component.nextStep();
fixture.detectChanges();
});
it('should copy command to clipboard', fakeAsync(() => {
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
component.copyCommand();
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(component.installCommand());
}));
it('should show copied feedback', fakeAsync(() => {
spyOn(navigator.clipboard, 'writeText').and.returnValue(Promise.resolve());
component.copyCommand();
expect(component.copied()).toBe(true);
tick(2000);
expect(component.copied()).toBe(false);
}));
});
describe('verify step', () => {
beforeEach(() => {
component.selectEnvironment('production');
component.nextStep();
component.agentName.set('my-agent');
component.nextStep();
component.nextStep();
fixture.detectChanges();
});
it('should display verify instructions', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Verify Connection');
});
it('should start verification automatically', () => {
expect(component.isVerifying()).toBe(true);
});
it('should show spinner during verification', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.spinner')).toBeTruthy();
});
it('should complete verification after timeout', fakeAsync(() => {
tick(3000);
expect(component.isVerifying()).toBe(false);
expect(component.isVerified()).toBe(true);
}));
it('should show success icon after verification', fakeAsync(() => {
tick(3000);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.success-icon')).toBeTruthy();
}));
it('should disable next until verified', () => {
expect(component.canProceed()).toBe(false);
});
it('should enable next after verification', fakeAsync(() => {
tick(3000);
expect(component.canProceed()).toBe(true);
}));
it('should show troubleshooting section', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.troubleshooting')).toBeTruthy();
});
it('should allow manual retry', fakeAsync(() => {
component.isVerifying.set(false);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const retryBtn = compiled.querySelector('.verify-status .btn--secondary');
expect(retryBtn).toBeTruthy();
retryBtn.click();
expect(component.isVerifying()).toBe(true);
tick(3000);
}));
});
describe('complete step', () => {
beforeEach(fakeAsync(() => {
component.selectEnvironment('production');
component.nextStep();
component.agentName.set('my-agent');
component.nextStep();
component.nextStep();
tick(3000);
component.nextStep();
fixture.detectChanges();
}));
it('should display completion message', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Agent Onboarded');
});
it('should show success icon', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.complete-icon')).toBeTruthy();
});
it('should show view all agents link', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('View All Agents');
});
it('should show view agent details link', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('View Agent Details');
});
it('should hide navigation footer on complete', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.wizard-footer')).toBeNull();
});
});
describe('canProceed', () => {
it('should return false on environment step without selection', () => {
expect(component.canProceed()).toBe(false);
});
it('should return true on environment step with selection', () => {
component.selectedEnvironment.set('production');
expect(component.canProceed()).toBe(true);
});
it('should return false on configure step without name', () => {
component.currentStep.set('configure');
expect(component.canProceed()).toBe(false);
});
it('should return false on configure step with invalid max tasks', () => {
component.currentStep.set('configure');
component.agentName.set('test');
component.maxTasks.set(0);
expect(component.canProceed()).toBe(false);
});
it('should return true on configure step with valid data', () => {
component.currentStep.set('configure');
component.agentName.set('test');
component.maxTasks.set(10);
expect(component.canProceed()).toBe(true);
});
it('should return true on install step', () => {
component.currentStep.set('install');
expect(component.canProceed()).toBe(true);
});
it('should return false on verify step when not verified', () => {
component.currentStep.set('verify');
expect(component.canProceed()).toBe(false);
});
it('should return true on verify step when verified', () => {
component.currentStep.set('verify');
component.isVerified.set(true);
expect(component.canProceed()).toBe(true);
});
});
describe('installCommand computed', () => {
it('should generate correct docker command', () => {
component.selectEnvironment('staging');
component.agentName.set('test-agent');
const command = component.installCommand();
expect(command).toContain('docker run');
expect(command).toContain('STELLA_AGENT_NAME="test-agent"');
expect(command).toContain('STELLA_ENVIRONMENT="staging"');
expect(command).toContain('stella-ops/agent:latest');
});
it('should use default name when empty', () => {
component.selectEnvironment('production');
component.agentName.set('');
const command = component.installCommand();
expect(command).toContain('STELLA_AGENT_NAME="my-agent"');
});
});
describe('accessibility', () => {
it('should have progress navigation with aria-label', () => {
const compiled = fixture.nativeElement;
const nav = compiled.querySelector('.wizard-progress');
expect(nav.getAttribute('aria-label')).toBe('Wizard progress');
});
it('should have labels for form inputs', () => {
component.selectEnvironment('production');
component.nextStep();
fixture.detectChanges();
const compiled = fixture.nativeElement;
const nameLabel = compiled.querySelector('label[for="agentName"]');
const maxTasksLabel = compiled.querySelector('label[for="maxTasks"]');
expect(nameLabel).toBeTruthy();
expect(maxTasksLabel).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,656 @@
/**
* Agent Onboard Wizard Component
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-010 - Add agent onboarding wizard
*
* Multi-step wizard for onboarding new agents.
*/
import { Component, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { inject } from '@angular/core';
type WizardStep = 'environment' | 'configure' | 'install' | 'verify' | 'complete';
@Component({
selector: 'st-agent-onboard-wizard',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink],
template: `
<div class="onboard-wizard">
<!-- Header -->
<header class="wizard-header">
<a routerLink="/ops/agents" class="wizard-header__back">
&larr; Back to Fleet
</a>
<h1 class="wizard-header__title">Add New Agent</h1>
</header>
<!-- Progress -->
<nav class="wizard-progress" aria-label="Wizard progress">
@for (step of steps; track step.id; let i = $index) {
<div
class="progress-step"
[class.progress-step--active]="currentStep() === step.id"
[class.progress-step--completed]="isStepCompleted(step.id)"
>
<span class="progress-step__number">{{ i + 1 }}</span>
<span class="progress-step__label">{{ step.label }}</span>
</div>
}
</nav>
<!-- Step Content -->
<main class="wizard-content">
@switch (currentStep()) {
@case ('environment') {
<section class="step-content">
<h2>Select Environment</h2>
<p>Choose the target environment for this agent.</p>
<div class="environment-options">
@for (env of environments; track env.id) {
<button
type="button"
class="env-option"
[class.env-option--selected]="selectedEnvironment() === env.id"
(click)="selectEnvironment(env.id)"
>
<span class="env-option__name">{{ env.name }}</span>
<span class="env-option__desc">{{ env.description }}</span>
</button>
}
</div>
</section>
}
@case ('configure') {
<section class="step-content">
<h2>Configure Agent</h2>
<p>Set up agent parameters.</p>
<div class="form-group">
<label for="agentName">Agent Name</label>
<input
id="agentName"
type="text"
class="form-input"
placeholder="e.g., prod-agent-01"
[value]="agentName()"
(input)="onNameInput($event)"
/>
</div>
<div class="form-group">
<label for="maxTasks">Max Concurrent Tasks</label>
<input
id="maxTasks"
type="number"
class="form-input"
min="1"
max="100"
[value]="maxTasks()"
(input)="onMaxTasksInput($event)"
/>
</div>
</section>
}
@case ('install') {
<section class="step-content">
<h2>Install Agent</h2>
<p>Run the following command on the target machine:</p>
<div class="install-command">
<pre>{{ installCommand() }}</pre>
<button
type="button"
class="copy-btn"
(click)="copyCommand()"
>
{{ copied() ? 'Copied!' : 'Copy' }}
</button>
</div>
<div class="install-notes">
<h3>Requirements</h3>
<ul>
<li>Docker or Podman installed</li>
<li>Network access to control plane</li>
<li>Minimum 2GB RAM, 2 CPU cores</li>
</ul>
</div>
</section>
}
@case ('verify') {
<section class="step-content">
<h2>Verify Connection</h2>
<p>Waiting for agent to connect...</p>
<div class="verify-status">
@if (isVerifying()) {
<div class="spinner"></div>
<p>Listening for agent heartbeat...</p>
} @else if (isVerified()) {
<div class="success-icon">&#10003;</div>
<p>Agent connected successfully!</p>
} @else {
<div class="pending-icon">&#8987;</div>
<p>Agent not yet connected</p>
<button type="button" class="btn btn--secondary" (click)="startVerification()">
Retry
</button>
}
</div>
<details class="troubleshooting">
<summary>Connection issues?</summary>
<ul>
<li>Check firewall allows outbound HTTPS</li>
<li>Verify environment variables are set correctly</li>
<li>Check agent logs: <code>docker logs stella-agent</code></li>
</ul>
</details>
</section>
}
@case ('complete') {
<section class="step-content step-content--center">
<div class="complete-icon">&#10003;</div>
<h2>Agent Onboarded!</h2>
<p>Your agent is now ready to receive tasks.</p>
<div class="complete-actions">
<a routerLink="/ops/agents" class="btn btn--secondary">
View All Agents
</a>
<a [routerLink]="['/ops/agents', newAgentId()]" class="btn btn--primary">
View Agent Details
</a>
</div>
</section>
}
}
</main>
<!-- Navigation -->
@if (currentStep() !== 'complete') {
<footer class="wizard-footer">
<button
type="button"
class="btn btn--secondary"
[disabled]="currentStep() === 'environment'"
(click)="previousStep()"
>
Previous
</button>
<button
type="button"
class="btn btn--primary"
[disabled]="!canProceed()"
(click)="nextStep()"
>
{{ currentStep() === 'verify' ? 'Finish' : 'Next' }}
</button>
</footer>
}
</div>
`,
styles: [`
.onboard-wizard {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
.wizard-header {
margin-bottom: 2rem;
}
.wizard-header__back {
display: inline-block;
margin-bottom: 0.5rem;
color: var(--primary, #3b82f6);
text-decoration: none;
font-size: 0.875rem;
&:hover {
text-decoration: underline;
}
}
.wizard-header__title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
/* Progress */
.wizard-progress {
display: flex;
justify-content: space-between;
margin-bottom: 2rem;
position: relative;
&::before {
content: '';
position: absolute;
top: 15px;
left: 40px;
right: 40px;
height: 2px;
background: var(--border-default, #e5e7eb);
}
}
.progress-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 1;
}
.progress-step__number {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--surface-primary, #ffffff);
border: 2px solid var(--border-default, #e5e7eb);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.progress-step__label {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
}
.progress-step--active .progress-step__number {
border-color: var(--primary, #3b82f6);
color: var(--primary, #3b82f6);
}
.progress-step--active .progress-step__label {
color: var(--primary, #3b82f6);
font-weight: 500;
}
.progress-step--completed .progress-step__number {
background: var(--primary, #3b82f6);
border-color: var(--primary, #3b82f6);
color: white;
}
/* Content */
.wizard-content {
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
padding: 2rem;
min-height: 300px;
}
.step-content h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
.step-content > p {
color: var(--text-secondary, #6b7280);
margin-bottom: 1.5rem;
}
.step-content--center {
text-align: center;
}
/* Environment Options */
.environment-options {
display: grid;
gap: 1rem;
}
.env-option {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 1rem;
border: 2px solid var(--border-default, #e5e7eb);
border-radius: 8px;
background: var(--surface-primary, #ffffff);
cursor: pointer;
text-align: left;
transition: all 0.15s;
&:hover {
border-color: var(--primary, #3b82f6);
}
&--selected {
border-color: var(--primary, #3b82f6);
background: rgba(59, 130, 246, 0.05);
}
}
.env-option__name {
font-weight: 600;
margin-bottom: 0.25rem;
}
.env-option__desc {
font-size: 0.8125rem;
color: var(--text-secondary, #6b7280);
}
/* Form */
.form-group {
margin-bottom: 1.5rem;
label {
display: block;
font-weight: 500;
margin-bottom: 0.5rem;
}
}
.form-input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 6px;
font-size: 0.875rem;
&:focus {
outline: none;
border-color: var(--primary, #3b82f6);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
}
/* Install Command */
.install-command {
position: relative;
background: var(--surface-code, #1f2937);
border-radius: 8px;
margin-bottom: 1.5rem;
pre {
margin: 0;
padding: 1rem;
color: #e5e7eb;
font-size: 0.8125rem;
overflow-x: auto;
}
.copy-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.375rem 0.75rem;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 4px;
color: #e5e7eb;
font-size: 0.75rem;
cursor: pointer;
&:hover {
background: rgba(255, 255, 255, 0.2);
}
}
}
.install-notes {
h3 {
font-size: 0.875rem;
margin: 0 0 0.5rem;
}
ul {
margin: 0;
padding-left: 1.25rem;
font-size: 0.8125rem;
color: var(--text-secondary, #6b7280);
}
}
/* Verify */
.verify-status {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
}
.spinner {
width: 48px;
height: 48px;
border: 3px solid var(--border-default, #e5e7eb);
border-top-color: var(--primary, #3b82f6);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.success-icon,
.pending-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
margin-bottom: 1rem;
}
.success-icon {
background: var(--status-success, #10b981);
color: white;
}
.pending-icon {
background: var(--surface-secondary, #f3f4f6);
color: var(--text-muted, #9ca3af);
}
.troubleshooting {
margin-top: 2rem;
font-size: 0.8125rem;
summary {
cursor: pointer;
color: var(--primary, #3b82f6);
}
ul {
margin-top: 0.5rem;
padding-left: 1.25rem;
color: var(--text-secondary, #6b7280);
}
code {
background: var(--surface-secondary, #f3f4f6);
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.75rem;
}
}
/* Complete */
.complete-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--status-success, #10b981);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
margin: 0 auto 1rem;
}
.complete-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 2rem;
}
/* Footer */
.wizard-footer {
display: flex;
justify-content: space-between;
margin-top: 1.5rem;
}
/* Buttons */
.btn {
padding: 0.625rem 1.25rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
text-decoration: none;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn--primary {
background: var(--primary, #3b82f6);
color: white;
&:hover:not(:disabled) {
background: var(--primary-hover, #2563eb);
}
}
.btn--secondary {
background: var(--surface-primary, #ffffff);
border-color: var(--border-default, #e5e7eb);
color: var(--text-primary, #111827);
&:hover:not(:disabled) {
background: var(--surface-hover, #f3f4f6);
}
}
`],
})
export class AgentOnboardWizardComponent {
private readonly router = inject(Router);
readonly steps: { id: WizardStep; label: string }[] = [
{ id: 'environment', label: 'Environment' },
{ id: 'configure', label: 'Configure' },
{ id: 'install', label: 'Install' },
{ id: 'verify', label: 'Verify' },
{ id: 'complete', label: 'Complete' },
];
readonly environments = [
{ id: 'development', name: 'Development', description: 'For testing and development workloads' },
{ id: 'staging', name: 'Staging', description: 'Pre-production environment' },
{ id: 'production', name: 'Production', description: 'Live production workloads' },
];
readonly currentStep = signal<WizardStep>('environment');
readonly selectedEnvironment = signal<string>('');
readonly agentName = signal('');
readonly maxTasks = signal(10);
readonly isVerifying = signal(false);
readonly isVerified = signal(false);
readonly copied = signal(false);
readonly newAgentId = signal('new-agent-id');
readonly installCommand = computed(() => {
const env = this.selectedEnvironment();
const name = this.agentName() || 'my-agent';
return `docker run -d \\
--name stella-agent \\
-e STELLA_AGENT_NAME="${name}" \\
-e STELLA_ENVIRONMENT="${env}" \\
-e STELLA_CONTROL_PLANE="https://stella.example.com" \\
-e STELLA_AGENT_TOKEN="<your-token>" \\
stella-ops/agent:latest`;
});
readonly canProceed = computed(() => {
switch (this.currentStep()) {
case 'environment':
return this.selectedEnvironment() !== '';
case 'configure':
return this.agentName() !== '' && this.maxTasks() > 0;
case 'install':
return true;
case 'verify':
return this.isVerified();
default:
return true;
}
});
selectEnvironment(envId: string): void {
this.selectedEnvironment.set(envId);
}
onNameInput(event: Event): void {
this.agentName.set((event.target as HTMLInputElement).value);
}
onMaxTasksInput(event: Event): void {
this.maxTasks.set(parseInt((event.target as HTMLInputElement).value, 10) || 1);
}
copyCommand(): void {
navigator.clipboard.writeText(this.installCommand());
this.copied.set(true);
setTimeout(() => this.copied.set(false), 2000);
}
startVerification(): void {
this.isVerifying.set(true);
// Simulate verification - in real app, poll API for agent connection
setTimeout(() => {
this.isVerifying.set(false);
this.isVerified.set(true);
}, 3000);
}
isStepCompleted(stepId: WizardStep): boolean {
const currentIndex = this.steps.findIndex((s) => s.id === this.currentStep());
const stepIndex = this.steps.findIndex((s) => s.id === stepId);
return stepIndex < currentIndex;
}
previousStep(): void {
const currentIndex = this.steps.findIndex((s) => s.id === this.currentStep());
if (currentIndex > 0) {
this.currentStep.set(this.steps[currentIndex - 1].id);
}
}
nextStep(): void {
const currentIndex = this.steps.findIndex((s) => s.id === this.currentStep());
if (currentIndex < this.steps.length - 1) {
this.currentStep.set(this.steps[currentIndex + 1].id);
// Start verification when reaching verify step
if (this.steps[currentIndex + 1].id === 'verify') {
this.startVerification();
}
}
}
}

View File

@@ -0,0 +1,33 @@
/**
* Agent Fleet Routes
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
*/
import { Routes } from '@angular/router';
export const AGENTS_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./agent-fleet-dashboard.component').then(
(m) => m.AgentFleetDashboardComponent
),
title: 'Agent Fleet',
},
{
path: 'onboard',
loadComponent: () =>
import('./agent-onboard-wizard.component').then(
(m) => m.AgentOnboardWizardComponent
),
title: 'Add Agent',
},
{
path: ':agentId',
loadComponent: () =>
import('./agent-detail-page.component').then(
(m) => m.AgentDetailPageComponent
),
title: 'Agent Details',
},
];

View File

@@ -0,0 +1,482 @@
/**
* Agent Action Modal Component Tests
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-008 - Add agent actions
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AgentActionModalComponent } from './agent-action-modal.component';
import { Agent, AgentAction } from '../../models/agent.models';
describe('AgentActionModalComponent', () => {
let component: AgentActionModalComponent;
let fixture: ComponentFixture<AgentActionModalComponent>;
const createMockAgent = (overrides: Partial<Agent> = {}): Agent => ({
id: 'agent-123',
name: 'test-agent',
environment: 'production',
version: '2.5.0',
status: 'online',
lastHeartbeat: new Date().toISOString(),
registeredAt: '2026-01-01T00:00:00Z',
resources: {
cpuPercent: 45,
memoryPercent: 60,
diskPercent: 35,
},
activeTasks: 3,
taskQueueDepth: 2,
capacityPercent: 65,
displayName: 'Test Agent Display',
...overrides,
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AgentActionModalComponent],
}).compileComponents();
fixture = TestBed.createComponent(AgentActionModalComponent);
component = fixture.componentInstance;
});
describe('visibility', () => {
it('should create', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should not render modal when not visible', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', false);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.modal-backdrop')).toBeNull();
});
it('should render modal when visible', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.modal-backdrop')).toBeTruthy();
});
});
describe('action configs', () => {
const actionTestCases: Array<{
action: AgentAction;
title: string;
variant: string;
}> = [
{ action: 'restart', title: 'Restart Agent', variant: 'warning' },
{ action: 'renew-certificate', title: 'Renew Certificate', variant: 'primary' },
{ action: 'drain', title: 'Drain Tasks', variant: 'warning' },
{ action: 'resume', title: 'Resume Tasks', variant: 'primary' },
{ action: 'remove', title: 'Remove Agent', variant: 'danger' },
];
actionTestCases.forEach(({ action, title, variant }) => {
it(`should display correct title for ${action}`, () => {
fixture.componentRef.setInput('action', action);
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.modal__title').textContent).toContain(title);
});
it(`should use ${variant} variant for ${action}`, () => {
fixture.componentRef.setInput('action', action);
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
expect(component.config().confirmVariant).toBe(variant);
});
});
it('should display action description', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.modal__description').textContent).toContain('restart the agent process');
});
});
describe('agent info display', () => {
it('should display agent name', () => {
const agent = createMockAgent({ name: 'my-agent' });
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const agentInfo = compiled.querySelector('.modal__agent-info');
expect(agentInfo).toBeTruthy();
});
it('should display displayName when available', () => {
const agent = createMockAgent({ displayName: 'Production Agent #1' });
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const agentInfo = compiled.querySelector('.modal__agent-info');
expect(agentInfo.textContent).toContain('Production Agent #1');
});
it('should display agent id', () => {
const agent = createMockAgent({ id: 'agent-xyz-789' });
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('agent-xyz-789');
});
});
describe('confirmation input for dangerous actions', () => {
it('should show input for remove action', () => {
const agent = createMockAgent();
fixture.componentRef.setInput('action', 'remove');
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.modal__input-group')).toBeTruthy();
});
it('should not show input for restart action', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.modal__input-group')).toBeNull();
});
it('should disable confirm button when input does not match agent name', () => {
const agent = createMockAgent({ name: 'my-agent' });
fixture.componentRef.setInput('action', 'remove');
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
expect(component.canConfirm()).toBe(false);
});
it('should enable confirm button when input matches agent name', () => {
const agent = createMockAgent({ name: 'my-agent' });
fixture.componentRef.setInput('action', 'remove');
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
component.confirmInput.set('my-agent');
expect(component.canConfirm()).toBe(true);
});
it('should update confirmInput on input change', () => {
const agent = createMockAgent();
fixture.componentRef.setInput('action', 'remove');
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const event = { target: { value: 'typed-value' } } as unknown as Event;
component.onInputChange(event);
expect(component.confirmInput()).toBe('typed-value');
});
it('should clear input error on input change', () => {
const agent = createMockAgent();
fixture.componentRef.setInput('action', 'remove');
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
component.inputError.set('Some error');
const event = { target: { value: 'new-value' } } as unknown as Event;
component.onInputChange(event);
expect(component.inputError()).toBeNull();
});
it('should show error when confirm clicked without valid input', () => {
const agent = createMockAgent({ name: 'my-agent' });
fixture.componentRef.setInput('action', 'remove');
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
component.confirmInput.set('wrong-name');
component.onConfirm();
expect(component.inputError()).toBeTruthy();
});
});
describe('buttons', () => {
it('should display cancel button', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Cancel');
});
it('should display action-specific confirm label', () => {
fixture.componentRef.setInput('action', 'drain');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Drain Tasks');
});
it('should apply warning class for warning actions', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.btn--warning')).toBeTruthy();
});
it('should apply danger class for danger actions', () => {
const agent = createMockAgent({ name: 'test-agent' });
fixture.componentRef.setInput('action', 'remove');
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.btn--danger')).toBeTruthy();
});
it('should apply primary class for primary actions', () => {
fixture.componentRef.setInput('action', 'resume');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.btn--primary')).toBeTruthy();
});
it('should disable buttons when submitting', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', true);
fixture.componentRef.setInput('isSubmitting', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const cancelBtn = compiled.querySelector('.btn--secondary');
expect(cancelBtn.disabled).toBe(true);
});
it('should show spinner when submitting', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', true);
fixture.componentRef.setInput('isSubmitting', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.btn__spinner')).toBeTruthy();
expect(compiled.textContent).toContain('Processing');
});
});
describe('events', () => {
it('should emit confirm when confirm button clicked', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const confirmSpy = jasmine.createSpy('confirm');
component.confirm.subscribe(confirmSpy);
component.onConfirm();
expect(confirmSpy).toHaveBeenCalledWith('restart');
});
it('should emit cancel when cancel button clicked', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const cancelSpy = jasmine.createSpy('cancel');
component.cancel.subscribe(cancelSpy);
component.onCancel();
expect(cancelSpy).toHaveBeenCalled();
});
it('should emit cancel when close button clicked', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const cancelSpy = jasmine.createSpy('cancel');
component.cancel.subscribe(cancelSpy);
const compiled = fixture.nativeElement;
compiled.querySelector('.modal__close').click();
expect(cancelSpy).toHaveBeenCalled();
});
it('should emit cancel when backdrop clicked', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const cancelSpy = jasmine.createSpy('cancel');
component.cancel.subscribe(cancelSpy);
const mockEvent = {
target: fixture.nativeElement.querySelector('.modal-backdrop'),
currentTarget: fixture.nativeElement.querySelector('.modal-backdrop'),
} as MouseEvent;
component.onBackdropClick(mockEvent);
expect(cancelSpy).toHaveBeenCalled();
});
it('should not emit cancel when modal content clicked', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const cancelSpy = jasmine.createSpy('cancel');
component.cancel.subscribe(cancelSpy);
const mockEvent = {
target: fixture.nativeElement.querySelector('.modal'),
currentTarget: fixture.nativeElement.querySelector('.modal-backdrop'),
} as MouseEvent;
component.onBackdropClick(mockEvent);
expect(cancelSpy).not.toHaveBeenCalled();
});
it('should reset state on cancel', () => {
const agent = createMockAgent();
fixture.componentRef.setInput('action', 'remove');
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
component.confirmInput.set('some-text');
component.inputError.set('some error');
component.onCancel();
expect(component.confirmInput()).toBe('');
expect(component.inputError()).toBeNull();
});
});
describe('accessibility', () => {
it('should have dialog role', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('[role="dialog"]')).toBeTruthy();
});
it('should have aria-modal attribute', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('[aria-modal="true"]')).toBeTruthy();
});
it('should have aria-labelledby pointing to title', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const backdrop = compiled.querySelector('.modal-backdrop');
const titleId = backdrop.getAttribute('aria-labelledby');
expect(titleId).toBe('modal-title-restart');
expect(compiled.querySelector(`#${titleId}`)).toBeTruthy();
});
it('should have close button with aria-label', () => {
fixture.componentRef.setInput('action', 'restart');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const closeBtn = compiled.querySelector('.modal__close');
expect(closeBtn.getAttribute('aria-label')).toBe('Close');
});
it('should have input label associated with input', () => {
const agent = createMockAgent();
fixture.componentRef.setInput('action', 'remove');
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const label = compiled.querySelector('.modal__input-label');
const input = compiled.querySelector('.modal__input');
expect(label.getAttribute('for')).toBe(input.getAttribute('id'));
});
});
describe('icon display', () => {
it('should show danger icon for remove action', () => {
const agent = createMockAgent();
fixture.componentRef.setInput('action', 'remove');
fixture.componentRef.setInput('agent', agent);
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.modal__icon--danger')).toBeTruthy();
});
it('should show warning icon for warning actions', () => {
fixture.componentRef.setInput('action', 'drain');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.modal__icon--warning')).toBeTruthy();
});
it('should show info icon for primary actions', () => {
fixture.componentRef.setInput('action', 'resume');
fixture.componentRef.setInput('visible', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.modal__icon--info')).toBeTruthy();
});
});
});

View File

@@ -0,0 +1,467 @@
/**
* Agent Action Modal Component
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-008 - Add agent actions
*
* Confirmation modal for agent management actions.
*/
import { Component, input, output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Agent, AgentAction } from '../../models/agent.models';
export interface ActionConfig {
action: AgentAction;
title: string;
description: string;
confirmLabel: string;
confirmVariant: 'primary' | 'danger' | 'warning';
requiresInput?: boolean;
inputLabel?: string;
inputPlaceholder?: string;
}
const ACTION_CONFIGS: Record<AgentAction, ActionConfig> = {
restart: {
action: 'restart',
title: 'Restart Agent',
description: 'This will restart the agent process. Active tasks will be gracefully terminated and re-queued.',
confirmLabel: 'Restart Agent',
confirmVariant: 'warning',
},
'renew-certificate': {
action: 'renew-certificate',
title: 'Renew Certificate',
description: 'This will trigger a certificate renewal process. The agent will generate a new certificate signing request.',
confirmLabel: 'Renew Certificate',
confirmVariant: 'primary',
},
drain: {
action: 'drain',
title: 'Drain Tasks',
description: 'The agent will stop accepting new tasks. Existing tasks will complete normally. Use this before maintenance.',
confirmLabel: 'Drain Tasks',
confirmVariant: 'warning',
},
resume: {
action: 'resume',
title: 'Resume Tasks',
description: 'The agent will start accepting new tasks again.',
confirmLabel: 'Resume Tasks',
confirmVariant: 'primary',
},
remove: {
action: 'remove',
title: 'Remove Agent',
description: 'This will permanently remove the agent from the fleet. Any pending tasks will be reassigned. This action cannot be undone.',
confirmLabel: 'Remove Agent',
confirmVariant: 'danger',
requiresInput: true,
inputLabel: 'Type the agent name to confirm',
inputPlaceholder: 'Enter agent name',
},
};
@Component({
selector: 'st-agent-action-modal',
standalone: true,
imports: [CommonModule],
template: `
@if (visible()) {
<div
class="modal-backdrop"
(click)="onBackdropClick($event)"
role="dialog"
aria-modal="true"
[attr.aria-labelledby]="'modal-title-' + config().action"
>
<div class="modal" (click)="$event.stopPropagation()">
<!-- Header -->
<header class="modal__header">
<h2 [id]="'modal-title-' + config().action" class="modal__title">
@switch (config().confirmVariant) {
@case ('danger') {
<span class="modal__icon modal__icon--danger" aria-hidden="true">&#9888;</span>
}
@case ('warning') {
<span class="modal__icon modal__icon--warning" aria-hidden="true">&#9888;</span>
}
@default {
<span class="modal__icon modal__icon--info" aria-hidden="true">&#9432;</span>
}
}
{{ config().title }}
</h2>
<button
type="button"
class="modal__close"
(click)="onCancel()"
aria-label="Close"
>
&times;
</button>
</header>
<!-- Body -->
<div class="modal__body">
<p class="modal__description">{{ config().description }}</p>
@if (agent(); as agentData) {
<div class="modal__agent-info">
<strong>{{ agentData.displayName || agentData.name }}</strong>
<code>{{ agentData.id }}</code>
</div>
}
@if (config().requiresInput) {
<div class="modal__input-group">
<label class="modal__input-label" [for]="'confirm-input-' + config().action">
{{ config().inputLabel }}
</label>
<input
[id]="'confirm-input-' + config().action"
type="text"
class="modal__input"
[placeholder]="config().inputPlaceholder || ''"
[value]="confirmInput()"
(input)="onInputChange($event)"
autocomplete="off"
/>
@if (inputError()) {
<p class="modal__input-error">{{ inputError() }}</p>
}
</div>
}
</div>
<!-- Footer -->
<footer class="modal__footer">
<button
type="button"
class="btn btn--secondary"
(click)="onCancel()"
[disabled]="isSubmitting()"
>
Cancel
</button>
<button
type="button"
class="btn"
[class.btn--primary]="config().confirmVariant === 'primary'"
[class.btn--warning]="config().confirmVariant === 'warning'"
[class.btn--danger]="config().confirmVariant === 'danger'"
(click)="onConfirm()"
[disabled]="!canConfirm() || isSubmitting()"
>
@if (isSubmitting()) {
<span class="btn__spinner"></span>
Processing...
} @else {
{{ config().confirmLabel }}
}
</button>
</footer>
</div>
</div>
}
`,
styles: [`
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fade-in 0.15s ease-out;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
width: 100%;
max-width: 480px;
background: var(--surface-primary, #ffffff);
border-radius: 12px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
animation: slide-up 0.2s ease-out;
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border-default, #e5e7eb);
}
.modal__title {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.modal__icon {
font-size: 1.25rem;
}
.modal__icon--danger {
color: var(--status-error, #ef4444);
}
.modal__icon--warning {
color: var(--status-warning, #f59e0b);
}
.modal__icon--info {
color: var(--primary, #3b82f6);
}
.modal__close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: 6px;
font-size: 1.5rem;
color: var(--text-muted, #9ca3af);
cursor: pointer;
&:hover {
background: var(--surface-hover, #f3f4f6);
color: var(--text-primary, #111827);
}
}
.modal__body {
padding: 1.5rem;
}
.modal__description {
margin: 0 0 1rem;
font-size: 0.9375rem;
line-height: 1.6;
color: var(--text-secondary, #6b7280);
}
.modal__agent-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem 1rem;
background: var(--surface-secondary, #f9fafb);
border-radius: 6px;
margin-bottom: 1rem;
strong {
font-size: 0.9375rem;
color: var(--text-primary, #111827);
}
code {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
}
}
.modal__input-group {
margin-top: 1rem;
}
.modal__input-label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #111827);
}
.modal__input {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 6px;
font-size: 0.9375rem;
&:focus {
outline: none;
border-color: var(--primary, #3b82f6);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
}
.modal__input-error {
margin: 0.5rem 0 0;
font-size: 0.8125rem;
color: var(--status-error, #ef4444);
}
.modal__footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
background: var(--surface-secondary, #f9fafb);
border-top: 1px solid var(--border-default, #e5e7eb);
border-radius: 0 0 12px 12px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn--secondary {
background: var(--surface-primary, #ffffff);
border-color: var(--border-default, #e5e7eb);
color: var(--text-primary, #111827);
&:hover:not(:disabled) {
background: var(--surface-hover, #f3f4f6);
}
}
.btn--primary {
background: var(--primary, #3b82f6);
color: white;
&:hover:not(:disabled) {
background: var(--primary-hover, #2563eb);
}
}
.btn--warning {
background: var(--status-warning, #f59e0b);
color: white;
&:hover:not(:disabled) {
background: #d97706;
}
}
.btn--danger {
background: var(--status-error, #ef4444);
color: white;
&:hover:not(:disabled) {
background: #dc2626;
}
}
.btn__spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`],
})
export class AgentActionModalComponent {
/** The action to perform */
readonly action = input.required<AgentAction>();
/** The agent to perform action on */
readonly agent = input<Agent | null>(null);
/** Whether the modal is visible */
readonly visible = input(false);
/** Whether the action is being submitted */
readonly isSubmitting = input(false);
/** Emits when user confirms the action */
readonly confirm = output<AgentAction>();
/** Emits when user cancels or closes the modal */
readonly cancel = output<void>();
// Local state
readonly confirmInput = signal('');
readonly inputError = signal<string | null>(null);
readonly config = computed<ActionConfig>(() => {
return ACTION_CONFIGS[this.action()] || ACTION_CONFIGS['restart'];
});
readonly canConfirm = computed(() => {
const cfg = this.config();
if (!cfg.requiresInput) {
return true;
}
// For dangerous actions, require typing the agent name
const agentData = this.agent();
if (!agentData) return false;
return this.confirmInput() === agentData.name;
});
onInputChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.confirmInput.set(input.value);
this.inputError.set(null);
}
onConfirm(): void {
if (!this.canConfirm()) {
if (this.config().requiresInput) {
this.inputError.set('Please enter the exact agent name to confirm');
}
return;
}
this.confirm.emit(this.action());
}
onCancel(): void {
this.confirmInput.set('');
this.inputError.set(null);
this.cancel.emit();
}
onBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget) {
this.onCancel();
}
}
}

View File

@@ -0,0 +1,375 @@
/**
* Agent Card Component Tests
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-002 - Implement Agent Card component
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AgentCardComponent } from './agent-card.component';
import { Agent, AgentStatus } from '../../models/agent.models';
describe('AgentCardComponent', () => {
let component: AgentCardComponent;
let fixture: ComponentFixture<AgentCardComponent>;
const createMockAgent = (overrides: Partial<Agent> = {}): Agent => ({
id: 'agent-001-abc123',
name: 'prod-agent-01',
displayName: 'Production Agent 1',
environment: 'production',
version: '2.5.0',
status: 'online',
lastHeartbeat: new Date().toISOString(),
registeredAt: '2026-01-01T00:00:00Z',
resources: {
cpuPercent: 45,
memoryPercent: 60,
diskPercent: 35,
networkLatencyMs: 12,
},
activeTasks: 3,
taskQueueDepth: 2,
capacityPercent: 65,
tags: ['production', 'us-east'],
...overrides,
});
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AgentCardComponent],
}).compileComponents();
fixture = TestBed.createComponent(AgentCardComponent);
component = fixture.componentInstance;
});
describe('rendering', () => {
it('should create', () => {
fixture.componentRef.setInput('agent', createMockAgent());
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should display agent name', () => {
const agent = createMockAgent({ displayName: 'Test Agent' });
fixture.componentRef.setInput('agent', agent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Test Agent');
});
it('should display environment tag', () => {
const agent = createMockAgent({ environment: 'staging' });
fixture.componentRef.setInput('agent', agent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('staging');
});
it('should display version', () => {
const agent = createMockAgent({ version: '3.0.1' });
fixture.componentRef.setInput('agent', agent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('v3.0.1');
});
it('should display capacity percentage', () => {
const agent = createMockAgent({ capacityPercent: 75 });
fixture.componentRef.setInput('agent', agent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('75%');
});
it('should display active tasks count', () => {
const agent = createMockAgent({ activeTasks: 5 });
fixture.componentRef.setInput('agent', agent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const metricsSection = compiled.querySelector('.agent-card__metrics');
expect(metricsSection.textContent).toContain('5');
});
it('should display queued tasks count', () => {
const agent = createMockAgent({ taskQueueDepth: 8 });
fixture.componentRef.setInput('agent', agent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const metricsSection = compiled.querySelector('.agent-card__metrics');
expect(metricsSection.textContent).toContain('8');
});
});
describe('status styling', () => {
it('should apply online class when status is online', () => {
fixture.componentRef.setInput('agent', createMockAgent({ status: 'online' }));
fixture.detectChanges();
const compiled = fixture.nativeElement;
const card = compiled.querySelector('.agent-card');
expect(card.classList.contains('agent-card--online')).toBe(true);
});
it('should apply offline class when status is offline', () => {
fixture.componentRef.setInput('agent', createMockAgent({ status: 'offline' }));
fixture.detectChanges();
const compiled = fixture.nativeElement;
const card = compiled.querySelector('.agent-card');
expect(card.classList.contains('agent-card--offline')).toBe(true);
});
it('should apply degraded class when status is degraded', () => {
fixture.componentRef.setInput('agent', createMockAgent({ status: 'degraded' }));
fixture.detectChanges();
const compiled = fixture.nativeElement;
const card = compiled.querySelector('.agent-card');
expect(card.classList.contains('agent-card--degraded')).toBe(true);
});
it('should apply unknown class when status is unknown', () => {
fixture.componentRef.setInput('agent', createMockAgent({ status: 'unknown' }));
fixture.detectChanges();
const compiled = fixture.nativeElement;
const card = compiled.querySelector('.agent-card');
expect(card.classList.contains('agent-card--unknown')).toBe(true);
});
});
describe('certificate warning', () => {
it('should show warning when certificate expires within 30 days', () => {
const agent = createMockAgent({
certificate: {
thumbprint: 'abc123',
subject: 'CN=agent',
issuer: 'CN=CA',
notBefore: '2026-01-01T00:00:00Z',
notAfter: '2026-02-15T00:00:00Z',
isExpired: false,
daysUntilExpiry: 15,
},
});
fixture.componentRef.setInput('agent', agent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const warning = compiled.querySelector('.agent-card__warning');
expect(warning).toBeTruthy();
expect(warning.textContent).toContain('15 days');
});
it('should not show warning when certificate has more than 30 days', () => {
const agent = createMockAgent({
certificate: {
thumbprint: 'abc123',
subject: 'CN=agent',
issuer: 'CN=CA',
notBefore: '2026-01-01T00:00:00Z',
notAfter: '2027-01-01T00:00:00Z',
isExpired: false,
daysUntilExpiry: 90,
},
});
fixture.componentRef.setInput('agent', agent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const warning = compiled.querySelector('.agent-card__warning');
expect(warning).toBeNull();
});
it('should not show warning when certificate is already expired', () => {
const agent = createMockAgent({
certificate: {
thumbprint: 'abc123',
subject: 'CN=agent',
issuer: 'CN=CA',
notBefore: '2025-01-01T00:00:00Z',
notAfter: '2025-12-31T00:00:00Z',
isExpired: true,
daysUntilExpiry: -5,
},
});
fixture.componentRef.setInput('agent', agent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const warning = compiled.querySelector('.agent-card__warning');
expect(warning).toBeNull();
});
});
describe('selection state', () => {
it('should apply selected class when selected', () => {
fixture.componentRef.setInput('agent', createMockAgent());
fixture.componentRef.setInput('selected', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const card = compiled.querySelector('.agent-card');
expect(card.classList.contains('agent-card--selected')).toBe(true);
});
it('should not apply selected class when not selected', () => {
fixture.componentRef.setInput('agent', createMockAgent());
fixture.componentRef.setInput('selected', false);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const card = compiled.querySelector('.agent-card');
expect(card.classList.contains('agent-card--selected')).toBe(false);
});
});
describe('events', () => {
it('should emit cardClick when card is clicked', () => {
const agent = createMockAgent();
fixture.componentRef.setInput('agent', agent);
fixture.detectChanges();
const cardClickSpy = jasmine.createSpy('cardClick');
component.cardClick.subscribe(cardClickSpy);
const compiled = fixture.nativeElement;
const card = compiled.querySelector('.agent-card');
card.click();
expect(cardClickSpy).toHaveBeenCalledWith(agent);
});
it('should emit menuClick when menu button is clicked', () => {
const agent = createMockAgent();
fixture.componentRef.setInput('agent', agent);
fixture.detectChanges();
const menuClickSpy = jasmine.createSpy('menuClick');
component.menuClick.subscribe(menuClickSpy);
const compiled = fixture.nativeElement;
const menuBtn = compiled.querySelector('.agent-card__menu-btn');
menuBtn.click();
expect(menuClickSpy).toHaveBeenCalled();
const callArgs = menuClickSpy.calls.mostRecent().args[0];
expect(callArgs.agent).toEqual(agent);
});
it('should not emit cardClick when menu button is clicked', () => {
const agent = createMockAgent();
fixture.componentRef.setInput('agent', agent);
fixture.detectChanges();
const cardClickSpy = jasmine.createSpy('cardClick');
component.cardClick.subscribe(cardClickSpy);
const compiled = fixture.nativeElement;
const menuBtn = compiled.querySelector('.agent-card__menu-btn');
menuBtn.click();
expect(cardClickSpy).not.toHaveBeenCalled();
});
});
describe('truncateId', () => {
it('should return full ID if 12 characters or less', () => {
fixture.componentRef.setInput('agent', createMockAgent());
fixture.detectChanges();
expect(component.truncateId('short-id')).toBe('short-id');
expect(component.truncateId('exactly12ch')).toBe('exactly12ch');
});
it('should truncate ID if longer than 12 characters', () => {
fixture.componentRef.setInput('agent', createMockAgent());
fixture.detectChanges();
const result = component.truncateId('very-long-agent-id-123456');
expect(result).toBe('very-lon...');
expect(result.length).toBe(11); // 8 chars + '...'
});
});
describe('accessibility', () => {
it('should have proper aria-label', () => {
const agent = createMockAgent({
name: 'test-agent',
status: 'online',
capacityPercent: 50,
activeTasks: 2,
});
fixture.componentRef.setInput('agent', agent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const card = compiled.querySelector('.agent-card');
expect(card.getAttribute('aria-label')).toContain('test-agent');
expect(card.getAttribute('aria-label')).toContain('Online');
expect(card.getAttribute('aria-label')).toContain('50%');
expect(card.getAttribute('aria-label')).toContain('2 active tasks');
});
it('should have role="button"', () => {
fixture.componentRef.setInput('agent', createMockAgent());
fixture.detectChanges();
const compiled = fixture.nativeElement;
const card = compiled.querySelector('.agent-card');
expect(card.getAttribute('role')).toBe('button');
});
it('should have tabindex="0"', () => {
fixture.componentRef.setInput('agent', createMockAgent());
fixture.detectChanges();
const compiled = fixture.nativeElement;
const card = compiled.querySelector('.agent-card');
expect(card.getAttribute('tabindex')).toBe('0');
});
});
describe('computed properties', () => {
it('should compute correct status color for online', () => {
fixture.componentRef.setInput('agent', createMockAgent({ status: 'online' }));
fixture.detectChanges();
expect(component.statusColor()).toContain('success');
});
it('should compute correct status color for offline', () => {
fixture.componentRef.setInput('agent', createMockAgent({ status: 'offline' }));
fixture.detectChanges();
expect(component.statusColor()).toContain('error');
});
it('should compute correct capacity color for low utilization', () => {
fixture.componentRef.setInput('agent', createMockAgent({ capacityPercent: 30 }));
fixture.detectChanges();
expect(component.capacityColor()).toContain('low');
});
it('should compute correct capacity color for high utilization', () => {
fixture.componentRef.setInput('agent', createMockAgent({ capacityPercent: 90 }));
fixture.detectChanges();
expect(component.capacityColor()).toContain('high');
});
it('should compute correct capacity color for critical utilization', () => {
fixture.componentRef.setInput('agent', createMockAgent({ capacityPercent: 98 }));
fixture.detectChanges();
expect(component.capacityColor()).toContain('critical');
});
});
});

View File

@@ -0,0 +1,346 @@
/**
* Agent Card Component
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-002 - Implement Agent Card component
*
* Displays agent status, capacity, and quick actions in a card format.
*/
import { Component, input, output, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
Agent,
AgentStatus,
getStatusColor,
getStatusLabel,
getCapacityColor,
formatHeartbeat,
} from '../../models/agent.models';
@Component({
selector: 'st-agent-card',
standalone: true,
imports: [CommonModule],
template: `
<article
class="agent-card"
[class.agent-card--online]="agent().status === 'online'"
[class.agent-card--offline]="agent().status === 'offline'"
[class.agent-card--degraded]="agent().status === 'degraded'"
[class.agent-card--unknown]="agent().status === 'unknown'"
[class.agent-card--selected]="selected()"
(click)="onCardClick()"
(keydown.enter)="onCardClick()"
tabindex="0"
role="button"
[attr.aria-label]="ariaLabel()"
>
<!-- Header -->
<header class="agent-card__header">
<div class="agent-card__status-indicator" [title]="statusLabel()">
<span class="status-dot" [style.background-color]="statusColor()"></span>
</div>
<div class="agent-card__title">
<h3 class="agent-card__name">{{ agent().displayName || agent().name }}</h3>
<span class="agent-card__id" [title]="agent().id">{{ truncateId(agent().id) }}</span>
</div>
<button
type="button"
class="agent-card__menu-btn"
(click)="onMenuClick($event)"
aria-label="Agent actions"
>
<span aria-hidden="true">&#8942;</span>
</button>
</header>
<!-- Environment & Version -->
<div class="agent-card__tags">
<span class="agent-card__tag agent-card__tag--env">{{ agent().environment }}</span>
<span class="agent-card__tag agent-card__tag--version">v{{ agent().version }}</span>
</div>
<!-- Capacity Bar -->
<div class="agent-card__capacity">
<div class="capacity-label">
<span>Capacity</span>
<span class="capacity-value">{{ agent().capacityPercent }}%</span>
</div>
<div class="capacity-bar">
<div
class="capacity-bar__fill"
[style.width.%]="agent().capacityPercent"
[style.background-color]="capacityColor()"
></div>
</div>
</div>
<!-- Metrics -->
<div class="agent-card__metrics">
<div class="metric">
<span class="metric__label">Active Tasks</span>
<span class="metric__value">{{ agent().activeTasks }}</span>
</div>
<div class="metric">
<span class="metric__label">Queued</span>
<span class="metric__value">{{ agent().taskQueueDepth }}</span>
</div>
<div class="metric">
<span class="metric__label">Heartbeat</span>
<span class="metric__value" [title]="agent().lastHeartbeat">{{ heartbeatLabel() }}</span>
</div>
</div>
<!-- Certificate Warning -->
@if (hasCertificateWarning()) {
<div class="agent-card__warning">
<span class="warning-icon" aria-hidden="true">&#9888;</span>
<span>Certificate expires in {{ agent().certificate?.daysUntilExpiry }} days</span>
</div>
}
</article>
`,
styles: [`
.agent-card {
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
padding: 1rem;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--border-hover, #d1d5db);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
&:focus-visible {
outline: 2px solid var(--focus-ring, #3b82f6);
outline-offset: 2px;
}
&--selected {
border-color: var(--primary, #3b82f6);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
&--offline {
opacity: 0.8;
background: var(--surface-muted, #f9fafb);
}
}
.agent-card__header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.agent-card__status-indicator {
flex-shrink: 0;
padding-top: 0.25rem;
}
.status-dot {
display: block;
width: 10px;
height: 10px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.agent-card--offline .status-dot,
.agent-card--unknown .status-dot {
animation: none;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.agent-card__title {
flex: 1;
min-width: 0;
}
.agent-card__name {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary, #111827);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-card__id {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
font-family: var(--font-mono, monospace);
}
.agent-card__menu-btn {
flex-shrink: 0;
padding: 0.25rem;
background: none;
border: none;
border-radius: 4px;
cursor: pointer;
color: var(--text-muted, #9ca3af);
font-size: 1.25rem;
line-height: 1;
&:hover {
background: var(--surface-hover, #f3f4f6);
color: var(--text-primary, #111827);
}
}
.agent-card__tags {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.agent-card__tag {
display: inline-flex;
align-items: center;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.agent-card__tag--env {
background: var(--tag-env-bg, #dbeafe);
color: var(--tag-env-text, #1e40af);
}
.agent-card__tag--version {
background: var(--tag-version-bg, #e5e7eb);
color: var(--tag-version-text, #374151);
}
.agent-card__capacity {
margin-bottom: 0.75rem;
}
.capacity-label {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--text-secondary, #6b7280);
margin-bottom: 0.25rem;
}
.capacity-value {
font-weight: 600;
}
.capacity-bar {
height: 6px;
background: var(--surface-secondary, #f3f4f6);
border-radius: 3px;
overflow: hidden;
}
.capacity-bar__fill {
height: 100%;
border-radius: 3px;
transition: width 0.3s ease;
}
.agent-card__metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border-default, #e5e7eb);
}
.metric {
text-align: center;
}
.metric__label {
display: block;
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-muted, #9ca3af);
margin-bottom: 0.125rem;
}
.metric__value {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #111827);
}
.agent-card__warning {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.5rem;
background: var(--warning-bg, #fef3c7);
border-radius: 4px;
font-size: 0.75rem;
color: var(--warning-text, #92400e);
}
.warning-icon {
color: var(--warning, #f59e0b);
}
`],
})
export class AgentCardComponent {
/** Agent data */
readonly agent = input.required<Agent>();
/** Whether the card is selected */
readonly selected = input<boolean>(false);
/** Emits when the card is clicked */
readonly cardClick = output<Agent>();
/** Emits when the menu button is clicked */
readonly menuClick = output<{ agent: Agent; event: MouseEvent }>();
// Computed properties
readonly statusColor = computed(() => getStatusColor(this.agent().status));
readonly statusLabel = computed(() => getStatusLabel(this.agent().status));
readonly capacityColor = computed(() => getCapacityColor(this.agent().capacityPercent));
readonly heartbeatLabel = computed(() => formatHeartbeat(this.agent().lastHeartbeat));
readonly hasCertificateWarning = computed(() => {
const cert = this.agent().certificate;
return cert && !cert.isExpired && cert.daysUntilExpiry <= 30;
});
readonly ariaLabel = computed(() => {
const agent = this.agent();
return `Agent ${agent.name}, ${this.statusLabel()}, ${agent.capacityPercent}% capacity, ${agent.activeTasks} active tasks`;
});
/** Truncate agent ID for display */
truncateId(id: string): string {
if (id.length <= 12) return id;
return `${id.slice(0, 8)}...`;
}
onCardClick(): void {
this.cardClick.emit(this.agent());
}
onMenuClick(event: MouseEvent): void {
event.stopPropagation();
this.menuClick.emit({ agent: this.agent(), event });
}
}

View File

@@ -0,0 +1,334 @@
/**
* Agent Health Tab Component Tests
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-004 - Implement Agent Health tab
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AgentHealthTabComponent } from './agent-health-tab.component';
import { AgentHealthResult } from '../../models/agent.models';
describe('AgentHealthTabComponent', () => {
let component: AgentHealthTabComponent;
let fixture: ComponentFixture<AgentHealthTabComponent>;
const createMockCheck = (overrides: Partial<AgentHealthResult> = {}): AgentHealthResult => ({
checkId: `check-${Math.random().toString(36).substr(2, 9)}`,
checkName: 'Test Check',
status: 'pass',
message: 'Check passed successfully',
lastChecked: new Date().toISOString(),
...overrides,
});
const createMockChecks = (): AgentHealthResult[] => [
createMockCheck({ checkId: 'c1', checkName: 'Connectivity', status: 'pass' }),
createMockCheck({ checkId: 'c2', checkName: 'Memory Usage', status: 'warn', message: 'Memory at 75%' }),
createMockCheck({ checkId: 'c3', checkName: 'Disk Space', status: 'fail', message: 'Disk usage critical' }),
createMockCheck({ checkId: 'c4', checkName: 'CPU Usage', status: 'pass' }),
createMockCheck({ checkId: 'c5', checkName: 'Certificate', status: 'warn', message: 'Expires in 30 days' }),
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AgentHealthTabComponent],
}).compileComponents();
fixture = TestBed.createComponent(AgentHealthTabComponent);
component = fixture.componentInstance;
});
describe('rendering', () => {
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should display health checks title', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Health Checks');
});
it('should display run diagnostics button', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement;
const btn = compiled.querySelector('.btn--primary');
expect(btn).toBeTruthy();
expect(btn.textContent).toContain('Run Diagnostics');
});
it('should show spinner when running', () => {
fixture.componentRef.setInput('isRunning', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const btn = compiled.querySelector('.btn--primary');
expect(btn.textContent).toContain('Running');
expect(compiled.querySelector('.spinner-small')).toBeTruthy();
});
it('should disable button when running', () => {
fixture.componentRef.setInput('isRunning', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const btn = compiled.querySelector('.btn--primary');
expect(btn.disabled).toBe(true);
});
});
describe('empty state', () => {
it('should display empty state when no checks', () => {
fixture.componentRef.setInput('checks', []);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.empty-state')).toBeTruthy();
expect(compiled.textContent).toContain('No health check results available');
});
it('should show run diagnostics button in empty state', () => {
fixture.componentRef.setInput('checks', []);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const emptyState = compiled.querySelector('.empty-state');
expect(emptyState.querySelector('.btn--primary')).toBeTruthy();
});
it('should not show empty state when running', () => {
fixture.componentRef.setInput('checks', []);
fixture.componentRef.setInput('isRunning', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.empty-state')).toBeNull();
});
});
describe('summary strip', () => {
it('should display summary when checks exist', () => {
fixture.componentRef.setInput('checks', createMockChecks());
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.health-summary')).toBeTruthy();
});
it('should not display summary when no checks', () => {
fixture.componentRef.setInput('checks', []);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.health-summary')).toBeNull();
});
it('should display correct pass count', () => {
fixture.componentRef.setInput('checks', createMockChecks());
fixture.detectChanges();
expect(component.passCount()).toBe(2);
});
it('should display correct warn count', () => {
fixture.componentRef.setInput('checks', createMockChecks());
fixture.detectChanges();
expect(component.warnCount()).toBe(2);
});
it('should display correct fail count', () => {
fixture.componentRef.setInput('checks', createMockChecks());
fixture.detectChanges();
expect(component.failCount()).toBe(1);
});
it('should display summary labels', () => {
fixture.componentRef.setInput('checks', createMockChecks());
fixture.detectChanges();
const compiled = fixture.nativeElement;
const summary = compiled.querySelector('.health-summary');
expect(summary.textContent).toContain('Passed');
expect(summary.textContent).toContain('Warnings');
expect(summary.textContent).toContain('Failed');
});
});
describe('check list', () => {
it('should display check items', () => {
const checks = createMockChecks();
fixture.componentRef.setInput('checks', checks);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const items = compiled.querySelectorAll('.check-item');
expect(items.length).toBe(checks.length);
});
it('should display check name', () => {
const checks = [createMockCheck({ checkName: 'Test Connectivity' })];
fixture.componentRef.setInput('checks', checks);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Test Connectivity');
});
it('should display check message', () => {
const checks = [createMockCheck({ message: 'Everything looks good' })];
fixture.componentRef.setInput('checks', checks);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Everything looks good');
});
it('should apply pass class for pass status', () => {
const checks = [createMockCheck({ status: 'pass' })];
fixture.componentRef.setInput('checks', checks);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const item = compiled.querySelector('.check-item');
expect(item.classList.contains('check-item--pass')).toBe(true);
});
it('should apply warn class for warn status', () => {
const checks = [createMockCheck({ status: 'warn' })];
fixture.componentRef.setInput('checks', checks);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const item = compiled.querySelector('.check-item');
expect(item.classList.contains('check-item--warn')).toBe(true);
});
it('should apply fail class for fail status', () => {
const checks = [createMockCheck({ status: 'fail' })];
fixture.componentRef.setInput('checks', checks);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const item = compiled.querySelector('.check-item');
expect(item.classList.contains('check-item--fail')).toBe(true);
});
it('should have list role on check list', () => {
fixture.componentRef.setInput('checks', createMockChecks());
fixture.detectChanges();
const compiled = fixture.nativeElement;
const list = compiled.querySelector('.check-list');
expect(list.getAttribute('role')).toBe('list');
});
it('should have listitem role on check items', () => {
fixture.componentRef.setInput('checks', createMockChecks());
fixture.detectChanges();
const compiled = fixture.nativeElement;
const items = compiled.querySelectorAll('.check-item');
items.forEach((item: HTMLElement) => {
expect(item.getAttribute('role')).toBe('listitem');
});
});
});
describe('events', () => {
it('should emit runChecks when run diagnostics clicked', () => {
fixture.detectChanges();
const runSpy = jasmine.createSpy('runChecks');
component.runChecks.subscribe(runSpy);
const compiled = fixture.nativeElement;
const btn = compiled.querySelector('.btn--primary');
btn.click();
expect(runSpy).toHaveBeenCalled();
});
it('should emit rerunCheck when rerun button clicked', () => {
const checks = [createMockCheck({ checkId: 'test-check-id' })];
fixture.componentRef.setInput('checks', checks);
fixture.detectChanges();
const rerunSpy = jasmine.createSpy('rerunCheck');
component.rerunCheck.subscribe(rerunSpy);
const compiled = fixture.nativeElement;
const rerunBtn = compiled.querySelector('.check-item__actions .btn--text');
rerunBtn.click();
expect(rerunSpy).toHaveBeenCalledWith('test-check-id');
});
});
describe('formatTime', () => {
beforeEach(() => {
jasmine.clock().install();
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should return "just now" for recent time', () => {
const now = new Date('2026-01-18T12:00:00Z');
jasmine.clock().mockDate(now);
const timestamp = new Date('2026-01-18T11:59:45Z').toISOString();
expect(component.formatTime(timestamp)).toBe('just now');
});
it('should return minutes ago for time within hour', () => {
const now = new Date('2026-01-18T12:00:00Z');
jasmine.clock().mockDate(now);
const timestamp = new Date('2026-01-18T11:30:00Z').toISOString();
expect(component.formatTime(timestamp)).toBe('30m ago');
});
it('should return hours ago for time within day', () => {
const now = new Date('2026-01-18T12:00:00Z');
jasmine.clock().mockDate(now);
const timestamp = new Date('2026-01-18T06:00:00Z').toISOString();
expect(component.formatTime(timestamp)).toBe('6h ago');
});
it('should return date for older timestamps', () => {
const now = new Date('2026-01-18T12:00:00Z');
jasmine.clock().mockDate(now);
const timestamp = new Date('2026-01-15T12:00:00Z').toISOString();
const result = component.formatTime(timestamp);
// Should return localized date string
expect(result).not.toContain('ago');
});
});
describe('history section', () => {
it('should display history toggle when checks exist', () => {
fixture.componentRef.setInput('checks', createMockChecks());
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.history-section')).toBeTruthy();
expect(compiled.textContent).toContain('View Check History');
});
it('should not display history toggle when no checks', () => {
fixture.componentRef.setInput('checks', []);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.history-section')).toBeNull();
});
});
});

View File

@@ -0,0 +1,387 @@
/**
* Agent Health Tab Component
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-004 - Implement Agent Health tab
*
* Shows Doctor check results specific to an agent.
*/
import { Component, input, output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AgentHealthResult } from '../../models/agent.models';
@Component({
selector: 'st-agent-health-tab',
standalone: true,
imports: [CommonModule],
template: `
<div class="agent-health-tab">
<!-- Header -->
<header class="tab-header">
<h2 class="tab-header__title">Health Checks</h2>
<div class="tab-header__actions">
<button
type="button"
class="btn btn--primary"
[disabled]="isRunning()"
(click)="runHealthChecks()"
>
@if (isRunning()) {
<span class="spinner-small"></span>
Running...
} @else {
Run Diagnostics
}
</button>
</div>
</header>
<!-- Summary Strip -->
@if (checks().length > 0) {
<div class="health-summary">
<div class="summary-item summary-item--pass">
<span class="summary-item__count">{{ passCount() }}</span>
<span class="summary-item__label">Passed</span>
</div>
<div class="summary-item summary-item--warn">
<span class="summary-item__count">{{ warnCount() }}</span>
<span class="summary-item__label">Warnings</span>
</div>
<div class="summary-item summary-item--fail">
<span class="summary-item__count">{{ failCount() }}</span>
<span class="summary-item__label">Failed</span>
</div>
</div>
}
<!-- Check Results -->
@if (checks().length > 0) {
<div class="check-list" role="list">
@for (check of checks(); track check.checkId) {
<article
class="check-item"
[class.check-item--pass]="check.status === 'pass'"
[class.check-item--warn]="check.status === 'warn'"
[class.check-item--fail]="check.status === 'fail'"
role="listitem"
>
<div class="check-item__status">
@switch (check.status) {
@case ('pass') {
<span class="status-icon status-icon--pass">&#10003;</span>
}
@case ('warn') {
<span class="status-icon status-icon--warn">&#9888;</span>
}
@case ('fail') {
<span class="status-icon status-icon--fail">&#10007;</span>
}
}
</div>
<div class="check-item__content">
<h3 class="check-item__name">{{ check.checkName }}</h3>
@if (check.message) {
<p class="check-item__message">{{ check.message }}</p>
}
<span class="check-item__time">
Checked {{ formatTime(check.lastChecked) }}
</span>
</div>
<div class="check-item__actions">
<button
type="button"
class="btn btn--text"
(click)="rerunCheck.emit(check.checkId)"
title="Re-run this check"
>
&#8635;
</button>
</div>
</article>
}
</div>
} @else if (!isRunning()) {
<div class="empty-state">
<p>No health check results available.</p>
<button type="button" class="btn btn--primary" (click)="runHealthChecks()">
Run Diagnostics Now
</button>
</div>
}
<!-- History Toggle -->
@if (checks().length > 0) {
<details class="history-section">
<summary>View Check History</summary>
<p class="placeholder">Check history timeline coming soon...</p>
</details>
}
</div>
`,
styles: [`
.agent-health-tab {
padding: 1.5rem 0;
}
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.tab-header__title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
/* Summary */
.health-summary {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.summary-item {
flex: 1;
padding: 1rem;
border-radius: 8px;
text-align: center;
background: var(--surface-secondary, #f9fafb);
border: 1px solid var(--border-default, #e5e7eb);
&--pass {
border-left: 3px solid var(--status-success, #10b981);
}
&--warn {
border-left: 3px solid var(--status-warning, #f59e0b);
}
&--fail {
border-left: 3px solid var(--status-error, #ef4444);
}
}
.summary-item__count {
display: block;
font-size: 1.5rem;
font-weight: 700;
}
.summary-item__label {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
text-transform: uppercase;
}
/* Check List */
.check-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.check-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
transition: box-shadow 0.15s;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&--pass {
border-left: 3px solid var(--status-success, #10b981);
}
&--warn {
border-left: 3px solid var(--status-warning, #f59e0b);
}
&--fail {
border-left: 3px solid var(--status-error, #ef4444);
}
}
.check-item__status {
flex-shrink: 0;
}
.status-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 0.875rem;
&--pass {
background: rgba(16, 185, 129, 0.1);
color: var(--status-success, #10b981);
}
&--warn {
background: rgba(245, 158, 11, 0.1);
color: var(--status-warning, #f59e0b);
}
&--fail {
background: rgba(239, 68, 68, 0.1);
color: var(--status-error, #ef4444);
}
}
.check-item__content {
flex: 1;
min-width: 0;
}
.check-item__name {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
}
.check-item__message {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
color: var(--text-secondary, #6b7280);
}
.check-item__time {
display: block;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
}
.check-item__actions {
flex-shrink: 0;
}
/* History */
.history-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-default, #e5e7eb);
summary {
cursor: pointer;
font-size: 0.875rem;
color: var(--primary, #3b82f6);
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.btn--primary {
background: var(--primary, #3b82f6);
color: white;
&:hover:not(:disabled) {
background: var(--primary-hover, #2563eb);
}
}
.btn--text {
background: transparent;
color: var(--text-muted, #9ca3af);
padding: 0.25rem 0.5rem;
&:hover {
color: var(--text-primary, #111827);
background: var(--surface-hover, #f3f4f6);
}
}
.spinner-small {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary, #6b7280);
}
.placeholder {
color: var(--text-muted, #9ca3af);
font-style: italic;
padding: 1rem 0;
}
`],
})
export class AgentHealthTabComponent {
/** Health check results */
readonly checks = input<AgentHealthResult[]>([]);
/** Whether checks are currently running */
readonly isRunning = input<boolean>(false);
/** Emits when user wants to run all checks */
readonly runChecks = output<void>();
/** Emits when user wants to re-run a specific check */
readonly rerunCheck = output<string>();
// Computed counts
readonly passCount = computed(() => this.checks().filter((c) => c.status === 'pass').length);
readonly warnCount = computed(() => this.checks().filter((c) => c.status === 'warn').length);
readonly failCount = computed(() => this.checks().filter((c) => c.status === 'fail').length);
runHealthChecks(): void {
this.runChecks.emit();
}
formatTime(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffMin < 1440) return `${Math.floor(diffMin / 60)}h ago`;
return date.toLocaleDateString();
}
}

View File

@@ -0,0 +1,406 @@
/**
* Agent Tasks Tab Component Tests
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-005 - Implement Agent Tasks tab
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AgentTasksTabComponent } from './agent-tasks-tab.component';
import { AgentTask } from '../../models/agent.models';
describe('AgentTasksTabComponent', () => {
let component: AgentTasksTabComponent;
let fixture: ComponentFixture<AgentTasksTabComponent>;
const createMockTask = (overrides: Partial<AgentTask> = {}): AgentTask => ({
taskId: `task-${Math.random().toString(36).substr(2, 9)}`,
taskType: 'scan',
status: 'completed',
startedAt: '2026-01-18T10:00:00Z',
completedAt: '2026-01-18T10:05:00Z',
...overrides,
});
const createMockTasks = (): AgentTask[] => [
createMockTask({ taskId: 't1', taskType: 'scan', status: 'running', progress: 45, completedAt: undefined }),
createMockTask({ taskId: 't2', taskType: 'deploy', status: 'pending', startedAt: undefined, completedAt: undefined }),
createMockTask({ taskId: 't3', taskType: 'verify', status: 'completed' }),
createMockTask({ taskId: 't4', taskType: 'scan', status: 'failed', errorMessage: 'Connection timeout' }),
createMockTask({ taskId: 't5', taskType: 'deploy', status: 'cancelled' }),
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AgentTasksTabComponent, RouterTestingModule],
}).compileComponents();
fixture = TestBed.createComponent(AgentTasksTabComponent);
component = fixture.componentInstance;
});
describe('rendering', () => {
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should display tasks title', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Tasks');
});
it('should display filter buttons', () => {
fixture.componentRef.setInput('tasks', createMockTasks());
fixture.detectChanges();
const compiled = fixture.nativeElement;
const filterBtns = compiled.querySelectorAll('.filter-btn');
expect(filterBtns.length).toBe(4);
expect(compiled.textContent).toContain('All');
expect(compiled.textContent).toContain('Active');
expect(compiled.textContent).toContain('Completed');
expect(compiled.textContent).toContain('Failed');
});
it('should display task count badges', () => {
fixture.componentRef.setInput('tasks', createMockTasks());
fixture.detectChanges();
const compiled = fixture.nativeElement;
const countBadges = compiled.querySelectorAll('.filter-btn__count');
expect(countBadges.length).toBeGreaterThan(0);
});
});
describe('filtering', () => {
it('should default to all filter', () => {
fixture.detectChanges();
expect(component.filter()).toBe('all');
});
it('should show all tasks when filter is all', () => {
const tasks = createMockTasks();
fixture.componentRef.setInput('tasks', tasks);
fixture.detectChanges();
expect(component.filteredTasks().length).toBe(tasks.length);
});
it('should filter active tasks', () => {
fixture.componentRef.setInput('tasks', createMockTasks());
component.setFilter('active');
fixture.detectChanges();
const filtered = component.filteredTasks();
expect(filtered.every((t) => t.status === 'running' || t.status === 'pending')).toBe(true);
expect(filtered.length).toBe(2);
});
it('should filter completed tasks', () => {
fixture.componentRef.setInput('tasks', createMockTasks());
component.setFilter('completed');
fixture.detectChanges();
const filtered = component.filteredTasks();
expect(filtered.every((t) => t.status === 'completed')).toBe(true);
expect(filtered.length).toBe(1);
});
it('should filter failed/cancelled tasks', () => {
fixture.componentRef.setInput('tasks', createMockTasks());
component.setFilter('failed');
fixture.detectChanges();
const filtered = component.filteredTasks();
expect(filtered.every((t) => t.status === 'failed' || t.status === 'cancelled')).toBe(true);
expect(filtered.length).toBe(2);
});
it('should mark active filter button', () => {
fixture.componentRef.setInput('tasks', createMockTasks());
component.setFilter('completed');
fixture.detectChanges();
const compiled = fixture.nativeElement;
const activeBtn = compiled.querySelector('.filter-btn--active');
expect(activeBtn.textContent).toContain('Completed');
});
});
describe('active queue visualization', () => {
it('should display queue when active tasks exist', () => {
fixture.componentRef.setInput('tasks', createMockTasks());
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.queue-viz')).toBeTruthy();
});
it('should not display queue when no active tasks', () => {
const tasks = [createMockTask({ status: 'completed' })];
fixture.componentRef.setInput('tasks', tasks);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.queue-viz')).toBeNull();
});
it('should show active task count in queue title', () => {
fixture.componentRef.setInput('tasks', createMockTasks());
fixture.detectChanges();
const compiled = fixture.nativeElement;
const queueTitle = compiled.querySelector('.queue-viz__title');
expect(queueTitle.textContent).toContain('Active Queue');
expect(queueTitle.textContent).toContain('(2)');
});
it('should display queue items for active tasks', () => {
fixture.componentRef.setInput('tasks', createMockTasks());
fixture.detectChanges();
const compiled = fixture.nativeElement;
const queueItems = compiled.querySelectorAll('.queue-item');
expect(queueItems.length).toBe(2);
});
it('should display progress bar for running tasks with progress', () => {
const tasks = [createMockTask({ status: 'running', progress: 60, completedAt: undefined })];
fixture.componentRef.setInput('tasks', tasks);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.queue-item__progress')).toBeTruthy();
});
});
describe('task list', () => {
it('should display task items', () => {
const tasks = createMockTasks();
fixture.componentRef.setInput('tasks', tasks);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const taskItems = compiled.querySelectorAll('.task-item');
expect(taskItems.length).toBe(tasks.length);
});
it('should display task type', () => {
const tasks = [createMockTask({ taskType: 'vulnerability-scan' })];
fixture.componentRef.setInput('tasks', tasks);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('vulnerability-scan');
});
it('should apply status class to task item', () => {
const tasks = [createMockTask({ status: 'running', completedAt: undefined })];
fixture.componentRef.setInput('tasks', tasks);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const taskItem = compiled.querySelector('.task-item');
expect(taskItem.classList.contains('task-item--running')).toBe(true);
});
it('should display status badge', () => {
const tasks = [createMockTask({ status: 'completed' })];
fixture.componentRef.setInput('tasks', tasks);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.status-badge--completed')).toBeTruthy();
});
it('should display progress bar for running tasks', () => {
const tasks = [createMockTask({ status: 'running', progress: 75, completedAt: undefined })];
fixture.componentRef.setInput('tasks', tasks);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.task-item__progress-bar')).toBeTruthy();
expect(compiled.textContent).toContain('75%');
});
it('should display error message for failed tasks', () => {
const tasks = [createMockTask({ status: 'failed', errorMessage: 'Network error' })];
fixture.componentRef.setInput('tasks', tasks);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.task-item__error')).toBeTruthy();
expect(compiled.textContent).toContain('Network error');
});
it('should display release link when releaseId exists', () => {
const tasks = [createMockTask({ releaseId: 'rel-123' })];
fixture.componentRef.setInput('tasks', tasks);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Release:');
});
it('should have list role', () => {
fixture.componentRef.setInput('tasks', createMockTasks());
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.task-list').getAttribute('role')).toBe('list');
});
});
describe('empty state', () => {
it('should display empty state when no tasks', () => {
fixture.componentRef.setInput('tasks', []);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.empty-state')).toBeTruthy();
expect(compiled.textContent).toContain('No tasks have been assigned');
});
it('should display filter-specific empty message', () => {
fixture.componentRef.setInput('tasks', [createMockTask({ status: 'completed' })]);
component.setFilter('failed');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('No failed tasks found');
});
it('should show "Show all tasks" button when filter empty', () => {
fixture.componentRef.setInput('tasks', [createMockTask({ status: 'completed' })]);
component.setFilter('failed');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Show all tasks');
});
});
describe('pagination', () => {
it('should display load more when hasMoreTasks is true', () => {
fixture.componentRef.setInput('tasks', createMockTasks());
fixture.componentRef.setInput('hasMoreTasks', true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.pagination')).toBeTruthy();
expect(compiled.textContent).toContain('Load More');
});
it('should not display load more when hasMoreTasks is false', () => {
fixture.componentRef.setInput('tasks', createMockTasks());
fixture.componentRef.setInput('hasMoreTasks', false);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.pagination')).toBeNull();
});
it('should emit loadMore when button clicked', () => {
fixture.componentRef.setInput('tasks', createMockTasks());
fixture.componentRef.setInput('hasMoreTasks', true);
fixture.detectChanges();
const loadMoreSpy = jasmine.createSpy('loadMore');
component.loadMore.subscribe(loadMoreSpy);
const compiled = fixture.nativeElement;
compiled.querySelector('.pagination .btn--secondary').click();
expect(loadMoreSpy).toHaveBeenCalled();
});
});
describe('events', () => {
it('should emit viewDetails when view button clicked', () => {
const tasks = [createMockTask({ taskId: 'view-test-task' })];
fixture.componentRef.setInput('tasks', tasks);
fixture.detectChanges();
const viewSpy = jasmine.createSpy('viewDetails');
component.viewDetails.subscribe(viewSpy);
const compiled = fixture.nativeElement;
compiled.querySelector('.task-item__actions .btn--icon').click();
expect(viewSpy).toHaveBeenCalledWith(tasks[0]);
});
});
describe('computed values', () => {
it('should calculate activeTasks correctly', () => {
fixture.componentRef.setInput('tasks', createMockTasks());
fixture.detectChanges();
const active = component.activeTasks();
expect(active.length).toBe(2);
expect(active.every((t) => t.status === 'running' || t.status === 'pending')).toBe(true);
});
it('should generate filter options with counts', () => {
fixture.componentRef.setInput('tasks', createMockTasks());
fixture.detectChanges();
const options = component.filterOptions();
expect(options.length).toBe(4);
const activeOption = options.find((o) => o.value === 'active');
expect(activeOption?.count).toBe(2);
const completedOption = options.find((o) => o.value === 'completed');
expect(completedOption?.count).toBe(1);
const failedOption = options.find((o) => o.value === 'failed');
expect(failedOption?.count).toBe(2);
});
});
describe('truncateId', () => {
it('should return full ID if 12 characters or less', () => {
expect(component.truncateId('short-id')).toBe('short-id');
expect(component.truncateId('123456789012')).toBe('123456789012');
});
it('should truncate ID if longer than 12 characters', () => {
const result = component.truncateId('very-long-task-identifier');
expect(result).toBe('very-lon...');
expect(result.length).toBe(11);
});
});
describe('formatTime', () => {
it('should format timestamp', () => {
const timestamp = '2026-01-18T14:30:00Z';
const result = component.formatTime(timestamp);
expect(result).toContain('Jan');
expect(result).toContain('18');
});
});
describe('calculateDuration', () => {
it('should return seconds for short durations', () => {
const start = '2026-01-18T10:00:00Z';
const end = '2026-01-18T10:00:45Z';
expect(component.calculateDuration(start, end)).toBe('45s');
});
it('should return minutes and seconds for medium durations', () => {
const start = '2026-01-18T10:00:00Z';
const end = '2026-01-18T10:05:30Z';
expect(component.calculateDuration(start, end)).toBe('5m 30s');
});
it('should return hours and minutes for long durations', () => {
const start = '2026-01-18T10:00:00Z';
const end = '2026-01-18T12:30:00Z';
expect(component.calculateDuration(start, end)).toBe('2h 30m');
});
});
});

View File

@@ -0,0 +1,637 @@
/**
* Agent Tasks Tab Component
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-005 - Implement Agent Tasks tab
*
* Shows active and historical tasks for an agent.
*/
import { Component, input, output, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { AgentTask } from '../../models/agent.models';
type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
@Component({
selector: 'st-agent-tasks-tab',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="agent-tasks-tab">
<!-- Header -->
<header class="tab-header">
<h2 class="tab-header__title">Tasks</h2>
<div class="tab-header__filters">
@for (filterOption of filterOptions; track filterOption.value) {
<button
type="button"
class="filter-btn"
[class.filter-btn--active]="filter() === filterOption.value"
(click)="setFilter(filterOption.value)"
>
{{ filterOption.label }}
@if (filterOption.count !== undefined) {
<span class="filter-btn__count">{{ filterOption.count }}</span>
}
</button>
}
</div>
</header>
<!-- Queue Visualization -->
@if (activeTasks().length > 0) {
<div class="queue-viz">
<h3 class="queue-viz__title">Active Queue ({{ activeTasks().length }})</h3>
<div class="queue-items">
@for (task of activeTasks(); track task.taskId) {
<div
class="queue-item"
[class.queue-item--running]="task.status === 'running'"
[class.queue-item--pending]="task.status === 'pending'"
>
<span class="queue-item__type">{{ task.taskType }}</span>
@if (task.progress !== undefined) {
<div class="queue-item__progress">
<div
class="queue-item__progress-fill"
[style.width.%]="task.progress"
></div>
</div>
}
</div>
}
</div>
</div>
}
<!-- Task List -->
@if (filteredTasks().length > 0) {
<div class="task-list" role="list">
@for (task of filteredTasks(); track task.taskId) {
<article
class="task-item"
[class]="'task-item--' + task.status"
role="listitem"
>
<!-- Status Indicator -->
<div class="task-item__status">
@switch (task.status) {
@case ('running') {
<span class="status-badge status-badge--running">
<span class="spinner-tiny"></span>
Running
</span>
}
@case ('pending') {
<span class="status-badge status-badge--pending">Pending</span>
}
@case ('completed') {
<span class="status-badge status-badge--completed">&#10003; Completed</span>
}
@case ('failed') {
<span class="status-badge status-badge--failed">&#10007; Failed</span>
}
@case ('cancelled') {
<span class="status-badge status-badge--cancelled">Cancelled</span>
}
}
</div>
<!-- Content -->
<div class="task-item__content">
<div class="task-item__header">
<h3 class="task-item__type">{{ task.taskType }}</h3>
<code class="task-item__id" [title]="task.taskId">{{ truncateId(task.taskId) }}</code>
</div>
@if (task.releaseId) {
<p class="task-item__ref">
Release:
<a [routerLink]="['/releases', task.releaseId]">{{ task.releaseId }}</a>
</p>
}
@if (task.deploymentId) {
<p class="task-item__ref">
Deployment:
<a [routerLink]="['/deployments', task.deploymentId]">{{ task.deploymentId }}</a>
</p>
}
<!-- Progress Bar -->
@if (task.status === 'running' && task.progress !== undefined) {
<div class="task-item__progress-bar">
<div
class="task-item__progress-fill"
[style.width.%]="task.progress"
></div>
<span class="task-item__progress-text">{{ task.progress }}%</span>
</div>
}
<!-- Error Message -->
@if (task.status === 'failed' && task.errorMessage) {
<div class="task-item__error">
<span class="error-icon">&#9888;</span>
{{ task.errorMessage }}
</div>
}
<!-- Timestamps -->
<div class="task-item__times">
@if (task.startedAt) {
<span>Started: {{ formatTime(task.startedAt) }}</span>
}
@if (task.completedAt) {
<span>Completed: {{ formatTime(task.completedAt) }}</span>
<span>Duration: {{ calculateDuration(task.startedAt!, task.completedAt) }}</span>
}
</div>
</div>
<!-- Actions -->
<div class="task-item__actions">
<button
type="button"
class="btn btn--icon"
title="View details"
(click)="viewDetails.emit(task)"
>
&#8594;
</button>
</div>
</article>
}
</div>
} @else {
<div class="empty-state">
@if (filter() === 'all') {
<p>No tasks have been assigned to this agent yet.</p>
} @else {
<p>No {{ filter() }} tasks found.</p>
<button type="button" class="btn btn--text" (click)="setFilter('all')">
Show all tasks
</button>
}
</div>
}
<!-- Pagination -->
@if (filteredTasks().length > 0 && hasMoreTasks()) {
<div class="pagination">
<button type="button" class="btn btn--secondary" (click)="loadMore.emit()">
Load More
</button>
</div>
}
</div>
`,
styles: [`
.agent-tasks-tab {
padding: 1.5rem 0;
}
.tab-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
}
.tab-header__title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.tab-header__filters {
display: flex;
gap: 0.5rem;
}
.filter-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 6px;
background: var(--surface-primary, #ffffff);
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: var(--primary, #3b82f6);
}
&--active {
background: var(--primary, #3b82f6);
border-color: var(--primary, #3b82f6);
color: white;
}
}
.filter-btn__count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 0.25rem;
background: rgba(0, 0, 0, 0.1);
border-radius: 9px;
font-size: 0.6875rem;
font-weight: 600;
}
.filter-btn--active .filter-btn__count {
background: rgba(255, 255, 255, 0.2);
}
/* Queue Visualization */
.queue-viz {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--surface-secondary, #f9fafb);
border-radius: 8px;
}
.queue-viz__title {
margin: 0 0 0.75rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary, #6b7280);
}
.queue-items {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.queue-item {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 6px;
min-width: 120px;
&--running {
border-color: var(--primary, #3b82f6);
}
}
.queue-item__type {
font-size: 0.75rem;
font-weight: 500;
}
.queue-item__progress {
height: 4px;
background: var(--surface-secondary, #e5e7eb);
border-radius: 2px;
overflow: hidden;
}
.queue-item__progress-fill {
height: 100%;
background: var(--primary, #3b82f6);
transition: width 0.3s;
}
/* Task List */
.task-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.task-item {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
transition: box-shadow 0.15s;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&--running {
border-left: 3px solid var(--primary, #3b82f6);
}
&--completed {
border-left: 3px solid var(--status-success, #10b981);
}
&--failed {
border-left: 3px solid var(--status-error, #ef4444);
}
&--cancelled {
opacity: 0.7;
}
}
.task-item__status {
flex-shrink: 0;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
&--running {
background: rgba(59, 130, 246, 0.1);
color: var(--primary, #3b82f6);
}
&--pending {
background: var(--surface-secondary, #f3f4f6);
color: var(--text-secondary, #6b7280);
}
&--completed {
background: rgba(16, 185, 129, 0.1);
color: var(--status-success, #10b981);
}
&--failed {
background: rgba(239, 68, 68, 0.1);
color: var(--status-error, #ef4444);
}
&--cancelled {
background: var(--surface-secondary, #f3f4f6);
color: var(--text-muted, #9ca3af);
}
}
.spinner-tiny {
width: 10px;
height: 10px;
border: 2px solid rgba(59, 130, 246, 0.3);
border-top-color: var(--primary, #3b82f6);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.task-item__content {
flex: 1;
min-width: 0;
}
.task-item__header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.task-item__type {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
}
.task-item__id {
font-size: 0.6875rem;
color: var(--text-muted, #9ca3af);
background: var(--surface-secondary, #f3f4f6);
padding: 0.125rem 0.375rem;
border-radius: 3px;
}
.task-item__ref {
margin: 0.25rem 0;
font-size: 0.8125rem;
color: var(--text-secondary, #6b7280);
a {
color: var(--primary, #3b82f6);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.task-item__progress-bar {
position: relative;
height: 6px;
background: var(--surface-secondary, #e5e7eb);
border-radius: 3px;
margin: 0.75rem 0;
overflow: hidden;
}
.task-item__progress-fill {
height: 100%;
background: var(--primary, #3b82f6);
border-radius: 3px;
transition: width 0.3s;
}
.task-item__progress-text {
position: absolute;
right: 0;
top: -1.25rem;
font-size: 0.6875rem;
color: var(--text-muted, #9ca3af);
}
.task-item__error {
display: flex;
align-items: flex-start;
gap: 0.375rem;
margin: 0.5rem 0;
padding: 0.5rem;
background: rgba(239, 68, 68, 0.1);
border-radius: 4px;
font-size: 0.8125rem;
color: var(--status-error, #ef4444);
}
.error-icon {
flex-shrink: 0;
}
.task-item__times {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
}
.task-item__actions {
flex-shrink: 0;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
}
.btn--icon {
padding: 0.375rem 0.5rem;
background: transparent;
border: none;
color: var(--text-muted, #9ca3af);
&:hover {
color: var(--text-primary, #111827);
background: var(--surface-hover, #f3f4f6);
}
}
.btn--secondary {
background: var(--surface-primary, #ffffff);
border-color: var(--border-default, #e5e7eb);
color: var(--text-primary, #111827);
&:hover {
background: var(--surface-hover, #f3f4f6);
}
}
.btn--text {
background: transparent;
color: var(--primary, #3b82f6);
&:hover {
text-decoration: underline;
}
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary, #6b7280);
}
/* Pagination */
.pagination {
margin-top: 1.5rem;
text-align: center;
}
`],
})
export class AgentTasksTabComponent {
/** Agent tasks */
readonly tasks = input<AgentTask[]>([]);
/** Whether there are more tasks to load */
readonly hasMoreTasks = input<boolean>(false);
/** Emits when user wants to view task details */
readonly viewDetails = output<AgentTask>();
/** Emits when user wants to load more tasks */
readonly loadMore = output<void>();
// Local state
readonly filter = signal<TaskFilter>('all');
// Computed
readonly activeTasks = computed(() =>
this.tasks().filter((t) => t.status === 'running' || t.status === 'pending')
);
readonly filteredTasks = computed(() => {
const tasks = this.tasks();
const currentFilter = this.filter();
switch (currentFilter) {
case 'active':
return tasks.filter((t) => t.status === 'running' || t.status === 'pending');
case 'completed':
return tasks.filter((t) => t.status === 'completed');
case 'failed':
return tasks.filter((t) => t.status === 'failed' || t.status === 'cancelled');
default:
return tasks;
}
});
readonly filterOptions = computed(() => [
{ value: 'all' as TaskFilter, label: 'All' },
{
value: 'active' as TaskFilter,
label: 'Active',
count: this.tasks().filter((t) => t.status === 'running' || t.status === 'pending').length,
},
{
value: 'completed' as TaskFilter,
label: 'Completed',
count: this.tasks().filter((t) => t.status === 'completed').length,
},
{
value: 'failed' as TaskFilter,
label: 'Failed',
count: this.tasks().filter((t) => t.status === 'failed' || t.status === 'cancelled').length,
},
]);
setFilter(filter: TaskFilter): void {
this.filter.set(filter);
}
truncateId(id: string): string {
if (id.length <= 12) return id;
return `${id.slice(0, 8)}...`;
}
formatTime(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
calculateDuration(start: string, end: string): string {
const startDate = new Date(start);
const endDate = new Date(end);
const diffMs = endDate.getTime() - startDate.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return `${diffSec}s`;
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ${diffSec % 60}s`;
return `${Math.floor(diffSec / 3600)}h ${Math.floor((diffSec % 3600) / 60)}m`;
}
}

View File

@@ -0,0 +1,365 @@
/**
* Capacity Heatmap Component Tests
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-006 - Implement capacity heatmap
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CapacityHeatmapComponent } from './capacity-heatmap.component';
import { Agent } from '../../models/agent.models';
describe('CapacityHeatmapComponent', () => {
let component: CapacityHeatmapComponent;
let fixture: ComponentFixture<CapacityHeatmapComponent>;
const createMockAgent = (overrides: Partial<Agent> = {}): Agent => ({
id: `agent-${Math.random().toString(36).substr(2, 9)}`,
name: 'test-agent',
environment: 'production',
version: '2.5.0',
status: 'online',
lastHeartbeat: new Date().toISOString(),
registeredAt: '2026-01-01T00:00:00Z',
resources: {
cpuPercent: 45,
memoryPercent: 60,
diskPercent: 35,
},
activeTasks: 3,
taskQueueDepth: 2,
capacityPercent: 65,
...overrides,
});
const createMockAgents = (): Agent[] => [
createMockAgent({ id: 'a1', name: 'agent-1', environment: 'production', capacityPercent: 30, status: 'online' }),
createMockAgent({ id: 'a2', name: 'agent-2', environment: 'production', capacityPercent: 60, status: 'online' }),
createMockAgent({ id: 'a3', name: 'agent-3', environment: 'staging', capacityPercent: 85, status: 'degraded' }),
createMockAgent({ id: 'a4', name: 'agent-4', environment: 'staging', capacityPercent: 96, status: 'online' }),
createMockAgent({ id: 'a5', name: 'agent-5', environment: 'development', capacityPercent: 20, status: 'offline' }),
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [CapacityHeatmapComponent],
}).compileComponents();
fixture = TestBed.createComponent(CapacityHeatmapComponent);
component = fixture.componentInstance;
});
describe('rendering', () => {
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should display heatmap title', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Fleet Capacity');
});
it('should display legend', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement;
const legend = compiled.querySelector('.heatmap-legend');
expect(legend).toBeTruthy();
expect(legend.textContent).toContain('<50%');
expect(legend.textContent).toContain('50-80%');
expect(legend.textContent).toContain('80-95%');
expect(legend.textContent).toContain('>95%');
});
it('should display cells for each agent', () => {
const agents = createMockAgents();
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const cells = compiled.querySelectorAll('.heatmap-cell');
expect(cells.length).toBe(agents.length);
});
it('should display capacity percentage in each cell', () => {
const agents = [createMockAgent({ capacityPercent: 75 })];
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const cellValue = compiled.querySelector('.heatmap-cell__value');
expect(cellValue.textContent).toContain('75%');
});
it('should apply offline class to offline agents', () => {
const agents = [createMockAgent({ status: 'offline' })];
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const cell = compiled.querySelector('.heatmap-cell');
expect(cell.classList.contains('heatmap-cell--offline')).toBe(true);
});
});
describe('grouping', () => {
it('should default to no grouping', () => {
fixture.detectChanges();
expect(component.groupBy()).toBe('none');
});
it('should group by environment when selected', () => {
const agents = createMockAgents();
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
component.groupBy.set('environment');
fixture.detectChanges();
const groups = component.groupedAgents();
expect(groups.length).toBe(3); // production, staging, development
expect(groups.map((g) => g.key).sort()).toEqual(['development', 'production', 'staging']);
});
it('should group by status when selected', () => {
const agents = createMockAgents();
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
component.groupBy.set('status');
fixture.detectChanges();
const groups = component.groupedAgents();
expect(groups.length).toBe(3); // online, degraded, offline
expect(groups.map((g) => g.key).sort()).toEqual(['degraded', 'offline', 'online']);
});
it('should display group headers when grouped', () => {
const agents = createMockAgents();
fixture.componentRef.setInput('agents', agents);
component.groupBy.set('environment');
fixture.detectChanges();
const compiled = fixture.nativeElement;
const groupTitles = compiled.querySelectorAll('.heatmap-group__title');
expect(groupTitles.length).toBe(3);
});
it('should handle groupBy change event', () => {
fixture.detectChanges();
const event = { target: { value: 'environment' } } as unknown as Event;
component.onGroupByChange(event);
expect(component.groupBy()).toBe('environment');
});
});
describe('summary statistics', () => {
it('should calculate average capacity', () => {
const agents = [
createMockAgent({ capacityPercent: 20 }),
createMockAgent({ capacityPercent: 40 }),
createMockAgent({ capacityPercent: 60 }),
];
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
expect(component.avgCapacity()).toBe(40);
});
it('should return 0 for empty agent list', () => {
fixture.componentRef.setInput('agents', []);
fixture.detectChanges();
expect(component.avgCapacity()).toBe(0);
});
it('should count high utilization agents (>80%)', () => {
const agents = [
createMockAgent({ capacityPercent: 50 }),
createMockAgent({ capacityPercent: 81 }),
createMockAgent({ capacityPercent: 90 }),
createMockAgent({ capacityPercent: 95 }),
];
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
expect(component.highUtilizationCount()).toBe(3);
});
it('should count critical agents (>95%)', () => {
const agents = [
createMockAgent({ capacityPercent: 50 }),
createMockAgent({ capacityPercent: 95 }),
createMockAgent({ capacityPercent: 96 }),
createMockAgent({ capacityPercent: 100 }),
];
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
expect(component.criticalCount()).toBe(2);
});
it('should display summary in footer', () => {
const agents = createMockAgents();
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const summary = compiled.querySelector('.heatmap-summary');
expect(summary).toBeTruthy();
expect(summary.textContent).toContain('Avg Capacity');
expect(summary.textContent).toContain('High Utilization');
expect(summary.textContent).toContain('Critical');
});
});
describe('events', () => {
it('should emit agentClick when cell is clicked', () => {
const agents = [createMockAgent({ id: 'test-agent' })];
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
const clickSpy = jasmine.createSpy('agentClick');
component.agentClick.subscribe(clickSpy);
component.onCellClick(agents[0]);
expect(clickSpy).toHaveBeenCalledWith(agents[0]);
});
});
describe('tooltip', () => {
it('should show tooltip on hover', () => {
const agents = [createMockAgent({ name: 'hover-test-agent' })];
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
component.hoveredAgent.set(agents[0]);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const tooltip = compiled.querySelector('.heatmap-tooltip');
expect(tooltip).toBeTruthy();
expect(tooltip.textContent).toContain('hover-test-agent');
});
it('should hide tooltip when not hovering', () => {
const agents = [createMockAgent()];
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
component.hoveredAgent.set(null);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const tooltip = compiled.querySelector('.heatmap-tooltip');
expect(tooltip).toBeNull();
});
it('should display agent details in tooltip', () => {
const agent = createMockAgent({
name: 'tooltip-agent',
environment: 'staging',
capacityPercent: 75,
activeTasks: 5,
status: 'online',
});
fixture.componentRef.setInput('agents', [agent]);
component.hoveredAgent.set(agent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const tooltip = compiled.querySelector('.heatmap-tooltip');
expect(tooltip.textContent).toContain('tooltip-agent');
expect(tooltip.textContent).toContain('staging');
expect(tooltip.textContent).toContain('75%');
expect(tooltip.textContent).toContain('5');
expect(tooltip.textContent).toContain('online');
});
});
describe('getCellColor', () => {
it('should return correct color based on capacity', () => {
fixture.detectChanges();
expect(component.getCellColor(30)).toContain('low');
expect(component.getCellColor(60)).toContain('medium');
expect(component.getCellColor(90)).toContain('high');
expect(component.getCellColor(98)).toContain('critical');
});
});
describe('getStatusColor', () => {
it('should return success color for online status', () => {
fixture.detectChanges();
expect(component.getStatusColor('online')).toContain('success');
});
it('should return warning color for degraded status', () => {
fixture.detectChanges();
expect(component.getStatusColor('degraded')).toContain('warning');
});
it('should return error color for offline status', () => {
fixture.detectChanges();
expect(component.getStatusColor('offline')).toContain('error');
});
it('should return unknown color for unknown status', () => {
fixture.detectChanges();
expect(component.getStatusColor('unknown')).toContain('unknown');
});
});
describe('getCellAriaLabel', () => {
it('should generate accessible label', () => {
fixture.detectChanges();
const agent = createMockAgent({
name: 'accessible-agent',
capacityPercent: 65,
status: 'online',
});
const label = component.getCellAriaLabel(agent);
expect(label).toBe('accessible-agent: 65% capacity, online');
});
});
describe('accessibility', () => {
it('should have grid role on heatmap container', () => {
const agents = [createMockAgent()];
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const grid = compiled.querySelector('.heatmap-grid');
expect(grid.getAttribute('role')).toBe('grid');
});
it('should have aria-label on cells', () => {
const agent = createMockAgent({ name: 'aria-agent' });
fixture.componentRef.setInput('agents', [agent]);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const cell = compiled.querySelector('.heatmap-cell');
expect(cell.getAttribute('aria-label')).toContain('aria-agent');
});
it('should have tooltip role on tooltip element', () => {
const agent = createMockAgent();
fixture.componentRef.setInput('agents', [agent]);
component.hoveredAgent.set(agent);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const tooltip = compiled.querySelector('.heatmap-tooltip');
expect(tooltip.getAttribute('role')).toBe('tooltip');
});
});
});

View File

@@ -0,0 +1,479 @@
/**
* Capacity Heatmap Component
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-006 - Implement capacity heatmap
*
* Visual heatmap showing agent capacity across the fleet.
*/
import { Component, input, output, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Agent, getCapacityColor } from '../../models/agent.models';
type GroupBy = 'none' | 'environment' | 'status';
@Component({
selector: 'st-capacity-heatmap',
standalone: true,
imports: [CommonModule],
template: `
<div class="capacity-heatmap">
<!-- Header -->
<header class="heatmap-header">
<h3 class="heatmap-header__title">Fleet Capacity</h3>
<div class="heatmap-header__controls">
<label class="group-label">
Group by:
<select
class="group-select"
[value]="groupBy()"
(change)="onGroupByChange($event)"
>
<option value="none">None</option>
<option value="environment">Environment</option>
<option value="status">Status</option>
</select>
</label>
</div>
</header>
<!-- Legend -->
<div class="heatmap-legend">
<span class="legend-label">Utilization:</span>
<div class="legend-scale">
<span class="legend-item" style="--item-color: var(--capacity-low, #10b981)">
&lt;50%
</span>
<span class="legend-item" style="--item-color: var(--capacity-medium, #f59e0b)">
50-80%
</span>
<span class="legend-item" style="--item-color: var(--capacity-high, #f97316)">
80-95%
</span>
<span class="legend-item" style="--item-color: var(--capacity-critical, #ef4444)">
&gt;95%
</span>
</div>
</div>
<!-- Heatmap Grid -->
@if (groupBy() === 'none') {
<div class="heatmap-grid" role="grid" aria-label="Agent capacity heatmap">
@for (agent of agents(); track agent.id) {
<button
type="button"
class="heatmap-cell"
[style.--cell-color]="getCellColor(agent.capacityPercent)"
[class.heatmap-cell--offline]="agent.status === 'offline'"
[attr.aria-label]="getCellAriaLabel(agent)"
(click)="onCellClick(agent)"
(mouseenter)="hoveredAgent.set(agent)"
(mouseleave)="hoveredAgent.set(null)"
>
<span class="heatmap-cell__value">{{ agent.capacityPercent }}%</span>
</button>
}
</div>
} @else {
<!-- Grouped View -->
@for (group of groupedAgents(); track group.key) {
<div class="heatmap-group">
<h4 class="heatmap-group__title">
{{ group.key }}
<span class="heatmap-group__count">({{ group.agents.length }})</span>
</h4>
<div class="heatmap-grid" role="grid">
@for (agent of group.agents; track agent.id) {
<button
type="button"
class="heatmap-cell"
[style.--cell-color]="getCellColor(agent.capacityPercent)"
[class.heatmap-cell--offline]="agent.status === 'offline'"
[attr.aria-label]="getCellAriaLabel(agent)"
(click)="onCellClick(agent)"
(mouseenter)="hoveredAgent.set(agent)"
(mouseleave)="hoveredAgent.set(null)"
>
<span class="heatmap-cell__value">{{ agent.capacityPercent }}%</span>
</button>
}
</div>
</div>
}
}
<!-- Tooltip -->
@if (hoveredAgent(); as agent) {
<div class="heatmap-tooltip" role="tooltip">
<div class="tooltip-header">
<span
class="tooltip-status"
[style.background-color]="getStatusColor(agent.status)"
></span>
<strong>{{ agent.name }}</strong>
</div>
<dl class="tooltip-details">
<dt>Environment</dt>
<dd>{{ agent.environment }}</dd>
<dt>Capacity</dt>
<dd>{{ agent.capacityPercent }}%</dd>
<dt>Active Tasks</dt>
<dd>{{ agent.activeTasks }}</dd>
<dt>Status</dt>
<dd>{{ agent.status }}</dd>
</dl>
<span class="tooltip-hint">Click to view details</span>
</div>
}
<!-- Summary -->
<footer class="heatmap-summary">
<div class="summary-stat">
<span class="summary-stat__value">{{ avgCapacity() }}%</span>
<span class="summary-stat__label">Avg Capacity</span>
</div>
<div class="summary-stat">
<span class="summary-stat__value">{{ highUtilizationCount() }}</span>
<span class="summary-stat__label">High Utilization (&gt;80%)</span>
</div>
<div class="summary-stat">
<span class="summary-stat__value">{{ criticalCount() }}</span>
<span class="summary-stat__label">Critical (&gt;95%)</span>
</div>
</footer>
</div>
`,
styles: [`
.capacity-heatmap {
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
padding: 1.25rem;
position: relative;
}
.heatmap-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.heatmap-header__title {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.group-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: var(--text-secondary, #6b7280);
}
.group-select {
padding: 0.25rem 0.5rem;
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
background: var(--surface-primary, #ffffff);
&:focus {
outline: none;
border-color: var(--primary, #3b82f6);
}
}
/* Legend */
.heatmap-legend {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 0.5rem 0.75rem;
background: var(--surface-secondary, #f9fafb);
border-radius: 6px;
}
.legend-label {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
}
.legend-scale {
display: flex;
gap: 0.75rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--text-secondary, #6b7280);
&::before {
content: '';
width: 12px;
height: 12px;
border-radius: 2px;
background: var(--item-color);
}
}
/* Grid */
.heatmap-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(48px, 1fr));
gap: 4px;
}
.heatmap-cell {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: var(--cell-color);
border: none;
border-radius: 4px;
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
min-width: 48px;
&:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10;
}
&:focus-visible {
outline: 2px solid var(--focus-ring, #3b82f6);
outline-offset: 2px;
}
&--offline {
opacity: 0.4;
background: var(--surface-secondary, #e5e7eb) !important;
}
}
.heatmap-cell__value {
font-size: 0.625rem;
font-weight: 600;
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.heatmap-cell--offline .heatmap-cell__value {
color: var(--text-muted, #9ca3af);
text-shadow: none;
}
/* Grouped View */
.heatmap-group {
margin-bottom: 1.5rem;
&:last-child {
margin-bottom: 0;
}
}
.heatmap-group__title {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary, #6b7280);
}
.heatmap-group__count {
font-weight: 400;
color: var(--text-muted, #9ca3af);
}
/* Tooltip */
.heatmap-tooltip {
position: absolute;
top: 50%;
right: 1rem;
transform: translateY(-50%);
width: 180px;
padding: 0.75rem;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 20;
pointer-events: none;
}
.tooltip-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.tooltip-status {
width: 8px;
height: 8px;
border-radius: 50%;
}
.tooltip-details {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.5rem;
margin: 0;
font-size: 0.75rem;
dt {
color: var(--text-muted, #9ca3af);
}
dd {
margin: 0;
font-weight: 500;
}
}
.tooltip-hint {
display: block;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-default, #e5e7eb);
font-size: 0.625rem;
color: var(--text-muted, #9ca3af);
text-align: center;
}
/* Summary */
.heatmap-summary {
display: flex;
justify-content: center;
gap: 2rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-default, #e5e7eb);
}
.summary-stat {
text-align: center;
}
.summary-stat__value {
display: block;
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary, #111827);
}
.summary-stat__label {
font-size: 0.6875rem;
color: var(--text-muted, #9ca3af);
text-transform: uppercase;
}
/* Responsive */
@media (max-width: 640px) {
.heatmap-tooltip {
position: fixed;
top: auto;
bottom: 1rem;
left: 1rem;
right: 1rem;
transform: none;
width: auto;
}
.heatmap-legend {
flex-wrap: wrap;
}
.heatmap-summary {
gap: 1rem;
}
}
`],
})
export class CapacityHeatmapComponent {
/** List of agents */
readonly agents = input<Agent[]>([]);
/** Emits when an agent cell is clicked */
readonly agentClick = output<Agent>();
// Local state
readonly groupBy = signal<GroupBy>('none');
readonly hoveredAgent = signal<Agent | null>(null);
// Computed
readonly groupedAgents = computed(() => {
const agents = this.agents();
const groupByValue = this.groupBy();
if (groupByValue === 'none') {
return [{ key: 'All', agents }];
}
const groups = new Map<string, Agent[]>();
for (const agent of agents) {
const key = groupByValue === 'environment' ? agent.environment : agent.status;
const existing = groups.get(key) || [];
groups.set(key, [...existing, agent]);
}
return Array.from(groups.entries())
.map(([key, groupAgents]) => ({ key, agents: groupAgents }))
.sort((a, b) => a.key.localeCompare(b.key));
});
readonly avgCapacity = computed(() => {
const agents = this.agents();
if (agents.length === 0) return 0;
const total = agents.reduce((sum, a) => sum + a.capacityPercent, 0);
return Math.round(total / agents.length);
});
readonly highUtilizationCount = computed(() =>
this.agents().filter((a) => a.capacityPercent > 80).length
);
readonly criticalCount = computed(() =>
this.agents().filter((a) => a.capacityPercent > 95).length
);
onGroupByChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.groupBy.set(select.value as GroupBy);
}
onCellClick(agent: Agent): void {
this.agentClick.emit(agent);
}
getCellColor(percent: number): string {
return getCapacityColor(percent);
}
getStatusColor(status: string): string {
switch (status) {
case 'online':
return 'var(--status-success, #10b981)';
case 'degraded':
return 'var(--status-warning, #f59e0b)';
case 'offline':
return 'var(--status-error, #ef4444)';
default:
return 'var(--status-unknown, #9ca3af)';
}
}
getCellAriaLabel(agent: Agent): string {
return `${agent.name}: ${agent.capacityPercent}% capacity, ${agent.status}`;
}
}

View File

@@ -0,0 +1,428 @@
/**
* Fleet Comparison Component Tests
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-009 - Create fleet comparison view
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FleetComparisonComponent } from './fleet-comparison.component';
import { Agent } from '../../models/agent.models';
describe('FleetComparisonComponent', () => {
let component: FleetComparisonComponent;
let fixture: ComponentFixture<FleetComparisonComponent>;
const createMockAgent = (overrides: Partial<Agent> = {}): Agent => ({
id: `agent-${Math.random().toString(36).substr(2, 9)}`,
name: 'test-agent',
environment: 'production',
version: '2.5.0',
status: 'online',
lastHeartbeat: new Date().toISOString(),
registeredAt: '2026-01-01T00:00:00Z',
resources: {
cpuPercent: 45,
memoryPercent: 60,
diskPercent: 35,
},
activeTasks: 3,
taskQueueDepth: 2,
capacityPercent: 65,
...overrides,
});
const createMockAgents = (): Agent[] => [
createMockAgent({ id: 'a1', name: 'alpha-agent', version: '2.5.0', capacityPercent: 30, environment: 'production' }),
createMockAgent({ id: 'a2', name: 'beta-agent', version: '2.4.0', capacityPercent: 60, environment: 'staging' }),
createMockAgent({ id: 'a3', name: 'gamma-agent', version: '2.5.0', capacityPercent: 85, environment: 'development' }),
createMockAgent({ id: 'a4', name: 'delta-agent', version: '2.3.0', capacityPercent: 45, environment: 'production' }),
];
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FleetComparisonComponent],
}).compileComponents();
fixture = TestBed.createComponent(FleetComparisonComponent);
component = fixture.componentInstance;
});
describe('rendering', () => {
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should display fleet comparison title', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Fleet Comparison');
});
it('should display agent count', () => {
const agents = createMockAgents();
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('4 agents');
});
it('should display table with column headers', () => {
fixture.componentRef.setInput('agents', createMockAgents());
fixture.detectChanges();
const compiled = fixture.nativeElement;
const headers = compiled.querySelectorAll('thead th');
expect(headers.length).toBeGreaterThan(0);
expect(compiled.querySelector('thead').textContent).toContain('Name');
expect(compiled.querySelector('thead').textContent).toContain('Environment');
expect(compiled.querySelector('thead').textContent).toContain('Status');
});
it('should display agent rows', () => {
const agents = createMockAgents();
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const rows = compiled.querySelectorAll('tbody tr');
expect(rows.length).toBe(agents.length);
});
});
describe('version mismatch detection', () => {
it('should detect latest version', () => {
const agents = createMockAgents();
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
expect(component.latestVersion()).toBe('2.5.0');
});
it('should count version mismatches', () => {
const agents = createMockAgents();
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
expect(component.versionMismatchCount()).toBe(2); // 2.4.0 and 2.3.0
});
it('should display version mismatch warning', () => {
const agents = createMockAgents();
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const alert = compiled.querySelector('.alert--warning');
expect(alert).toBeTruthy();
expect(alert.textContent).toContain('2 agents have version mismatches');
});
it('should not display warning when no mismatches', () => {
const agents = [
createMockAgent({ version: '2.5.0' }),
createMockAgent({ version: '2.5.0' }),
];
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const alert = compiled.querySelector('.alert--warning');
expect(alert).toBeNull();
});
});
describe('sorting', () => {
it('should default sort by name ascending', () => {
expect(component.sortColumn()).toBe('name');
expect(component.sortDirection()).toBe('asc');
});
it('should sort agents by name', () => {
const agents = createMockAgents();
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
const sorted = component.sortedAgents();
expect(sorted[0].name).toBe('alpha-agent');
expect(sorted[1].name).toBe('beta-agent');
expect(sorted[2].name).toBe('delta-agent');
expect(sorted[3].name).toBe('gamma-agent');
});
it('should toggle sort direction when clicking same column', () => {
fixture.detectChanges();
component.sortBy('name');
expect(component.sortDirection()).toBe('desc');
component.sortBy('name');
expect(component.sortDirection()).toBe('asc');
});
it('should reset to ascending when changing column', () => {
fixture.detectChanges();
component.sortBy('name'); // Now desc
expect(component.sortDirection()).toBe('desc');
component.sortBy('capacity');
expect(component.sortColumn()).toBe('capacity');
expect(component.sortDirection()).toBe('asc');
});
it('should sort by capacity', () => {
const agents = createMockAgents();
fixture.componentRef.setInput('agents', agents);
component.sortBy('capacity');
fixture.detectChanges();
const sorted = component.sortedAgents();
expect(sorted[0].capacityPercent).toBe(30);
expect(sorted[3].capacityPercent).toBe(85);
});
it('should sort by environment', () => {
const agents = createMockAgents();
fixture.componentRef.setInput('agents', agents);
component.sortBy('environment');
fixture.detectChanges();
const sorted = component.sortedAgents();
expect(sorted[0].environment).toBe('development');
expect(sorted[3].environment).toBe('staging');
});
it('should sort by version numerically', () => {
const agents = createMockAgents();
fixture.componentRef.setInput('agents', agents);
component.sortBy('version');
fixture.detectChanges();
const sorted = component.sortedAgents();
expect(sorted[0].version).toBe('2.3.0');
expect(sorted[3].version).toBe('2.5.0');
});
});
describe('column visibility', () => {
it('should have all columns visible by default', () => {
fixture.detectChanges();
const configs = component.columnConfigs();
expect(configs.every((c) => c.visible)).toBe(true);
});
it('should toggle column visibility', () => {
fixture.detectChanges();
component.toggleColumn('environment');
const configs = component.columnConfigs();
const envConfig = configs.find((c) => c.key === 'environment');
expect(envConfig?.visible).toBe(false);
});
it('should filter visible columns', () => {
fixture.detectChanges();
component.toggleColumn('environment');
component.toggleColumn('version');
const visible = component.visibleColumns();
expect(visible.some((c) => c.key === 'environment')).toBe(false);
expect(visible.some((c) => c.key === 'version')).toBe(false);
expect(visible.some((c) => c.key === 'name')).toBe(true);
});
it('should toggle column menu visibility', () => {
fixture.detectChanges();
expect(component.showColumnMenu()).toBe(false);
component.toggleColumnMenu();
expect(component.showColumnMenu()).toBe(true);
component.toggleColumnMenu();
expect(component.showColumnMenu()).toBe(false);
});
});
describe('events', () => {
it('should emit viewAgent when view button is clicked', () => {
const agents = [createMockAgent({ id: 'test-id', name: 'emit-test' })];
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
const viewSpy = jasmine.createSpy('viewAgent');
component.viewAgent.subscribe(viewSpy);
const compiled = fixture.nativeElement;
const viewBtn = compiled.querySelector('.actions-col .btn--icon');
viewBtn.click();
expect(viewSpy).toHaveBeenCalledWith(agents[0]);
});
});
describe('truncateId', () => {
it('should return full ID if 8 characters or less', () => {
fixture.detectChanges();
expect(component.truncateId('short')).toBe('short');
expect(component.truncateId('12345678')).toBe('12345678');
});
it('should truncate ID if longer than 8 characters', () => {
fixture.detectChanges();
const result = component.truncateId('very-long-agent-id');
expect(result).toBe('very-l...');
expect(result.length).toBe(9); // 6 chars + '...'
});
});
describe('formatHeartbeat', () => {
beforeEach(() => {
jasmine.clock().install();
});
afterEach(() => {
jasmine.clock().uninstall();
});
it('should return "Just now" for recent heartbeat', () => {
const now = new Date('2026-01-18T12:00:00Z');
jasmine.clock().mockDate(now);
const heartbeat = new Date('2026-01-18T11:59:45Z').toISOString();
expect(component.formatHeartbeat(heartbeat)).toBe('Just now');
});
it('should return minutes ago for heartbeat within hour', () => {
const now = new Date('2026-01-18T12:00:00Z');
jasmine.clock().mockDate(now);
const heartbeat = new Date('2026-01-18T11:30:00Z').toISOString();
expect(component.formatHeartbeat(heartbeat)).toBe('30m ago');
});
it('should return hours ago for heartbeat within day', () => {
const now = new Date('2026-01-18T12:00:00Z');
jasmine.clock().mockDate(now);
const heartbeat = new Date('2026-01-18T06:00:00Z').toISOString();
expect(component.formatHeartbeat(heartbeat)).toBe('6h ago');
});
it('should return days ago for old heartbeat', () => {
const now = new Date('2026-01-18T12:00:00Z');
jasmine.clock().mockDate(now);
const heartbeat = new Date('2026-01-16T12:00:00Z').toISOString();
expect(component.formatHeartbeat(heartbeat)).toBe('2d ago');
});
});
describe('CSV export', () => {
it('should generate CSV content', () => {
const agents = createMockAgents();
fixture.componentRef.setInput('agents', agents);
fixture.detectChanges();
// Spy on DOM methods
const mockLink = { href: '', download: '', click: jasmine.createSpy('click') };
spyOn(document, 'createElement').and.returnValue(mockLink as any);
spyOn(URL, 'createObjectURL').and.returnValue('blob:url');
spyOn(URL, 'revokeObjectURL');
component.exportToCsv();
expect(mockLink.click).toHaveBeenCalled();
expect(mockLink.download).toContain('agent-fleet-');
expect(mockLink.download).toContain('.csv');
});
});
describe('helper functions', () => {
it('should return correct status color', () => {
fixture.detectChanges();
expect(component.getStatusColor('online')).toContain('success');
expect(component.getStatusColor('degraded')).toContain('warning');
expect(component.getStatusColor('offline')).toContain('error');
});
it('should return correct status label', () => {
fixture.detectChanges();
expect(component.getStatusLabel('online')).toBe('Online');
expect(component.getStatusLabel('degraded')).toBe('Degraded');
expect(component.getStatusLabel('offline')).toBe('Offline');
});
it('should return correct capacity color', () => {
fixture.detectChanges();
expect(component.getCapacityColor(30)).toContain('low');
expect(component.getCapacityColor(60)).toContain('medium');
expect(component.getCapacityColor(90)).toContain('high');
expect(component.getCapacityColor(98)).toContain('critical');
});
});
describe('certificate expiry display', () => {
it('should display certificate days until expiry', () => {
const agent = createMockAgent({
certificate: {
thumbprint: 'abc',
subject: 'CN=agent',
issuer: 'CN=CA',
notBefore: '2026-01-01T00:00:00Z',
notAfter: '2027-01-01T00:00:00Z',
isExpired: false,
daysUntilExpiry: 90,
},
});
fixture.componentRef.setInput('agents', [agent]);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('90d');
});
it('should display N/A when no certificate', () => {
const agent = createMockAgent({ certificate: undefined });
fixture.componentRef.setInput('agents', [agent]);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const certCell = compiled.querySelector('.cert-expiry--na');
expect(certCell).toBeTruthy();
expect(certCell.textContent).toContain('N/A');
});
});
describe('accessibility', () => {
it('should have grid role on table', () => {
fixture.componentRef.setInput('agents', createMockAgents());
fixture.detectChanges();
const compiled = fixture.nativeElement;
const table = compiled.querySelector('.comparison-table');
expect(table.getAttribute('role')).toBe('grid');
});
it('should have scope="col" on header cells', () => {
fixture.componentRef.setInput('agents', createMockAgents());
fixture.detectChanges();
const compiled = fixture.nativeElement;
const headers = compiled.querySelectorAll('thead th');
headers.forEach((th: HTMLElement) => {
expect(th.getAttribute('scope')).toBe('col');
});
});
});
});

View File

@@ -0,0 +1,713 @@
/**
* Fleet Comparison Component
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-009 - Create fleet comparison view
*
* Table view for comparing agents across the fleet.
*/
import { Component, input, output, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Agent, getStatusColor, getStatusLabel, getCapacityColor } from '../../models/agent.models';
type SortColumn = 'name' | 'environment' | 'status' | 'version' | 'capacity' | 'tasks' | 'heartbeat' | 'certExpiry';
type SortDirection = 'asc' | 'desc';
interface ColumnConfig {
key: SortColumn;
label: string;
visible: boolean;
}
@Component({
selector: 'st-fleet-comparison',
standalone: true,
imports: [CommonModule],
template: `
<div class="fleet-comparison">
<!-- Toolbar -->
<header class="comparison-toolbar">
<div class="toolbar-left">
<h3 class="toolbar-title">Fleet Comparison</h3>
<span class="toolbar-count">{{ agents().length }} agents</span>
</div>
<div class="toolbar-right">
<!-- Column Selector -->
<div class="column-selector">
<button
type="button"
class="btn btn--icon"
(click)="toggleColumnMenu()"
title="Select columns"
>
<span aria-hidden="true">&#9881;</span>
</button>
@if (showColumnMenu()) {
<div class="column-menu">
<h4>Show Columns</h4>
@for (col of columnConfigs(); track col.key) {
<label class="column-option">
<input
type="checkbox"
[checked]="col.visible"
(change)="toggleColumn(col.key)"
/>
{{ col.label }}
</label>
}
</div>
}
</div>
<!-- Export -->
<button
type="button"
class="btn btn--secondary"
(click)="exportToCsv()"
>
Export CSV
</button>
</div>
</header>
<!-- Alerts -->
@if (versionMismatchCount() > 0) {
<div class="alert alert--warning">
<span class="alert-icon">&#9888;</span>
{{ versionMismatchCount() }} agents have version mismatches.
Latest version: {{ latestVersion() }}
</div>
}
<!-- Table -->
<div class="table-container">
<table class="comparison-table" role="grid">
<thead>
<tr>
@for (col of visibleColumns(); track col.key) {
<th
scope="col"
[class.sortable]="true"
[class.sorted]="sortColumn() === col.key"
(click)="sortBy(col.key)"
>
{{ col.label }}
@if (sortColumn() === col.key) {
<span class="sort-indicator">
{{ sortDirection() === 'asc' ? '&#9650;' : '&#9660;' }}
</span>
}
</th>
}
<th scope="col" class="actions-col">Actions</th>
</tr>
</thead>
<tbody>
@for (agent of sortedAgents(); track agent.id) {
<tr
[class.row--offline]="agent.status === 'offline'"
[class.row--version-mismatch]="agent.version !== latestVersion()"
>
@for (col of visibleColumns(); track col.key) {
<td [class]="'col-' + col.key">
@switch (col.key) {
@case ('name') {
<div class="cell-name">
<span
class="status-dot"
[style.background-color]="getStatusColor(agent.status)"
></span>
<span class="agent-name">{{ agent.displayName || agent.name }}</span>
<code class="agent-id">{{ truncateId(agent.id) }}</code>
</div>
}
@case ('environment') {
<span class="tag tag--env">{{ agent.environment }}</span>
}
@case ('status') {
<span
class="status-badge"
[style.--badge-color]="getStatusColor(agent.status)"
>
{{ getStatusLabel(agent.status) }}
</span>
}
@case ('version') {
<span
class="version"
[class.version--mismatch]="agent.version !== latestVersion()"
>
v{{ agent.version }}
@if (agent.version !== latestVersion()) {
<span class="mismatch-icon" title="Not latest version">&#9888;</span>
}
</span>
}
@case ('capacity') {
<div class="capacity-cell">
<div class="capacity-bar">
<div
class="capacity-bar__fill"
[style.width.%]="agent.capacityPercent"
[style.background-color]="getCapacityColor(agent.capacityPercent)"
></div>
</div>
<span class="capacity-value">{{ agent.capacityPercent }}%</span>
</div>
}
@case ('tasks') {
<span class="tasks-count">
{{ agent.activeTasks }} / {{ agent.taskQueueDepth }}
</span>
}
@case ('heartbeat') {
<span class="heartbeat" [title]="agent.lastHeartbeat">
{{ formatHeartbeat(agent.lastHeartbeat) }}
</span>
}
@case ('certExpiry') {
@if (agent.certificate) {
<span
class="cert-expiry"
[class.cert-expiry--warning]="agent.certificate.daysUntilExpiry <= 30"
[class.cert-expiry--critical]="agent.certificate.daysUntilExpiry <= 7"
>
{{ agent.certificate.daysUntilExpiry }}d
</span>
} @else {
<span class="cert-expiry cert-expiry--na">N/A</span>
}
}
}
</td>
}
<td class="actions-col">
<button
type="button"
class="btn btn--icon"
title="View details"
(click)="viewAgent.emit(agent)"
>
&#8594;
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Footer -->
<footer class="comparison-footer">
<span class="footer-info">
Showing {{ sortedAgents().length }} agents
@if (versionMismatchCount() > 0) {
&middot; {{ versionMismatchCount() }} version mismatches
}
</span>
</footer>
</div>
`,
styles: [`
.fleet-comparison {
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
overflow: hidden;
}
/* Toolbar */
.comparison-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-default, #e5e7eb);
background: var(--surface-secondary, #f9fafb);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 1rem;
}
.toolbar-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.toolbar-count {
font-size: 0.8125rem;
color: var(--text-muted, #9ca3af);
}
.toolbar-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.column-selector {
position: relative;
}
.column-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 0.25rem;
padding: 0.75rem;
background: var(--surface-primary, #ffffff);
border: 1px solid var(--border-default, #e5e7eb);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 100;
min-width: 160px;
h4 {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted, #9ca3af);
text-transform: uppercase;
}
}
.column-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.8125rem;
cursor: pointer;
input {
cursor: pointer;
}
}
/* Alert */
.alert {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.75rem 1rem;
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: 0.8125rem;
&--warning {
background: var(--warning-bg, #fef3c7);
color: var(--warning-text, #92400e);
}
}
.alert-icon {
font-size: 1rem;
}
/* Table */
.table-container {
overflow-x: auto;
}
.comparison-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
}
.comparison-table th,
.comparison-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--border-default, #e5e7eb);
}
.comparison-table th {
background: var(--surface-secondary, #f9fafb);
font-weight: 600;
color: var(--text-secondary, #6b7280);
white-space: nowrap;
&.sortable {
cursor: pointer;
user-select: none;
&:hover {
color: var(--text-primary, #111827);
}
}
&.sorted {
color: var(--primary, #3b82f6);
}
}
.sort-indicator {
margin-left: 0.25rem;
font-size: 0.625rem;
}
.comparison-table tbody tr {
transition: background 0.15s;
&:hover {
background: var(--surface-hover, #f3f4f6);
}
&.row--offline {
opacity: 0.6;
}
&.row--version-mismatch {
background: rgba(245, 158, 11, 0.05);
}
}
.actions-col {
width: 60px;
text-align: center;
}
/* Cell Styles */
.cell-name {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.agent-name {
font-weight: 500;
}
.agent-id {
font-size: 0.6875rem;
color: var(--text-muted, #9ca3af);
background: var(--surface-secondary, #f3f4f6);
padding: 0.125rem 0.25rem;
border-radius: 3px;
}
.tag {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 500;
&--env {
background: var(--tag-env-bg, #dbeafe);
color: var(--tag-env-text, #1e40af);
}
}
.status-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
background: color-mix(in srgb, var(--badge-color) 15%, transparent);
color: var(--badge-color);
}
.version {
display: inline-flex;
align-items: center;
gap: 0.25rem;
&--mismatch {
color: var(--status-warning, #f59e0b);
}
}
.mismatch-icon {
font-size: 0.75rem;
}
.capacity-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.capacity-bar {
width: 60px;
height: 6px;
background: var(--surface-secondary, #e5e7eb);
border-radius: 3px;
overflow: hidden;
}
.capacity-bar__fill {
height: 100%;
border-radius: 3px;
}
.capacity-value {
font-variant-numeric: tabular-nums;
min-width: 32px;
}
.tasks-count {
font-variant-numeric: tabular-nums;
}
.heartbeat {
color: var(--text-secondary, #6b7280);
}
.cert-expiry {
&--warning {
color: var(--status-warning, #f59e0b);
font-weight: 600;
}
&--critical {
color: var(--status-error, #ef4444);
font-weight: 600;
}
&--na {
color: var(--text-muted, #9ca3af);
}
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
}
.btn--secondary {
background: var(--surface-primary, #ffffff);
border-color: var(--border-default, #e5e7eb);
color: var(--text-primary, #111827);
&:hover {
background: var(--surface-hover, #f3f4f6);
}
}
.btn--icon {
padding: 0.375rem 0.5rem;
background: transparent;
border: none;
color: var(--text-muted, #9ca3af);
&:hover {
color: var(--text-primary, #111827);
background: var(--surface-hover, #f3f4f6);
}
}
/* Footer */
.comparison-footer {
padding: 0.75rem 1rem;
background: var(--surface-secondary, #f9fafb);
border-top: 1px solid var(--border-default, #e5e7eb);
}
.footer-info {
font-size: 0.75rem;
color: var(--text-muted, #9ca3af);
}
/* Responsive */
@media (max-width: 768px) {
.comparison-toolbar {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.toolbar-right {
width: 100%;
justify-content: flex-end;
}
}
`],
})
export class FleetComparisonComponent {
/** List of agents */
readonly agents = input<Agent[]>([]);
/** Emits when view agent is clicked */
readonly viewAgent = output<Agent>();
// Local state
readonly sortColumn = signal<SortColumn>('name');
readonly sortDirection = signal<SortDirection>('asc');
readonly showColumnMenu = signal(false);
readonly columnConfigs = signal<ColumnConfig[]>([
{ key: 'name', label: 'Name', visible: true },
{ key: 'environment', label: 'Environment', visible: true },
{ key: 'status', label: 'Status', visible: true },
{ key: 'version', label: 'Version', visible: true },
{ key: 'capacity', label: 'Capacity', visible: true },
{ key: 'tasks', label: 'Tasks', visible: true },
{ key: 'heartbeat', label: 'Heartbeat', visible: true },
{ key: 'certExpiry', label: 'Cert Expiry', visible: true },
]);
// Computed
readonly visibleColumns = computed(() =>
this.columnConfigs().filter((c) => c.visible)
);
readonly latestVersion = computed(() => {
const versions = this.agents().map((a) => a.version);
if (versions.length === 0) return '';
return versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))[0];
});
readonly versionMismatchCount = computed(() => {
const latest = this.latestVersion();
return this.agents().filter((a) => a.version !== latest).length;
});
readonly sortedAgents = computed(() => {
const agents = [...this.agents()];
const column = this.sortColumn();
const direction = this.sortDirection();
agents.sort((a, b) => {
let comparison = 0;
switch (column) {
case 'name':
comparison = (a.displayName || a.name).localeCompare(b.displayName || b.name);
break;
case 'environment':
comparison = a.environment.localeCompare(b.environment);
break;
case 'status':
comparison = a.status.localeCompare(b.status);
break;
case 'version':
comparison = a.version.localeCompare(b.version, undefined, { numeric: true });
break;
case 'capacity':
comparison = a.capacityPercent - b.capacityPercent;
break;
case 'tasks':
comparison = a.activeTasks - b.activeTasks;
break;
case 'heartbeat':
comparison = new Date(a.lastHeartbeat).getTime() - new Date(b.lastHeartbeat).getTime();
break;
case 'certExpiry':
const aExpiry = a.certificate?.daysUntilExpiry ?? Infinity;
const bExpiry = b.certificate?.daysUntilExpiry ?? Infinity;
comparison = aExpiry - bExpiry;
break;
}
return direction === 'asc' ? comparison : -comparison;
});
return agents;
});
toggleColumnMenu(): void {
this.showColumnMenu.update((v) => !v);
}
toggleColumn(key: SortColumn): void {
this.columnConfigs.update((configs) =>
configs.map((c) => (c.key === key ? { ...c, visible: !c.visible } : c))
);
}
sortBy(column: SortColumn): void {
if (this.sortColumn() === column) {
this.sortDirection.update((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
this.sortColumn.set(column);
this.sortDirection.set('asc');
}
}
exportToCsv(): void {
const headers = this.visibleColumns().map((c) => c.label);
const rows = this.sortedAgents().map((agent) =>
this.visibleColumns().map((col) => {
switch (col.key) {
case 'name':
return agent.displayName || agent.name;
case 'environment':
return agent.environment;
case 'status':
return agent.status;
case 'version':
return agent.version;
case 'capacity':
return `${agent.capacityPercent}%`;
case 'tasks':
return `${agent.activeTasks}/${agent.taskQueueDepth}`;
case 'heartbeat':
return agent.lastHeartbeat;
case 'certExpiry':
return agent.certificate ? `${agent.certificate.daysUntilExpiry}d` : 'N/A';
default:
return '';
}
})
);
const csv = [headers, ...rows].map((row) => row.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `agent-fleet-${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
}
truncateId(id: string): string {
if (id.length <= 8) return id;
return `${id.slice(0, 6)}...`;
}
formatHeartbeat(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return 'Just now';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
return `${Math.floor(diffSec / 86400)}d ago`;
}
getStatusColor(status: string): string {
return getStatusColor(status as any);
}
getStatusLabel(status: string): string {
return getStatusLabel(status as any);
}
getCapacityColor(percent: number): string {
return getCapacityColor(percent);
}
}

View File

@@ -0,0 +1,25 @@
/**
* Agent Fleet Feature Module
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
*/
// Routes
export { AGENTS_ROUTES } from './agents.routes';
// Components
export { AgentFleetDashboardComponent } from './agent-fleet-dashboard.component';
export { AgentDetailPageComponent } from './agent-detail-page.component';
export { AgentOnboardWizardComponent } from './agent-onboard-wizard.component';
export { AgentCardComponent } from './components/agent-card/agent-card.component';
export { AgentHealthTabComponent } from './components/agent-health-tab/agent-health-tab.component';
export { AgentTasksTabComponent } from './components/agent-tasks-tab/agent-tasks-tab.component';
export { CapacityHeatmapComponent } from './components/capacity-heatmap/capacity-heatmap.component';
export { FleetComparisonComponent } from './components/fleet-comparison/fleet-comparison.component';
export { AgentActionModalComponent } from './components/agent-action-modal/agent-action-modal.component';
// Services
export { AgentStore } from './services/agent.store';
export { AgentRealtimeService } from './services/agent-realtime.service';
// Models
export * from './models/agent.models';

View File

@@ -0,0 +1,144 @@
/**
* Agent Models Tests
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-001 - Agent Fleet dashboard page
*/
import {
getStatusColor,
getStatusLabel,
getCapacityColor,
formatHeartbeat,
AgentStatus,
} from './agent.models';
describe('Agent Model Helper Functions', () => {
describe('getStatusColor', () => {
it('should return success color for online status', () => {
expect(getStatusColor('online')).toContain('success');
});
it('should return warning color for degraded status', () => {
expect(getStatusColor('degraded')).toContain('warning');
});
it('should return error color for offline status', () => {
expect(getStatusColor('offline')).toContain('error');
});
it('should return unknown color for unknown status', () => {
expect(getStatusColor('unknown')).toContain('unknown');
});
it('should return unknown color for unrecognized status', () => {
// @ts-expect-error Testing edge case
expect(getStatusColor('invalid')).toContain('unknown');
});
});
describe('getStatusLabel', () => {
it('should return "Online" for online status', () => {
expect(getStatusLabel('online')).toBe('Online');
});
it('should return "Degraded" for degraded status', () => {
expect(getStatusLabel('degraded')).toBe('Degraded');
});
it('should return "Offline" for offline status', () => {
expect(getStatusLabel('offline')).toBe('Offline');
});
it('should return "Unknown" for unknown status', () => {
expect(getStatusLabel('unknown')).toBe('Unknown');
});
it('should return "Unknown" for unrecognized status', () => {
// @ts-expect-error Testing edge case
expect(getStatusLabel('invalid')).toBe('Unknown');
});
});
describe('getCapacityColor', () => {
it('should return low color for utilization under 50%', () => {
expect(getCapacityColor(0)).toContain('low');
expect(getCapacityColor(25)).toContain('low');
expect(getCapacityColor(49)).toContain('low');
});
it('should return medium color for utilization 50-79%', () => {
expect(getCapacityColor(50)).toContain('medium');
expect(getCapacityColor(65)).toContain('medium');
expect(getCapacityColor(79)).toContain('medium');
});
it('should return high color for utilization 80-94%', () => {
expect(getCapacityColor(80)).toContain('high');
expect(getCapacityColor(90)).toContain('high');
expect(getCapacityColor(94)).toContain('high');
});
it('should return critical color for utilization 95% and above', () => {
expect(getCapacityColor(95)).toContain('critical');
expect(getCapacityColor(99)).toContain('critical');
expect(getCapacityColor(100)).toContain('critical');
});
});
describe('formatHeartbeat', () => {
let originalDate: typeof Date;
beforeAll(() => {
originalDate = Date;
});
afterAll(() => {
// @ts-expect-error Restoring Date
global.Date = originalDate;
});
it('should return "Just now" for heartbeat within last minute', () => {
const now = new Date('2026-01-18T12:00:00Z');
jasmine.clock().install();
jasmine.clock().mockDate(now);
const heartbeat = new Date('2026-01-18T11:59:30Z').toISOString();
expect(formatHeartbeat(heartbeat)).toBe('Just now');
jasmine.clock().uninstall();
});
it('should return minutes ago for heartbeat within last hour', () => {
const now = new Date('2026-01-18T12:00:00Z');
jasmine.clock().install();
jasmine.clock().mockDate(now);
const heartbeat = new Date('2026-01-18T11:45:00Z').toISOString();
expect(formatHeartbeat(heartbeat)).toBe('15m ago');
jasmine.clock().uninstall();
});
it('should return hours ago for heartbeat within last day', () => {
const now = new Date('2026-01-18T12:00:00Z');
jasmine.clock().install();
jasmine.clock().mockDate(now);
const heartbeat = new Date('2026-01-18T08:00:00Z').toISOString();
expect(formatHeartbeat(heartbeat)).toBe('4h ago');
jasmine.clock().uninstall();
});
it('should return days ago for heartbeat older than a day', () => {
const now = new Date('2026-01-18T12:00:00Z');
jasmine.clock().install();
jasmine.clock().mockDate(now);
const heartbeat = new Date('2026-01-15T12:00:00Z').toISOString();
expect(formatHeartbeat(heartbeat)).toBe('3d ago');
jasmine.clock().uninstall();
});
});
});

View File

@@ -0,0 +1,205 @@
/**
* Agent Fleet Models
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-001 - Agent Fleet dashboard page
*/
/**
* Agent status indicator states.
*/
export type AgentStatus = 'online' | 'offline' | 'degraded' | 'unknown';
/**
* Agent health check result.
*/
export interface AgentHealthResult {
readonly checkId: string;
readonly checkName: string;
readonly status: 'pass' | 'warn' | 'fail';
readonly message?: string;
readonly lastChecked: string;
}
/**
* Agent task information.
*/
export interface AgentTask {
readonly taskId: string;
readonly taskType: string;
readonly status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
readonly progress?: number;
readonly releaseId?: string;
readonly deploymentId?: string;
readonly startedAt?: string;
readonly completedAt?: string;
readonly errorMessage?: string;
}
/**
* Agent resource utilization metrics.
*/
export interface AgentResources {
readonly cpuPercent: number;
readonly memoryPercent: number;
readonly diskPercent: number;
readonly networkLatencyMs?: number;
}
/**
* Agent certificate information.
*/
export interface AgentCertificate {
readonly thumbprint: string;
readonly subject: string;
readonly issuer: string;
readonly notBefore: string;
readonly notAfter: string;
readonly isExpired: boolean;
readonly daysUntilExpiry: number;
}
/**
* Agent configuration.
*/
export interface AgentConfig {
readonly maxConcurrentTasks: number;
readonly heartbeatIntervalSeconds: number;
readonly taskTimeoutSeconds: number;
readonly autoUpdate: boolean;
readonly logLevel: 'debug' | 'info' | 'warn' | 'error';
}
/**
* Core agent information.
*/
export interface Agent {
readonly id: string;
readonly name: string;
readonly displayName?: string;
readonly environment: string;
readonly version: string;
readonly status: AgentStatus;
readonly lastHeartbeat: string;
readonly registeredAt: string;
readonly resources: AgentResources;
readonly certificate?: AgentCertificate;
readonly config?: AgentConfig;
readonly activeTasks: number;
readonly taskQueueDepth: number;
readonly capacityPercent: number;
readonly tags?: readonly string[];
}
/**
* Agent fleet summary metrics.
*/
export interface AgentFleetSummary {
readonly totalAgents: number;
readonly onlineAgents: number;
readonly offlineAgents: number;
readonly degradedAgents: number;
readonly unknownAgents: number;
readonly totalCapacityPercent: number;
readonly totalActiveTasks: number;
readonly totalQueuedTasks: number;
readonly avgLatencyMs: number;
readonly certificatesExpiringSoon: number;
readonly versionMismatches: number;
}
/**
* Agent list filter options.
*/
export interface AgentListFilter {
readonly status?: AgentStatus[];
readonly environment?: string[];
readonly version?: string[];
readonly search?: string;
readonly minCapacity?: number;
readonly maxCapacity?: number;
readonly hasCertificateIssue?: boolean;
}
/**
* Agent action types.
*/
export type AgentAction = 'restart' | 'renew-certificate' | 'drain' | 'resume' | 'remove';
/**
* Agent action request.
*/
export interface AgentActionRequest {
readonly agentId: string;
readonly action: AgentAction;
readonly reason?: string;
}
/**
* Agent action result.
*/
export interface AgentActionResult {
readonly agentId: string;
readonly action: AgentAction;
readonly success: boolean;
readonly message: string;
readonly timestamp: string;
}
/**
* Get status indicator color.
*/
export function getStatusColor(status: AgentStatus): string {
switch (status) {
case 'online':
return 'var(--status-success, #10b981)';
case 'degraded':
return 'var(--status-warning, #f59e0b)';
case 'offline':
return 'var(--status-error, #ef4444)';
case 'unknown':
default:
return 'var(--status-unknown, #9ca3af)';
}
}
/**
* Get status label.
*/
export function getStatusLabel(status: AgentStatus): string {
switch (status) {
case 'online':
return 'Online';
case 'degraded':
return 'Degraded';
case 'offline':
return 'Offline';
case 'unknown':
default:
return 'Unknown';
}
}
/**
* Get capacity color based on utilization percentage.
*/
export function getCapacityColor(percent: number): string {
if (percent < 50) return 'var(--capacity-low, #10b981)';
if (percent < 80) return 'var(--capacity-medium, #f59e0b)';
if (percent < 95) return 'var(--capacity-high, #f97316)';
return 'var(--capacity-critical, #ef4444)';
}
/**
* Format last heartbeat for display.
*/
export function formatHeartbeat(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return 'Just now';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
return `${Math.floor(diffSec / 86400)}d ago`;
}

View File

@@ -0,0 +1,232 @@
/**
* Agent Real-time Service
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-007 - Implement real-time status updates
*
* WebSocket service for real-time agent status updates.
*/
import { Injectable, inject, signal, computed, OnDestroy } from '@angular/core';
import { Subject, Observable, timer, EMPTY } from 'rxjs';
import { webSocket, WebSocketSubject } from 'rxjs/webSocket';
import { retry, catchError, takeUntil, switchMap, tap, delay } from 'rxjs/operators';
import { Agent, AgentStatus } from '../models/agent.models';
/** Events received from the WebSocket */
export interface AgentRealtimeEvent {
type: AgentEventType;
agentId: string;
timestamp: string;
payload: AgentEventPayload;
}
export type AgentEventType =
| 'agent.online'
| 'agent.offline'
| 'agent.degraded'
| 'agent.heartbeat'
| 'agent.task.started'
| 'agent.task.completed'
| 'agent.task.failed'
| 'agent.capacity.changed'
| 'agent.certificate.expiring';
export interface AgentEventPayload {
status?: AgentStatus;
capacityPercent?: number;
activeTasks?: number;
taskId?: string;
taskType?: string;
certificateDaysRemaining?: number;
lastHeartbeat?: string;
metrics?: {
cpuPercent?: number;
memoryPercent?: number;
diskPercent?: number;
};
}
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error';
@Injectable({
providedIn: 'root',
})
export class AgentRealtimeService implements OnDestroy {
private socket$: WebSocketSubject<AgentRealtimeEvent> | null = null;
private readonly destroy$ = new Subject<void>();
private readonly events$ = new Subject<AgentRealtimeEvent>();
private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5;
private readonly reconnectDelay = 3000;
// Reactive state
readonly connectionStatus = signal<ConnectionStatus>('disconnected');
readonly lastEventTime = signal<string | null>(null);
readonly recentEvents = signal<AgentRealtimeEvent[]>([]);
readonly eventCount = signal(0);
// Keep track of recent events (last 50)
private readonly maxRecentEvents = 50;
/** Observable stream of all real-time events */
readonly events: Observable<AgentRealtimeEvent> = this.events$.asObservable();
/** Whether connection is active */
readonly isConnected = computed(() => this.connectionStatus() === 'connected');
/** Whether currently trying to reconnect */
readonly isReconnecting = computed(() => this.connectionStatus() === 'reconnecting');
ngOnDestroy(): void {
this.disconnect();
this.destroy$.next();
this.destroy$.complete();
}
/**
* Connect to the agent events WebSocket
* @param baseUrl Optional base URL for the WebSocket endpoint
*/
connect(baseUrl?: string): void {
if (this.socket$) {
return; // Already connected
}
const wsUrl = this.buildWebSocketUrl(baseUrl);
this.connectionStatus.set('connecting');
try {
this.socket$ = webSocket<AgentRealtimeEvent>({
url: wsUrl,
openObserver: {
next: () => {
this.connectionStatus.set('connected');
this.reconnectAttempts = 0;
console.log('[AgentRealtime] Connected to WebSocket');
},
},
closeObserver: {
next: (event) => {
console.log('[AgentRealtime] WebSocket closed', event);
this.handleDisconnection();
},
},
});
this.socket$
.pipe(
takeUntil(this.destroy$),
catchError((error) => {
console.error('[AgentRealtime] WebSocket error', error);
this.connectionStatus.set('error');
this.handleDisconnection();
return EMPTY;
})
)
.subscribe({
next: (event) => this.handleEvent(event),
error: (error) => {
console.error('[AgentRealtime] Subscription error', error);
this.handleDisconnection();
},
});
} catch (error) {
console.error('[AgentRealtime] Failed to create WebSocket', error);
this.connectionStatus.set('error');
}
}
/**
* Disconnect from the WebSocket
*/
disconnect(): void {
if (this.socket$) {
this.socket$.complete();
this.socket$ = null;
}
this.connectionStatus.set('disconnected');
this.reconnectAttempts = 0;
}
/**
* Subscribe to events for a specific agent
*/
subscribeToAgent(agentId: string): Observable<AgentRealtimeEvent> {
return this.events$.pipe(
takeUntil(this.destroy$),
// Filter to only this agent's events
// eslint-disable-next-line @typescript-eslint/no-unused-vars
switchMap((event) =>
event.agentId === agentId ? new Observable<AgentRealtimeEvent>((sub) => sub.next(event)) : EMPTY
)
);
}
/**
* Subscribe to specific event types
*/
subscribeToEventType(eventType: AgentEventType): Observable<AgentRealtimeEvent> {
return this.events$.pipe(
takeUntil(this.destroy$),
switchMap((event) =>
event.type === eventType ? new Observable<AgentRealtimeEvent>((sub) => sub.next(event)) : EMPTY
)
);
}
/**
* Force reconnection
*/
reconnect(): void {
this.disconnect();
this.connect();
}
private buildWebSocketUrl(baseUrl?: string): string {
// Build WebSocket URL from current location or provided base
const base = baseUrl || window.location.origin;
const wsProtocol = base.startsWith('https') ? 'wss' : 'ws';
const host = base.replace(/^https?:\/\//, '');
return `${wsProtocol}://${host}/api/agents/events`;
}
private handleEvent(event: AgentRealtimeEvent): void {
// Update state
this.lastEventTime.set(event.timestamp);
this.eventCount.update((count) => count + 1);
// Add to recent events (keep last N)
this.recentEvents.update((events) => {
const updated = [event, ...events];
return updated.slice(0, this.maxRecentEvents);
});
// Emit to subscribers
this.events$.next(event);
}
private handleDisconnection(): void {
this.socket$ = null;
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.connectionStatus.set('reconnecting');
this.reconnectAttempts++;
console.log(
`[AgentRealtime] Reconnecting in ${this.reconnectDelay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`
);
timer(this.reconnectDelay * this.reconnectAttempts)
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
if (this.connectionStatus() === 'reconnecting') {
this.connect();
}
});
} else {
console.log('[AgentRealtime] Max reconnect attempts reached');
this.connectionStatus.set('error');
}
}
}

View File

@@ -0,0 +1,521 @@
/**
* Agent Fleet Store
* Sprint: SPRINT_20260118_023_FE_agent_fleet_visualization
* Task: FLEET-001 - Agent Fleet dashboard page
*
* Signal-based state management for agent fleet.
*/
import { Injectable, computed, inject, signal, OnDestroy } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
import { catchError, of, tap, interval, switchMap, takeUntil, Subject, filter } from 'rxjs';
import {
Agent,
AgentFleetSummary,
AgentListFilter,
AgentStatus,
AgentActionRequest,
AgentActionResult,
AgentTask,
AgentHealthResult,
} from '../models/agent.models';
import { AgentRealtimeService, AgentRealtimeEvent } from './agent-realtime.service';
interface AgentState {
agents: Agent[];
summary: AgentFleetSummary | null;
selectedAgentId: string | null;
selectedAgent: Agent | null;
agentTasks: AgentTask[];
agentHealth: AgentHealthResult[];
filter: AgentListFilter;
isLoading: boolean;
error: string | null;
lastRefresh: string | null;
realtimeEnabled: boolean;
recentUpdates: Map<string, string>; // agentId -> timestamp of last update
}
const initialState: AgentState = {
agents: [],
summary: null,
selectedAgentId: null,
selectedAgent: null,
agentTasks: [],
agentHealth: [],
filter: {},
isLoading: false,
error: null,
lastRefresh: null,
realtimeEnabled: false,
recentUpdates: new Map(),
};
@Injectable({ providedIn: 'root' })
export class AgentStore implements OnDestroy {
private readonly http = inject(HttpClient);
private readonly realtime = inject(AgentRealtimeService);
private readonly state = signal<AgentState>(initialState);
private readonly destroy$ = new Subject<void>();
// Selectors
readonly agents = computed(() => this.state().agents);
readonly summary = computed(() => this.state().summary);
readonly selectedAgentId = computed(() => this.state().selectedAgentId);
readonly selectedAgent = computed(() => this.state().selectedAgent);
readonly agentTasks = computed(() => this.state().agentTasks);
readonly agentHealth = computed(() => this.state().agentHealth);
readonly filter = computed(() => this.state().filter);
readonly isLoading = computed(() => this.state().isLoading);
readonly error = computed(() => this.state().error);
readonly lastRefresh = computed(() => this.state().lastRefresh);
readonly realtimeEnabled = computed(() => this.state().realtimeEnabled);
readonly recentUpdates = computed(() => this.state().recentUpdates);
// Real-time connection status (delegated)
readonly realtimeConnectionStatus = this.realtime.connectionStatus;
readonly isRealtimeConnected = this.realtime.isConnected;
// Derived selectors
readonly filteredAgents = computed(() => {
const agents = this.agents();
const filter = this.filter();
return agents.filter((agent) => {
// Status filter
if (filter.status?.length && !filter.status.includes(agent.status)) {
return false;
}
// Environment filter
if (filter.environment?.length && !filter.environment.includes(agent.environment)) {
return false;
}
// Version filter
if (filter.version?.length && !filter.version.includes(agent.version)) {
return false;
}
// Search filter
if (filter.search) {
const search = filter.search.toLowerCase();
const matchesName = agent.name.toLowerCase().includes(search);
const matchesId = agent.id.toLowerCase().includes(search);
const matchesDisplay = agent.displayName?.toLowerCase().includes(search);
if (!matchesName && !matchesId && !matchesDisplay) {
return false;
}
}
// Capacity filter
if (filter.minCapacity !== undefined && agent.capacityPercent < filter.minCapacity) {
return false;
}
if (filter.maxCapacity !== undefined && agent.capacityPercent > filter.maxCapacity) {
return false;
}
// Certificate issue filter
if (filter.hasCertificateIssue && (!agent.certificate || !agent.certificate.isExpired && agent.certificate.daysUntilExpiry > 30)) {
return false;
}
return true;
});
});
readonly uniqueEnvironments = computed(() => {
const envs = new Set(this.agents().map((a) => a.environment));
return Array.from(envs).sort();
});
readonly uniqueVersions = computed(() => {
const versions = new Set(this.agents().map((a) => a.version));
return Array.from(versions).sort();
});
readonly agentsByStatus = computed(() => {
const agents = this.agents();
const result: Record<AgentStatus, Agent[]> = {
online: [],
offline: [],
degraded: [],
unknown: [],
};
for (const agent of agents) {
result[agent.status].push(agent);
}
return result;
});
// Actions
fetchAgents(): void {
this.updateState({ isLoading: true, error: null });
this.http
.get<Agent[]>('/api/agents')
.pipe(
tap((agents) => {
this.updateState({
agents,
isLoading: false,
lastRefresh: new Date().toISOString(),
});
}),
catchError((err) => {
this.updateState({
isLoading: false,
error: err.message || 'Failed to fetch agents',
});
return of([]);
})
)
.subscribe();
}
fetchSummary(): void {
this.http
.get<AgentFleetSummary>('/api/agents/summary')
.pipe(
tap((summary) => {
this.updateState({ summary });
}),
catchError((err) => {
console.error('Failed to fetch agent summary', err);
return of(null);
})
)
.subscribe();
}
fetchAgentById(agentId: string): void {
this.updateState({ isLoading: true, selectedAgentId: agentId, error: null });
this.http
.get<Agent>(`/api/agents/${agentId}`)
.pipe(
tap((agent) => {
this.updateState({
selectedAgent: agent,
isLoading: false,
});
}),
catchError((err) => {
this.updateState({
isLoading: false,
error: err.message || 'Failed to fetch agent',
});
return of(null);
})
)
.subscribe();
}
fetchAgentTasks(agentId: string): void {
this.http
.get<AgentTask[]>(`/api/agents/${agentId}/tasks`)
.pipe(
tap((tasks) => {
this.updateState({ agentTasks: tasks });
}),
catchError((err) => {
console.error('Failed to fetch agent tasks', err);
return of([]);
})
)
.subscribe();
}
fetchAgentHealth(agentId: string): void {
this.http
.get<AgentHealthResult[]>(`/api/agents/${agentId}/health`)
.pipe(
tap((health) => {
this.updateState({ agentHealth: health });
}),
catchError((err) => {
console.error('Failed to fetch agent health', err);
return of([]);
})
)
.subscribe();
}
executeAction(request: AgentActionRequest): void {
this.updateState({ isLoading: true, error: null });
this.http
.post<AgentActionResult>(`/api/agents/${request.agentId}/actions`, request)
.pipe(
tap((result) => {
this.updateState({ isLoading: false });
if (result.success) {
// Refresh agent data after action
this.fetchAgentById(request.agentId);
}
}),
catchError((err) => {
this.updateState({
isLoading: false,
error: err.message || 'Failed to execute action',
});
return of(null);
})
)
.subscribe();
}
// Filter actions
setStatusFilter(status: AgentStatus[]): void {
this.updateState({
filter: { ...this.filter(), status },
});
}
setEnvironmentFilter(environment: string[]): void {
this.updateState({
filter: { ...this.filter(), environment },
});
}
setVersionFilter(version: string[]): void {
this.updateState({
filter: { ...this.filter(), version },
});
}
setSearchFilter(search: string): void {
this.updateState({
filter: { ...this.filter(), search },
});
}
clearFilters(): void {
this.updateState({ filter: {} });
}
selectAgent(agentId: string | null): void {
this.updateState({
selectedAgentId: agentId,
selectedAgent: agentId ? this.agents().find((a) => a.id === agentId) ?? null : null,
agentTasks: [],
agentHealth: [],
});
if (agentId) {
this.fetchAgentById(agentId);
this.fetchAgentTasks(agentId);
this.fetchAgentHealth(agentId);
}
}
// Auto-refresh
startAutoRefresh(intervalMs: number = 30000): void {
interval(intervalMs)
.pipe(
takeUntil(this.destroy$),
switchMap(() => {
this.fetchAgents();
this.fetchSummary();
return of(null);
})
)
.subscribe();
}
stopAutoRefresh(): void {
this.destroy$.next();
}
// Real-time methods
enableRealtime(): void {
if (this.realtimeEnabled()) {
return;
}
this.updateState({ realtimeEnabled: true });
this.realtime.connect();
// Subscribe to events
this.realtime.events
.pipe(takeUntil(this.destroy$))
.subscribe((event) => this.handleRealtimeEvent(event));
}
disableRealtime(): void {
this.updateState({ realtimeEnabled: false });
this.realtime.disconnect();
}
reconnectRealtime(): void {
this.realtime.reconnect();
}
/**
* Check if an agent was recently updated (within last 5 seconds)
*/
wasRecentlyUpdated(agentId: string): boolean {
const updates = this.recentUpdates();
const lastUpdate = updates.get(agentId);
if (!lastUpdate) return false;
const elapsed = Date.now() - new Date(lastUpdate).getTime();
return elapsed < 5000;
}
private handleRealtimeEvent(event: AgentRealtimeEvent): void {
const { type, agentId, payload, timestamp } = event;
// Mark agent as recently updated
this.state.update((current) => {
const updates = new Map(current.recentUpdates);
updates.set(agentId, timestamp);
return { ...current, recentUpdates: updates };
});
// Update agent in list
switch (type) {
case 'agent.online':
case 'agent.offline':
case 'agent.degraded':
this.updateAgentStatus(agentId, payload.status!);
break;
case 'agent.heartbeat':
this.updateAgentHeartbeat(agentId, payload);
break;
case 'agent.capacity.changed':
this.updateAgentCapacity(agentId, payload);
break;
case 'agent.task.started':
case 'agent.task.completed':
case 'agent.task.failed':
this.updateAgentTasks(agentId, payload);
break;
case 'agent.certificate.expiring':
this.updateAgentCertificate(agentId, payload);
break;
}
// If this is the selected agent, update detailed view
if (this.selectedAgentId() === agentId) {
// Refresh the detailed agent data
this.fetchAgentById(agentId);
}
// Clear old update markers after 5 seconds
setTimeout(() => {
this.state.update((current) => {
const updates = new Map(current.recentUpdates);
const lastUpdate = updates.get(agentId);
if (lastUpdate === timestamp) {
updates.delete(agentId);
}
return { ...current, recentUpdates: updates };
});
}, 5000);
}
private updateAgentStatus(agentId: string, status: AgentStatus): void {
this.state.update((current) => ({
...current,
agents: current.agents.map((agent) =>
agent.id === agentId ? { ...agent, status } : agent
),
}));
// Update summary counts
this.fetchSummary();
}
private updateAgentHeartbeat(agentId: string, payload: AgentRealtimeEvent['payload']): void {
this.state.update((current) => ({
...current,
agents: current.agents.map((agent) =>
agent.id === agentId
? {
...agent,
lastHeartbeat: payload.lastHeartbeat || agent.lastHeartbeat,
metrics: payload.metrics
? {
...agent.metrics,
cpuPercent: payload.metrics.cpuPercent ?? agent.metrics?.cpuPercent ?? 0,
memoryPercent: payload.metrics.memoryPercent ?? agent.metrics?.memoryPercent ?? 0,
diskPercent: payload.metrics.diskPercent ?? agent.metrics?.diskPercent ?? 0,
}
: agent.metrics,
}
: agent
),
}));
}
private updateAgentCapacity(agentId: string, payload: AgentRealtimeEvent['payload']): void {
this.state.update((current) => ({
...current,
agents: current.agents.map((agent) =>
agent.id === agentId
? {
...agent,
capacityPercent: payload.capacityPercent ?? agent.capacityPercent,
activeTasks: payload.activeTasks ?? agent.activeTasks,
}
: agent
),
}));
}
private updateAgentTasks(agentId: string, payload: AgentRealtimeEvent['payload']): void {
// Update active tasks count
if (payload.activeTasks !== undefined) {
this.state.update((current) => ({
...current,
agents: current.agents.map((agent) =>
agent.id === agentId
? { ...agent, activeTasks: payload.activeTasks! }
: agent
),
}));
}
// If viewing this agent's tasks, refresh task list
if (this.selectedAgentId() === agentId) {
this.fetchAgentTasks(agentId);
}
}
private updateAgentCertificate(agentId: string, payload: AgentRealtimeEvent['payload']): void {
this.state.update((current) => ({
...current,
agents: current.agents.map((agent) =>
agent.id === agentId && agent.certificate
? {
...agent,
certificate: {
...agent.certificate,
daysUntilExpiry: payload.certificateDaysRemaining ?? agent.certificate.daysUntilExpiry,
},
}
: agent
),
}));
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.disableRealtime();
}
private updateState(partial: Partial<AgentState>): void {
this.state.update((current) => ({ ...current, ...partial }));
}
}

View File

@@ -1,31 +1,33 @@
@use '../../../styles/tokens/breakpoints' as *;
.verify-action {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
overflow: hidden;
&.state-running {
.action-header {
background: var(--color-info-bg, #f0f9ff);
border-left: 4px solid var(--color-info, #2563eb);
background: var(--color-status-info-bg);
border-left: 4px solid var(--color-status-info);
}
.status-icon { color: var(--color-info, #2563eb); }
.status-icon { color: var(--color-status-info); }
}
&.state-completed {
.action-header {
background: var(--color-success-bg, #ecfdf5);
border-left: 4px solid var(--color-success, #059669);
background: var(--color-status-success-bg);
border-left: 4px solid var(--color-status-success);
}
.status-icon { color: var(--color-success, #059669); }
.status-icon { color: var(--color-status-success); }
}
&.state-error {
.action-header {
background: var(--color-error-bg, #fef2f2);
border-left: 4px solid var(--color-error, #dc2626);
background: var(--color-status-error-bg);
border-left: 4px solid var(--color-status-error);
}
.status-icon { color: var(--color-error, #dc2626); }
.status-icon { color: var(--color-status-error); }
}
}
@@ -33,83 +35,83 @@
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-left: 4px solid var(--color-border, #e5e7eb);
gap: var(--space-3);
padding: var(--space-2-5) var(--space-3);
background: var(--color-surface-secondary);
border-left: 4px solid var(--color-border-primary);
flex-wrap: wrap;
}
.action-info {
display: flex;
align-items: center;
gap: 0.75rem;
gap: var(--space-2-5);
}
.status-icon {
font-family: monospace;
font-weight: 700;
font-size: 1rem;
color: var(--color-text-muted, #6b7280);
font-family: var(--font-family-mono);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-base);
color: var(--color-text-muted);
}
.action-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
gap: var(--space-0-5);
}
.action-title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text, #111827);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.action-desc {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.action-buttons {
display: flex;
gap: 0.5rem;
gap: var(--space-2);
}
.btn-verify {
padding: 0.5rem 1rem;
background: var(--color-primary, #2563eb);
padding: var(--space-2) var(--space-3);
background: var(--color-brand-primary);
border: none;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 600;
color: white;
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-inverse);
cursor: pointer;
&:hover {
background: var(--color-primary-dark, #1d4ed8);
background: var(--color-brand-primary-hover);
}
}
.btn-cli {
padding: 0.5rem 0.75rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
font-family: monospace;
padding: var(--space-2) var(--space-2-5);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-family: var(--font-family-mono);
cursor: pointer;
color: var(--color-text-muted, #6b7280);
color: var(--color-text-muted);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
background: var(--color-surface-secondary);
}
&.active {
background: var(--color-primary, #2563eb);
color: white;
border-color: var(--color-primary, #2563eb);
background: var(--color-brand-primary);
color: var(--color-text-inverse);
border-color: var(--color-brand-primary);
}
}
@@ -117,30 +119,30 @@
.progress-section {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
gap: var(--space-2-5);
padding: var(--space-2-5) var(--space-3);
border-top: 1px solid var(--color-border-primary);
}
.progress-bar {
flex: 1;
height: 8px;
background: var(--color-bg-subtle, #e5e7eb);
border-radius: 4px;
background: var(--color-surface-tertiary);
border-radius: var(--radius-sm);
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--color-primary, #2563eb);
border-radius: 4px;
transition: width 0.2s ease;
background: var(--color-brand-primary);
border-radius: var(--radius-sm);
transition: width var(--motion-duration-fast) var(--motion-ease-default);
}
.progress-text {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
min-width: 40px;
text-align: right;
}
@@ -149,130 +151,130 @@
.error-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--color-error-bg, #fef2f2);
border-top: 1px solid var(--color-error-border, #fecaca);
gap: var(--space-2);
padding: var(--space-2-5) var(--space-3);
background: var(--color-status-error-bg);
border-top: 1px solid var(--color-status-error);
}
.error-icon {
font-family: monospace;
font-weight: 700;
color: var(--color-error, #dc2626);
font-family: var(--font-family-mono);
font-weight: var(--font-weight-bold);
color: var(--color-status-error);
}
.error-message {
flex: 1;
font-size: 0.8125rem;
color: var(--color-error, #dc2626);
font-size: var(--font-size-sm);
color: var(--color-status-error);
}
.btn-retry {
padding: 0.25rem 0.75rem;
background: var(--color-error, #dc2626);
padding: var(--space-1) var(--space-2-5);
background: var(--color-status-error);
border: none;
border-radius: 4px;
font-size: 0.75rem;
color: white;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
color: var(--color-text-inverse);
cursor: pointer;
&:hover {
background: var(--color-error-dark, #b91c1c);
filter: brightness(0.9);
}
}
// Results
.results-section {
padding: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
padding: var(--space-3);
border-top: 1px solid var(--color-border-primary);
}
.results-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
gap: var(--space-2-5);
margin-bottom: var(--space-3);
}
.stat-card {
padding: 0.75rem;
background: var(--color-bg-subtle, #f9fafb);
border-radius: 6px;
padding: var(--space-2-5);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
text-align: center;
&.success {
background: var(--color-success-bg, #ecfdf5);
.stat-value { color: var(--color-success, #059669); }
background: var(--color-status-success-bg);
.stat-value { color: var(--color-status-success); }
}
&.error {
background: var(--color-error-bg, #fef2f2);
.stat-value { color: var(--color-error, #dc2626); }
background: var(--color-status-error-bg);
.stat-value { color: var(--color-status-error); }
}
}
.stat-value {
display: block;
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text, #111827);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.stat-label {
font-size: 0.6875rem;
color: var(--color-text-muted, #6b7280);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.violations-preview {
margin-bottom: 1rem;
margin-bottom: var(--space-3);
}
.preview-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text, #374151);
margin: 0 0 0.5rem;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
margin: 0 0 var(--space-2);
display: flex;
align-items: center;
gap: 0.5rem;
gap: var(--space-2);
}
.violation-count {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background: var(--color-error-bg, #fef2f2);
color: var(--color-error, #dc2626);
border-radius: 10px;
font-size: var(--font-size-xs);
padding: var(--space-0-5) var(--space-1-5);
background: var(--color-status-error-bg);
color: var(--color-status-error);
border-radius: var(--radius-full);
font-weight: normal;
}
.code-breakdown {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-bottom: 0.75rem;
gap: var(--space-1-5);
margin-bottom: var(--space-2-5);
}
.code-chip {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--color-bg-subtle, #f3f4f6);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-family: monospace;
font-size: 0.75rem;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
}
.code-count {
font-size: 0.625rem;
padding: 0 0.25rem;
background: var(--color-error, #dc2626);
color: white;
border-radius: 8px;
font-size: var(--font-size-xs);
padding: 0 var(--space-1);
background: var(--color-status-error);
color: var(--color-text-inverse);
border-radius: var(--radius-full);
}
.violations-list {
@@ -282,7 +284,7 @@
}
.violation-item {
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
border-bottom: 1px solid var(--color-border-secondary);
&:last-child {
border-bottom: none;
@@ -292,101 +294,101 @@
.violation-btn {
display: flex;
align-items: center;
gap: 0.5rem;
gap: var(--space-2);
width: 100%;
padding: 0.5rem;
padding: var(--space-2);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
background: var(--color-surface-secondary);
}
}
.v-code {
font-family: monospace;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-error, #dc2626);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-status-error);
}
.v-doc {
font-family: monospace;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.v-field {
font-size: 0.6875rem;
padding: 0.125rem 0.25rem;
background: var(--color-warning-bg, #fef3c7);
border-radius: 2px;
color: var(--color-warning-dark, #92400e);
font-size: var(--font-size-xs);
padding: var(--space-0-5) var(--space-1);
background: var(--color-status-warning-bg);
border-radius: var(--radius-xs);
color: var(--color-status-warning);
}
.more-violations {
padding: 0.5rem;
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
padding: var(--space-2);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-style: italic;
}
.no-violations {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
background: var(--color-success-bg, #ecfdf5);
border-radius: 4px;
font-size: 0.875rem;
color: var(--color-success, #059669);
margin-bottom: 1rem;
gap: var(--space-2);
padding: var(--space-3);
background: var(--color-status-success-bg);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
color: var(--color-status-success);
margin-bottom: var(--space-3);
}
.success-icon {
font-family: monospace;
font-weight: 700;
font-family: var(--font-family-mono);
font-weight: var(--font-weight-bold);
}
.completion-info {
display: flex;
justify-content: space-between;
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
padding-top: 0.5rem;
border-top: 1px solid var(--color-border-light, #f3f4f6);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
padding-top: var(--space-2);
border-top: 1px solid var(--color-border-secondary);
}
.verify-id {
font-family: monospace;
font-family: var(--font-family-mono);
}
// CLI Guidance
.cli-guidance {
padding: 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-top: 1px solid var(--color-border, #e5e7eb);
padding: var(--space-3);
background: var(--color-surface-secondary);
border-top: 1px solid var(--color-border-primary);
}
.cli-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text, #374151);
margin: 0 0 0.5rem;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
margin: 0 0 var(--space-2);
}
.cli-desc {
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
margin: 0 0 1rem;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin: 0 0 var(--space-3);
}
.cli-command-section,
.cli-flags-section,
.cli-examples-section {
margin-bottom: 1rem;
margin-bottom: var(--space-3);
&:last-child {
margin-bottom: 0;
@@ -395,55 +397,55 @@
.cli-label {
display: block;
font-size: 0.6875rem;
font-weight: 600;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
margin-bottom: 0.375rem;
color: var(--color-text-muted);
margin-bottom: var(--space-1-5);
}
.cli-command {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--color-bg-code, #1f2937);
border-radius: 4px;
gap: var(--space-2);
padding: var(--space-2) var(--space-2-5);
background: var(--color-terminal-bg);
border-radius: var(--radius-sm);
code {
flex: 1;
font-size: 0.8125rem;
color: #e5e7eb;
font-size: var(--font-size-sm);
color: var(--color-terminal-text);
white-space: nowrap;
overflow-x: auto;
}
}
.btn-copy {
padding: 0.25rem 0.375rem;
padding: var(--space-1) var(--space-1-5);
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 3px;
font-family: monospace;
font-size: 0.625rem;
color: #9ca3af;
border-radius: var(--radius-xs);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
cursor: pointer;
flex-shrink: 0;
&:hover {
background: rgba(255, 255, 255, 0.2);
color: white;
color: var(--color-text-inverse);
}
}
.flags-table {
width: 100%;
font-size: 0.8125rem;
font-size: var(--font-size-sm);
border-collapse: collapse;
tr {
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
border-bottom: 1px solid var(--color-border-secondary);
&:last-child {
border-bottom: none;
@@ -451,43 +453,43 @@
}
td {
padding: 0.375rem 0;
padding: var(--space-1-5) 0;
}
.flag-name {
width: 140px;
code {
font-size: 0.75rem;
background: var(--color-bg-code, #f3f4f6);
padding: 0.125rem 0.25rem;
border-radius: 2px;
font-size: var(--font-size-xs);
background: var(--color-surface-tertiary);
padding: var(--space-0-5) var(--space-1);
border-radius: var(--radius-xs);
}
}
.flag-desc {
color: var(--color-text-muted, #6b7280);
color: var(--color-text-muted);
}
}
.examples-list {
display: flex;
flex-direction: column;
gap: 0.375rem;
gap: var(--space-1-5);
}
.example-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
background: var(--color-bg-code, #1f2937);
border-radius: 4px;
gap: var(--space-2);
padding: var(--space-1-5) var(--space-2);
background: var(--color-terminal-bg);
border-radius: var(--radius-sm);
code {
flex: 1;
font-size: 0.75rem;
color: #d1d5db;
font-size: var(--font-size-xs);
color: var(--color-terminal-text);
white-space: nowrap;
overflow-x: auto;
}
@@ -496,22 +498,22 @@
.install-hint {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
background: var(--color-info-bg, #f0f9ff);
border-radius: 4px;
font-size: 0.75rem;
color: var(--color-info, #0284c7);
margin-top: 1rem;
gap: var(--space-2);
padding: var(--space-2);
background: var(--color-status-info-bg);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
color: var(--color-status-info);
margin-top: var(--space-3);
code {
background: var(--color-bg-code, #e0f2fe);
padding: 0.125rem 0.25rem;
border-radius: 2px;
background: var(--color-surface-tertiary);
padding: var(--space-0-5) var(--space-1);
border-radius: var(--radius-xs);
}
}
.hint-icon {
font-family: monospace;
font-weight: 600;
font-family: var(--font-family-mono);
font-weight: var(--font-weight-semibold);
}

View File

@@ -1,7 +1,9 @@
@use '../../../styles/tokens/breakpoints' as *;
.violation-drilldown {
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
overflow: hidden;
}
@@ -9,17 +11,17 @@
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
gap: var(--space-3);
padding: var(--space-3);
background: var(--color-surface-secondary);
border-bottom: 1px solid var(--color-border-primary);
flex-wrap: wrap;
}
.summary-stats {
display: flex;
align-items: center;
gap: 1.5rem;
gap: var(--space-4);
flex-wrap: wrap;
}
@@ -29,96 +31,98 @@
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text, #111827);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.stat-label {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.severity-breakdown {
display: flex;
gap: 0.5rem;
gap: var(--space-2);
flex-wrap: wrap;
}
.severity-chip {
font-size: 0.6875rem;
padding: 0.125rem 0.5rem;
border-radius: 12px;
font-weight: 500;
font-size: var(--font-size-xs);
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-lg);
font-weight: var(--font-weight-medium);
&.critical {
background: var(--color-critical-bg, #fef2f2);
color: var(--color-critical, #dc2626);
background: var(--color-severity-critical-bg);
color: var(--color-severity-critical);
}
&.high {
background: var(--color-error-bg, #fff7ed);
color: var(--color-error, #ea580c);
background: var(--color-severity-high-bg);
color: var(--color-severity-high);
}
&.medium {
background: var(--color-warning-bg, #fffbeb);
color: var(--color-warning, #d97706);
background: var(--color-severity-medium-bg);
color: var(--color-severity-medium);
}
&.low {
background: var(--color-info-bg, #f0f9ff);
color: var(--color-info, #0284c7);
background: var(--color-severity-low-bg);
color: var(--color-severity-low);
}
}
.controls {
display: flex;
gap: 0.75rem;
gap: var(--space-2-5);
align-items: center;
flex-wrap: wrap;
}
.view-toggle {
display: flex;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
overflow: hidden;
}
.toggle-btn {
padding: 0.375rem 0.75rem;
background: var(--color-bg-card, white);
padding: var(--space-1-5) var(--space-2-5);
background: var(--color-surface-primary);
border: none;
font-size: 0.8125rem;
font-size: var(--font-size-sm);
cursor: pointer;
color: var(--color-text-muted, #6b7280);
color: var(--color-text-muted);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
background: var(--color-surface-secondary);
}
&.active {
background: var(--color-primary, #2563eb);
color: white;
background: var(--color-brand-primary);
color: var(--color-text-inverse);
}
&:not(:last-child) {
border-right: 1px solid var(--color-border, #e5e7eb);
border-right: 1px solid var(--color-border-primary);
}
}
.search-input {
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.8125rem;
padding: var(--space-1-5) var(--space-2-5);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
min-width: 200px;
background: var(--color-surface-primary);
color: var(--color-text-primary);
&:focus {
outline: 2px solid var(--color-primary, #2563eb);
outline: 2px solid var(--color-brand-primary);
outline-offset: -1px;
}
}
@@ -130,52 +134,52 @@
}
.violation-group {
border-bottom: 1px solid var(--color-border, #e5e7eb);
border-bottom: 1px solid var(--color-border-primary);
&:last-child {
border-bottom: none;
}
&.severity-critical {
.group-header { border-left: 3px solid var(--color-critical, #dc2626); }
.severity-icon { color: var(--color-critical, #dc2626); }
.group-header { border-left: 3px solid var(--color-severity-critical); }
.severity-icon { color: var(--color-severity-critical); }
}
&.severity-high {
.group-header { border-left: 3px solid var(--color-error, #ea580c); }
.severity-icon { color: var(--color-error, #ea580c); }
.group-header { border-left: 3px solid var(--color-severity-high); }
.severity-icon { color: var(--color-severity-high); }
}
&.severity-medium {
.group-header { border-left: 3px solid var(--color-warning, #d97706); }
.severity-icon { color: var(--color-warning, #d97706); }
.group-header { border-left: 3px solid var(--color-severity-medium); }
.severity-icon { color: var(--color-severity-medium); }
}
&.severity-low {
.group-header { border-left: 3px solid var(--color-info, #0284c7); }
.severity-icon { color: var(--color-info, #0284c7); }
.group-header { border-left: 3px solid var(--color-severity-low); }
.severity-icon { color: var(--color-severity-low); }
}
}
.group-header {
display: flex;
align-items: center;
gap: 0.75rem;
gap: var(--space-2-5);
width: 100%;
padding: 0.75rem 1rem;
padding: var(--space-2-5) var(--space-3);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
background: var(--color-surface-secondary);
}
}
.severity-icon {
font-weight: 700;
font-size: 0.875rem;
font-weight: var(--font-weight-bold);
font-size: var(--font-size-sm);
width: 1.5rem;
text-align: center;
}
@@ -185,34 +189,34 @@
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
gap: var(--space-0-5);
}
.violation-code {
font-family: monospace;
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text, #111827);
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.violation-desc {
font-size: 0.8125rem;
color: var(--color-text-muted, #6b7280);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.affected-count {
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
white-space: nowrap;
}
.expand-icon {
font-size: 0.625rem;
color: var(--color-text-muted, #9ca3af);
transition: transform 0.2s;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
transition: transform var(--motion-duration-fast) var(--motion-ease-default);
&.expanded {
transform: rotate(180deg);
@@ -220,39 +224,39 @@
}
.group-details {
padding: 0 1rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
padding: 0 var(--space-3) var(--space-3);
background: var(--color-surface-secondary);
}
.remediation-hint {
font-size: 0.8125rem;
padding: 0.5rem 0.75rem;
margin-bottom: 0.75rem;
background: var(--color-info-bg, #f0f9ff);
border-radius: 4px;
color: var(--color-text, #374151);
font-size: var(--font-size-sm);
padding: var(--space-2) var(--space-2-5);
margin-bottom: var(--space-2-5);
background: var(--color-status-info-bg);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
}
.violations-table {
width: 100%;
border-collapse: collapse;
font-size: 0.8125rem;
font-size: var(--font-size-sm);
th {
text-align: left;
padding: 0.5rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
border-bottom: 1px solid var(--color-border, #e5e7eb);
font-size: 0.75rem;
padding: var(--space-2);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border-primary);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.03em;
}
td {
padding: 0.5rem;
padding: var(--space-2);
vertical-align: top;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
border-bottom: 1px solid var(--color-border-secondary);
}
tr:last-child td {
@@ -263,9 +267,9 @@
.doc-link {
background: none;
border: none;
color: var(--color-primary, #2563eb);
font-family: monospace;
font-size: 0.75rem;
color: var(--color-brand-primary);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
cursor: pointer;
padding: 0;
@@ -275,21 +279,21 @@
}
.field-path {
font-size: 0.75rem;
padding: 0.125rem 0.25rem;
border-radius: 2px;
font-size: var(--font-size-xs);
padding: var(--space-0-5) var(--space-1);
border-radius: var(--radius-xs);
&.highlighted {
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning-dark, #92400e);
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
}
.value {
font-size: 0.75rem;
padding: 0.125rem 0.25rem;
border-radius: 2px;
background: var(--color-bg-code, #f3f4f6);
font-size: var(--font-size-xs);
padding: var(--space-0-5) var(--space-1);
border-radius: var(--radius-xs);
background: var(--color-surface-tertiary);
max-width: 150px;
display: inline-block;
overflow: hidden;
@@ -297,38 +301,38 @@
white-space: nowrap;
&.expected {
background: var(--color-success-bg, #ecfdf5);
color: var(--color-success, #059669);
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.actual.error {
background: var(--color-error-bg, #fef2f2);
color: var(--color-error, #dc2626);
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
}
.no-field,
.no-value,
.no-provenance {
color: var(--color-text-muted, #9ca3af);
color: var(--color-text-muted);
font-style: italic;
}
.provenance-info {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.6875rem;
gap: var(--space-0-5);
font-size: var(--font-size-xs);
}
.source-type {
font-family: monospace;
font-weight: 600;
font-family: var(--font-family-mono);
font-weight: var(--font-weight-semibold);
}
.source-id,
.digest {
color: var(--color-text-muted, #6b7280);
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -337,17 +341,17 @@
.btn-icon {
background: none;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
padding: 0.25rem 0.5rem;
font-family: monospace;
font-size: 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
padding: var(--space-1) var(--space-2);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
cursor: pointer;
color: var(--color-text-muted, #6b7280);
color: var(--color-text-muted);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
color: var(--color-text, #374151);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
}
}
@@ -358,7 +362,7 @@
}
.document-card {
border-bottom: 1px solid var(--color-border, #e5e7eb);
border-bottom: 1px solid var(--color-border-primary);
&:last-child {
border-bottom: none;
@@ -368,57 +372,57 @@
.doc-header {
display: flex;
align-items: center;
gap: 0.75rem;
gap: var(--space-2-5);
width: 100%;
padding: 0.75rem 1rem;
padding: var(--space-2-5) var(--space-3);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
background: var(--color-surface-secondary);
}
}
.doc-type-badge {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
border-radius: 3px;
background: var(--color-bg-subtle, #f3f4f6);
color: var(--color-text-muted, #6b7280);
font-size: var(--font-size-xs);
padding: var(--space-0-5) var(--space-1-5);
border-radius: var(--radius-xs);
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
text-transform: uppercase;
font-weight: 500;
font-weight: var(--font-weight-medium);
}
.doc-id {
flex: 1;
font-family: monospace;
font-size: 0.8125rem;
color: var(--color-text, #111827);
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.violation-count {
font-size: 0.75rem;
color: var(--color-error, #dc2626);
font-weight: 500;
font-size: var(--font-size-xs);
color: var(--color-status-error);
font-weight: var(--font-weight-medium);
}
.doc-details {
padding: 0 1rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
padding: 0 var(--space-3) var(--space-3);
background: var(--color-surface-secondary);
}
.section-title {
font-size: 0.75rem;
font-weight: 600;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
margin: 0 0 0.5rem;
color: var(--color-text-muted);
margin: 0 0 var(--space-2);
display: flex;
justify-content: space-between;
align-items: center;
@@ -427,8 +431,8 @@
.btn-link {
background: none;
border: none;
color: var(--color-primary, #2563eb);
font-size: 0.75rem;
color: var(--color-brand-primary);
font-size: var(--font-size-xs);
cursor: pointer;
padding: 0;
text-transform: none;
@@ -443,7 +447,7 @@
.provenance-section,
.violations-section,
.raw-content-section {
margin-bottom: 1rem;
margin-bottom: var(--space-3);
&:last-child {
margin-bottom: 0;
@@ -453,31 +457,31 @@
.provenance-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.5rem;
gap: var(--space-2);
margin: 0;
}
.prov-item {
dt {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
margin-bottom: 0.125rem;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
margin-bottom: var(--space-0-5);
}
dd {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text, #374151);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
code {
font-size: 0.75rem;
background: var(--color-bg-code, #f3f4f6);
padding: 0.125rem 0.25rem;
border-radius: 2px;
font-size: var(--font-size-xs);
background: var(--color-surface-tertiary);
padding: var(--space-0-5) var(--space-1);
border-radius: var(--radius-xs);
}
&.url {
font-size: 0.75rem;
font-size: var(--font-size-xs);
word-break: break-all;
}
}
@@ -490,11 +494,11 @@
}
.doc-violation-item {
padding: 0.5rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
margin-bottom: 0.5rem;
padding: var(--space-2);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
margin-bottom: var(--space-2);
&:last-child {
margin-bottom: 0;
@@ -504,82 +508,82 @@
.violation-header {
display: flex;
align-items: center;
gap: 0.375rem;
gap: var(--space-1-5);
flex-wrap: wrap;
}
.at-field {
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.value-diff {
margin-top: 0.5rem;
padding: 0.5rem;
background: var(--color-bg-subtle, #f9fafb);
border-radius: 4px;
margin-top: var(--space-2);
padding: var(--space-2);
background: var(--color-surface-secondary);
border-radius: var(--radius-sm);
}
.expected-row,
.actual-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
font-size: 0.8125rem;
gap: var(--space-2);
font-size: var(--font-size-sm);
.label {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
min-width: 60px;
}
}
.actual-row {
margin-top: 0.25rem;
margin-top: var(--space-1);
}
.field-preview {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
overflow: hidden;
}
.field-row {
display: flex;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
font-size: 0.8125rem;
padding: var(--space-1-5) var(--space-2);
border-bottom: 1px solid var(--color-border-secondary);
font-size: var(--font-size-sm);
&:last-child {
border-bottom: none;
}
&.error {
background: var(--color-error-bg, #fef2f2);
background: var(--color-status-error-bg);
.field-name {
color: var(--color-error, #dc2626);
color: var(--color-status-error);
}
}
}
.field-name {
font-family: monospace;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
min-width: 120px;
}
.field-value {
font-size: 0.75rem;
color: var(--color-text, #374151);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
word-break: break-all;
}
.empty-state {
padding: 2rem;
padding: var(--space-6);
text-align: center;
color: var(--color-text-muted, #9ca3af);
color: var(--color-text-muted);
font-style: italic;
}

View File

@@ -0,0 +1,827 @@
/**
* Approval Detail Page Component
* Sprint: SPRINT_20260118_005_FE_approvals_feature (APPR-003, APPR-008)
*
* Full approval workflow page with diff, gates, decision panel,
* and ReachabilityWitnessPanel (THE MOAT feature).
*/
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
interface WitnessNode {
function: string;
file: string;
line: number;
type: 'entry' | 'call' | 'sink' | 'guard';
}
interface ReachabilityWitness {
findingId: string;
component: string;
version: string;
description: string;
state: 'reachable' | 'unreachable' | 'uncertain';
confidence: number;
confidenceExplanation: string;
callPath: WitnessNode[];
analysisDetails: {
guards: string[];
dynamicLoading: boolean;
reflection: boolean;
conditionalExecution: string | null;
dataFlowConfidence: number;
};
}
@Component({
selector: 'app-approval-detail-page',
standalone: true,
imports: [CommonModule, RouterLink, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="approval-detail">
<header class="page-header">
<a routerLink="/approvals" class="back-link">← Back to Approvals</a>
<div class="header-main">
<h1 class="page-title">{{ approval().releaseVersion }}: {{ approval().fromEnv }} → {{ approval().toEnv }}</h1>
<span class="status-badge" [class]="'status-badge--' + approval().status">
{{ approval().status | uppercase }}
</span>
</div>
<p class="page-subtitle">
Bundle: <code class="digest">{{ approval().bundleDigest }}</code>
· Requested by {{ approval().requestedBy }} · {{ approval().requestedAt }}
</p>
</header>
<div class="content-layout">
<!-- Main Content -->
<div class="main-content">
<!-- Security Diff Panel -->
<section class="panel">
<h3 class="panel-title">Security Diff</h3>
<p class="panel-subtitle">Changes from {{ approval().fromEnv }} to {{ approval().toEnv }}</p>
<div class="diff-summary">
<div class="diff-item diff-item--added">
<span class="diff-icon">+</span>
<span>2 new CVEs introduced</span>
</div>
<div class="diff-item diff-item--removed">
<span class="diff-icon"></span>
<span>1 CVE resolved</span>
</div>
<div class="diff-item diff-item--changed">
<span class="diff-icon">~</span>
<span>3 components updated</span>
</div>
</div>
<table class="diff-table">
<thead>
<tr>
<th>Finding</th>
<th>Change</th>
<th>Severity</th>
<th>Reachable</th>
</tr>
</thead>
<tbody>
<tr class="diff-row diff-row--added">
<td>CVE-2026-1234</td>
<td><span class="change-badge change-badge--new">NEW</span></td>
<td><span class="severity severity--high">HIGH</span></td>
<td>
<button type="button" class="reachability-chip reachability-chip--reachable" (click)="openWitness('CVE-2026-1234')">
Reachable (82%)
</button>
</td>
</tr>
<tr class="diff-row diff-row--added">
<td>CVE-2026-5678</td>
<td><span class="change-badge change-badge--new">NEW</span></td>
<td><span class="severity severity--medium">MEDIUM</span></td>
<td>
<button type="button" class="reachability-chip reachability-chip--unreachable" (click)="openWitness('CVE-2026-5678')">
Unreachable (94%)
</button>
</td>
</tr>
<tr class="diff-row diff-row--removed">
<td>CVE-2025-9999</td>
<td><span class="change-badge change-badge--fixed">FIXED</span></td>
<td><span class="severity severity--critical">CRITICAL</span></td>
<td>—</td>
</tr>
</tbody>
</table>
</section>
<!-- Reachability Witness Panel (THE MOAT) -->
@if (selectedWitness()) {
<section class="panel witness-panel">
<div class="witness-header">
<div class="witness-header__info">
<h3 class="panel-title">Reachability Witness</h3>
<p class="panel-subtitle">{{ selectedWitness()!.findingId }} in {{ selectedWitness()!.component }}&#64;{{ selectedWitness()!.version }}</p>
</div>
<button type="button" class="btn btn--sm btn--secondary" (click)="closeWitness()">
✕ Close
</button>
</div>
<!-- Finding Info -->
<div class="witness-finding">
<div class="finding-description">{{ selectedWitness()!.description }}</div>
</div>
<!-- State & Confidence -->
<div class="witness-state">
<div class="state-indicator" [class]="'state-indicator--' + selectedWitness()!.state">
<span class="state-label">{{ selectedWitness()!.state | uppercase }}</span>
<span class="state-confidence">{{ selectedWitness()!.confidence }}% confidence</span>
</div>
<div class="confidence-explanation">
{{ selectedWitness()!.confidenceExplanation }}
</div>
</div>
<!-- Call Path Visualization -->
<div class="witness-callpath">
<h4 class="section-title">Call Path</h4>
<div class="callpath-visualization">
@for (node of selectedWitness()!.callPath; track node.function; let i = $index; let last = $last) {
<div class="callpath-node" [class]="'callpath-node--' + node.type">
<div class="node-icon">
@switch (node.type) {
@case ('entry') { ▶ }
@case ('call') { → }
@case ('guard') { 🛡 }
@case ('sink') { ⚠ }
}
</div>
<div class="node-info">
<code class="node-function">{{ node.function }}</code>
<span class="node-location">{{ node.file }}:{{ node.line }}</span>
</div>
</div>
@if (!last) {
<div class="callpath-arrow">↓</div>
}
}
</div>
</div>
<!-- Analysis Details -->
<div class="witness-analysis">
<h4 class="section-title">Analysis Details</h4>
<div class="analysis-grid">
<div class="analysis-item">
<span class="analysis-label">Data Flow Confidence</span>
<span class="analysis-value">{{ selectedWitness()!.analysisDetails.dataFlowConfidence }}%</span>
</div>
<div class="analysis-item">
<span class="analysis-label">Dynamic Loading</span>
<span class="analysis-value" [class.analysis-value--warning]="selectedWitness()!.analysisDetails.dynamicLoading">
{{ selectedWitness()!.analysisDetails.dynamicLoading ? 'Detected' : 'None' }}
</span>
</div>
<div class="analysis-item">
<span class="analysis-label">Reflection</span>
<span class="analysis-value" [class.analysis-value--warning]="selectedWitness()!.analysisDetails.reflection">
{{ selectedWitness()!.analysisDetails.reflection ? 'Detected' : 'None' }}
</span>
</div>
@if (selectedWitness()!.analysisDetails.conditionalExecution) {
<div class="analysis-item analysis-item--full">
<span class="analysis-label">Conditional Execution</span>
<span class="analysis-value">{{ selectedWitness()!.analysisDetails.conditionalExecution }}</span>
</div>
}
</div>
@if (selectedWitness()!.analysisDetails.guards.length > 0) {
<div class="guards-section">
<span class="analysis-label">Guards Detected</span>
<div class="guards-list">
@for (guard of selectedWitness()!.analysisDetails.guards; track guard) {
<span class="guard-badge">🛡 {{ guard }}</span>
}
</div>
</div>
}
</div>
<!-- Actions -->
<div class="witness-actions">
<button type="button" class="btn btn--primary" (click)="openFullWitness()">
Open Full Witness
</button>
<button type="button" class="btn btn--secondary" (click)="exportWitness('dot')">
Export DOT
</button>
<button type="button" class="btn btn--secondary" (click)="exportWitness('mermaid')">
Export Mermaid
</button>
<button type="button" class="btn btn--secondary" (click)="replayVerify()">
Replay Verify
</button>
</div>
</section>
}
<!-- Gates Panel -->
<section class="panel">
<h3 class="panel-title">Gate Results</h3>
<p class="panel-subtitle">Policy: stg-baseline v3.1</p>
<div class="gate-list">
@for (gate of gates; track gate.name) {
<div class="gate-item">
<span class="gate-badge" [class]="'gate-badge--' + gate.status.toLowerCase()">{{ gate.status }}</span>
<span class="gate-name">{{ gate.name }}</span>
<button type="button" class="btn btn--sm btn--link" (click)="explainGate(gate)">Explain</button>
</div>
}
</div>
</section>
<!-- Comments -->
<section class="panel">
<h3 class="panel-title">Comments</h3>
<div class="comments-list">
@for (comment of comments; track comment.id) {
<div class="comment">
<div class="comment-header">
<span class="comment-author">{{ comment.author }}</span>
<span class="comment-time">{{ comment.time }}</span>
</div>
<p class="comment-body">{{ comment.body }}</p>
</div>
} @empty {
<p class="no-comments">No comments yet</p>
}
</div>
<div class="comment-form">
<textarea
class="comment-input"
placeholder="Add a comment..."
[(ngModel)]="newComment"
rows="3"
></textarea>
<button type="button" class="btn btn--secondary" (click)="addComment()">
Add Comment
</button>
</div>
</section>
</div>
<!-- Decision Sidebar -->
<aside class="decision-sidebar">
<div class="decision-panel">
<h3>Decision</h3>
@if (approval().status === 'pending') {
<div class="decision-info">
<p>You are authorized to approve or reject this promotion request.</p>
</div>
<div class="decision-actions">
<button
type="button"
class="btn btn--success btn--lg"
(click)="approve()"
>
✓ Approve
</button>
<button
type="button"
class="btn btn--danger btn--lg"
(click)="reject()"
>
✕ Reject
</button>
</div>
<div class="decision-options">
<label class="checkbox-label">
<input type="checkbox" [(ngModel)]="requestException" />
Request exception for blocked gates
</label>
</div>
} @else {
<div class="decision-result">
<p>
<strong>{{ approval().status | uppercase }}</strong> by {{ approval().decidedBy }}
</p>
<p class="decision-time">{{ approval().decidedAt }}</p>
</div>
}
<div class="evidence-link">
<h4>Evidence</h4>
<a [routerLink]="['/evidence', approval().evidenceId]" class="btn btn--secondary btn--block">
📋 Open Evidence Packet
</a>
</div>
</div>
</aside>
</div>
</div>
`,
styles: [`
.approval-detail { max-width: 1400px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.back-link {
display: inline-block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: var(--primary-color, #3b82f6);
text-decoration: none;
}
.header-main { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.5rem; }
.page-title { margin: 0; font-size: 1.5rem; font-weight: 600; }
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.status-badge--pending { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
.status-badge--approved { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.status-badge--rejected { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
.page-subtitle { margin: 0; color: var(--text-color-secondary, #64748b); font-size: 0.875rem; }
.digest {
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
background: var(--surface-ground, #f1f5f9);
border-radius: 4px;
}
.content-layout { display: grid; grid-template-columns: 1fr 320px; gap: 1.5rem; }
.main-content { display: flex; flex-direction: column; gap: 1.5rem; }
.panel {
padding: 1.25rem;
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
}
.panel-title { margin: 0 0 0.25rem; font-size: 1rem; font-weight: 600; }
.panel-subtitle { margin: 0 0 1rem; font-size: 0.875rem; color: var(--text-color-secondary, #64748b); }
.diff-summary { display: flex; gap: 1.5rem; margin-bottom: 1rem; }
.diff-item { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; }
.diff-icon {
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: 600;
font-size: 0.875rem;
}
.diff-item--added .diff-icon { background: var(--red-100, #fee2e2); color: var(--red-600, #dc2626); }
.diff-item--removed .diff-icon { background: var(--green-100, #dcfce7); color: var(--green-600, #16a34a); }
.diff-item--changed .diff-icon { background: var(--blue-100, #dbeafe); color: var(--blue-600, #2563eb); }
.diff-table { width: 100%; border-collapse: collapse; }
.diff-table th, .diff-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--surface-border, #e2e8f0);
}
.diff-table th {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-secondary, #64748b);
text-transform: uppercase;
}
.diff-row--added { background: var(--red-50, #fef2f2); }
.diff-row--removed { background: var(--green-50, #f0fdf4); }
.change-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
}
.change-badge--new { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
.change-badge--fixed { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.severity {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
}
.severity--critical { background: var(--purple-100, #f3e8ff); color: var(--purple-700, #7c3aed); }
.severity--high { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
.severity--medium { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
.reachability-chip {
padding: 0.25rem 0.5rem;
border: 1px solid;
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
background: transparent;
}
.reachability-chip--reachable {
border-color: var(--red-200, #fecaca);
color: var(--red-700, #b91c1c);
}
.reachability-chip--unreachable {
border-color: var(--green-200, #bbf7d0);
color: var(--green-700, #15803d);
}
.gate-list { display: flex; flex-direction: column; gap: 0.5rem; }
.gate-item { display: flex; align-items: center; gap: 0.5rem; }
.gate-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
}
.gate-badge--pass { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.gate-badge--warn { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
.gate-badge--block { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
.gate-name { flex: 1; }
.comments-list { margin-bottom: 1rem; }
.comment {
padding: 0.75rem;
background: var(--surface-ground, #f8fafc);
border-radius: 6px;
margin-bottom: 0.5rem;
}
.comment-header { display: flex; justify-content: space-between; margin-bottom: 0.25rem; }
.comment-author { font-weight: 500; font-size: 0.875rem; }
.comment-time { font-size: 0.75rem; color: var(--text-color-secondary, #94a3b8); }
.comment-body { margin: 0; font-size: 0.875rem; }
.no-comments { color: var(--text-color-secondary, #94a3b8); font-size: 0.875rem; }
.comment-form { display: flex; flex-direction: column; gap: 0.5rem; }
.comment-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 6px;
font-size: 0.875rem;
resize: vertical;
}
.decision-sidebar { position: sticky; top: 1rem; }
.decision-panel {
padding: 1.25rem;
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
}
.decision-panel h3 { margin: 0 0 1rem; font-size: 1rem; font-weight: 600; }
.decision-info { margin-bottom: 1rem; font-size: 0.875rem; color: var(--text-color-secondary, #64748b); }
.decision-info p { margin: 0; }
.decision-actions { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1rem; }
.decision-options { margin-bottom: 1rem; }
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
}
.decision-result { margin-bottom: 1rem; }
.decision-time { font-size: 0.75rem; color: var(--text-color-secondary, #94a3b8); }
.evidence-link h4 { margin: 0 0 0.5rem; font-size: 0.875rem; font-weight: 600; }
.btn {
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
.btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.btn--lg { padding: 0.75rem 1.5rem; font-size: 1rem; }
.btn--block { width: 100%; }
.btn--secondary { background: var(--surface-ground, #f8fafc); border: 1px solid var(--surface-border, #e2e8f0); color: var(--text-color, #1e293b); }
.btn--success { background: var(--green-600, #16a34a); border: none; color: white; }
.btn--danger { background: var(--red-600, #dc2626); border: none; color: white; }
.btn--link { background: transparent; border: none; color: var(--primary-color, #3b82f6); padding: 0; }
.btn--primary { background: var(--primary-color, #3b82f6); border: none; color: white; }
/* Witness Panel Styles (APPR-008 - THE MOAT) */
.witness-panel {
border: 2px solid var(--primary-color, #3b82f6);
background: linear-gradient(to bottom, var(--blue-50, #eff6ff) 0%, var(--surface-card, #ffffff) 100%);
}
.witness-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.witness-header__info { flex: 1; }
.witness-finding { margin-bottom: 1rem; }
.finding-description {
font-size: 0.875rem;
color: var(--text-color-secondary, #64748b);
padding: 0.75rem;
background: var(--surface-ground, #f8fafc);
border-radius: 6px;
}
.witness-state { margin-bottom: 1.25rem; }
.state-indicator {
display: inline-flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
border-radius: 8px;
margin-bottom: 0.5rem;
}
.state-indicator--reachable {
background: var(--red-100, #fee2e2);
border: 1px solid var(--red-200, #fecaca);
}
.state-indicator--unreachable {
background: var(--green-100, #dcfce7);
border: 1px solid var(--green-200, #bbf7d0);
}
.state-indicator--uncertain {
background: var(--yellow-100, #fef9c3);
border: 1px solid var(--yellow-200, #fef08a);
}
.state-label {
font-weight: 700;
font-size: 0.875rem;
}
.state-indicator--reachable .state-label { color: var(--red-700, #b91c1c); }
.state-indicator--unreachable .state-label { color: var(--green-700, #15803d); }
.state-indicator--uncertain .state-label { color: var(--yellow-700, #a16207); }
.state-confidence {
font-size: 0.875rem;
color: var(--text-color-secondary, #64748b);
}
.confidence-explanation {
font-size: 0.8125rem;
color: var(--text-color-secondary, #64748b);
font-style: italic;
}
.witness-callpath { margin-bottom: 1.25rem; }
.section-title {
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-color-secondary, #64748b);
}
.callpath-visualization {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem;
background: var(--surface-ground, #f8fafc);
border-radius: 8px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
}
.callpath-node {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
border-radius: 6px;
}
.callpath-node--entry { background: var(--blue-100, #dbeafe); }
.callpath-node--call { background: var(--gray-100, #f3f4f6); }
.callpath-node--guard { background: var(--yellow-100, #fef9c3); }
.callpath-node--sink { background: var(--red-100, #fee2e2); }
.node-icon {
width: 1.5rem;
text-align: center;
font-size: 0.875rem;
}
.node-info { display: flex; flex-direction: column; gap: 0.125rem; }
.node-function { font-size: 0.8125rem; font-weight: 500; }
.node-location { font-size: 0.6875rem; color: var(--text-color-secondary, #94a3b8); }
.callpath-arrow {
text-align: center;
color: var(--text-color-secondary, #94a3b8);
font-size: 0.75rem;
}
.witness-analysis { margin-bottom: 1.25rem; }
.analysis-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.analysis-item {
padding: 0.75rem;
background: var(--surface-ground, #f8fafc);
border-radius: 6px;
}
.analysis-item--full { grid-column: 1 / -1; }
.analysis-label {
display: block;
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-color-secondary, #64748b);
text-transform: uppercase;
margin-bottom: 0.25rem;
}
.analysis-value { font-size: 0.875rem; font-weight: 500; }
.analysis-value--warning { color: var(--yellow-600, #ca8a04); }
.guards-section { margin-top: 0.75rem; }
.guards-list { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.5rem; }
.guard-badge {
padding: 0.25rem 0.5rem;
background: var(--yellow-100, #fef9c3);
border: 1px solid var(--yellow-200, #fef08a);
border-radius: 4px;
font-size: 0.75rem;
}
.witness-actions {
display: flex;
gap: 0.5rem;
padding-top: 1rem;
border-top: 1px solid var(--surface-border, #e2e8f0);
}
@media (max-width: 1024px) {
.content-layout { grid-template-columns: 1fr; }
.decision-sidebar { position: static; }
}
`]
})
export class ApprovalDetailPageComponent implements OnInit {
private route = inject(ActivatedRoute);
approvalId = signal('');
newComment = '';
requestException = false;
// Reachability Witness Panel state (APPR-008 - THE MOAT)
selectedWitness = signal<ReachabilityWitness | null>(null);
// Mock witness data for demonstration
private readonly mockWitnesses: Record<string, ReachabilityWitness> = {
'CVE-2026-1234': {
findingId: 'CVE-2026-1234',
component: 'log4j-core',
version: '2.14.1',
description: 'Remote code execution vulnerability in Apache Log4j2 allowing attackers to execute arbitrary code via JNDI lookups in log messages.',
state: 'reachable',
confidence: 82,
confidenceExplanation: 'High confidence due to direct call path from HTTP request handler to vulnerable logging method. No guards detected on the path.',
callPath: [
{ function: 'main()', file: 'Application.java', line: 15, type: 'entry' },
{ function: 'handleRequest(HttpRequest)', file: 'RequestController.java', line: 42, type: 'call' },
{ function: 'processPayload(String)', file: 'PayloadProcessor.java', line: 78, type: 'call' },
{ function: 'log.info(message)', file: 'PayloadProcessor.java', line: 85, type: 'call' },
{ function: 'Logger.log(Level, String)', file: 'log4j-core-2.14.1.jar', line: 0, type: 'sink' },
],
analysisDetails: {
guards: [],
dynamicLoading: false,
reflection: false,
conditionalExecution: null,
dataFlowConfidence: 91,
},
},
'CVE-2026-5678': {
findingId: 'CVE-2026-5678',
component: 'spring-boot',
version: '2.7.5',
description: 'Server-side request forgery (SSRF) vulnerability in Spring Boot actuator endpoints.',
state: 'unreachable',
confidence: 94,
confidenceExplanation: 'Actuator endpoints are disabled in production configuration. Guard detected: security config disables all actuator endpoints.',
callPath: [
{ function: 'main()', file: 'Application.java', line: 15, type: 'entry' },
{ function: 'SecurityConfig.configure()', file: 'SecurityConfig.java', line: 28, type: 'guard' },
{ function: 'ActuatorEndpoint.invoke()', file: 'spring-boot-actuator-2.7.5.jar', line: 0, type: 'sink' },
],
analysisDetails: {
guards: ['SecurityConfig: actuator.endpoints.enabled=false', 'WebSecurityConfig: /actuator/** denied'],
dynamicLoading: false,
reflection: false,
conditionalExecution: 'Endpoint only accessible when management.endpoints.web.exposure.include is configured',
dataFlowConfidence: 98,
},
},
};
approval = signal({
releaseVersion: 'v1.2.5',
bundleDigest: 'sha256:7aa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9',
fromEnv: 'QA',
toEnv: 'Staging',
status: 'pending' as const,
requestedBy: 'user1',
requestedAt: '2h ago',
decidedBy: '',
decidedAt: '',
evidenceId: 'EVD-2026-045',
});
gates = [
{ name: 'SBOM Signed', status: 'PASS' },
{ name: 'Provenance', status: 'PASS' },
{ name: 'Reachability', status: 'BLOCK' },
{ name: 'VEX Consensus', status: 'WARN' },
];
comments: Array<{ id: string; author: string; time: string; body: string }> = [];
ngOnInit(): void {
this.route.params.subscribe(params => {
this.approvalId.set(params['approvalId'] || '');
});
}
// Reachability Witness Panel methods (APPR-008 - THE MOAT)
openWitness(findingId: string = 'CVE-2026-1234'): void {
const witness = this.mockWitnesses[findingId];
if (witness) {
this.selectedWitness.set(witness);
}
}
closeWitness(): void {
this.selectedWitness.set(null);
}
openFullWitness(): void {
const witness = this.selectedWitness();
if (witness) {
console.log('Navigate to full witness viewer for:', witness.findingId);
// Would navigate to /security/reachability/witness/:findingId
}
}
exportWitness(format: 'dot' | 'mermaid'): void {
const witness = this.selectedWitness();
if (witness) {
console.log(`Export witness for ${witness.findingId} as ${format.toUpperCase()}`);
// Would generate and download the graph in requested format
}
}
replayVerify(): void {
const witness = this.selectedWitness();
if (witness) {
console.log('Replay verification for:', witness.findingId);
// Would trigger a re-analysis to verify the reachability status
}
}
explainGate(gate: { name: string; status: string }): void {
console.log('Explain gate:', gate.name);
}
addComment(): void {
if (this.newComment.trim()) {
this.comments.push({
id: `comment-${Date.now()}`,
author: 'Current User',
time: 'Just now',
body: this.newComment,
});
this.newComment = '';
}
}
approve(): void {
console.log('Approve with exception request:', this.requestException);
this.approval.update(a => ({
...a,
status: 'approved' as const,
decidedBy: 'Current User',
decidedAt: 'Just now',
}));
}
reject(): void {
console.log('Reject');
this.approval.update(a => ({
...a,
status: 'rejected' as const,
decidedBy: 'Current User',
decidedAt: 'Just now',
}));
}
}

View File

@@ -0,0 +1,71 @@
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
/**
* ApprovalDetailComponent - Detailed view of a single approval.
*/
@Component({
selector: 'app-approval-detail',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="approval-detail">
<header class="approval-detail__header">
<a routerLink="/approvals" class="back-link">← Back to Approvals</a>
<h1>Approval #{{ approvalId }}</h1>
<p>Review and decide on this promotion request.</p>
</header>
<div class="approval-detail__content">
<p>Detailed approval view coming soon...</p>
</div>
</div>
`,
styles: [`
.approval-detail {
max-width: 1000px;
margin: 0 auto;
}
.approval-detail__header {
margin-bottom: 2rem;
}
.back-link {
display: inline-block;
margin-bottom: 1rem;
color: var(--primary-color, #3b82f6);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
h1 {
margin: 0 0 0.5rem;
font-size: 1.5rem;
}
p {
margin: 0;
color: var(--text-color-secondary, #64748b);
}
.approval-detail__content {
padding: 2rem;
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ApprovalDetailComponent {
private readonly route = inject(ActivatedRoute);
get approvalId(): string {
return this.route.snapshot.paramMap.get('id') || '';
}
}

View File

@@ -0,0 +1,301 @@
/**
* Approvals Inbox Page Component
* Sprint: SPRINT_20260118_005_FE_approvals_feature (APPR-001)
*
* Inbox-style list of pending and recent approvals.
*/
import { Component, ChangeDetectionStrategy, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
interface ApprovalRequest {
id: string;
releaseVersion: string;
bundleDigest: string;
fromEnv: string;
toEnv: string;
status: 'pending' | 'approved' | 'rejected' | 'expired';
gateStatus: 'PASS' | 'WARN' | 'BLOCK';
requestedBy: string;
requestedAt: string;
riskDelta: string;
newFindings: number;
}
@Component({
selector: 'app-approvals-inbox-page',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="approvals-page">
<header class="page-header">
<div>
<h1 class="page-title">Approvals</h1>
<p class="page-subtitle">Human decisions required for release promotions</p>
</div>
</header>
<!-- Status Filter -->
<div class="status-filter">
@for (filter of statusFilters; track filter.id) {
<button
type="button"
class="status-filter__btn"
[class.status-filter__btn--active]="activeFilter() === filter.id"
(click)="setFilter(filter.id)"
>
{{ filter.label }}
<span class="status-filter__count">{{ filter.count }}</span>
</button>
}
</div>
<!-- Approvals List -->
<div class="approvals-list">
@for (approval of filteredApprovals(); track approval.id) {
<a class="approval-card" [routerLink]="['./', approval.id]" [class]="'approval-card--' + approval.status">
<div class="approval-card__header">
<span class="approval-card__release">{{ approval.releaseVersion }}</span>
<span class="approval-card__route">{{ approval.fromEnv }} → {{ approval.toEnv }}</span>
<span class="approval-card__status" [class]="'approval-card__status--' + approval.status">
{{ approval.status | uppercase }}
</span>
</div>
<div class="approval-card__body">
<div class="approval-card__info">
<span class="approval-card__digest">
<code>{{ shortDigest(approval.bundleDigest) }}</code>
</span>
<span class="approval-card__meta">
Requested by {{ approval.requestedBy }} · {{ approval.requestedAt }}
</span>
</div>
<div class="approval-card__indicators">
<span class="gate-badge" [class]="'gate-badge--' + approval.gateStatus.toLowerCase()">
{{ approval.gateStatus }}
</span>
@if (approval.newFindings > 0) {
<span class="findings-badge">{{ approval.newFindings }} new findings</span>
}
<span class="risk-delta" [class.risk-delta--worse]="approval.riskDelta.startsWith('+')">
{{ approval.riskDelta }}
</span>
</div>
</div>
</a>
} @empty {
<div class="empty-state">
<p>No approvals found</p>
</div>
}
</div>
</div>
`,
styles: [`
.approvals-page { max-width: 1000px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.page-title { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: 600; }
.page-subtitle { margin: 0; color: var(--text-color-secondary, #64748b); }
.status-filter {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.status-filter__btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 6px;
font-size: 0.875rem;
cursor: pointer;
}
.status-filter__btn:hover { background: var(--surface-hover, #f8fafc); }
.status-filter__btn--active {
background: var(--primary-color, #3b82f6);
border-color: var(--primary-color, #3b82f6);
color: white;
}
.status-filter__count {
padding: 0.125rem 0.375rem;
background: rgba(0, 0, 0, 0.1);
border-radius: 10px;
font-size: 0.75rem;
}
.approvals-list { display: flex; flex-direction: column; gap: 0.75rem; }
.approval-card {
display: block;
padding: 1rem;
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.approval-card:hover {
border-color: var(--primary-color, #3b82f6);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.approval-card--pending { border-left: 4px solid var(--yellow-400, #facc15); }
.approval-card--approved { border-left: 4px solid var(--green-400, #4ade80); }
.approval-card--rejected { border-left: 4px solid var(--red-400, #f87171); }
.approval-card--expired { border-left: 4px solid var(--gray-400, #9ca3af); }
.approval-card__header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.75rem;
}
.approval-card__release { font-weight: 600; font-size: 1rem; }
.approval-card__route { color: var(--text-color-secondary, #64748b); }
.approval-card__status {
margin-left: auto;
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
}
.approval-card__status--pending { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
.approval-card__status--approved { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.approval-card__status--rejected { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
.approval-card__status--expired { background: var(--gray-100, #f3f4f6); color: var(--gray-600, #4b5563); }
.approval-card__body {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.approval-card__info { display: flex; flex-direction: column; gap: 0.25rem; }
.approval-card__digest code {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
background: var(--surface-ground, #f1f5f9);
border-radius: 4px;
}
.approval-card__meta { font-size: 0.75rem; color: var(--text-color-secondary, #94a3b8); }
.approval-card__indicators { display: flex; align-items: center; gap: 0.5rem; }
.gate-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
}
.gate-badge--pass { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.gate-badge--warn { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
.gate-badge--block { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
.findings-badge {
padding: 0.125rem 0.5rem;
background: var(--orange-100, #ffedd5);
color: var(--orange-700, #c2410c);
border-radius: 4px;
font-size: 0.625rem;
font-weight: 500;
}
.risk-delta { font-size: 0.75rem; color: var(--green-600, #16a34a); }
.risk-delta--worse { color: var(--red-600, #dc2626); }
.empty-state {
padding: 3rem;
text-align: center;
color: var(--text-color-secondary, #64748b);
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
}
`]
})
export class ApprovalsInboxPageComponent {
activeFilter = signal('pending');
statusFilters = [
{ id: 'pending', label: 'Pending', count: 3 },
{ id: 'approved', label: 'Approved', count: 12 },
{ id: 'rejected', label: 'Rejected', count: 2 },
{ id: 'all', label: 'All', count: 17 },
];
approvals = signal<ApprovalRequest[]>([
{
id: 'APPR-2026-045',
releaseVersion: 'v1.2.5',
bundleDigest: 'sha256:7aa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9',
fromEnv: 'QA',
toEnv: 'Staging',
status: 'pending',
gateStatus: 'WARN',
requestedBy: 'user1',
requestedAt: '2h ago',
riskDelta: '+2 CVEs',
newFindings: 2,
},
{
id: 'APPR-2026-044',
releaseVersion: 'v1.2.6',
bundleDigest: 'sha256:8ee1a2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8e5',
fromEnv: 'Dev',
toEnv: 'QA',
status: 'pending',
gateStatus: 'PASS',
requestedBy: 'user2',
requestedAt: '30m ago',
riskDelta: 'net safer',
newFindings: 0,
},
{
id: 'APPR-2026-043',
releaseVersion: 'v1.2.4',
bundleDigest: 'sha256:6bb1a2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8b8',
fromEnv: 'Staging',
toEnv: 'Prod',
status: 'pending',
gateStatus: 'BLOCK',
requestedBy: 'user1',
requestedAt: '1d ago',
riskDelta: '+1 reachable CVE',
newFindings: 1,
},
{
id: 'APPR-2026-040',
releaseVersion: 'v1.2.3',
bundleDigest: 'sha256:5cc1a2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8c7',
fromEnv: 'Staging',
toEnv: 'Prod',
status: 'approved',
gateStatus: 'PASS',
requestedBy: 'user1',
requestedAt: '3d ago',
riskDelta: 'net safer',
newFindings: 0,
},
]);
filteredApprovals = computed(() => {
const filter = this.activeFilter();
if (filter === 'all') return this.approvals();
return this.approvals().filter(a => a.status === filter);
});
setFilter(filterId: string): void {
this.activeFilter.set(filterId);
}
shortDigest(digest: string): string {
const parts = digest.split(':');
if (parts.length === 2 && parts[1].length > 10) {
return `${parts[0]}:${parts[1].substring(0, 4)}...${parts[1].substring(parts[1].length - 3)}`;
}
return digest;
}
}

View File

@@ -0,0 +1,332 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
/**
* ApprovalsInboxComponent - Approval decision cockpit.
*/
@Component({
selector: 'app-approvals-inbox',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="approvals">
<header class="approvals__header">
<div>
<h1 class="approvals__title">Approvals</h1>
<p class="approvals__subtitle">
Decide promotions with policy + reachability, backed by signed evidence.
</p>
</div>
<a routerLink="/docs" class="btn btn--secondary">Docs →</a>
</header>
<!-- Filters -->
<div class="approvals__filters">
<select class="filter-select">
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="all">All</option>
</select>
<select class="filter-select">
<option value="">All Environments</option>
<option value="dev">Dev</option>
<option value="qa">QA</option>
<option value="staging">Staging</option>
<option value="prod">Prod</option>
</select>
<input type="text" class="filter-search" placeholder="Search..." />
</div>
<!-- Pending approvals -->
<section class="approvals__section">
<h2 class="approvals__section-title">Pending (3)</h2>
@for (approval of pendingApprovals; track approval.id) {
<div class="approval-card">
<div class="approval-card__header">
<a [routerLink]="['/releases', approval.release]" class="approval-card__release">
{{ approval.release }}
</a>
<span class="approval-card__flow">{{ approval.from }} → {{ approval.to }}</span>
<span class="approval-card__meta">Requested by: {{ approval.requestedBy }} • {{ approval.timeAgo }}</span>
</div>
<div class="approval-card__changes">
<strong>WHAT CHANGED:</strong>
{{ approval.changes }}
</div>
<div class="approval-card__gates">
<div class="gates-row">
@for (gate of approval.gates; track gate.name) {
<div class="gate-item" [class]="'gate-item--' + gate.state">
<span class="gate-item__badge">{{ gate.state | uppercase }}</span>
<span class="gate-item__name">{{ gate.name }}</span>
</div>
}
</div>
</div>
<div class="approval-card__actions">
<button type="button" class="btn btn--success">Approve</button>
<button type="button" class="btn btn--danger">Reject</button>
<a [routerLink]="['/approvals', approval.id]" class="btn btn--secondary">View Details</a>
<a [routerLink]="['/evidence', approval.evidenceId]" class="btn btn--ghost">Open Evidence</a>
</div>
</div>
}
</section>
</div>
`,
styles: [`
.approvals {
max-width: 1200px;
margin: 0 auto;
}
.approvals__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 2rem;
margin-bottom: 1.5rem;
}
.approvals__title {
margin: 0 0 0.5rem;
font-size: 1.75rem;
font-weight: 600;
}
.approvals__subtitle {
margin: 0;
color: var(--text-color-secondary, #64748b);
}
.approvals__filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.filter-select,
.filter-search {
padding: 0.5rem 0.75rem;
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 6px;
font-size: 0.875rem;
background: var(--surface-card, #ffffff);
}
.filter-search {
flex: 1;
min-width: 200px;
}
.approvals__section {
margin-bottom: 2rem;
}
.approvals__section-title {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
}
.approval-card {
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
padding: 1.25rem;
margin-bottom: 1rem;
}
.approval-card__header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.approval-card__release {
font-weight: 600;
color: var(--primary-color, #3b82f6);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.approval-card__flow {
font-size: 0.875rem;
color: var(--text-color-secondary, #64748b);
}
.approval-card__meta {
font-size: 0.75rem;
color: var(--text-color-secondary, #94a3b8);
margin-left: auto;
}
.approval-card__changes {
font-size: 0.875rem;
margin-bottom: 1rem;
padding: 0.75rem;
background: var(--surface-ground, #f8fafc);
border-radius: 6px;
}
.approval-card__gates {
margin-bottom: 1rem;
}
.gates-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.gate-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
}
.gate-item__badge {
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
font-weight: 600;
border-radius: 3px;
}
.gate-item--pass .gate-item__badge {
background: var(--green-100, #dcfce7);
color: var(--green-700, #15803d);
}
.gate-item--warn .gate-item__badge {
background: var(--yellow-100, #fef9c3);
color: var(--yellow-700, #a16207);
}
.gate-item--block .gate-item__badge {
background: var(--red-100, #fee2e2);
color: var(--red-700, #b91c1c);
}
.approval-card__actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: background-color 0.15s;
}
.btn--success {
background: var(--green-500, #22c55e);
color: white;
&:hover {
background: var(--green-600, #16a34a);
}
}
.btn--danger {
background: var(--red-500, #ef4444);
color: white;
&:hover {
background: var(--red-600, #dc2626);
}
}
.btn--secondary {
background: var(--surface-ground, #f1f5f9);
color: var(--text-color, #1e293b);
&:hover {
background: var(--surface-hover, #e2e8f0);
}
}
.btn--ghost {
background: transparent;
color: var(--primary-color, #3b82f6);
&:hover {
background: var(--primary-50, #eff6ff);
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ApprovalsInboxComponent {
readonly pendingApprovals = [
{
id: '1',
release: 'v1.2.5',
from: 'QA',
to: 'Staging',
requestedBy: 'deploy-bot',
timeAgo: '2h ago',
changes: '+3 pkgs +2 CVEs (1 reachable) -5 fixed Drift: none',
evidenceId: 'EVD-2026-0045',
gates: [
{ name: 'SBOM signed', state: 'pass' },
{ name: 'Provenance', state: 'pass' },
{ name: 'Reachability', state: 'warn' },
{ name: 'Critical CVEs', state: 'pass' },
],
},
{
id: '2',
release: 'v1.2.6',
from: 'Dev',
to: 'QA',
requestedBy: 'ci-pipeline',
timeAgo: '4h ago',
changes: '+1 pkg 0 CVEs -2 fixed Drift: none',
evidenceId: 'EVD-2026-0046',
gates: [
{ name: 'SBOM signed', state: 'pass' },
{ name: 'Provenance', state: 'pass' },
{ name: 'Reachability', state: 'pass' },
{ name: 'Critical CVEs', state: 'pass' },
],
},
{
id: '3',
release: 'v1.2.4',
from: 'Staging',
to: 'Prod',
requestedBy: 'release-mgr',
timeAgo: '1d ago',
changes: '+0 pkgs +1 CVE (reachable!) Drift: 1 config',
evidenceId: 'EVD-2026-0044',
gates: [
{ name: 'SBOM signed', state: 'pass' },
{ name: 'Provenance', state: 'pass' },
{ name: 'Reachability', state: 'block' },
{ name: 'Critical CVEs', state: 'block' },
],
},
];
}

View File

@@ -0,0 +1,16 @@
import { Routes } from '@angular/router';
export const APPROVALS_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./approvals-inbox.component').then((m) => m.ApprovalsInboxComponent),
data: { breadcrumb: 'Approvals' },
},
{
path: ':id',
loadComponent: () =>
import('./approval-detail.component').then((m) => m.ApprovalDetailComponent),
data: { breadcrumb: 'Approval Detail' },
},
];

View File

@@ -0,0 +1,3 @@
export { ApprovalsInboxComponent } from './approvals-inbox.component';
export { ApprovalDetailComponent } from './approval-detail.component';
export { APPROVALS_ROUTES } from './approvals.routes';

View File

@@ -0,0 +1,772 @@
/**
* Request Exception Modal Component
* Sprint: SPRINT_20260118_005_FE_approvals_feature (APPR-010)
*
* Modal for requesting an exception when a gate is blocking an approval.
* Allows users to provide justification and acknowledge risk.
*/
import {
Component,
Input,
Output,
EventEmitter,
ChangeDetectionStrategy,
signal,
computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
export interface ExceptionRequest {
gateId: string;
gateName: string;
exceptionType: 'time-limited' | 'permanent' | 'conditional';
timeLimitDays?: number;
condition?: string;
justification: string;
riskAcknowledged: boolean;
supportingEvidenceFile?: File;
}
export interface GateContext {
gateId: string;
gateName: string;
status: 'BLOCK' | 'WARN';
reason?: string;
}
@Component({
selector: 'app-request-exception-modal',
standalone: true,
imports: [CommonModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (open) {
<div class="modal-backdrop" (click)="onBackdropClick($event)">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<header class="modal__header">
<h2 id="modal-title" class="modal__title">Request Gate Exception</h2>
<button
type="button"
class="modal__close"
(click)="close()"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</header>
<div class="modal__body">
<!-- Gate Context -->
<div class="gate-context">
<div class="gate-context__badge" [class]="'gate-context__badge--' + gate.status.toLowerCase()">
{{ gate.status }}
</div>
<div class="gate-context__info">
<span class="gate-context__name">{{ gate.gateName }}</span>
@if (gate.reason) {
<span class="gate-context__reason">{{ gate.reason }}</span>
}
</div>
</div>
<form class="exception-form" (ngSubmit)="onSubmit()">
<!-- Exception Type -->
<div class="form-group">
<label class="form-label">Exception Type <span class="required">*</span></label>
<div class="radio-group">
<label class="radio-option">
<input
type="radio"
name="exceptionType"
value="time-limited"
[checked]="exceptionType() === 'time-limited'"
(change)="exceptionType.set('time-limited')"
/>
<span class="radio-label">
<strong>Time-Limited</strong>
<small>Exception expires after a set period</small>
</span>
</label>
<label class="radio-option">
<input
type="radio"
name="exceptionType"
value="conditional"
[checked]="exceptionType() === 'conditional'"
(change)="exceptionType.set('conditional')"
/>
<span class="radio-label">
<strong>Conditional</strong>
<small>Exception applies under specific conditions</small>
</span>
</label>
<label class="radio-option">
<input
type="radio"
name="exceptionType"
value="permanent"
[checked]="exceptionType() === 'permanent'"
(change)="exceptionType.set('permanent')"
/>
<span class="radio-label">
<strong>Permanent</strong>
<small>Exception does not expire (requires elevated approval)</small>
</span>
</label>
</div>
</div>
<!-- Time Limit (conditional) -->
@if (exceptionType() === 'time-limited') {
<div class="form-group">
<label class="form-label" for="timeLimitDays">Duration (days) <span class="required">*</span></label>
<select
id="timeLimitDays"
class="form-select"
[ngModel]="timeLimitDays()"
(ngModelChange)="timeLimitDays.set($event)"
name="timeLimitDays"
>
<option [value]="7">7 days</option>
<option [value]="14">14 days</option>
<option [value]="30">30 days</option>
<option [value]="60">60 days</option>
<option [value]="90">90 days</option>
</select>
</div>
}
<!-- Condition (conditional) -->
@if (exceptionType() === 'conditional') {
<div class="form-group">
<label class="form-label" for="condition">Condition <span class="required">*</span></label>
<input
id="condition"
type="text"
class="form-input"
[ngModel]="condition()"
(ngModelChange)="condition.set($event)"
name="condition"
placeholder="e.g., Only applies when feature flag X is disabled"
/>
<small class="form-hint">Describe the specific condition under which this exception applies</small>
</div>
}
<!-- Justification -->
<div class="form-group">
<label class="form-label" for="justification">Justification <span class="required">*</span></label>
<textarea
id="justification"
class="form-textarea"
[ngModel]="justification()"
(ngModelChange)="justification.set($event)"
name="justification"
rows="4"
placeholder="Explain why this exception is needed and what mitigations are in place..."
></textarea>
<small class="form-hint">
{{ justification().length }}/500 characters
@if (justification().length < 50) {
<span class="warning"> (minimum 50 required)</span>
}
</small>
</div>
<!-- Supporting Evidence -->
<div class="form-group">
<label class="form-label" for="evidence">Supporting Evidence (optional)</label>
<div class="file-input">
<input
id="evidence"
type="file"
(change)="onFileSelect($event)"
accept=".pdf,.doc,.docx,.txt,.md"
/>
<div class="file-input__label">
@if (selectedFile()) {
<span class="file-input__name">{{ selectedFile()!.name }}</span>
<button type="button" class="file-input__remove" (click)="removeFile()">Remove</button>
} @else {
<span class="file-input__placeholder">Click to upload or drag and drop</span>
<small>PDF, DOC, TXT, MD up to 5MB</small>
}
</div>
</div>
</div>
<!-- Risk Acknowledgment -->
<div class="form-group form-group--checkbox">
<label class="checkbox-label">
<input
type="checkbox"
[ngModel]="riskAcknowledged()"
(ngModelChange)="riskAcknowledged.set($event)"
name="riskAcknowledged"
/>
<span class="checkbox-text">
<strong>I acknowledge the risk</strong>
<small>
I understand that requesting this exception may allow potentially risky changes
to proceed and take responsibility for the decision.
</small>
</span>
</label>
</div>
<!-- Validation Errors -->
@if (validationErrors().length > 0) {
<div class="validation-errors" role="alert">
<strong>Please fix the following errors:</strong>
<ul>
@for (error of validationErrors(); track error) {
<li>{{ error }}</li>
}
</ul>
</div>
}
</form>
</div>
<footer class="modal__footer">
<button
type="button"
class="btn btn--secondary"
(click)="close()"
[disabled]="submitting()"
>
Cancel
</button>
<button
type="button"
class="btn btn--primary"
(click)="onSubmit()"
[disabled]="!isValid() || submitting()"
>
@if (submitting()) {
<span class="spinner"></span>
Submitting...
} @else {
Request Exception
}
</button>
</footer>
</div>
</div>
}
`,
styles: [`
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1000;
animation: fadeIn 0.15s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
background: var(--color-surface-primary, white);
border-radius: 12px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
width: 100%;
max-width: 560px;
max-height: calc(100vh - 2rem);
display: flex;
flex-direction: column;
animation: slideUp 0.2s ease-out;
}
@keyframes slideUp {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--color-border-primary, #e2e8f0);
}
.modal__title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-primary, #1e293b);
}
.modal__close {
display: flex;
padding: 0.5rem;
background: none;
border: none;
border-radius: 6px;
color: var(--color-text-secondary, #64748b);
cursor: pointer;
transition: all 0.15s ease;
}
.modal__close:hover {
background: var(--color-surface-secondary, #f8fafc);
color: var(--color-text-primary, #1e293b);
}
.modal__body {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.modal__footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--color-border-primary, #e2e8f0);
background: var(--color-surface-secondary, #f8fafc);
border-radius: 0 0 12px 12px;
}
/* Gate Context */
.gate-context {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
background: var(--color-surface-secondary, #f8fafc);
border-radius: 8px;
margin-bottom: 1.5rem;
}
.gate-context__badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
}
.gate-context__badge--block {
background: var(--color-error-100, #fee2e2);
color: var(--color-error-700, #b91c1c);
}
.gate-context__badge--warn {
background: var(--color-warning-100, #fef3c7);
color: var(--color-warning-700, #b45309);
}
.gate-context__info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.gate-context__name {
font-weight: 600;
color: var(--color-text-primary, #1e293b);
}
.gate-context__reason {
font-size: 0.875rem;
color: var(--color-text-secondary, #64748b);
}
/* Form */
.exception-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group--checkbox {
gap: 0;
}
.form-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary, #1e293b);
}
.required {
color: var(--color-error-500, #ef4444);
}
.form-hint {
font-size: 0.75rem;
color: var(--color-text-secondary, #64748b);
}
.form-hint .warning {
color: var(--color-warning-600, #d97706);
}
.form-input,
.form-select,
.form-textarea {
padding: 0.625rem 0.875rem;
border: 1px solid var(--color-border-primary, #e2e8f0);
border-radius: 6px;
font-size: 0.875rem;
color: var(--color-text-primary, #1e293b);
background: white;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
outline: none;
border-color: var(--color-brand-500, #3b82f6);
box-shadow: 0 0 0 3px var(--color-brand-100, #dbeafe);
}
.form-textarea {
resize: vertical;
min-height: 100px;
}
/* Radio Group */
.radio-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.radio-option {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem;
border: 1px solid var(--color-border-primary, #e2e8f0);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
}
.radio-option:hover {
border-color: var(--color-brand-300, #93c5fd);
background: var(--color-brand-50, #eff6ff);
}
.radio-option:has(input:checked) {
border-color: var(--color-brand-500, #3b82f6);
background: var(--color-brand-50, #eff6ff);
}
.radio-option input {
margin-top: 0.125rem;
}
.radio-label {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.radio-label strong {
font-size: 0.875rem;
color: var(--color-text-primary, #1e293b);
}
.radio-label small {
font-size: 0.75rem;
color: var(--color-text-secondary, #64748b);
}
/* File Input */
.file-input {
position: relative;
}
.file-input input {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.file-input__label {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
padding: 1.5rem;
border: 2px dashed var(--color-border-primary, #e2e8f0);
border-radius: 8px;
text-align: center;
transition: all 0.15s ease;
}
.file-input:hover .file-input__label {
border-color: var(--color-brand-300, #93c5fd);
background: var(--color-brand-50, #eff6ff);
}
.file-input__placeholder {
font-size: 0.875rem;
color: var(--color-text-secondary, #64748b);
}
.file-input__name {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-primary, #1e293b);
}
.file-input__remove {
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 0.75rem;
color: var(--color-error-600, #dc2626);
cursor: pointer;
}
.file-input__remove:hover {
text-decoration: underline;
}
/* Checkbox */
.checkbox-label {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
background: var(--color-warning-50, #fffbeb);
border: 1px solid var(--color-warning-200, #fde68a);
border-radius: 8px;
cursor: pointer;
}
.checkbox-label input {
margin-top: 0.125rem;
width: 18px;
height: 18px;
cursor: pointer;
}
.checkbox-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.checkbox-text strong {
font-size: 0.875rem;
color: var(--color-warning-800, #92400e);
}
.checkbox-text small {
font-size: 0.75rem;
color: var(--color-warning-700, #b45309);
}
/* Validation Errors */
.validation-errors {
padding: 1rem;
background: var(--color-error-50, #fef2f2);
border: 1px solid var(--color-error-200, #fecaca);
border-radius: 8px;
color: var(--color-error-700, #b91c1c);
font-size: 0.875rem;
}
.validation-errors ul {
margin: 0.5rem 0 0;
padding-left: 1.25rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn--primary {
background: var(--color-brand-600, #2563eb);
color: white;
}
.btn--primary:hover:not(:disabled) {
background: var(--color-brand-700, #1d4ed8);
}
.btn--secondary {
background: var(--color-surface-secondary, #f8fafc);
color: var(--color-text-primary, #1e293b);
border: 1px solid var(--color-border-primary, #e2e8f0);
}
.btn--secondary:hover:not(:disabled) {
background: var(--color-surface-tertiary, #f1f5f9);
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`],
})
export class RequestExceptionModalComponent {
@Input() open = false;
@Input() gate: GateContext = { gateId: '', gateName: '', status: 'BLOCK' };
@Output() closed = new EventEmitter<void>();
@Output() submitted = new EventEmitter<ExceptionRequest>();
// Form state
readonly exceptionType = signal<'time-limited' | 'permanent' | 'conditional'>('time-limited');
readonly timeLimitDays = signal(30);
readonly condition = signal('');
readonly justification = signal('');
readonly riskAcknowledged = signal(false);
readonly selectedFile = signal<File | null>(null);
readonly submitting = signal(false);
// Validation
readonly validationErrors = computed(() => {
const errors: string[] = [];
if (this.exceptionType() === 'conditional' && !this.condition().trim()) {
errors.push('Condition is required for conditional exceptions');
}
if (this.justification().length < 50) {
errors.push('Justification must be at least 50 characters');
}
if (this.justification().length > 500) {
errors.push('Justification must not exceed 500 characters');
}
if (!this.riskAcknowledged()) {
errors.push('You must acknowledge the risk to proceed');
}
return errors;
});
readonly isValid = computed(() => this.validationErrors().length === 0);
close(): void {
this.resetForm();
this.closed.emit();
}
onBackdropClick(event: MouseEvent): void {
if ((event.target as HTMLElement).classList.contains('modal-backdrop')) {
this.close();
}
}
onFileSelect(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (file) {
// Validate file size (5MB max)
if (file.size > 5 * 1024 * 1024) {
alert('File size must be under 5MB');
return;
}
this.selectedFile.set(file);
}
}
removeFile(): void {
this.selectedFile.set(null);
}
onSubmit(): void {
if (!this.isValid() || this.submitting()) return;
this.submitting.set(true);
const request: ExceptionRequest = {
gateId: this.gate.gateId,
gateName: this.gate.gateName,
exceptionType: this.exceptionType(),
justification: this.justification(),
riskAcknowledged: this.riskAcknowledged(),
};
if (this.exceptionType() === 'time-limited') {
request.timeLimitDays = this.timeLimitDays();
}
if (this.exceptionType() === 'conditional') {
request.condition = this.condition();
}
if (this.selectedFile()) {
request.supportingEvidenceFile = this.selectedFile()!;
}
// Simulate API call
setTimeout(() => {
this.submitted.emit(request);
this.submitting.set(false);
this.close();
}, 500);
}
private resetForm(): void {
this.exceptionType.set('time-limited');
this.timeLimitDays.set(30);
this.condition.set('');
this.justification.set('');
this.riskAcknowledged.set(false);
this.selectedFile.set(null);
}
}

View File

@@ -0,0 +1,388 @@
/**
* Approval Detail Store
* Sprint: SPRINT_20260118_005_FE_approvals_feature (APPR-009)
*
* Signal-based state management for the approval detail page.
* Handles approval data, gate results, witness data, comments, and decision actions.
*/
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
// === Interfaces ===
export interface Approval {
id: string;
releaseId: string;
releaseVersion: string;
bundleDigest: string;
fromEnvironment: string;
toEnvironment: string;
status: 'pending' | 'approved' | 'rejected' | 'expired';
requestedBy: string;
requestedAt: string;
decidedBy?: string;
decidedAt?: string;
decisionComment?: string;
expiresAt?: string;
}
export interface DiffSummary {
componentsAdded: number;
componentsRemoved: number;
componentsUpdated: number;
newCves: number;
fixedCves: number;
reachableCves: number;
unreachableCves: number;
uncertainCves: number;
securityScoreDelta: number;
licensesChanged: boolean;
}
export interface GateResult {
gateId: string;
name: string;
status: 'PASS' | 'WARN' | 'BLOCK' | 'SKIP';
reason?: string;
evidenceRef?: string;
canRequestException?: boolean;
}
export interface WitnessNode {
function: string;
file: string;
line: number;
type: 'entry' | 'call' | 'sink' | 'guard';
}
export interface ReachabilityWitness {
findingId: string;
component: string;
version: string;
description: string;
state: 'reachable' | 'unreachable' | 'uncertain';
confidence: number;
confidenceExplanation: string;
callPath: WitnessNode[];
analysisDetails: {
guards: string[];
dynamicLoading: boolean;
reflection: boolean;
conditionalExecution: string | null;
dataFlowConfidence: number;
};
}
export interface ApprovalComment {
id: string;
author: string;
authorEmail: string;
content: string;
createdAt: string;
type: 'comment' | 'system' | 'decision';
}
export interface SecurityDiffEntry {
cveId: string;
component: string;
version: string;
severity: 'critical' | 'high' | 'medium' | 'low';
changeType: 'new' | 'fixed';
reachability: 'reachable' | 'unreachable' | 'uncertain';
confidence: number;
}
// === Store ===
@Injectable({ providedIn: 'root' })
export class ApprovalDetailStore {
private http = inject(HttpClient);
private apiBase = '/api/approvals';
// === Core State ===
readonly approval = signal<Approval | null>(null);
readonly diffSummary = signal<DiffSummary | null>(null);
readonly gateResults = signal<GateResult[]>([]);
readonly witness = signal<ReachabilityWitness | null>(null);
readonly comments = signal<ApprovalComment[]>([]);
readonly securityDiff = signal<SecurityDiffEntry[]>([]);
// === Loading & Error State ===
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly submitting = signal(false);
readonly commentSubmitting = signal(false);
// === Computed Properties ===
readonly approvalId = computed(() => this.approval()?.id ?? null);
readonly isPending = computed(() => {
const approval = this.approval();
return approval?.status === 'pending';
});
readonly isExpired = computed(() => {
const approval = this.approval();
if (!approval?.expiresAt) return false;
return new Date(approval.expiresAt) < new Date();
});
readonly canApprove = computed(() => {
return this.isPending() && !this.hasBlockingGates() && !this.isExpired();
});
readonly canReject = computed(() => {
return this.isPending() && !this.isExpired();
});
readonly hasBlockingGates = computed(() => {
return this.gateResults().some(g => g.status === 'BLOCK');
});
readonly hasWarningGates = computed(() => {
return this.gateResults().some(g => g.status === 'WARN');
});
readonly overallGateStatus = computed(() => {
const gates = this.gateResults();
if (gates.some(g => g.status === 'BLOCK')) return 'BLOCK';
if (gates.some(g => g.status === 'WARN')) return 'WARN';
if (gates.length === 0) return 'SKIP';
return 'PASS';
});
readonly newReachableCves = computed(() => {
return this.securityDiff()
.filter(e => e.changeType === 'new' && e.reachability === 'reachable')
.length;
});
readonly criticalFindings = computed(() => {
return this.securityDiff()
.filter(e => e.severity === 'critical' && e.changeType === 'new');
});
readonly promotionRoute = computed(() => {
const approval = this.approval();
if (!approval) return '';
return `${approval.fromEnvironment}${approval.toEnvironment}`;
});
// === Actions ===
/**
* Load approval detail data
*/
load(approvalId: string): void {
this.loading.set(true);
this.error.set(null);
// In a real app, this would be HTTP calls
// For now, simulate with mock data
setTimeout(() => {
this.approval.set({
id: approvalId,
releaseId: 'rel-123',
releaseVersion: 'v1.2.5',
bundleDigest: 'sha256:7aa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9',
fromEnvironment: 'QA',
toEnvironment: 'Staging',
status: 'pending',
requestedBy: 'ci-bot',
requestedAt: '2026-01-17T15:00:00Z',
expiresAt: '2026-01-24T15:00:00Z',
});
this.diffSummary.set({
componentsAdded: 2,
componentsRemoved: 1,
componentsUpdated: 5,
newCves: 3,
fixedCves: 7,
reachableCves: 1,
unreachableCves: 2,
uncertainCves: 0,
securityScoreDelta: -5, // Lower is better
licensesChanged: false,
});
this.gateResults.set([
{ gateId: 'sbom', name: 'SBOM Signed', status: 'PASS' },
{ gateId: 'provenance', name: 'Provenance', status: 'PASS' },
{ gateId: 'reachability', name: 'Reachability', status: 'WARN', reason: '1 reachable CVE', canRequestException: true },
{ gateId: 'vex', name: 'VEX Consensus', status: 'PASS' },
{ gateId: 'license', name: 'License Compliance', status: 'PASS' },
]);
this.securityDiff.set([
{ cveId: 'CVE-2026-1234', component: 'log4j-core', version: '2.14.1', severity: 'critical', changeType: 'new', reachability: 'reachable', confidence: 0.87 },
{ cveId: 'CVE-2026-5678', component: 'spring-core', version: '5.3.12', severity: 'high', changeType: 'new', reachability: 'unreachable', confidence: 0.95 },
{ cveId: 'CVE-2025-9999', component: 'jackson-databind', version: '2.13.0', severity: 'medium', changeType: 'new', reachability: 'unreachable', confidence: 0.92 },
{ cveId: 'CVE-2025-1111', component: 'lodash', version: '4.17.19', severity: 'high', changeType: 'fixed', reachability: 'unreachable', confidence: 1.0 },
{ cveId: 'CVE-2025-2222', component: 'express', version: '4.17.0', severity: 'medium', changeType: 'fixed', reachability: 'unreachable', confidence: 1.0 },
]);
this.comments.set([
{ id: 'c1', author: 'ci-bot', authorEmail: 'ci@acme.com', content: 'Automated promotion request triggered by successful QA deployment.', createdAt: '2026-01-17T15:00:00Z', type: 'system' },
{ id: 'c2', author: 'Jane Smith', authorEmail: 'jane@acme.com', content: 'I\'ve reviewed the reachable CVE. The affected code path is behind a feature flag that\'s disabled in production.', createdAt: '2026-01-17T16:30:00Z', type: 'comment' },
]);
this.loading.set(false);
}, 300);
}
/**
* Approve the promotion request
*/
approve(comment?: string): void {
const approval = this.approval();
if (!approval || !this.canApprove()) return;
this.submitting.set(true);
console.log(`Approving ${approval.id}`, comment ? `with comment: ${comment}` : '');
// In real app, would POST to /api/approvals/{id}/approve
setTimeout(() => {
this.approval.update(a => a ? { ...a, status: 'approved', decidedAt: new Date().toISOString(), decidedBy: 'Current User' } : null);
if (comment) {
this.comments.update(list => [
...list,
{ id: `c${Date.now()}`, author: 'Current User', authorEmail: 'user@acme.com', content: comment, createdAt: new Date().toISOString(), type: 'decision' },
]);
}
this.submitting.set(false);
}, 500);
}
/**
* Reject the promotion request
*/
reject(comment: string): void {
const approval = this.approval();
if (!approval || !this.canReject()) return;
this.submitting.set(true);
console.log(`Rejecting ${approval.id} with reason: ${comment}`);
// In real app, would POST to /api/approvals/{id}/reject
setTimeout(() => {
this.approval.update(a => a ? { ...a, status: 'rejected', decidedAt: new Date().toISOString(), decidedBy: 'Current User', decisionComment: comment } : null);
this.comments.update(list => [
...list,
{ id: `c${Date.now()}`, author: 'Current User', authorEmail: 'user@acme.com', content: `Rejected: ${comment}`, createdAt: new Date().toISOString(), type: 'decision' },
]);
this.submitting.set(false);
}, 500);
}
/**
* Add a comment to the approval
*/
addComment(content: string): void {
if (!content.trim()) return;
this.commentSubmitting.set(true);
// Optimistic update
const optimisticComment: ApprovalComment = {
id: `optimistic-${Date.now()}`,
author: 'Current User',
authorEmail: 'user@acme.com',
content,
createdAt: new Date().toISOString(),
type: 'comment',
};
this.comments.update(list => [...list, optimisticComment]);
// In real app, would POST to /api/approvals/{id}/comments
setTimeout(() => {
// Update with real ID from server
this.comments.update(list =>
list.map(c => c.id === optimisticComment.id ? { ...c, id: `c${Date.now()}` } : c)
);
this.commentSubmitting.set(false);
}, 200);
}
/**
* Request an exception for a blocking gate
*/
requestException(gateId: string): void {
console.log(`Requesting exception for gate: ${gateId}`);
// This opens the exception modal - handled by the component
// The actual exception request is handled by the modal component
}
/**
* Load witness data for a specific finding
*/
loadWitness(findingId: string): void {
console.log(`Loading witness for ${findingId}`);
// In real app, would GET /api/witnesses/{findingId}
setTimeout(() => {
this.witness.set({
findingId,
component: 'log4j-core',
version: '2.14.1',
description: 'Remote code execution via JNDI lookup',
state: 'reachable',
confidence: 0.87,
confidenceExplanation: 'Static analysis found path; runtime signals confirm usage',
callPath: [
{ function: 'main()', file: 'App.java', line: 25, type: 'entry' },
{ function: 'handleRequest()', file: 'Controller.java', line: 142, type: 'call' },
{ function: 'log()', file: 'LogService.java', line: 87, type: 'call' },
{ function: 'lookup()', file: 'log4j-core/LogManager.java', line: 256, type: 'sink' },
],
analysisDetails: {
guards: [],
dynamicLoading: false,
reflection: false,
conditionalExecution: null,
dataFlowConfidence: 0.92,
},
});
}, 200);
}
/**
* Clear witness data
*/
clearWitness(): void {
this.witness.set(null);
}
/**
* Refresh approval data
*/
refresh(): void {
const approval = this.approval();
if (approval) {
this.load(approval.id);
}
}
/**
* Reset store state
*/
reset(): void {
this.approval.set(null);
this.diffSummary.set(null);
this.gateResults.set([]);
this.witness.set(null);
this.comments.set([]);
this.securityDiff.set([]);
this.loading.set(false);
this.error.set(null);
this.submitting.set(false);
this.commentSubmitting.set(false);
}
}

View File

@@ -1,9 +1,9 @@
// byte-diff-viewer.component.scss
@use '../../../../../../styles/tokens/breakpoints' as *;
.byte-diff-viewer {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
@@ -11,53 +11,53 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--color-surface-alt, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
padding: var(--space-3) var(--space-4);
background: var(--color-surface-secondary);
border-bottom: 1px solid var(--color-border-primary);
h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary, #111827);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
.icon {
font-family: var(--font-mono, monospace);
color: var(--color-primary, #2563eb);
font-family: var(--font-family-mono);
color: var(--color-brand-primary);
}
}
}
.view-toggle {
display: flex;
gap: 4px;
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #d1d5db);
border-radius: 6px;
gap: var(--space-1);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
padding: 2px;
}
.toggle-btn {
padding: 4px 12px;
padding: var(--space-1) var(--space-3);
border: none;
background: transparent;
border-radius: 4px;
font-size: 12px;
color: var(--color-text-secondary, #6b7280);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.15s ease;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: var(--color-surface-hover, #f3f4f6);
color: var(--color-text-primary, #111827);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
}
&.active {
background: var(--color-primary, #2563eb);
color: white;
background: var(--color-brand-primary);
color: var(--color-text-inverse);
}
}
@@ -71,66 +71,66 @@
border-collapse: collapse;
th, td {
padding: 10px 16px;
padding: var(--space-2-5) var(--space-4);
text-align: left;
border-bottom: 1px solid var(--color-border, #e5e7eb);
border-bottom: 1px solid var(--color-border-primary);
}
th {
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
background: var(--color-surface-alt, #f9fafb);
background: var(--color-surface-secondary);
}
td {
font-size: 12px;
font-size: var(--font-size-sm);
}
.offset {
font-family: var(--font-mono, monospace);
color: var(--color-primary, #2563eb);
font-family: var(--font-family-mono);
color: var(--color-brand-primary);
}
.size {
color: var(--color-text-secondary, #6b7280);
color: var(--color-text-muted);
}
.section {
font-family: var(--font-mono, monospace);
color: var(--color-text-secondary, #6b7280);
font-family: var(--font-family-mono);
color: var(--color-text-muted);
}
.hash {
font-family: var(--font-mono, monospace);
font-size: 11px;
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
&.from {
color: var(--color-text-muted, #9ca3af);
color: var(--color-text-muted);
}
&.to {
color: var(--color-text-primary, #111827);
color: var(--color-text-primary);
}
}
tbody tr:hover {
background: var(--color-surface-hover, #f9fafb);
background: var(--color-surface-secondary);
}
}
// Hex view styles
.hex-view {
padding: 8px;
padding: var(--space-2);
}
.hex-grid {
background: var(--color-surface-alt, #f9fafb);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
margin-bottom: 8px;
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
margin-bottom: var(--space-2);
overflow: hidden;
&:last-child {
@@ -141,43 +141,43 @@
.hex-header {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: var(--color-surface, #ffffff);
border-bottom: 1px solid var(--color-border, #e5e7eb);
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
background: var(--color-surface-primary);
border-bottom: 1px solid var(--color-border-primary);
.offset {
font-family: var(--font-mono, monospace);
font-size: 13px;
font-weight: 600;
color: var(--color-primary, #2563eb);
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-brand-primary);
}
.section {
font-family: var(--font-mono, monospace);
font-size: 11px;
color: var(--color-text-secondary, #6b7280);
padding: 2px 6px;
background: var(--color-surface-alt, #f9fafb);
border-radius: 4px;
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
padding: var(--space-0-5) var(--space-1-5);
background: var(--color-surface-secondary);
border-radius: var(--radius-sm);
}
.size {
margin-left: auto;
font-size: 12px;
color: var(--color-text-muted, #9ca3af);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
.hex-content {
padding: 12px;
padding: var(--space-3);
}
.hex-row {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 8px;
gap: var(--space-3);
margin-bottom: var(--space-2);
&:last-child {
margin-bottom: 0;
@@ -186,75 +186,75 @@
.label {
flex-shrink: 0;
width: 60px;
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
}
.hash {
font-family: var(--font-mono, monospace);
font-size: 11px;
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
word-break: break-all;
line-height: 1.5;
}
&.from .hash {
color: var(--color-text-muted, #9ca3af);
color: var(--color-text-muted);
text-decoration: line-through;
text-decoration-color: var(--color-danger, #dc2626);
text-decoration-color: var(--color-status-error);
}
&.to .hash {
color: var(--color-text-primary, #111827);
background: var(--color-success-bg, #dcfce7);
padding: 2px 4px;
border-radius: 2px;
color: var(--color-text-primary);
background: var(--color-status-success-bg);
padding: var(--space-0-5) var(--space-1);
border-radius: var(--radius-xs);
}
}
.hex-context {
display: flex;
align-items: flex-start;
gap: 12px;
padding-top: 8px;
border-top: 1px dashed var(--color-border, #e5e7eb);
margin-top: 8px;
gap: var(--space-3);
padding-top: var(--space-2);
border-top: 1px dashed var(--color-border-primary);
margin-top: var(--space-2);
.label {
flex-shrink: 0;
width: 60px;
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary, #6b7280);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
}
.value {
font-size: 12px;
color: var(--color-text-secondary, #6b7280);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-style: italic;
}
}
.empty-state {
padding: 48px 24px;
padding: var(--space-12) var(--space-6);
text-align: center;
color: var(--color-text-muted, #9ca3af);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
color: var(--color-text-muted);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
}
// Responsive
@media (max-width: 768px) {
@include screen-below-md {
.byte-table {
th, td {
padding: 8px 12px;
padding: var(--space-2) var(--space-3);
}
}
.hex-row {
flex-direction: column;
gap: 4px;
gap: var(--space-1);
.label {
width: auto;

View File

@@ -1,9 +1,9 @@
// delta-list.component.scss
@use '../../../../../../styles/tokens/breakpoints' as *;
.delta-list {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
@@ -11,41 +11,41 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border, #e5e7eb);
background: var(--color-surface-alt, #f9fafb);
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
label {
font-size: 13px;
color: var(--color-text-secondary, #6b7280);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
.filter-select {
padding: 6px 12px;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 6px;
font-size: 13px;
background: var(--color-surface, #ffffff);
color: var(--color-text-primary, #111827);
padding: var(--space-1-5) var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
background: var(--color-surface-primary);
color: var(--color-text-primary);
cursor: pointer;
&:focus {
outline: none;
border-color: var(--color-primary, #2563eb);
box-shadow: 0 0 0 2px var(--color-primary-bg, #eff6ff);
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-brand-light);
}
}
.count-label {
font-size: 12px;
color: var(--color-text-muted, #9ca3af);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.delta-table {
@@ -53,16 +53,16 @@
border-collapse: collapse;
th, td {
padding: 12px 16px;
padding: var(--space-3) var(--space-4);
text-align: left;
border-bottom: 1px solid var(--color-border, #e5e7eb);
border-bottom: 1px solid var(--color-border-primary);
}
th {
font-size: 12px;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
background: var(--color-surface-alt, #f9fafb);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
background: var(--color-surface-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -71,8 +71,8 @@
user-select: none;
&:hover {
color: var(--color-text-primary, #111827);
background: var(--color-surface-hover, #f3f4f6);
color: var(--color-text-primary);
background: var(--color-surface-tertiary);
}
}
}
@@ -101,45 +101,45 @@
.delta-row {
cursor: pointer;
transition: background-color 0.15s ease;
transition: background-color var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background-color: var(--color-surface-hover, #f9fafb);
background-color: var(--color-surface-secondary);
}
&.selected {
background-color: var(--color-primary-bg, #eff6ff);
background-color: var(--color-brand-light);
}
&.expanded {
background-color: var(--color-surface-alt, #f9fafb);
background-color: var(--color-surface-secondary);
}
}
.expand-btn {
width: 24px;
height: 24px;
border: 1px solid var(--color-border, #d1d5db);
border-radius: 4px;
background: var(--color-surface, #ffffff);
color: var(--color-text-secondary, #6b7280);
font-size: 14px;
font-weight: bold;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
color: var(--color-text-muted);
font-size: var(--font-size-base);
font-weight: var(--font-weight-bold);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: var(--color-surface-hover, #f3f4f6);
color: var(--color-text-primary, #111827);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
}
}
.component-cell {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
}
.change-icon {
@@ -149,116 +149,116 @@
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-bold);
&.change-added {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.change-removed {
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #dc2626);
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
&.change-upgraded {
background: var(--color-info-bg, #dbeafe);
color: var(--color-info, #2563eb);
background: var(--color-status-info-bg);
color: var(--color-status-info);
}
&.change-downgraded {
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning, #d97706);
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
&.change-patched {
background: var(--color-primary-bg, #eff6ff);
color: var(--color-primary, #2563eb);
background: var(--color-brand-light);
color: var(--color-brand-primary);
}
&.change-rebuilt {
background: var(--color-neutral-bg, #f3f4f6);
color: var(--color-neutral, #6b7280);
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
}
&.change-unchanged {
background: var(--color-surface-alt, #f9fafb);
color: var(--color-text-muted, #9ca3af);
background: var(--color-surface-secondary);
color: var(--color-text-muted);
}
}
.purl {
font-family: var(--font-mono, monospace);
font-size: 13px;
color: var(--color-text-primary, #111827);
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
color: var(--color-text-primary);
}
.version {
font-family: var(--font-mono, monospace);
font-size: 12px;
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
&.from {
color: var(--color-text-muted, #9ca3af);
color: var(--color-text-muted);
}
&.to {
color: var(--color-text-primary, #111827);
color: var(--color-text-primary);
}
}
.arrow {
margin: 0 8px;
color: var(--color-text-muted, #9ca3af);
margin: 0 var(--space-2);
color: var(--color-text-muted);
}
.change-chip {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
padding: var(--space-1) var(--space-2-5);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
text-transform: uppercase;
&.change-added {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.change-removed {
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #dc2626);
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
&.change-upgraded {
background: var(--color-info-bg, #dbeafe);
color: var(--color-info, #2563eb);
background: var(--color-status-info-bg);
color: var(--color-status-info);
}
&.change-downgraded {
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning, #d97706);
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
&.change-patched {
background: var(--color-primary-bg, #eff6ff);
color: var(--color-primary, #2563eb);
background: var(--color-brand-light);
color: var(--color-brand-primary);
}
&.change-rebuilt {
background: var(--color-neutral-bg, #f3f4f6);
color: var(--color-neutral, #6b7280);
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
}
&.change-unchanged {
background: var(--color-surface-alt, #f9fafb);
color: var(--color-text-muted, #9ca3af);
background: var(--color-surface-secondary);
color: var(--color-text-muted);
}
}
// Symbol details expandable row
.symbol-detail-row {
background: var(--color-surface-alt, #f9fafb);
background: var(--color-surface-secondary);
td {
padding: 0;
@@ -266,35 +266,35 @@
}
.symbol-details {
padding: 16px;
border-top: 1px solid var(--color-border, #e5e7eb);
padding: var(--space-4);
border-top: 1px solid var(--color-border-primary);
h4 {
margin: 0 0 12px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-secondary, #6b7280);
margin: 0 0 var(--space-3);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
}
}
.symbol-table {
width: 100%;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
overflow: hidden;
background: var(--color-surface, #ffffff);
background: var(--color-surface-primary);
th, td {
padding: 8px 12px;
font-size: 12px;
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-sm);
}
th {
background: var(--color-surface-alt, #f9fafb);
background: var(--color-surface-secondary);
}
.symbol-name {
font-family: var(--font-mono, monospace);
font-family: var(--font-family-mono);
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
@@ -302,56 +302,56 @@
}
.positive {
color: var(--color-success, #16a34a);
color: var(--color-status-success);
}
.negative {
color: var(--color-danger, #dc2626);
color: var(--color-status-error);
}
}
.symbol-change-chip {
display: inline-block;
padding: 2px 8px;
padding: var(--space-0-5) var(--space-2);
border-radius: 10px;
font-size: 10px;
font-weight: 500;
font-size: 0.625rem;
font-weight: var(--font-weight-medium);
text-transform: uppercase;
&.symbol-added {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.symbol-removed {
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #dc2626);
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
&.symbol-modified {
background: var(--color-info-bg, #dbeafe);
color: var(--color-info, #2563eb);
background: var(--color-status-info-bg);
color: var(--color-status-info);
}
&.symbol-patched {
background: var(--color-primary-bg, #eff6ff);
color: var(--color-primary, #2563eb);
background: var(--color-brand-light);
color: var(--color-brand-primary);
}
&.symbol-unchanged {
background: var(--color-neutral-bg, #f3f4f6);
color: var(--color-neutral, #6b7280);
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
}
}
.empty-state {
padding: 48px 24px;
padding: var(--space-12) var(--space-6);
text-align: center;
color: var(--color-text-muted, #9ca3af);
color: var(--color-text-muted);
}
// Responsive
@media (max-width: 1024px) {
@include screen-below-lg {
.delta-table {
display: block;
overflow-x: auto;

View File

@@ -1,9 +1,9 @@
// proof-panel.component.scss
@use '../../../../../../styles/tokens/breakpoints' as *;
.proof-panel {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
@@ -11,152 +11,152 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--color-surface-alt, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
padding: var(--space-3) var(--space-4);
background: var(--color-surface-secondary);
border-bottom: 1px solid var(--color-border-primary);
cursor: pointer;
user-select: none;
&:hover {
background: var(--color-surface-hover, #f3f4f6);
background: var(--color-surface-tertiary);
}
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
.icon {
font-family: var(--font-mono, monospace);
font-size: 14px;
color: var(--color-primary, #2563eb);
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
color: var(--color-brand-primary);
}
.title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary, #111827);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
}
.header-meta {
display: flex;
align-items: center;
gap: 12px;
gap: var(--space-3);
.step-count {
font-size: 12px;
color: var(--color-text-muted, #9ca3af);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.expand-icon {
font-size: 16px;
font-weight: bold;
color: var(--color-text-secondary, #6b7280);
font-size: var(--font-size-md);
font-weight: var(--font-weight-bold);
color: var(--color-text-secondary);
}
}
.panel-content {
padding: 16px;
padding: var(--space-4);
}
.trust-scores {
display: flex;
align-items: center;
gap: 16px;
padding: 12px;
background: var(--color-surface-alt, #f9fafb);
border-radius: 6px;
margin-bottom: 16px;
gap: var(--space-4);
padding: var(--space-3);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
}
.score-item {
display: flex;
align-items: baseline;
gap: 8px;
gap: var(--space-2);
.label {
font-size: 12px;
color: var(--color-text-secondary, #6b7280);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
}
.value {
font-size: 18px;
font-weight: 600;
font-family: var(--font-mono, monospace);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
font-family: var(--font-family-mono);
}
&.before .value {
color: var(--color-text-muted, #9ca3af);
color: var(--color-text-muted);
}
&.after .value {
color: var(--color-text-primary, #111827);
color: var(--color-text-primary);
}
}
.arrow {
color: var(--color-text-muted, #9ca3af);
font-size: 14px;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.score-delta {
margin-left: auto;
padding: 6px 12px;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
font-family: var(--font-mono, monospace);
padding: var(--space-1-5) var(--space-3);
border-radius: var(--radius-md);
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
font-family: var(--font-family-mono);
&.trust-positive {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.trust-negative {
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #dc2626);
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
&.trust-neutral {
background: var(--color-neutral-bg, #f3f4f6);
color: var(--color-neutral, #6b7280);
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
}
}
.impact-badges {
display: flex;
gap: 12px;
margin-bottom: 16px;
gap: var(--space-3);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.impact-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
gap: var(--space-1-5);
padding: var(--space-1-5) var(--space-3);
border-radius: var(--radius-md);
font-size: var(--font-size-xs);
.impact-icon {
font-family: var(--font-mono, monospace);
font-family: var(--font-family-mono);
}
&.impact-positive {
background: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.impact-negative {
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #dc2626);
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
&.impact-neutral {
background: var(--color-neutral-bg, #f3f4f6);
color: var(--color-neutral, #6b7280);
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
}
}
@@ -169,94 +169,94 @@
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-left: 3px solid var(--color-border, #e5e7eb);
margin-bottom: 4px;
background: var(--color-surface, #ffffff);
border-radius: 0 6px 6px 0;
gap: var(--space-2);
padding: var(--space-2-5) var(--space-3);
border-left: 3px solid var(--color-border-primary);
margin-bottom: var(--space-1);
background: var(--color-surface-primary);
border-radius: 0 var(--radius-md) var(--radius-md) 0;
&:hover {
background: var(--color-surface-alt, #f9fafb);
background: var(--color-surface-secondary);
}
&.step-positive {
border-left-color: var(--color-success, #16a34a);
background: var(--color-success-bg, #dcfce7);
border-left-color: var(--color-status-success);
background: var(--color-status-success-bg);
&:hover {
background: #bbf7d0;
background: var(--color-status-success-hover-bg);
}
}
&.step-negative {
border-left-color: var(--color-danger, #dc2626);
background: var(--color-danger-bg, #fee2e2);
border-left-color: var(--color-status-error);
background: var(--color-status-error-bg);
&:hover {
background: #fecaca;
background: var(--color-status-error-hover-bg);
}
}
}
}
.step-icon {
font-family: var(--font-mono, monospace);
font-size: 12px;
color: var(--color-text-secondary, #6b7280);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
flex-shrink: 0;
}
.step-text {
flex: 1;
font-size: 13px;
font-size: var(--font-size-sm);
line-height: 1.4;
color: var(--color-text-primary, #111827);
color: var(--color-text-primary);
}
.evidence-chips {
width: 100%;
display: flex;
gap: 6px;
margin-top: 4px;
padding-left: 24px;
gap: var(--space-1-5);
margin-top: var(--space-1);
padding-left: var(--space-6);
}
.evidence-chip {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 500;
font-family: var(--font-mono, monospace);
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-full);
font-size: 0.625rem;
font-weight: var(--font-weight-medium);
font-family: var(--font-family-mono);
&.evidence-cve {
background: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #dc2626);
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
&.evidence-confidence {
background: var(--color-info-bg, #dbeafe);
color: var(--color-info, #2563eb);
background: var(--color-status-info-bg);
color: var(--color-status-info);
}
&.evidence-version {
background: var(--color-neutral-bg, #f3f4f6);
color: var(--color-neutral, #6b7280);
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
}
}
.empty-state {
padding: 48px 24px;
padding: var(--space-12) var(--space-6);
text-align: center;
color: var(--color-text-muted, #9ca3af);
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
color: var(--color-text-muted);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
}
// Responsive
@media (max-width: 640px) {
@include screen-below-sm {
.trust-scores {
flex-wrap: wrap;
}

View File

@@ -1,104 +1,104 @@
// summary-header.component.scss
@use '../../../../../../styles/tokens/breakpoints' as *;
.summary-header {
background: var(--color-surface, #ffffff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: var(--space-4);
margin-bottom: var(--space-4);
}
.header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 16px;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.image-info {
.image-ref {
font-size: 18px;
font-weight: 600;
color: var(--color-text-primary, #111827);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
display: block;
margin-bottom: 4px;
margin-bottom: var(--space-1);
}
.digest-comparison {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-family: var(--font-mono, monospace);
color: var(--color-text-secondary, #6b7280);
gap: var(--space-2);
font-size: var(--font-size-xs);
font-family: var(--font-family-mono);
color: var(--color-text-secondary);
}
.digest {
padding: 2px 6px;
background: var(--color-surface-alt, #f9fafb);
border-radius: 4px;
padding: var(--space-0-5) var(--space-1-5);
background: var(--color-surface-secondary);
border-radius: var(--radius-sm);
&.from {
color: var(--color-text-muted, #9ca3af);
color: var(--color-text-muted);
}
&.to {
color: var(--color-text-primary, #111827);
color: var(--color-text-primary);
}
}
.arrow {
color: var(--color-text-muted, #9ca3af);
color: var(--color-text-muted);
}
}
.verdict-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 8px;
font-weight: 600;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-lg);
font-weight: var(--font-weight-semibold);
&.verdict-positive {
background-color: var(--color-success-bg, #dcfce7);
color: var(--color-success, #16a34a);
background-color: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.verdict-negative {
background-color: var(--color-danger-bg, #fee2e2);
color: var(--color-danger, #dc2626);
background-color: var(--color-status-error-bg);
color: var(--color-status-error);
}
&.verdict-neutral {
background-color: var(--color-neutral-bg, #f3f4f6);
color: var(--color-neutral, #6b7280);
background-color: var(--color-surface-tertiary);
color: var(--color-text-secondary);
}
&.verdict-warning {
background-color: var(--color-warning-bg, #fef3c7);
color: var(--color-warning, #d97706);
background-color: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
.verdict-icon {
font-size: 16px;
font-size: var(--font-size-md);
}
.verdict-label {
font-size: 14px;
font-size: var(--font-size-sm);
}
.trust-delta {
font-family: var(--font-mono, monospace);
font-size: 14px;
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
}
}
.stats-row {
display: flex;
gap: 24px;
margin-bottom: 16px;
gap: var(--space-6);
margin-bottom: var(--space-4);
}
.stat {
@@ -106,49 +106,49 @@
flex-direction: column;
.stat-value {
font-size: 24px;
font-weight: 700;
color: var(--color-text-primary, #111827);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.stat-label {
font-size: 12px;
color: var(--color-text-secondary, #6b7280);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
}
}
.method-badges {
display: flex;
gap: 8px;
margin-bottom: 12px;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.method-chip {
padding: 4px 12px;
background: var(--color-primary-bg, #eff6ff);
color: var(--color-primary, #2563eb);
border-radius: 16px;
font-size: 12px;
font-weight: 500;
padding: var(--space-1) var(--space-3);
background: var(--color-brand-light);
color: var(--color-brand-primary);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
.metadata-row {
display: flex;
gap: 16px;
font-size: 12px;
color: var(--color-text-muted, #9ca3af);
gap: var(--space-4);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
.timestamp {
flex: 1;
}
.engine-version {
font-family: var(--font-mono, monospace);
font-family: var(--font-family-mono);
}
}
// Responsive adjustments
@media (max-width: 768px) {
@include screen-below-md {
.header-row {
flex-direction: column;
}

View File

@@ -1,70 +1,72 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
.actionables-panel {
padding: 16px;
background: var(--surface);
border-top: 1px solid var(--outline-variant);
padding: var(--space-4);
background: var(--color-surface-primary);
border-top: 1px solid var(--color-border-primary);
h4 {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 16px;
font-size: 1rem;
font-weight: 600;
gap: var(--space-2);
margin: 0 0 var(--space-4);
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
mat-icon {
color: var(--primary);
color: var(--color-brand-primary);
}
}
mat-list-item {
border-bottom: 1px solid var(--outline-variant);
padding: 12px 0;
border-bottom: 1px solid var(--color-border-primary);
padding: var(--space-3) 0;
&:last-child {
border-bottom: none;
}
}
.action-upgrade mat-icon { color: var(--tertiary); }
.action-patch mat-icon { color: var(--secondary); }
.action-vex mat-icon { color: var(--primary); }
.action-config mat-icon { color: var(--warning); }
.action-investigate mat-icon { color: var(--error); }
.action-upgrade mat-icon { color: var(--color-status-info); }
.action-patch mat-icon { color: var(--color-brand-secondary); }
.action-vex mat-icon { color: var(--color-brand-primary); }
.action-config mat-icon { color: var(--color-status-warning); }
.action-investigate mat-icon { color: var(--color-status-error); }
.priority-critical {
background: var(--error);
color: white;
background: var(--color-severity-critical);
color: var(--color-text-inverse);
}
.priority-high {
background: var(--warning);
color: black;
background: var(--color-severity-high);
color: var(--color-text-inverse);
}
.priority-medium {
background: var(--tertiary);
color: white;
background: var(--color-severity-medium);
color: var(--color-text-inverse);
}
.priority-low {
background: var(--outline);
color: white;
background: var(--color-severity-low);
color: var(--color-text-inverse);
}
.component-info {
font-size: 0.875rem;
color: var(--on-surface-variant);
font-family: 'Courier New', monospace;
font-size: var(--font-size-base);
color: var(--color-text-muted);
font-family: var(--font-family-mono);
}
.cve-list {
font-size: 0.75rem;
color: var(--error);
font-size: var(--font-size-sm);
color: var(--color-status-error);
}
.effort-estimate {
font-size: 0.75rem;
color: var(--on-surface-variant);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-style: italic;
}
@@ -73,15 +75,15 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: var(--on-surface-variant);
padding: var(--space-12);
color: var(--color-text-muted);
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 16px;
color: var(--success);
margin-bottom: var(--space-4);
color: var(--color-status-success);
}
}
}

View File

@@ -1,22 +1,22 @@
.baseline-rationale {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--secondary-container);
color: var(--on-secondary-container);
border-bottom: 1px solid var(--outline-variant);
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
border-bottom: 1px solid var(--color-border-primary);
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--on-secondary-container);
color: var(--color-text-secondary);
}
.rationale-text {
flex: 1;
font-size: 0.875rem;
font-size: var(--font-size-sm);
line-height: 1.4;
}

View File

@@ -1,3 +1,5 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
.compare-view {
display: flex;
flex-direction: column;
@@ -7,60 +9,60 @@
.compare-toolbar {
display: flex;
justify-content: space-between;
padding: 8px 16px;
background: var(--surface-container);
padding: var(--space-2) var(--space-4);
background: var(--color-surface-secondary);
.target-selector {
display: flex;
align-items: center;
gap: 12px;
gap: var(--space-3);
.label {
color: var(--on-surface-variant);
color: var(--color-text-muted);
}
.target {
font-weight: 500;
padding: 4px 12px;
background: var(--primary-container);
border-radius: 16px;
font-weight: var(--font-weight-medium);
padding: var(--space-1) var(--space-3);
background: var(--color-brand-light);
border-radius: var(--radius-full);
}
}
.toolbar-actions {
display: flex;
gap: 8px;
gap: var(--space-2);
}
}
.delta-summary {
display: flex;
gap: 16px;
padding: 12px 16px;
background: var(--surface);
border-bottom: 1px solid var(--outline-variant);
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
background: var(--color-surface-primary);
border-bottom: 1px solid var(--color-border-primary);
.summary-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 16px;
font-weight: 500;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
font-weight: var(--font-weight-medium);
&.added {
background: var(--success-container);
color: var(--on-success-container);
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.removed {
background: var(--error-container);
color: var(--on-error-container);
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
&.changed {
background: var(--warning-container);
color: var(--on-warning-container);
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
}
}
@@ -74,15 +76,15 @@
.pane {
display: flex;
flex-direction: column;
border-right: 1px solid var(--outline-variant);
border-right: 1px solid var(--color-border-primary);
overflow-y: auto;
h4 {
padding: 12px 16px;
padding: var(--space-3) var(--space-4);
margin: 0;
background: var(--surface-variant);
font-size: 0.875rem;
font-weight: 600;
background: var(--color-surface-secondary);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
}
@@ -98,12 +100,12 @@
.category-counts {
display: flex;
gap: 8px;
font-size: 0.75rem;
gap: var(--space-2);
font-size: var(--font-size-sm);
.added { color: var(--success); }
.removed { color: var(--error); }
.changed { color: var(--warning); }
.added { color: var(--color-status-success); }
.removed { color: var(--color-status-error); }
.changed { color: var(--color-status-warning); }
}
}
@@ -111,61 +113,61 @@
width: 320px;
flex-shrink: 0;
.change-added { color: var(--success); }
.change-removed { color: var(--error); }
.change-changed { color: var(--warning); }
.change-added { color: var(--color-status-success); }
.change-removed { color: var(--color-status-error); }
.change-changed { color: var(--color-status-warning); }
.severity-critical { background: var(--error); color: white; }
.severity-high { background: var(--warning); color: black; }
.severity-medium { background: var(--tertiary); color: white; }
.severity-low { background: var(--outline); color: white; }
.severity-critical { background: var(--color-severity-critical); color: var(--color-text-inverse); }
.severity-high { background: var(--color-severity-high); color: var(--color-text-inverse); }
.severity-medium { background: var(--color-severity-medium); color: var(--color-text-inverse); }
.severity-low { background: var(--color-severity-low); color: var(--color-text-inverse); }
}
.evidence-pane {
flex: 1;
.evidence-content {
padding: 16px;
padding: var(--space-4);
}
.side-by-side {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
gap: var(--space-4);
.before, .after {
h5 {
margin: 0 0 8px;
font-size: 0.875rem;
color: var(--on-surface-variant);
margin: 0 0 var(--space-2);
font-size: var(--font-size-base);
color: var(--color-text-muted);
}
pre {
background: var(--surface-variant);
padding: 12px;
border-radius: 8px;
background: var(--color-surface-secondary);
padding: var(--space-3);
border-radius: var(--radius-md);
overflow-x: auto;
font-size: 0.75rem;
font-size: var(--font-size-sm);
}
}
.before pre {
border-left: 3px solid var(--error);
border-left: 3px solid var(--color-status-error);
}
.after pre {
border-left: 3px solid var(--success);
border-left: 3px solid var(--color-status-success);
}
}
.unified {
.diff-view {
background: var(--surface-variant);
padding: 12px;
border-radius: 8px;
background: var(--color-surface-secondary);
padding: var(--space-3);
border-radius: var(--radius-md);
.added { background: rgba(var(--success-rgb), 0.2); }
.removed { background: rgba(var(--error-rgb), 0.2); }
.added { background: var(--color-status-success-bg); }
.removed { background: var(--color-status-error-bg); }
}
}
}
@@ -175,17 +177,17 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
color: var(--on-surface-variant);
padding: var(--space-12);
color: var(--color-text-muted);
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 16px;
margin-bottom: var(--space-4);
}
}
mat-list-item.selected {
background: var(--primary-container);
background: var(--color-brand-light);
}

View File

@@ -1,28 +1,35 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
/**
* Degraded Mode Banner Component Styles
* Migrated to design system tokens
*/
.degraded-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 16px;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
&--warning {
background: var(--amber-50, #fffbeb);
border: 1px solid var(--amber-300, #fcd34d);
color: var(--amber-900, #78350f);
background: var(--color-status-warning-bg);
border: 1px solid var(--color-status-warning);
color: var(--color-status-warning);
.degraded-banner__icon {
color: var(--amber-600, #d97706);
color: var(--color-status-warning);
}
}
&--error {
background: var(--red-50, #fef2f2);
border: 1px solid var(--red-300, #fca5a5);
color: var(--red-900, #7f1d1d);
background: var(--color-status-error-bg);
border: 1px solid var(--color-status-error);
color: var(--color-status-error);
.degraded-banner__icon {
color: var(--red-600, #dc2626);
color: var(--color-status-error);
}
}
@@ -37,47 +44,47 @@
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
gap: var(--space-0-5);
}
&__title {
font-weight: 600;
font-size: 14px;
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-sm);
}
&__message {
font-size: 13px;
font-size: var(--font-size-sm);
opacity: 0.9;
}
&__details {
font-size: 12px;
font-size: var(--font-size-xs);
opacity: 0.7;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-family: var(--font-family-mono);
}
&__additional {
font-size: 12px;
font-size: var(--font-size-xs);
opacity: 0.7;
margin-top: 4px;
margin-top: var(--space-1);
}
&__actions {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
flex-shrink: 0;
.retry-btn {
font-size: 12px;
padding: 4px 12px;
font-size: var(--font-size-xs);
padding: var(--space-1) var(--space-3);
height: 32px;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
margin-right: 4px;
margin-right: var(--space-1);
}
}
}
@@ -85,7 +92,7 @@
// Compact variant for smaller spaces
:host(.compact) .degraded-banner {
padding: 8px 12px;
padding: var(--space-2) var(--space-3);
&__icon {
font-size: 20px;
@@ -94,10 +101,22 @@
}
&__title {
font-size: 13px;
font-size: var(--font-size-sm);
}
&__message {
font-size: 12px;
font-size: var(--font-size-xs);
}
}
@include screen-below-sm {
.degraded-banner {
flex-direction: column;
align-items: flex-start;
&__actions {
width: 100%;
margin-top: var(--space-2);
}
}
}

View File

@@ -1,19 +1,26 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
/**
* Envelope Hashes Component Styles
* Migrated to design system tokens
*/
.envelope-hashes {
background: var(--surface-card, #f8fafc);
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 8px;
padding: 12px;
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: var(--space-3);
&__header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-color, #e2e8f0);
gap: var(--space-2);
margin-bottom: var(--space-3);
padding-bottom: var(--space-2);
border-bottom: 1px solid var(--color-border-primary);
mat-icon {
color: var(--text-secondary, #64748b);
color: var(--color-text-muted);
font-size: 18px;
width: 18px;
height: 18px;
@@ -21,10 +28,10 @@
.title {
flex: 1;
font-size: 12px;
font-weight: 600;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
color: var(--text-secondary, #64748b);
color: var(--color-text-muted);
letter-spacing: 0.5px;
}
@@ -43,7 +50,7 @@
&__list {
display: flex;
flex-direction: column;
gap: 8px;
gap: var(--space-2);
}
}
@@ -51,16 +58,16 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: var(--surface-ground, #ffffff);
border-radius: 4px;
padding: var(--space-2) var(--space-2);
background: var(--color-surface-primary);
border-radius: var(--radius-sm);
.hash-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-primary, #1e293b);
gap: var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-text-primary);
mat-icon {
font-size: 16px;
@@ -68,19 +75,19 @@
height: 16px;
&.verified {
color: var(--green-600, #16a34a);
color: var(--color-status-success);
}
&.invalid {
color: var(--red-600, #dc2626);
color: var(--color-status-error);
}
&.pending {
color: var(--amber-600, #d97706);
color: var(--color-status-warning);
}
&.unknown {
color: var(--gray-400, #9ca3af);
color: var(--color-text-muted);
}
}
}
@@ -88,22 +95,22 @@
.hash-value {
display: flex;
align-items: center;
gap: 4px;
gap: var(--space-1);
code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 11px;
color: var(--text-secondary, #64748b);
background: var(--surface-hover, #f1f5f9);
padding: 2px 6px;
border-radius: 3px;
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
background: var(--color-surface-tertiary);
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-xs);
}
button {
width: 24px;
height: 24px;
opacity: 0;
transition: opacity 0.15s;
transition: opacity var(--motion-duration-fast) var(--motion-ease-default);
mat-icon {
font-size: 14px;
@@ -117,3 +124,21 @@
opacity: 1;
}
}
@include screen-below-sm {
.hash-row {
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
.hash-value {
width: 100%;
code {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}

View File

@@ -1,16 +1,23 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
/**
* Export Actions Component Styles
* Migrated to design system tokens
*/
.export-actions {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
flex-wrap: wrap;
}
.export-btn {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 500;
gap: var(--space-2);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
mat-icon {
font-size: 18px;
@@ -19,7 +26,7 @@
}
mat-spinner {
margin-right: 4px;
margin-right: var(--space-1);
}
&--primary {
@@ -28,7 +35,7 @@
}
// Responsive: stack buttons on small screens
@media (max-width: 600px) {
@include screen-below-sm {
.export-actions {
flex-direction: column;
align-items: stretch;

View File

@@ -1,11 +1,18 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
/**
* Graph Mini Map Component Styles
* Migrated to design system tokens
*/
.graph-mini-map {
display: flex;
flex-direction: column;
background: var(--surface-card, #ffffff);
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 8px;
padding: 12px;
gap: 8px;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: var(--space-3);
gap: var(--space-2);
}
.mini-map__header {
@@ -15,41 +22,41 @@
}
.mini-map__title {
font-size: 12px;
font-weight: 600;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
color: var(--text-secondary, #64748b);
color: var(--color-text-muted);
letter-spacing: 0.5px;
}
.mini-map__stats {
.stat {
font-size: 12px;
color: var(--text-secondary, #64748b);
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-family: var(--font-family-mono);
}
}
.mini-map__canvas {
width: 100%;
height: 120px;
border-radius: 4px;
border-radius: var(--radius-sm);
cursor: crosshair;
border: 1px solid var(--border-color, #e2e8f0);
border: 1px solid var(--color-border-primary);
}
.mini-map__legend {
display: flex;
gap: 12px;
gap: var(--space-3);
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--text-secondary, #64748b);
gap: var(--space-1);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
.dot {
width: 8px;
@@ -58,15 +65,15 @@
}
&--entry .dot {
background: #22c55e;
background: var(--color-status-success);
}
&--sink .dot {
background: #ef4444;
background: var(--color-status-error);
}
&--changed .dot {
background: #f59e0b;
background: var(--color-status-warning);
}
}
@@ -74,14 +81,25 @@
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 8px;
border-top: 1px solid var(--border-color, #e2e8f0);
padding-top: var(--space-2);
border-top: 1px solid var(--color-border-primary);
mat-slide-toggle {
font-size: 12px;
font-size: var(--font-size-xs);
::ng-deep .mdc-form-field {
font-size: 12px;
font-size: var(--font-size-xs);
}
}
}
@include screen-below-sm {
.mini-map__legend {
justify-content: center;
}
.mini-map__controls {
flex-direction: column;
gap: var(--space-2);
}
}

View File

@@ -1,10 +1,17 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
/**
* Trust Indicators Component Styles
* Migrated to design system tokens
*/
.trust-indicators {
padding: 12px 16px;
background: var(--surface);
border-bottom: 1px solid var(--outline-variant);
padding: var(--space-3) var(--space-4);
background: var(--color-surface-primary);
border-bottom: 1px solid var(--color-border-primary);
&.degraded {
background: var(--error-container);
background: var(--color-status-error-bg);
}
.degraded-banner,
@@ -12,11 +19,11 @@
.feed-staleness-warning {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin-bottom: 12px;
border-radius: 8px;
font-size: 0.875rem;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-3);
border-radius: var(--radius-lg);
font-size: var(--font-size-sm);
mat-icon {
font-size: 20px;
@@ -26,29 +33,29 @@
}
.degraded-banner {
background: var(--error-container);
color: var(--on-error-container);
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
.policy-drift-warning {
background: var(--warning-container);
color: var(--on-warning-container);
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
.feed-staleness-warning {
background: var(--warning-container);
color: var(--on-warning-container);
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
.tooltip-text {
margin-left: auto;
font-size: 0.75rem;
font-size: var(--font-size-xs);
font-style: italic;
}
}
.indicators-row {
display: flex;
gap: 24px;
gap: var(--space-6);
align-items: center;
flex-wrap: wrap;
}
@@ -56,61 +63,61 @@
.indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.875rem;
gap: var(--space-2);
font-size: var(--font-size-sm);
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--on-surface-variant);
color: var(--color-text-muted);
}
.label {
color: var(--on-surface-variant);
font-weight: 500;
color: var(--color-text-muted);
font-weight: var(--font-weight-medium);
}
code {
font-family: 'Courier New', monospace;
background: var(--surface-variant);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8rem;
font-family: var(--font-family-mono);
background: var(--color-surface-tertiary);
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
}
.age {
font-size: 0.75rem;
color: var(--on-surface-variant);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.signer {
font-size: 0.75rem;
color: var(--on-surface-variant);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-style: italic;
}
&.stale {
mat-icon {
color: var(--warning);
color: var(--color-status-warning);
}
}
&.sig-valid mat-icon {
color: var(--success);
color: var(--color-status-success);
}
&.sig-invalid mat-icon,
&.sig-missing mat-icon {
color: var(--error);
color: var(--color-status-error);
}
&.sig-pending mat-icon {
color: var(--warning);
color: var(--color-status-warning);
}
button {
margin-left: 4px;
margin-left: var(--space-1);
mat-icon {
font-size: 16px;
@@ -121,12 +128,22 @@
}
.replay-command {
margin-top: 12px;
margin-top: var(--space-3);
button {
mat-icon {
margin-right: 4px;
margin-right: var(--space-1);
}
}
}
}
@include screen-below-sm {
.trust-indicators {
.indicators-row {
flex-direction: column;
align-items: flex-start;
gap: var(--space-3);
}
}
}

View File

@@ -1,78 +1,85 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
/**
* VEX Merge Explanation Component Styles
* Migrated to design system tokens
*/
.merge-explanation {
padding: 16px;
background: var(--surface);
padding: var(--space-4);
background: var(--color-surface-primary);
.merge-strategy {
padding: 12px;
background: var(--primary-container);
border-radius: 8px;
margin-bottom: 16px;
font-size: 0.875rem;
padding: var(--space-3);
background: var(--color-brand-light);
border-radius: var(--radius-lg);
margin-bottom: var(--space-4);
font-size: var(--font-size-sm);
strong {
color: var(--on-primary-container);
color: var(--color-brand-primary);
}
.conflict {
color: var(--error);
font-weight: 500;
margin-left: 8px;
color: var(--color-status-error);
font-weight: var(--font-weight-medium);
margin-left: var(--space-2);
}
}
.sources-list {
display: flex;
flex-direction: column;
gap: 12px;
gap: var(--space-3);
}
.source {
padding: 12px;
border: 1px solid var(--outline-variant);
border-radius: 8px;
background: var(--surface-variant);
transition: all 0.2s;
padding: var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-secondary);
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&.winner {
border-color: var(--primary);
border-color: var(--color-brand-primary);
border-width: 2px;
background: var(--primary-container);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
background: var(--color-brand-light);
box-shadow: var(--shadow-sm);
}
.source-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
gap: var(--space-2);
margin-bottom: var(--space-2);
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
color: var(--on-surface-variant);
color: var(--color-text-muted);
}
.source-type {
font-weight: 600;
font-weight: var(--font-weight-semibold);
text-transform: capitalize;
color: var(--on-surface);
color: var(--color-text-primary);
}
.source-status {
background: var(--tertiary-container);
color: var(--on-tertiary-container);
background: var(--color-brand-light);
color: var(--color-brand-primary);
}
.source-priority {
background: var(--secondary-container);
color: var(--on-secondary-container);
font-size: 0.75rem;
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
}
.winner-badge {
margin-left: auto;
color: var(--primary);
color: var(--color-brand-primary);
font-size: 24px;
width: 24px;
height: 24px;
@@ -82,37 +89,37 @@
.source-details {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
gap: var(--space-3);
margin-bottom: var(--space-2);
code {
flex: 1;
font-family: 'Courier New', monospace;
font-size: 0.75rem;
background: var(--surface);
padding: 4px 8px;
border-radius: 4px;
color: var(--on-surface);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
background: var(--color-surface-primary);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
color: var(--color-text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.timestamp {
font-size: 0.75rem;
color: var(--on-surface-variant);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
}
.justification {
padding: 8px;
background: var(--surface);
border-radius: 4px;
font-size: 0.875rem;
color: var(--on-surface-variant);
padding: var(--space-2);
background: var(--color-surface-primary);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
strong {
color: var(--on-surface);
color: var(--color-text-primary);
}
}
}
@@ -121,7 +128,7 @@
mat-panel-title {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
mat-icon {
font-size: 20px;
@@ -129,3 +136,24 @@ mat-panel-title {
height: 20px;
}
}
@include screen-below-sm {
.merge-explanation {
padding: var(--space-3);
.source {
.source-header {
flex-wrap: wrap;
}
.source-details {
flex-direction: column;
align-items: flex-start;
code {
width: 100%;
}
}
}
}
}

View File

@@ -1,28 +1,35 @@
@use '../../../../../../styles/tokens/breakpoints' as *;
/**
* Witness Path Component Styles
* Migrated to design system tokens
*/
.witness-path {
padding: 16px;
background: var(--surface);
border-radius: 8px;
border: 1px solid var(--outline-variant);
padding: var(--space-4);
background: var(--color-surface-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
.path-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
margin-bottom: var(--space-4);
.confidence-confirmed {
background: var(--success);
color: white;
background: var(--color-status-success);
color: var(--color-text-inverse);
}
.confidence-likely {
background: var(--warning);
color: black;
background: var(--color-status-warning);
color: var(--color-text-primary);
}
.confidence-present {
background: var(--tertiary);
color: white;
background: var(--color-brand-secondary);
color: var(--color-text-inverse);
}
}
@@ -35,30 +42,30 @@
.path-node {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px 12px;
border-radius: 4px;
transition: background-color 0.2s;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
transition: background-color var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: var(--surface-variant);
background: var(--color-surface-secondary);
}
&.entrypoint {
background: var(--primary-container);
border-left: 3px solid var(--primary);
background: var(--color-brand-light);
border-left: 3px solid var(--color-brand-primary);
.node-icon mat-icon {
color: var(--primary);
color: var(--color-brand-primary);
}
}
&.sink {
background: var(--error-container);
border-left: 3px solid var(--error);
background: var(--color-status-error-bg);
border-left: 3px solid var(--color-status-error);
.node-icon mat-icon {
color: var(--error);
color: var(--color-status-error);
}
}
@@ -73,7 +80,7 @@
font-size: 20px;
width: 20px;
height: 20px;
color: var(--on-surface-variant);
color: var(--color-text-muted);
}
}
@@ -81,23 +88,23 @@
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
gap: var(--space-1);
.method {
font-family: 'Courier New', monospace;
font-size: 0.875rem;
font-weight: 500;
color: var(--on-surface);
background: var(--surface-variant);
padding: 2px 6px;
border-radius: 4px;
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
background: var(--color-surface-tertiary);
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-sm);
display: inline-block;
}
.location {
font-size: 0.75rem;
color: var(--on-surface-variant);
font-family: 'Courier New', monospace;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
font-family: var(--font-family-mono);
}
}
}
@@ -105,7 +112,7 @@
.path-connector {
width: 2px;
height: 12px;
background: var(--outline-variant);
background: var(--color-border-primary);
margin-left: 23px;
}
@@ -113,12 +120,12 @@
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
margin: 8px 0;
background: var(--surface-variant);
border-radius: 4px;
color: var(--on-surface-variant);
gap: var(--space-2);
padding: var(--space-3);
margin: var(--space-2) 0;
background: var(--color-surface-secondary);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
mat-icon {
font-size: 20px;
@@ -127,25 +134,25 @@
}
span {
font-size: 0.875rem;
font-size: var(--font-size-sm);
}
}
.path-gates {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--outline-variant);
gap: var(--space-2);
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-primary);
.gates-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.875rem;
font-weight: 500;
color: var(--on-surface-variant);
gap: var(--space-1);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
mat-icon {
font-size: 18px;
@@ -155,8 +162,24 @@
}
mat-chip {
background: var(--tertiary-container);
color: var(--on-tertiary-container);
background: var(--color-brand-light);
color: var(--color-brand-primary);
}
}
}
@include screen-below-sm {
.witness-path {
padding: var(--space-3);
.path-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-2);
}
.path-gates {
flex-wrap: wrap;
}
}
}

View File

@@ -1,43 +1,47 @@
@use '../../../styles/tokens/breakpoints' as *;
.console-profile {
display: flex;
flex-direction: column;
gap: 1.5rem;
gap: var(--space-4);
}
.console-profile__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
gap: var(--space-3);
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
color: #0f172a;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.console-profile__subtitle {
margin: 0.25rem 0 0;
color: #475569;
font-size: 0.95rem;
margin: var(--space-1) 0 0;
color: var(--color-text-secondary);
font-size: var(--font-size-base);
}
button {
appearance: none;
border: none;
border-radius: 0.75rem;
padding: 0.5rem 1.2rem;
font-weight: 600;
border-radius: var(--radius-lg);
padding: var(--space-2) var(--space-4);
font-weight: var(--font-weight-semibold);
cursor: pointer;
background: linear-gradient(90deg, #4f46e5 0%, #9333ea 100%);
color: #f8fafc;
transition: transform 0.2s ease, box-shadow 0.2s ease;
background: var(--color-brand-primary);
color: var(--color-text-inverse);
transition: transform var(--motion-duration-fast) var(--motion-ease-default),
box-shadow var(--motion-duration-fast) var(--motion-ease-default);
&:hover,
&:focus-visible {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(79, 70, 229, 0.28);
background: var(--color-brand-primary-hover);
box-shadow: var(--shadow-lg);
}
&:disabled {
@@ -50,60 +54,60 @@
}
.console-profile__loading {
padding: 1rem 1.25rem;
border-radius: 0.75rem;
background: rgba(79, 70, 229, 0.08);
color: #4338ca;
font-weight: 500;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
background: var(--color-brand-light);
color: var(--color-brand-primary);
font-weight: var(--font-weight-medium);
}
.console-profile__error {
padding: 1rem 1.25rem;
border-radius: 0.75rem;
background: rgba(220, 38, 38, 0.08);
color: #b91c1c;
border: 1px solid rgba(220, 38, 38, 0.24);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
background: var(--color-status-error-bg);
color: var(--color-status-error);
border: 1px solid var(--color-status-error);
}
.console-profile__card {
background: linear-gradient(150deg, #ffffff 0%, #f8f9ff 100%);
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
border: 1px solid rgba(79, 70, 229, 0.08);
background: var(--color-surface-primary);
border-radius: var(--radius-xl);
padding: var(--space-4);
box-shadow: var(--shadow-lg);
border: 1px solid var(--color-border-primary);
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.25rem;
margin-bottom: var(--space-4);
h2 {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
color: #1e293b;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
}
dl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem 1.5rem;
gap: var(--space-3) var(--space-4);
margin: 0;
dt {
margin: 0 0 0.25rem;
font-size: 0.8rem;
margin: 0 0 var(--space-1);
font-size: var(--font-size-xs);
letter-spacing: 0.04em;
text-transform: uppercase;
color: #64748b;
color: var(--color-text-muted);
}
dd {
margin: 0;
font-size: 0.95rem;
color: #0f172a;
font-size: var(--font-size-base);
color: var(--color-text-primary);
word-break: break-word;
}
}
@@ -113,36 +117,36 @@
.tenant-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.8rem;
font-weight: 600;
gap: var(--space-1);
padding: var(--space-1) var(--space-2-5);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
background-color: rgba(15, 23, 42, 0.08);
color: #0f172a;
background-color: var(--color-surface-secondary);
color: var(--color-text-primary);
}
.chip--active {
background-color: rgba(22, 163, 74, 0.12);
color: #15803d;
background-color: var(--color-status-success-bg);
color: var(--color-status-success);
}
.chip--inactive {
background-color: rgba(220, 38, 38, 0.12);
color: #b91c1c;
background-color: var(--color-status-error-bg);
color: var(--color-status-error);
}
.tenant-chip {
background-color: rgba(79, 70, 229, 0.12);
color: #4338ca;
background-color: var(--color-brand-light);
color: var(--color-brand-primary);
}
.tenant-count {
font-size: 0.85rem;
font-weight: 500;
color: #475569;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.tenant-list {
@@ -150,12 +154,12 @@
padding: 0;
margin: 0;
display: grid;
gap: 0.75rem;
gap: var(--space-2-5);
}
.tenant-list__item--active button {
border-color: rgba(79, 70, 229, 0.45);
background-color: rgba(79, 70, 229, 0.08);
border-color: var(--color-brand-primary);
background-color: var(--color-brand-light);
}
.tenant-list button {
@@ -163,19 +167,21 @@
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
border: 1px solid rgba(100, 116, 139, 0.18);
background: #ffffff;
gap: var(--space-1);
padding: var(--space-2-5) var(--space-3);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
text-align: left;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
transition: border-color var(--motion-duration-fast) var(--motion-ease-default),
box-shadow var(--motion-duration-fast) var(--motion-ease-default),
transform var(--motion-duration-fast) var(--motion-ease-default);
&:hover,
&:focus-visible {
border-color: rgba(79, 70, 229, 0.45);
box-shadow: 0 8px 20px rgba(79, 70, 229, 0.16);
border-color: var(--color-brand-primary);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
}
}
@@ -184,37 +190,37 @@
display: flex;
justify-content: space-between;
width: 100%;
font-weight: 600;
color: #1e293b;
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.tenant-meta {
font-size: 0.85rem;
color: #475569;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.fresh-auth {
margin-top: 1.25rem;
padding: 0.6rem 0.9rem;
border-radius: 0.75rem;
font-size: 0.9rem;
margin-top: var(--space-4);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-lg);
font-size: var(--font-size-sm);
display: inline-flex;
align-items: center;
gap: 0.5rem;
gap: var(--space-2);
}
.fresh-auth--active {
background-color: rgba(16, 185, 129, 0.1);
color: #047857;
background-color: var(--color-status-success-bg);
color: var(--color-status-success);
}
.fresh-auth--stale {
background-color: rgba(249, 115, 22, 0.12);
color: #c2410c;
background-color: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
.console-profile__empty {
margin: 0;
font-size: 0.95rem;
color: #475569;
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}

View File

@@ -1,80 +1,82 @@
@use '../../../styles/tokens/breakpoints' as *;
.console-status {
display: flex;
flex-direction: column;
gap: 1rem;
gap: var(--space-3);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
gap: var(--space-3);
}
.hint {
color: #69707a;
color: var(--color-text-muted);
margin: 0;
}
.status-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
gap: var(--space-2-5);
}
.status-cards article {
background: #0d1117;
border: 1px solid #1f2a36;
border-radius: 8px;
padding: 0.75rem;
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: var(--space-2-5);
}
.status-cards .label {
margin: 0;
color: #9da9bb;
font-size: 0.85rem;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.status-cards .value {
margin: 0.2rem 0 0;
font-size: 1.3rem;
font-weight: 600;
margin: var(--space-0-5) 0 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
}
.value.ok {
color: #2dc98c;
color: var(--color-status-success);
}
.value.warn {
color: #f0ad4e;
color: var(--color-status-warning);
}
.run-stream header {
display: flex;
align-items: center;
gap: 1rem;
gap: var(--space-3);
}
.run-stream app-first-signal-card {
margin: 0.75rem 0 1rem;
margin: var(--space-2-5) 0 var(--space-3);
}
.run-stream label {
display: flex;
align-items: center;
gap: 0.5rem;
gap: var(--space-2);
}
.events {
border: 1px solid #1f2a36;
border-radius: 8px;
padding: 0.5rem;
background: #0b0f14;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: var(--space-2);
background: var(--color-surface-tertiary);
}
.event {
border-bottom: 1px solid #1f2a36;
padding: 0.5rem 0;
border-bottom: 1px solid var(--color-border-primary);
padding: var(--space-2) 0;
}
.event:last-child {
@@ -83,16 +85,16 @@ header {
.meta {
display: flex;
gap: 0.75rem;
font-size: 0.85rem;
color: #9da9bb;
gap: var(--space-2-5);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.detail {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.25rem;
gap: var(--space-2-5);
margin-top: var(--space-1);
}
.kind {
@@ -101,20 +103,23 @@ header {
}
.progress {
color: #2dc98c;
color: var(--color-status-success);
}
.empty {
color: #69707a;
margin: 0.5rem 0 0;
color: var(--color-text-muted);
margin: var(--space-2) 0 0;
}
.error {
color: #f05d5d;
color: var(--color-status-error);
}
.skeleton article {
background: linear-gradient(90deg, #0d1117, #111824, #0d1117);
background: linear-gradient(90deg,
var(--color-surface-secondary),
var(--color-surface-tertiary),
var(--color-surface-secondary));
background-size: 200% 100%;
animation: shimmer 2s infinite;
}

View File

@@ -0,0 +1,417 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
/**
* ControlPlaneDashboardComponent - Main landing page for the new shell.
*
* Displays:
* - Environment pipeline overview
* - Action inbox (pending items)
* - Drift & risk changes
* - Pending promotions
*/
@Component({
selector: 'app-control-plane-dashboard',
standalone: true,
imports: [CommonModule, RouterLink],
template: `
<div class="dashboard">
<header class="dashboard__header">
<div>
<h1 class="dashboard__title">Control Plane</h1>
<p class="dashboard__subtitle">
Release governance with evidence. Promote by digest. Explain every decision.
</p>
</div>
<div class="dashboard__actions">
<a routerLink="/docs" class="btn btn--secondary">Docs →</a>
<button type="button" class="btn btn--primary">Create Release</button>
</div>
</header>
<!-- Environment Pipeline -->
<section class="dashboard__section">
<h2 class="dashboard__section-title">Environment Pipeline</h2>
<div class="pipeline">
@for (env of environments; track env.name) {
<div class="pipeline__stage" [class.pipeline__stage--pending]="env.status === 'pending'">
<div class="pipeline__stage-header">
<span class="pipeline__stage-name">{{ env.name }}</span>
<span class="pipeline__stage-version">{{ env.version }}</span>
</div>
<div class="pipeline__stage-status">
<span class="pipeline__status-badge" [class]="'pipeline__status-badge--' + env.status">
{{ env.status | uppercase }}
</span>
</div>
</div>
@if (!$last) {
<div class="pipeline__arrow">→</div>
}
}
</div>
<p class="pipeline__hint">Deployed by digest. Click an environment to see targets, drift, and evidence.</p>
</section>
<!-- Action Inbox & Drift/Risk -->
<div class="dashboard__grid">
<section class="card">
<h3 class="card__title">Action Inbox</h3>
<p class="card__subtitle">What needs attention</p>
<ul class="card__list">
<li>• 3 approvals pending</li>
<li>• 1 blocked promotion (reachability)</li>
<li>• 2 failed deployments (retry available)</li>
<li>• 1 key expiring in 14 days</li>
</ul>
<div class="card__actions">
<a routerLink="/approvals" class="btn btn--small">Go to Approvals</a>
<a routerLink="/deployments" class="btn btn--small">Go to Deployments</a>
</div>
</section>
<section class="card">
<h3 class="card__title">Drift & Risk Changes</h3>
<p class="card__subtitle">Since last evidence</p>
<ul class="card__list">
<li>• 2 promotions newly BLOCKED</li>
<li>• 5 CVEs updated (1 reachable)</li>
<li>• 1 feed stale risk (OSV 36h old)</li>
<li>• 0 config drifts in prod</li>
</ul>
<div class="card__actions">
<a routerLink="/drift" class="btn btn--small">View Drift</a>
<a routerLink="/security" class="btn btn--small">View Security Impact</a>
</div>
</section>
</div>
<!-- Pending Promotions -->
<section class="dashboard__section">
<h2 class="dashboard__section-title">Pending Promotions</h2>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Release</th>
<th>From → To</th>
<th>Status</th>
<th>Gates</th>
<th>Risk Delta</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr>
<td><a routerLink="/releases/v1.2.5">v1.2.5</a></td>
<td>QA → Staging</td>
<td><span class="badge badge--warning">Waiting</span></td>
<td>
<span class="gate gate--pass">PASS</span>
<span class="gate gate--warn">WARN</span>
</td>
<td>+2 new CVEs</td>
<td><a routerLink="/approvals/1" class="btn btn--small btn--primary">Open Approval</a></td>
</tr>
<tr>
<td><a routerLink="/releases/v1.2.6">v1.2.6</a></td>
<td>Dev → QA</td>
<td><span class="badge badge--success">Auto-approved</span></td>
<td><span class="gate gate--pass">PASS</span></td>
<td>net safer</td>
<td><button type="button" class="btn btn--small btn--primary">Deploy Now</button></td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
`,
styles: [`
.dashboard {
max-width: 1400px;
margin: 0 auto;
}
.dashboard__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 2rem;
margin-bottom: 2rem;
}
.dashboard__title {
margin: 0 0 0.5rem;
font-size: 1.75rem;
font-weight: 600;
color: var(--text-color, #1e293b);
}
.dashboard__subtitle {
margin: 0;
color: var(--text-color-secondary, #64748b);
}
.dashboard__actions {
display: flex;
gap: 0.75rem;
}
.dashboard__section {
margin-bottom: 2rem;
}
.dashboard__section-title {
margin: 0 0 1rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-color, #1e293b);
}
.dashboard__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
/* Pipeline */
.pipeline {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1.5rem;
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
overflow-x: auto;
}
.pipeline__stage {
flex: 1;
min-width: 120px;
padding: 1rem;
background: var(--surface-ground, #f8fafc);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
text-align: center;
}
.pipeline__stage--pending {
border-color: var(--yellow-400, #facc15);
background: var(--yellow-50, #fefce8);
}
.pipeline__stage-header {
margin-bottom: 0.5rem;
}
.pipeline__stage-name {
display: block;
font-weight: 600;
color: var(--text-color, #1e293b);
}
.pipeline__stage-version {
font-size: 0.875rem;
color: var(--text-color-secondary, #64748b);
}
.pipeline__status-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 4px;
}
.pipeline__status-badge--ok {
background: var(--green-100, #dcfce7);
color: var(--green-700, #15803d);
}
.pipeline__status-badge--pending {
background: var(--yellow-100, #fef9c3);
color: var(--yellow-700, #a16207);
}
.pipeline__arrow {
flex-shrink: 0;
font-size: 1.25rem;
color: var(--text-color-secondary, #94a3b8);
}
.pipeline__hint {
margin: 0.75rem 0 0;
font-size: 0.875rem;
color: var(--text-color-secondary, #64748b);
}
/* Cards */
.card {
padding: 1.5rem;
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
}
.card__title {
margin: 0 0 0.25rem;
font-size: 1rem;
font-weight: 600;
}
.card__subtitle {
margin: 0 0 1rem;
font-size: 0.875rem;
color: var(--text-color-secondary, #64748b);
}
.card__list {
margin: 0 0 1rem;
padding: 0;
list-style: none;
font-size: 0.875rem;
line-height: 1.75;
}
.card__actions {
display: flex;
gap: 0.5rem;
}
/* Table */
.table-container {
overflow-x: auto;
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--surface-border, #e2e8f0);
}
.table th {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-color-secondary, #64748b);
background: var(--surface-ground, #f8fafc);
}
.table td {
font-size: 0.875rem;
}
.table a {
color: var(--primary-color, #3b82f6);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
/* Badges and Gates */
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 4px;
}
.badge--success {
background: var(--green-100, #dcfce7);
color: var(--green-700, #15803d);
}
.badge--warning {
background: var(--yellow-100, #fef9c3);
color: var(--yellow-700, #a16207);
}
.gate {
display: inline-block;
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
font-weight: 600;
border-radius: 3px;
margin-right: 0.25rem;
}
.gate--pass {
background: var(--green-100, #dcfce7);
color: var(--green-700, #15803d);
}
.gate--warn {
background: var(--yellow-100, #fef9c3);
color: var(--yellow-700, #a16207);
}
.gate--block {
background: var(--red-100, #fee2e2);
color: var(--red-700, #b91c1c);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
transition: background-color 0.15s;
}
.btn--primary {
background: var(--primary-color, #3b82f6);
color: white;
&:hover {
background: var(--primary-600, #2563eb);
}
}
.btn--secondary {
background: var(--surface-ground, #f1f5f9);
color: var(--text-color, #1e293b);
&:hover {
background: var(--surface-hover, #e2e8f0);
}
}
.btn--small {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ControlPlaneDashboardComponent {
readonly environments = [
{ name: 'DEV', version: 'v1.3.0', status: 'ok' },
{ name: 'QA', version: 'v1.2.5', status: 'ok' },
{ name: 'STAGING', version: 'v1.2.4', status: 'pending' },
{ name: 'PROD', version: 'v1.2.3', status: 'ok' },
];
}

View File

@@ -0,0 +1,10 @@
import { Routes } from '@angular/router';
export const CONTROL_PLANE_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./control-plane-dashboard.component').then((m) => m.ControlPlaneDashboardComponent),
data: { breadcrumb: 'Control Plane' },
},
];

View File

@@ -0,0 +1,349 @@
/**
* Control Plane Store
* Sprint: SPRINT_20260118_003_FE_control_plane_home (CP-006)
*
* Signal-based state management for Control Plane dashboard widgets.
*/
import { Injectable, signal, computed, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
// =============================================
// Models
// =============================================
export interface EnvironmentState {
name: string;
version: string;
status: 'ok' | 'pending' | 'blocked' | 'failed';
targetCount: number;
healthyTargets: number;
lastDeployment: string;
driftStatus: 'synced' | 'drifted' | 'unknown';
}
export interface EnvironmentPipelineState {
environments: EnvironmentState[];
lastUpdated: string;
}
export interface ActionInboxItem {
id: string;
type: 'approval' | 'deployment' | 'key-expiry' | 'drift' | 'blocked';
title: string;
description: string;
severity: 'info' | 'warning' | 'critical';
createdAt: string;
actionLink: string;
}
export interface ActionInboxState {
items: ActionInboxItem[];
totalCount: number;
}
export interface DriftRiskDelta {
promotionsBlocked: number;
cvesUpdated: number;
reachableCves: number;
feedStaleRisks: number;
configDrifts: number;
lastEvidenceTime: string;
}
export interface PendingPromotion {
id: string;
releaseVersion: string;
releaseId: string;
fromEnv: string;
toEnv: string;
status: 'waiting' | 'auto-approved' | 'blocked';
gates: GateSummary[];
riskDelta: string;
requestedAt: string;
requestedBy: string;
}
export interface GateSummary {
name: string;
status: 'PASS' | 'WARN' | 'BLOCK' | 'SKIP';
}
// =============================================
// Store
// =============================================
@Injectable({
providedIn: 'root',
})
export class ControlPlaneStore {
private http = inject(HttpClient);
// =============================================
// State Signals
// =============================================
/** Environment pipeline state (Dev -> QA -> Staging -> Prod) */
readonly pipeline = signal<EnvironmentPipelineState | null>(null);
/** Action inbox items requiring attention */
readonly inbox = signal<ActionInboxState | null>(null);
/** Pending promotions awaiting approval or deployment */
readonly promotions = signal<PendingPromotion[]>([]);
/** Drift and risk changes since last evidence */
readonly driftDelta = signal<DriftRiskDelta | null>(null);
/** Loading state */
readonly loading = signal(false);
/** Error state */
readonly error = signal<string | null>(null);
/** Last refresh timestamp */
readonly lastRefresh = signal<Date | null>(null);
// =============================================
// Computed Properties
// =============================================
/** Total pending approvals count */
readonly pendingApprovalsCount = computed(() => {
const items = this.inbox()?.items ?? [];
return items.filter(i => i.type === 'approval').length;
});
/** Critical action items count */
readonly criticalItemsCount = computed(() => {
const items = this.inbox()?.items ?? [];
return items.filter(i => i.severity === 'critical').length;
});
/** Blocked promotions count */
readonly blockedPromotionsCount = computed(() => {
return this.promotions().filter(p => p.status === 'blocked').length;
});
/** Whether any environment has drift */
readonly hasEnvironmentDrift = computed(() => {
const envs = this.pipeline()?.environments ?? [];
return envs.some(e => e.driftStatus === 'drifted');
});
/** Summary of gate statuses across all pending promotions */
readonly gateStatusSummary = computed(() => {
const promotions = this.promotions();
let pass = 0, warn = 0, block = 0;
for (const promotion of promotions) {
for (const gate of promotion.gates) {
if (gate.status === 'PASS') pass++;
else if (gate.status === 'WARN') warn++;
else if (gate.status === 'BLOCK') block++;
}
}
return { pass, warn, block };
});
// =============================================
// Actions
// =============================================
/**
* Loads all Control Plane data.
*/
async load(): Promise<void> {
this.loading.set(true);
this.error.set(null);
try {
// In production, these would be API calls
// For now, load mock data
await this.loadMockData();
this.lastRefresh.set(new Date());
} catch (e) {
this.error.set(e instanceof Error ? e.message : 'Failed to load Control Plane data');
} finally {
this.loading.set(false);
}
}
/**
* Refreshes all data.
*/
async refresh(): Promise<void> {
await this.load();
}
/**
* Clears all state.
*/
reset(): void {
this.pipeline.set(null);
this.inbox.set(null);
this.promotions.set([]);
this.driftDelta.set(null);
this.loading.set(false);
this.error.set(null);
this.lastRefresh.set(null);
}
/**
* Triggers a promotion deployment.
*/
async deployPromotion(promotionId: string): Promise<void> {
console.log('Deploying promotion:', promotionId);
// TODO: API call to trigger deployment
// await this.http.post(`/api/promotions/${promotionId}/deploy`, {}).toPromise();
await this.refresh();
}
/**
* Opens approval for a promotion.
*/
openApproval(promotionId: string): void {
// Navigation handled by component
console.log('Opening approval:', promotionId);
}
// =============================================
// Private Methods
// =============================================
private async loadMockData(): Promise<void> {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 300));
// Mock pipeline state
this.pipeline.set({
environments: [
{
name: 'DEV',
version: 'v1.3.0',
status: 'ok',
targetCount: 4,
healthyTargets: 4,
lastDeployment: '10m ago',
driftStatus: 'synced',
},
{
name: 'QA',
version: 'v1.2.5',
status: 'ok',
targetCount: 4,
healthyTargets: 4,
lastDeployment: '2h ago',
driftStatus: 'synced',
},
{
name: 'STAGING',
version: 'v1.2.4',
status: 'pending',
targetCount: 6,
healthyTargets: 6,
lastDeployment: '6h ago',
driftStatus: 'drifted',
},
{
name: 'PROD',
version: 'v1.2.3',
status: 'ok',
targetCount: 20,
healthyTargets: 20,
lastDeployment: '1d ago',
driftStatus: 'synced',
},
],
lastUpdated: new Date().toISOString(),
});
// Mock inbox
this.inbox.set({
items: [
{
id: '1',
type: 'approval',
title: '3 approvals pending',
description: 'Release promotions awaiting review',
severity: 'warning',
createdAt: new Date().toISOString(),
actionLink: '/approvals',
},
{
id: '2',
type: 'blocked',
title: '1 blocked promotion (reachability)',
description: 'Critical CVE reachable in v1.2.6',
severity: 'critical',
createdAt: new Date().toISOString(),
actionLink: '/approvals/blocked-1',
},
{
id: '3',
type: 'deployment',
title: '2 failed deployments (retry available)',
description: 'Transient network errors',
severity: 'warning',
createdAt: new Date().toISOString(),
actionLink: '/deployments?status=failed',
},
{
id: '4',
type: 'key-expiry',
title: '1 key expiring in 14 days',
description: 'Signing key needs rotation',
severity: 'info',
createdAt: new Date().toISOString(),
actionLink: '/settings/trust/keys',
},
],
totalCount: 4,
});
// Mock promotions
this.promotions.set([
{
id: 'promo-1',
releaseVersion: 'v1.2.5',
releaseId: 'rel-v1.2.5',
fromEnv: 'QA',
toEnv: 'Staging',
status: 'waiting',
gates: [
{ name: 'SBOM', status: 'PASS' },
{ name: 'Reachability', status: 'WARN' },
],
riskDelta: '+2 new CVEs',
requestedAt: new Date().toISOString(),
requestedBy: 'ci-pipeline',
},
{
id: 'promo-2',
releaseVersion: 'v1.2.6',
releaseId: 'rel-v1.2.6',
fromEnv: 'Dev',
toEnv: 'QA',
status: 'auto-approved',
gates: [
{ name: 'SBOM', status: 'PASS' },
{ name: 'Reachability', status: 'PASS' },
],
riskDelta: 'net safer',
requestedAt: new Date().toISOString(),
requestedBy: 'ci-pipeline',
},
]);
// Mock drift delta
this.driftDelta.set({
promotionsBlocked: 2,
cvesUpdated: 5,
reachableCves: 1,
feedStaleRisks: 1,
configDrifts: 0,
lastEvidenceTime: new Date(Date.now() - 3600000).toISOString(),
});
}
}

View File

@@ -0,0 +1,2 @@
export { ControlPlaneDashboardComponent } from './control-plane-dashboard.component';
export { CONTROL_PLANE_ROUTES } from './control-plane.routes';

View File

@@ -1,30 +1,32 @@
@use '../../../styles/tokens/breakpoints' as *;
.cvss-receipt {
display: grid;
gap: 1rem;
gap: var(--space-4);
}
.cvss-receipt__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
gap: var(--space-4);
}
.cvss-receipt__label {
font-size: 0.875rem;
font-size: var(--font-size-base);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
}
.cvss-receipt__id {
font-size: 0.9rem;
color: #666;
font-size: var(--font-size-base);
color: var(--color-text-muted);
}
.cvss-receipt__meta {
color: #555;
margin: 0.25rem 0 0;
color: var(--color-text-secondary);
margin: var(--space-1) 0 0;
}
.cvss-receipt__score {
@@ -34,62 +36,63 @@
.cvss-score-badge {
display: inline-flex;
align-items: baseline;
gap: 0.35rem;
padding: 0.35rem 0.6rem;
border-radius: 0.4rem;
background: #0a5ac2;
color: #fff;
font-weight: 700;
font-size: 1.5rem;
gap: var(--space-1-5);
padding: var(--space-1-5) var(--space-2-5);
border-radius: var(--radius-md);
background: var(--color-brand-primary);
color: var(--color-text-inverse);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xl);
}
.cvss-score-badge__label {
font-size: 0.85rem;
font-weight: 600;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
}
.cvss-score-badge--critical {
background: #b3261e;
background: var(--color-severity-critical);
}
.cvss-receipt__vector {
margin: 0.35rem 0 0;
font-family: monospace;
color: #333;
margin: var(--space-1-5) 0 0;
font-family: var(--font-family-mono);
color: var(--color-text-secondary);
}
.cvss-tabs {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid #ddd;
gap: var(--space-2);
border-bottom: 1px solid var(--color-border-primary);
}
.cvss-tabs button {
border: none;
background: transparent;
padding: 0.5rem 0.75rem;
font-weight: 600;
padding: var(--space-2) var(--space-3);
font-weight: var(--font-weight-semibold);
cursor: pointer;
color: var(--color-text-muted);
}
.cvss-tabs button.active {
border-bottom: 2px solid #0a5ac2;
color: #0a5ac2;
border-bottom: 2px solid var(--color-brand-primary);
color: var(--color-brand-primary);
}
.cvss-panel {
background: #f8f9fb;
border: 1px solid #e1e4ea;
border-radius: 0.5rem;
padding: 1rem;
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.evidence__id {
font-weight: 700;
font-weight: var(--font-weight-bold);
margin: 0;
}
.evidence__source {
color: #666;
font-size: 0.9rem;
color: var(--color-text-muted);
font-size: var(--font-size-base);
}

View File

@@ -1,5 +1,7 @@
@use '../../../styles/tokens/breakpoints' as *;
.sources-dashboard {
padding: 1.5rem;
padding: var(--space-6);
max-width: 1400px;
margin: 0 auto;
}
@@ -8,27 +10,29 @@
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
margin-bottom: var(--space-6);
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.actions {
display: flex;
gap: 0.5rem;
gap: var(--space-2);
}
}
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
cursor: pointer;
border: 1px solid transparent;
transition: background-color 0.2s, border-color 0.2s;
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
border-color var(--motion-duration-fast) var(--motion-ease-default);
&:disabled {
opacity: 0.6;
@@ -36,21 +40,21 @@
}
&-primary {
background-color: var(--color-primary, #2563eb);
color: white;
background-color: var(--color-brand-primary);
color: var(--color-text-inverse);
&:hover:not(:disabled) {
background-color: var(--color-primary-hover, #1d4ed8);
background-color: var(--color-brand-primary-hover);
}
}
&-secondary {
background-color: transparent;
border-color: var(--color-border, #d1d5db);
color: var(--color-text, #374151);
border-color: var(--color-border-primary);
color: var(--color-text-primary);
&:hover:not(:disabled) {
background-color: var(--color-bg-hover, #f3f4f6);
background-color: var(--color-surface-secondary);
}
}
}
@@ -60,15 +64,15 @@
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem;
padding: var(--space-12);
text-align: center;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--color-border, #e5e7eb);
border-top-color: var(--color-primary, #2563eb);
border: 3px solid var(--color-border-primary);
border-top-color: var(--color-brand-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@@ -80,69 +84,71 @@
}
.error-message {
color: var(--color-error, #dc2626);
margin-bottom: 1rem;
color: var(--color-status-error);
margin-bottom: var(--space-4);
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 1.5rem;
gap: var(--space-6);
}
.tile {
background: var(--color-bg-card, white);
border-radius: 8px;
border: 1px solid var(--color-border, #e5e7eb);
background: var(--color-surface-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
overflow: hidden;
&-title {
font-size: 0.875rem;
font-weight: 600;
padding: 0.75rem 1rem;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
padding: var(--space-3) var(--space-4);
margin: 0;
background: var(--color-bg-subtle, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
background: var(--color-surface-secondary);
border-bottom: 1px solid var(--color-border-primary);
color: var(--color-text-primary);
}
&-content {
padding: 1rem;
padding: var(--space-4);
}
}
.tile-pass-fail {
&.excellent .metric-large .value { color: var(--color-success, #059669); }
&.good .metric-large .value { color: var(--color-success-muted, #10b981); }
&.warning .metric-large .value { color: var(--color-warning, #d97706); }
&.critical .metric-large .value { color: var(--color-error, #dc2626); }
&.excellent .metric-large .value { color: var(--color-status-success); }
&.good .metric-large .value { color: var(--color-status-success); }
&.warning .metric-large .value { color: var(--color-status-warning); }
&.critical .metric-large .value { color: var(--color-status-error); }
}
.metric-large {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 1rem;
margin-bottom: var(--space-4);
.value {
font-size: 2.5rem;
font-weight: 700;
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
line-height: 1;
color: var(--color-text-primary);
}
.label {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-top: 0.25rem;
margin-top: var(--space-1);
}
}
.metric-details {
display: flex;
justify-content: space-around;
padding-top: 1rem;
border-top: 1px solid var(--color-border, #e5e7eb);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-primary);
.detail {
display: flex;
@@ -151,17 +157,17 @@
}
.count {
font-size: 1.25rem;
font-weight: 600;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
&.pass { color: var(--color-success, #059669); }
&.fail { color: var(--color-error, #dc2626); }
&.total { color: var(--color-text, #374151); }
&.pass { color: var(--color-status-success); }
&.fail { color: var(--color-status-error); }
&.total { color: var(--color-text-primary); }
}
.label {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
@@ -172,57 +178,58 @@
}
.violation-item {
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 0.5rem;
background: var(--color-bg-subtle, #f9fafb);
padding: var(--space-3);
border-radius: var(--radius-sm);
margin-bottom: var(--space-2);
background: var(--color-surface-secondary);
&.severity-critical { border-left: 3px solid var(--color-error, #dc2626); }
&.severity-high { border-left: 3px solid var(--color-warning, #d97706); }
&.severity-medium { border-left: 3px solid var(--color-info, #2563eb); }
&.severity-low { border-left: 3px solid var(--color-text-muted, #9ca3af); }
&.severity-critical { border-left: 3px solid var(--color-severity-critical); }
&.severity-high { border-left: 3px solid var(--color-severity-high); }
&.severity-medium { border-left: 3px solid var(--color-severity-medium); }
&.severity-low { border-left: 3px solid var(--color-severity-low); }
}
.violation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
margin-bottom: var(--space-1);
}
.violation-code {
font-family: monospace;
font-size: 0.8125rem;
font-weight: 600;
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.violation-count {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.violation-desc {
font-size: 0.8125rem;
margin: 0 0 0.25rem;
color: var(--color-text, #374151);
font-size: var(--font-size-sm);
margin: 0 0 var(--space-1);
color: var(--color-text-primary);
}
.violation-time {
font-size: 0.6875rem;
color: var(--color-text-muted, #9ca3af);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.empty-state {
color: var(--color-text-muted, #6b7280);
color: var(--color-text-muted);
font-style: italic;
text-align: center;
padding: 1rem;
padding: var(--space-4);
}
.throughput-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: 1rem;
gap: var(--space-4);
text-align: center;
}
@@ -231,95 +238,113 @@
flex-direction: column;
.value {
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text, #374151);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.label {
font-size: 0.6875rem;
color: var(--color-text-muted, #6b7280);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-transform: uppercase;
}
}
.tile-throughput {
&.critical .throughput-item .value { color: var(--color-error, #dc2626); }
&.warning .throughput-item .value { color: var(--color-warning, #d97706); }
&.critical .throughput-item .value { color: var(--color-status-error); }
&.warning .throughput-item .value { color: var(--color-status-warning); }
}
.verification-result {
margin-top: 1.5rem;
padding: 1rem;
border-radius: 8px;
border: 1px solid var(--color-border, #e5e7eb);
margin-top: var(--space-6);
padding: var(--space-4);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
&.status-passed { background: var(--color-success-bg, #ecfdf5); border-color: var(--color-success, #059669); }
&.status-failed { background: var(--color-error-bg, #fef2f2); border-color: var(--color-error, #dc2626); }
&.status-partial { background: var(--color-warning-bg, #fffbeb); border-color: var(--color-warning, #d97706); }
&.status-passed { background: var(--color-status-success-bg); border-color: var(--color-status-success); }
&.status-failed { background: var(--color-status-error-bg); border-color: var(--color-status-error); }
&.status-partial { background: var(--color-status-warning-bg); border-color: var(--color-status-warning); }
h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
margin: 0 0 var(--space-2);
font-size: var(--font-size-md);
color: var(--color-text-primary);
}
.result-summary {
display: flex;
gap: 1rem;
gap: var(--space-4);
flex-wrap: wrap;
margin-bottom: 0.75rem;
margin-bottom: var(--space-3);
}
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
}
&.status-passed .status-badge { background: var(--color-success, #059669); color: white; }
&.status-failed .status-badge { background: var(--color-error, #dc2626); color: white; }
&.status-partial .status-badge { background: var(--color-warning, #d97706); color: white; }
&.status-passed .status-badge { background: var(--color-status-success); color: var(--color-text-inverse); }
&.status-failed .status-badge { background: var(--color-status-error); color: var(--color-text-inverse); }
&.status-partial .status-badge { background: var(--color-status-warning); color: var(--color-text-inverse); }
}
.violations-details {
margin: 0.75rem 0;
margin: var(--space-3) 0;
summary {
cursor: pointer;
color: var(--color-primary, #2563eb);
font-size: 0.875rem;
color: var(--color-brand-primary);
font-size: var(--font-size-base);
}
.violation-list {
margin-top: 0.5rem;
padding-left: 1.25rem;
font-size: 0.8125rem;
margin-top: var(--space-2);
padding-left: var(--space-5);
font-size: var(--font-size-sm);
li {
margin-bottom: 0.5rem;
margin-bottom: var(--space-2);
}
}
}
.cli-hint {
margin: 0.75rem 0 0;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
margin: var(--space-3) 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
code {
background: var(--color-bg-code, #f3f4f6);
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-family: monospace;
font-size: 0.6875rem;
background: var(--color-surface-tertiary);
padding: var(--space-0-5) var(--space-1-5);
border-radius: var(--radius-xs);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
}
}
.time-window {
margin-top: 1rem;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
margin-top: var(--space-4);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-align: center;
}
// Responsive
@include screen-below-md {
.sources-dashboard {
padding: var(--space-4);
}
.dashboard-header {
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
}
.metrics-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,655 @@
/**
* Deployment Detail Page Component
* Sprint: SPRINT_20260118_008_FE_environments_deployments (DEP-002, DEP-003, DEP-004, DEP-005)
*
* Deployment detail with Workflow DAG, Artifacts, and Logs tabs.
*/
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit, ElementRef, ViewChild, AfterViewChecked } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
interface WorkflowStep {
id: string;
name: string;
status: 'pending' | 'running' | 'complete' | 'failed' | 'skipped';
duration: string;
startedAt?: string;
endedAt?: string;
dependsOn: string[];
logs?: string;
}
interface DeploymentArtifact {
name: string;
type: 'lock' | 'script' | 'evidence' | 'manifest' | 'config';
hash: string;
size: string;
}
@Component({
selector: 'app-deployment-detail-page',
standalone: true,
imports: [CommonModule, RouterLink, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="deployment-detail">
<header class="page-header">
<a routerLink="/deployments" class="back-link">← Back to Deployments</a>
<div class="header-main">
<div class="header-title-row">
<h1 class="page-title">{{ deployment().id }}</h1>
<span class="status-badge" [class]="'status-badge--' + deployment().status">
{{ deployment().status | uppercase }}
</span>
</div>
<div class="header-meta">
<span>Env: <strong>{{ deployment().environment }}</strong></span>
<span>Release: <strong>{{ deployment().releaseVersion }}</strong></span>
<span>Plan Hash: <code>{{ deployment().planHash }}</code></span>
<span>Agent: <strong>{{ deployment().agentId }}</strong></span>
</div>
</div>
<div class="header-actions">
<button type="button" class="btn btn--secondary" (click)="openEvidence()">
Open Evidence
</button>
@if (deployment().status === 'success') {
<button type="button" class="btn btn--secondary" (click)="rollback()">
Rollback
</button>
}
<button type="button" class="btn btn--secondary" (click)="replayVerify()">
Replay Verify
</button>
</div>
</header>
<!-- Tabs -->
<nav class="tabs">
@for (tab of tabs; track tab.id) {
<button
type="button"
class="tab"
[class.tab--active]="activeTab() === tab.id"
(click)="setTab(tab.id)"
>
{{ tab.label }}
</button>
}
</nav>
<div class="tab-content">
@switch (activeTab()) {
<!-- DEP-003: Workflow DAG Tab -->
@case ('workflow') {
<section class="panel workflow-panel">
<div class="panel-header">
<h3>Workflow DAG</h3>
<span class="workflow-summary">
{{ getCompletedSteps() }}/{{ workflowSteps().length }} steps complete
· Total: {{ deployment().duration }}
</span>
</div>
<div class="workflow-dag">
@for (step of workflowSteps(); track step.id; let i = $index) {
<div
class="dag-node"
[class]="'dag-node--' + step.status"
[class.dag-node--selected]="selectedStep() === step.id"
(click)="selectStep(step.id)"
>
<div class="dag-node__icon">
@switch (step.status) {
@case ('complete') { <span class="icon-check">✓</span> }
@case ('running') { <span class="icon-spin">⟳</span> }
@case ('pending') { <span class="icon-pending">○</span> }
@case ('failed') { <span class="icon-fail">✕</span> }
@case ('skipped') { <span class="icon-skip">⊘</span> }
}
</div>
<div class="dag-node__content">
<span class="dag-node__name">{{ step.name }}</span>
<span class="dag-node__duration">{{ step.duration }}</span>
</div>
@if (i < workflowSteps().length - 1) {
<div class="dag-connector">
<svg width="24" height="40" viewBox="0 0 24 40">
<path d="M12 0 L12 40" stroke="currentColor" stroke-width="2" fill="none" stroke-dasharray="4 2"/>
<polygon points="8,32 12,40 16,32" fill="currentColor"/>
</svg>
</div>
}
</div>
}
</div>
@if (selectedStep()) {
<div class="step-detail">
<div class="step-detail__header">
<h4>{{ getSelectedStepData()?.name }}</h4>
<span class="step-detail__status" [class]="'step-detail__status--' + getSelectedStepData()?.status">
{{ getSelectedStepData()?.status | uppercase }}
</span>
</div>
<div class="step-detail__meta">
<span>Duration: {{ getSelectedStepData()?.duration }}</span>
@if (getSelectedStepData()?.startedAt) {
<span>Started: {{ getSelectedStepData()?.startedAt }}</span>
}
</div>
@if (getSelectedStepData()?.logs) {
<div class="step-detail__logs">
<pre>{{ getSelectedStepData()?.logs }}</pre>
</div>
}
</div>
}
</section>
}
<!-- DEP-004: Artifacts Tab -->
@case ('artifacts') {
<section class="panel">
<div class="panel-header">
<h3>Immutable Artifacts</h3>
<span class="artifacts-count">{{ artifacts().length }} artifacts</span>
</div>
<table class="data-table artifacts-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Hash (SHA-256)</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (artifact of artifacts(); track artifact.name) {
<tr>
<td class="artifact-name">
<span class="artifact-icon" [class]="'artifact-icon--' + artifact.type">
@switch (artifact.type) {
@case ('lock') { 🔒 }
@case ('script') { 📜 }
@case ('evidence') { 📋 }
@case ('manifest') { 📄 }
@case ('config') { ⚙️ }
}
</span>
{{ artifact.name }}
</td>
<td>
<span class="type-badge" [class]="'type-badge--' + artifact.type">
{{ artifact.type }}
</span>
</td>
<td class="artifact-hash">
<code>{{ artifact.hash.slice(0, 16) }}...</code>
<button class="copy-btn" (click)="copyHash(artifact.hash)" title="Copy full hash">
📋
</button>
</td>
<td>{{ artifact.size }}</td>
<td class="artifact-actions">
<button type="button" class="btn btn--sm" (click)="viewArtifact(artifact)">View</button>
<button type="button" class="btn btn--sm" (click)="downloadArtifact(artifact)">Download</button>
</td>
</tr>
}
</tbody>
</table>
</section>
}
<!-- DEP-005: Logs Tab -->
@case ('logs') {
<section class="panel logs-panel">
<div class="panel-header">
<h3>Deployment Logs</h3>
<div class="logs-controls">
<select
class="logs-step-select"
[ngModel]="selectedLogStep()"
(ngModelChange)="selectLogStep($event)"
>
<option value="all">All Steps</option>
@for (step of workflowSteps(); track step.id) {
<option [value]="step.id">{{ step.name }}</option>
}
</select>
<input
type="text"
class="logs-search"
placeholder="Search logs..."
[ngModel]="logSearchQuery()"
(ngModelChange)="searchLogs($event)"
/>
<button
type="button"
class="btn btn--sm"
[class.active]="autoScroll()"
(click)="toggleAutoScroll()"
title="Auto-scroll"
>
{{ autoScroll() ? '⏸ Pause' : '▶ Follow' }}
</button>
<button type="button" class="btn btn--sm" (click)="downloadLogs()">
Download
</button>
</div>
</div>
<div class="log-viewer" #logViewer>
<pre class="log-content">{{ filteredLogs() }}</pre>
</div>
<div class="logs-footer">
<span class="logs-line-count">{{ getLogLineCount() }} lines</span>
@if (logSearchQuery()) {
<span class="logs-match-count">{{ getMatchCount() }} matches</span>
}
</div>
</section>
}
<!-- Targets Tab -->
@case ('targets') {
<section class="panel">
<h3>Deployment Targets</h3>
<table class="data-table">
<thead>
<tr>
<th>Target</th>
<th>Type</th>
<th>Status</th>
<th>Deployed Digest</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
@for (target of deploymentTargets; track target.name) {
<tr>
<td>{{ target.name }}</td>
<td>{{ target.type }}</td>
<td>
<span class="target-status" [class]="'target-status--' + target.status">
{{ target.status }}
</span>
</td>
<td><code>{{ target.digest.slice(0, 16) }}...</code></td>
<td>{{ target.duration }}</td>
</tr>
}
</tbody>
</table>
</section>
}
<!-- Evidence Tab -->
@case ('evidence') {
<section class="panel">
<h3>Deployment Evidence</h3>
<p class="evidence-info">
Evidence for this deployment is sealed and signed.
<a [routerLink]="['/evidence', deployment().evidenceId]">View full evidence packet</a>
</p>
<div class="evidence-summary">
<div class="evidence-item">
<span class="evidence-label">Evidence ID</span>
<code>{{ deployment().evidenceId }}</code>
</div>
<div class="evidence-item">
<span class="evidence-label">Signed</span>
<span class="evidence-badge evidence-badge--success">Yes</span>
</div>
<div class="evidence-item">
<span class="evidence-label">Verified</span>
<span class="evidence-badge evidence-badge--success">Yes</span>
</div>
<div class="evidence-item">
<span class="evidence-label">Rekor Entry</span>
<a href="#" class="rekor-link">View in Rekor</a>
</div>
</div>
</section>
}
}
</div>
</div>
`,
styles: [`
.deployment-detail { max-width: 1200px; margin: 0 auto; }
/* Header */
.page-header { margin-bottom: 1.5rem; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 1rem; }
.back-link { display: block; margin-bottom: 0.5rem; font-size: 0.875rem; color: var(--primary-color, #3b82f6); text-decoration: none; width: 100%; }
.header-main { flex: 1; }
.header-title-row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
.page-title { margin: 0; font-size: 1.5rem; font-weight: 600; font-family: ui-monospace, SFMono-Regular, monospace; }
.header-meta { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.875rem; color: var(--text-color-secondary, #64748b); }
.header-meta strong { color: var(--text-color, #1e293b); }
.header-meta code { font-size: 0.625rem; }
.header-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.status-badge { padding: 0.25rem 0.75rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
.status-badge--running { background: var(--blue-100, #dbeafe); color: var(--blue-700, #1d4ed8); }
.status-badge--success { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.status-badge--failed { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
.status-badge--cancelled { background: var(--gray-100, #f3f4f6); color: var(--gray-600, #4b5563); }
/* Tabs */
.tabs { display: flex; gap: 0.25rem; border-bottom: 1px solid var(--surface-border, #e2e8f0); margin-bottom: 1.5rem; overflow-x: auto; }
.tab { padding: 0.75rem 1rem; background: transparent; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; font-size: 0.875rem; font-weight: 500; color: var(--text-color-secondary, #64748b); cursor: pointer; white-space: nowrap; }
.tab:hover { color: var(--text-color, #1e293b); }
.tab--active { color: var(--primary-color, #3b82f6); border-bottom-color: var(--primary-color, #3b82f6); }
/* Panel */
.panel { padding: 1.25rem; background: var(--surface-card, #ffffff); border: 1px solid var(--surface-border, #e2e8f0); border-radius: 8px; }
.panel h3 { margin: 0; font-size: 1rem; font-weight: 600; }
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem; }
/* Workflow DAG (DEP-003) */
.workflow-panel { }
.workflow-summary { font-size: 0.75rem; color: var(--text-color-secondary, #64748b); }
.workflow-dag { display: flex; flex-direction: column; align-items: center; gap: 0; padding: 1rem 0; }
.dag-node { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1.5rem; background: var(--surface-ground, #f8fafc); border: 2px solid var(--surface-border, #e2e8f0); border-radius: 8px; cursor: pointer; transition: all 0.15s; min-width: 250px; position: relative; }
.dag-node:hover { border-color: var(--primary-color, #3b82f6); }
.dag-node--selected { border-color: var(--primary-color, #3b82f6); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); }
.dag-node--complete { border-color: var(--green-300, #86efac); background: var(--green-50, #f0fdf4); }
.dag-node--running { border-color: var(--blue-300, #93c5fd); background: var(--blue-50, #eff6ff); animation: pulse 2s infinite; }
.dag-node--failed { border-color: var(--red-300, #fca5a5); background: var(--red-50, #fef2f2); }
.dag-node--pending { border-color: var(--gray-200, #e5e7eb); background: var(--gray-50, #f9fafb); }
.dag-node--skipped { border-color: var(--gray-200, #e5e7eb); background: var(--gray-100, #f3f4f6); opacity: 0.6; }
.dag-node__icon { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 50%; font-size: 1rem; }
.dag-node--complete .dag-node__icon { background: var(--green-100, #dcfce7); color: var(--green-600, #16a34a); }
.dag-node--running .dag-node__icon { background: var(--blue-100, #dbeafe); color: var(--blue-600, #2563eb); }
.dag-node--failed .dag-node__icon { background: var(--red-100, #fee2e2); color: var(--red-600, #dc2626); }
.dag-node--pending .dag-node__icon { background: var(--gray-100, #f3f4f6); color: var(--gray-400, #9ca3af); }
.dag-node__content { flex: 1; }
.dag-node__name { display: block; font-weight: 600; font-size: 0.875rem; }
.dag-node__duration { display: block; font-size: 0.75rem; color: var(--text-color-secondary, #64748b); }
.dag-connector { position: absolute; bottom: -40px; left: 50%; transform: translateX(-50%); color: var(--surface-border, #cbd5e1); z-index: 1; }
.icon-spin { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } }
.step-detail { margin-top: 1.5rem; padding: 1rem; background: var(--surface-ground, #f8fafc); border-radius: 8px; border: 1px solid var(--surface-border, #e2e8f0); }
.step-detail__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; }
.step-detail__header h4 { margin: 0; font-size: 0.875rem; }
.step-detail__status { font-size: 0.625rem; padding: 0.125rem 0.5rem; border-radius: 4px; font-weight: 600; }
.step-detail__status--complete { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.step-detail__status--running { background: var(--blue-100, #dbeafe); color: var(--blue-700, #1d4ed8); }
.step-detail__status--failed { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
.step-detail__meta { font-size: 0.75rem; color: var(--text-color-secondary, #64748b); display: flex; gap: 1rem; margin-bottom: 0.75rem; }
.step-detail__logs { background: var(--gray-900, #111827); padding: 0.75rem; border-radius: 6px; max-height: 200px; overflow: auto; }
.step-detail__logs pre { margin: 0; font-size: 0.625rem; color: var(--gray-100, #f3f4f6); font-family: ui-monospace, monospace; }
/* Artifacts Table (DEP-004) */
.artifacts-count { font-size: 0.75rem; color: var(--text-color-secondary, #64748b); }
.artifacts-table { }
.artifact-name { display: flex; align-items: center; gap: 0.5rem; }
.artifact-icon { font-size: 1rem; }
.artifact-hash { font-family: ui-monospace, monospace; }
.artifact-hash code { font-size: 0.625rem; }
.copy-btn { background: none; border: none; cursor: pointer; padding: 0.25rem; font-size: 0.75rem; }
.artifact-actions { display: flex; gap: 0.5rem; }
.type-badge { font-size: 0.625rem; padding: 0.125rem 0.5rem; border-radius: 4px; font-weight: 500; text-transform: uppercase; }
.type-badge--lock { background: var(--blue-100, #dbeafe); color: var(--blue-700, #1d4ed8); }
.type-badge--script { background: var(--purple-100, #f3e8ff); color: var(--purple-700, #7c3aed); }
.type-badge--evidence { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.type-badge--manifest { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
.type-badge--config { background: var(--gray-100, #f3f4f6); color: var(--gray-600, #4b5563); }
/* Logs (DEP-005) */
.logs-panel { display: flex; flex-direction: column; }
.logs-controls { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.logs-step-select { padding: 0.375rem 0.75rem; border: 1px solid var(--surface-border, #e2e8f0); border-radius: 6px; font-size: 0.75rem; }
.logs-search { padding: 0.375rem 0.75rem; border: 1px solid var(--surface-border, #e2e8f0); border-radius: 6px; font-size: 0.75rem; width: 200px; }
.log-viewer { background: var(--gray-900, #111827); border-radius: 8px; padding: 1rem; max-height: 500px; overflow: auto; flex: 1; }
.log-content { margin: 0; font-size: 0.75rem; color: var(--gray-100, #f3f4f6); font-family: ui-monospace, monospace; white-space: pre-wrap; }
.logs-footer { display: flex; justify-content: space-between; padding-top: 0.5rem; font-size: 0.75rem; color: var(--text-color-secondary, #64748b); }
.logs-match-count { color: var(--yellow-600, #ca8a04); }
/* Targets */
.target-status { font-size: 0.625rem; padding: 0.125rem 0.5rem; border-radius: 4px; font-weight: 600; }
.target-status--ok { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.target-status--failed { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
.target-status--pending { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
/* Evidence */
.evidence-info { margin: 0 0 1rem; font-size: 0.875rem; }
.evidence-info a { color: var(--primary-color, #3b82f6); }
.evidence-summary { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; }
.evidence-item { display: flex; flex-direction: column; gap: 0.25rem; }
.evidence-label { font-size: 0.625rem; text-transform: uppercase; color: var(--text-color-secondary, #94a3b8); }
.evidence-item code { font-size: 0.625rem; }
.evidence-badge { font-size: 0.625rem; padding: 0.125rem 0.5rem; border-radius: 4px; font-weight: 600; width: fit-content; }
.evidence-badge--success { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.rekor-link { font-size: 0.875rem; color: var(--primary-color, #3b82f6); }
/* Data Table */
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--surface-border, #e2e8f0); }
.data-table th { font-size: 0.75rem; font-weight: 600; color: var(--text-color-secondary, #64748b); text-transform: uppercase; }
.data-table code { font-size: 0.625rem; }
/* Buttons */
.btn { padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.875rem; font-weight: 500; cursor: pointer; text-decoration: none; }
.btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.btn--secondary { background: var(--surface-ground, #f8fafc); border: 1px solid var(--surface-border, #e2e8f0); color: var(--text-color, #1e293b); }
.btn--secondary:hover { background: var(--surface-hover, #f1f5f9); }
.btn--secondary.active { background: var(--primary-color, #3b82f6); color: white; border-color: var(--primary-color, #3b82f6); }
@media (max-width: 768px) {
.evidence-summary { grid-template-columns: 1fr; }
.logs-controls { flex-direction: column; align-items: stretch; }
.logs-search { width: 100%; }
}
`]
})
export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked {
@ViewChild('logViewer') logViewerRef?: ElementRef<HTMLDivElement>;
private route = inject(ActivatedRoute);
deploymentId = signal('');
activeTab = signal('workflow');
selectedStep = signal<string | null>(null);
selectedLogStep = signal('all');
logSearchQuery = signal('');
autoScroll = signal(false);
tabs = [
{ id: 'workflow', label: 'Workflow' },
{ id: 'targets', label: 'Targets' },
{ id: 'artifacts', label: 'Artifacts' },
{ id: 'logs', label: 'Logs' },
{ id: 'evidence', label: 'Evidence' },
];
deployment = signal({
id: 'DEP-2026-049',
releaseVersion: 'v1.2.5',
environment: 'QA',
bundleDigest: 'sha256:7aa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9',
planHash: 'ph_91a2b3c4',
agentId: 'qa-agent-02',
evidenceId: 'evd-2026-049',
status: 'success' as 'running' | 'success' | 'failed' | 'cancelled',
startedAt: '2h ago',
duration: '2m 15s',
initiatedBy: 'user1',
});
// DEP-003: Workflow steps with DAG support
workflowSteps = signal<WorkflowStep[]>([
{ id: 'fetch', name: 'Fetch Bundle', status: 'complete', duration: '5s', dependsOn: [], startedAt: '10:00:00', logs: '[10:00:00] Fetching bundle sha256:7aa1b2c3...\n[10:00:05] Bundle downloaded (4.2 MB)' },
{ id: 'lock', name: 'Generate Lock', status: 'complete', duration: '3s', dependsOn: ['fetch'], startedAt: '10:00:05', logs: '[10:00:05] Generating lock file...\n[10:00:08] Lock generated: compose.stella.lock.yml' },
{ id: 'deploy', name: 'Deploy Containers', status: 'complete', duration: '1m 45s', dependsOn: ['lock'], startedAt: '10:00:08', logs: '[10:00:08] Starting deployment to 4 targets\n[10:01:02] web-01: deployed\n[10:01:32] web-02: deployed\n[10:01:53] api-01: deployed' },
{ id: 'verify', name: 'Verify Deployment', status: 'complete', duration: '15s', dependsOn: ['deploy'], startedAt: '10:01:53', logs: '[10:01:53] Running health checks...\n[10:02:08] All targets healthy' },
{ id: 'seal', name: 'Seal Evidence', status: 'complete', duration: '7s', dependsOn: ['verify'], startedAt: '10:02:08', logs: '[10:02:08] Sealing evidence packet...\n[10:02:15] Evidence sealed and signed' },
]);
// DEP-004: Artifacts
artifacts = signal<DeploymentArtifact[]>([
{ name: 'compose.stella.lock.yml', type: 'lock', hash: 'sha256:11a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0', size: '2.1 KB' },
{ name: 'deploy.stella.script.dll', type: 'script', hash: 'sha256:22b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1', size: '156 KB' },
{ name: 'release.evidence.json', type: 'evidence', hash: 'sha256:33c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2', size: '12.8 KB' },
{ name: 'stella.version.json', type: 'manifest', hash: 'sha256:44d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3', size: '4.2 KB' },
]);
// Deployment targets
deploymentTargets = [
{ name: 'qa-web-01', type: 'Container', status: 'ok', digest: 'sha256:a1b2c3d4e5f6', duration: '28s' },
{ name: 'qa-web-02', type: 'Container', status: 'ok', digest: 'sha256:a1b2c3d4e5f6', duration: '30s' },
{ name: 'qa-api-01', type: 'Container', status: 'ok', digest: 'sha256:b2c3d4e5f6a7', duration: '21s' },
{ name: 'qa-worker-01', type: 'Container', status: 'ok', digest: 'sha256:c3d4e5f6a7b8', duration: '15s' },
];
// DEP-005: Full logs
fullLogs = `[2026-01-18 10:00:00] Starting deployment DEP-2026-049
[2026-01-18 10:00:00] Environment: QA
[2026-01-18 10:00:00] Release: v1.2.5
[2026-01-18 10:00:00] Agent: qa-agent-02
[2026-01-18 10:00:00] ----------------------------------------
[2026-01-18 10:00:00] [STEP: Fetch Bundle]
[2026-01-18 10:00:01] Fetching bundle sha256:7aa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9
[2026-01-18 10:00:05] Bundle downloaded successfully (4.2 MB)
[2026-01-18 10:00:05] ----------------------------------------
[2026-01-18 10:00:05] [STEP: Generate Lock]
[2026-01-18 10:00:05] Generating lock file from bundle...
[2026-01-18 10:00:08] Lock file generated: compose.stella.lock.yml
[2026-01-18 10:00:08] ----------------------------------------
[2026-01-18 10:00:08] [STEP: Deploy Containers]
[2026-01-18 10:00:08] Starting deployment to 4 targets
[2026-01-18 10:00:10] Deploying to qa-web-01...
[2026-01-18 10:00:38] qa-web-01: Container started
[2026-01-18 10:00:40] Deploying to qa-web-02...
[2026-01-18 10:01:10] qa-web-02: Container started
[2026-01-18 10:01:12] Deploying to qa-api-01...
[2026-01-18 10:01:33] qa-api-01: Container started
[2026-01-18 10:01:35] Deploying to qa-worker-01...
[2026-01-18 10:01:50] qa-worker-01: Container started
[2026-01-18 10:01:53] All containers deployed successfully
[2026-01-18 10:01:53] ----------------------------------------
[2026-01-18 10:01:53] [STEP: Verify Deployment]
[2026-01-18 10:01:53] Running health checks...
[2026-01-18 10:01:55] qa-web-01: healthy
[2026-01-18 10:01:57] qa-web-02: healthy
[2026-01-18 10:01:59] qa-api-01: healthy
[2026-01-18 10:02:01] qa-worker-01: healthy
[2026-01-18 10:02:08] All health checks passed
[2026-01-18 10:02:08] ----------------------------------------
[2026-01-18 10:02:08] [STEP: Seal Evidence]
[2026-01-18 10:02:08] Collecting deployment evidence...
[2026-01-18 10:02:10] Signing evidence with key: prod-signer-001
[2026-01-18 10:02:14] Writing to Rekor transparency log...
[2026-01-18 10:02:15] Evidence sealed successfully
[2026-01-18 10:02:15] Evidence ID: evd-2026-049
[2026-01-18 10:02:15] ----------------------------------------
[2026-01-18 10:02:15] Deployment completed successfully
[2026-01-18 10:02:15] Duration: 2m 15s`;
// Computed: filtered logs
filteredLogs = computed(() => {
let logs = this.fullLogs;
const step = this.selectedLogStep();
const query = this.logSearchQuery().toLowerCase();
// Filter by step
if (step !== 'all') {
const stepData = this.workflowSteps().find(s => s.id === step);
if (stepData?.logs) {
logs = stepData.logs;
}
}
// Search filter - highlight matches would be nice but for now just filter
if (query) {
logs = logs.split('\n').filter(line => line.toLowerCase().includes(query)).join('\n');
}
return logs;
});
ngOnInit(): void {
this.route.params.subscribe(params => {
this.deploymentId.set(params['deploymentId'] || params['deployId'] || '');
this.deployment.update(d => ({ ...d, id: params['deploymentId'] || params['deployId'] || d.id }));
});
}
ngAfterViewChecked(): void {
if (this.autoScroll() && this.logViewerRef) {
const el = this.logViewerRef.nativeElement;
el.scrollTop = el.scrollHeight;
}
}
setTab(tabId: string): void {
this.activeTab.set(tabId);
}
// DEP-003: Workflow methods
selectStep(stepId: string): void {
this.selectedStep.set(this.selectedStep() === stepId ? null : stepId);
}
getSelectedStepData(): WorkflowStep | undefined {
return this.workflowSteps().find(s => s.id === this.selectedStep());
}
getCompletedSteps(): number {
return this.workflowSteps().filter(s => s.status === 'complete').length;
}
// DEP-004: Artifact methods
copyHash(hash: string): void {
navigator.clipboard.writeText(hash);
console.log('Copied hash:', hash);
}
viewArtifact(artifact: DeploymentArtifact): void {
console.log('View artifact:', artifact.name);
}
downloadArtifact(artifact: DeploymentArtifact): void {
console.log('Download artifact:', artifact.name);
}
// DEP-005: Logs methods
selectLogStep(stepId: string): void {
this.selectedLogStep.set(stepId);
}
searchLogs(query: string): void {
this.logSearchQuery.set(query);
}
toggleAutoScroll(): void {
this.autoScroll.set(!this.autoScroll());
}
downloadLogs(): void {
console.log('Download logs');
}
getLogLineCount(): number {
return this.filteredLogs().split('\n').length;
}
getMatchCount(): number {
const query = this.logSearchQuery().toLowerCase();
if (!query) return 0;
return (this.fullLogs.toLowerCase().match(new RegExp(query, 'g')) || []).length;
}
// Header actions
openEvidence(): void {
console.log('Open evidence:', this.deployment().evidenceId);
}
rollback(): void {
console.log('Rollback deployment:', this.deployment().id);
}
replayVerify(): void {
console.log('Replay verify deployment:', this.deployment().id);
}
}

View File

@@ -0,0 +1,128 @@
/**
* Deployments List Page Component
* Sprint: SPRINT_20260118_008_FE_environments_deployments (DEP-004)
*/
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
interface Deployment {
id: string;
releaseVersion: string;
environment: string;
status: 'running' | 'success' | 'failed' | 'cancelled';
startedAt: string;
duration: string;
initiatedBy: string;
}
@Component({
selector: 'app-deployments-list-page',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="deployments-page">
<header class="page-header">
<div>
<h1 class="page-title">Deployments</h1>
<p class="page-subtitle">Recent deployment runs across all environments</p>
</div>
</header>
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Deployment ID</th>
<th>Release</th>
<th>Environment</th>
<th>Status</th>
<th>Started</th>
<th>Duration</th>
<th>Initiated By</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (deployment of deployments(); track deployment.id) {
<tr>
<td>
<a [routerLink]="['./', deployment.id]" class="deployment-link">
{{ deployment.id }}
</a>
</td>
<td>
<a [routerLink]="['/releases', deployment.releaseVersion]">{{ deployment.releaseVersion }}</a>
</td>
<td>{{ deployment.environment }}</td>
<td>
<span class="status-badge" [class]="'status-badge--' + deployment.status">
@if (deployment.status === 'running') {
<span class="spinner"></span>
}
{{ deployment.status | uppercase }}
</span>
</td>
<td>{{ deployment.startedAt }}</td>
<td>{{ deployment.duration }}</td>
<td>{{ deployment.initiatedBy }}</td>
<td>
<a [routerLink]="['./', deployment.id]" class="btn btn--sm">View</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
`,
styles: [`
.deployments-page { max-width: 1400px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.page-title { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: 600; }
.page-subtitle { margin: 0; color: var(--text-color-secondary, #64748b); }
.table-container {
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
overflow: hidden;
}
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--surface-border, #e2e8f0); }
.data-table th { background: var(--surface-ground, #f8fafc); font-size: 0.75rem; font-weight: 600; color: var(--text-color-secondary, #64748b); text-transform: uppercase; }
.data-table tbody tr:hover { background: var(--surface-hover, #f8fafc); }
.data-table a { color: var(--primary-color, #3b82f6); text-decoration: none; }
.deployment-link { font-family: ui-monospace, SFMono-Regular, monospace; font-weight: 500; }
.status-badge { display: inline-flex; align-items: center; gap: 0.375rem; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.625rem; font-weight: 600; }
.status-badge--running { background: var(--blue-100, #dbeafe); color: var(--blue-700, #1d4ed8); }
.status-badge--success { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.status-badge--failed { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
.status-badge--cancelled { background: var(--gray-100, #f3f4f6); color: var(--gray-600, #4b5563); }
.spinner {
width: 10px;
height: 10px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.btn { padding: 0.25rem 0.5rem; border-radius: 6px; font-size: 0.75rem; font-weight: 500; text-decoration: none; background: var(--surface-ground, #f8fafc); border: 1px solid var(--surface-border, #e2e8f0); color: var(--text-color, #1e293b); }
`]
})
export class DeploymentsListPageComponent {
deployments = signal<Deployment[]>([
{ id: 'DEP-2026-050', releaseVersion: 'v1.3.0', environment: 'Dev', status: 'running', startedAt: '5m ago', duration: '—', initiatedBy: 'CI' },
{ id: 'DEP-2026-049', releaseVersion: 'v1.2.5', environment: 'QA', status: 'success', startedAt: '2h ago', duration: '2m 15s', initiatedBy: 'user1' },
{ id: 'DEP-2026-048', releaseVersion: 'v1.2.4', environment: 'Staging', status: 'success', startedAt: '6h ago', duration: '3m 42s', initiatedBy: 'user1' },
{ id: 'DEP-2026-047', releaseVersion: 'v1.2.3', environment: 'Prod', status: 'success', startedAt: '1d ago', duration: '5m 18s', initiatedBy: 'admin1' },
{ id: 'DEP-2026-046', releaseVersion: 'v1.2.2', environment: 'Staging', status: 'failed', startedAt: '2d ago', duration: '1m 05s', initiatedBy: 'user2' },
]);
}

View File

@@ -0,0 +1,21 @@
/**
* Deployments Routes
* Sprint: SPRINT_20260118_008_FE_environments_deployments
*/
import { Routes } from '@angular/router';
export const DEPLOYMENTS_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./deployments-list-page.component').then(m => m.DeploymentsListPageComponent),
data: { breadcrumb: 'Deployments' },
},
{
path: ':deploymentId',
loadComponent: () =>
import('./deployment-detail-page.component').then(m => m.DeploymentDetailPageComponent),
data: { breadcrumb: 'Deployment Detail' },
},
];

View File

@@ -0,0 +1,6 @@
/**
* Deployments Feature Module
* Sprint: SPRINT_20260118_008_FE_environments_deployments
*/
export * from './deployments.routes';

View File

@@ -1,72 +1,84 @@
@use '../../../../../../../styles/tokens/breakpoints' as *;
/**
* Check Result Component Styles
* Migrated to design system tokens
*/
.check-result {
border: 1px solid var(--border, #e2e8f0);
border-radius: 8px;
background: white;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
overflow: hidden;
transition: box-shadow 0.15s ease;
transition: box-shadow var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
box-shadow: var(--shadow-sm);
}
&.expanded {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
}
// Severity border indicator
&.severity-pass { border-left: 4px solid var(--success, #22c55e); }
&.severity-info { border-left: 4px solid var(--info, #3b82f6); }
&.severity-warn { border-left: 4px solid var(--warning, #f59e0b); }
&.severity-fail { border-left: 4px solid var(--error, #ef4444); }
&.severity-skip { border-left: 4px solid var(--text-tertiary, #94a3b8); }
&.severity-pass { border-left: 4px solid var(--color-status-success); }
&.severity-info { border-left: 4px solid var(--color-status-info); }
&.severity-warn { border-left: 4px solid var(--color-status-warning); }
&.severity-fail { border-left: 4px solid var(--color-status-error); }
&.severity-skip { border-left: 4px solid var(--color-text-muted); }
}
.result-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
gap: var(--space-4);
padding: var(--space-4);
cursor: pointer;
transition: background 0.15s ease;
transition: background var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: var(--bg-hover, #f8fafc);
background: var(--color-surface-secondary);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: -2px;
}
}
.result-icon {
font-size: 1.25rem;
font-size: var(--font-size-xl);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border-radius: var(--radius-full);
flex-shrink: 0;
.severity-pass & {
background: var(--success-bg, #dcfce7);
color: var(--success, #22c55e);
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
.severity-info & {
background: var(--info-bg, #dbeafe);
color: var(--info, #3b82f6);
background: var(--color-status-info-bg);
color: var(--color-status-info);
}
.severity-warn & {
background: var(--warning-bg, #fef3c7);
color: var(--warning, #f59e0b);
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
.severity-fail & {
background: var(--error-bg, #fee2e2);
color: var(--error, #ef4444);
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
.severity-skip & {
background: var(--bg-tertiary, #f1f5f9);
color: var(--text-tertiary, #94a3b8);
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
}
}
@@ -78,54 +90,54 @@
.result-title {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
gap: var(--space-2);
margin-bottom: var(--space-1);
.check-id {
font-family: monospace;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #1a1a2e);
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
}
.severity-badge {
font-size: 0.6875rem;
font-weight: 600;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
padding: 0.125rem 0.375rem;
border-radius: 4px;
padding: var(--space-0-5) var(--space-1-5);
border-radius: var(--radius-sm);
letter-spacing: 0.025em;
&.severity-pass {
background: var(--success-bg, #dcfce7);
color: var(--success-dark, #15803d);
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.severity-info {
background: var(--info-bg, #dbeafe);
color: var(--info-dark, #1d4ed8);
background: var(--color-status-info-bg);
color: var(--color-status-info);
}
&.severity-warn {
background: var(--warning-bg, #fef3c7);
color: var(--warning-dark, #b45309);
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
&.severity-fail {
background: var(--error-bg, #fee2e2);
color: var(--error-dark, #b91c1c);
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
&.severity-skip {
background: var(--bg-tertiary, #f1f5f9);
color: var(--text-tertiary, #64748b);
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
}
}
.result-diagnosis {
font-size: 0.875rem;
color: var(--text-secondary, #64748b);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -139,29 +151,29 @@
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
gap: var(--space-1);
flex-shrink: 0;
}
.category-badge {
font-size: 0.6875rem;
font-weight: 500;
color: var(--text-secondary, #64748b);
background: var(--bg-tertiary, #f1f5f9);
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
background: var(--color-surface-tertiary);
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-sm);
}
.duration {
font-family: monospace;
font-size: 0.75rem;
color: var(--text-tertiary, #94a3b8);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.result-actions {
display: flex;
align-items: center;
gap: 0.5rem;
gap: var(--space-2);
flex-shrink: 0;
}
@@ -170,57 +182,62 @@
height: 28px;
border: none;
background: transparent;
border-radius: 4px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 1rem;
color: var(--text-secondary, #64748b);
font-size: var(--font-size-base);
color: var(--color-text-secondary);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: var(--bg-hover, #f1f5f9);
color: var(--primary, #3b82f6);
background: var(--color-surface-secondary);
color: var(--color-brand-primary);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
}
.expand-indicator {
font-size: 0.75rem;
color: var(--text-tertiary, #94a3b8);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
// Details
.result-details {
padding: 1rem 1rem 1rem 3.5rem;
border-top: 1px solid var(--border, #e2e8f0);
background: var(--bg-secondary, #fafafa);
padding: var(--space-4) var(--space-4) var(--space-4) var(--space-14);
border-top: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
}
.likely-causes {
margin: 1rem 0;
margin: var(--space-4) 0;
h4 {
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #1a1a2e);
margin: 0 0 var(--space-2) 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
ol {
margin: 0;
padding-left: 1.25rem;
padding-left: var(--space-5);
li {
font-size: 0.875rem;
color: var(--text-secondary, #64748b);
margin-bottom: 0.25rem;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
margin-bottom: var(--space-1);
}
}
}
// Responsive
@media (max-width: 640px) {
@include screen-below-sm {
.result-header {
flex-wrap: wrap;
}
@@ -234,18 +251,38 @@
flex-direction: row;
order: 2;
width: 100%;
margin-top: 0.5rem;
margin-top: var(--space-2);
justify-content: flex-start;
gap: 0.5rem;
gap: var(--space-2);
}
.result-actions {
position: absolute;
right: 1rem;
top: 1rem;
right: var(--space-4);
top: var(--space-4);
}
.result-details {
padding-left: 1rem;
padding-left: var(--space-4);
}
}
/* High contrast mode */
@media (prefers-contrast: high) {
.check-result {
border-left-width: 6px;
}
.severity-badge {
border: 1px solid currentColor;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.check-result,
.result-header,
.btn-icon-small {
transition: none;
}
}

View File

@@ -1,5 +1,7 @@
@use '../../../styles/tokens/breakpoints' as *;
.doctor-dashboard {
padding: 1.5rem;
padding: var(--space-6);
max-width: 1400px;
margin: 0 auto;
}
@@ -9,28 +11,28 @@
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
margin-bottom: var(--space-6);
flex-wrap: wrap;
gap: 1rem;
gap: var(--space-4);
.header-content {
h1 {
margin: 0 0 0.25rem 0;
font-size: 1.75rem;
font-weight: 600;
color: var(--text-primary, #1a1a2e);
margin: 0 0 var(--space-1) 0;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.subtitle {
margin: 0;
color: var(--text-secondary, #64748b);
font-size: 0.875rem;
color: var(--color-text-secondary);
font-size: var(--font-size-base);
}
}
.header-actions {
display: flex;
gap: 0.5rem;
gap: var(--space-2);
flex-wrap: wrap;
}
}
@@ -39,13 +41,13 @@
.btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
gap: var(--space-1-5);
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all 0.15s ease;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
border: 1px solid transparent;
&:disabled {
@@ -54,55 +56,55 @@
}
.btn-icon {
font-size: 1rem;
font-size: var(--font-size-md);
}
}
.btn-primary {
background: var(--primary, #3b82f6);
color: white;
border-color: var(--primary, #3b82f6);
background: var(--color-brand-primary);
color: var(--color-text-inverse);
border-color: var(--color-brand-primary);
&:hover:not(:disabled) {
background: var(--primary-dark, #2563eb);
background: var(--color-brand-primary-hover);
}
}
.btn-secondary {
background: var(--secondary, #6366f1);
color: white;
border-color: var(--secondary, #6366f1);
background: var(--color-status-info);
color: var(--color-text-inverse);
border-color: var(--color-status-info);
&:hover:not(:disabled) {
background: var(--secondary-dark, #4f46e5);
filter: brightness(0.9);
}
}
.btn-outline {
background: transparent;
color: var(--text-primary, #1a1a2e);
border-color: var(--border, #e2e8f0);
color: var(--color-text-primary);
border-color: var(--color-border-primary);
&:hover:not(:disabled) {
background: var(--bg-hover, #f1f5f9);
background: var(--color-surface-secondary);
}
}
.btn-ghost {
background: transparent;
color: var(--text-secondary, #64748b);
color: var(--color-text-secondary);
border: none;
&:hover:not(:disabled) {
background: var(--bg-hover, #f1f5f9);
color: var(--text-primary, #1a1a2e);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
}
}
.btn-link {
background: none;
border: none;
color: var(--primary, #3b82f6);
color: var(--color-brand-primary);
padding: 0;
cursor: pointer;
@@ -113,23 +115,23 @@
// Progress
.progress-container {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--bg-secondary, #f8fafc);
border-radius: 8px;
margin-bottom: var(--space-6);
padding: var(--space-4);
background: var(--color-surface-secondary);
border-radius: var(--radius-lg);
}
.progress-bar {
height: 8px;
background: var(--bg-tertiary, #e2e8f0);
border-radius: 4px;
background: var(--color-surface-tertiary);
border-radius: var(--radius-sm);
overflow: hidden;
margin-bottom: 0.5rem;
margin-bottom: var(--space-2);
}
.progress-fill {
height: 100%;
background: var(--primary, #3b82f6);
background: var(--color-brand-primary);
transition: width 0.3s ease;
}
@@ -137,17 +139,17 @@
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
font-size: var(--font-size-base);
.progress-text {
color: var(--text-primary, #1a1a2e);
font-weight: 500;
color: var(--color-text-primary);
font-weight: var(--font-weight-medium);
}
.progress-current {
color: var(--text-secondary, #64748b);
font-family: monospace;
font-size: 0.8125rem;
color: var(--color-text-secondary);
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
}
}
@@ -155,29 +157,29 @@
.error-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
background: var(--error-bg, #fef2f2);
border: 1px solid var(--error-border, #fecaca);
border-radius: 8px;
margin-bottom: 1.5rem;
gap: var(--space-3);
padding: var(--space-4);
background: var(--color-status-error-bg);
border: 1px solid var(--color-status-error);
border-radius: var(--radius-lg);
margin-bottom: var(--space-6);
.error-icon {
font-size: 1.25rem;
color: var(--error, #ef4444);
font-size: var(--font-size-xl);
color: var(--color-status-error);
}
.error-message {
flex: 1;
color: var(--error-text, #991b1b);
color: var(--color-status-error);
}
.error-dismiss {
background: transparent;
border: none;
color: var(--error, #ef4444);
color: var(--color-status-error);
cursor: pointer;
font-size: 0.875rem;
font-size: var(--font-size-base);
&:hover {
text-decoration: underline;
@@ -187,28 +189,28 @@
// Packs
.pack-section {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--bg-secondary, #f8fafc);
border-radius: 8px;
margin-bottom: var(--space-6);
padding: var(--space-4);
background: var(--color-surface-secondary);
border-radius: var(--radius-lg);
.section-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 1rem;
gap: 1rem;
margin-bottom: var(--space-4);
gap: var(--space-4);
h2 {
margin: 0;
font-size: 1.125rem;
color: var(--text-primary, #1a1a2e);
font-size: var(--font-size-lg);
color: var(--color-text-primary);
}
p {
margin: 0;
font-size: 0.8125rem;
color: var(--text-secondary, #64748b);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
}
}
@@ -216,61 +218,61 @@
.pack-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
gap: var(--space-4);
}
.pack-card {
background: white;
border: 1px solid var(--border, #e2e8f0);
border-radius: 8px;
padding: 0.75rem;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: var(--space-3);
}
.pack-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
margin-bottom: var(--space-3);
h3 {
margin: 0;
font-size: 0.9375rem;
color: var(--text-primary, #1a1a2e);
font-size: var(--font-size-md);
color: var(--color-text-primary);
}
.pack-meta {
font-size: 0.75rem;
color: var(--text-secondary, #64748b);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
}
.plugin-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: var(--space-3);
}
.plugin-card {
border: 1px solid var(--border, #e2e8f0);
border-radius: 6px;
padding: 0.75rem;
background: var(--bg-surface, #ffffff);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
padding: var(--space-3);
background: var(--color-surface-primary);
}
.plugin-header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
gap: var(--space-3);
.plugin-name {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-primary, #1a1a2e);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-base);
color: var(--color-text-primary);
}
.plugin-meta {
font-size: 0.75rem;
color: var(--text-secondary, #64748b);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
word-break: break-all;
}
@@ -278,111 +280,113 @@
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.125rem;
font-size: 0.75rem;
color: var(--text-secondary, #64748b);
gap: var(--space-0-5);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.plugin-version {
font-size: 0.6875rem;
color: var(--text-tertiary, #94a3b8);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
}
.plugin-checks {
list-style: none;
padding: 0;
margin: 0.5rem 0 0 0;
margin: var(--space-2) 0 0 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: var(--space-1);
li {
padding: 0.25rem 0.5rem;
background: var(--bg-hover, #f1f5f9);
border-radius: 4px;
font-family: monospace;
font-size: 0.8125rem;
padding: var(--space-1) var(--space-2);
background: var(--color-surface-secondary);
border-radius: var(--radius-sm);
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
}
}
.plugin-empty {
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--text-tertiary, #94a3b8);
margin-top: var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// Filters
.filters-container {
display: flex;
gap: 1rem;
gap: var(--space-4);
align-items: flex-end;
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--bg-secondary, #f8fafc);
border-radius: 8px;
margin-bottom: var(--space-6);
padding: var(--space-4);
background: var(--color-surface-secondary);
border-radius: var(--radius-lg);
flex-wrap: wrap;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
gap: var(--space-1-5);
label {
font-size: 0.75rem;
font-weight: 500;
color: var(--text-secondary, #64748b);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.025em;
}
}
.filter-select {
padding: 0.5rem 2rem 0.5rem 0.75rem;
border: 1px solid var(--border, #e2e8f0);
border-radius: 6px;
font-size: 0.875rem;
background: white;
padding: var(--space-2) var(--space-8) var(--space-2) var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
background: var(--color-surface-primary);
color: var(--color-text-primary);
cursor: pointer;
min-width: 150px;
&:focus {
outline: none;
border-color: var(--primary, #3b82f6);
box-shadow: 0 0 0 3px var(--primary-light, rgba(59, 130, 246, 0.1));
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 3px var(--color-brand-light);
}
}
.severity-filters {
.severity-checkboxes {
display: flex;
gap: 0.75rem;
gap: var(--space-3);
}
}
.severity-checkbox {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
gap: var(--space-1-5);
font-size: var(--font-size-base);
cursor: pointer;
padding: 0.375rem 0.625rem;
border-radius: 4px;
transition: background 0.15s ease;
padding: var(--space-1-5) var(--space-2-5);
border-radius: var(--radius-sm);
transition: background var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: var(--bg-hover, #f1f5f9);
background: var(--color-surface-tertiary);
}
input {
margin: 0;
accent-color: var(--color-brand-primary);
}
&.severity-fail span { color: var(--error, #ef4444); }
&.severity-warn span { color: var(--warning, #f59e0b); }
&.severity-pass span { color: var(--success, #22c55e); }
&.severity-info span { color: var(--info, #3b82f6); }
&.severity-fail span { color: var(--color-status-error); }
&.severity-warn span { color: var(--color-status-warning); }
&.severity-pass span { color: var(--color-status-success); }
&.severity-info span { color: var(--color-status-info); }
}
.search-group {
@@ -392,19 +396,21 @@
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border, #e2e8f0);
border-radius: 6px;
font-size: 0.875rem;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
background: var(--color-surface-primary);
color: var(--color-text-primary);
&:focus {
outline: none;
border-color: var(--primary, #3b82f6);
box-shadow: 0 0 0 3px var(--primary-light, rgba(59, 130, 246, 0.1));
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 3px var(--color-brand-light);
}
&::placeholder {
color: var(--text-tertiary, #94a3b8);
color: var(--color-text-muted);
}
}
@@ -421,18 +427,18 @@
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
margin-bottom: var(--space-4);
.results-count {
font-size: 0.875rem;
color: var(--text-secondary, #64748b);
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}
}
.results-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: var(--space-3);
}
// Empty state
@@ -441,40 +447,44 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 4rem 2rem;
padding: var(--space-16) var(--space-8);
text-align: center;
.empty-icon {
font-size: 4rem;
margin-bottom: 1rem;
margin-bottom: var(--space-4);
opacity: 0.5;
}
h3 {
margin: 0 0 0.5rem 0;
font-size: 1.25rem;
color: var(--text-primary, #1a1a2e);
margin: 0 0 var(--space-2) 0;
font-size: var(--font-size-xl);
color: var(--color-text-primary);
}
p {
margin: 0;
color: var(--text-secondary, #64748b);
color: var(--color-text-secondary);
max-width: 400px;
}
}
.no-results {
text-align: center;
padding: 2rem;
color: var(--text-secondary, #64748b);
padding: var(--space-8);
color: var(--color-text-secondary);
p {
margin: 0 0 0.5rem 0;
margin: 0 0 var(--space-2) 0;
}
}
// Responsive
@media (max-width: 768px) {
@include screen-below-md {
.doctor-dashboard {
padding: var(--space-4);
}
.dashboard-header {
flex-direction: column;
align-items: stretch;

View File

@@ -0,0 +1,703 @@
/**
* Environment Detail Page Component
* Sprint: SPRINT_20260118_008_FE_environments_deployments (ENV-002, ENV-003)
*
* Environment detail with Overview showing release history, risk snapshot, and targets preview.
*/
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
interface ReleaseHistoryEntry {
version: string;
deployedAt: string;
isCurrent: boolean;
}
interface GateSummary {
name: string;
status: 'PASS' | 'WARN' | 'BLOCK';
}
@Component({
selector: 'app-environment-detail-page',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="env-detail">
<header class="page-header">
<a routerLink="/environments" class="back-link">← Back to Environments</a>
<div class="header-main">
<div class="header-title-row">
<h1 class="page-title">{{ env().name }}</h1>
<span class="stage-badge">{{ env().stage }}</span>
@if (env().freezeStatus === 'frozen') {
<span class="freeze-badge">Frozen</span>
}
</div>
<div class="header-meta">
<span class="meta-item">Current: <strong>{{ env().currentRelease }}</strong></span>
<span class="meta-item">Policy: <strong>{{ env().policyBaseline }}</strong></span>
</div>
</div>
<div class="header-actions">
<button type="button" class="btn btn--primary" (click)="requestPromotion()">
Request Promotion
</button>
<button type="button" class="btn btn--secondary" (click)="openEvidence()">
Open Evidence
</button>
</div>
</header>
<!-- Tabs -->
<nav class="tabs">
@for (tab of tabs; track tab.id) {
<button
type="button"
class="tab"
[class.tab--active]="activeTab() === tab.id"
(click)="setTab(tab.id)"
>
{{ tab.label }}
</button>
}
</nav>
<div class="tab-content">
@switch (activeTab()) {
@case ('overview') {
<!-- ENV-003: Overview Tab with Release History, Risk Snapshot, Targets Preview -->
<div class="overview-layout">
<div class="overview-main">
<!-- Release History (ledger style) -->
<section class="panel">
<div class="panel-header">
<h3>Release History</h3>
<a routerLink="./promotions" class="panel-link">View All Promotions</a>
</div>
<div class="release-timeline">
@for (entry of releaseHistory(); track entry.version; let i = $index) {
<div class="timeline-entry" [class.timeline-entry--current]="entry.isCurrent">
<div class="timeline-node"></div>
@if (i < releaseHistory().length - 1) {
<div class="timeline-line"></div>
}
<div class="timeline-content">
<a [routerLink]="['/releases', entry.version]" class="timeline-version">
{{ entry.version }}
</a>
@if (entry.isCurrent) {
<span class="current-badge">current</span>
}
<span class="timeline-date">{{ entry.deployedAt }}</span>
</div>
</div>
}
</div>
<div class="last-promotion">
Last promotion: {{ env().lastDeployment }}
<a routerLink="./evidence" class="promotion-link">Open Proof Chain</a>
</div>
</section>
<!-- Current Risk Snapshot -->
<section class="panel">
<div class="panel-header">
<h3>Current Risk Snapshot</h3>
<a [routerLink]="['/security/findings']" [queryParams]="{env: env().name}" class="panel-link">
Open Findings
</a>
</div>
<div class="risk-content">
<!-- Gate Summary -->
<div class="risk-row">
<span class="risk-label">Gate Summary</span>
<div class="gate-badges">
@for (gate of gateSummary(); track gate.name) {
<span class="gate-badge" [class]="'gate-badge--' + gate.status.toLowerCase()">
{{ gate.name }}: {{ gate.status }}
</span>
}
</div>
</div>
<!-- Reachability Coverage -->
<div class="risk-row">
<span class="risk-label">Reachability Coverage</span>
<div class="coverage-display">
<div class="coverage-bar">
<div class="coverage-bar__fill" [style.width.%]="env().reachabilityCoverage"></div>
</div>
<span class="coverage-percent">{{ env().reachabilityCoverage }}%</span>
</div>
</div>
<!-- Drift Status -->
<div class="risk-row">
<span class="risk-label">Drift Status</span>
<span class="drift-badge" [class]="'drift-badge--' + env().driftStatus">
{{ env().driftStatus }}
</span>
</div>
<!-- Active Findings -->
<div class="risk-row">
<span class="risk-label">Active Findings</span>
<div class="findings-counts">
<span class="finding-count finding-count--critical">{{ findings().critical }} Critical</span>
<span class="finding-count finding-count--high">{{ findings().high }} High</span>
<span class="finding-count finding-count--reachable">{{ findings().reachable }} Reachable</span>
</div>
</div>
</div>
</section>
</div>
<div class="overview-sidebar">
<!-- Targets Preview (quick view) -->
<section class="panel">
<div class="panel-header">
<h3>Targets</h3>
<a (click)="setTab('targets')" class="panel-link">View All ({{ env().targetCount }})</a>
</div>
<div class="target-health-summary">
<div class="health-circle" [class]="'health-circle--' + getHealthStatus()">
<span class="health-number">{{ env().healthyTargets }}/{{ env().targetCount }}</span>
<span class="health-label">healthy</span>
</div>
</div>
<div class="targets-preview">
@for (target of targets.slice(0, 5); track target.id) {
<div class="target-preview-item">
<span class="status-dot" [class]="'status-dot--' + target.status"></span>
<span class="target-name">{{ target.name }}</span>
<span class="target-version">{{ target.version }}</span>
</div>
}
@if (targets.length > 5) {
<div class="targets-more">
+{{ targets.length - 5 }} more targets
</div>
}
</div>
</section>
<!-- Quick Stats -->
<section class="panel panel--compact">
<h3>Quick Stats</h3>
<div class="quick-stats">
<div class="stat-item">
<span class="stat-value">{{ env().deploymentsToday }}</span>
<span class="stat-label">Deployments Today</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ env().avgDeployTime }}</span>
<span class="stat-label">Avg Deploy Time</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ env().uptime }}</span>
<span class="stat-label">Uptime</span>
</div>
</div>
</section>
</div>
</div>
}
@case ('targets') {
<section class="panel">
<h3>Deployment Targets</h3>
<table class="data-table">
<thead>
<tr>
<th>Target</th>
<th>Type</th>
<th>Version</th>
<th>Status</th>
<th>Last Check</th>
</tr>
</thead>
<tbody>
@for (target of targets; track target.id) {
<tr>
<td>{{ target.name }}</td>
<td>{{ target.type }}</td>
<td>{{ target.version }}</td>
<td>
<span class="status-dot" [class]="'status-dot--' + target.status"></span>
{{ target.status }}
</td>
<td>{{ target.lastCheck }}</td>
</tr>
}
</tbody>
</table>
</section>
}
@case ('promotions') {
<!-- ENV-003: Promotions tab showing promotion history -->
<section class="panel">
<h3>Promotion History</h3>
<p class="panel-subtitle">Releases promoted to this environment</p>
<table class="data-table">
<thead>
<tr>
<th>Release</th>
<th>From</th>
<th>Status</th>
<th>Gates</th>
<th>Promoted By</th>
<th>Date</th>
</tr>
</thead>
<tbody>
@for (promo of promotionHistory; track promo.id) {
<tr>
<td><a [routerLink]="['/releases', promo.releaseId]">{{ promo.version }}</a></td>
<td>{{ promo.fromEnv }}</td>
<td>
<span class="status-badge" [class]="'status-badge--' + promo.status">
{{ promo.status }}
</span>
</td>
<td>
@for (gate of promo.gates; track gate.name) {
<span class="gate-chip" [class]="'gate-chip--' + gate.status.toLowerCase()">
{{ gate.status }}
</span>
}
</td>
<td>{{ promo.promotedBy }}</td>
<td>{{ promo.promotedAt }}</td>
</tr>
}
</tbody>
</table>
</section>
}
@case ('deployments') {
<!-- ENV-004: Deployments tab showing deployment runs -->
<section class="panel">
<h3>Deployment History</h3>
<p class="panel-subtitle">Deployment runs to targets in this environment</p>
<table class="data-table">
<thead>
<tr>
<th>Deployment ID</th>
<th>Release</th>
<th>Status</th>
<th>Duration</th>
<th>Targets</th>
<th>Time</th>
<th>Evidence</th>
</tr>
</thead>
<tbody>
@for (deployment of deploymentHistory; track deployment.id) {
<tr>
<td><a [routerLink]="['/deployments', deployment.id]">{{ deployment.id }}</a></td>
<td>{{ deployment.release }}</td>
<td>
<span class="status-badge" [class]="'status-badge--' + deployment.status">
{{ deployment.status }}
</span>
</td>
<td>{{ deployment.duration }}</td>
<td>{{ deployment.targetCount }}/{{ deployment.successfulTargets }}</td>
<td>{{ deployment.completedAt }}</td>
<td>
<a [routerLink]="['/evidence', deployment.evidenceId]" class="evidence-link">
View
</a>
</td>
</tr>
}
</tbody>
</table>
</section>
}
@case ('drift') {
<section class="panel">
<h3>Drift Detection</h3>
@if (env().driftStatus === 'drifted') {
<div class="drift-alert">
<p>⚠️ Configuration drift detected. Some targets have diverged from expected state.</p>
<button type="button" class="btn btn--primary" (click)="reconcile()">Reconcile</button>
</div>
} @else {
<p class="no-drift">✓ All targets are in sync with expected state.</p>
}
</section>
}
@case ('evidence') {
<!-- ENV-005: Evidence tab showing environment evidence packets -->
<section class="panel">
<h3>Environment Evidence</h3>
<p class="panel-subtitle">Evidence packets linked to this environment</p>
<div class="evidence-grid">
@for (evidence of environmentEvidence; track evidence.id) {
<div class="evidence-card">
<div class="evidence-card-header">
<span class="evidence-type-badge">{{ evidence.type }}</span>
<div class="evidence-badges">
@if (evidence.signed) {
<span class="badge badge--signed">Signed</span>
}
@if (evidence.verified) {
<span class="badge badge--verified">Verified</span>
}
</div>
</div>
<div class="evidence-card-body">
<h4>{{ evidence.subject }}</h4>
<p class="evidence-meta">{{ evidence.createdAt }}</p>
</div>
<div class="evidence-card-footer">
<a [routerLink]="['/evidence', evidence.id]" class="btn btn--sm">View</a>
<button type="button" class="btn btn--sm btn--secondary" (click)="downloadEvidence(evidence.id)">Download</button>
</div>
</div>
}
</div>
</section>
}
}
</div>
</div>
`,
styles: [`
.env-detail { max-width: 1200px; margin: 0 auto; }
/* Header */
.page-header { margin-bottom: 1.5rem; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 1rem; }
.back-link { display: inline-block; margin-bottom: 0.5rem; font-size: 0.875rem; color: var(--primary-color, #3b82f6); text-decoration: none; width: 100%; }
.header-main { flex: 1; }
.header-title-row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
.page-title { margin: 0; font-size: 1.5rem; font-weight: 600; }
.stage-badge { padding: 0.25rem 0.75rem; background: var(--surface-ground, #f1f5f9); border-radius: 4px; font-size: 0.75rem; font-weight: 500; }
.freeze-badge { padding: 0.25rem 0.5rem; background: var(--blue-100, #dbeafe); color: var(--blue-700, #1d4ed8); border-radius: 4px; font-size: 0.625rem; font-weight: 700; text-transform: uppercase; }
.header-meta { display: flex; gap: 1.5rem; color: var(--text-color-secondary, #64748b); font-size: 0.875rem; }
.meta-item strong { color: var(--text-color, #1e293b); }
.header-actions { display: flex; gap: 0.5rem; }
/* Tabs */
.tabs { display: flex; gap: 0.25rem; border-bottom: 1px solid var(--surface-border, #e2e8f0); margin-bottom: 1.5rem; }
.tab { padding: 0.75rem 1rem; background: transparent; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; font-size: 0.875rem; font-weight: 500; color: var(--text-color-secondary, #64748b); cursor: pointer; }
.tab:hover { color: var(--text-color, #1e293b); }
.tab--active { color: var(--primary-color, #3b82f6); border-bottom-color: var(--primary-color, #3b82f6); }
/* Overview Layout */
.overview-layout { display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; }
.overview-main { display: flex; flex-direction: column; gap: 1.5rem; }
.overview-sidebar { display: flex; flex-direction: column; gap: 1.5rem; }
/* Panel */
.panel { padding: 1.25rem; background: var(--surface-card, #ffffff); border: 1px solid var(--surface-border, #e2e8f0); border-radius: 8px; }
.panel--compact { padding: 1rem; }
.panel h3 { margin: 0; font-size: 0.875rem; font-weight: 600; }
.panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.panel-link { font-size: 0.75rem; color: var(--primary-color, #3b82f6); text-decoration: none; cursor: pointer; }
.panel-link:hover { text-decoration: underline; }
/* Release Timeline */
.release-timeline { display: flex; flex-direction: column; gap: 0; margin-bottom: 1rem; }
.timeline-entry { display: flex; align-items: flex-start; gap: 0.75rem; position: relative; padding-bottom: 0.75rem; }
.timeline-node { width: 12px; height: 12px; border-radius: 50%; background: var(--surface-border, #e2e8f0); border: 2px solid var(--surface-card, #ffffff); box-shadow: 0 0 0 2px var(--surface-border, #e2e8f0); flex-shrink: 0; margin-top: 0.25rem; }
.timeline-entry--current .timeline-node { background: var(--primary-color, #3b82f6); box-shadow: 0 0 0 2px var(--primary-color, #3b82f6); }
.timeline-line { position: absolute; left: 5px; top: 14px; bottom: -2px; width: 2px; background: var(--surface-border, #e2e8f0); }
.timeline-content { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
.timeline-version { font-weight: 600; font-size: 0.875rem; color: var(--primary-color, #3b82f6); text-decoration: none; }
.timeline-version:hover { text-decoration: underline; }
.current-badge { font-size: 0.625rem; padding: 0.125rem 0.375rem; background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); border-radius: 4px; font-weight: 600; }
.timeline-date { font-size: 0.75rem; color: var(--text-color-secondary, #94a3b8); }
.last-promotion { font-size: 0.75rem; color: var(--text-color-secondary, #64748b); padding-top: 0.5rem; border-top: 1px solid var(--surface-border, #e2e8f0); display: flex; justify-content: space-between; }
.promotion-link { color: var(--primary-color, #3b82f6); text-decoration: none; }
/* Risk Snapshot */
.risk-content { display: flex; flex-direction: column; gap: 0.75rem; }
.risk-row { display: flex; justify-content: space-between; align-items: center; }
.risk-label { font-size: 0.75rem; color: var(--text-color-secondary, #64748b); }
.gate-badges { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.gate-badge { font-size: 0.625rem; padding: 0.125rem 0.5rem; border-radius: 4px; font-weight: 600; }
.gate-badge--pass { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.gate-badge--warn { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
.gate-badge--block { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
.coverage-display { display: flex; align-items: center; gap: 0.5rem; }
.coverage-bar { width: 100px; height: 6px; background: var(--surface-ground, #e2e8f0); border-radius: 3px; overflow: hidden; }
.coverage-bar__fill { height: 100%; background: var(--green-500, #22c55e); }
.coverage-percent { font-size: 0.875rem; font-weight: 600; }
.findings-counts { display: flex; gap: 0.75rem; }
.finding-count { font-size: 0.75rem; font-weight: 500; }
.finding-count--critical { color: var(--purple-600, #9333ea); }
.finding-count--high { color: var(--red-600, #dc2626); }
.finding-count--reachable { color: var(--yellow-600, #ca8a04); }
/* Targets Preview */
.target-health-summary { display: flex; justify-content: center; margin-bottom: 1rem; }
.health-circle { width: 80px; height: 80px; border-radius: 50%; display: flex; flex-direction: column; align-items: center; justify-content: center; }
.health-circle--healthy { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.health-circle--degraded { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
.health-circle--unhealthy { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
.health-number { font-size: 1.25rem; font-weight: 700; }
.health-label { font-size: 0.625rem; text-transform: uppercase; }
.targets-preview { display: flex; flex-direction: column; gap: 0.5rem; }
.target-preview-item { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8125rem; }
.target-name { flex: 1; font-weight: 500; }
.target-version { font-size: 0.75rem; color: var(--text-color-secondary, #94a3b8); }
.targets-more { font-size: 0.75rem; color: var(--text-color-secondary, #94a3b8); text-align: center; padding-top: 0.5rem; }
/* Quick Stats */
.quick-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; margin-top: 0.75rem; }
.stat-item { text-align: center; }
.stat-value { display: block; font-size: 1rem; font-weight: 700; color: var(--text-color, #1e293b); }
.stat-label { font-size: 0.625rem; color: var(--text-color-secondary, #94a3b8); text-transform: uppercase; }
/* Drift Badge */
.drift-badge { padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 500; text-transform: capitalize; }
.drift-badge--synced { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.drift-badge--drifted { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
/* Data Table */
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--surface-border, #e2e8f0); }
.data-table th { font-size: 0.75rem; font-weight: 600; color: var(--text-color-secondary, #64748b); text-transform: uppercase; }
/* Status Dot */
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.5rem; }
.status-dot--healthy { background: var(--green-500, #22c55e); }
.status-dot--unhealthy { background: var(--red-500, #ef4444); }
.status-dot--unknown { background: var(--gray-400, #9ca3af); }
/* Drift Alert */
.drift-alert { padding: 1rem; background: var(--yellow-50, #fefce8); border: 1px solid var(--yellow-200, #fef08a); border-radius: 8px; }
.drift-alert p { margin: 0 0 1rem; }
.no-drift { margin: 0; color: var(--green-600, #16a34a); }
/* Buttons */
.btn { padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.875rem; font-weight: 500; cursor: pointer; text-decoration: none; }
.btn--primary { background: var(--primary-color, #3b82f6); border: none; color: white; }
.btn--primary:hover { background: var(--primary-color-dark, #2563eb); }
.btn--secondary { background: var(--surface-ground, #f8fafc); border: 1px solid var(--surface-border, #e2e8f0); color: var(--text-color, #1e293b); }
.btn--secondary:hover { background: var(--surface-hover, #f1f5f9); }
.btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
/* Sprint: SPRINT_20260118_008 - Additional styles for new tabs */
/* Panel subtitle */
.panel-subtitle { margin: -0.5rem 0 1rem; font-size: 0.875rem; color: var(--text-color-secondary, #64748b); }
/* Status badge */
.status-badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 500; text-transform: capitalize; }
.status-badge--completed, .status-badge--success { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.status-badge--pending { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
.status-badge--failed { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
/* Gate chip */
.gate-chip { display: inline-block; padding: 0.125rem 0.375rem; margin-right: 0.25rem; border-radius: 3px; font-size: 0.625rem; font-weight: 600; }
.gate-chip--pass { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.gate-chip--warn { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
.gate-chip--block { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
/* Evidence link */
.evidence-link { color: var(--primary-color, #3b82f6); text-decoration: none; font-size: 0.8125rem; }
.evidence-link:hover { text-decoration: underline; }
/* Evidence grid */
.evidence-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
.evidence-card { background: var(--surface-ground, #f8fafc); border: 1px solid var(--surface-border, #e2e8f0); border-radius: 8px; overflow: hidden; }
.evidence-card-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; background: var(--surface-card, #ffffff); border-bottom: 1px solid var(--surface-border, #e2e8f0); }
.evidence-type-badge { font-size: 0.625rem; font-weight: 600; text-transform: uppercase; color: var(--text-color-secondary, #64748b); }
.evidence-badges { display: flex; gap: 0.25rem; }
.badge { padding: 0.125rem 0.375rem; border-radius: 3px; font-size: 0.625rem; font-weight: 600; }
.badge--signed { background: var(--blue-100, #dbeafe); color: var(--blue-700, #1d4ed8); }
.badge--verified { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.evidence-card-body { padding: 1rem; }
.evidence-card-body h4 { margin: 0 0 0.25rem; font-size: 0.875rem; font-weight: 600; }
.evidence-meta { margin: 0; font-size: 0.75rem; color: var(--text-color-secondary, #94a3b8); }
.evidence-card-footer { display: flex; gap: 0.5rem; padding: 0.75rem 1rem; background: var(--surface-card, #ffffff); border-top: 1px solid var(--surface-border, #e2e8f0); }
@media (max-width: 1024px) {
.overview-layout { grid-template-columns: 1fr; }
.quick-stats { grid-template-columns: repeat(3, 1fr); }
}
`]
})
export class EnvironmentDetailPageComponent implements OnInit {
private route = inject(ActivatedRoute);
envId = signal('');
activeTab = signal('overview');
tabs = [
{ id: 'overview', label: 'Overview' },
{ id: 'targets', label: 'Targets' },
{ id: 'promotions', label: 'Promotions' },
{ id: 'deployments', label: 'Deployments' },
{ id: 'drift', label: 'Drift' },
{ id: 'evidence', label: 'Evidence' },
];
env = signal({
name: 'Staging',
stage: 'Staging',
currentRelease: 'v1.2.4',
policyBaseline: 'stg-baseline v3.1',
freezeStatus: 'active' as 'active' | 'frozen',
targetCount: 4,
healthyTargets: 4,
lastDeployment: '6h ago',
driftStatus: 'drifted' as 'synced' | 'drifted',
reachabilityCoverage: 89,
deploymentsToday: 3,
avgDeployTime: '2m 34s',
uptime: '99.9%',
});
// Release history (ledger style)
releaseHistory = signal<ReleaseHistoryEntry[]>([
{ version: 'v1.2.4', deployedAt: '6h ago', isCurrent: true },
{ version: 'v1.2.3', deployedAt: '2 days ago', isCurrent: false },
{ version: 'v1.2.2', deployedAt: '5 days ago', isCurrent: false },
]);
// Gate summary for risk snapshot
gateSummary = signal<GateSummary[]>([
{ name: 'SBOM', status: 'PASS' },
{ name: 'Provenance', status: 'PASS' },
{ name: 'Reachability', status: 'WARN' },
{ name: 'VEX', status: 'PASS' },
]);
// Active findings counts
findings = signal({
critical: 0,
high: 2,
reachable: 1,
});
targets = [
{ id: '1', name: 'staging-web-01', type: 'Container', version: 'v1.2.4', status: 'healthy', lastCheck: '5m ago' },
{ id: '2', name: 'staging-web-02', type: 'Container', version: 'v1.2.4', status: 'healthy', lastCheck: '5m ago' },
{ id: '3', name: 'staging-api-01', type: 'Container', version: 'v1.2.4', status: 'healthy', lastCheck: '5m ago' },
{ id: '4', name: 'staging-worker-01', type: 'Container', version: 'v1.2.4', status: 'healthy', lastCheck: '5m ago' },
];
// Sprint: SPRINT_20260118_008 (ENV-003) - Promotion history data
promotionHistory = [
{
id: 'promo-1',
releaseId: 'rel-v1.2.4',
version: 'v1.2.4',
fromEnv: 'QA',
status: 'completed',
gates: [{ name: 'SBOM', status: 'PASS' as const }, { name: 'Reachability', status: 'PASS' as const }],
promotedBy: 'jane.doe',
promotedAt: '6h ago',
},
{
id: 'promo-2',
releaseId: 'rel-v1.2.3',
version: 'v1.2.3',
fromEnv: 'QA',
status: 'completed',
gates: [{ name: 'SBOM', status: 'PASS' as const }, { name: 'Reachability', status: 'WARN' as const }],
promotedBy: 'ci-pipeline',
promotedAt: '2 days ago',
},
];
// Sprint: SPRINT_20260118_008 (ENV-004) - Deployment history data
deploymentHistory = [
{
id: 'DEP-2026-051',
release: 'v1.2.4',
status: 'success',
duration: '2m 15s',
targetCount: 4,
successfulTargets: 4,
completedAt: '6h ago',
evidenceId: 'EVD-2026-101',
},
{
id: 'DEP-2026-049',
release: 'v1.2.3',
status: 'success',
duration: '2m 08s',
targetCount: 4,
successfulTargets: 4,
completedAt: '2 days ago',
evidenceId: 'EVD-2026-098',
},
{
id: 'DEP-2026-047',
release: 'v1.2.2',
status: 'failed',
duration: '1m 42s',
targetCount: 4,
successfulTargets: 2,
completedAt: '5 days ago',
evidenceId: 'EVD-2026-095',
},
];
// Sprint: SPRINT_20260118_008 (ENV-005) - Environment evidence data
environmentEvidence = [
{
id: 'EVD-2026-101',
type: 'deployment',
subject: 'v1.2.4 → Staging',
signed: true,
verified: true,
createdAt: '6h ago',
},
{
id: 'EVD-2026-098',
type: 'policy',
subject: 'Gate evaluation v1.2.3',
signed: true,
verified: true,
createdAt: '2 days ago',
},
{
id: 'EVD-2026-095',
type: 'scan',
subject: 'Security scan v1.2.2',
signed: true,
verified: false,
createdAt: '5 days ago',
},
];
ngOnInit(): void {
this.route.params.subscribe(params => {
this.envId.set(params['envId'] || '');
});
}
setTab(tabId: string): void {
this.activeTab.set(tabId);
}
getHealthStatus(): 'healthy' | 'degraded' | 'unhealthy' {
const ratio = this.env().healthyTargets / this.env().targetCount;
if (ratio >= 1) return 'healthy';
if (ratio >= 0.5) return 'degraded';
return 'unhealthy';
}
requestPromotion(): void {
console.log('Request promotion for', this.env().name);
}
openEvidence(): void {
console.log('Open evidence for', this.env().name);
}
reconcile(): void {
console.log('Reconcile drift');
}
// Sprint: SPRINT_20260118_008 (ENV-005) - Download evidence
downloadEvidence(evidenceId: string): void {
console.log('Downloading evidence:', evidenceId);
}
}

View File

@@ -0,0 +1,119 @@
/**
* Environments List Page Component
* Sprint: SPRINT_20260118_008_FE_environments_deployments (ENV-001)
*/
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
interface Environment {
id: string;
name: string;
stage: string;
currentRelease: string;
targetCount: number;
healthyTargets: number;
lastDeployment: string;
driftStatus: 'synced' | 'drifted' | 'unknown';
}
@Component({
selector: 'app-environments-list-page',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="environments-page">
<header class="page-header">
<div>
<h1 class="page-title">Environments</h1>
<p class="page-subtitle">Manage deployment targets and environment configuration</p>
</div>
<button type="button" class="btn btn--primary" (click)="createEnvironment()">
+ Create Environment
</button>
</header>
<!-- Environment Cards -->
<div class="env-grid">
@for (env of environments(); track env.id) {
<a class="env-card" [routerLink]="['./', env.id]">
<div class="env-card__header">
<h3>{{ env.name }}</h3>
<span class="stage-badge">{{ env.stage }}</span>
</div>
<div class="env-card__body">
<div class="env-metric">
<span class="metric-label">Current Release</span>
<span class="metric-value">{{ env.currentRelease }}</span>
</div>
<div class="env-metric">
<span class="metric-label">Targets</span>
<span class="metric-value">{{ env.healthyTargets }}/{{ env.targetCount }} healthy</span>
</div>
<div class="env-metric">
<span class="metric-label">Last Deployment</span>
<span class="metric-value">{{ env.lastDeployment }}</span>
</div>
</div>
<div class="env-card__footer">
<span class="drift-badge" [class]="'drift-badge--' + env.driftStatus">
{{ env.driftStatus === 'synced' ? '✓ Synced' : env.driftStatus === 'drifted' ? '⚠ Drifted' : '? Unknown' }}
</span>
</div>
</a>
}
</div>
</div>
`,
styles: [`
.environments-page { max-width: 1400px; margin: 0 auto; }
.page-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.5rem; }
.page-title { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: 600; }
.page-subtitle { margin: 0; color: var(--text-color-secondary, #64748b); }
.env-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
.env-card {
display: block;
padding: 1.25rem;
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.env-card:hover { border-color: var(--primary-color, #3b82f6); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); }
.env-card__header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.env-card__header h3 { margin: 0; font-size: 1rem; font-weight: 600; }
.stage-badge { padding: 0.125rem 0.5rem; background: var(--surface-ground, #f1f5f9); border-radius: 4px; font-size: 0.75rem; font-weight: 500; }
.env-card__body { display: flex; flex-direction: column; gap: 0.75rem; margin-bottom: 1rem; }
.env-metric { display: flex; justify-content: space-between; }
.metric-label { font-size: 0.75rem; color: var(--text-color-secondary, #64748b); }
.metric-value { font-size: 0.875rem; font-weight: 500; }
.drift-badge { padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 500; }
.drift-badge--synced { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.drift-badge--drifted { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
.drift-badge--unknown { background: var(--gray-100, #f3f4f6); color: var(--gray-600, #4b5563); }
.btn { padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.875rem; font-weight: 500; cursor: pointer; }
.btn--primary { background: var(--primary-color, #3b82f6); border: none; color: white; }
`]
})
export class EnvironmentsListPageComponent {
environments = signal<Environment[]>([
{ id: 'dev', name: 'Development', stage: 'Dev', currentRelease: 'v1.3.0', targetCount: 3, healthyTargets: 3, lastDeployment: '30m ago', driftStatus: 'synced' },
{ id: 'qa', name: 'QA', stage: 'QA', currentRelease: 'v1.2.5', targetCount: 5, healthyTargets: 5, lastDeployment: '2h ago', driftStatus: 'synced' },
{ id: 'staging', name: 'Staging', stage: 'Staging', currentRelease: 'v1.2.4', targetCount: 4, healthyTargets: 4, lastDeployment: '6h ago', driftStatus: 'drifted' },
{ id: 'prod', name: 'Production', stage: 'Prod', currentRelease: 'v1.2.3', targetCount: 12, healthyTargets: 11, lastDeployment: '1d ago', driftStatus: 'synced' },
]);
createEnvironment(): void {
console.log('Create environment');
}
}

View File

@@ -0,0 +1,21 @@
/**
* Environments Routes
* Sprint: SPRINT_20260118_008_FE_environments_deployments
*/
import { Routes } from '@angular/router';
export const ENVIRONMENTS_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./environments-list-page.component').then(m => m.EnvironmentsListPageComponent),
data: { breadcrumb: 'Environments' },
},
{
path: ':envId',
loadComponent: () =>
import('./environment-detail-page.component').then(m => m.EnvironmentDetailPageComponent),
data: { breadcrumb: 'Environment Detail' },
},
];

View File

@@ -0,0 +1,6 @@
/**
* Environments Feature Module
* Sprint: SPRINT_20260118_008_FE_environments_deployments
*/
export * from './environments.routes';

View File

@@ -1,12 +1,17 @@
// Evidence Export Dialog Component Styles
@use '../../../../../../../../styles/tokens/breakpoints' as *;
/**
* Evidence Export Dialog Component Styles
* Migrated to design system tokens
*/
h2[mat-dialog-title] {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
mat-icon {
color: var(--mat-sys-primary);
color: var(--color-brand-primary);
}
}
@@ -17,79 +22,84 @@ mat-dialog-content {
// Thread Info
.thread-info {
background: var(--mat-sys-surface-variant);
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 24px;
background: var(--color-surface-tertiary);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-lg);
margin-bottom: var(--space-6);
.info-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
gap: var(--space-2);
margin-bottom: var(--space-1);
&:last-child {
margin-bottom: 0;
}
.label {
font-size: 0.75rem;
font-weight: 500;
color: var(--mat-sys-on-surface-variant);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
min-width: 60px;
}
.digest {
font-family: 'Roboto Mono', monospace;
font-size: 0.75rem;
color: var(--mat-sys-on-surface);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-primary);
}
.value {
font-size: 0.875rem;
color: var(--mat-sys-on-surface);
font-size: var(--font-size-sm);
color: var(--color-text-primary);
}
}
}
// Format Selection
.format-selection {
margin-bottom: 24px;
margin-bottom: var(--space-6);
h3 {
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
margin: 0 0 12px 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
margin: 0 0 var(--space-3) 0;
}
.format-options {
display: flex;
flex-direction: column;
gap: 8px;
gap: var(--space-2);
}
.format-option {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border: 1px solid var(--mat-sys-outline-variant);
border-radius: 8px;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all 0.2s ease;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: var(--mat-sys-surface-variant);
border-color: var(--mat-sys-outline);
background: var(--color-surface-secondary);
border-color: var(--color-border-secondary);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
&.selected {
background: var(--mat-sys-primary-container);
border-color: var(--mat-sys-primary);
background: var(--color-brand-primary-bg);
border-color: var(--color-brand-primary);
mat-icon:first-child {
color: var(--mat-sys-primary);
color: var(--color-brand-primary);
}
}
@@ -97,7 +107,7 @@ mat-dialog-content {
font-size: 24px;
width: 24px;
height: 24px;
color: var(--mat-sys-on-surface-variant);
color: var(--color-text-muted);
}
.format-details {
@@ -106,38 +116,38 @@ mat-dialog-content {
.format-label {
display: block;
font-weight: 500;
color: var(--mat-sys-on-surface);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.format-description {
display: block;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
margin-top: 2px;
}
}
.check-icon {
color: var(--mat-sys-primary);
color: var(--color-brand-primary);
}
}
}
// Signing Options
.signing-options {
margin-bottom: 24px;
margin-bottom: var(--space-6);
h3 {
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
margin: 0 0 12px 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
margin: 0 0 var(--space-3) 0;
}
mat-checkbox {
display: block;
margin-bottom: 16px;
margin-bottom: var(--space-4);
}
.key-ref-field {
@@ -149,12 +159,12 @@ mat-dialog-content {
.error-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--mat-sys-error-container);
color: var(--mat-sys-on-error-container);
border-radius: 8px;
margin-top: 16px;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: var(--color-status-error-bg);
color: var(--color-status-error);
border-radius: var(--radius-lg);
margin-top: var(--space-4);
mat-icon {
flex-shrink: 0;
@@ -166,7 +176,25 @@ mat-dialog-actions {
button {
mat-spinner {
display: inline-block;
margin-right: 8px;
margin-right: var(--space-2);
}
}
}
/* High contrast mode */
@media (prefers-contrast: high) {
.format-option {
border-width: 2px;
}
.error-banner {
border: 2px solid var(--color-status-error);
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.format-option {
transition: none;
}
}

View File

@@ -1,12 +1,17 @@
// Evidence Graph Panel Component Styles
@use '../../../../../../../../styles/tokens/breakpoints' as *;
/**
* Evidence Graph Panel Component Styles
* Migrated to design system tokens
*/
.evidence-graph-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 400px;
background: var(--mat-sys-surface);
border-radius: 8px;
background: var(--color-surface-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
@@ -14,25 +19,25 @@
.graph-toolbar {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
background: var(--mat-sys-surface-variant);
border-bottom: 1px solid var(--mat-sys-outline-variant);
gap: var(--space-4);
padding: var(--space-2) var(--space-4);
background: var(--color-surface-secondary);
border-bottom: 1px solid var(--color-border-primary);
.toolbar-group {
display: flex;
align-items: center;
gap: 4px;
gap: var(--space-1);
}
.zoom-indicator {
margin-left: auto;
font-size: 0.75rem;
font-weight: 500;
color: var(--mat-sys-on-surface-variant);
background: var(--mat-sys-surface);
padding: 4px 12px;
border-radius: 12px;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
background: var(--color-surface-primary);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
min-width: 60px;
text-align: center;
}
@@ -48,7 +53,8 @@
display: block;
.node {
transition: stroke 0.2s ease, stroke-width 0.2s ease;
transition: stroke var(--motion-duration-fast) var(--motion-ease-default),
stroke-width var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
filter: brightness(1.1);
@@ -62,7 +68,7 @@
.label {
pointer-events: none;
user-select: none;
font-family: var(--mat-sys-body-medium-font);
font-family: var(--font-family-base);
}
}
@@ -74,8 +80,8 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: var(--mat-sys-on-surface-variant);
gap: var(--space-4);
color: var(--color-text-muted);
mat-icon {
font-size: 64px;
@@ -94,46 +100,46 @@
.graph-legend {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: var(--mat-sys-surface-variant);
border-top: 1px solid var(--mat-sys-outline-variant);
gap: var(--space-3);
padding: var(--space-2) var(--space-4);
background: var(--color-surface-secondary);
border-top: 1px solid var(--color-border-primary);
flex-wrap: wrap;
.legend-title {
font-size: 0.75rem;
font-weight: 500;
color: var(--mat-sys-on-surface-variant);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
}
.legend-items {
display: flex;
align-items: center;
gap: 16px;
gap: var(--space-4);
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--mat-sys-on-surface);
gap: var(--space-1);
font-size: var(--font-size-xs);
color: var(--color-text-primary);
.legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
border-radius: var(--radius-full);
flex-shrink: 0;
}
}
}
// Responsive adjustments
@media (max-width: 768px) {
@include screen-below-md {
.graph-legend {
.legend-items {
gap: 8px;
gap: var(--space-2);
}
.legend-item {
@@ -146,3 +152,22 @@
}
}
}
/* High contrast mode */
@media (prefers-contrast: high) {
.graph-toolbar,
.graph-legend {
border-width: 2px;
}
.legend-dot {
border: 1px solid currentColor;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
::ng-deep svg .node {
transition: none;
}
}

View File

@@ -1,21 +1,32 @@
// Evidence Node Card Component Styles
@use '../../../../../../../../styles/tokens/breakpoints' as *;
/**
* Evidence Node Card Component Styles
* Migrated to design system tokens
*/
.evidence-node-card {
margin-bottom: 12px;
transition: box-shadow 0.2s ease, transform 0.2s ease;
margin-bottom: var(--space-3);
transition: box-shadow var(--motion-duration-fast) var(--motion-ease-default),
transform var(--motion-duration-fast) var(--motion-ease-default);
&.selectable {
cursor: pointer;
&:hover {
box-shadow: var(--mat-sys-level3);
box-shadow: var(--shadow-lg);
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
}
&.compact {
mat-card-content {
padding: 8px 16px;
padding: var(--space-2) var(--space-4);
}
.node-summary {
@@ -27,7 +38,7 @@
}
&.expanded {
box-shadow: var(--mat-sys-level2);
box-shadow: var(--shadow-md);
}
}
@@ -37,9 +48,9 @@ mat-card-header {
cursor: inherit;
.node-icon {
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
border-radius: 50%;
background: var(--color-brand-primary-bg);
color: var(--color-brand-primary);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
@@ -47,148 +58,148 @@ mat-card-header {
height: 40px;
&.kind-sbom_diff {
background: #e3f2fd;
color: #1565c0;
background: var(--color-status-info-bg);
color: var(--color-status-info);
}
&.kind-reachability {
background: #fff3e0;
color: #ef6c00;
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
&.kind-vex {
background: #e8f5e9;
color: #2e7d32;
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.kind-attestation {
background: #f3e5f5;
color: #7b1fa2;
background: var(--color-evidence-attestation-bg);
color: var(--color-evidence-attestation);
}
&.kind-policy_eval {
background: #e0f2f1;
color: #00695c;
background: var(--color-evidence-policy-bg);
color: var(--color-evidence-policy);
}
&.kind-runtime_observation {
background: #fce4ec;
color: #c2185b;
background: var(--color-evidence-runtime-bg);
color: var(--color-evidence-runtime);
}
&.kind-patch_verification {
background: #e8eaf6;
color: #3949ab;
background: var(--color-evidence-patch-bg);
color: var(--color-evidence-patch);
}
&.kind-approval {
background: #f1f8e9;
color: #558b2f;
background: var(--color-evidence-approval-bg);
color: var(--color-evidence-approval);
}
&.kind-ai_rationale {
background: #fff8e1;
color: #ff8f00;
background: var(--color-evidence-ai-bg);
color: var(--color-evidence-ai);
}
}
.close-btn {
position: absolute;
top: 8px;
right: 8px;
top: var(--space-2);
right: var(--space-2);
}
}
mat-card-subtitle {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
flex-wrap: wrap;
.kind-badge {
font-size: 0.75rem;
background: var(--mat-sys-surface-variant);
color: var(--mat-sys-on-surface-variant);
padding: 2px 8px;
border-radius: 4px;
font-size: var(--font-size-xs);
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-sm);
}
.date {
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
}
// Card Content
mat-card-content {
padding-top: 8px;
padding-top: var(--space-2);
}
.node-summary {
color: var(--mat-sys-on-surface);
color: var(--color-text-primary);
line-height: 1.5;
margin: 0 0 12px 0;
margin: 0 0 var(--space-3) 0;
}
.node-metadata {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
gap: var(--space-2);
margin-bottom: var(--space-3);
mat-chip {
font-size: 0.75rem;
font-size: var(--font-size-xs);
}
}
// Confidence styling
.confidence-high {
--mat-chip-elevated-container-color: #e8f5e9;
--mat-chip-label-text-color: #2e7d32;
--mat-chip-elevated-container-color: var(--color-status-success-bg);
--mat-chip-label-text-color: var(--color-status-success);
}
.confidence-medium {
--mat-chip-elevated-container-color: #fff3e0;
--mat-chip-label-text-color: #ef6c00;
--mat-chip-elevated-container-color: var(--color-status-warning-bg);
--mat-chip-label-text-color: var(--color-status-warning);
}
.confidence-low {
--mat-chip-elevated-container-color: #ffebee;
--mat-chip-label-text-color: #c62828;
--mat-chip-elevated-container-color: var(--color-status-error-bg);
--mat-chip-label-text-color: var(--color-status-error);
}
// Content section
.node-content {
margin-top: 16px;
margin-top: var(--space-4);
h4 {
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
margin: 0 0 12px 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
margin: 0 0 var(--space-3) 0;
}
.content-item {
margin-bottom: 8px;
margin-bottom: var(--space-2);
.content-key {
font-weight: 500;
color: var(--mat-sys-on-surface-variant);
margin-right: 8px;
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
margin-right: var(--space-2);
}
.content-value {
color: var(--mat-sys-on-surface);
color: var(--color-text-primary);
&.complex {
display: block;
background: var(--mat-sys-surface-variant);
padding: 12px;
border-radius: 4px;
font-family: 'Roboto Mono', monospace;
font-size: 0.75rem;
background: var(--color-surface-tertiary);
padding: var(--space-3);
border-radius: var(--radius-sm);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
overflow-x: auto;
margin-top: 4px;
margin-top: var(--space-1);
white-space: pre-wrap;
word-break: break-word;
}
@@ -198,26 +209,26 @@ mat-card-content {
// Anchors section
.node-anchors {
margin-top: 16px;
margin-top: var(--space-4);
h4 {
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
margin: 0 0 12px 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
margin: 0 0 var(--space-3) 0;
}
.anchor-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px;
background: var(--mat-sys-surface-variant);
border-radius: 4px;
margin-bottom: 8px;
gap: var(--space-2);
padding: var(--space-2);
background: var(--color-surface-tertiary);
border-radius: var(--radius-sm);
margin-bottom: var(--space-2);
mat-icon {
color: var(--mat-sys-on-surface-variant);
color: var(--color-text-muted);
font-size: 18px;
width: 18px;
height: 18px;
@@ -229,24 +240,24 @@ mat-card-content {
min-width: 0;
.anchor-type {
font-size: 0.75rem;
font-weight: 500;
color: var(--mat-sys-primary);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-brand-primary);
text-transform: uppercase;
display: block;
}
.anchor-label {
font-size: 0.875rem;
color: var(--mat-sys-on-surface);
font-size: var(--font-size-sm);
color: var(--color-text-primary);
display: block;
margin-top: 2px;
}
.anchor-id {
font-family: 'Roboto Mono', monospace;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
display: block;
margin-top: 2px;
word-break: break-all;
@@ -256,5 +267,27 @@ mat-card-content {
}
mat-divider {
margin: 16px 0;
margin: var(--space-4) 0;
}
/* High contrast mode */
@media (prefers-contrast: high) {
.evidence-node-card {
border: 2px solid var(--color-border-primary);
}
.node-icon {
border: 1px solid currentColor;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.evidence-node-card {
transition: none;
&.selectable:hover {
transform: none;
}
}
}

View File

@@ -1,7 +1,12 @@
// Evidence Thread List Component Styles
@use '../../../../../../../../styles/tokens/breakpoints' as *;
/**
* Evidence Thread List Component Styles
* Migrated to design system tokens
*/
.evidence-thread-list {
padding: 24px;
padding: var(--space-6);
max-width: 1400px;
margin: 0 auto;
}
@@ -11,19 +16,19 @@
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
margin-bottom: var(--space-6);
.header-left {
h1 {
font-size: 1.5rem;
font-weight: 500;
margin: 0 0 4px 0;
color: var(--mat-sys-on-surface);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-medium);
margin: 0 0 var(--space-1) 0;
color: var(--color-text-primary);
}
.subtitle {
font-size: 0.875rem;
color: var(--mat-sys-on-surface-variant);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
margin: 0;
}
}
@@ -31,11 +36,11 @@
// Filters
.filters-card {
margin-bottom: 24px;
margin-bottom: var(--space-6);
.filters-row {
display: flex;
gap: 16px;
gap: var(--space-4);
align-items: flex-start;
flex-wrap: wrap;
@@ -56,11 +61,11 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
gap: 16px;
padding: var(--space-16) var(--space-6);
gap: var(--space-4);
p {
color: var(--mat-sys-on-surface-variant);
color: var(--color-text-muted);
margin: 0;
}
}
@@ -71,18 +76,18 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
gap: 16px;
padding: var(--space-16) var(--space-6);
gap: var(--space-4);
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
color: var(--mat-sys-error);
color: var(--color-status-error);
}
p {
color: var(--mat-sys-error);
color: var(--color-status-error);
margin: 0;
text-align: center;
max-width: 400px;
@@ -95,24 +100,24 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
padding: var(--space-16) var(--space-6);
text-align: center;
mat-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: var(--mat-sys-on-surface-variant);
color: var(--color-text-muted);
opacity: 0.3;
margin-bottom: 16px;
margin-bottom: var(--space-4);
}
p {
color: var(--mat-sys-on-surface-variant);
margin: 0 0 8px 0;
color: var(--color-text-muted);
margin: 0 0 var(--space-2) 0;
&.hint {
font-size: 0.875rem;
font-size: var(--font-size-sm);
}
}
}
@@ -127,25 +132,30 @@
.clickable-row {
cursor: pointer;
transition: background-color 0.2s ease;
transition: background-color var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: var(--mat-sys-surface-variant);
background: var(--color-surface-secondary);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: -2px;
}
}
.artifact-cell {
.artifact-name {
display: block;
font-weight: 500;
color: var(--mat-sys-on-surface);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.artifact-digest {
display: block;
font-family: 'Roboto Mono', monospace;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
margin-top: 2px;
}
}
@@ -153,38 +163,38 @@
// Verdict chips
.verdict-success {
--mat-chip-elevated-container-color: #e8f5e9;
--mat-chip-label-text-color: #2e7d32;
--mat-chip-elevated-container-color: var(--color-status-success-bg);
--mat-chip-label-text-color: var(--color-status-success);
}
.verdict-warning {
--mat-chip-elevated-container-color: #fff3e0;
--mat-chip-label-text-color: #ef6c00;
--mat-chip-elevated-container-color: var(--color-status-warning-bg);
--mat-chip-label-text-color: var(--color-status-warning);
}
.verdict-error {
--mat-chip-elevated-container-color: #ffebee;
--mat-chip-label-text-color: #c62828;
--mat-chip-elevated-container-color: var(--color-status-error-bg);
--mat-chip-label-text-color: var(--color-status-error);
}
.verdict-info {
--mat-chip-elevated-container-color: #e3f2fd;
--mat-chip-label-text-color: #1565c0;
--mat-chip-elevated-container-color: var(--color-status-info-bg);
--mat-chip-label-text-color: var(--color-status-info);
}
.verdict-neutral {
--mat-chip-elevated-container-color: var(--mat-sys-surface-variant);
--mat-chip-label-text-color: var(--mat-sys-on-surface-variant);
--mat-chip-elevated-container-color: var(--color-surface-tertiary);
--mat-chip-label-text-color: var(--color-text-muted);
}
// Status badges
.status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
padding: 4px 8px;
border-radius: 4px;
gap: var(--space-1);
font-size: var(--font-size-xs);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
mat-icon {
font-size: 14px;
@@ -193,48 +203,63 @@
}
&.status-active {
background: #e8f5e9;
color: #2e7d32;
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.status-archived {
background: var(--mat-sys-surface-variant);
color: var(--mat-sys-on-surface-variant);
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
}
&.status-exported {
background: #e3f2fd;
color: #1565c0;
background: var(--color-status-info-bg);
color: var(--color-status-info);
}
}
// Risk scores
.risk-score {
font-weight: 500;
padding: 4px 8px;
border-radius: 4px;
font-weight: var(--font-weight-medium);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
&.risk-critical {
background: #ffebee;
color: #c62828;
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
&.risk-high {
background: #fff3e0;
color: #ef6c00;
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
&.risk-medium {
background: #fff8e1;
color: #f9a825;
background: var(--color-severity-medium-bg);
color: var(--color-severity-medium);
}
&.risk-low {
background: #e8f5e9;
color: #2e7d32;
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
}
.no-score {
color: var(--mat-sys-on-surface-variant);
color: var(--color-text-muted);
}
/* High contrast mode */
@media (prefers-contrast: high) {
.status-badge,
.risk-score {
border: 1px solid currentColor;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.clickable-row {
transition: none;
}
}

View File

@@ -1,4 +1,9 @@
// Evidence Thread View Component Styles
@use '../../../../../../../../styles/tokens/breakpoints' as *;
/**
* Evidence Thread View Component Styles
* Migrated to design system tokens
*/
.evidence-thread-view {
display: flex;
@@ -12,16 +17,16 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: var(--mat-sys-surface);
border-bottom: 1px solid var(--mat-sys-outline-variant);
gap: 16px;
padding: var(--space-4) var(--space-6);
background: var(--color-surface-primary);
border-bottom: 1px solid var(--color-border-primary);
gap: var(--space-4);
flex-wrap: wrap;
.header-left {
display: flex;
align-items: center;
gap: 12px;
gap: var(--space-3);
min-width: 0;
flex: 1;
}
@@ -32,10 +37,10 @@
}
.thread-title {
font-size: 1.25rem;
font-weight: 500;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-medium);
margin: 0;
color: var(--mat-sys-on-surface);
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -44,16 +49,16 @@
.thread-digest {
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
gap: var(--space-1);
margin-top: var(--space-1);
code {
font-family: 'Roboto Mono', monospace;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
background: var(--mat-sys-surface-variant);
padding: 2px 8px;
border-radius: 4px;
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
background: var(--color-surface-tertiary);
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-sm);
}
.copy-btn {
@@ -72,41 +77,41 @@
.header-right {
display: flex;
align-items: center;
gap: 16px;
gap: var(--space-4);
flex-wrap: wrap;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
}
}
// Verdict chips styling
.verdict-success {
--mat-chip-elevated-container-color: var(--mat-sys-tertiary-container);
--mat-chip-label-text-color: var(--mat-sys-on-tertiary-container);
--mat-chip-elevated-container-color: var(--color-status-success-bg);
--mat-chip-label-text-color: var(--color-status-success);
}
.verdict-warning {
--mat-chip-elevated-container-color: #fff3e0;
--mat-chip-label-text-color: #e65100;
--mat-chip-elevated-container-color: var(--color-status-warning-bg);
--mat-chip-label-text-color: var(--color-status-warning);
}
.verdict-error {
--mat-chip-elevated-container-color: var(--mat-sys-error-container);
--mat-chip-label-text-color: var(--mat-sys-on-error-container);
--mat-chip-elevated-container-color: var(--color-status-error-bg);
--mat-chip-label-text-color: var(--color-status-error);
}
.verdict-info {
--mat-chip-elevated-container-color: var(--mat-sys-secondary-container);
--mat-chip-label-text-color: var(--mat-sys-on-secondary-container);
--mat-chip-elevated-container-color: var(--color-status-info-bg);
--mat-chip-label-text-color: var(--color-status-info);
}
.verdict-neutral {
--mat-chip-elevated-container-color: var(--mat-sys-surface-variant);
--mat-chip-label-text-color: var(--mat-sys-on-surface-variant);
--mat-chip-elevated-container-color: var(--color-surface-tertiary);
--mat-chip-label-text-color: var(--color-text-muted);
}
// Loading state
@@ -115,11 +120,11 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
gap: 16px;
padding: var(--space-16) var(--space-6);
gap: var(--space-4);
p {
color: var(--mat-sys-on-surface-variant);
color: var(--color-text-muted);
margin: 0;
}
}
@@ -130,18 +135,18 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
gap: 16px;
padding: var(--space-16) var(--space-6);
gap: var(--space-4);
mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
color: var(--mat-sys-error);
color: var(--color-status-error);
}
p {
color: var(--mat-sys-error);
color: var(--color-status-error);
margin: 0;
text-align: center;
max-width: 400px;
@@ -154,19 +159,19 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
gap: 16px;
padding: var(--space-16) var(--space-6);
gap: var(--space-4);
mat-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: var(--mat-sys-on-surface-variant);
color: var(--color-text-muted);
opacity: 0.5;
}
p {
color: var(--mat-sys-on-surface-variant);
color: var(--color-text-muted);
margin: 0;
}
}
@@ -190,7 +195,7 @@
.tab-content {
height: 100%;
padding: 16px;
padding: var(--space-4);
overflow: auto;
}
}
@@ -200,7 +205,7 @@
.mat-mdc-tab-label-content {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
mat-icon {
font-size: 20px;
@@ -214,12 +219,12 @@
.node-detail-panel {
width: 400px;
max-width: 40%;
border-left: 1px solid var(--mat-sys-outline-variant);
background: var(--mat-sys-surface);
border-left: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
overflow-y: auto;
padding: 16px;
padding: var(--space-4);
@media (max-width: 768px) {
@include screen-below-md {
position: fixed;
top: 0;
right: 0;
@@ -227,6 +232,26 @@
width: 100%;
max-width: 100%;
z-index: 100;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-xl);
}
}
/* High contrast mode */
@media (prefers-contrast: high) {
.thread-header {
border-bottom-width: 2px;
}
.node-detail-panel {
border-left-width: 2px;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.evidence-thread-view *,
.thread-header *,
.node-detail-panel * {
transition: none !important;
}
}

View File

@@ -1,9 +1,14 @@
// Evidence Timeline Panel Component Styles
@use '../../../../../../../../styles/tokens/breakpoints' as *;
/**
* Evidence Timeline Panel Component Styles
* Migrated to design system tokens
*/
.evidence-timeline-panel {
height: 100%;
overflow-y: auto;
padding: 16px;
padding: var(--space-4);
}
.empty-state {
@@ -13,8 +18,8 @@
justify-content: center;
height: 100%;
min-height: 200px;
gap: 16px;
color: var(--mat-sys-on-surface-variant);
gap: var(--space-4);
color: var(--color-text-muted);
mat-icon {
font-size: 64px;
@@ -34,16 +39,16 @@
}
.timeline-date-group {
margin-bottom: 24px;
margin-bottom: var(--space-6);
.date-header {
padding: 8px 0;
margin-bottom: 8px;
padding: var(--space-2) 0;
margin-bottom: var(--space-2);
.date-text {
font-size: 0.875rem;
font-weight: 600;
color: var(--mat-sys-primary);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-brand-primary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
@@ -53,20 +58,26 @@
.timeline-entry {
display: flex;
align-items: stretch;
gap: 16px;
padding: 12px;
margin-left: 8px;
border-radius: 8px;
gap: var(--space-4);
padding: var(--space-3);
margin-left: var(--space-2);
border-radius: var(--radius-lg);
cursor: pointer;
transition: background-color 0.2s ease, box-shadow 0.2s ease;
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
box-shadow var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: var(--mat-sys-surface-variant);
background: var(--color-surface-secondary);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
&.selected {
background: var(--mat-sys-primary-container);
box-shadow: var(--mat-sys-level1);
background: var(--color-brand-primary-bg);
box-shadow: var(--shadow-sm);
}
}
@@ -80,30 +91,30 @@
.connector-line {
width: 2px;
flex: 1;
background: var(--mat-sys-outline-variant);
background: var(--color-border-primary);
&.hidden {
visibility: hidden;
}
&.top {
min-height: 8px;
min-height: var(--space-2);
}
&.bottom {
min-height: 8px;
min-height: var(--space-2);
}
}
.connector-dot {
width: 32px;
height: 32px;
border-radius: 50%;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
background: var(--mat-sys-surface);
border: 2px solid var(--mat-sys-outline);
background: var(--color-surface-primary);
border: 2px solid var(--color-border-secondary);
flex-shrink: 0;
mat-icon {
@@ -114,57 +125,57 @@
// Kind-specific colors
&.kind-sbom_diff {
background: #e3f2fd;
border-color: #1565c0;
color: #1565c0;
background: var(--color-status-info-bg);
border-color: var(--color-status-info);
color: var(--color-status-info);
}
&.kind-reachability {
background: #fff3e0;
border-color: #ef6c00;
color: #ef6c00;
background: var(--color-status-warning-bg);
border-color: var(--color-status-warning);
color: var(--color-status-warning);
}
&.kind-vex {
background: #e8f5e9;
border-color: #2e7d32;
color: #2e7d32;
background: var(--color-status-success-bg);
border-color: var(--color-status-success);
color: var(--color-status-success);
}
&.kind-attestation {
background: #f3e5f5;
border-color: #7b1fa2;
color: #7b1fa2;
background: var(--color-evidence-attestation-bg);
border-color: var(--color-evidence-attestation);
color: var(--color-evidence-attestation);
}
&.kind-policy_eval {
background: #e0f2f1;
border-color: #00695c;
color: #00695c;
background: var(--color-evidence-policy-bg);
border-color: var(--color-evidence-policy);
color: var(--color-evidence-policy);
}
&.kind-runtime_observation {
background: #fce4ec;
border-color: #c2185b;
color: #c2185b;
background: var(--color-evidence-runtime-bg);
border-color: var(--color-evidence-runtime);
color: var(--color-evidence-runtime);
}
&.kind-patch_verification {
background: #e8eaf6;
border-color: #3949ab;
color: #3949ab;
background: var(--color-evidence-patch-bg);
border-color: var(--color-evidence-patch);
color: var(--color-evidence-patch);
}
&.kind-approval {
background: #f1f8e9;
border-color: #558b2f;
color: #558b2f;
background: var(--color-evidence-approval-bg);
border-color: var(--color-evidence-approval);
color: var(--color-evidence-approval);
}
&.kind-ai_rationale {
background: #fff8e1;
border-color: #ff8f00;
color: #ff8f00;
background: var(--color-evidence-ai-bg);
border-color: var(--color-evidence-ai);
color: var(--color-evidence-ai);
}
}
}
@@ -176,64 +187,64 @@
.entry-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
gap: var(--space-2);
margin-bottom: var(--space-1);
.entry-kind {
font-size: 0.75rem;
font-weight: 500;
color: var(--mat-sys-primary);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--color-brand-primary);
text-transform: uppercase;
}
.entry-time {
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
}
.entry-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
margin-bottom: 4px;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
margin-bottom: var(--space-1);
}
.entry-summary {
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 8px;
margin-bottom: var(--space-2);
}
.entry-footer {
display: flex;
align-items: center;
gap: 12px;
gap: var(--space-3);
.confidence-badge {
font-size: 0.625rem;
padding: 2px 8px;
border-radius: 4px;
font-weight: 500;
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-sm);
font-weight: var(--font-weight-medium);
&.confidence-high {
background: #e8f5e9;
color: #2e7d32;
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.confidence-medium {
background: #fff3e0;
color: #ef6c00;
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
&.confidence-low {
background: #ffebee;
color: #c62828;
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
}
@@ -242,7 +253,7 @@
align-items: center;
gap: 2px;
font-size: 0.625rem;
color: var(--mat-sys-on-surface-variant);
color: var(--color-text-muted);
mat-icon {
font-size: 12px;
@@ -256,9 +267,32 @@
.expand-btn {
align-self: center;
opacity: 0;
transition: opacity 0.2s ease;
transition: opacity var(--motion-duration-fast) var(--motion-ease-default);
.timeline-entry:hover & {
opacity: 1;
}
}
/* High contrast mode */
@media (prefers-contrast: high) {
.connector-dot {
border-width: 3px;
}
.confidence-badge {
border: 1px solid currentColor;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.timeline-entry,
.expand-btn {
transition: none;
}
.expand-btn {
opacity: 1;
}
}

View File

@@ -1,9 +1,14 @@
// Evidence Transcript Panel Component Styles
@use '../../../../../../../../styles/tokens/breakpoints' as *;
/**
* Evidence Transcript Panel Component Styles
* Migrated to design system tokens
*/
.evidence-transcript-panel {
display: flex;
flex-direction: column;
gap: 16px;
gap: var(--space-4);
height: 100%;
overflow-y: auto;
}
@@ -12,10 +17,10 @@
.generation-form {
mat-card-header {
mat-icon[mat-card-avatar] {
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
border-radius: 50%;
padding: 8px;
background: var(--color-brand-primary-bg);
color: var(--color-brand-primary);
border-radius: var(--radius-full);
padding: var(--space-2);
width: 40px;
height: 40px;
display: flex;
@@ -27,7 +32,7 @@
.form-row {
display: flex;
align-items: flex-start;
gap: 24px;
gap: var(--space-6);
flex-wrap: wrap;
.type-select {
@@ -36,12 +41,12 @@
}
.llm-checkbox {
padding-top: 8px;
padding-top: var(--space-2);
.checkbox-label {
display: flex;
align-items: center;
gap: 4px;
gap: var(--space-1);
mat-icon {
font-size: 18px;
@@ -52,10 +57,10 @@
.checkbox-hint {
display: block;
font-size: 0.75rem;
color: var(--mat-sys-on-surface-variant);
margin-top: 4px;
margin-left: 24px;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
margin-top: var(--space-1);
margin-left: var(--space-6);
}
}
}
@@ -64,7 +69,7 @@
button {
mat-spinner {
display: inline-block;
margin-right: 8px;
margin-right: var(--space-2);
}
}
}
@@ -74,11 +79,11 @@
.error-banner {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--mat-sys-error-container);
color: var(--mat-sys-on-error-container);
border-radius: 8px;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-status-error-bg);
color: var(--color-status-error);
border-radius: var(--radius-lg);
mat-icon {
flex-shrink: 0;
@@ -98,31 +103,31 @@
mat-card-subtitle {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
flex-wrap: wrap;
.type-badge {
font-size: 0.75rem;
font-weight: 500;
background: var(--mat-sys-primary-container);
color: var(--mat-sys-on-primary-container);
padding: 2px 8px;
border-radius: 4px;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
background: var(--color-brand-primary-bg);
color: var(--color-brand-primary);
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-sm);
}
.separator {
color: var(--mat-sys-outline);
color: var(--color-border-primary);
}
.llm-badge {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
background: #fff8e1;
color: #ff8f00;
padding: 2px 8px;
border-radius: 4px;
gap: var(--space-1);
font-size: var(--font-size-xs);
background: var(--color-evidence-ai-bg);
color: var(--color-evidence-ai);
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-sm);
mat-icon {
font-size: 14px;
@@ -136,23 +141,23 @@
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
gap: var(--space-4);
overflow: hidden;
}
.transcript-content {
flex: 1;
overflow: auto;
background: var(--mat-sys-surface-variant);
border-radius: 8px;
padding: 16px;
background: var(--color-surface-tertiary);
border-radius: var(--radius-lg);
padding: var(--space-4);
min-height: 200px;
.transcript-text {
font-family: var(--mat-sys-body-medium-font);
font-size: 0.875rem;
font-family: var(--font-family-base);
font-size: var(--font-size-sm);
line-height: 1.6;
color: var(--mat-sys-on-surface);
color: var(--color-text-primary);
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
@@ -161,34 +166,34 @@
.transcript-anchors {
h4 {
font-size: 0.875rem;
font-weight: 500;
color: var(--mat-sys-on-surface);
margin: 0 0 8px 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
margin: 0 0 var(--space-2) 0;
}
.anchor-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
gap: var(--space-2);
}
.anchor-chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
background: var(--mat-sys-surface);
color: var(--mat-sys-on-surface);
padding: 4px 12px;
border-radius: 16px;
border: 1px solid var(--mat-sys-outline-variant);
gap: var(--space-1);
font-size: var(--font-size-xs);
background: var(--color-surface-primary);
color: var(--color-text-primary);
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-full);
border: 1px solid var(--color-border-primary);
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
color: var(--mat-sys-primary);
color: var(--color-brand-primary);
}
}
}
@@ -200,7 +205,7 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px 24px;
padding: var(--space-16) var(--space-6);
text-align: center;
flex: 1;
@@ -208,18 +213,36 @@
font-size: 64px;
width: 64px;
height: 64px;
color: var(--mat-sys-on-surface-variant);
color: var(--color-text-muted);
opacity: 0.3;
margin-bottom: 16px;
margin-bottom: var(--space-4);
}
p {
color: var(--mat-sys-on-surface-variant);
margin: 0 0 8px 0;
color: var(--color-text-muted);
margin: 0 0 var(--space-2) 0;
&.hint {
font-size: 0.875rem;
font-size: var(--font-size-sm);
max-width: 400px;
}
}
}
/* High contrast mode */
@media (prefers-contrast: high) {
.error-banner {
border: 2px solid var(--color-status-error);
}
.anchor-chip {
border-width: 2px;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.evidence-transcript-panel * {
transition: none !important;
}
}

View File

@@ -0,0 +1,332 @@
/**
* Evidence Center Page Component
* Sprint: SPRINT_20260118_006_FE_evidence_unification (EVD-001)
*
* Browse all evidence packets with search and filters.
*/
import { Component, ChangeDetectionStrategy, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
interface EvidencePacket {
id: string;
type: 'scan' | 'promotion' | 'deployment' | 'attestation' | 'exception';
bundleDigest: string;
releaseVersion?: string;
environment?: string;
createdAt: string;
signed: boolean;
verified: boolean;
containsProofChain: boolean;
}
@Component({
selector: 'app-evidence-center-page',
standalone: true,
imports: [CommonModule, RouterLink, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="evidence-center">
<header class="page-header">
<div>
<h1 class="page-title">Evidence Center</h1>
<p class="page-subtitle">Browse and verify signed evidence packets</p>
</div>
<div class="page-actions">
<button type="button" class="btn btn--secondary" (click)="exportAuditBundle()">
📦 Export Audit Bundle
</button>
</div>
</header>
<!-- Search and Filters -->
<div class="filter-bar">
<div class="filter-bar__search">
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
<input
type="text"
class="filter-bar__input"
placeholder="Search by ID, digest, or version..."
[(ngModel)]="searchQuery"
/>
</div>
<select class="filter-bar__select" (change)="filterByType($event)">
<option value="">All Types</option>
<option value="scan">Scan</option>
<option value="promotion">Promotion</option>
<option value="deployment">Deployment</option>
<option value="attestation">Attestation</option>
<option value="exception">Exception</option>
</select>
<select class="filter-bar__select" (change)="filterByVerification($event)">
<option value="">All Status</option>
<option value="verified">Verified</option>
<option value="unverified">Unverified</option>
</select>
</div>
<!-- Evidence Table -->
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Evidence ID</th>
<th>Type</th>
<th>Release</th>
<th>Environment</th>
<th>Signed</th>
<th>Verified</th>
<th>Proof Chain</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (packet of filteredPackets(); track packet.id) {
<tr>
<td>
<a [routerLink]="['./', packet.id]" class="evidence-link">
{{ packet.id }}
</a>
</td>
<td>
<span class="type-badge" [class]="'type-badge--' + packet.type">
{{ packet.type | uppercase }}
</span>
</td>
<td>{{ packet.releaseVersion || '—' }}</td>
<td>{{ packet.environment || '—' }}</td>
<td>
@if (packet.signed) {
<span class="status-icon status-icon--success">✓</span>
} @else {
<span class="status-icon status-icon--warning">⚠</span>
}
</td>
<td>
@if (packet.verified) {
<span class="status-icon status-icon--success">✓</span>
} @else {
<span class="status-icon status-icon--muted">—</span>
}
</td>
<td>
@if (packet.containsProofChain) {
<a [routerLink]="['./', packet.id]" [queryParams]="{tab: 'proof-chain'}" class="proof-chain-link">
View Chain
</a>
} @else {
<span class="text-muted">—</span>
}
</td>
<td>{{ packet.createdAt }}</td>
<td>
<div class="action-buttons">
<button type="button" class="btn btn--sm" (click)="verifyPacket(packet)">
Verify
</button>
<button type="button" class="btn btn--sm" (click)="downloadPacket(packet)">
</button>
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="9" class="empty-state">
<p>No evidence packets found</p>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
`,
styles: [`
.evidence-center { max-width: 1400px; margin: 0 auto; }
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
}
.page-title { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: 600; }
.page-subtitle { margin: 0; color: var(--text-color-secondary, #64748b); }
.page-actions { display: flex; gap: 0.75rem; }
.filter-bar {
display: flex;
gap: 0.75rem;
padding: 1rem;
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
margin-bottom: 1rem;
}
.filter-bar__search {
position: relative;
flex: 1;
}
.filter-bar__search-icon {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-color-secondary, #94a3b8);
}
.filter-bar__input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.25rem;
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 6px;
font-size: 0.875rem;
}
.filter-bar__select {
padding: 0.5rem 2rem 0.5rem 0.75rem;
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 6px;
font-size: 0.875rem;
background: var(--surface-ground, #f8fafc);
}
.table-container {
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
overflow: hidden;
}
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td {
padding: 0.75rem 1rem;
text-align: left;
border-bottom: 1px solid var(--surface-border, #e2e8f0);
}
.data-table th {
background: var(--surface-ground, #f8fafc);
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-secondary, #64748b);
text-transform: uppercase;
}
.data-table tbody tr:hover { background: var(--surface-hover, #f8fafc); }
.evidence-link {
color: var(--primary-color, #3b82f6);
text-decoration: none;
font-weight: 500;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.875rem;
}
.evidence-link:hover { text-decoration: underline; }
.type-badge {
padding: 0.125rem 0.5rem;
border-radius: 4px;
font-size: 0.625rem;
font-weight: 600;
}
.type-badge--scan { background: var(--blue-100, #dbeafe); color: var(--blue-700, #1d4ed8); }
.type-badge--promotion { background: var(--purple-100, #f3e8ff); color: var(--purple-700, #7c3aed); }
.type-badge--deployment { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.type-badge--attestation { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
.type-badge--exception { background: var(--orange-100, #ffedd5); color: var(--orange-700, #c2410c); }
.status-icon { font-size: 0.875rem; }
.status-icon--success { color: var(--green-500, #22c55e); }
.status-icon--warning { color: var(--yellow-500, #eab308); }
.status-icon--muted { color: var(--gray-400, #9ca3af); }
.proof-chain-link {
color: var(--primary-color, #3b82f6);
text-decoration: none;
font-size: 0.75rem;
}
.text-muted { color: var(--text-color-secondary, #94a3b8); }
.action-buttons { display: flex; gap: 0.25rem; }
.btn {
padding: 0.375rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
background: var(--surface-ground, #f8fafc);
border: 1px solid var(--surface-border, #e2e8f0);
color: var(--text-color, #1e293b);
}
.btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.btn--secondary { background: var(--surface-ground, #f8fafc); border: 1px solid var(--surface-border, #e2e8f0); color: var(--text-color, #1e293b); }
.empty-state {
text-align: center;
padding: 3rem !important;
color: var(--text-color-secondary, #64748b);
}
`]
})
export class EvidenceCenterPageComponent {
searchQuery = '';
typeFilter = signal('');
verificationFilter = signal('');
packets = signal<EvidencePacket[]>([
{ id: 'EVD-2026-045', type: 'promotion', bundleDigest: 'sha256:7aa1...', releaseVersion: 'v1.2.5', environment: 'QA', createdAt: '2h ago', signed: true, verified: true, containsProofChain: true },
{ id: 'EVD-2026-044', type: 'scan', bundleDigest: 'sha256:7aa1...', releaseVersion: 'v1.2.5', environment: 'Dev', createdAt: '3h ago', signed: true, verified: true, containsProofChain: false },
{ id: 'EVD-2026-043', type: 'deployment', bundleDigest: 'sha256:6bb1...', releaseVersion: 'v1.2.4', environment: 'Staging', createdAt: '6h ago', signed: true, verified: true, containsProofChain: true },
{ id: 'EVD-2026-042', type: 'attestation', bundleDigest: 'sha256:5cc1...', releaseVersion: 'v1.2.3', environment: 'Prod', createdAt: '1d ago', signed: true, verified: true, containsProofChain: true },
{ id: 'EVD-2026-041', type: 'exception', bundleDigest: 'sha256:7aa1...', releaseVersion: 'v1.2.5', createdAt: '2d ago', signed: true, verified: false, containsProofChain: false },
]);
filteredPackets = computed(() => {
let result = this.packets();
const query = this.searchQuery.toLowerCase();
const type = this.typeFilter();
const verification = this.verificationFilter();
if (query) {
result = result.filter(p =>
p.id.toLowerCase().includes(query) ||
p.bundleDigest.toLowerCase().includes(query) ||
(p.releaseVersion?.toLowerCase().includes(query) ?? false)
);
}
if (type) {
result = result.filter(p => p.type === type);
}
if (verification === 'verified') {
result = result.filter(p => p.verified);
} else if (verification === 'unverified') {
result = result.filter(p => !p.verified);
}
return result;
});
filterByType(event: Event): void {
const select = event.target as HTMLSelectElement;
this.typeFilter.set(select.value);
}
filterByVerification(event: Event): void {
const select = event.target as HTMLSelectElement;
this.verificationFilter.set(select.value);
}
verifyPacket(packet: EvidencePacket): void {
console.log('Verify packet:', packet.id);
}
downloadPacket(packet: EvidencePacket): void {
console.log('Download packet:', packet.id);
}
exportAuditBundle(): void {
console.log('Export audit bundle');
}
}

View File

@@ -0,0 +1,415 @@
/**
* Evidence Packet Page Component
* Sprint: SPRINT_20260118_006_FE_evidence_unification (EVD-003)
*
* Detailed view of a single evidence packet with contents and verification.
*/
import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
@Component({
selector: 'app-evidence-packet-page',
standalone: true,
imports: [CommonModule, RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="evidence-packet">
<header class="page-header">
<a routerLink="/evidence" class="back-link">← Back to Evidence Center</a>
<div class="header-main">
<h1 class="page-title">{{ packet().id }}</h1>
<div class="header-badges">
<span class="type-badge" [class]="'type-badge--' + packet().type">
{{ packet().type | uppercase }}
</span>
@if (packet().verified) {
<span class="verified-badge">✓ Verified</span>
}
</div>
</div>
<p class="page-subtitle">
Created {{ packet().createdAt }} · Release {{ packet().releaseVersion }}
</p>
</header>
<!-- Quick Summary -->
<section class="summary-cards">
<div class="summary-card">
<span class="summary-label">Bundle Digest</span>
<code class="summary-value">{{ packet().bundleDigest }}</code>
</div>
<div class="summary-card">
<span class="summary-label">Signature</span>
<span class="summary-value">
@if (packet().signed) {
<span class="status-success">✓ Signed</span>
} @else {
<span class="status-warning">⚠ Unsigned</span>
}
</span>
</div>
<div class="summary-card">
<span class="summary-label">Verification</span>
<span class="summary-value">
@if (packet().verified) {
<span class="status-success">✓ Valid</span>
} @else {
<span class="status-muted">Not verified</span>
}
</span>
</div>
</section>
<!-- Tabs -->
<nav class="tabs">
@for (tab of tabs; track tab.id) {
<button
type="button"
class="tab"
[class.tab--active]="activeTab() === tab.id"
(click)="setTab(tab.id)"
>
{{ tab.label }}
</button>
}
</nav>
<!-- Tab Content -->
<div class="tab-content">
@switch (activeTab()) {
@case ('summary') {
<div class="panel">
<h3>Evidence Summary</h3>
<dl class="details-list">
<dt>Type</dt>
<dd>{{ packet().type }}</dd>
<dt>Created</dt>
<dd>{{ packet().createdAt }}</dd>
<dt>Release</dt>
<dd>{{ packet().releaseVersion }}</dd>
<dt>Environment</dt>
<dd>{{ packet().environment }}</dd>
<dt>Bundle Digest</dt>
<dd><code>{{ packet().bundleDigest }}</code></dd>
</dl>
</div>
}
@case ('contents') {
<div class="panel">
<h3>Packet Contents</h3>
<p class="section-subtitle">Files included in this evidence packet</p>
<table class="data-table">
<thead>
<tr>
<th>File</th>
<th>Type</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (file of packetFiles; track file.name) {
<tr>
<td><code>{{ file.name }}</code></td>
<td>{{ file.type }}</td>
<td>{{ file.size }}</td>
<td>
<button type="button" class="btn btn--sm" (click)="viewFile(file)">View</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@case ('verify') {
<div class="panel">
<h3>Verification</h3>
<div class="verification-status">
@if (packet().verified) {
<div class="verification-result verification-result--success">
<span class="verification-icon">✓</span>
<div>
<strong>Signature Valid</strong>
<p>Verified against trusted key: ops-signing-key-2026</p>
</div>
</div>
} @else {
<div class="verification-result verification-result--pending">
<span class="verification-icon">?</span>
<div>
<strong>Not Yet Verified</strong>
<p>Click verify to check signature</p>
</div>
</div>
}
</div>
<button type="button" class="btn btn--primary" (click)="runVerification()">
Run Verification
</button>
</div>
}
@case ('proof-chain') {
<div class="panel">
<h3>Proof Chain</h3>
<p class="section-subtitle">Cryptographic chain of custody</p>
<div class="proof-chain">
@for (node of proofChain; track node.id) {
<div class="chain-node">
<div class="chain-node__header">
<span class="chain-node__icon">{{ node.icon }}</span>
<span class="chain-node__type">{{ node.type }}</span>
</div>
<code class="chain-node__hash">{{ node.hash }}</code>
<span class="chain-node__time">{{ node.time }}</span>
</div>
@if (!$last) {
<div class="chain-connector">
<span class="chain-arrow">↓</span>
</div>
}
}
</div>
<div class="proof-actions">
<button type="button" class="btn btn--secondary" (click)="exportProofChain()">
📦 Export Proof Chain
</button>
<button type="button" class="btn btn--secondary" (click)="verifyChain()">
✓ Verify Entire Chain
</button>
</div>
</div>
}
}
</div>
</div>
`,
styles: [`
.evidence-packet { max-width: 1000px; margin: 0 auto; }
.page-header { margin-bottom: 1.5rem; }
.back-link {
display: inline-block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
color: var(--primary-color, #3b82f6);
text-decoration: none;
}
.header-main { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.5rem; }
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
}
.header-badges { display: flex; gap: 0.5rem; }
.type-badge {
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.type-badge--promotion { background: var(--purple-100, #f3e8ff); color: var(--purple-700, #7c3aed); }
.type-badge--scan { background: var(--blue-100, #dbeafe); color: var(--blue-700, #1d4ed8); }
.type-badge--deployment { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
.type-badge--attestation { background: var(--yellow-100, #fef9c3); color: var(--yellow-700, #a16207); }
.type-badge--exception { background: var(--orange-100, #ffedd5); color: var(--orange-700, #c2410c); }
.verified-badge {
padding: 0.25rem 0.75rem;
background: var(--green-100, #dcfce7);
color: var(--green-700, #15803d);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.page-subtitle { margin: 0; color: var(--text-color-secondary, #64748b); }
.summary-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1.5rem;
}
.summary-card {
padding: 1rem;
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
}
.summary-label { display: block; font-size: 0.75rem; color: var(--text-color-secondary, #64748b); margin-bottom: 0.25rem; }
.summary-value { font-size: 0.875rem; font-weight: 500; }
.summary-value code {
font-size: 0.75rem;
word-break: break-all;
}
.status-success { color: var(--green-600, #16a34a); }
.status-warning { color: var(--yellow-600, #ca8a04); }
.status-muted { color: var(--gray-400, #9ca3af); }
.tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--surface-border, #e2e8f0);
margin-bottom: 1.5rem;
}
.tab {
padding: 0.75rem 1rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-color-secondary, #64748b);
cursor: pointer;
}
.tab:hover { color: var(--text-color, #1e293b); }
.tab--active {
color: var(--primary-color, #3b82f6);
border-bottom-color: var(--primary-color, #3b82f6);
}
.panel {
padding: 1.5rem;
background: var(--surface-card, #ffffff);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
}
.panel h3 { margin: 0 0 1rem; font-size: 1rem; font-weight: 600; }
.section-subtitle { margin: 0 0 1rem; font-size: 0.875rem; color: var(--text-color-secondary, #64748b); }
.details-list { display: grid; grid-template-columns: auto 1fr; gap: 0.5rem 1rem; margin: 0; }
.details-list dt { font-weight: 500; color: var(--text-color-secondary, #64748b); font-size: 0.875rem; }
.details-list dd { margin: 0; font-size: 0.875rem; }
.details-list code { font-size: 0.75rem; word-break: break-all; }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--surface-border, #e2e8f0);
}
.data-table th {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-color-secondary, #64748b);
text-transform: uppercase;
}
.data-table code { font-size: 0.75rem; }
.verification-status { margin-bottom: 1rem; }
.verification-result {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
border-radius: 8px;
}
.verification-result--success { background: var(--green-50, #f0fdf4); border: 1px solid var(--green-200, #bbf7d0); }
.verification-result--pending { background: var(--gray-50, #f9fafb); border: 1px solid var(--gray-200, #e5e7eb); }
.verification-icon { font-size: 1.5rem; }
.verification-result strong { display: block; margin-bottom: 0.25rem; }
.verification-result p { margin: 0; font-size: 0.875rem; color: var(--text-color-secondary, #64748b); }
.proof-chain { margin-bottom: 1.5rem; }
.chain-node {
padding: 1rem;
background: var(--surface-ground, #f8fafc);
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 8px;
}
.chain-node__header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; }
.chain-node__icon { font-size: 1.25rem; }
.chain-node__type { font-weight: 600; font-size: 0.875rem; }
.chain-node__hash { display: block; font-size: 0.625rem; color: var(--text-color-secondary, #64748b); margin-bottom: 0.25rem; }
.chain-node__time { font-size: 0.75rem; color: var(--text-color-secondary, #94a3b8); }
.chain-connector { display: flex; justify-content: center; padding: 0.5rem 0; }
.chain-arrow { color: var(--text-color-secondary, #94a3b8); font-size: 1.25rem; }
.proof-actions { display: flex; gap: 0.75rem; }
.btn {
padding: 0.5rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
.btn--primary { background: var(--primary-color, #3b82f6); border: none; color: white; }
.btn--secondary { background: var(--surface-ground, #f8fafc); border: 1px solid var(--surface-border, #e2e8f0); color: var(--text-color, #1e293b); }
`]
})
export class EvidencePacketPageComponent implements OnInit {
private route = inject(ActivatedRoute);
packetId = signal('');
activeTab = signal('summary');
tabs = [
{ id: 'summary', label: 'Summary' },
{ id: 'contents', label: 'Contents' },
{ id: 'verify', label: 'Verify' },
{ id: 'proof-chain', label: 'Proof Chain' },
];
packet = signal({
id: 'EVD-2026-045',
type: 'promotion' as const,
bundleDigest: 'sha256:7aa1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9',
releaseVersion: 'v1.2.5',
environment: 'QA',
createdAt: '2h ago',
signed: true,
verified: true,
});
packetFiles = [
{ name: 'manifest.json', type: 'JSON', size: '2.4 KB' },
{ name: 'sbom.cdx.json', type: 'SBOM', size: '156 KB' },
{ name: 'policy-result.json', type: 'Policy', size: '8.2 KB' },
{ name: 'signature.sig', type: 'Signature', size: '512 B' },
];
proofChain = [
{ id: '1', type: 'Source Commit', icon: '📁', hash: 'sha256:a1b2c3...', time: '3h ago' },
{ id: '2', type: 'Build', icon: '🔨', hash: 'sha256:d4e5f6...', time: '2h 45m ago' },
{ id: '3', type: 'Scan', icon: '🔍', hash: 'sha256:g7h8i9...', time: '2h 30m ago' },
{ id: '4', type: 'Policy Evaluation', icon: '📋', hash: 'sha256:j0k1l2...', time: '2h 15m ago' },
{ id: '5', type: 'Promotion', icon: '✅', hash: 'sha256:m3n4o5...', time: '2h ago' },
];
ngOnInit(): void {
this.route.params.subscribe(params => {
this.packetId.set(params['packetId'] || '');
this.packet.update(p => ({ ...p, id: params['packetId'] || p.id }));
});
this.route.queryParams.subscribe(params => {
if (params['tab']) {
this.activeTab.set(params['tab']);
}
});
}
setTab(tabId: string): void {
this.activeTab.set(tabId);
}
viewFile(file: { name: string }): void {
console.log('View file:', file.name);
}
runVerification(): void {
console.log('Run verification');
}
exportProofChain(): void {
console.log('Export proof chain');
}
verifyChain(): void {
console.log('Verify entire chain');
}
}

View File

@@ -0,0 +1,21 @@
/**
* Evidence Routes
* Sprint: SPRINT_20260118_006_FE_evidence_unification
*/
import { Routes } from '@angular/router';
export const EVIDENCE_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./evidence-center-page.component').then(m => m.EvidenceCenterPageComponent),
data: { breadcrumb: 'Evidence' },
},
{
path: ':packetId',
loadComponent: () =>
import('./evidence-packet-page.component').then(m => m.EvidencePacketPageComponent),
data: { breadcrumb: 'Evidence Packet' },
},
];

View File

@@ -0,0 +1,864 @@
/**
* Audit Bundle Create Modal Component
* Sprint: SPRINT_20260118_006_FE_evidence_unification (EVID-009)
*
* Modal for creating audit bundles from selected evidence packets and artifacts.
*/
import {
Component,
Input,
Output,
EventEmitter,
ChangeDetectionStrategy,
signal,
computed,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
export interface SelectableItem {
id: string;
name: string;
type: 'evidence' | 'sbom' | 'report' | 'attestation';
digest?: string;
date?: string;
size?: string;
}
export interface AuditBundleConfig {
name: string;
description: string;
selectedItems: string[];
includeFullContent: boolean;
exportFormat: 'json' | 'zip';
}
@Component({
selector: 'app-audit-bundle-create-modal',
standalone: true,
imports: [CommonModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (open) {
<div class="modal-backdrop" (click)="onBackdropClick($event)">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<header class="modal__header">
<h2 id="modal-title" class="modal__title">Create Audit Bundle</h2>
<button
type="button"
class="modal__close"
(click)="close()"
aria-label="Close"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</header>
<div class="modal__body">
<!-- Step indicator -->
<div class="steps">
<div class="step" [class.step--active]="currentStep() === 1" [class.step--complete]="currentStep() > 1">
<span class="step__number">1</span>
<span class="step__label">Details</span>
</div>
<div class="step__connector"></div>
<div class="step" [class.step--active]="currentStep() === 2" [class.step--complete]="currentStep() > 2">
<span class="step__number">2</span>
<span class="step__label">Select Items</span>
</div>
<div class="step__connector"></div>
<div class="step" [class.step--active]="currentStep() === 3">
<span class="step__number">3</span>
<span class="step__label">Options</span>
</div>
</div>
<!-- Step 1: Details -->
@if (currentStep() === 1) {
<div class="step-content">
<div class="form-group">
<label class="form-label" for="bundleName">Bundle Name <span class="required">*</span></label>
<input
id="bundleName"
type="text"
class="form-input"
[ngModel]="bundleName()"
(ngModelChange)="bundleName.set($event)"
placeholder="e.g., Q1 2026 Audit Package"
/>
</div>
<div class="form-group">
<label class="form-label" for="bundleDesc">Description</label>
<textarea
id="bundleDesc"
class="form-textarea"
[ngModel]="bundleDescription()"
(ngModelChange)="bundleDescription.set($event)"
rows="3"
placeholder="Optional description for this audit bundle..."
></textarea>
</div>
</div>
}
<!-- Step 2: Select Items -->
@if (currentStep() === 2) {
<div class="step-content">
<div class="selection-header">
<span class="selection-count">{{ selectedItemIds().length }} items selected</span>
<div class="selection-actions">
<button type="button" class="link-btn" (click)="selectAll()">Select All</button>
<button type="button" class="link-btn" (click)="clearSelection()">Clear</button>
</div>
</div>
<!-- Filter tabs -->
<div class="filter-tabs">
<button
type="button"
class="filter-tab"
[class.filter-tab--active]="itemFilter() === 'all'"
(click)="itemFilter.set('all')"
>
All ({{ availableItems.length }})
</button>
<button
type="button"
class="filter-tab"
[class.filter-tab--active]="itemFilter() === 'evidence'"
(click)="itemFilter.set('evidence')"
>
Evidence
</button>
<button
type="button"
class="filter-tab"
[class.filter-tab--active]="itemFilter() === 'sbom'"
(click)="itemFilter.set('sbom')"
>
SBOMs
</button>
<button
type="button"
class="filter-tab"
[class.filter-tab--active]="itemFilter() === 'report'"
(click)="itemFilter.set('report')"
>
Reports
</button>
</div>
<!-- Items list -->
<div class="items-list">
@for (item of filteredItems(); track item.id) {
<label class="item-row" [class.item-row--selected]="isSelected(item.id)">
<input
type="checkbox"
[checked]="isSelected(item.id)"
(change)="toggleItem(item.id)"
/>
<div class="item-row__icon" [class]="'item-row__icon--' + item.type">
@switch (item.type) {
@case ('evidence') {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
}
@case ('sbom') {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<line x1="9" y1="3" x2="9" y2="21"/>
</svg>
}
@case ('report') {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
}
@default {
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
}
}
</div>
<div class="item-row__info">
<span class="item-row__name">{{ item.name }}</span>
<span class="item-row__meta">
{{ item.type | titlecase }}
@if (item.date) { · {{ item.date }} }
@if (item.size) { · {{ item.size }} }
</span>
</div>
@if (item.digest) {
<code class="item-row__digest">{{ item.digest | slice:7:15 }}...</code>
}
</label>
} @empty {
<div class="empty-state">
<p>No items match the current filter.</p>
</div>
}
</div>
</div>
}
<!-- Step 3: Options -->
@if (currentStep() === 3) {
<div class="step-content">
<div class="summary-box">
<h4>Bundle Summary</h4>
<dl>
<dt>Name</dt>
<dd>{{ bundleName() }}</dd>
<dt>Items</dt>
<dd>{{ selectedItemIds().length }} selected</dd>
</dl>
</div>
<div class="form-group">
<label class="form-label">Content Options</label>
<div class="radio-group">
<label class="radio-option">
<input
type="radio"
name="includeContent"
[value]="true"
[checked]="includeFullContent()"
(change)="includeFullContent.set(true)"
/>
<span class="radio-label">
<strong>Full Content</strong>
<small>Include complete artifacts (larger file size)</small>
</span>
</label>
<label class="radio-option">
<input
type="radio"
name="includeContent"
[value]="false"
[checked]="!includeFullContent()"
(change)="includeFullContent.set(false)"
/>
<span class="radio-label">
<strong>References Only</strong>
<small>Include digests and metadata only (smaller file)</small>
</span>
</label>
</div>
</div>
<div class="form-group">
<label class="form-label">Export Format</label>
<div class="radio-group radio-group--horizontal">
<label class="radio-option radio-option--compact">
<input
type="radio"
name="exportFormat"
value="json"
[checked]="exportFormat() === 'json'"
(change)="exportFormat.set('json')"
/>
<span class="radio-label">
<strong>JSON</strong>
</span>
</label>
<label class="radio-option radio-option--compact">
<input
type="radio"
name="exportFormat"
value="zip"
[checked]="exportFormat() === 'zip'"
(change)="exportFormat.set('zip')"
/>
<span class="radio-label">
<strong>ZIP Archive</strong>
</span>
</label>
</div>
</div>
</div>
}
</div>
<footer class="modal__footer">
@if (currentStep() > 1) {
<button type="button" class="btn btn--secondary" (click)="previousStep()">
Back
</button>
} @else {
<button type="button" class="btn btn--secondary" (click)="close()">
Cancel
</button>
}
@if (currentStep() < 3) {
<button
type="button"
class="btn btn--primary"
[disabled]="!canProceed()"
(click)="nextStep()"
>
Continue
</button>
} @else {
<button
type="button"
class="btn btn--primary"
[disabled]="generating()"
(click)="generate()"
>
@if (generating()) {
<span class="spinner"></span>
Generating...
} @else {
Generate Bundle
}
</button>
}
</footer>
</div>
</div>
}
`,
styles: [`
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 1000;
}
.modal {
background: white;
border-radius: 12px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
width: 100%;
max-width: 600px;
max-height: calc(100vh - 2rem);
display: flex;
flex-direction: column;
}
.modal__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e2e8f0;
}
.modal__title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.modal__close {
display: flex;
padding: 0.5rem;
background: none;
border: none;
border-radius: 6px;
color: #64748b;
cursor: pointer;
}
.modal__close:hover {
background: #f8fafc;
color: #1e293b;
}
.modal__body {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
.modal__footer {
display: flex;
justify-content: space-between;
padding: 1rem 1.5rem;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
border-radius: 0 0 12px 12px;
}
/* Steps */
.steps {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.step {
display: flex;
align-items: center;
gap: 0.5rem;
color: #94a3b8;
}
.step--active {
color: #2563eb;
}
.step--complete {
color: #16a34a;
}
.step__number {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: currentColor;
color: white;
font-size: 0.75rem;
font-weight: 600;
}
.step--active .step__number,
.step--complete .step__number {
background: currentColor;
}
.step__label {
font-size: 0.8125rem;
font-weight: 500;
}
.step__connector {
width: 40px;
height: 2px;
background: #e2e8f0;
}
/* Form elements */
.step-content {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-size: 0.875rem;
font-weight: 500;
color: #1e293b;
}
.required {
color: #ef4444;
}
.form-input,
.form-textarea {
padding: 0.625rem 0.875rem;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 0.875rem;
}
.form-input:focus,
.form-textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px #dbeafe;
}
/* Selection */
.selection-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.selection-count {
font-size: 0.875rem;
font-weight: 500;
color: #1e293b;
}
.selection-actions {
display: flex;
gap: 0.75rem;
}
.link-btn {
padding: 0;
background: none;
border: none;
font-size: 0.8125rem;
color: #2563eb;
cursor: pointer;
}
.link-btn:hover {
text-decoration: underline;
}
/* Filter tabs */
.filter-tabs {
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: #f8fafc;
border-radius: 8px;
}
.filter-tab {
flex: 1;
padding: 0.5rem;
background: none;
border: none;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
color: #64748b;
cursor: pointer;
}
.filter-tab:hover {
color: #1e293b;
}
.filter-tab--active {
background: white;
color: #1e293b;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
/* Items list */
.items-list {
max-height: 280px;
overflow-y: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.item-row {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-bottom: 1px solid #f1f5f9;
cursor: pointer;
}
.item-row:last-child {
border-bottom: none;
}
.item-row:hover {
background: #f8fafc;
}
.item-row--selected {
background: #eff6ff;
}
.item-row input {
width: 16px;
height: 16px;
}
.item-row__icon {
display: flex;
padding: 0.375rem;
border-radius: 6px;
}
.item-row__icon--evidence { background: #dbeafe; color: #2563eb; }
.item-row__icon--sbom { background: #d1fae5; color: #059669; }
.item-row__icon--report { background: #fef3c7; color: #d97706; }
.item-row__icon--attestation { background: #f3e8ff; color: #7c3aed; }
.item-row__info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.item-row__name {
font-size: 0.8125rem;
font-weight: 500;
color: #1e293b;
}
.item-row__meta {
font-size: 0.6875rem;
color: #64748b;
}
.item-row__digest {
font-size: 0.6875rem;
font-family: monospace;
color: #64748b;
background: #f1f5f9;
padding: 0.125rem 0.375rem;
border-radius: 4px;
}
.empty-state {
padding: 2rem;
text-align: center;
color: #64748b;
}
/* Summary */
.summary-box {
padding: 1rem;
background: #f8fafc;
border-radius: 8px;
}
.summary-box h4 {
margin: 0 0 0.75rem;
font-size: 0.875rem;
font-weight: 600;
}
.summary-box dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 1rem;
margin: 0;
font-size: 0.8125rem;
}
.summary-box dt {
color: #64748b;
}
.summary-box dd {
margin: 0;
font-weight: 500;
}
/* Radio group */
.radio-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.radio-group--horizontal {
flex-direction: row;
}
.radio-option {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 8px;
cursor: pointer;
}
.radio-option:hover {
border-color: #93c5fd;
background: #eff6ff;
}
.radio-option:has(input:checked) {
border-color: #3b82f6;
background: #eff6ff;
}
.radio-option--compact {
padding: 0.625rem 1rem;
}
.radio-label {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.radio-label strong {
font-size: 0.875rem;
}
.radio-label small {
font-size: 0.75rem;
color: #64748b;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border: none;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn--primary {
background: #2563eb;
color: white;
}
.btn--primary:hover:not(:disabled) {
background: #1d4ed8;
}
.btn--secondary {
background: #f8fafc;
color: #1e293b;
border: 1px solid #e2e8f0;
}
.btn--secondary:hover:not(:disabled) {
background: #f1f5f9;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`],
})
export class AuditBundleCreateModalComponent {
@Input() open = false;
@Input() availableItems: SelectableItem[] = [];
@Output() closed = new EventEmitter<void>();
@Output() created = new EventEmitter<AuditBundleConfig>();
readonly currentStep = signal(1);
readonly bundleName = signal('');
readonly bundleDescription = signal('');
readonly selectedItemIds = signal<string[]>([]);
readonly itemFilter = signal<'all' | 'evidence' | 'sbom' | 'report' | 'attestation'>('all');
readonly includeFullContent = signal(true);
readonly exportFormat = signal<'json' | 'zip'>('json');
readonly generating = signal(false);
readonly filteredItems = computed(() => {
const filter = this.itemFilter();
if (filter === 'all') return this.availableItems;
return this.availableItems.filter(item => item.type === filter);
});
readonly canProceed = computed(() => {
const step = this.currentStep();
if (step === 1) return this.bundleName().trim().length > 0;
if (step === 2) return this.selectedItemIds().length > 0;
return true;
});
close(): void {
this.resetForm();
this.closed.emit();
}
onBackdropClick(event: MouseEvent): void {
if ((event.target as HTMLElement).classList.contains('modal-backdrop')) {
this.close();
}
}
nextStep(): void {
if (this.currentStep() < 3 && this.canProceed()) {
this.currentStep.update(s => s + 1);
}
}
previousStep(): void {
if (this.currentStep() > 1) {
this.currentStep.update(s => s - 1);
}
}
isSelected(id: string): boolean {
return this.selectedItemIds().includes(id);
}
toggleItem(id: string): void {
this.selectedItemIds.update(ids => {
if (ids.includes(id)) {
return ids.filter(i => i !== id);
}
return [...ids, id];
});
}
selectAll(): void {
this.selectedItemIds.set(this.filteredItems().map(i => i.id));
}
clearSelection(): void {
this.selectedItemIds.set([]);
}
generate(): void {
if (this.generating()) return;
this.generating.set(true);
const config: AuditBundleConfig = {
name: this.bundleName(),
description: this.bundleDescription(),
selectedItems: this.selectedItemIds(),
includeFullContent: this.includeFullContent(),
exportFormat: this.exportFormat(),
};
// Simulate generation
setTimeout(() => {
this.created.emit(config);
this.generating.set(false);
this.close();
}, 1000);
}
private resetForm(): void {
this.currentStep.set(1);
this.bundleName.set('');
this.bundleDescription.set('');
this.selectedItemIds.set([]);
this.itemFilter.set('all');
this.includeFullContent.set(true);
this.exportFormat.set('json');
}
}

View File

@@ -1,45 +1,47 @@
@use '../../../styles/tokens/breakpoints' as *;
.approval-queue {
display: flex;
flex-direction: column;
gap: 1rem;
gap: var(--space-4);
}
.queue-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
padding-bottom: 0.75rem;
gap: var(--space-4);
border-bottom: 1px solid var(--color-border-primary);
padding-bottom: var(--space-3);
}
.queue-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text, #111827);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.queue-subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted, #6b7280);
font-size: 0.875rem;
margin: var(--space-1) 0 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.queue-actions {
display: flex;
gap: 0.5rem;
gap: var(--space-2);
}
.queue-controls {
display: flex;
flex-wrap: wrap;
gap: 1rem;
gap: var(--space-4);
align-items: flex-end;
padding: 1rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
background: var(--color-bg-card, white);
padding: var(--space-4);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
}
.control-group {
@@ -48,102 +50,116 @@
.control-actions {
display: flex;
gap: 0.5rem;
gap: var(--space-2);
}
.queue-table {
display: grid;
gap: 0.5rem;
gap: var(--space-2);
}
.queue-row {
display: grid;
grid-template-columns: 80px 2fr 1fr 1.4fr 1.6fr 1fr;
gap: 0.75rem;
gap: var(--space-3);
align-items: center;
padding: 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
background: var(--color-bg-card, white);
font-size: 0.8125rem;
padding: var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
font-size: var(--font-size-sm);
&.queue-header-row {
background: var(--color-bg-subtle, #f9fafb);
font-weight: 600;
color: var(--color-text-muted, #6b7280);
background: var(--color-surface-secondary);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
}
}
.exception-name {
font-weight: 600;
color: var(--color-text, #1f2937);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.exception-meta {
color: var(--color-text-muted, #6b7280);
font-size: 0.75rem;
color: var(--color-text-muted);
font-size: var(--font-size-xs);
}
.alert {
padding: 0.75rem 1rem;
border-radius: 6px;
background: #fee2e2;
color: #b91c1c;
font-size: 0.875rem;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
background: var(--color-status-error-bg);
color: var(--color-status-error);
font-size: var(--font-size-sm);
}
.state-panel {
padding: 1.5rem;
border: 1px dashed var(--color-border, #e5e7eb);
border-radius: 6px;
padding: var(--space-6);
border: 1px dashed var(--color-border-primary);
border-radius: var(--radius-md);
text-align: center;
color: var(--color-text-muted, #6b7280);
color: var(--color-text-muted);
}
.field-label {
display: block;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
margin-bottom: 0.25rem;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
margin-bottom: var(--space-1);
}
.field-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.8125rem;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
background: var(--color-surface-primary);
color: var(--color-text-primary);
}
.btn-primary,
.btn-secondary,
.btn-danger {
padding: 0.5rem 1rem;
border-radius: 4px;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-sm);
border: none;
font-size: 0.8125rem;
font-size: var(--font-size-sm);
cursor: pointer;
text-decoration: none;
text-align: center;
}
.btn-primary {
background: var(--color-primary, #2563eb);
color: white;
background: var(--color-brand-primary);
color: var(--color-text-inverse);
&:hover {
background: var(--color-brand-primary-hover);
}
}
.btn-secondary {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
color: var(--color-text, #374151);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
color: var(--color-text-secondary);
&:hover {
background: var(--color-surface-secondary);
}
}
.btn-danger {
background: #dc2626;
color: white;
background: var(--color-status-error);
color: var(--color-text-inverse);
&:hover {
background: var(--color-status-error-hover);
}
}
@media (max-width: 1100px) {
@include screen-below-lg {
.queue-row {
grid-template-columns: 60px 2fr 1fr;
grid-auto-rows: minmax(24px, auto);

View File

@@ -1,8 +1,10 @@
@use '../../../styles/tokens/breakpoints' as *;
.exception-center {
display: flex;
flex-direction: column;
height: 100%;
background: var(--color-bg, #f9fafb);
background: var(--color-surface-secondary);
}
// Header
@@ -10,142 +12,142 @@
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
background: var(--color-bg-card, white);
border-bottom: 1px solid var(--color-border, #e5e7eb);
gap: var(--space-4);
padding: var(--space-4);
background: var(--color-surface-primary);
border-bottom: 1px solid var(--color-border-primary);
flex-wrap: wrap;
}
.header-left {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: var(--space-2);
}
.center-title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text, #111827);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.status-chips {
display: flex;
gap: 0.375rem;
gap: var(--space-1-5);
flex-wrap: wrap;
}
.status-chip {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border: 1px solid;
border-radius: 4px;
font-size: 0.6875rem;
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
cursor: pointer;
background: var(--color-bg-card, white);
color: var(--color-text-muted, #6b7280);
background: var(--color-surface-primary);
color: var(--color-text-muted);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
background: var(--color-surface-secondary);
}
&.active {
background: var(--color-bg-subtle, #f3f4f6);
font-weight: 600;
background: var(--color-surface-tertiary);
font-weight: var(--font-weight-semibold);
}
}
.chip-count {
font-size: 0.625rem;
padding: 0 0.25rem;
background: var(--color-bg-subtle, #e5e7eb);
border-radius: 8px;
padding: 0 var(--space-1);
background: var(--color-surface-tertiary);
border-radius: var(--radius-lg);
}
.header-right {
display: flex;
gap: 0.5rem;
gap: var(--space-2);
align-items: center;
}
.view-toggle {
display: flex;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
overflow: hidden;
}
.toggle-btn {
padding: 0.375rem 0.625rem;
background: var(--color-bg-card, white);
padding: var(--space-1-5) var(--space-2-5);
background: var(--color-surface-primary);
border: none;
font-family: monospace;
font-size: 0.875rem;
font-family: var(--font-family-mono);
font-size: var(--font-size-base);
cursor: pointer;
color: var(--color-text-muted, #6b7280);
color: var(--color-text-muted);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
background: var(--color-surface-secondary);
}
&.active {
background: var(--color-primary, #2563eb);
color: white;
background: var(--color-brand-primary);
color: var(--color-text-inverse);
}
&:not(:last-child) {
border-right: 1px solid var(--color-border, #e5e7eb);
border-right: 1px solid var(--color-border-primary);
}
}
.btn-filter {
padding: 0.375rem 0.75rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
padding: var(--space-1-5) var(--space-3);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
cursor: pointer;
color: var(--color-text, #374151);
color: var(--color-text-secondary);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
background: var(--color-surface-secondary);
}
&.active {
background: var(--color-primary-bg, #eff6ff);
border-color: var(--color-primary, #2563eb);
color: var(--color-primary, #2563eb);
background: var(--color-brand-light);
border-color: var(--color-brand-primary);
color: var(--color-brand-primary);
}
}
.btn-create {
padding: 0.375rem 1rem;
background: var(--color-primary, #2563eb);
padding: var(--space-1-5) var(--space-4);
background: var(--color-brand-primary);
border: none;
border-radius: 4px;
font-size: 0.8125rem;
font-weight: 600;
color: white;
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-inverse);
cursor: pointer;
&:hover {
background: var(--color-primary-dark, #1d4ed8);
background: var(--color-brand-primary-hover);
}
}
// Filters Panel
.filters-panel {
padding: 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
padding: var(--space-4);
background: var(--color-surface-secondary);
border-bottom: 1px solid var(--color-border-primary);
}
.filter-row {
display: flex;
gap: 1.5rem;
gap: var(--space-6);
flex-wrap: wrap;
align-items: flex-start;
}
@@ -153,33 +155,35 @@
.filter-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
gap: var(--space-1-5);
}
.filter-label {
font-size: 0.6875rem;
font-weight: 600;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
color: var(--color-text-muted);
}
.filter-input {
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.8125rem;
padding: var(--space-1-5) var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
min-width: 200px;
background: var(--color-surface-primary);
color: var(--color-text-primary);
&:focus {
outline: 2px solid var(--color-primary, #2563eb);
outline: 2px solid var(--color-brand-primary);
outline-offset: -1px;
}
}
.filter-chips {
display: flex;
gap: 0.25rem;
gap: var(--space-1);
flex-wrap: wrap;
&.tags {
@@ -188,50 +192,50 @@
}
.filter-chip {
padding: 0.25rem 0.5rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 4px;
font-size: 0.75rem;
padding: var(--space-1) var(--space-2);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
cursor: pointer;
color: var(--color-text-muted, #6b7280);
color: var(--color-text-muted);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
background: var(--color-surface-secondary);
}
&.active {
background: var(--color-primary, #2563eb);
color: white;
border-color: var(--color-primary, #2563eb);
background: var(--color-brand-primary);
color: var(--color-text-inverse);
border-color: var(--color-brand-primary);
}
&.sev-critical.active { background: var(--color-critical, #dc2626); border-color: var(--color-critical, #dc2626); }
&.sev-high.active { background: var(--color-error, #ea580c); border-color: var(--color-error, #ea580c); }
&.sev-medium.active { background: var(--color-warning, #d97706); border-color: var(--color-warning, #d97706); }
&.sev-low.active { background: var(--color-info, #0284c7); border-color: var(--color-info, #0284c7); }
&.sev-critical.active { background: var(--color-severity-critical); border-color: var(--color-severity-critical); }
&.sev-high.active { background: var(--color-severity-high); border-color: var(--color-severity-high); }
&.sev-medium.active { background: var(--color-severity-medium); border-color: var(--color-severity-medium); }
&.sev-low.active { background: var(--color-status-info); border-color: var(--color-status-info); }
&.tag {
font-size: 0.6875rem;
font-size: var(--font-size-xs);
}
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: var(--color-text, #374151);
gap: var(--space-1-5);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
cursor: pointer;
}
.btn-clear-filters {
margin-top: 0.75rem;
padding: 0.25rem 0.5rem;
margin-top: var(--space-3);
padding: var(--space-1) var(--space-2);
background: none;
border: none;
font-size: 0.75rem;
color: var(--color-primary, #2563eb);
font-size: var(--font-size-sm);
color: var(--color-brand-primary);
cursor: pointer;
&:hover {
@@ -243,16 +247,16 @@
.list-view {
flex: 1;
overflow: auto;
background: var(--color-bg-card, white);
background: var(--color-surface-primary);
}
.list-header {
display: grid;
grid-template-columns: 2fr 100px 120px 100px 100px 150px;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--color-bg-subtle, #f9fafb);
border-bottom: 1px solid var(--color-border, #e5e7eb);
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
background: var(--color-surface-secondary);
border-bottom: 1px solid var(--color-border-primary);
position: sticky;
top: 0;
z-index: 1;
@@ -261,20 +265,20 @@
.sort-btn {
background: none;
border: none;
padding: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
padding: var(--space-1);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
color: var(--color-text-muted);
cursor: pointer;
text-align: left;
display: flex;
align-items: center;
gap: 0.25rem;
gap: var(--space-1);
&:hover {
color: var(--color-text, #374151);
color: var(--color-text-secondary);
}
}
@@ -283,12 +287,12 @@
}
.col-header {
font-size: 0.6875rem;
font-weight: 600;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted, #6b7280);
padding: 0.25rem;
color: var(--color-text-muted);
padding: var(--space-1);
}
.list-body {
@@ -299,26 +303,26 @@
.exception-row {
display: flex;
align-items: center;
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
border-bottom: 1px solid var(--color-border-secondary);
&:hover {
background: var(--color-bg-hover, #f9fafb);
background: var(--color-surface-secondary);
}
&.status-draft { border-left: 3px solid #9ca3af; }
&.status-pending { border-left: 3px solid #f59e0b; }
&.status-approved { border-left: 3px solid #3b82f6; }
&.status-active { border-left: 3px solid #10b981; }
&.status-expired { border-left: 3px solid #6b7280; }
&.status-revoked { border-left: 3px solid #ef4444; }
&.status-draft { border-left: 3px solid var(--color-text-muted); }
&.status-pending { border-left: 3px solid var(--color-status-warning); }
&.status-approved { border-left: 3px solid var(--color-brand-primary); }
&.status-active { border-left: 3px solid var(--color-status-success); }
&.status-expired { border-left: 3px solid var(--color-text-muted); }
&.status-revoked { border-left: 3px solid var(--color-status-error); }
}
.row-main {
display: grid;
grid-template-columns: 2fr 100px 120px 100px 100px;
gap: 0.5rem;
gap: var(--space-2);
flex: 1;
padding: 0.75rem 1rem;
padding: var(--space-3) var(--space-4);
background: none;
border: none;
cursor: pointer;
@@ -328,7 +332,7 @@
.exc-title-cell {
display: flex;
align-items: center;
gap: 0.5rem;
gap: var(--space-2);
min-width: 0;
}
@@ -338,12 +342,12 @@
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 4px;
font-family: monospace;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted, #6b7280);
background: var(--color-surface-tertiary);
border-radius: var(--radius-sm);
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
flex-shrink: 0;
}
@@ -354,91 +358,91 @@
}
.exc-title {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text, #111827);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.exc-id {
font-size: 0.6875rem;
font-family: monospace;
color: var(--color-text-muted, #9ca3af);
font-size: var(--font-size-xs);
font-family: var(--font-family-mono);
color: var(--color-text-muted);
}
.severity-badge {
display: inline-block;
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.6875rem;
font-weight: 600;
padding: var(--space-0-5) var(--space-1-5);
border-radius: var(--radius-xs);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
&.severity-critical { background: #fef2f2; color: #dc2626; }
&.severity-high { background: #fff7ed; color: #ea580c; }
&.severity-medium { background: #fffbeb; color: #d97706; }
&.severity-low { background: #f0f9ff; color: #0284c7; }
&.severity-critical { background: var(--color-severity-critical-bg); color: var(--color-severity-critical); }
&.severity-high { background: var(--color-severity-high-bg); color: var(--color-severity-high); }
&.severity-medium { background: var(--color-severity-medium-bg); color: var(--color-severity-medium); }
&.severity-low { background: var(--color-severity-low-bg); color: var(--color-severity-low); }
}
.status-badge {
font-size: 0.75rem;
font-family: monospace;
font-size: var(--font-size-sm);
font-family: var(--font-family-mono);
&.status-draft { color: #6b7280; }
&.status-pending { color: #d97706; }
&.status-approved { color: #2563eb; }
&.status-active { color: #059669; }
&.status-expired { color: #6b7280; }
&.status-revoked { color: #dc2626; }
&.status-draft { color: var(--color-text-muted); }
&.status-pending { color: var(--color-status-warning); }
&.status-approved { color: var(--color-brand-primary); }
&.status-active { color: var(--color-status-success); }
&.status-expired { color: var(--color-text-muted); }
&.status-revoked { color: var(--color-status-error); }
}
.expires-text {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
&.warning { color: var(--color-warning, #d97706); font-weight: 500; }
&.expired { color: var(--color-error, #dc2626); font-weight: 500; }
&.warning { color: var(--color-status-warning); font-weight: var(--font-weight-medium); }
&.expired { color: var(--color-status-error); font-weight: var(--font-weight-medium); }
}
.exc-updated-cell {
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.row-actions {
display: flex;
gap: 0.25rem;
padding: 0.5rem;
gap: var(--space-1);
padding: var(--space-2);
}
.action-btn {
padding: 0.25rem 0.5rem;
background: var(--color-bg-subtle, #f3f4f6);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 3px;
font-size: 0.6875rem;
padding: var(--space-1) var(--space-2);
background: var(--color-surface-tertiary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xs);
font-size: var(--font-size-xs);
cursor: pointer;
color: var(--color-text, #374151);
color: var(--color-text-secondary);
&:hover {
background: var(--color-bg-hover, #e5e7eb);
background: var(--color-surface-secondary);
}
&.audit {
font-family: monospace;
font-family: var(--font-family-mono);
}
}
.empty-state {
padding: 3rem;
padding: var(--space-12);
text-align: center;
color: var(--color-text-muted, #9ca3af);
color: var(--color-text-muted);
.btn-link {
background: none;
border: none;
color: var(--color-primary, #2563eb);
color: var(--color-brand-primary);
cursor: pointer;
&:hover {
@@ -450,8 +454,8 @@
// Kanban View
.kanban-view {
display: flex;
gap: 1rem;
padding: 1rem;
gap: var(--space-4);
padding: var(--space-4);
flex: 1;
overflow-x: auto;
}
@@ -460,8 +464,8 @@
flex: 0 0 280px;
display: flex;
flex-direction: column;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 8px;
background: var(--color-surface-tertiary);
border-radius: var(--radius-lg);
max-height: 100%;
}
@@ -469,58 +473,58 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
padding: var(--space-3);
border-bottom: 3px solid;
background: var(--color-bg-card, white);
border-radius: 8px 8px 0 0;
background: var(--color-surface-primary);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.column-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text, #374151);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
}
.column-count {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
background: var(--color-bg-subtle, #e5e7eb);
font-size: var(--font-size-sm);
padding: var(--space-0-5) var(--space-2);
background: var(--color-surface-tertiary);
border-radius: 10px;
color: var(--color-text-muted, #6b7280);
color: var(--color-text-muted);
}
.column-body {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
padding: var(--space-2);
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: var(--space-2);
}
.kanban-card {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
overflow: hidden;
&.severity-critical { border-left: 3px solid #dc2626; }
&.severity-high { border-left: 3px solid #ea580c; }
&.severity-medium { border-left: 3px solid #d97706; }
&.severity-low { border-left: 3px solid #0284c7; }
&.severity-critical { border-left: 3px solid var(--color-severity-critical); }
&.severity-high { border-left: 3px solid var(--color-severity-high); }
&.severity-medium { border-left: 3px solid var(--color-severity-medium); }
&.severity-low { border-left: 3px solid var(--color-severity-low); }
}
.card-main {
display: block;
width: 100%;
padding: 0.75rem;
padding: var(--space-3);
background: none;
border: none;
cursor: pointer;
text-align: left;
&:hover {
background: var(--color-bg-hover, #f9fafb);
background: var(--color-surface-secondary);
}
}
@@ -528,7 +532,7 @@
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
margin-bottom: var(--space-2);
}
.severity-dot {
@@ -536,101 +540,101 @@
height: 8px;
border-radius: 50%;
&.severity-critical { background: #dc2626; }
&.severity-high { background: #ea580c; }
&.severity-medium { background: #d97706; }
&.severity-low { background: #0284c7; }
&.severity-critical { background: var(--color-severity-critical); }
&.severity-high { background: var(--color-severity-high); }
&.severity-medium { background: var(--color-severity-medium); }
&.severity-low { background: var(--color-severity-low); }
}
.card-title {
margin: 0 0 0.25rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text, #111827);
margin: 0 0 var(--space-1);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.card-id {
margin: 0 0 0.5rem;
font-size: 0.6875rem;
font-family: monospace;
color: var(--color-text-muted, #9ca3af);
margin: 0 0 var(--space-2);
font-size: var(--font-size-xs);
font-family: var(--font-family-mono);
color: var(--color-text-muted);
}
.card-meta {
margin-bottom: 0.5rem;
margin-bottom: var(--space-2);
}
.expires-badge {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background: var(--color-bg-subtle, #f3f4f6);
border-radius: 3px;
color: var(--color-text-muted, #6b7280);
font-size: var(--font-size-xs);
padding: var(--space-0-5) var(--space-1-5);
background: var(--color-surface-tertiary);
border-radius: var(--radius-xs);
color: var(--color-text-muted);
&.warning {
background: var(--color-warning-bg, #fef3c7);
color: var(--color-warning, #d97706);
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
&.expired {
background: var(--color-error-bg, #fef2f2);
color: var(--color-error, #dc2626);
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
}
.card-tags {
display: flex;
gap: 0.25rem;
gap: var(--space-1);
flex-wrap: wrap;
}
.tag {
font-size: 0.625rem;
padding: 0.0625rem 0.25rem;
background: var(--color-bg-subtle, #e5e7eb);
border-radius: 2px;
color: var(--color-text-muted, #6b7280);
padding: var(--space-0-5) var(--space-1);
background: var(--color-surface-tertiary);
border-radius: var(--radius-xs);
color: var(--color-text-muted);
}
.card-actions {
display: flex;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: var(--color-bg-subtle, #f9fafb);
border-top: 1px solid var(--color-border-light, #f3f4f6);
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
background: var(--color-surface-secondary);
border-top: 1px solid var(--color-border-secondary);
}
.card-action-btn {
flex: 1;
padding: 0.25rem;
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 3px;
padding: var(--space-1);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xs);
font-size: 0.625rem;
cursor: pointer;
color: var(--color-text, #374151);
color: var(--color-text-secondary);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
background: var(--color-surface-secondary);
}
}
.column-empty {
padding: 1rem;
padding: var(--space-4);
text-align: center;
font-size: 0.75rem;
color: var(--color-text-muted, #9ca3af);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-style: italic;
}
// Footer
.center-footer {
padding: 0.5rem 1rem;
background: var(--color-bg-card, white);
border-top: 1px solid var(--color-border, #e5e7eb);
padding: var(--space-2) var(--space-4);
background: var(--color-surface-primary);
border-top: 1px solid var(--color-border-primary);
}
.total-count {
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}

View File

@@ -1,95 +1,98 @@
@use '../../../styles/tokens/breakpoints' as *;
.exception-dashboard {
display: flex;
flex-direction: column;
gap: 1rem;
gap: var(--space-4);
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem 0;
border-bottom: 1px solid var(--color-border, #e5e7eb);
gap: var(--space-4);
padding: var(--space-4) 0;
border-bottom: 1px solid var(--color-border-primary);
}
.dashboard-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: var(--color-text, #111827);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.dashboard-subtitle {
margin: 0.25rem 0 0;
color: var(--color-text-muted, #6b7280);
font-size: 0.875rem;
margin: var(--space-1) 0 0;
color: var(--color-text-muted);
font-size: var(--font-size-base);
}
.dashboard-actions {
display: flex;
align-items: center;
gap: 0.5rem;
gap: var(--space-2);
}
.btn-primary,
.btn-secondary {
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-sm);
font-size: var(--font-size-base);
cursor: pointer;
border: none;
text-decoration: none;
text-align: center;
transition: background-color var(--motion-duration-fast) var(--motion-ease-default);
}
.btn-primary {
background: var(--color-primary, #2563eb);
color: white;
background: var(--color-brand-primary);
color: var(--color-text-inverse);
&:hover {
background: var(--color-primary-dark, #1d4ed8);
background: var(--color-brand-primary-hover);
}
}
.btn-secondary {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
color: var(--color-text, #374151);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
color: var(--color-text-secondary);
&:hover {
background: var(--color-bg-hover, #f3f4f6);
background: var(--color-surface-tertiary);
}
}
.alert {
padding: 0.75rem 1rem;
border-radius: 6px;
font-size: 0.875rem;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
&.alert-error {
background: #fee2e2;
color: #b91c1c;
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
&.alert-warning {
background: #fef3c7;
color: #92400e;
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
}
.state-panel {
padding: 1.5rem;
border: 1px dashed var(--color-border, #e5e7eb);
border-radius: 6px;
padding: var(--space-6);
border: 1px dashed var(--color-border-primary);
border-radius: var(--radius-md);
text-align: center;
color: var(--color-text-muted, #6b7280);
color: var(--color-text-muted);
}
.dashboard-body {
display: grid;
grid-template-columns: 2.2fr 1fr;
gap: 1rem;
gap: var(--space-4);
align-items: start;
&.has-detail {
@@ -102,10 +105,10 @@
}
.detail-pane {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
padding: 1rem;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: var(--space-4);
&.empty {
display: flex;
@@ -122,7 +125,7 @@
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
padding: var(--space-4);
z-index: 1000;
}
@@ -130,12 +133,12 @@
width: min(900px, 100%);
max-height: 90vh;
overflow: auto;
background: var(--color-bg-card, white);
border-radius: 10px;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.25);
background: var(--color-surface-primary);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
}
@media (max-width: 1024px) {
@include screen-below-lg {
.dashboard-body {
grid-template-columns: 1fr;
}

View File

@@ -1,110 +1,116 @@
@use '../../../styles/tokens/breakpoints' as *;
.detail-container {
display: flex;
flex-direction: column;
gap: 1rem;
gap: var(--space-4);
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
border-bottom: 1px solid var(--color-border, #e5e7eb);
padding-bottom: 0.75rem;
gap: var(--space-4);
border-bottom: 1px solid var(--color-border-primary);
padding-bottom: var(--space-3);
}
.detail-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text, #111827);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.detail-subtitle {
margin: 0.25rem 0 0;
font-size: 0.75rem;
color: var(--color-text-muted, #6b7280);
margin: var(--space-1) 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.detail-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: var(--space-3);
}
.section-title {
font-size: 0.75rem;
font-size: var(--font-size-sm);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted, #6b7280);
color: var(--color-text-muted);
margin: 0;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.75rem;
gap: var(--space-3);
}
.detail-label {
display: block;
font-size: 0.6875rem;
color: var(--color-text-muted, #6b7280);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.detail-value {
font-size: 0.8125rem;
color: var(--color-text, #374151);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.scope-summary {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
gap: var(--space-2);
}
.scope-chip {
padding: 0.25rem 0.5rem;
border-radius: 999px;
background: var(--color-bg-subtle, #f3f4f6);
font-size: 0.75rem;
color: var(--color-text, #374151);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-full);
background: var(--color-surface-tertiary);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.text-area {
min-height: 90px;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
padding: 0.75rem;
font-size: 0.875rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
padding: var(--space-3);
font-size: var(--font-size-base);
resize: vertical;
background: var(--color-surface-primary);
color: var(--color-text-primary);
&:disabled {
background: #f9fafb;
background: var(--color-surface-secondary);
}
}
.labels-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: var(--space-2);
}
.label-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.5rem;
gap: var(--space-2);
align-items: center;
}
.field-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 6px;
font-size: 0.8125rem;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
background: var(--color-surface-primary);
color: var(--color-text-primary);
&:disabled {
background: #f9fafb;
background: var(--color-surface-secondary);
}
}
@@ -115,25 +121,25 @@
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: var(--space-2);
}
.extend-row {
display: flex;
gap: 0.5rem;
gap: var(--space-2);
align-items: center;
}
.transition-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
gap: var(--space-2);
}
.transition-comment {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: var(--space-1);
}
.detail-footer {
@@ -144,42 +150,42 @@
.btn-primary,
.btn-secondary,
.btn-link {
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.8125rem;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
font-size: var(--font-size-sm);
cursor: pointer;
border: none;
background: none;
}
.btn-primary {
background: var(--color-primary, #2563eb);
color: white;
background: var(--color-brand-primary);
color: var(--color-text-inverse);
&:disabled {
background: #9ca3af;
background: var(--color-text-muted);
cursor: not-allowed;
}
}
.btn-secondary {
background: var(--color-bg-card, white);
border: 1px solid var(--color-border, #e5e7eb);
color: var(--color-text, #374151);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
color: var(--color-text-secondary);
}
.btn-link {
color: var(--color-primary, #2563eb);
color: var(--color-brand-primary);
&.danger {
color: var(--color-error, #dc2626);
color: var(--color-status-error);
}
}
.alert {
padding: 0.5rem 0.75rem;
border-radius: 6px;
background: #fee2e2;
color: #b91c1c;
font-size: 0.8125rem;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
background: var(--color-status-error-bg);
color: var(--color-status-error);
font-size: var(--font-size-sm);
}

View File

@@ -1,73 +1,75 @@
@use '../../../styles/tokens/breakpoints' as *;
.draft-inline {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background: white;
border-radius: 0.5rem;
border: 1px solid #e2e8f0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
gap: var(--space-4);
padding: var(--space-4);
background: var(--color-surface-primary);
border-radius: var(--radius-lg);
border: 1px solid var(--color-border-primary);
box-shadow: var(--shadow-md);
}
// Header
.draft-inline__header {
display: flex;
align-items: baseline;
gap: 0.5rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #f1f5f9;
gap: var(--space-2);
padding-bottom: var(--space-3);
border-bottom: 1px solid var(--color-border-secondary);
h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #1e293b;
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
}
.draft-inline__source {
font-size: 0.75rem;
color: #64748b;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// Error
.draft-inline__error {
padding: 0.5rem 0.75rem;
background: #fef2f2;
color: #991b1b;
border: 1px solid #fca5a5;
border-radius: 0.375rem;
font-size: 0.8125rem;
padding: var(--space-2) var(--space-3);
background: var(--color-status-error-bg);
color: var(--color-status-error);
border: 1px solid var(--color-status-error);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
}
// Scope
.draft-inline__scope {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #f8fafc;
border-radius: 0.375rem;
font-size: 0.8125rem;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
}
.draft-inline__scope-label {
color: #64748b;
font-weight: 500;
color: var(--color-text-muted);
font-weight: var(--font-weight-medium);
}
.draft-inline__scope-value {
color: #1e293b;
color: var(--color-text-primary);
flex: 1;
}
.draft-inline__scope-type {
padding: 0.125rem 0.5rem;
background: #e0e7ff;
color: #4338ca;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
padding: var(--space-0-5) var(--space-2);
background: var(--color-brand-light);
color: var(--color-brand-primary);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
text-transform: uppercase;
}
@@ -76,54 +78,54 @@
.draft-inline__components {
display: flex;
flex-direction: column;
gap: 0.375rem;
gap: var(--space-1-5);
}
.draft-inline__vulns-label,
.draft-inline__components-label {
font-size: 0.75rem;
color: #64748b;
font-weight: 500;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-weight: var(--font-weight-medium);
}
.draft-inline__vulns-list,
.draft-inline__components-list {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
gap: var(--space-1-5);
}
.vuln-chip {
display: inline-flex;
padding: 0.125rem 0.5rem;
background: #fef2f2;
color: #dc2626;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-family: ui-monospace, monospace;
padding: var(--space-0-5) var(--space-2);
background: var(--color-status-error-bg);
color: var(--color-status-error);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-family: var(--font-family-mono);
&--more {
background: #f1f5f9;
color: #64748b;
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
}
}
.component-chip {
display: inline-flex;
padding: 0.125rem 0.5rem;
background: #f0fdf4;
color: #166534;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-family: ui-monospace, monospace;
padding: var(--space-0-5) var(--space-2);
background: var(--color-status-success-bg);
color: var(--color-status-success);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-family: var(--font-family-mono);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&--more {
background: #f1f5f9;
color: #64748b;
background: var(--color-surface-tertiary);
color: var(--color-text-muted);
}
}
@@ -131,42 +133,44 @@
.draft-inline__form {
display: flex;
flex-direction: column;
gap: 0.875rem;
gap: var(--space-3-5);
}
.form-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: var(--space-1);
&--inline {
flex-direction: row;
align-items: center;
gap: 0.5rem;
gap: var(--space-2);
}
}
.form-label {
font-size: 0.75rem;
font-weight: 500;
color: #475569;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.required {
color: #ef4444;
color: var(--color-status-error);
}
.form-input {
padding: 0.5rem 0.625rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
font-size: 0.8125rem;
transition: border-color 0.2s ease;
padding: var(--space-2) var(--space-2-5);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
background: var(--color-surface-primary);
color: var(--color-text-primary);
transition: border-color var(--motion-duration-fast) var(--motion-ease-default);
&:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-brand-light);
}
&--small {
@@ -176,29 +180,31 @@
}
.form-textarea {
padding: 0.5rem 0.625rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
font-size: 0.8125rem;
padding: var(--space-2) var(--space-2-5);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
background: var(--color-surface-primary);
color: var(--color-text-primary);
resize: vertical;
min-height: 60px;
&:focus {
outline: none;
border-color: #4f46e5;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1);
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-brand-light);
}
}
.form-hint {
font-size: 0.6875rem;
color: #94a3b8;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
// Severity chips
.severity-chips {
display: flex;
gap: 0.375rem;
gap: var(--space-1-5);
flex-wrap: wrap;
}
@@ -216,59 +222,59 @@
.severity-chip {
display: inline-flex;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
transition: box-shadow 0.2s ease;
padding: var(--space-1) var(--space-2-5);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
transition: box-shadow var(--motion-duration-fast) var(--motion-ease-default);
&--critical {
background: #fef2f2;
color: #dc2626;
background: var(--color-severity-critical-bg);
color: var(--color-severity-critical);
}
&--high {
background: #fff7ed;
color: #ea580c;
background: var(--color-severity-high-bg);
color: var(--color-severity-high);
}
&--medium {
background: #fefce8;
color: #ca8a04;
background: var(--color-severity-medium-bg);
color: var(--color-severity-medium);
}
&--low {
background: #f0fdf4;
color: #16a34a;
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
}
// Template chips
.template-chips {
display: flex;
gap: 0.375rem;
gap: var(--space-1-5);
flex-wrap: wrap;
}
.template-chip {
padding: 0.25rem 0.625rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
background: white;
color: #475569;
font-size: 0.6875rem;
padding: var(--space-1) var(--space-2-5);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
cursor: pointer;
transition: all 0.2s ease;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: #f8fafc;
border-color: #4f46e5;
background: var(--color-surface-secondary);
border-color: var(--color-brand-primary);
}
&--selected {
background: #eef2ff;
border-color: #4f46e5;
color: #4f46e5;
background: var(--color-brand-light);
border-color: var(--color-brand-primary);
color: var(--color-brand-primary);
}
}
@@ -276,89 +282,89 @@
.timebox-quick {
display: flex;
align-items: center;
gap: 0.375rem;
gap: var(--space-1-5);
}
.timebox-btn {
padding: 0.25rem 0.5rem;
border: 1px solid #e2e8f0;
border-radius: 0.25rem;
background: white;
color: #64748b;
font-size: 0.6875rem;
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-primary);
color: var(--color-text-muted);
font-size: var(--font-size-xs);
cursor: pointer;
&:hover {
background: #f1f5f9;
border-color: #4f46e5;
color: #4f46e5;
background: var(--color-surface-tertiary);
border-color: var(--color-brand-primary);
color: var(--color-brand-primary);
}
}
.timebox-label {
font-size: 0.75rem;
color: #64748b;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// Simulation
.draft-inline__simulation {
padding-top: 0.75rem;
border-top: 1px solid #f1f5f9;
padding-top: var(--space-3);
border-top: 1px solid var(--color-border-secondary);
}
.simulation-toggle {
padding: 0.375rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
background: white;
color: #4f46e5;
font-size: 0.75rem;
padding: var(--space-1-5) var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-brand-primary);
font-size: var(--font-size-sm);
cursor: pointer;
&:hover {
background: #eef2ff;
background: var(--color-brand-light);
}
}
.simulation-result {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.75rem;
background: #f8fafc;
border-radius: 0.375rem;
gap: var(--space-2);
margin-top: var(--space-3);
padding: var(--space-3);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
}
.simulation-stat {
display: flex;
flex-direction: column;
gap: 0.125rem;
gap: var(--space-0-5);
text-align: center;
}
.simulation-stat__label {
font-size: 0.625rem;
color: #64748b;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.simulation-stat__value {
font-size: 0.875rem;
font-weight: 600;
color: #1e293b;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
&--high {
color: #dc2626;
color: var(--color-status-error);
}
&--moderate {
color: #f59e0b;
color: var(--color-status-warning);
}
&--low {
color: #10b981;
color: var(--color-status-success);
}
}
@@ -366,9 +372,9 @@
.draft-inline__footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid #f1f5f9;
gap: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--color-border-secondary);
}
// Buttons
@@ -376,13 +382,13 @@
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
padding: var(--space-2) var(--space-4);
border: none;
border-radius: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all 0.2s ease;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:disabled {
opacity: 0.6;
@@ -390,36 +396,36 @@
}
&--primary {
background: #4f46e5;
color: white;
background: var(--color-brand-primary);
color: var(--color-text-inverse);
&:hover:not(:disabled) {
background: #4338ca;
background: var(--color-brand-primary-hover);
}
}
&--secondary {
background: white;
color: #475569;
border: 1px solid #e2e8f0;
background: var(--color-surface-primary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border-primary);
&:hover:not(:disabled) {
background: #f8fafc;
background: var(--color-surface-secondary);
}
}
&--text {
background: transparent;
color: #64748b;
color: var(--color-text-muted);
&:hover:not(:disabled) {
color: #1e293b;
color: var(--color-text-primary);
}
}
}
// Responsive
@media (max-width: 480px) {
@include screen-below-sm {
.simulation-result {
grid-template-columns: 1fr;
}

View File

@@ -1,9 +1,11 @@
@use '../../../styles/tokens/breakpoints' as *;
.feed-mirror-page {
display: grid;
gap: 1.5rem;
padding: 1.5rem;
color: #e2e8f0;
background: #0f172a;
gap: var(--space-6);
padding: var(--space-6);
color: var(--color-text-primary);
background: var(--color-surface-primary);
min-height: calc(100vh - 120px);
}
@@ -12,66 +14,66 @@
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
gap: var(--space-4);
flex-wrap: wrap;
}
.header-content {
h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
}
.subtitle {
margin: 0.25rem 0 0;
color: #94a3b8;
font-size: 0.875rem;
margin: var(--space-1) 0 0;
color: var(--color-text-muted);
font-size: var(--font-size-base);
}
}
// Tab Navigation
.tab-nav {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid #1f2933;
gap: var(--space-1);
border-bottom: 1px solid var(--color-border-primary);
padding-bottom: 0;
button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
gap: var(--space-2);
padding: var(--space-3) var(--space-5);
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: #94a3b8;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all 0.15s;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
color: #e2e8f0;
background: rgba(255, 255, 255, 0.03);
color: var(--color-text-primary);
background: var(--color-surface-secondary);
}
&.tab--active {
color: #3b82f6;
border-bottom-color: #3b82f6;
color: var(--color-brand-primary);
border-bottom-color: var(--color-brand-primary);
}
}
}
.tab-badge {
padding: 0.125rem 0.5rem;
border-radius: 10px;
font-size: 0.6875rem;
font-weight: 600;
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
&--error {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
}
@@ -79,48 +81,49 @@
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
gap: var(--space-4);
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
background: #111827;
border: 1px solid #1f2933;
border-radius: 8px;
padding: var(--space-4);
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
text-align: center;
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
line-height: 1;
margin-bottom: 0.25rem;
margin-bottom: var(--space-1);
color: var(--color-text-primary);
}
.stat-label {
font-size: 0.6875rem;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
color: var(--color-text-muted);
}
.stat-card--synced .stat-value {
color: #22c55e;
color: var(--color-status-success);
}
.stat-card--stale .stat-value {
color: #eab308;
color: var(--color-status-warning);
}
.stat-card--error .stat-value {
color: #ef4444;
color: var(--color-status-error);
}
.stat-card--storage .stat-value {
color: #3b82f6;
color: var(--color-brand-primary);
}
// Loading
@@ -129,18 +132,18 @@
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: #94a3b8;
padding: var(--space-12);
color: var(--color-text-muted);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #334155;
border-top-color: #3b82f6;
border: 3px solid var(--color-border-secondary);
border-top-color: var(--color-brand-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
margin-bottom: var(--space-4);
}
@keyframes spin {
@@ -152,45 +155,45 @@
// AirGap Content
.airgap-content {
display: grid;
gap: 1.5rem;
gap: var(--space-6);
}
.airgap-actions-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
gap: var(--space-4);
}
.action-card {
display: flex;
align-items: center;
gap: 1rem;
padding: 1.5rem;
background: #111827;
border: 1px solid #1f2933;
border-radius: 8px;
gap: var(--space-4);
padding: var(--space-6);
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
text-decoration: none;
color: inherit;
transition: all 0.15s;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
border-color: #334155;
background: #1e293b;
border-color: var(--color-border-secondary);
background: var(--color-surface-tertiary);
}
&--import {
border-left: 3px solid #22c55e;
border-left: 3px solid var(--color-status-success);
.action-icon {
color: #22c55e;
color: var(--color-status-success);
}
}
&--export {
border-left: 3px solid #3b82f6;
border-left: 3px solid var(--color-brand-primary);
.action-icon {
color: #3b82f6;
color: var(--color-brand-primary);
}
}
}
@@ -202,29 +205,29 @@
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
background: var(--color-surface-tertiary);
border-radius: var(--radius-lg);
}
.action-text {
h3 {
margin: 0 0 0.25rem;
font-size: 1rem;
font-weight: 600;
margin: 0 0 var(--space-1);
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
}
p {
margin: 0;
font-size: 0.8125rem;
color: #94a3b8;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
// Bundles Section
.bundles-section {
background: #111827;
border: 1px solid #1f2933;
border-radius: 8px;
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
@@ -232,73 +235,103 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid #1f2933;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border-primary);
h2 {
margin: 0;
font-size: 0.875rem;
font-weight: 600;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: #94a3b8;
color: var(--color-text-muted);
}
}
.placeholder-text {
padding: 2rem 1.25rem;
padding: var(--space-8) var(--space-5);
margin: 0;
color: #64748b;
color: var(--color-text-muted);
text-align: center;
font-size: 0.875rem;
font-size: var(--font-size-base);
}
// Status classes
.status--syncing {
color: #3b82f6;
color: var(--color-brand-primary);
}
.status--synced {
color: #22c55e;
color: var(--color-status-success);
}
.status--stale {
color: #eab308;
color: var(--color-status-warning);
}
.status--error {
color: #ef4444;
color: var(--color-status-error);
}
.status--pending {
color: #94a3b8;
color: var(--color-text-muted);
}
.status--disabled {
color: #64748b;
color: var(--color-text-muted);
}
// Sync state
.sync-state--online {
color: #22c55e;
color: var(--color-status-success);
}
.sync-state--offline {
color: #ef4444;
color: var(--color-status-error);
}
.sync-state--partial {
color: #eab308;
color: var(--color-status-warning);
}
.sync-state--syncing {
color: #3b82f6;
color: var(--color-brand-primary);
}
.sync-state--stale {
color: #f97316;
color: var(--color-severity-high);
}
.sync-state--unknown {
color: #64748b;
color: var(--color-text-muted);
}
// Responsive
@include screen-below-md {
.feed-mirror-page {
padding: var(--space-4);
gap: var(--space-4);
}
.page-header {
flex-direction: column;
align-items: flex-start;
}
.tab-nav {
overflow-x: auto;
button {
padding: var(--space-2) var(--space-4);
white-space: nowrap;
}
}
.stats-row {
grid-template-columns: repeat(2, 1fr);
}
.airgap-actions-row {
grid-template-columns: 1fr;
}
}

View File

@@ -1,28 +1,30 @@
@use '../../../styles/tokens/breakpoints' as *;
.bulk-triage-view {
font-family: system-ui, -apple-system, sans-serif;
font-family: var(--font-family-base);
}
// Bucket summary cards
.bucket-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 16px;
gap: var(--space-3);
margin-bottom: var(--space-4);
}
.bucket-card {
padding: 16px;
background: white;
border: 2px solid var(--bucket-color, #e5e7eb);
border-radius: 8px;
transition: all 0.15s ease;
padding: var(--space-4);
background: var(--color-surface-primary);
border: 2px solid var(--bucket-color, var(--color-border-primary));
border-radius: var(--radius-lg);
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&.has-selection {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
box-shadow: 0 0 0 2px var(--color-brand-light);
}
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-md);
}
}
@@ -30,19 +32,19 @@
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
margin-bottom: var(--space-3);
}
.bucket-label {
font-size: 14px;
font-weight: 600;
color: var(--bucket-color, #374151);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--bucket-color, var(--color-text-secondary));
}
.bucket-count {
font-size: 24px;
font-weight: 700;
color: var(--bucket-color, #374151);
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--bucket-color, var(--color-text-secondary));
}
.bucket-selection {
@@ -53,30 +55,30 @@
.select-all-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
color: #6b7280;
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-radius: 6px;
gap: var(--space-1-5);
padding: var(--space-1-5) var(--space-3);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-muted);
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s ease;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: #e5e7eb;
color: #374151;
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
}
&[aria-pressed="true"] {
background: #3b82f6;
border-color: #3b82f6;
color: white;
background: var(--color-brand-primary);
border-color: var(--color-brand-primary);
color: var(--color-text-inverse);
}
&:focus-visible {
outline: 2px solid #3b82f6;
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
}
@@ -84,16 +86,16 @@
.check-icon,
.partial-icon,
.empty-icon {
font-size: 14px;
font-size: var(--font-size-base);
}
.partial-icon {
color: #f59e0b;
color: var(--color-status-warning);
}
.no-findings {
font-size: 12px;
color: #9ca3af;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-style: italic;
}
@@ -101,14 +103,14 @@
.action-bar {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 16px;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
opacity: 0;
transform: translateY(-8px);
transition: all 0.2s ease;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
pointer-events: none;
&.visible {
@@ -121,52 +123,52 @@
.selection-info {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
}
.selection-count {
font-size: 14px;
font-weight: 600;
color: #374151;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
}
.clear-btn {
padding: 4px 8px;
font-size: 12px;
color: #6b7280;
padding: var(--space-1) var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
background: transparent;
border: none;
cursor: pointer;
&:hover {
color: #374151;
color: var(--color-text-secondary);
text-decoration: underline;
}
}
.action-buttons {
display: flex;
gap: 8px;
gap: var(--space-2);
flex: 1;
}
.action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
color: #374151;
background: white;
border: 1px solid #d1d5db;
border-radius: 6px;
gap: var(--space-1-5);
padding: var(--space-2) var(--space-3-5);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s ease;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover:not(:disabled) {
background: #f3f4f6;
border-color: #9ca3af;
background: var(--color-surface-secondary);
border-color: var(--color-border-secondary);
}
&:disabled {
@@ -175,69 +177,69 @@
}
&:focus-visible {
outline: 2px solid #3b82f6;
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
// Action type variants
&.acknowledge {
&:hover:not(:disabled) {
background: #dcfce7;
border-color: #16a34a;
color: #16a34a;
background: var(--color-status-success-bg);
border-color: var(--color-status-success);
color: var(--color-status-success);
}
}
&.suppress {
&:hover:not(:disabled) {
background: #fef3c7;
border-color: #f59e0b;
color: #d97706;
background: var(--color-status-warning-bg);
border-color: var(--color-status-warning);
color: var(--color-status-warning);
}
}
&.assign {
&:hover:not(:disabled) {
background: #dbeafe;
border-color: #3b82f6;
color: #2563eb;
background: var(--color-status-info-bg);
border-color: var(--color-status-info);
color: var(--color-status-info);
}
}
&.escalate {
&:hover:not(:disabled) {
background: #fee2e2;
border-color: #dc2626;
color: #dc2626;
background: var(--color-status-error-bg);
border-color: var(--color-status-error);
color: var(--color-status-error);
}
}
}
.action-icon {
font-size: 14px;
font-size: var(--font-size-base);
}
.undo-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
font-size: 13px;
color: #6b7280;
gap: var(--space-1);
padding: var(--space-2) var(--space-3);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s ease;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
color: #374151;
background: #e5e7eb;
color: var(--color-text-secondary);
background: var(--color-surface-tertiary);
}
}
.undo-icon {
font-size: 16px;
font-size: var(--font-size-md);
}
// Progress overlay
@@ -253,48 +255,48 @@
.progress-content {
width: 320px;
padding: 24px;
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
padding: var(--space-6);
background: var(--color-surface-primary);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
}
.progress-header {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
margin-bottom: var(--space-3);
}
.progress-action {
font-size: 14px;
font-weight: 600;
color: #374151;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
}
.progress-percent {
font-size: 14px;
font-weight: 600;
color: #3b82f6;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-brand-primary);
}
.progress-bar-container {
height: 8px;
background: #e5e7eb;
border-radius: 4px;
background: var(--color-border-primary);
border-radius: var(--radius-sm);
overflow: hidden;
margin-bottom: 8px;
margin-bottom: var(--space-2);
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #2563eb);
border-radius: 4px;
background: var(--color-brand-primary);
border-radius: var(--radius-sm);
transition: width 0.1s linear;
}
.progress-detail {
font-size: 12px;
color: #6b7280;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// Modal
@@ -311,50 +313,52 @@
.modal {
width: 100%;
max-width: 400px;
padding: 24px;
background: white;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
padding: var(--space-6);
background: var(--color-surface-primary);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
}
.modal-title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: #111827;
margin: 0 0 var(--space-2);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.modal-description {
margin: 0 0 16px;
font-size: 14px;
color: #6b7280;
margin: 0 0 var(--space-4);
font-size: var(--font-size-base);
color: var(--color-text-muted);
}
.modal-field {
display: block;
margin-bottom: 16px;
margin-bottom: var(--space-4);
}
.field-label {
display: block;
margin-bottom: 4px;
font-size: 12px;
font-weight: 500;
color: #374151;
margin-bottom: var(--space-1);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.field-input {
width: 100%;
padding: 10px 12px;
font-size: 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
padding: var(--space-2-5) var(--space-3);
font-size: var(--font-size-base);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
box-sizing: border-box;
background: var(--color-surface-primary);
color: var(--color-text-primary);
&:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-brand-light);
}
&.field-textarea {
@@ -366,34 +370,34 @@
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
gap: var(--space-2);
}
.modal-btn {
padding: 10px 16px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
padding: var(--space-2-5) var(--space-4);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s ease;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&.secondary {
color: #374151;
background: white;
border: 1px solid #d1d5db;
color: var(--color-text-secondary);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
&:hover {
background: #f3f4f6;
background: var(--color-surface-secondary);
}
}
&.primary {
color: white;
background: #3b82f6;
border: 1px solid #3b82f6;
color: var(--color-text-inverse);
background: var(--color-brand-primary);
border: 1px solid var(--color-brand-primary);
&:hover:not(:disabled) {
background: #2563eb;
background: var(--color-brand-primary-hover);
}
&:disabled {
@@ -411,13 +415,13 @@
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #1f2937;
color: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
animation: slideUp 0.2s ease-out;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-surface-inverse);
color: var(--color-text-inverse);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
animation: slideUp var(--motion-duration-fast) var(--motion-ease-default);
z-index: 50;
}
@@ -433,84 +437,26 @@
}
.toast-message {
font-size: 14px;
font-size: var(--font-size-base);
}
.toast-undo {
padding: 4px 8px;
font-size: 12px;
font-weight: 500;
color: #93c5fd;
padding: var(--space-1) var(--space-2);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-brand-light);
background: transparent;
border: 1px solid #93c5fd;
border-radius: 4px;
border: 1px solid var(--color-brand-light);
border-radius: var(--radius-sm);
cursor: pointer;
&:hover {
background: rgba(147, 197, 253, 0.1);
}
}
// Dark mode
@media (prefers-color-scheme: dark) {
.bucket-card {
background: #1f2937;
border-color: var(--bucket-color, #374151);
}
.bucket-label,
.bucket-count {
color: #f9fafb;
}
.select-all-btn {
background: #374151;
border-color: #4b5563;
color: #d1d5db;
&:hover {
background: #4b5563;
color: #f9fafb;
}
}
.action-bar {
background: #1f2937;
border-color: #374151;
}
.selection-count {
color: #f9fafb;
}
.action-btn {
background: #374151;
border-color: #4b5563;
color: #f9fafb;
&:hover:not(:disabled) {
background: #4b5563;
}
}
.modal,
.progress-content {
background: #1f2937;
}
.modal-title {
color: #f9fafb;
}
.field-input {
background: #374151;
border-color: #4b5563;
color: #f9fafb;
background: rgba(255, 255, 255, 0.1);
}
}
// Responsive
@media (max-width: 640px) {
@include screen-below-sm {
.bucket-summary {
grid-template-columns: repeat(2, 1fr);
}
@@ -530,6 +476,6 @@
}
.modal {
margin: 16px;
margin: var(--space-4);
}
}

View File

@@ -1,34 +1,35 @@
@use '../../../styles/tokens/breakpoints' as *;
.findings-list {
display: flex;
flex-direction: column;
height: 100%;
font-family: system-ui, -apple-system, sans-serif;
}
// Header
.findings-header {
padding: 16px;
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
background: var(--color-surface-secondary, #f9fafb);
padding: var(--space-4);
border-bottom: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
}
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
margin-bottom: var(--space-3);
}
.findings-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--color-text-primary, #1f2937);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.findings-count {
font-size: 14px;
color: var(--color-text-secondary, #6b7280);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.header-actions {
@@ -38,32 +39,32 @@
// Bucket summary chips
.bucket-summary {
display: flex;
gap: 8px;
margin-bottom: 12px;
gap: var(--space-2);
margin-bottom: var(--space-3);
flex-wrap: wrap;
}
.bucket-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--color-border-primary, #e5e7eb);
border-radius: 16px;
background: var(--color-surface-primary, #ffffff);
font-size: 13px;
gap: var(--space-1-5);
padding: var(--space-1-5) var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-primary);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all 0.15s ease;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
border-color: var(--bucket-color, #9ca3af);
background: color-mix(in srgb, var(--bucket-color, #9ca3af) 10%, var(--color-surface-primary, white));
border-color: var(--bucket-color, var(--color-text-muted));
background: color-mix(in srgb, var(--bucket-color, var(--color-text-muted)) 10%, var(--color-surface-primary));
}
&.active {
border-color: var(--bucket-color, #3b82f6);
background: var(--bucket-color, #3b82f6);
color: white;
border-color: var(--bucket-color, var(--color-brand-primary));
background: var(--bucket-color, var(--color-brand-primary));
color: var(--color-text-inverse);
.bucket-count {
background: rgba(255, 255, 255, 0.2);
@@ -73,23 +74,23 @@
}
.bucket-label {
font-weight: 500;
font-weight: var(--font-weight-medium);
}
.bucket-count {
padding: 2px 6px;
background: var(--color-surface-tertiary, #f3f4f6);
border-radius: 10px;
font-size: 11px;
font-weight: 600;
color: var(--color-text-secondary, #4b5563);
padding: var(--space-0-5) var(--space-1-5);
background: var(--color-surface-tertiary);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
}
// Filters row
.filters-row {
display: flex;
align-items: center;
gap: 16px;
gap: var(--space-4);
flex-wrap: wrap;
}
@@ -101,55 +102,55 @@
.search-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--color-border-secondary, #d1d5db);
border-radius: 6px;
font-size: 14px;
background: var(--color-surface-primary, #ffffff);
color: var(--color-text-primary, #1f2937);
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border-secondary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
background: var(--color-surface-primary);
color: var(--color-text-primary);
&:focus {
outline: none;
border-color: var(--color-brand-primary, #3b82f6);
box-shadow: 0 0 0 2px var(--color-focus-ring, rgba(59, 130, 246, 0.2));
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-brand-light);
}
&::placeholder {
color: var(--color-text-muted, #9ca3af);
color: var(--color-text-muted);
}
}
.flag-filters {
display: flex;
gap: 12px;
gap: var(--space-3);
flex-wrap: wrap;
}
.flag-checkbox {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--color-text-secondary, #4b5563);
gap: var(--space-1);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
cursor: pointer;
input {
accent-color: var(--color-brand-primary, #3b82f6);
accent-color: var(--color-brand-primary);
}
}
.clear-filters-btn {
padding: 6px 12px;
border: 1px solid var(--color-border-secondary, #d1d5db);
border-radius: 6px;
background: var(--color-surface-primary, white);
font-size: 13px;
color: var(--color-text-secondary, #6b7280);
padding: var(--space-1-5) var(--space-3);
border: 1px solid var(--color-border-secondary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
cursor: pointer;
&:hover {
background: var(--color-surface-tertiary, #f3f4f6);
color: var(--color-text-primary, #1f2937);
background: var(--color-surface-tertiary);
color: var(--color-text-primary);
}
}
@@ -157,38 +158,38 @@
.selection-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--color-selection-bg, #eff6ff);
border-bottom: 1px solid var(--color-selection-border, #bfdbfe);
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-status-info-bg);
border-bottom: 1px solid var(--color-brand-light);
}
.selection-count {
font-size: 14px;
font-weight: 500;
color: var(--color-selection-text, #1e40af);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-brand-primary);
}
.action-btn {
padding: 6px 12px;
border: 1px solid var(--color-selection-border, #93c5fd);
border-radius: 6px;
background: var(--color-surface-primary, white);
font-size: 13px;
color: var(--color-selection-text, #1e40af);
padding: var(--space-1-5) var(--space-3);
border: 1px solid var(--color-brand-light);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
font-size: var(--font-size-sm);
color: var(--color-brand-primary);
cursor: pointer;
&:hover {
background: var(--color-selection-hover, #dbeafe);
background: var(--color-brand-light);
}
&.primary {
background: var(--color-brand-primary, #2563eb);
border-color: var(--color-brand-primary, #2563eb);
color: white;
background: var(--color-brand-primary);
border-color: var(--color-brand-primary);
color: var(--color-text-inverse);
&:hover {
background: var(--color-brand-primary-hover, #1d4ed8);
background: var(--color-brand-primary-hover);
}
}
}
@@ -202,18 +203,18 @@
.findings-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
font-size: var(--font-size-sm);
}
.findings-table th {
position: sticky;
top: 0;
padding: 12px 8px;
background: var(--color-surface-secondary, #f9fafb);
border-bottom: 2px solid var(--color-border-primary, #e5e7eb);
padding: var(--space-3) var(--space-2);
background: var(--color-surface-secondary);
border-bottom: 2px solid var(--color-border-primary);
text-align: left;
font-weight: 600;
color: var(--color-text-primary, #374151);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
white-space: nowrap;
&.sortable {
@@ -221,63 +222,63 @@
user-select: none;
&:hover {
background: var(--color-surface-tertiary, #f3f4f6);
background: var(--color-surface-tertiary);
}
}
}
.findings-table td {
padding: 12px 8px;
border-bottom: 1px solid var(--color-border-primary, #e5e7eb);
padding: var(--space-3) var(--space-2);
border-bottom: 1px solid var(--color-border-primary);
vertical-align: middle;
}
.finding-row {
cursor: pointer;
transition: background-color 0.1s ease;
transition: background-color var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: var(--color-surface-secondary, #f9fafb);
background: var(--color-surface-secondary);
}
&.selected {
background: var(--color-selection-bg, #eff6ff);
background: var(--color-status-info-bg);
&:hover {
background: var(--color-selection-hover, #dbeafe);
background: var(--color-brand-light);
}
}
// Sprint: SPRINT_20260112_004_FE_attested_score_ui (FE-ATT-004)
// Hard-fail row highlighting
&.hard-fail-row {
background: var(--color-hard-fail-bg, rgba(220, 38, 38, 0.05));
border-left: 3px solid var(--color-hard-fail-border, #dc2626);
background: var(--color-status-error-bg);
border-left: 3px solid var(--color-status-error);
&:hover {
background: var(--color-hard-fail-hover, rgba(220, 38, 38, 0.1));
background: rgba(220, 38, 38, 0.1);
}
&.selected {
background: var(--color-hard-fail-selected, rgba(220, 38, 38, 0.15));
background: rgba(220, 38, 38, 0.15);
}
}
// Anchored row indicator (subtle violet glow on left border)
&.anchored-row {
border-left: 3px solid var(--color-anchored-border, #7c3aed);
border-left: 3px solid var(--color-brand-secondary);
// If also hard-fail, hard-fail takes precedence visually
&.hard-fail-row {
border-left-color: var(--color-hard-fail-border, #dc2626);
border-left-color: var(--color-status-error);
}
}
}
.empty-row td {
padding: 32px;
padding: var(--space-8);
text-align: center;
color: var(--color-text-secondary, #6b7280);
color: var(--color-text-secondary);
font-style: italic;
}
@@ -316,109 +317,106 @@
display: inline-block;
width: 32px;
text-align: center;
color: var(--color-text-muted, #9ca3af);
color: var(--color-text-muted);
}
.score-na {
display: inline-block;
width: 32px;
text-align: center;
color: var(--color-border-secondary, #d1d5db);
color: var(--color-border-secondary);
}
.advisory-id {
font-family: monospace;
font-size: 13px;
color: var(--color-text-primary, #1f2937);
font-family: var(--font-family-mono);
font-size: var(--font-size-sm);
color: var(--color-text-primary);
}
.package-name {
display: block;
font-weight: 500;
color: var(--color-text-primary, #1f2937);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.package-version {
display: block;
font-size: 12px;
color: var(--color-text-secondary, #6b7280);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
}
.flags-container {
display: flex;
gap: 4px;
gap: var(--space-1);
}
// Severity badges
.severity-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
text-transform: uppercase;
&.severity-critical {
background: var(--color-severity-critical-bg, rgba(220, 38, 38, 0.1));
color: var(--color-severity-critical, #991b1b);
background: var(--color-severity-critical-bg);
color: var(--color-severity-critical);
}
&.severity-high {
background: var(--color-severity-high-bg, rgba(234, 88, 12, 0.1));
color: var(--color-severity-high, #9a3412);
background: var(--color-severity-high-bg);
color: var(--color-severity-high);
}
&.severity-medium {
background: var(--color-severity-medium-bg, rgba(245, 158, 11, 0.1));
color: var(--color-severity-medium, #92400e);
background: var(--color-severity-medium-bg);
color: var(--color-severity-medium);
}
&.severity-low {
background: var(--color-severity-low-bg, rgba(34, 197, 94, 0.1));
color: var(--color-severity-low, #166534);
background: var(--color-severity-low-bg);
color: var(--color-severity-low);
}
&.severity-unknown {
background: var(--color-surface-tertiary, #f3f4f6);
color: var(--color-text-secondary, #4b5563);
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
}
}
// Status badges
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
padding: var(--space-0-5) var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
text-transform: capitalize;
&.status-open {
background: var(--color-status-error-bg, #fef2f2);
color: var(--color-status-error-text, #991b1b);
background: var(--color-status-error-bg);
color: var(--color-status-error);
}
&.status-in_progress {
background: var(--color-status-warning-bg, #fffbeb);
color: var(--color-status-warning-text, #92400e);
background: var(--color-status-warning-bg);
color: var(--color-status-warning);
}
&.status-fixed {
background: var(--color-status-success-bg, #f0fdf4);
color: var(--color-status-success-text, #166534);
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&.status-excepted {
background: var(--color-surface-tertiary, #f3f4f6);
color: var(--color-text-secondary, #4b5563);
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
}
}
// Dark mode is now handled automatically via CSS custom properties
// defined in styles/tokens/_colors.scss
// Responsive - Tablet
@media (max-width: 768px) {
@include screen-below-md {
.filters-row {
flex-direction: column;
align-items: stretch;
@@ -433,7 +431,7 @@
}
.findings-table {
font-size: 13px;
font-size: var(--font-size-sm);
}
.col-flags,
@@ -443,26 +441,26 @@
}
// Responsive - Mobile (compact card mode)
@media (max-width: 480px) {
@include screen-below-xs {
.findings-header {
padding: 12px;
padding: var(--space-3);
}
.header-row {
margin-bottom: 8px;
margin-bottom: var(--space-2);
}
.findings-title {
font-size: 16px;
font-size: var(--font-size-md);
}
.bucket-summary {
gap: 6px;
gap: var(--space-1-5);
}
.bucket-chip {
padding: 4px 8px;
font-size: 12px;
padding: var(--space-1) var(--space-2);
font-size: var(--font-size-xs);
}
// Compact card layout instead of table
@@ -477,28 +475,28 @@
.findings-table tbody {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
gap: var(--space-2);
padding: var(--space-2);
}
.finding-row {
display: grid;
grid-template-columns: 32px 50px 1fr;
grid-template-rows: auto auto;
gap: 4px 8px;
padding: 12px;
background: var(--color-surface-primary, white);
border: 1px solid var(--color-border-primary, #e5e7eb);
border-radius: 8px;
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.05));
gap: var(--space-1) var(--space-2);
padding: var(--space-3);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
&:hover {
background: var(--color-surface-secondary, #f9fafb);
background: var(--color-surface-secondary);
}
&.selected {
border-color: var(--color-brand-primary, #3b82f6);
box-shadow: 0 0 0 2px var(--color-focus-ring, rgba(59, 130, 246, 0.2));
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-brand-light);
}
}
@@ -540,23 +538,23 @@
}
.advisory-id {
font-size: 14px;
font-weight: 600;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.package-name {
font-size: 13px;
font-size: var(--font-size-sm);
}
.package-version {
font-size: 11px;
font-size: var(--font-size-xs);
}
// Selection bar
.selection-bar {
padding: 8px 12px;
padding: var(--space-2) var(--space-3);
flex-wrap: wrap;
gap: 8px;
gap: var(--space-2);
}
.action-btn {
@@ -582,7 +580,7 @@
}
&:active {
background: var(--color-surface-tertiary, #f3f4f6);
background: var(--color-surface-tertiary);
}
}

View File

@@ -1,10 +1,12 @@
@use '../../../styles/tokens/breakpoints' as *;
.graph-explorer {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1.5rem;
gap: var(--space-6);
padding: var(--space-6);
min-height: 100vh;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
background: var(--color-surface-secondary);
}
// Header
@@ -12,47 +14,47 @@
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
gap: var(--space-4);
flex-wrap: wrap;
h1 {
margin: 0;
font-size: 1.75rem;
font-weight: 600;
color: #1e293b;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
}
.graph-explorer__subtitle {
margin: 0.25rem 0 0;
color: #64748b;
font-size: 0.875rem;
margin: var(--space-1) 0 0;
color: var(--color-text-muted);
font-size: var(--font-size-base);
}
.graph-explorer__actions {
display: flex;
gap: 0.5rem;
gap: var(--space-2);
}
// Message Toast
.graph-explorer__message {
padding: 0.75rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
background: #e0f2fe;
color: #0369a1;
border: 1px solid #7dd3fc;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
background: var(--color-status-info-bg);
color: var(--color-status-info);
border: 1px solid var(--color-status-info);
&--success {
background: #dcfce7;
color: #166534;
border-color: #86efac;
background: var(--color-status-success-bg);
color: var(--color-status-success);
border-color: var(--color-status-success);
}
&--error {
background: #fef2f2;
color: #991b1b;
border-color: #fca5a5;
background: var(--color-status-error-bg);
color: var(--color-status-error);
border-color: var(--color-status-error);
}
}
@@ -60,57 +62,57 @@
.graph-explorer__toolbar {
display: flex;
flex-wrap: wrap;
gap: 1rem;
gap: var(--space-4);
align-items: center;
padding: 1rem;
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: var(--space-4);
background: var(--color-surface-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
}
.view-toggle {
display: flex;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
overflow: hidden;
}
.view-toggle__btn {
padding: 0.5rem 1rem;
padding: var(--space-2) var(--space-4);
border: none;
background: white;
color: #64748b;
font-size: 0.875rem;
background: var(--color-surface-primary);
color: var(--color-text-muted);
font-size: var(--font-size-base);
cursor: pointer;
transition: all 0.2s ease;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: #f1f5f9;
background: var(--color-surface-secondary);
}
&--active {
background: #4f46e5;
color: white;
background: var(--color-brand-primary);
color: var(--color-text-inverse);
&:hover {
background: #4338ca;
background: var(--color-brand-primary-hover);
}
}
}
.layer-toggles {
display: flex;
gap: 0.75rem;
padding-left: 1rem;
border-left: 1px solid #e2e8f0;
gap: var(--space-3);
padding-left: var(--space-4);
border-left: 1px solid var(--color-border-primary);
}
.layer-toggle {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: #475569;
gap: var(--space-1-5);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
cursor: pointer;
input {
@@ -121,34 +123,34 @@
}
.layer-toggle__icon {
font-size: 1rem;
font-size: var(--font-size-md);
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: var(--space-1);
margin-left: auto;
}
.filter-group__label {
font-size: 0.75rem;
color: #64748b;
font-weight: 500;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-weight: var(--font-weight-medium);
}
.filter-group__select {
padding: 0.5rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
font-size: 0.875rem;
background: white;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
background: var(--color-surface-primary);
cursor: pointer;
min-width: 120px;
&:focus {
outline: none;
border-color: #4f46e5;
border-color: var(--color-brand-primary);
}
}
@@ -157,16 +159,16 @@
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 3rem;
color: #64748b;
gap: var(--space-3);
padding: var(--space-8);
color: var(--color-text-muted);
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid #e2e8f0;
border-top-color: #4f46e5;
border: 3px solid var(--color-border-primary);
border-top-color: var(--color-brand-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@@ -181,7 +183,7 @@
.canvas-view {
display: grid;
grid-template-columns: 1fr 320px;
gap: 1rem;
gap: var(--space-4);
width: 100%;
min-height: 500px;
}
@@ -195,7 +197,7 @@
overflow-y: auto;
}
@media (max-width: 1024px) {
@include screen-below-lg {
.canvas-view {
grid-template-columns: 1fr;
}
@@ -210,110 +212,110 @@
.hierarchy-view {
display: flex;
flex-direction: column;
gap: 1.5rem;
gap: var(--space-6);
}
.graph-layer {
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: 1rem;
background: var(--color-surface-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: var(--space-4);
}
.graph-layer__title {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 1rem;
font-size: 0.875rem;
font-weight: 600;
color: #475569;
gap: var(--space-2);
margin: 0 0 var(--space-4);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
}
.graph-layer__icon {
font-size: 1.25rem;
font-size: var(--font-size-xl);
}
.graph-nodes {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
gap: var(--space-3);
}
.graph-node {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
gap: var(--space-2);
padding: var(--space-2-5) var(--space-4);
background: var(--color-surface-secondary);
border: 2px solid var(--color-border-primary);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
border-color: #4f46e5;
background: #eef2ff;
border-color: var(--color-brand-primary);
background: var(--color-brand-light);
}
&.node--selected {
border-color: #4f46e5;
background: #eef2ff;
border-color: var(--color-brand-primary);
background: var(--color-brand-light);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.2);
}
&.node--excepted {
background: #faf5ff;
border-color: #c4b5fd;
background: var(--color-exception-bg);
border-color: var(--color-exception-border);
}
&.node--critical {
border-left: 4px solid #dc2626;
border-left: 4px solid var(--color-severity-critical);
}
&.node--high {
border-left: 4px solid #ea580c;
border-left: 4px solid var(--color-severity-high);
}
&.node--medium {
border-left: 4px solid #ca8a04;
border-left: 4px solid var(--color-severity-medium);
}
&.node--low {
border-left: 4px solid #16a34a;
border-left: 4px solid var(--color-severity-low);
}
}
.graph-node__name {
font-weight: 500;
color: #1e293b;
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.graph-node__version {
font-size: 0.75rem;
color: #64748b;
font-family: ui-monospace, monospace;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
font-family: var(--font-family-mono);
}
.graph-node__badge {
padding: 0.125rem 0.5rem;
background: #fef2f2;
color: #dc2626;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
padding: var(--space-0-5) var(--space-2);
background: var(--color-severity-critical-bg);
color: var(--color-severity-critical);
border-radius: var(--radius-full);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
.graph-node__exception {
color: #7c3aed;
font-weight: bold;
color: var(--color-exception);
font-weight: var(--font-weight-bold);
}
// Flat View
.flat-view {
background: white;
border-radius: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
background: var(--color-surface-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
@@ -322,39 +324,39 @@
border-collapse: collapse;
th {
padding: 0.75rem 1rem;
padding: var(--space-3) var(--space-4);
text-align: left;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
background: var(--color-surface-secondary);
border-bottom: 1px solid var(--color-border-primary);
}
td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #f1f5f9;
font-size: 0.875rem;
color: #334155;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--color-border-secondary);
font-size: var(--font-size-base);
color: var(--color-text-secondary);
vertical-align: middle;
}
}
.node-table__row {
cursor: pointer;
transition: background-color 0.15s ease;
transition: background-color var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: #f8fafc;
background: var(--color-surface-secondary);
}
&--selected {
background: #eef2ff;
background: var(--color-brand-light);
&:hover {
background: #e0e7ff;
background: var(--color-brand-light);
}
}
}
@@ -362,70 +364,70 @@
.node-type-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-xs);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
text-transform: capitalize;
&--asset {
background: #dbeafe;
color: #1d4ed8;
background: var(--color-status-info-bg);
color: var(--color-status-info);
}
&--component {
background: #dcfce7;
color: #166534;
background: var(--color-status-success-bg);
color: var(--color-status-success);
}
&--vulnerability {
background: #fef2f2;
color: #dc2626;
background: var(--color-severity-critical-bg);
color: var(--color-severity-critical);
}
}
.exception-indicator {
color: #7c3aed;
font-size: 0.75rem;
font-weight: 500;
color: var(--color-exception);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
// Chips
.chip {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
padding: var(--space-1) var(--space-2-5);
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
white-space: nowrap;
text-transform: capitalize;
&--small {
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
padding: var(--space-0-5) var(--space-2);
font-size: var(--font-size-xs);
}
}
.severity--critical {
background: #fef2f2;
color: #dc2626;
background: var(--color-severity-critical-bg);
color: var(--color-severity-critical);
}
.severity--high {
background: #fff7ed;
color: #ea580c;
background: var(--color-severity-high-bg);
color: var(--color-severity-high);
}
.severity--medium {
background: #fefce8;
color: #ca8a04;
background: var(--color-severity-medium-bg);
color: var(--color-severity-medium);
}
.severity--low {
background: #f0fdf4;
color: #16a34a;
background: var(--color-severity-low-bg);
color: var(--color-severity-low);
}
// Detail Panel
@@ -436,8 +438,8 @@
width: 420px;
max-width: 100%;
height: 100vh;
background: white;
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
background: var(--color-surface-primary);
box-shadow: var(--shadow-xl);
display: flex;
flex-direction: column;
z-index: 100;
@@ -448,56 +450,56 @@
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
}
.detail-panel__title {
display: flex;
align-items: center;
gap: 0.5rem;
gap: var(--space-2);
h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1e293b;
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
}
.detail-panel__icon {
font-size: 1.5rem;
font-size: var(--font-size-2xl);
}
.detail-panel__close {
padding: 0.375rem 0.75rem;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
background: white;
color: #64748b;
font-size: 0.8125rem;
padding: var(--space-1-5) var(--space-3);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-muted);
font-size: var(--font-size-sm);
cursor: pointer;
&:hover {
background: #f1f5f9;
background: var(--color-surface-secondary);
}
}
.detail-panel__content {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
padding: var(--space-5);
}
.detail-section {
margin-bottom: 1.5rem;
margin-bottom: var(--space-6);
h4 {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 600;
color: #64748b;
margin: 0 0 var(--space-2);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
@@ -506,113 +508,113 @@
.detail-grid {
display: flex;
flex-wrap: wrap;
gap: 1rem;
gap: var(--space-4);
}
.detail-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: var(--space-1);
}
.detail-item__label {
font-size: 0.6875rem;
color: #64748b;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-item__value {
font-size: 0.875rem;
color: #1e293b;
font-size: var(--font-size-base);
color: var(--color-text-primary);
}
.purl-display {
display: block;
padding: 0.5rem 0.75rem;
background: #f1f5f9;
border-radius: 0.375rem;
font-size: 0.75rem;
font-family: ui-monospace, monospace;
padding: var(--space-2) var(--space-3);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
font-family: var(--font-family-mono);
word-break: break-all;
color: #475569;
color: var(--color-text-secondary);
}
// Exception Badge
.exception-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: #f3e8ff;
border: 1px solid #c4b5fd;
border-radius: 0.5rem;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
background: var(--color-exception-bg);
border: 1px solid var(--color-exception-border);
border-radius: var(--radius-md);
}
.exception-badge__icon {
color: #7c3aed;
font-weight: bold;
color: var(--color-exception);
font-weight: var(--font-weight-bold);
}
.exception-badge__text {
font-size: 0.875rem;
color: #6d28d9;
font-size: var(--font-size-base);
color: var(--color-exception);
}
// Related Nodes
.related-nodes {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: var(--space-2);
}
.related-node {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: #eef2ff;
border-color: #4f46e5;
background: var(--color-brand-light);
border-color: var(--color-brand-primary);
}
}
.related-node__icon {
font-size: 1rem;
font-size: var(--font-size-md);
}
.related-node__name {
flex: 1;
font-size: 0.875rem;
color: #1e293b;
font-size: var(--font-size-base);
color: var(--color-text-primary);
}
.related-nodes__empty {
padding: 1rem;
padding: var(--space-4);
text-align: center;
color: #94a3b8;
font-size: 0.875rem;
color: var(--color-text-muted);
font-size: var(--font-size-base);
}
// Actions
.detail-panel__actions {
display: flex;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
gap: var(--space-2);
padding: var(--space-4) var(--space-5);
border-top: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
}
.detail-panel__exception-draft {
border-top: 1px solid #e2e8f0;
padding: 1rem 1.25rem;
background: #fafafa;
border-top: 1px solid var(--color-border-primary);
padding: var(--space-4) var(--space-5);
background: var(--color-surface-tertiary);
}
// Buttons
@@ -620,13 +622,13 @@
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
padding: var(--space-2) var(--space-4);
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: var(--radius-md);
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all 0.2s ease;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:disabled {
opacity: 0.6;
@@ -634,21 +636,21 @@
}
&--primary {
background: #4f46e5;
color: white;
background: var(--color-brand-primary);
color: var(--color-text-inverse);
&:hover:not(:disabled) {
background: #4338ca;
background: var(--color-brand-primary-hover);
}
}
&--secondary {
background: white;
color: #475569;
border: 1px solid #e2e8f0;
background: var(--color-surface-primary);
color: var(--color-text-secondary);
border: 1px solid var(--color-border-primary);
&:hover:not(:disabled) {
background: #f8fafc;
background: var(--color-surface-secondary);
}
}
}
@@ -661,7 +663,7 @@
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
padding: var(--space-4);
}
.explain-modal__backdrop {
@@ -676,7 +678,7 @@
z-index: 1;
width: 100%;
max-width: 480px;
animation: modal-appear 0.2s ease-out;
animation: modal-appear var(--motion-duration-fast) var(--motion-ease-default);
}
@keyframes modal-appear {
@@ -691,9 +693,9 @@
}
// Responsive
@media (max-width: 768px) {
@include screen-below-md {
.graph-explorer {
padding: 1rem;
padding: var(--space-4);
}
.graph-explorer__toolbar {
@@ -704,8 +706,8 @@
.layer-toggles {
padding-left: 0;
border-left: none;
padding-top: 0.75rem;
border-top: 1px solid #e2e8f0;
padding-top: var(--space-3);
border-top: 1px solid var(--color-border-primary);
}
.filter-group {

View File

@@ -1,10 +1,12 @@
// Home Dashboard Styles
// Security-focused landing page with aggregated metrics
@use '../../../styles/tokens/breakpoints' as *;
.dashboard {
max-width: 1200px;
max-width: var(--container-xl);
margin: 0 auto;
padding: 0 1rem;
padding: 0 var(--space-4);
}
// =============================================================================
@@ -15,42 +17,43 @@
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
margin-bottom: var(--space-6);
flex-wrap: wrap;
gap: 1rem;
gap: var(--space-4);
}
.dashboard__title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.dashboard__actions {
display: flex;
align-items: center;
gap: 1rem;
gap: var(--space-4);
}
.dashboard__updated {
font-size: 0.8125rem;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
.dashboard__refresh {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
font-size: 0.8125rem;
font-weight: 500;
gap: var(--space-1-5);
padding: var(--space-2) var(--space-3-5);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
background-color: var(--color-surface-tertiary);
border: 1px solid var(--color-border-primary);
border-radius: 0.375rem;
border-radius: var(--radius-md);
cursor: pointer;
transition: background-color 150ms ease, border-color 150ms ease;
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
border-color var(--motion-duration-fast) var(--motion-ease-default);
&:hover:not(:disabled) {
background-color: var(--color-surface-secondary);
@@ -77,17 +80,17 @@
// =============================================================================
.dashboard__errors {
margin-bottom: 1rem;
margin-bottom: var(--space-4);
}
.dashboard__error {
padding: 0.75rem 1rem;
font-size: 0.875rem;
padding: var(--space-3) var(--space-4);
font-size: var(--font-size-base);
color: var(--color-severity-high);
background-color: rgba(234, 88, 12, 0.1);
border: 1px solid rgba(234, 88, 12, 0.3);
border-radius: 0.375rem;
margin-bottom: 0.5rem;
background-color: var(--color-status-warning-bg);
border: 1px solid var(--color-status-warning-border);
border-radius: var(--radius-md);
margin-bottom: var(--space-2);
}
// =============================================================================
@@ -97,10 +100,10 @@
.dashboard__grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.25rem;
margin-bottom: 2rem;
gap: var(--space-5);
margin-bottom: var(--space-8);
@media (max-width: 768px) {
@include screen-below-md {
grid-template-columns: 1fr;
}
}
@@ -112,16 +115,16 @@
.card {
background-color: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: 0.5rem;
border-radius: var(--radius-lg);
overflow: hidden;
transition:
transform var(--motion-duration-normal, 150ms) var(--motion-ease-default, ease),
box-shadow var(--motion-duration-normal, 150ms) var(--motion-ease-default, ease),
border-color var(--motion-duration-normal, 150ms) var(--motion-ease-default, ease);
transform var(--motion-duration-normal) var(--motion-ease-default),
box-shadow var(--motion-duration-normal) var(--motion-ease-default),
border-color var(--motion-duration-normal) var(--motion-ease-default);
&:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow-lg);
border-color: var(--color-border-secondary);
}
}
@@ -130,37 +133,39 @@
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
padding: var(--space-4) var(--space-5);
border-bottom: 1px solid var(--color-border-primary);
}
.card__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.card__link {
font-size: 0.75rem;
font-weight: 500;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-brand-primary);
text-decoration: none;
transition: color var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
text-decoration: underline;
color: var(--color-brand-primary-hover);
}
}
.card__content {
padding: 1.25rem;
padding: var(--space-5);
}
.card__loading {
padding: 1.25rem;
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: var(--space-3);
&--centered {
align-items: center;
@@ -173,15 +178,15 @@
.card__loading-row {
display: flex;
gap: 1rem;
margin-top: 0.75rem;
gap: var(--space-4);
margin-top: var(--space-3);
}
.card__loading-legend {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
gap: var(--space-2);
margin-top: var(--space-3);
width: 100%;
}
@@ -189,19 +194,19 @@
display: flex;
flex-direction: column;
align-items: center;
margin-top: 1rem;
padding-top: 1rem;
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-primary);
}
.card__stat-value {
font-size: 2rem;
font-weight: 700;
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.card__stat-label {
font-size: 0.75rem;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -220,10 +225,10 @@
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s ease-in-out infinite;
border-radius: 0.25rem;
border-radius: var(--radius-sm);
&--bar {
height: 1.5rem;
height: var(--space-6);
width: 100%;
}
@@ -252,32 +257,32 @@
.severity-bars {
display: flex;
flex-direction: column;
gap: 0.625rem;
gap: var(--space-2-5);
}
.severity-bar {
display: grid;
grid-template-columns: 60px 1fr 40px;
align-items: center;
gap: 0.75rem;
gap: var(--space-3);
}
.severity-bar__label {
font-size: 0.8125rem;
font-weight: 500;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.severity-bar__track {
height: 8px;
background-color: var(--color-surface-tertiary);
border-radius: 4px;
border-radius: var(--radius-sm);
overflow: hidden;
}
.severity-bar__fill {
height: 100%;
border-radius: 4px;
border-radius: var(--radius-sm);
transition: width 300ms ease;
}
@@ -287,8 +292,8 @@
.severity-bar--low .severity-bar__fill { background-color: var(--color-severity-low); }
.severity-bar__count {
font-size: 0.875rem;
font-weight: 600;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
text-align: right;
}
@@ -301,7 +306,7 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
gap: var(--space-3);
}
.risk-score__circle {
@@ -334,38 +339,38 @@
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
font-weight: 700;
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.risk-score__trend {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 9999px;
gap: var(--space-1);
padding: var(--space-1) var(--space-2-5);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
border-radius: var(--radius-full);
background-color: var(--color-surface-tertiary);
color: var(--color-text-secondary);
&--improving {
background-color: rgba(34, 197, 94, 0.15);
background-color: var(--color-status-success-bg);
color: var(--color-severity-low);
}
&--worsening {
background-color: rgba(234, 88, 12, 0.15);
background-color: var(--color-status-warning-bg);
color: var(--color-severity-high);
}
}
.risk-counts {
display: flex;
gap: 1.5rem;
margin-top: 1rem;
padding-top: 1rem;
gap: var(--space-6);
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-primary);
}
@@ -373,17 +378,17 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
gap: var(--space-0-5);
}
.risk-count__value {
font-size: 1.25rem;
font-weight: 700;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
}
.risk-count__label {
font-size: 0.6875rem;
font-weight: 500;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
@@ -401,7 +406,7 @@
position: relative;
width: 140px;
height: 140px;
margin: 0 auto 1rem;
margin: 0 auto var(--space-4);
svg {
transform: rotate(-90deg);
@@ -440,14 +445,14 @@
}
.reachability-donut__value {
font-size: 1.5rem;
font-weight: 700;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.reachability-donut__label {
font-size: 0.6875rem;
font-weight: 500;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
@@ -456,13 +461,13 @@
.reachability-legend {
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: var(--space-2);
}
.reachability-legend__item {
display: flex;
align-items: center;
gap: 0.5rem;
gap: var(--space-2);
}
.reachability-legend__dot {
@@ -477,13 +482,13 @@
.reachability-legend__label {
flex: 1;
font-size: 0.8125rem;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.reachability-legend__value {
font-size: 0.875rem;
font-weight: 600;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
@@ -494,8 +499,8 @@
.vex-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1rem;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.vex-stat {
@@ -506,8 +511,8 @@
}
.vex-stat__value {
font-size: 1.5rem;
font-weight: 700;
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-bold);
}
.vex-stat--suppressed .vex-stat__value { color: var(--color-severity-low); }
@@ -515,14 +520,14 @@
.vex-stat--investigating .vex-stat__value { color: var(--color-severity-medium); }
.vex-stat__label {
font-size: 0.75rem;
font-weight: 600;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin-top: 0.25rem;
margin-top: var(--space-1);
}
.vex-stat__sublabel {
font-size: 0.6875rem;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
@@ -530,18 +535,18 @@
display: flex;
flex-direction: column;
align-items: center;
padding-top: 1rem;
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-primary);
}
.vex-impact__percent {
font-size: 2rem;
font-weight: 700;
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
color: var(--color-severity-low);
}
.vex-impact__label {
font-size: 0.75rem;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -552,26 +557,26 @@
// =============================================================================
.quick-actions {
margin-bottom: 2rem;
margin-bottom: var(--space-8);
}
.quick-actions__title {
font-size: 1rem;
font-weight: 600;
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin: 0 0 1rem;
margin: 0 0 var(--space-4);
}
.quick-actions__grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
gap: var(--space-4);
@media (max-width: 768px) {
@include screen-below-md {
grid-template-columns: repeat(2, 1fr);
}
@media (max-width: 480px) {
@include screen-below-xs {
grid-template-columns: 1fr;
}
}
@@ -580,14 +585,17 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.25rem;
gap: var(--space-3);
padding: var(--space-5);
text-decoration: none;
background-color: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: 0.5rem;
border-radius: var(--radius-lg);
color: var(--color-text-secondary);
transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease, transform 150ms ease;
transition: background-color var(--motion-duration-fast) var(--motion-ease-default),
border-color var(--motion-duration-fast) var(--motion-ease-default),
color var(--motion-duration-fast) var(--motion-ease-default),
transform var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background-color: var(--color-surface-secondary);
@@ -597,7 +605,7 @@
}
span {
font-size: 0.875rem;
font-weight: 500;
font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
}
}

View File

@@ -1,4 +1,4 @@
/* Integration Wizard Styles (Sprint: SPRINT_20251229_014) */
/* Integration Wizard Styles */
.wizard-container {
display: flex;
@@ -6,43 +6,43 @@
height: 100%;
max-width: 800px;
margin: 0 auto;
padding: 1.5rem;
padding: var(--space-6);
}
.wizard-stepper {
display: flex;
gap: 0.5rem;
margin-bottom: 2rem;
padding: 1rem;
background: var(--surface-secondary);
border-radius: 0.5rem;
gap: var(--space-2);
margin-bottom: var(--space-8);
padding: var(--space-4);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
overflow-x: auto;
.step-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 1rem;
gap: var(--space-1);
padding: var(--space-2) var(--space-4);
background: transparent;
border: none;
border-radius: 0.25rem;
border-radius: var(--radius-xs);
cursor: pointer;
transition: all 0.2s;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
flex: 1;
min-width: 80px;
&:hover:not(.disabled) {
background: var(--surface-hover);
background: var(--color-surface-tertiary);
}
&.active {
background: var(--primary);
color: var(--on-primary);
background: var(--color-brand-primary);
color: var(--color-text-inverse);
}
&.completed {
color: var(--success);
color: var(--color-status-success);
}
&.disabled {
@@ -54,16 +54,16 @@
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
width: var(--space-6);
height: var(--space-6);
border-radius: 50%;
border: 2px solid currentColor;
font-size: 0.75rem;
font-weight: 600;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
}
.step-label {
font-size: 0.75rem;
font-size: var(--font-size-sm);
white-space: nowrap;
}
}
@@ -72,68 +72,68 @@
.wizard-content {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
padding: var(--space-2);
}
.step-content {
h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 var(--space-2);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
}
.step-description {
margin: 0 0 1.5rem;
color: var(--text-secondary);
margin: 0 0 var(--space-6);
color: var(--color-text-secondary);
}
}
.provider-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1rem;
gap: var(--space-4);
}
.provider-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.5rem;
background: var(--surface-secondary);
gap: var(--space-2);
padding: var(--space-6);
background: var(--color-surface-secondary);
border: 2px solid transparent;
border-radius: 0.5rem;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
border-color: var(--border-hover);
border-color: var(--color-border-secondary);
}
&.selected {
border-color: var(--primary);
background: var(--primary-surface);
border-color: var(--color-brand-primary);
background: var(--color-brand-light);
}
.provider-icon {
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
background: var(--surface-tertiary);
border-radius: 0.5rem;
font-size: 1.25rem;
font-weight: 700;
width: var(--space-12);
height: var(--space-12);
background: var(--color-surface-tertiary);
border-radius: var(--radius-md);
font-size: var(--font-size-xl);
font-weight: var(--font-weight-bold);
}
.provider-name {
font-weight: 600;
font-weight: var(--font-weight-semibold);
}
.provider-desc {
font-size: 0.75rem;
color: var(--text-secondary);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
text-align: center;
}
}
@@ -141,59 +141,59 @@
.auth-methods {
display: flex;
flex-direction: column;
gap: 1rem;
gap: var(--space-4);
}
.auth-method-card {
padding: 1rem;
background: var(--surface-secondary);
padding: var(--space-4);
background: var(--color-surface-secondary);
border: 2px solid transparent;
border-radius: 0.5rem;
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color 0.2s;
transition: border-color var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
border-color: var(--border-hover);
border-color: var(--color-border-secondary);
}
&.selected {
border-color: var(--primary);
border-color: var(--color-brand-primary);
}
.auth-method-header {
display: flex;
align-items: center;
gap: 0.5rem;
gap: var(--space-2);
label {
font-weight: 600;
font-weight: var(--font-weight-semibold);
cursor: pointer;
}
}
.auth-method-desc {
margin: 0.5rem 0 0 1.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
margin: var(--space-2) 0 0 var(--space-6);
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}
.auth-fields {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-primary);
}
}
.form-field {
margin-bottom: 1rem;
margin-bottom: var(--space-4);
label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
margin-bottom: var(--space-1);
font-weight: var(--font-weight-medium);
.required {
color: var(--error);
color: var(--color-status-error);
}
}
@@ -201,15 +201,15 @@
textarea,
select {
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--surface-primary);
border: 1px solid var(--border);
border-radius: 0.25rem;
font-size: 0.875rem;
padding: var(--space-2) var(--space-3);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xs);
font-size: var(--font-size-base);
&:focus {
outline: none;
border-color: var(--primary);
border-color: var(--color-brand-primary);
}
}
@@ -220,96 +220,96 @@
.field-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: var(--space-1);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
}
.schedule-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
gap: var(--space-3);
margin-bottom: var(--space-6);
}
.schedule-card {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem;
background: var(--surface-secondary);
gap: var(--space-3);
padding: var(--space-4);
background: var(--color-surface-secondary);
border: 2px solid transparent;
border-radius: 0.5rem;
border-radius: var(--radius-md);
cursor: pointer;
&:hover {
border-color: var(--border-hover);
border-color: var(--color-border-secondary);
}
&.selected {
border-color: var(--primary);
border-color: var(--color-brand-primary);
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: var(--space-1);
cursor: pointer;
.schedule-label {
font-weight: 600;
font-weight: var(--font-weight-semibold);
}
.schedule-desc {
font-size: 0.875rem;
color: var(--text-secondary);
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}
}
}
.webhook-toggle {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
margin-top: var(--space-6);
padding-top: var(--space-6);
border-top: 1px solid var(--color-border-primary);
.toggle-label {
display: flex;
align-items: center;
gap: 0.5rem;
gap: var(--space-2);
cursor: pointer;
font-weight: 500;
font-weight: var(--font-weight-medium);
}
.webhook-secret {
margin-top: 1rem;
padding: 1rem;
background: var(--surface-secondary);
border-radius: 0.25rem;
margin-top: var(--space-4);
padding: var(--space-4);
background: var(--color-surface-secondary);
border-radius: var(--radius-xs);
.secret-display {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
gap: var(--space-2);
margin-top: var(--space-2);
code {
flex: 1;
padding: 0.5rem;
background: var(--surface-tertiary);
border-radius: 0.25rem;
font-size: 0.75rem;
padding: var(--space-2);
background: var(--color-surface-tertiary);
border-radius: var(--radius-xs);
font-size: var(--font-size-sm);
word-break: break-all;
}
.copy-btn {
padding: 0.25rem 0.5rem;
background: var(--primary);
color: var(--on-primary);
padding: var(--space-1) var(--space-2);
background: var(--color-brand-primary);
color: var(--color-text-inverse);
border: none;
border-radius: 0.25rem;
border-radius: var(--radius-xs);
cursor: pointer;
font-size: 0.75rem;
font-size: var(--font-size-sm);
}
}
}
@@ -318,73 +318,73 @@
.preflight-checks {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
gap: var(--space-3);
margin-bottom: var(--space-6);
}
.check-item {
display: flex;
gap: 0.75rem;
padding: 1rem;
background: var(--surface-secondary);
border-radius: 0.5rem;
gap: var(--space-3);
padding: var(--space-4);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
.check-status {
font-size: 1.25rem;
font-size: var(--font-size-xl);
line-height: 1;
}
.check-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: var(--space-1);
.check-name {
font-weight: 600;
font-weight: var(--font-weight-semibold);
}
.check-desc {
font-size: 0.875rem;
color: var(--text-secondary);
font-size: var(--font-size-base);
color: var(--color-text-secondary);
}
.check-message {
font-size: 0.75rem;
color: var(--text-tertiary);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
}
&.status-success .check-status { color: var(--success); }
&.status-warning .check-status { color: var(--warning); }
&.status-error .check-status { color: var(--error); }
&.status-success .check-status { color: var(--color-status-success); }
&.status-warning .check-status { color: var(--color-status-warning); }
&.status-error .check-status { color: var(--color-status-error); }
&.status-running .check-status {
animation: spin 1s linear infinite;
}
}
.running-message {
color: var(--text-secondary);
color: var(--color-text-secondary);
font-style: italic;
}
.review-summary {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.summary-section {
padding: 1rem;
background: var(--surface-secondary);
border-radius: 0.5rem;
padding: var(--space-4);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
h3 {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: 600;
margin: 0 0 var(--space-2);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
color: var(--text-secondary);
color: var(--color-text-secondary);
}
p {
@@ -393,52 +393,52 @@
}
.tags-section {
margin-top: 1.5rem;
margin-top: var(--space-6);
> label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
margin-bottom: var(--space-2);
font-weight: var(--font-weight-medium);
}
.tags-input {
display: flex;
gap: 0.5rem;
gap: var(--space-2);
input {
flex: 1;
padding: 0.5rem 0.75rem;
background: var(--surface-primary);
border: 1px solid var(--border);
border-radius: 0.25rem;
padding: var(--space-2) var(--space-3);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xs);
}
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
gap: var(--space-2);
margin-top: var(--space-3);
}
.tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--surface-tertiary);
border-radius: 0.25rem;
font-size: 0.875rem;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
background: var(--color-surface-tertiary);
border-radius: var(--radius-xs);
font-size: var(--font-size-base);
.tag-remove {
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
padding: 0 0.125rem;
color: var(--color-text-secondary);
padding: 0 var(--space-0-5);
&:hover {
color: var(--error);
color: var(--color-status-error);
}
}
}
@@ -448,30 +448,30 @@
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
margin-top: 1.5rem;
padding-top: var(--space-6);
border-top: 1px solid var(--color-border-primary);
margin-top: var(--space-6);
.footer-actions {
display: flex;
gap: 0.75rem;
gap: var(--space-3);
}
}
.btn {
padding: 0.5rem 1rem;
border-radius: 0.25rem;
font-weight: 500;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius-xs);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all 0.2s;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&.btn-primary {
background: var(--primary);
color: var(--on-primary);
background: var(--color-brand-primary);
color: var(--color-text-inverse);
border: none;
&:hover:not(:disabled) {
background: var(--primary-hover);
background: var(--color-brand-primary-hover);
}
&:disabled {
@@ -481,27 +481,27 @@
}
&.btn-secondary {
background: var(--surface-secondary);
border: 1px solid var(--border);
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
&:hover {
background: var(--surface-hover);
background: var(--color-surface-tertiary);
}
}
&.btn-text {
background: none;
border: none;
color: var(--text-secondary);
color: var(--color-text-secondary);
&:hover {
color: var(--text-primary);
color: var(--color-text-primary);
}
}
&.btn-small {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
padding: var(--space-1) var(--space-2);
font-size: var(--font-size-base);
}
}

View File

@@ -1,3 +1,10 @@
@use '../../../../../../../../styles/tokens/breakpoints' as *;
/**
* Audit Pack Export Component Styles
* Migrated to design system tokens
*/
:host {
display: block;
}
@@ -8,27 +15,27 @@
max-height: 90vh;
max-width: 800px;
width: 100%;
background: var(--bg-primary, #ffffff);
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
background: var(--color-surface-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-xl);
overflow: hidden;
}
// Header
/* Header */
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-secondary, #f8f9fa);
padding: var(--space-5) var(--space-6);
border-bottom: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
}
.dialog-title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary, #333);
font-size: var(--font-size-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.close-btn {
@@ -36,7 +43,7 @@
border: none;
font-size: 28px;
line-height: 1;
color: var(--text-secondary, #666);
color: var(--color-text-muted);
cursor: pointer;
padding: 0;
width: 32px;
@@ -44,101 +51,102 @@
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background 0.2s, color 0.2s;
border-radius: var(--radius-sm);
transition: background var(--motion-duration-fast) var(--motion-ease-default),
color var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: var(--bg-hover, #e0e0e0);
color: var(--text-primary, #333);
background: var(--color-surface-tertiary);
color: var(--color-text-primary);
}
&:focus {
outline: 2px solid var(--accent-color, #007bff);
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
}
// Content
/* Content */
.dialog-content {
flex: 1;
overflow-y: auto;
padding: 24px;
padding: var(--space-6);
}
// Artifact Summary
/* Artifact Summary */
.artifact-summary {
display: flex;
gap: 24px;
padding: 16px;
background: var(--bg-secondary, #f8f9fa);
border-radius: 6px;
margin-bottom: 24px;
border: 1px solid var(--border-color, #e0e0e0);
gap: var(--space-6);
padding: var(--space-4);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
margin-bottom: var(--space-6);
border: 1px solid var(--color-border-primary);
}
.summary-item {
display: flex;
gap: 8px;
gap: var(--space-2);
align-items: baseline;
}
.summary-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #666);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
}
.summary-value {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #333);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
// Progress Section
/* Progress Section */
.progress-section {
margin-bottom: 24px;
padding: 16px;
background: var(--bg-secondary, #f8f9fa);
border-radius: 6px;
border: 1px solid var(--border-color, #e0e0e0);
margin-bottom: var(--space-6);
padding: var(--space-4);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
border: 1px solid var(--color-border-primary);
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
margin-bottom: var(--space-2);
}
.progress-message {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #333);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
&.error {
color: var(--error-color, #d32f2f);
color: var(--color-status-error);
}
}
.progress-percent {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #666);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
}
.progress-bar {
height: 8px;
background: var(--bg-tertiary, #e9ecef);
border-radius: 4px;
background: var(--color-surface-tertiary);
border-radius: var(--radius-sm);
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
background: var(--accent-color, #007bff);
border-radius: 4px;
transition: width 0.3s ease;
background: var(--color-brand-primary);
border-radius: var(--radius-sm);
transition: width var(--motion-duration-normal) var(--motion-ease-default);
position: relative;
overflow: hidden;
@@ -149,17 +157,12 @@
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
animation: shimmer 1.5s infinite;
}
&.complete {
background: var(--success-color, #28a745);
background: var(--color-status-success);
&::after {
animation: none;
@@ -167,7 +170,7 @@
}
&.error {
background: var(--error-color, #d32f2f);
background: var(--color-status-error);
&::after {
animation: none;
@@ -185,118 +188,118 @@
}
.error-message {
margin-top: 8px;
font-size: 13px;
color: var(--error-color, #d32f2f);
padding: 8px 12px;
background: var(--error-bg, #ffebee);
border-radius: 4px;
border-left: 3px solid var(--error-color, #d32f2f);
margin-top: var(--space-2);
font-size: var(--font-size-sm);
color: var(--color-status-error);
padding: var(--space-2) var(--space-3);
background: var(--color-status-error-bg);
border-radius: var(--radius-sm);
border-left: 3px solid var(--color-status-error);
}
// Configuration Sections
/* Configuration Sections */
.config-sections {
display: flex;
flex-direction: column;
gap: 20px;
gap: var(--space-5);
}
.config-section {
display: flex;
flex-direction: column;
gap: 12px;
gap: var(--space-3);
}
.section-title {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #333);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
// Format Options
/* Format Options */
.format-options {
display: flex;
flex-direction: column;
gap: 12px;
gap: var(--space-3);
}
.format-label {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px;
background: var(--bg-secondary, #f8f9fa);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
gap: var(--space-2-5);
padding: var(--space-3);
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
user-select: none;
input[type="radio"] {
input[type='radio'] {
margin-top: 2px;
cursor: pointer;
}
&:hover {
background: var(--bg-hover, #f0f0f0);
border-color: var(--accent-color, #007bff);
background: var(--color-surface-tertiary);
border-color: var(--color-brand-primary);
}
&:has(input:checked) {
background: var(--accent-bg, #e7f3ff);
border-color: var(--accent-color, #007bff);
background: var(--color-brand-primary-alpha);
border-color: var(--color-brand-primary);
}
}
.format-info {
display: flex;
flex-direction: column;
gap: 4px;
gap: var(--space-1);
flex: 1;
}
.format-name {
font-weight: 600;
font-size: 14px;
color: var(--text-primary, #333);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-base);
color: var(--color-text-primary);
}
.format-description {
font-size: 12px;
color: var(--text-secondary, #666);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
// Results Section
/* Results Section */
.results-section {
display: flex;
flex-direction: column;
gap: 16px;
padding: 20px;
background: var(--success-bg, #e8f5e9);
border-radius: 6px;
border: 1px solid var(--success-color, #28a745);
gap: var(--space-4);
padding: var(--space-5);
background: var(--color-status-success-bg);
border-radius: var(--radius-md);
border: 1px solid var(--color-status-success);
}
.results-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--success-color, #28a745);
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
color: var(--color-status-success);
}
.result-details {
display: flex;
flex-direction: column;
gap: 12px;
gap: var(--space-3);
}
.detail-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid var(--border-color, #e0e0e0);
gap: var(--space-3);
padding: var(--space-2-5) 0;
border-bottom: 1px solid var(--color-border-primary);
&:last-child {
border-bottom: none;
@@ -308,57 +311,57 @@
}
.detail-label {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary, #666);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
min-width: 120px;
}
.detail-value {
font-size: 13px;
color: var(--text-primary, #333);
font-family: monospace;
background: var(--bg-tertiary, #e9ecef);
padding: 4px 8px;
border-radius: 4px;
font-size: var(--font-size-sm);
color: var(--color-text-primary);
font-family: var(--font-family-mono);
background: var(--color-surface-tertiary);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
}
.signature-link,
.rekor-link {
font-size: 13px;
color: var(--accent-color, #007bff);
font-size: var(--font-size-sm);
color: var(--color-brand-primary);
text-decoration: none;
font-weight: 500;
font-weight: var(--font-weight-medium);
&:hover {
text-decoration: underline;
}
}
// Content Summary
/* Content Summary */
.content-summary {
margin-top: 8px;
padding: 16px;
background: var(--bg-primary, #ffffff);
border-radius: 6px;
margin-top: var(--space-2);
padding: var(--space-4);
background: var(--color-surface-primary);
border-radius: var(--radius-md);
}
.summary-title {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #333);
margin: 0 0 var(--space-3) 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.content-list {
margin: 0;
padding-left: 20px;
padding-left: var(--space-5);
list-style-type: disc;
li {
font-size: 13px;
color: var(--text-primary, #333);
margin-bottom: 6px;
font-size: var(--font-size-sm);
color: var(--color-text-primary);
margin-bottom: var(--space-1-5);
&:last-child {
margin-bottom: 0;
@@ -366,29 +369,29 @@
}
}
// Dialog Actions
/* Dialog Actions */
.dialog-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
border-top: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-secondary, #f8f9fa);
gap: var(--space-3);
padding: var(--space-4) var(--space-6);
border-top: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
}
.btn {
padding: 10px 20px;
font-size: 14px;
font-weight: 600;
border-radius: 6px;
padding: var(--space-2-5) var(--space-5);
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
border-radius: var(--radius-md);
border: none;
cursor: pointer;
transition: all 0.2s;
transition: all var(--motion-duration-fast) var(--motion-ease-default);
white-space: nowrap;
&:focus {
outline: 2px solid var(--accent-color, #007bff);
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
@@ -399,41 +402,41 @@
}
.btn-primary {
background: var(--accent-color, #007bff);
color: white;
background: var(--color-brand-primary);
color: var(--color-text-inverse);
&:hover:not(:disabled) {
background: var(--accent-color-hover, #0056b3);
filter: brightness(1.1);
}
&:active:not(:disabled) {
background: var(--accent-color-active, #004085);
filter: brightness(0.9);
}
}
.btn-secondary {
background: var(--bg-tertiary, #e9ecef);
color: var(--text-primary, #333);
border: 1px solid var(--border-color, #e0e0e0);
background: var(--color-surface-tertiary);
color: var(--color-text-primary);
border: 1px solid var(--color-border-primary);
&:hover:not(:disabled) {
background: var(--bg-hover, #d0d0d0);
background: var(--color-surface-tertiary);
}
}
.exporting-indicator {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary, #666);
font-size: 14px;
gap: var(--space-2);
color: var(--color-text-muted);
font-size: var(--font-size-base);
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border-color, #e0e0e0);
border-top-color: var(--accent-color, #007bff);
border: 2px solid var(--color-border-primary);
border-top-color: var(--color-brand-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@@ -444,138 +447,8 @@
}
}
// Dark Mode
:host-context(.dark-mode) {
.audit-pack-dialog {
background: var(--bg-primary-dark, #1e1e2e);
}
.dialog-header {
background: var(--bg-secondary-dark, #2a2a3a);
border-color: var(--border-color-dark, #3a3a4a);
}
.dialog-title {
color: var(--text-primary-dark, #e0e0e0);
}
.close-btn {
color: var(--text-secondary-dark, #999);
&:hover {
background: var(--bg-hover-dark, #333344);
color: var(--text-primary-dark, #e0e0e0);
}
}
.artifact-summary {
background: var(--bg-secondary-dark, #2a2a3a);
border-color: var(--border-color-dark, #3a3a4a);
}
.summary-label {
color: var(--text-secondary-dark, #999);
}
.summary-value {
color: var(--text-primary-dark, #e0e0e0);
}
.progress-section {
background: var(--bg-secondary-dark, #2a2a3a);
border-color: var(--border-color-dark, #3a3a4a);
}
.progress-message {
color: var(--text-primary-dark, #e0e0e0);
}
.progress-percent {
color: var(--text-secondary-dark, #999);
}
.progress-bar {
background: var(--bg-tertiary-dark, #1a1a2a);
}
.section-title {
color: var(--text-primary-dark, #e0e0e0);
}
.format-label {
background: var(--bg-secondary-dark, #2a2a3a);
border-color: var(--border-color-dark, #3a3a4a);
&:hover {
background: var(--bg-hover-dark, #333344);
}
}
.format-name {
color: var(--text-primary-dark, #e0e0e0);
}
.format-description {
color: var(--text-secondary-dark, #999);
}
.results-section {
background: var(--success-bg-dark, #1a2e1a);
border-color: var(--success-color, #28a745);
}
.detail-row {
border-color: var(--border-color-dark, #3a3a4a);
}
.detail-label {
color: var(--text-secondary-dark, #999);
}
.detail-value {
color: var(--text-primary-dark, #e0e0e0);
background: var(--bg-tertiary-dark, #1a1a2a);
}
.content-summary {
background: var(--bg-secondary-dark, #2a2a3a);
}
.summary-title {
color: var(--text-primary-dark, #e0e0e0);
}
.content-list li {
color: var(--text-primary-dark, #e0e0e0);
}
.dialog-actions {
background: var(--bg-secondary-dark, #2a2a3a);
border-color: var(--border-color-dark, #3a3a4a);
}
.btn-secondary {
background: var(--bg-tertiary-dark, #1a1a2a);
color: var(--text-primary-dark, #e0e0e0);
border-color: var(--border-color-dark, #3a3a4a);
&:hover:not(:disabled) {
background: var(--bg-hover-dark, #333344);
}
}
.exporting-indicator {
color: var(--text-secondary-dark, #999);
}
.spinner {
border-color: var(--border-color-dark, #3a3a4a);
border-top-color: var(--accent-color, #007bff);
}
}
// Responsive
@media (max-width: 768px) {
/* Responsive */
@include screen-below-md {
.audit-pack-dialog {
max-width: 100%;
max-height: 100vh;
@@ -584,7 +457,7 @@
.artifact-summary {
flex-direction: column;
gap: 12px;
gap: var(--space-3);
}
.dialog-actions {

View File

@@ -1,3 +1,10 @@
@use '../../../../../../../../../styles/tokens/breakpoints' as *;
/**
* Export Options Component Styles
* Migrated to design system tokens
*/
:host {
display: block;
}
@@ -5,45 +12,45 @@
.export-options {
display: flex;
flex-direction: column;
gap: 16px;
gap: var(--space-4);
}
.options-section {
display: flex;
flex-direction: column;
gap: 12px;
gap: var(--space-3);
}
.section-title {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #333);
margin: 0 0 var(--space-2) 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.option-row {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px;
background: var(--bg-secondary, #f8f9fa);
border-radius: 6px;
border: 1px solid var(--border-color, #e0e0e0);
transition: all 0.2s;
gap: var(--space-1-5);
padding: var(--space-3);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
border: 1px solid var(--color-border-primary);
transition: all var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
background: var(--bg-hover, #f0f0f0);
background: var(--color-surface-tertiary);
}
}
.option-label {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
cursor: pointer;
user-select: none;
input[type="checkbox"] {
input[type='checkbox'] {
cursor: pointer;
width: 16px;
height: 16px;
@@ -51,71 +58,51 @@
}
.option-name {
font-weight: 600;
font-size: 14px;
color: var(--text-primary, #333);
font-weight: var(--font-weight-semibold);
font-size: var(--font-size-base);
color: var(--color-text-primary);
}
.option-description {
font-size: 12px;
color: var(--text-secondary, #666);
padding-left: 24px;
font-size: var(--font-size-sm);
color: var(--color-text-muted);
padding-left: var(--space-6);
}
.sub-options {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 0 0 24px;
margin-top: 4px;
gap: var(--space-1-5);
padding: var(--space-2) 0 0 var(--space-6);
margin-top: var(--space-1);
}
.radio-label {
display: flex;
align-items: center;
gap: 8px;
gap: var(--space-2);
cursor: pointer;
font-size: 13px;
color: var(--text-secondary, #666);
font-size: var(--font-size-sm);
color: var(--color-text-muted);
user-select: none;
transition: color var(--motion-duration-fast) var(--motion-ease-default);
input[type="radio"] {
input[type='radio'] {
cursor: pointer;
}
&:hover {
color: var(--text-primary, #333);
color: var(--color-text-primary);
}
}
// Dark mode
:host-context(.dark-mode) {
.section-title {
color: var(--text-primary-dark, #e0e0e0);
}
.option-row {
background: var(--bg-secondary-dark, #2a2a3a);
border-color: var(--border-color-dark, #3a3a4a);
&:hover {
background: var(--bg-hover-dark, #333344);
}
}
.option-name {
color: var(--text-primary-dark, #e0e0e0);
}
@include screen-below-sm {
.option-description {
color: var(--text-secondary-dark, #999);
padding-left: 0;
margin-top: var(--space-1);
}
.radio-label {
color: var(--text-secondary-dark, #999);
&:hover {
color: var(--text-primary-dark, #e0e0e0);
}
.sub-options {
padding-left: 0;
}
}

Some files were not shown because too many files have changed in this diff Show More