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:
@@ -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();
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '**',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">▾</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') { ✓ }
|
||||
@else { ✗ }
|
||||
</span>
|
||||
<span class="action-toast__message">{{ feedback.message }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="action-toast__close"
|
||||
(click)="clearActionFeedback()"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
×
|
||||
</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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">↻</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">▦▦</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">■</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">☰</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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
← 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">✓</div>
|
||||
<p>Agent connected successfully!</p>
|
||||
} @else {
|
||||
<div class="pending-icon">⌛</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">✓</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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">⚠</span>
|
||||
}
|
||||
@case ('warning') {
|
||||
<span class="modal__icon modal__icon--warning" aria-hidden="true">⚠</span>
|
||||
}
|
||||
@default {
|
||||
<span class="modal__icon modal__icon--info" aria-hidden="true">ⓘ</span>
|
||||
}
|
||||
}
|
||||
{{ config().title }}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="modal__close"
|
||||
(click)="onCancel()"
|
||||
aria-label="Close"
|
||||
>
|
||||
×
|
||||
</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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">⋮</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">⚠</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 });
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">✓</span>
|
||||
}
|
||||
@case ('warn') {
|
||||
<span class="status-icon status-icon--warn">⚠</span>
|
||||
}
|
||||
@case ('fail') {
|
||||
<span class="status-icon status-icon--fail">✗</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"
|
||||
>
|
||||
↻
|
||||
</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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">✓ Completed</span>
|
||||
}
|
||||
@case ('failed') {
|
||||
<span class="status-badge status-badge--failed">✗ 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">⚠</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)"
|
||||
>
|
||||
→
|
||||
</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`;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)">
|
||||
<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)">
|
||||
>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 (>80%)</span>
|
||||
</div>
|
||||
<div class="summary-stat">
|
||||
<span class="summary-stat__value">{{ criticalCount() }}</span>
|
||||
<span class="summary-stat__label">Critical (>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}`;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">⚙</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">⚠</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' ? '▲' : '▼' }}
|
||||
</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">⚠</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)"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="comparison-footer">
|
||||
<span class="footer-info">
|
||||
Showing {{ sortedAgents().length }} agents
|
||||
@if (versionMismatchCount() > 0) {
|
||||
· {{ 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);
|
||||
}
|
||||
}
|
||||
25
src/Web/StellaOps.Web/src/app/features/agents/index.ts
Normal file
25
src/Web/StellaOps.Web/src/app/features/agents/index.ts
Normal 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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }}@{{ 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',
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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') || '';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ApprovalsInboxComponent } from './approvals-inbox.component';
|
||||
export { ApprovalDetailComponent } from './approval-detail.component';
|
||||
export { APPROVALS_ROUTES } from './approvals.routes';
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
];
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ControlPlaneDashboardComponent } from './control-plane-dashboard.component';
|
||||
export { CONTROL_PLANE_ROUTES } from './control-plane.routes';
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
]);
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Deployments Feature Module
|
||||
* Sprint: SPRINT_20260118_008_FE_environments_deployments
|
||||
*/
|
||||
|
||||
export * from './deployments.routes';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Environments Feature Module
|
||||
* Sprint: SPRINT_20260118_008_FE_environments_deployments
|
||||
*/
|
||||
|
||||
export * from './environments.routes';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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' },
|
||||
},
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user