Consolidate Operations UI, rename Policy Packs to Release Policies, add host infrastructure

Five sprints delivered in this change:

Sprint 001 - Ops UI Consolidation:
  Remove Operations Hub, Agents Fleet Dashboard, and Signals Runtime Dashboard
  (31 files deleted). Ops nav goes from 8 to 4 items. Redirects from old routes.

Sprint 002 - Host Infrastructure (Backend):
  Add SshHostConfig and WinRmHostConfig target connection types with validation.
  Implement AgentInventoryCollector (real IInventoryCollector that parses docker ps
  JSON via IRemoteCommandExecutor abstraction). Enrich TopologyHostProjection with
  ProbeStatus/ProbeType/ProbeLastHeartbeat fields.

Sprint 003 - Host UI + Environment Verification:
  Add runtime verification column to environment target list with Verified/Drift/
  Offline/Unmonitored badges. Add container-level verification detail to Deploy
  Status tab showing deployed vs running digests with drift highlighting.

Sprint 004 - Release Policies Rename:
  Move "Policy Packs" from Ops to Release Control as "Release Policies". Remove
  "Risk & Governance" from Security nav. Rename Pack Registry to Automation Catalog.
  Create gate-catalog.ts with 11 gate type display names and descriptions.

Sprint 005 - Policy Builder:
  Create visual policy builder (3-step: name, gates, review) with per-gate-type
  config forms (CVSS threshold slider, signature toggles, freshness days, etc).
  Simplify pack workspace tabs from 6 to 3 (Rules, Test, Activate). Add YAML
  toggle within Rules tab.

59/59 Playwright e2e tests pass across 4 test suites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-01 00:31:38 +03:00
parent db967a54f8
commit 1d7c8fadbd
58 changed files with 2492 additions and 12138 deletions

View File

@@ -0,0 +1,220 @@
/**
* Operations UI Consolidation — Docker Stack E2E Tests
*
* Uses the auth fixture for authenticated page access.
* Run against live Docker stack: npx playwright test --config playwright.e2e.config.ts ops-consolidation
*
* Verifies:
* - Removed pages are gone (Operations Hub, Agent Fleet, Signals)
* - Sidebar Ops section has exactly 5 items
* - Old routes redirect (not 404)
* - Remaining ops pages render
* - Topology pages unaffected
* - No Angular runtime errors
*/
import { test, expect } from './fixtures/auth.fixture';
import { navigateAndWait, assertPageHasContent, getPageHeading } from './helpers/nav.helper';
const SCREENSHOT_DIR = 'e2e/screenshots/ops-consolidation';
function collectNgErrors(page: import('@playwright/test').Page) {
const errors: string[] = [];
page.on('console', (msg) => {
const text = msg.text();
if (msg.type() === 'error' && /NG0\d{3,4}/.test(text)) {
errors.push(text);
}
});
return errors;
}
// ---------------------------------------------------------------------------
// 1. Sidebar: removed items are gone
// ---------------------------------------------------------------------------
test.describe('Ops sidebar cleanup verification', () => {
test('Operations Hub is removed from sidebar', async ({ authenticatedPage: page }) => {
await navigateAndWait(page, '/ops/operations/jobs-queues', { timeout: 30_000 });
const sidebar = page.locator('aside.sidebar');
await expect(sidebar).toBeVisible({ timeout: 15_000 });
await expect(sidebar).not.toContainText('Operations Hub');
await page.screenshot({ path: `${SCREENSHOT_DIR}/01-no-ops-hub.png`, fullPage: true });
});
test('Agent Fleet is removed from sidebar', async ({ authenticatedPage: page }) => {
await navigateAndWait(page, '/ops/operations/jobs-queues', { timeout: 30_000 });
const sidebar = page.locator('aside.sidebar');
await expect(sidebar).toBeVisible({ timeout: 15_000 });
await expect(sidebar).not.toContainText('Agent Fleet');
});
test('Signals is removed from sidebar nav items', async ({ authenticatedPage: page }) => {
await navigateAndWait(page, '/ops/operations/jobs-queues', { timeout: 30_000 });
const navItems = page.locator('aside.sidebar a.nav-item');
const count = await navItems.count();
for (let i = 0; i < count; i++) {
const text = ((await navItems.nth(i).textContent()) ?? '').trim();
expect(text).not.toBe('Signals');
}
});
});
// ---------------------------------------------------------------------------
// 2. Sidebar: expected 5 items present
// ---------------------------------------------------------------------------
test.describe('Ops sidebar has correct items', () => {
const EXPECTED_ITEMS = [
'Policy Packs',
'Scheduled Jobs',
'Feeds & Airgap',
'Scripts',
'Diagnostics',
];
test(`sidebar Ops section contains all ${EXPECTED_ITEMS.length} expected items`, async ({
authenticatedPage: page,
}) => {
await navigateAndWait(page, '/ops/operations/jobs-queues', { timeout: 30_000 });
const sidebarText = (await page.locator('aside.sidebar').textContent()) ?? '';
for (const label of EXPECTED_ITEMS) {
expect(sidebarText, `Should contain "${label}"`).toContain(label);
}
await page.screenshot({ path: `${SCREENSHOT_DIR}/02-ops-nav-5-items.png`, fullPage: true });
});
});
// ---------------------------------------------------------------------------
// 3. Redirects: old routes handled gracefully
// ---------------------------------------------------------------------------
test.describe('Old route redirects', () => {
test('/ops/operations redirects to jobs-queues', async ({ authenticatedPage: page }) => {
await navigateAndWait(page, '/ops/operations', { timeout: 30_000 });
expect(page.url()).toContain('/ops/operations/jobs-queues');
await assertPageHasContent(page);
});
test('/ops/signals redirects to diagnostics', async ({ authenticatedPage: page }) => {
await navigateAndWait(page, '/ops/signals', { timeout: 30_000 });
expect(page.url()).toContain('/ops/operations/doctor');
await assertPageHasContent(page);
});
test('/ops base redirects through to content', async ({ authenticatedPage: page }) => {
await navigateAndWait(page, '/ops', { timeout: 30_000 });
await assertPageHasContent(page);
});
});
// ---------------------------------------------------------------------------
// 4. Remaining ops pages render
// ---------------------------------------------------------------------------
test.describe('Remaining Ops pages render', () => {
const PAGES = [
{ path: '/ops/operations/jobs-queues', name: 'Jobs & Queues' },
{ path: '/ops/operations/feeds-airgap', name: 'Feeds & AirGap' },
{ path: '/ops/operations/doctor', name: 'Diagnostics' },
{ path: '/ops/operations/data-integrity', name: 'Data Integrity' },
{ path: '/ops/operations/event-stream', name: 'Event Stream' },
{ path: '/ops/operations/jobengine', name: 'JobEngine Dashboard' },
];
for (const route of PAGES) {
test(`${route.name} (${route.path}) renders`, async ({ authenticatedPage: page }) => {
const ngErrors = collectNgErrors(page);
await navigateAndWait(page, route.path, { timeout: 30_000 });
await page.waitForTimeout(2000);
await assertPageHasContent(page);
expect(
ngErrors,
`Angular errors on ${route.path}: ${ngErrors.join('\n')}`,
).toHaveLength(0);
});
}
});
// ---------------------------------------------------------------------------
// 5. Topology pages unaffected
// ---------------------------------------------------------------------------
test.describe('Topology pages still work', () => {
const TOPOLOGY_PAGES = [
{ path: '/setup/topology/overview', name: 'Overview' },
{ path: '/setup/topology/hosts', name: 'Hosts' },
{ path: '/setup/topology/targets', name: 'Targets' },
{ path: '/setup/topology/agents', name: 'Agents' },
];
for (const route of TOPOLOGY_PAGES) {
test(`topology ${route.name} (${route.path}) renders`, async ({
authenticatedPage: page,
}) => {
const ngErrors = collectNgErrors(page);
await navigateAndWait(page, route.path, { timeout: 30_000 });
await page.waitForTimeout(2000);
await assertPageHasContent(page);
expect(ngErrors).toHaveLength(0);
});
}
test('operations/agents loads topology agents (not fleet dashboard)', async ({
authenticatedPage: page,
}) => {
const ngErrors = collectNgErrors(page);
await navigateAndWait(page, '/ops/operations/agents', { timeout: 30_000 });
await page.waitForTimeout(2000);
await assertPageHasContent(page);
expect(ngErrors).toHaveLength(0);
await page.screenshot({ path: `${SCREENSHOT_DIR}/03-agents-topology.png`, fullPage: true });
});
});
// ---------------------------------------------------------------------------
// 6. Full journey stability
// ---------------------------------------------------------------------------
test.describe('Ops navigation journey', () => {
test('sequential navigation across all ops routes has no Angular errors', async ({
authenticatedPage: page,
}) => {
test.setTimeout(180_000);
const ngErrors = collectNgErrors(page);
const routes = [
'/ops/operations/jobs-queues',
'/ops/operations/feeds-airgap',
'/ops/operations/doctor',
'/ops/operations/data-integrity',
'/ops/operations/agents',
'/ops/operations/event-stream',
'/ops/integrations',
'/ops/policy',
'/setup/topology/overview',
'/setup/topology/hosts',
'/setup/topology/agents',
];
for (const route of routes) {
await navigateAndWait(page, route, { timeout: 30_000 });
await page.waitForTimeout(1000);
}
expect(
ngErrors,
`Angular errors during journey: ${ngErrors.join('\n')}`,
).toHaveLength(0);
});
test('browser back/forward works across ops routes', async ({
authenticatedPage: page,
}) => {
await navigateAndWait(page, '/ops/operations/jobs-queues', { timeout: 30_000 });
await navigateAndWait(page, '/ops/operations/doctor', { timeout: 30_000 });
await navigateAndWait(page, '/ops/operations/feeds-airgap', { timeout: 30_000 });
await page.goBack();
await page.waitForTimeout(1500);
expect(page.url()).toContain('/doctor');
await page.goForward();
await page.waitForTimeout(1500);
expect(page.url()).toContain('/feeds-airgap');
});
});

View File

@@ -17,6 +17,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'home',
label: 'Home',
description: 'Daily overview, health signals, and the fastest path back into active work.',
icon: 'home',
items: [
{
@@ -24,6 +25,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
label: 'Dashboard',
route: '/',
icon: 'dashboard',
tooltip: 'Daily health, feed freshness, and onboarding progress',
},
],
},
@@ -34,6 +36,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'release-control',
label: 'Release Control',
description: 'Plan, approve, and promote verified releases through your environments.',
icon: 'package',
items: [
{
@@ -57,6 +60,14 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
icon: 'package',
tooltip: 'Release versions and bundles',
},
{
id: 'release-policies',
label: 'Release Policies',
route: '/ops/policy/packs',
icon: 'clipboard',
tooltip: 'Define and manage the rules that gate your releases',
requiredScopes: ['policy:author'],
},
],
},
@@ -66,6 +77,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'security',
label: 'Security',
description: 'Scan images, triage findings, and explain exploitability before promotion.',
icon: 'shield',
items: [
{
@@ -86,21 +98,25 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
id: 'supply-chain-data',
label: 'Supply-Chain Data',
route: '/security/supply-chain-data',
tooltip: 'Components, packages, and SBOM-backed inventory',
},
{
id: 'findings-explorer',
label: 'Findings Explorer',
route: '/security/findings',
tooltip: 'Detailed findings, comparisons, and investigation pivots',
},
{
id: 'reachability',
label: 'Reachability',
route: '/security/reachability',
tooltip: 'Which vulnerable code paths are actually callable',
},
{
id: 'unknowns',
label: 'Unknowns',
route: '/security/unknowns',
tooltip: 'Components that still need identification or classification',
},
],
},
@@ -118,26 +134,6 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
icon: 'file-text',
tooltip: 'Manage VEX statements and policy exceptions',
},
{
id: 'risk-governance',
label: 'Risk & Governance',
route: '/ops/policy/governance',
icon: 'shield',
tooltip: 'Risk budgets, trust weights, and policy governance',
children: [
{
id: 'policy-simulation',
label: 'Simulation',
route: '/ops/policy/simulation',
requiredScopes: ['policy:simulate'],
},
{
id: 'policy-audit',
label: 'Policy Audit',
route: '/ops/policy/audit',
},
],
},
],
},
@@ -147,6 +143,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'evidence',
label: 'Evidence',
description: 'Verify what happened, inspect proof chains, and export auditor-ready bundles.',
icon: 'file-text',
items: [
{
@@ -154,12 +151,14 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
label: 'Evidence Overview',
route: '/evidence/overview',
icon: 'file-text',
tooltip: 'Search evidence, proof chains, and verification status',
},
{
id: 'decision-capsules',
label: 'Decision Capsules',
route: '/evidence/capsules',
icon: 'archive',
tooltip: 'Signed decision records for promotions and exceptions',
},
{
id: 'audit-log',
@@ -184,57 +183,36 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'ops',
label: 'Ops',
description: 'Run the platform, keep feeds healthy, and investigate background execution.',
icon: 'server',
items: [
{
id: 'operations-hub',
label: 'Operations Hub',
route: '/ops/operations',
icon: 'server',
},
{
id: 'policy-packs',
label: 'Policy Packs',
route: '/ops/policy/packs',
icon: 'clipboard',
tooltip: 'Author and manage policy packs',
requiredScopes: ['policy:author'],
},
{
id: 'scheduled-jobs',
label: 'Scheduled Jobs',
route: OPERATIONS_PATHS.jobsQueues,
icon: 'workflow',
tooltip: 'Queue health, execution state, and recovery work',
},
{
id: 'feeds-airgap',
label: 'Feeds & AirGap',
route: OPERATIONS_PATHS.feedsAirgap,
icon: 'mirror',
},
{
id: 'agent-fleet',
label: 'Agent Fleet',
route: '/ops/operations/agents',
icon: 'cpu',
},
{
id: 'signals',
label: 'Signals',
route: '/ops/operations/signals',
icon: 'radio',
tooltip: 'Feed freshness, offline kits, and transfer readiness',
},
{
id: 'scripts',
label: 'Scripts',
route: '/ops/scripts',
icon: 'code',
tooltip: 'Operator scripts and reusable automation entry points',
},
{
id: 'diagnostics',
label: 'Diagnostics',
route: '/ops/operations/doctor',
icon: 'activity',
tooltip: 'Service health, drift signals, and operational checks',
},
],
},
@@ -245,6 +223,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
{
id: 'admin',
label: 'Admin',
description: 'Identity, trust, tenant settings, and governance controls for operators.',
icon: 'settings',
requiredScopes: ['ui.admin'],
items: [

View File

@@ -0,0 +1,93 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260331_004_FE_release_policies_rename_and_nav
/**
* Human-readable metadata for policy gate types.
*
* Maps backend gate type identifiers (e.g. "CvssThresholdGate") to
* display names, descriptions, and icons used throughout the UI.
*
* This is intentionally a frontend constant — the 8 standard gate types
* are stable. If gates become plugin-extensible, migrate to a backend
* catalog endpoint (GET /api/policy/gates/catalog).
*/
export interface GateCatalogEntry {
readonly displayName: string;
readonly description: string;
readonly icon: string;
}
export const GATE_CATALOG: Readonly<Record<string, GateCatalogEntry>> = {
CvssThresholdGate: {
displayName: 'Vulnerability Severity',
description: 'Block releases with CVEs above a severity threshold',
icon: 'alert-triangle',
},
SignatureRequiredGate: {
displayName: 'Image Signature',
description: 'Require cryptographic signature on container images',
icon: 'lock',
},
EvidenceFreshnessGate: {
displayName: 'Scan Freshness',
description: 'Require scan results newer than a configured threshold',
icon: 'clock',
},
SbomPresenceGate: {
displayName: 'SBOM Required',
description: 'Require a Software Bill of Materials for every image',
icon: 'file-text',
},
MinimumConfidenceGate: {
displayName: 'Detection Confidence',
description: 'Minimum confidence level for vulnerability detections',
icon: 'target',
},
UnknownsBudgetGate: {
displayName: 'Unknowns Limit',
description: 'Maximum acceptable fraction of unresolved findings',
icon: 'help-circle',
},
ReachabilityRequirementGate: {
displayName: 'Reachability Analysis',
description: 'Require proof of exploitability before blocking',
icon: 'git-branch',
},
SourceQuotaGate: {
displayName: 'Data Source Diversity',
description: 'Minimum number of independent intelligence sources',
icon: 'layers',
},
RuntimeWitnessGate: {
displayName: 'Runtime Witness',
description: 'Require runtime execution evidence for reachability claims',
icon: 'cpu',
},
FixChainGate: {
displayName: 'Fix Chain Verification',
description: 'Verify that upstream fixes exist and are applicable',
icon: 'link',
},
VexProofGate: {
displayName: 'VEX Proof',
description: 'Require VEX statements with sufficient proof for non-affected claims',
icon: 'shield',
},
} as const;
/** Get the human-readable display name for a gate type, falling back to the raw type. */
export function getGateDisplayName(gateType: string): string {
return GATE_CATALOG[gateType]?.displayName ?? gateType.replace(/Gate$/, '');
}
/** Get the human-readable description for a gate type. */
export function getGateDescription(gateType: string): string {
return GATE_CATALOG[gateType]?.description ?? '';
}
/** Get the icon identifier for a gate type. */
export function getGateIcon(gateType: string): string {
return GATE_CATALOG[gateType]?.icon ?? 'shield';
}

View File

@@ -5,3 +5,4 @@ export * from './policy-error.handler';
export * from './policy-error.interceptor';
export * from './policy-quota.service';
export * from './policy-studio-metrics.service';
export * from './gate-catalog';

View File

@@ -1,562 +0,0 @@
/**
* 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 { provideRouter, ActivatedRoute, Router, convertToParamMap } from '@angular/router';
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],
providers: [
provideRouter([]),
{ provide: AgentStore, useValue: mockStore },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
paramMap: convertToParamMap({ agentId: 'agent-123' }),
},
},
},
],
}).compileComponents();
fixture = TestBed.createComponent(AgentDetailPageComponent);
component = fixture.componentInstance;
router = TestBed.inject(Router);
});
describe('initialization', () => {
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should select agent from route param', () => {
fixture.detectChanges();
expect(mockStore.selectAgent).toHaveBeenCalledWith('agent-123');
});
});
describe('breadcrumb', () => {
it('should display breadcrumb', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.breadcrumb')).toBeTruthy();
});
it('should show agent name in breadcrumb', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('test-agent');
});
it('should have link back to fleet', () => {
fixture.detectChanges();
const compiled = fixture.nativeElement;
const backLink = compiled.querySelector('.breadcrumb__link');
expect(backLink.textContent).toContain('Agent Fleet');
});
});
describe('header', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should display agent display name', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.detail-header__title h1').textContent).toContain('Test Agent');
});
it('should display agent ID', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('agent-123');
});
it('should display status indicator', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.status-indicator')).toBeTruthy();
});
it('should display run diagnostics button', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Run Diagnostics');
});
it('should display actions dropdown', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Actions');
});
});
describe('tags', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should display environment tag', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('production');
});
it('should display version tag', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('v2.5.0');
});
it('should display custom tags', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('primary');
expect(compiled.textContent).toContain('scanner');
});
});
describe('tabs', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should display all tabs', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Overview');
expect(compiled.textContent).toContain('Health');
expect(compiled.textContent).toContain('Tasks');
expect(compiled.textContent).toContain('Logs');
expect(compiled.textContent).toContain('Configuration');
});
it('should default to overview tab', () => {
expect(component.activeTab()).toBe('overview');
});
it('should switch tabs', () => {
component.setActiveTab('health');
expect(component.activeTab()).toBe('health');
});
it('should mark active tab', () => {
const compiled = fixture.nativeElement;
const activeTab = compiled.querySelector('.tab--active');
expect(activeTab.textContent).toContain('Overview');
});
it('should have correct ARIA attributes', () => {
const compiled = fixture.nativeElement;
const tabs = compiled.querySelectorAll('.tab');
tabs.forEach((tab: HTMLElement) => {
expect(tab.getAttribute('role')).toBe('tab');
});
});
});
describe('overview tab', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should display status', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Status');
expect(compiled.textContent).toContain('Online');
});
it('should display capacity', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Capacity');
expect(compiled.textContent).toContain('65%');
});
it('should display active tasks count', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Active Tasks');
expect(compiled.textContent).toContain('3');
});
it('should display resource meters', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Resource Utilization');
expect(compiled.textContent).toContain('CPU');
expect(compiled.textContent).toContain('Memory');
expect(compiled.textContent).toContain('Disk');
});
it('should display certificate info', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Certificate');
expect(compiled.textContent).toContain('CN=test-agent');
});
it('should display agent information', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Agent Information');
expect(compiled.textContent).toContain('agent-123');
});
});
describe('health tab', () => {
beforeEach(() => {
component.setActiveTab('health');
fixture.detectChanges();
});
it('should display health tab component', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('st-agent-health-tab')).toBeTruthy();
});
it('should run health checks on request', () => {
component.onRunHealthChecks();
expect(mockStore.fetchAgentHealth).toHaveBeenCalledWith('agent-123');
});
});
describe('tasks tab', () => {
beforeEach(() => {
component.setActiveTab('tasks');
fixture.detectChanges();
});
it('should display tasks tab component', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('st-agent-tasks-tab')).toBeTruthy();
});
it('should load more tasks on request', () => {
component.onLoadMoreTasks();
expect(mockStore.fetchAgentTasks).toHaveBeenCalledWith('agent-123');
});
});
describe('config tab', () => {
beforeEach(() => {
component.setActiveTab('config');
fixture.detectChanges();
});
it('should display configuration', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Configuration');
});
it('should display max concurrent tasks', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Max Concurrent Tasks');
expect(compiled.textContent).toContain('10');
});
it('should display heartbeat interval', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Heartbeat Interval');
expect(compiled.textContent).toContain('30s');
});
it('should display auto update status', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Auto Update');
expect(compiled.textContent).toContain('Enabled');
});
});
describe('actions menu', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should toggle actions menu', () => {
expect(component.showActionsMenu()).toBe(false);
component.toggleActionsMenu();
expect(component.showActionsMenu()).toBe(true);
component.toggleActionsMenu();
expect(component.showActionsMenu()).toBe(false);
});
it('should show menu items when open', () => {
component.showActionsMenu.set(true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Restart Agent');
expect(compiled.textContent).toContain('Renew Certificate');
expect(compiled.textContent).toContain('Drain Tasks');
expect(compiled.textContent).toContain('Resume Tasks');
expect(compiled.textContent).toContain('Remove Agent');
});
});
describe('action execution', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should set pending action when executing', () => {
component.executeAction('restart');
expect(component.pendingAction()).toBe('restart');
expect(component.showActionsMenu()).toBe(false);
});
it('should show action modal when pending action set', () => {
component.pendingAction.set('restart');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('st-agent-action-modal')).toBeTruthy();
});
it('should execute action on confirm', fakeAsync(() => {
component.onActionConfirmed('restart');
expect(component.isExecutingAction()).toBe(true);
tick(1500);
expect(component.isExecutingAction()).toBe(false);
expect(component.pendingAction()).toBeNull();
expect(mockStore.executeAction).toHaveBeenCalled();
}));
it('should clear pending action on cancel', () => {
component.pendingAction.set('restart');
component.onActionCancelled();
expect(component.pendingAction()).toBeNull();
});
});
describe('action feedback', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should show success feedback', () => {
component.showFeedback('success', 'Action completed');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.action-toast--success')).toBeTruthy();
expect(compiled.textContent).toContain('Action completed');
});
it('should show error feedback', () => {
component.showFeedback('error', 'Action failed');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.action-toast--error')).toBeTruthy();
});
it('should auto-dismiss feedback', fakeAsync(() => {
component.showFeedback('success', 'Test');
expect(component.actionFeedback()).not.toBeNull();
tick(5000);
expect(component.actionFeedback()).toBeNull();
}));
it('should clear feedback manually', () => {
component.showFeedback('success', 'Test');
component.clearActionFeedback();
expect(component.actionFeedback()).toBeNull();
});
});
describe('computed values', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should compute agent from store', () => {
expect(component.agent()).toBeTruthy();
expect(component.agent()?.id).toBe('agent-123');
});
it('should compute status color', () => {
expect(component.statusColor()).toContain('success');
});
it('should compute status label', () => {
expect(component.statusLabel()).toBe('Online');
});
it('should compute heartbeat label', () => {
expect(component.heartbeatLabel()).toBeTruthy();
});
});
describe('loading state', () => {
it('should show loading spinner when loading', () => {
mockStore.isLoading.set(true);
mockStore.selectedAgent.set(null);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.loading-state')).toBeTruthy();
});
});
describe('error state', () => {
it('should show error message', () => {
mockStore.error.set('Failed to load agent');
mockStore.selectedAgent.set(null);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Failed to load agent');
});
it('should show back to fleet link on error', () => {
mockStore.error.set('Error');
mockStore.selectedAgent.set(null);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Back to Fleet');
});
});
describe('certificate warning', () => {
it('should show warning for expiring certificate', () => {
mockStore.selectedAgent.set(createMockAgent({
certificate: {
thumbprint: 'abc',
subject: 'CN=test',
issuer: 'CN=CA',
notBefore: '2026-01-01T00:00:00Z',
notAfter: '2026-02-15T00:00:00Z',
isExpired: false,
daysUntilExpiry: 25,
},
}));
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('25 days remaining');
});
it('should apply warning class for expiring certificate', () => {
mockStore.selectedAgent.set(createMockAgent({
certificate: {
thumbprint: 'abc',
subject: 'CN=test',
issuer: 'CN=CA',
notBefore: '2026-01-01T00:00:00Z',
notAfter: '2026-02-15T00:00:00Z',
isExpired: false,
daysUntilExpiry: 25,
},
}));
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.section-card--warning')).toBeTruthy();
});
});
describe('formatDate', () => {
it('should format ISO date string', () => {
const result = component.formatDate('2026-01-18T14:30:00Z');
expect(result).toContain('Jan');
expect(result).toContain('18');
expect(result).toContain('2026');
});
});
describe('getCapacityColor', () => {
it('should return capacity color', () => {
const color = component.getCapacityColor(80);
expect(color).toBeTruthy();
expect(color).toContain('high');
});
});
describe('diagnostics navigation', () => {
beforeEach(() => {
fixture.detectChanges();
spyOn(router, 'navigate');
});
it('should navigate to doctor with agent filter', () => {
component.runDiagnostics();
expect(router.navigate).toHaveBeenCalledWith(['/ops/doctor'], { queryParams: { agent: 'agent-123' } });
});
});
});

View File

@@ -1,897 +0,0 @@
/**
* 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 { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
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';
import { DateFormatService } from '../../core/i18n/date-format.service';
import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component';
type DetailTab = 'overview' | 'health' | 'tasks' | 'logs' | 'config';
const AGENT_DETAIL_TABS: readonly StellaPageTab[] = [
{ id: 'overview', label: 'Overview', icon: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z|||M9 22V12h6v10' },
{ id: 'health', label: 'Health', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' },
{ id: 'tasks', label: 'Tasks', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' },
{ id: 'logs', label: 'Logs', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' },
{ id: 'config', label: 'Configuration', icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' },
];
interface ActionFeedback {
type: 'success' | 'error';
message: string;
}
@Component({
selector: 'st-agent-detail-page',
imports: [RouterLink, AgentHealthTabComponent, AgentTasksTabComponent, AgentActionModalComponent, LoadingStateComponent, StellaPageTabsComponent],
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()) {
<app-loading-state size="lg" message="Loading agent details..." />
} @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
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</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 -->
<stella-page-tabs
[tabs]="tabs"
[activeTab]="activeTab()"
urlParam="tab"
(tabChange)="activeTab.set($any($event))"
ariaLabel="Agent detail tabs"
>
@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>
}
}
</stella-page-tabs>
}
<!-- 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') {
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
} @else {
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
}
</span>
<span class="action-toast__message">{{ feedback.message }}</span>
<button
type="button"
class="action-toast__close"
(click)="clearActionFeedback()"
aria-label="Dismiss"
>
&times;
</button>
</div>
}
}
</div>
`,
styles: [`
.agent-detail-page {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
/* Breadcrumb */
.breadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1.5rem;
font-size: 0.875rem;
}
.breadcrumb__link {
color: var(--color-text-link);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.breadcrumb__separator {
color: var(--color-text-muted);
}
.breadcrumb__current {
color: var(--color-text-secondary);
}
/* 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: var(--radius-full);
margin-top: 0.5rem;
}
.detail-header__title h1 {
margin: 0;
font-size: 1.5rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.detail-header__id {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.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: var(--radius-sm);
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
}
.tag--env {
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
}
.tag--version {
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
}
/* 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(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
text-align: center;
}
.stat-card__label {
display: block;
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
margin-bottom: 0.25rem;
}
.stat-card__value {
font-size: 1.25rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
/* Section Card */
.section-card {
padding: 1.25rem;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
margin-bottom: 1.5rem;
&--warning {
border-left: 3px solid var(--color-status-warning);
}
}
.section-card__title {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
/* 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(--color-surface-secondary);
border-radius: var(--radius-sm);
overflow: hidden;
}
.resource-meter__fill {
height: 100%;
border-radius: var(--radius-sm);
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: var(--font-weight-medium);
color: var(--color-text-secondary);
font-size: 0.8125rem;
}
.detail-list dd {
margin: 0;
font-size: 0.8125rem;
color: var(--color-text-primary);
}
.detail-list code {
font-size: 0.75rem;
background: var(--color-surface-secondary);
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
}
.warning-badge {
display: inline-block;
margin-left: 0.5rem;
padding: 0.125rem 0.5rem;
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
border-radius: var(--radius-sm);
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(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
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(--color-nav-hover);
}
&.danger {
color: var(--color-status-error);
}
}
hr {
margin: 0.25rem 0;
border: none;
border-top: 1px solid var(--color-border-primary);
}
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
border: 1px solid transparent;
}
.btn--secondary {
background: var(--color-surface-primary);
border-color: var(--color-border-primary);
color: var(--color-text-primary);
&:hover {
background: var(--color-nav-hover);
}
}
/* States */
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 4rem;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--color-border-primary);
border-top-color: var(--color-brand-primary);
border-radius: var(--radius-full);
animation: spin 0.8s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-state__message {
color: var(--color-status-error);
margin-bottom: 1rem;
}
.placeholder {
color: var(--color-text-muted);
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(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
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(--color-status-success);
}
.action-toast--error {
border-left: 3px solid var(--color-status-error);
}
.action-toast__icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-full);
}
.action-toast--success .action-toast__icon {
background: rgba(16, 185, 129, 0.1);
color: var(--color-status-success);
}
.action-toast--error .action-toast__icon {
background: rgba(239, 68, 68, 0.1);
color: var(--color-status-error);
}
.action-toast__message {
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.action-toast__close {
margin-left: 0.5rem;
padding: 0.25rem;
background: none;
border: none;
font-size: 1.25rem;
color: var(--color-text-muted);
cursor: pointer;
&:hover {
color: var(--color-text-primary);
}
}
`]
})
export class AgentDetailPageComponent implements OnInit {
private readonly dateFmt = inject(DateFormatService);
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 = AGENT_DETAIL_TABS;
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);
}
}
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(this.dateFmt.locale(), {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
getCapacityColor(percent: number): string {
return getCapacityColor(percent);
}
// Health tab methods
onRunHealthChecks(): void {
const agentId = this.agent()?.id;
if (agentId) {
this.isRunningHealth.set(true);
this.store.fetchAgentHealth(agentId);
// Simulated delay for demo - in real app, health endpoint handles this
setTimeout(() => this.isRunningHealth.set(false), 2000);
}
}
onRerunHealthCheck(checkId: string): void {
console.log('Re-running health check:', checkId);
// Would call specific health check API
this.onRunHealthChecks();
}
// Tasks tab methods
onViewTaskDetails(task: AgentTask): void {
// Could open a drawer or navigate to task detail page
console.log('View task details:', task.taskId);
}
onLoadMoreTasks(): void {
const agentId = this.agent()?.id;
if (agentId) {
// Would call paginated tasks API
this.store.fetchAgentTasks(agentId);
}
}
}

View File

@@ -1,540 +0,0 @@
/**
* 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 { provideRouter, Router } from '@angular/router';
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],
providers: [provideRouter([]), { provide: AgentStore, useValue: mockStore }],
}).compileComponents();
fixture = TestBed.createComponent(AgentFleetDashboardComponent);
component = fixture.componentInstance;
router = TestBed.inject(Router);
});
describe('initialization', () => {
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should fetch agents on init', () => {
fixture.detectChanges();
expect(mockStore.fetchAgents).toHaveBeenCalled();
});
it('should fetch summary on init', () => {
fixture.detectChanges();
expect(mockStore.fetchSummary).toHaveBeenCalled();
});
it('should enable realtime on init', () => {
fixture.detectChanges();
expect(mockStore.enableRealtime).toHaveBeenCalled();
});
it('should start auto refresh on init', () => {
fixture.detectChanges();
expect(mockStore.startAutoRefresh).toHaveBeenCalledWith(60000);
});
it('should stop auto refresh on destroy', () => {
fixture.detectChanges();
component.ngOnDestroy();
expect(mockStore.stopAutoRefresh).toHaveBeenCalled();
});
it('should disable realtime on destroy', () => {
fixture.detectChanges();
component.ngOnDestroy();
expect(mockStore.disableRealtime).toHaveBeenCalled();
});
});
describe('page header', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should display page title', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Agent Fleet');
});
it('should display subtitle', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Monitor and manage');
});
it('should have refresh button', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Refresh');
});
it('should have add agent button', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Add Agent');
});
});
describe('realtime status', () => {
it('should show disconnected status', () => {
mockStore.realtimeConnectionStatus.set('disconnected');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Disconnected');
});
it('should show connected status', () => {
mockStore.isRealtimeConnected.set(true);
mockStore.realtimeConnectionStatus.set('connected');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Live');
});
it('should show connecting status', () => {
mockStore.realtimeConnectionStatus.set('connecting');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Connecting');
});
it('should show retry button on error', () => {
mockStore.realtimeConnectionStatus.set('error');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Retry');
});
it('should reconnect on retry click', () => {
mockStore.realtimeConnectionStatus.set('error');
fixture.detectChanges();
component.reconnectRealtime();
expect(mockStore.reconnectRealtime).toHaveBeenCalled();
});
});
describe('KPI strip', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should display total agents count', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('10');
expect(compiled.textContent).toContain('Total Agents');
});
it('should display online agents count', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('7');
expect(compiled.textContent).toContain('Online');
});
it('should display degraded agents count', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Degraded');
});
it('should display offline agents count', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Offline');
});
it('should display average capacity', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('55%');
expect(compiled.textContent).toContain('Avg Capacity');
});
it('should display certificates expiring', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Certs Expiring');
});
});
describe('filtering', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should have search input', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.search-input')).toBeTruthy();
});
it('should update search filter on input', () => {
const event = { target: { value: 'test-search' } } as unknown as Event;
component.onSearchInput(event);
expect(component.searchQuery()).toBe('test-search');
expect(mockStore.setSearchFilter).toHaveBeenCalledWith('test-search');
});
it('should display status filter chips', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Online');
expect(compiled.textContent).toContain('Degraded');
expect(compiled.textContent).toContain('Offline');
});
it('should toggle status filter', () => {
component.toggleStatusFilter('online');
expect(component.selectedStatuses()).toContain('online');
expect(mockStore.setStatusFilter).toHaveBeenCalled();
});
it('should remove status from filter when already selected', () => {
component.toggleStatusFilter('online');
component.toggleStatusFilter('online');
expect(component.selectedStatuses()).not.toContain('online');
});
it('should check if status is selected', () => {
component.selectedStatuses.set(['online', 'degraded']);
expect(component.isStatusSelected('online')).toBe(true);
expect(component.isStatusSelected('offline')).toBe(false);
});
it('should display environment filter', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Environment');
});
it('should display version filter', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Version');
});
it('should update environment filter', () => {
const event = { target: { value: 'production' } } as unknown as Event;
component.onEnvironmentChange(event);
expect(component.selectedEnvironment()).toBe('production');
expect(mockStore.setEnvironmentFilter).toHaveBeenCalledWith(['production']);
});
it('should update version filter', () => {
const event = { target: { value: '2.5.0' } } as unknown as Event;
component.onVersionChange(event);
expect(component.selectedVersion()).toBe('2.5.0');
expect(mockStore.setVersionFilter).toHaveBeenCalledWith(['2.5.0']);
});
it('should clear all filters', () => {
component.searchQuery.set('test');
component.selectedEnvironment.set('prod');
component.selectedStatuses.set(['online']);
component.clearFilters();
expect(component.searchQuery()).toBe('');
expect(component.selectedEnvironment()).toBe('');
expect(component.selectedStatuses()).toEqual([]);
expect(mockStore.clearFilters).toHaveBeenCalled();
});
it('should detect active filters', () => {
expect(component.hasActiveFilters()).toBe(false);
component.searchQuery.set('test');
expect(component.hasActiveFilters()).toBe(true);
});
});
describe('view modes', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should default to grid view', () => {
expect(component.viewMode()).toBe('grid');
});
it('should switch to heatmap view', () => {
component.setViewMode('heatmap');
expect(component.viewMode()).toBe('heatmap');
});
it('should switch to table view', () => {
component.setViewMode('table');
expect(component.viewMode()).toBe('table');
});
it('should display view toggle buttons', () => {
const compiled = fixture.nativeElement;
const viewBtns = compiled.querySelectorAll('.view-btn');
expect(viewBtns.length).toBe(3);
});
it('should mark active view button', () => {
const compiled = fixture.nativeElement;
const activeBtn = compiled.querySelector('.view-btn--active');
expect(activeBtn).toBeTruthy();
});
});
describe('loading state', () => {
it('should show loading spinner when loading', () => {
mockStore.isLoading.set(true);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.loading-state')).toBeTruthy();
expect(compiled.querySelector('.spinner')).toBeTruthy();
});
it('should hide loading state when not loading', () => {
mockStore.isLoading.set(false);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.loading-state')).toBeNull();
});
});
describe('error state', () => {
it('should show error message', () => {
mockStore.error.set('Failed to fetch agents');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Failed to fetch agents');
});
it('should show try again button', () => {
mockStore.error.set('Error');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Try Again');
});
});
describe('empty state', () => {
it('should show empty state when no agents', () => {
mockStore.filteredAgents.set([]);
mockStore.isLoading.set(false);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.empty-state')).toBeTruthy();
});
it('should show filter-specific empty message', () => {
mockStore.filteredAgents.set([]);
mockStore.agents.set([createMockAgent()]);
component.searchQuery.set('test');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('No agents match your filters');
});
it('should show add agent button when no agents at all', () => {
mockStore.filteredAgents.set([]);
mockStore.agents.set([]);
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Add Your First Agent');
});
});
describe('agent grid', () => {
beforeEach(() => {
const agents = [
createMockAgent({ id: 'a1', name: 'agent-1' }),
createMockAgent({ id: 'a2', name: 'agent-2' }),
];
mockStore.filteredAgents.set(agents);
mockStore.agents.set(agents);
fixture.detectChanges();
});
it('should display agent count', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('2 of 2 agents');
});
it('should display agent grid', () => {
const compiled = fixture.nativeElement;
expect(compiled.querySelector('.agent-grid')).toBeTruthy();
});
});
describe('navigation', () => {
beforeEach(() => {
fixture.detectChanges();
spyOn(router, 'navigate');
});
it('should navigate to agent detail on click', () => {
const agent = createMockAgent({ id: 'agent-123' });
component.onAgentClick(agent);
expect(router.navigate).toHaveBeenCalledWith(['/ops/agents', 'agent-123']);
});
it('should navigate to onboarding wizard', () => {
component.openOnboardingWizard();
expect(router.navigate).toHaveBeenCalledWith(['/ops/agents/onboard']);
});
});
describe('refresh', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should fetch agents on refresh', () => {
component.refresh();
expect(mockStore.fetchAgents).toHaveBeenCalledTimes(2); // init + refresh
});
it('should fetch summary on refresh', () => {
component.refresh();
expect(mockStore.fetchSummary).toHaveBeenCalledTimes(2);
});
});
describe('last refresh time', () => {
it('should display last refresh time', () => {
mockStore.lastRefresh.set('2026-01-18T12:00:00Z');
fixture.detectChanges();
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('Last updated');
});
it('should format refresh time', () => {
const result = component.formatRefreshTime('2026-01-18T14:30:00Z');
expect(result).toContain(':');
});
});
describe('accessibility', () => {
beforeEach(() => {
fixture.detectChanges();
});
it('should have aria-label on KPI section', () => {
const compiled = fixture.nativeElement;
const kpiSection = compiled.querySelector('.kpi-strip');
expect(kpiSection.getAttribute('aria-label')).toBe('Fleet metrics');
});
it('should have aria-label on agent grid', () => {
mockStore.filteredAgents.set([createMockAgent()]);
fixture.detectChanges();
const compiled = fixture.nativeElement;
const grid = compiled.querySelector('.agent-grid');
expect(grid.getAttribute('aria-label')).toBe('Agent list');
});
it('should have aria-labels on view toggle buttons', () => {
const compiled = fixture.nativeElement;
const viewBtns = compiled.querySelectorAll('.view-btn');
viewBtns.forEach((btn: HTMLElement) => {
expect(btn.getAttribute('aria-label')).toBeTruthy();
});
});
});
});

View File

@@ -1,799 +0,0 @@
/**
* 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 { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component';
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',
imports: [FormsModule, AgentCardComponent, CapacityHeatmapComponent, FleetComparisonComponent, LoadingStateComponent],
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()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="btn__icon" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
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"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
</button>
<button
type="button"
class="view-btn"
[class.view-btn--active]="viewMode() === 'heatmap'"
(click)="setViewMode('heatmap')"
aria-label="Heatmap view"
title="Capacity heatmap"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/></svg>
</button>
<button
type="button"
class="view-btn"
[class.view-btn--active]="viewMode() === 'table'"
(click)="setViewMode('table')"
aria-label="Table view"
title="Comparison table"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
</div>
</div>
<!-- Loading State -->
@if (store.isLoading()) {
<app-loading-state size="lg" message="Loading agents..." />
}
<!-- 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: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.page-header__subtitle {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: var(--color-text-secondary);
}
.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(--color-surface-secondary);
border-radius: var(--radius-full);
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.realtime-status--connected {
background: rgba(16, 185, 129, 0.1);
color: var(--color-status-success);
}
.realtime-status__indicator {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: var(--color-text-muted);
}
.realtime-status__indicator--connected {
background: var(--color-status-success);
animation: pulse-connected 2s infinite;
}
.realtime-status__indicator--connecting {
background: var(--color-status-warning);
animation: pulse-connecting 1s infinite;
}
.realtime-status__indicator--error {
background: var(--color-status-error);
}
@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: var(--font-weight-medium);
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: all 0.15s;
border: 1px solid transparent;
}
.btn--primary {
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
&:hover {
background: var(--color-btn-primary-bg-hover);
}
}
.btn--secondary {
background: var(--color-surface-primary);
border-color: var(--color-border-primary);
color: var(--color-text-primary);
&:hover {
background: var(--color-nav-hover);
}
}
.btn--text {
background: transparent;
color: var(--color-text-link);
padding: 0.5rem;
&:hover {
background: var(--color-nav-hover);
}
&:disabled {
color: var(--color-text-muted);
cursor: not-allowed;
}
}
.btn__icon {
display: inline-flex;
align-items: center;
}
/* 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(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
text-align: center;
&--success {
border-left: 3px solid var(--color-status-success);
}
&--warning {
border-left: 3px solid var(--color-status-warning);
}
&--danger {
border-left: 3px solid var(--color-status-error);
}
}
.kpi-card__value {
display: block;
font-size: 1.5rem;
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.kpi-card__label {
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.02em;
}
/* Filter Bar */
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem;
background: var(--color-surface-secondary);
border-radius: var(--radius-lg);
margin-bottom: 1rem;
}
.filter-bar__search {
flex: 1;
min-width: 200px;
}
.search-input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
&:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
}
.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: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.filter-chips {
display: flex;
gap: 0.375rem;
}
.filter-chip {
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: var(--chip-color, var(--color-brand-primary));
}
&--active {
background: var(--chip-color, var(--color-brand-primary));
border-color: var(--chip-color, var(--color-brand-primary));
color: white;
}
}
.filter-select {
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.8125rem;
background: var(--color-surface-primary);
&:focus {
outline: none;
border-color: var(--color-brand-primary);
}
}
/* 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(--color-text-secondary);
}
.view-controls__toggle {
display: flex;
gap: 0.25rem;
padding: 0.25rem;
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
}
.view-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.5rem;
border: none;
background: transparent;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--color-text-muted);
&:hover {
color: var(--color-text-primary);
}
&--active {
background: var(--color-surface-primary);
color: var(--color-text-primary);
box-shadow: var(--shadow-sm);
}
}
/* 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(--color-border-primary);
border-top-color: var(--color-brand-primary);
border-radius: var(--radius-full);
animation: spin 0.8s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-state__message {
color: var(--color-status-error);
margin-bottom: 1rem;
}
.empty-state__message {
color: var(--color-text-secondary);
margin-bottom: 1rem;
}
/* Footer */
.page-footer {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border-primary);
text-align: center;
}
.page-footer__refresh {
font-size: 0.75rem;
color: var(--color-text-muted);
}
/* Responsive */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
gap: 1rem;
}
.page-header__actions {
width: 100%;
}
.kpi-strip {
flex-wrap: wrap;
}
.kpi-card {
min-width: calc(50% - 0.5rem);
}
.filter-bar {
flex-direction: column;
}
.filter-bar__filters {
width: 100%;
}
}
`]
})
export class AgentFleetDashboardComponent implements OnInit, OnDestroy {
readonly store = inject(AgentStore);
private readonly router = inject(Router);
// Local state
readonly searchQuery = signal('');
readonly selectedEnvironment = signal('');
readonly selectedVersion = signal('');
readonly viewMode = signal<ViewMode>('grid');
readonly selectedStatuses = signal<AgentStatus[]>([]);
readonly statusOptions = [
{ value: 'online' as AgentStatus, label: 'Online', color: getStatusColor('online') },
{ value: 'degraded' as AgentStatus, label: 'Degraded', color: getStatusColor('degraded') },
{ value: 'offline' as AgentStatus, label: 'Offline', color: getStatusColor('offline') },
];
readonly hasActiveFilters = computed(() => {
return (
this.searchQuery() !== '' ||
this.selectedEnvironment() !== '' ||
this.selectedVersion() !== '' ||
this.selectedStatuses().length > 0
);
});
ngOnInit(): void {
this.store.fetchAgents();
this.store.fetchSummary();
// Enable real-time updates with WebSocket fallback to polling
this.store.enableRealtime();
// Keep polling as fallback (longer interval when real-time is connected)
this.store.startAutoRefresh(60000);
}
ngOnDestroy(): void {
this.store.stopAutoRefresh();
this.store.disableRealtime();
}
refresh(): void {
this.store.fetchAgents();
this.store.fetchSummary();
}
reconnectRealtime(): void {
this.store.reconnectRealtime();
}
onSearchInput(event: Event): void {
const input = event.target as HTMLInputElement;
this.searchQuery.set(input.value);
this.store.setSearchFilter(input.value);
}
toggleStatusFilter(status: AgentStatus): void {
const current = this.selectedStatuses();
const updated = current.includes(status)
? current.filter((s) => s !== status)
: [...current, status];
this.selectedStatuses.set(updated);
this.store.setStatusFilter(updated);
}
isStatusSelected(status: AgentStatus): boolean {
return this.selectedStatuses().includes(status);
}
onEnvironmentChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedEnvironment.set(select.value);
this.store.setEnvironmentFilter(select.value ? [select.value] : []);
}
onVersionChange(event: Event): void {
const select = event.target as HTMLSelectElement;
this.selectedVersion.set(select.value);
this.store.setVersionFilter(select.value ? [select.value] : []);
}
clearFilters(): void {
this.searchQuery.set('');
this.selectedEnvironment.set('');
this.selectedVersion.set('');
this.selectedStatuses.set([]);
this.store.clearFilters();
}
setViewMode(mode: ViewMode): void {
this.viewMode.set(mode);
}
onAgentClick(agent: Agent): void {
this.router.navigate(['/ops/agents', agent.id]);
}
onAgentMenuClick(event: { agent: Agent; event: MouseEvent }): void {
// TODO: Open context menu with actions
console.log('Menu clicked for agent:', event.agent.id);
}
openOnboardingWizard(): void {
this.router.navigate(['/ops/agents/onboard']);
}
formatRefreshTime(timestamp: string): string {
return new Date(timestamp).toLocaleTimeString();
}
}

View File

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

View File

@@ -1,653 +0,0 @@
/**
* 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 { 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',
imports: [FormsModule, RouterLink],
template: `
<div class="onboard-wizard">
<!-- Header -->
<header class="wizard-header">
<a routerLink="/ops/agents" class="wizard-header__back">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> 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"><svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></div>
<p>Agent connected successfully!</p>
} @else {
<div class="pending-icon"><svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></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"><svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></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(--color-text-link);
text-decoration: none;
font-size: 0.875rem;
&:hover {
text-decoration: underline;
}
}
.wizard-header__title {
margin: 0;
font-size: 1.5rem;
font-weight: var(--font-weight-semibold);
}
/* 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(--color-border-primary);
}
}
.progress-step {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
z-index: 1;
}
.progress-step__number {
width: 32px;
height: 32px;
border-radius: var(--radius-full);
background: var(--color-surface-primary);
border: 2px solid var(--color-border-primary);
display: flex;
align-items: center;
justify-content: center;
font-weight: var(--font-weight-semibold);
font-size: 0.875rem;
margin-bottom: 0.5rem;
}
.progress-step__label {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.progress-step--active .progress-step__number {
border-color: var(--color-brand-primary);
color: var(--color-text-link);
}
.progress-step--active .progress-step__label {
color: var(--color-text-link);
font-weight: var(--font-weight-medium);
}
.progress-step--completed .progress-step__number {
background: var(--color-btn-primary-bg);
border-color: var(--color-brand-primary);
color: white;
}
/* Content */
.wizard-content {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 2rem;
min-height: 300px;
}
.step-content h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
}
.step-content > p {
color: var(--color-text-secondary);
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(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
cursor: pointer;
text-align: left;
transition: all 0.15s;
&:hover {
border-color: var(--color-brand-primary);
}
&--selected {
border-color: var(--color-brand-primary);
background: rgba(59, 130, 246, 0.05);
}
}
.env-option__name {
font-weight: var(--font-weight-semibold);
margin-bottom: 0.25rem;
}
.env-option__desc {
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
/* Form */
.form-group {
margin-bottom: 1.5rem;
label {
display: block;
font-weight: var(--font-weight-medium);
margin-bottom: 0.5rem;
}
}
.form-input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
&:focus {
outline: none;
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
}
/* Install Command */
.install-command {
position: relative;
background: var(--color-surface-tertiary);
border-radius: var(--radius-lg);
margin-bottom: 1.5rem;
pre {
margin: 0;
padding: 1rem;
color: var(--color-border-primary);
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: var(--radius-sm);
color: var(--color-border-primary);
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(--color-text-secondary);
}
}
/* Verify */
.verify-status {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
}
.spinner {
width: 48px;
height: 48px;
border: 3px solid var(--color-border-primary);
border-top-color: var(--color-brand-primary);
border-radius: var(--radius-full);
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: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
}
.success-icon {
background: var(--color-status-success);
color: white;
}
.pending-icon {
background: var(--color-surface-secondary);
color: var(--color-text-muted);
}
.troubleshooting {
margin-top: 2rem;
font-size: 0.8125rem;
summary {
cursor: pointer;
color: var(--color-text-link);
}
ul {
margin-top: 0.5rem;
padding-left: 1.25rem;
color: var(--color-text-secondary);
}
code {
background: var(--color-surface-secondary);
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
}
}
/* Complete */
.complete-icon {
width: 64px;
height: 64px;
border-radius: var(--radius-full);
background: var(--color-status-success);
color: white;
display: flex;
align-items: center;
justify-content: center;
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: var(--radius-md);
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
border: 1px solid transparent;
text-decoration: none;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn--primary {
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
&:hover:not(:disabled) {
background: var(--color-btn-primary-bg-hover);
}
}
.btn--secondary {
background: var(--color-surface-primary);
border-color: var(--color-border-primary);
color: var(--color-text-primary);
&:hover:not(:disabled) {
background: var(--color-nav-hover);
}
}
`]
})
export class AgentOnboardWizardComponent {
private readonly router = inject(Router);
readonly steps: { id: WizardStep; label: string }[] = [
{ id: 'environment', label: 'Environment' },
{ id: 'configure', label: 'Configure' },
{ id: 'install', label: 'Install' },
{ id: 'verify', label: 'Verify' },
{ id: 'complete', label: 'Complete' },
];
readonly environments = [
{ id: 'development', name: 'Development', description: 'For testing and development workloads' },
{ id: 'staging', name: 'Staging', description: 'Pre-production environment' },
{ id: 'production', name: 'Production', description: 'Live production workloads' },
];
readonly currentStep = signal<WizardStep>('environment');
readonly selectedEnvironment = signal<string>('');
readonly agentName = signal('');
readonly maxTasks = signal(10);
readonly isVerifying = signal(false);
readonly isVerified = signal(false);
readonly copied = signal(false);
readonly newAgentId = signal('new-agent-id');
readonly installCommand = computed(() => {
const env = this.selectedEnvironment();
const name = this.agentName() || 'my-agent';
return `docker run -d \\
--name stella-agent \\
-e STELLA_AGENT_NAME="${name}" \\
-e STELLA_ENVIRONMENT="${env}" \\
-e STELLA_CONTROL_PLANE="https://stella.example.com" \\
-e STELLA_AGENT_TOKEN="<your-token>" \\
stella-ops/agent:latest`;
});
readonly canProceed = computed(() => {
switch (this.currentStep()) {
case 'environment':
return this.selectedEnvironment() !== '';
case 'configure':
return this.agentName() !== '' && this.maxTasks() > 0;
case 'install':
return true;
case 'verify':
return this.isVerified();
default:
return true;
}
});
selectEnvironment(envId: string): void {
this.selectedEnvironment.set(envId);
}
onNameInput(event: Event): void {
this.agentName.set((event.target as HTMLInputElement).value);
}
onMaxTasksInput(event: Event): void {
this.maxTasks.set(parseInt((event.target as HTMLInputElement).value, 10) || 1);
}
copyCommand(): void {
navigator.clipboard.writeText(this.installCommand());
this.copied.set(true);
setTimeout(() => this.copied.set(false), 2000);
}
startVerification(): void {
this.isVerifying.set(true);
// Simulate verification - in real app, poll API for agent connection
setTimeout(() => {
this.isVerifying.set(false);
this.isVerified.set(true);
}, 3000);
}
isStepCompleted(stepId: WizardStep): boolean {
const currentIndex = this.steps.findIndex((s) => s.id === this.currentStep());
const stepIndex = this.steps.findIndex((s) => s.id === stepId);
return stepIndex < currentIndex;
}
previousStep(): void {
const currentIndex = this.steps.findIndex((s) => s.id === this.currentStep());
if (currentIndex > 0) {
this.currentStep.set(this.steps[currentIndex - 1].id);
}
}
nextStep(): void {
const currentIndex = this.steps.findIndex((s) => s.id === this.currentStep());
if (currentIndex < this.steps.length - 1) {
this.currentStep.set(this.steps[currentIndex + 1].id);
// Start verification when reaching verify step
if (this.steps[currentIndex + 1].id === 'verify') {
this.startVerification();
}
}
}
}

View File

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

View File

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

View File

@@ -1,457 +0,0 @@
/**
* 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 { 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',
imports: [],
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"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
}
@case ('warning') {
<span class="modal__icon modal__icon--warning" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
}
@default {
<span class="modal__icon modal__icon--info" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></span>
}
}
{{ config().title }}
</h2>
<button
type="button"
class="modal__close"
(click)="onCancel()"
aria-label="Close"
>
&times;
</button>
</header>
<!-- Body -->
<div class="modal__body">
<p class="modal__description">{{ config().description }}</p>
@if (agent(); as agentData) {
<div class="modal__agent-info">
<strong>{{ agentData.displayName || agentData.name }}</strong>
<code>{{ agentData.id }}</code>
</div>
}
@if (config().requiresInput) {
<div class="modal__input-group">
<label class="modal__input-label" [for]="'confirm-input-' + config().action">
{{ config().inputLabel }}
</label>
<input
[id]="'confirm-input-' + config().action"
type="text"
class="modal__input"
[placeholder]="config().inputPlaceholder || ''"
[value]="confirmInput()"
(input)="onInputChange($event)"
autocomplete="off"
/>
@if (inputError()) {
<p class="modal__input-error">{{ inputError() }}</p>
}
</div>
}
</div>
<!-- Footer -->
<footer class="modal__footer">
<button
type="button"
class="btn btn--secondary"
(click)="onCancel()"
[disabled]="isSubmitting()"
>
Cancel
</button>
<button
type="button"
class="btn"
[class.btn--primary]="config().confirmVariant === 'primary'"
[class.btn--warning]="config().confirmVariant === 'warning'"
[class.btn--danger]="config().confirmVariant === 'danger'"
(click)="onConfirm()"
[disabled]="!canConfirm() || isSubmitting()"
>
@if (isSubmitting()) {
<span class="btn__spinner"></span>
Processing...
} @else {
{{ config().confirmLabel }}
}
</button>
</footer>
</div>
</div>
}
`,
styles: [`
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fade-in 0.15s ease-out;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
width: 100%;
max-width: 480px;
background: var(--color-surface-primary);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-xl);
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(--color-border-primary);
}
.modal__title {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0;
font-size: 1.125rem;
font-weight: var(--font-weight-semibold);
}
.modal__icon {
display: inline-flex;
align-items: center;
}
.modal__icon--danger {
color: var(--color-status-error);
}
.modal__icon--warning {
color: var(--color-status-warning);
}
.modal__icon--info {
color: var(--color-text-link);
}
.modal__close {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
border-radius: var(--radius-md);
font-size: 1.5rem;
color: var(--color-text-muted);
cursor: pointer;
&:hover {
background: var(--color-nav-hover);
color: var(--color-text-primary);
}
}
.modal__body {
padding: 1.5rem;
}
.modal__description {
margin: 0 0 1rem;
font-size: 0.9375rem;
line-height: 1.6;
color: var(--color-text-secondary);
}
.modal__agent-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem 1rem;
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
margin-bottom: 1rem;
strong {
font-size: 0.9375rem;
color: var(--color-text-primary);
}
code {
font-size: 0.75rem;
color: var(--color-text-muted);
}
}
.modal__input-group {
margin-top: 1rem;
}
.modal__input-label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.modal__input {
width: 100%;
}
.modal__input-error {
margin: 0.5rem 0 0;
font-size: 0.8125rem;
color: var(--color-status-error);
}
.modal__footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding: 1rem 1.5rem;
background: var(--color-surface-secondary);
border-top: 1px solid var(--color-border-primary);
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: var(--radius-md);
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
border: 1px solid transparent;
transition: all 0.15s;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.btn--secondary {
background: var(--color-surface-primary);
border-color: var(--color-border-primary);
color: var(--color-text-primary);
&:hover:not(:disabled) {
background: var(--color-nav-hover);
}
}
.btn--primary {
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
&:hover:not(:disabled) {
background: var(--color-btn-primary-bg-hover);
}
}
.btn--warning {
background: var(--color-status-warning);
color: white;
&:hover:not(:disabled) {
background: var(--color-status-warning-text);
}
}
.btn--danger {
background: var(--color-status-error);
color: white;
&:hover:not(:disabled) {
background: var(--color-status-error);
}
}
.btn__spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: var(--radius-full);
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`]
})
export class AgentActionModalComponent {
/** The action to perform */
readonly action = input.required<AgentAction>();
/** The agent to perform action on */
readonly agent = input<Agent | null>(null);
/** Whether the modal is visible */
readonly visible = input(false);
/** Whether the action is being submitted */
readonly isSubmitting = input(false);
/** Emits when user confirms the action */
readonly confirm = output<AgentAction>();
/** Emits when user cancels or closes the modal */
readonly cancel = output<void>();
// Local state
readonly confirmInput = signal('');
readonly inputError = signal<string | null>(null);
readonly config = computed<ActionConfig>(() => {
return ACTION_CONFIGS[this.action()] || ACTION_CONFIGS['restart'];
});
readonly canConfirm = computed(() => {
const cfg = this.config();
if (!cfg.requiresInput) {
return true;
}
// For dangerous actions, require typing the agent name
const agentData = this.agent();
if (!agentData) return false;
return this.confirmInput() === agentData.name;
});
onInputChange(event: Event): void {
const input = event.target as HTMLInputElement;
this.confirmInput.set(input.value);
this.inputError.set(null);
}
onConfirm(): void {
if (!this.canConfirm()) {
if (this.config().requiresInput) {
this.inputError.set('Please enter the exact agent name to confirm');
}
return;
}
this.confirm.emit(this.action());
}
onCancel(): void {
this.confirmInput.set('');
this.inputError.set(null);
this.cancel.emit();
}
onBackdropClick(event: MouseEvent): void {
if (event.target === event.currentTarget) {
this.onCancel();
}
}
}

View File

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

View File

@@ -1,346 +0,0 @@
/**
* 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 {
Agent,
AgentStatus,
getStatusColor,
getStatusLabel,
getCapacityColor,
formatHeartbeat,
} from '../../models/agent.models';
@Component({
selector: 'st-agent-card',
imports: [],
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"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>
</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"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
<span>Certificate expires in {{ agent().certificate?.daysUntilExpiry }} days</span>
</div>
}
</article>
`,
styles: [`
.agent-card {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1rem;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
border-color: var(--color-border-secondary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
&--selected {
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
&--offline {
opacity: 0.8;
background: var(--color-surface-tertiary);
}
}
.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: var(--radius-full);
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: var(--font-weight-semibold);
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-card__id {
font-size: 0.75rem;
color: var(--color-text-muted);
font-family: var(--font-mono, monospace);
}
.agent-card__menu-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
background: none;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--color-text-muted);
&:hover {
background: var(--color-nav-hover);
color: var(--color-text-primary);
}
}
.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: var(--radius-sm);
font-size: 0.6875rem;
font-weight: var(--font-weight-medium);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.agent-card__tag--env {
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
}
.agent-card__tag--version {
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
}
.agent-card__capacity {
margin-bottom: 0.75rem;
}
.capacity-label {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--color-text-secondary);
margin-bottom: 0.25rem;
}
.capacity-value {
font-weight: var(--font-weight-semibold);
}
.capacity-bar {
height: 6px;
background: var(--color-surface-secondary);
border-radius: var(--radius-sm);
overflow: hidden;
}
.capacity-bar__fill {
height: 100%;
border-radius: var(--radius-sm);
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(--color-border-primary);
}
.metric {
text-align: center;
}
.metric__label {
display: block;
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted);
margin-bottom: 0.125rem;
}
.metric__value {
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.agent-card__warning {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
padding: 0.5rem;
background: var(--color-status-warning-bg);
border-radius: var(--radius-sm);
font-size: 0.75rem;
color: var(--color-status-warning-text);
}
.warning-icon {
color: var(--color-status-warning);
}
`]
})
export class AgentCardComponent {
/** Agent data */
readonly agent = input.required<Agent>();
/** Whether the card is selected */
readonly selected = input<boolean>(false);
/** Emits when the card is clicked */
readonly cardClick = output<Agent>();
/** Emits when the menu button is clicked */
readonly menuClick = output<{ agent: Agent; event: MouseEvent }>();
// Computed properties
readonly statusColor = computed(() => getStatusColor(this.agent().status));
readonly statusLabel = computed(() => getStatusLabel(this.agent().status));
readonly capacityColor = computed(() => getCapacityColor(this.agent().capacityPercent));
readonly heartbeatLabel = computed(() => formatHeartbeat(this.agent().lastHeartbeat));
readonly hasCertificateWarning = computed(() => {
const cert = this.agent().certificate;
return cert && !cert.isExpired && cert.daysUntilExpiry <= 30;
});
readonly ariaLabel = computed(() => {
const agent = this.agent();
return `Agent ${agent.name}, ${this.statusLabel()}, ${agent.capacityPercent}% capacity, ${agent.activeTasks} active tasks`;
});
/** Truncate agent ID for display */
truncateId(id: string): string {
if (id.length <= 12) return id;
return `${id.slice(0, 8)}...`;
}
onCardClick(): void {
this.cardClick.emit(this.agent());
}
onMenuClick(event: MouseEvent): void {
event.stopPropagation();
this.menuClick.emit({ agent: this.agent(), event });
}
}

View File

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

View File

@@ -1,385 +0,0 @@
/**
* 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 { AgentHealthResult } from '../../models/agent.models';
@Component({
selector: 'st-agent-health-tab',
imports: [],
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"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
}
@case ('warn') {
<span class="status-icon status-icon--warn"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
}
@case ('fail') {
<span class="status-icon status-icon--fail"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></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"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</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: var(--font-weight-semibold);
}
/* Summary */
.health-summary {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.summary-item {
flex: 1;
padding: 1rem;
border-radius: var(--radius-lg);
text-align: center;
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
&--pass {
border-left: 3px solid var(--color-status-success);
}
&--warn {
border-left: 3px solid var(--color-status-warning);
}
&--fail {
border-left: 3px solid var(--color-status-error);
}
}
.summary-item__count {
display: block;
font-size: 1.5rem;
font-weight: var(--font-weight-bold);
}
.summary-item__label {
font-size: 0.75rem;
color: var(--color-text-muted);
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(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
transition: box-shadow 0.15s;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&--pass {
border-left: 3px solid var(--color-status-success);
}
&--warn {
border-left: 3px solid var(--color-status-warning);
}
&--fail {
border-left: 3px solid var(--color-status-error);
}
}
.check-item__status {
flex-shrink: 0;
}
.status-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-full);
&--pass {
background: rgba(16, 185, 129, 0.1);
color: var(--color-status-success);
}
&--warn {
background: rgba(245, 158, 11, 0.1);
color: var(--color-status-warning);
}
&--fail {
background: rgba(239, 68, 68, 0.1);
color: var(--color-status-error);
}
}
.check-item__content {
flex: 1;
min-width: 0;
}
.check-item__name {
margin: 0;
font-size: 0.9375rem;
font-weight: var(--font-weight-semibold);
}
.check-item__message {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.check-item__time {
display: block;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.check-item__actions {
flex-shrink: 0;
}
/* History */
.history-section {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border-primary);
summary {
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-link);
font-weight: var(--font-weight-medium);
&:hover {
text-decoration: underline;
}
}
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
border: 1px solid transparent;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.btn--primary {
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
&:hover:not(:disabled) {
background: var(--color-btn-primary-bg-hover);
}
}
.btn--text {
background: transparent;
color: var(--color-text-muted);
padding: 0.25rem 0.5rem;
&:hover {
color: var(--color-text-primary);
background: var(--color-nav-hover);
}
}
.spinner-small {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: var(--radius-full);
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-secondary);
}
.placeholder {
color: var(--color-text-muted);
font-style: italic;
padding: 1rem 0;
}
`]
})
export class AgentHealthTabComponent {
/** Health check results */
readonly checks = input<AgentHealthResult[]>([]);
/** Whether checks are currently running */
readonly isRunning = input<boolean>(false);
/** Emits when user wants to run all checks */
readonly runChecks = output<void>();
/** Emits when user wants to re-run a specific check */
readonly rerunCheck = output<string>();
// Computed counts
readonly passCount = computed(() => this.checks().filter((c) => c.status === 'pass').length);
readonly warnCount = computed(() => this.checks().filter((c) => c.status === 'warn').length);
readonly failCount = computed(() => this.checks().filter((c) => c.status === 'fail').length);
runHealthChecks(): void {
this.runChecks.emit();
}
formatTime(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60000);
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffMin < 1440) return `${Math.floor(diffMin / 60)}h ago`;
return date.toLocaleDateString();
}
}

View File

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

View File

@@ -1,640 +0,0 @@
/**
* 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,
inject,} from '@angular/core';
import { RouterLink } from '@angular/router';
import { AgentTask } from '../../models/agent.models';
import { DateFormatService } from '../../../../core/i18n/date-format.service';
type TaskFilter = 'all' | 'active' | 'completed' | 'failed';
@Component({
selector: 'st-agent-tasks-tab',
imports: [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"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg> Completed</span>
}
@case ('failed') {
<span class="status-badge status-badge--failed"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> 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"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></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)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
</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: var(--font-weight-semibold);
}
.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(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.15s;
&:hover {
border-color: var(--color-brand-primary);
}
&--active {
background: var(--color-btn-primary-bg);
border-color: var(--color-brand-primary);
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: var(--radius-lg);
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold);
}
.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(--color-surface-secondary);
border-radius: var(--radius-lg);
}
.queue-viz__title {
margin: 0 0 0.75rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
}
.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(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
min-width: 120px;
&--running {
border-color: var(--color-brand-primary);
}
}
.queue-item__type {
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
}
.queue-item__progress {
height: 4px;
background: var(--color-surface-secondary);
border-radius: var(--radius-sm);
overflow: hidden;
}
.queue-item__progress-fill {
height: 100%;
background: var(--color-btn-primary-bg);
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(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
transition: box-shadow 0.15s;
&:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
&--running {
border-left: 3px solid var(--color-brand-primary);
}
&--completed {
border-left: 3px solid var(--color-status-success);
}
&--failed {
border-left: 3px solid var(--color-status-error);
}
&--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: var(--radius-sm);
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
&--running {
background: rgba(59, 130, 246, 0.1);
color: var(--color-text-link);
}
&--pending {
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
}
&--completed {
background: rgba(16, 185, 129, 0.1);
color: var(--color-status-success);
}
&--failed {
background: rgba(239, 68, 68, 0.1);
color: var(--color-status-error);
}
&--cancelled {
background: var(--color-surface-secondary);
color: var(--color-text-muted);
}
}
.spinner-tiny {
width: 10px;
height: 10px;
border: 2px solid rgba(59, 130, 246, 0.3);
border-top-color: var(--color-brand-primary);
border-radius: var(--radius-full);
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: var(--font-weight-semibold);
}
.task-item__id {
font-size: 0.6875rem;
color: var(--color-text-muted);
background: var(--color-surface-secondary);
padding: 0.125rem 0.375rem;
border-radius: var(--radius-sm);
}
.task-item__ref {
margin: 0.25rem 0;
font-size: 0.8125rem;
color: var(--color-text-secondary);
a {
color: var(--color-text-link);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
}
.task-item__progress-bar {
position: relative;
height: 6px;
background: var(--color-surface-secondary);
border-radius: var(--radius-sm);
margin: 0.75rem 0;
overflow: hidden;
}
.task-item__progress-fill {
height: 100%;
background: var(--color-btn-primary-bg);
border-radius: var(--radius-sm);
transition: width 0.3s;
}
.task-item__progress-text {
position: absolute;
right: 0;
top: -1.25rem;
font-size: 0.6875rem;
color: var(--color-text-muted);
}
.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: var(--radius-sm);
font-size: 0.8125rem;
color: var(--color-status-error);
}
.error-icon {
flex-shrink: 0;
}
.task-item__times {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
font-size: 0.75rem;
color: var(--color-text-muted);
}
.task-item__actions {
flex-shrink: 0;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
border: 1px solid transparent;
}
.btn--icon {
padding: 0.375rem 0.5rem;
background: transparent;
border: none;
color: var(--color-text-muted);
&:hover {
color: var(--color-text-primary);
background: var(--color-nav-hover);
}
}
.btn--secondary {
background: var(--color-surface-primary);
border-color: var(--color-border-primary);
color: var(--color-text-primary);
&:hover {
background: var(--color-nav-hover);
}
}
.btn--text {
background: transparent;
color: var(--color-text-link);
&:hover {
text-decoration: underline;
}
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-secondary);
}
/* Pagination */
.pagination {
margin-top: 1.5rem;
text-align: center;
}
`]
})
export class AgentTasksTabComponent {
private readonly dateFmt = inject(DateFormatService);
/** 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(this.dateFmt.locale(), {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
calculateDuration(start: string, end: string): string {
const startDate = new Date(start);
const endDate = new Date(end);
const diffMs = endDate.getTime() - startDate.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return `${diffSec}s`;
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ${diffSec % 60}s`;
return `${Math.floor(diffSec / 3600)}h ${Math.floor((diffSec % 3600) / 60)}m`;
}
}

View File

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

View File

@@ -1,478 +0,0 @@
/**
* 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 { Agent, getCapacityColor } from '../../models/agent.models';
type GroupBy = 'none' | 'environment' | 'status';
@Component({
selector: 'st-capacity-heatmap',
imports: [],
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(--color-severity-low)">
&lt;50%
</span>
<span class="legend-item" style="--item-color: var(--color-severity-medium)">
50-80%
</span>
<span class="legend-item" style="--item-color: var(--color-severity-high)">
80-95%
</span>
<span class="legend-item" style="--item-color: var(--color-severity-critical)">
&gt;95%
</span>
</div>
</div>
<!-- Heatmap Grid -->
@if (groupBy() === 'none') {
<div class="heatmap-grid" role="grid" aria-label="Agent capacity heatmap">
@for (agent of agents(); track agent.id) {
<button
type="button"
class="heatmap-cell"
[style.--cell-color]="getCellColor(agent.capacityPercent)"
[class.heatmap-cell--offline]="agent.status === 'offline'"
[attr.aria-label]="getCellAriaLabel(agent)"
(click)="onCellClick(agent)"
(mouseenter)="hoveredAgent.set(agent)"
(mouseleave)="hoveredAgent.set(null)"
>
<span class="heatmap-cell__value">{{ agent.capacityPercent }}%</span>
</button>
}
</div>
} @else {
<!-- Grouped View -->
@for (group of groupedAgents(); track group.key) {
<div class="heatmap-group">
<h4 class="heatmap-group__title">
{{ group.key }}
<span class="heatmap-group__count">({{ group.agents.length }})</span>
</h4>
<div class="heatmap-grid" role="grid">
@for (agent of group.agents; track agent.id) {
<button
type="button"
class="heatmap-cell"
[style.--cell-color]="getCellColor(agent.capacityPercent)"
[class.heatmap-cell--offline]="agent.status === 'offline'"
[attr.aria-label]="getCellAriaLabel(agent)"
(click)="onCellClick(agent)"
(mouseenter)="hoveredAgent.set(agent)"
(mouseleave)="hoveredAgent.set(null)"
>
<span class="heatmap-cell__value">{{ agent.capacityPercent }}%</span>
</button>
}
</div>
</div>
}
}
<!-- Tooltip -->
@if (hoveredAgent(); as agent) {
<div class="heatmap-tooltip" role="tooltip">
<div class="tooltip-header">
<span
class="tooltip-status"
[style.background-color]="getStatusColor(agent.status)"
></span>
<strong>{{ agent.name }}</strong>
</div>
<dl class="tooltip-details">
<dt>Environment</dt>
<dd>{{ agent.environment }}</dd>
<dt>Capacity</dt>
<dd>{{ agent.capacityPercent }}%</dd>
<dt>Active Tasks</dt>
<dd>{{ agent.activeTasks }}</dd>
<dt>Status</dt>
<dd>{{ agent.status }}</dd>
</dl>
<span class="tooltip-hint">Click to view details</span>
</div>
}
<!-- Summary -->
<footer class="heatmap-summary">
<div class="summary-stat">
<span class="summary-stat__value">{{ avgCapacity() }}%</span>
<span class="summary-stat__label">Avg Capacity</span>
</div>
<div class="summary-stat">
<span class="summary-stat__value">{{ highUtilizationCount() }}</span>
<span class="summary-stat__label">High Utilization (&gt;80%)</span>
</div>
<div class="summary-stat">
<span class="summary-stat__value">{{ criticalCount() }}</span>
<span class="summary-stat__label">Critical (&gt;95%)</span>
</div>
</footer>
</div>
`,
styles: [`
.capacity-heatmap {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
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: var(--font-weight-semibold);
}
.group-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: var(--color-text-secondary);
}
.group-select {
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
font-size: 0.8125rem;
background: var(--color-surface-primary);
&:focus {
outline: none;
border-color: var(--color-brand-primary);
}
}
/* Legend */
.heatmap-legend {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
padding: 0.5rem 0.75rem;
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
}
.legend-label {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.legend-scale {
display: flex;
gap: 0.75rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--color-text-secondary);
&::before {
content: '';
width: 12px;
height: 12px;
border-radius: var(--radius-sm);
background: var(--color-text-secondary);
}
}
/* 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(--color-text-secondary);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: transform 0.15s, box-shadow 0.15s;
min-width: 48px;
&:hover {
transform: scale(1.1);
box-shadow: var(--shadow-lg);
z-index: 10;
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
&--offline {
opacity: 0.4;
background: var(--color-surface-secondary) !important;
}
}
.heatmap-cell__value {
font-size: 0.625rem;
font-weight: var(--font-weight-semibold);
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.heatmap-cell--offline .heatmap-cell__value {
color: var(--color-text-muted);
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: var(--font-weight-semibold);
color: var(--color-text-secondary);
}
.heatmap-group__count {
font-weight: var(--font-weight-normal);
color: var(--color-text-muted);
}
/* Tooltip */
.heatmap-tooltip {
position: absolute;
top: 50%;
right: 1rem;
transform: translateY(-50%);
width: 180px;
padding: 0.75rem;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
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: var(--radius-full);
}
.tooltip-details {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.25rem 0.5rem;
margin: 0;
font-size: 0.75rem;
dt {
color: var(--color-text-muted);
}
dd {
margin: 0;
font-weight: var(--font-weight-medium);
}
}
.tooltip-hint {
display: block;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border-primary);
font-size: 0.625rem;
color: var(--color-text-muted);
text-align: center;
}
/* Summary */
.heatmap-summary {
display: flex;
justify-content: center;
gap: 2rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border-primary);
}
.summary-stat {
text-align: center;
}
.summary-stat__value {
display: block;
font-size: 1.25rem;
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
.summary-stat__label {
font-size: 0.6875rem;
color: var(--color-text-muted);
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(--color-status-success)';
case 'degraded':
return 'var(--color-status-warning)';
case 'offline':
return 'var(--color-status-error)';
default:
return 'var(--color-text-muted)';
}
}
getCellAriaLabel(agent: Agent): string {
return `${agent.name}: ${agent.capacityPercent}% capacity, ${agent.status}`;
}
}

View File

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

View File

@@ -1,721 +0,0 @@
/**
* 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 { 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',
imports: [],
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"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</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"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></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">
@if (sortDirection() === 'asc') {
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="18 15 12 9 6 15"/></svg>
} @else {
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
}
</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"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></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)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<!-- Footer -->
<footer class="comparison-footer">
<span class="footer-info">
Showing {{ sortedAgents().length }} agents
@if (versionMismatchCount() > 0) {
&middot; {{ versionMismatchCount() }} version mismatches
}
</span>
</footer>
</div>
`,
styles: [`
.fleet-comparison {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
}
/* Toolbar */
.comparison-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 1rem;
}
.toolbar-title {
margin: 0;
font-size: 1rem;
font-weight: var(--font-weight-semibold);
}
.toolbar-count {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.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(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
z-index: 100;
min-width: 160px;
h4 {
margin: 0 0 0.5rem;
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
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: var(--radius-md);
font-size: 0.8125rem;
&--warning {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
}
.alert-icon {
display: inline-flex;
align-items: center;
}
/* 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(--color-border-primary);
}
.comparison-table th {
background: var(--color-surface-secondary);
font-weight: var(--font-weight-semibold);
color: var(--color-text-secondary);
white-space: nowrap;
&.sortable {
cursor: pointer;
user-select: none;
&:hover {
color: var(--color-text-primary);
}
}
&.sorted {
color: var(--color-text-link);
}
}
.sort-indicator {
margin-left: 0.25rem;
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.comparison-table tbody tr {
transition: background 0.15s;
&:hover {
background: var(--color-nav-hover);
}
&.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: var(--radius-full);
flex-shrink: 0;
}
.agent-name {
font-weight: var(--font-weight-medium);
}
.agent-id {
font-size: 0.6875rem;
color: var(--color-text-muted);
background: var(--color-surface-secondary);
padding: 0.125rem 0.25rem;
border-radius: var(--radius-sm);
}
.tag {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.6875rem;
font-weight: var(--font-weight-medium);
&--env {
background: var(--color-surface-tertiary);
color: var(--color-text-secondary);
}
}
.status-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.6875rem;
font-weight: var(--font-weight-semibold);
background: color-mix(in srgb, var(--color-text-secondary) 15%, transparent);
color: var(--color-text-secondary);
}
.version {
display: inline-flex;
align-items: center;
gap: 0.25rem;
&--mismatch {
color: var(--color-status-warning);
}
}
.mismatch-icon {
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.capacity-cell {
display: flex;
align-items: center;
gap: 0.5rem;
}
.capacity-bar {
width: 60px;
height: 6px;
background: var(--color-surface-secondary);
border-radius: var(--radius-sm);
overflow: hidden;
}
.capacity-bar__fill {
height: 100%;
border-radius: var(--radius-sm);
}
.capacity-value {
font-variant-numeric: tabular-nums;
min-width: 32px;
}
.tasks-count {
font-variant-numeric: tabular-nums;
}
.heartbeat {
color: var(--color-text-secondary);
}
.cert-expiry {
&--warning {
color: var(--color-status-warning);
font-weight: var(--font-weight-semibold);
}
&--critical {
color: var(--color-status-error);
font-weight: var(--font-weight-semibold);
}
&--na {
color: var(--color-text-muted);
}
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
cursor: pointer;
border: 1px solid transparent;
}
.btn--secondary {
background: var(--color-surface-primary);
border-color: var(--color-border-primary);
color: var(--color-text-primary);
&:hover {
background: var(--color-nav-hover);
}
}
.btn--icon {
padding: 0.375rem 0.5rem;
background: transparent;
border: none;
color: var(--color-text-muted);
&:hover {
color: var(--color-text-primary);
background: var(--color-nav-hover);
}
}
/* Footer */
.comparison-footer {
padding: 0.75rem 1rem;
background: var(--color-surface-secondary);
border-top: 1px solid var(--color-border-primary);
}
.footer-info {
font-size: 0.75rem;
color: var(--color-text-muted);
}
/* Responsive */
@media (max-width: 768px) {
.comparison-toolbar {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.toolbar-right {
width: 100%;
justify-content: flex-end;
}
}
`]
})
export class FleetComparisonComponent {
/** List of agents */
readonly agents = input<Agent[]>([]);
/** Emits when view agent is clicked */
readonly viewAgent = output<Agent>();
// Local state
readonly sortColumn = signal<SortColumn>('name');
readonly sortDirection = signal<SortDirection>('asc');
readonly showColumnMenu = signal(false);
readonly columnConfigs = signal<ColumnConfig[]>([
{ key: 'name', label: 'Name', visible: true },
{ key: 'environment', label: 'Environment', visible: true },
{ key: 'status', label: 'Status', visible: true },
{ key: 'version', label: 'Version', visible: true },
{ key: 'capacity', label: 'Capacity', visible: true },
{ key: 'tasks', label: 'Tasks', visible: true },
{ key: 'heartbeat', label: 'Heartbeat', visible: true },
{ key: 'certExpiry', label: 'Cert Expiry', visible: true },
]);
// Computed
readonly visibleColumns = computed(() =>
this.columnConfigs().filter((c) => c.visible)
);
readonly latestVersion = computed(() => {
const versions = this.agents().map((a) => a.version);
if (versions.length === 0) return '';
return versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))[0];
});
readonly versionMismatchCount = computed(() => {
const latest = this.latestVersion();
return this.agents().filter((a) => a.version !== latest).length;
});
readonly sortedAgents = computed(() => {
const agents = [...this.agents()];
const column = this.sortColumn();
const direction = this.sortDirection();
agents.sort((a, b) => {
let comparison = 0;
switch (column) {
case 'name':
comparison = (a.displayName || a.name).localeCompare(b.displayName || b.name);
break;
case 'environment':
comparison = a.environment.localeCompare(b.environment);
break;
case 'status':
comparison = a.status.localeCompare(b.status);
break;
case 'version':
comparison = a.version.localeCompare(b.version, undefined, { numeric: true });
break;
case 'capacity':
comparison = a.capacityPercent - b.capacityPercent;
break;
case 'tasks':
comparison = a.activeTasks - b.activeTasks;
break;
case 'heartbeat':
comparison = new Date(a.lastHeartbeat).getTime() - new Date(b.lastHeartbeat).getTime();
break;
case 'certExpiry':
const aExpiry = a.certificate?.daysUntilExpiry ?? Infinity;
const bExpiry = b.certificate?.daysUntilExpiry ?? Infinity;
comparison = aExpiry - bExpiry;
break;
}
return direction === 'asc' ? comparison : -comparison;
});
return agents;
});
toggleColumnMenu(): void {
this.showColumnMenu.update((v) => !v);
}
toggleColumn(key: SortColumn): void {
this.columnConfigs.update((configs) =>
configs.map((c) => (c.key === key ? { ...c, visible: !c.visible } : c))
);
}
sortBy(column: SortColumn): void {
if (this.sortColumn() === column) {
this.sortDirection.update((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
this.sortColumn.set(column);
this.sortDirection.set('asc');
}
}
exportToCsv(): void {
const headers = this.visibleColumns().map((c) => c.label);
const rows = this.sortedAgents().map((agent) =>
this.visibleColumns().map((col) => {
switch (col.key) {
case 'name':
return agent.displayName || agent.name;
case 'environment':
return agent.environment;
case 'status':
return agent.status;
case 'version':
return agent.version;
case 'capacity':
return `${agent.capacityPercent}%`;
case 'tasks':
return `${agent.activeTasks}/${agent.taskQueueDepth}`;
case 'heartbeat':
return agent.lastHeartbeat;
case 'certExpiry':
return agent.certificate ? `${agent.certificate.daysUntilExpiry}d` : 'N/A';
default:
return '';
}
})
);
const csv = [headers, ...rows].map((row) => row.join(',')).join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `agent-fleet-${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(url);
}
truncateId(id: string): string {
if (id.length <= 8) return id;
return `${id.slice(0, 6)}...`;
}
formatHeartbeat(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return 'Just now';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
return `${Math.floor(diffSec / 86400)}d ago`;
}
getStatusColor(status: string): string {
return getStatusColor(status as any);
}
getStatusLabel(status: string): string {
return getStatusLabel(status as any);
}
getCapacityColor(percent: number): string {
return getCapacityColor(percent);
}
}

View File

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

View File

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

View File

@@ -1,206 +0,0 @@
/**
* 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 metrics?: 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(--color-status-success)';
case 'degraded':
return 'var(--color-status-warning)';
case 'offline':
return 'var(--color-status-error)';
case 'unknown':
default:
return 'var(--color-text-muted)';
}
}
/**
* 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(--color-severity-low)';
if (percent < 80) return 'var(--color-severity-medium)';
if (percent < 95) return 'var(--color-severity-high)';
return 'var(--color-severity-critical)';
}
/**
* Format last heartbeat for display.
*/
export function formatHeartbeat(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return 'Just now';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
return `${Math.floor(diffSec / 86400)}d ago`;
}

View File

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

View File

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

View File

@@ -19,8 +19,8 @@ import { ContextHeaderComponent } from '../../shared/ui/context-header/context-h
template: `
<section class="pack-registry-page">
<app-context-header
title="Pack Registry Browser"
subtitle="Browse TaskRunner packs, inspect DSSE signature state, and run compatibility-checked installs and upgrades."
title="Automation Catalog"
subtitle="Browse automation packs, inspect DSSE signature state, and run compatibility-checked installs and upgrades."
testId="pack-registry-header"
>
<button header-actions type="button" class="refresh-btn" (click)="refresh()" [disabled]="loading()">

View File

@@ -18,7 +18,7 @@ interface EventType {
<section class="event-stream">
<header class="event-stream__header">
<nav class="event-stream__breadcrumb">
<a [routerLink]="OPERATIONS_PATHS.overview">Operations</a>
<a [routerLink]="OPERATIONS_PATHS.jobsQueues">Operations</a>
<span class="separator">/</span>
<span>Event Stream</span>
</nav>

View File

@@ -1,7 +1,6 @@
export const OPERATIONS_ROOT = '/ops/operations';
export const OPERATIONS_PATHS = {
overview: OPERATIONS_ROOT,
dataIntegrity: `${OPERATIONS_ROOT}/data-integrity`,
jobsQueues: `${OPERATIONS_ROOT}/jobs-queues`,
healthSlo: `${OPERATIONS_ROOT}/health-slo`,
@@ -10,7 +9,6 @@ export const OPERATIONS_PATHS = {
quotas: `${OPERATIONS_ROOT}/quotas`,
aoc: `${OPERATIONS_ROOT}/aoc`,
doctor: `${OPERATIONS_ROOT}/doctor`,
signals: `${OPERATIONS_ROOT}/signals`,
packs: `${OPERATIONS_ROOT}/packs`,
notifications: `${OPERATIONS_ROOT}/notifications`,
eventStream: `${OPERATIONS_ROOT}/event-stream`,

View File

@@ -1,143 +0,0 @@
<section class="ops-overview" data-testid="operations-overview">
<header class="ops-overview__header">
<div class="ops-overview__title-group">
<h1>Operations</h1>
<p class="ops-overview__subtitle">
Platform health, execution control, diagnostics, and airgap workflows.
</p>
</div>
<div class="ops-overview__header-right">
<stella-quick-links class="ops-overview__inline-links" [links]="quickNav" label="Quick Links" layout="strip" />
<div class="ops-overview__actions">
<a [routerLink]="OPERATIONS_PATHS.doctor">Run Doctor</a>
<a routerLink="/evidence/audit-log">Audit Log</a>
<a routerLink="/evidence/exports">Export Ops Report</a>
<button
type="button"
data-testid="operations-refresh-btn"
(click)="refreshSnapshot()"
>
Refresh
</button>
</div>
</div>
</header>
<stella-metric-grid [columns]="4">
<stella-metric-card
label="Blocking subsystems"
value="3"
subtitle="Release affecting"
icon="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01"
/>
<stella-metric-card
label="Degraded surfaces"
value="5"
subtitle="Operator follow-up"
icon="M22 12h-4l-3 9L9 3l-3 9H2"
/>
<stella-metric-card
label="Setup-owned handoffs"
value="2"
subtitle="Topology boundary"
icon="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2|||M9 7a4 4 0 1 0 0-8a4 4 0 0 0 0 8z"
/>
<stella-metric-card
label="Queued actions"
[value]="'' + pendingActions.length"
subtitle="Needs review"
icon="M12 2a10 10 0 1 0 0 20a10 10 0 0 0 0-20z|||M12 6v6l4 2"
/>
</stella-metric-grid>
<section class="ops-overview__blocking" aria-labelledby="operations-blocking-title">
<div class="ops-overview__section-header">
<div>
<h2 id="operations-blocking-title">Blocking</h2>
<p>Open the highest-impact drills first. These cards route to the owning child page.</p>
</div>
</div>
<div class="ops-overview__blocking-grid">
@for (item of blockingCards; track item.id) {
<a
class="blocking-card"
[routerLink]="item.route"
[attr.data-testid]="'operations-blocking-' + item.id"
>
<div class="blocking-card__topline">
<span class="impact" [class]="'impact impact--' + item.impact">{{ item.metric }}</span>
<span class="blocking-card__route">Open</span>
</div>
<h3>{{ item.title }}</h3>
<p>{{ item.detail }}</p>
</a>
}
</div>
</section>
<st-doctor-checks-inline category="core" heading="Critical diagnostics" />
<section class="ops-overview__columns-layout">
<div class="ops-overview__columns-left">
<article class="ops-overview__panel" data-testid="operations-blocking-badges">
<h2>Blocking Subsystem Badges</h2>
<div class="ops-overview__badge-grid">
@for (item of blockingCards; track item.id) {
<a class="ops-overview__badge-item" [routerLink]="item.route">
<span class="impact" [class]="'impact impact--' + item.impact">{{ item.impact }}</span>
<span>{{ item.title }}</span>
</a>
}
</div>
</article>
@for (group of overviewGroups; track group.id) {
<article
class="ops-overview__panel ops-overview__group-column"
[attr.data-testid]="'operations-group-' + group.id"
>
<h2>{{ group.title }}</h2>
<p class="ops-overview__group-desc">{{ group.description }}</p>
<div class="ops-overview__badge-grid">
@for (card of group.cards; track card.id) {
<a
class="ops-overview__badge-item"
[routerLink]="card.route"
[attr.data-testid]="'operations-card-' + card.id"
>
<span class="impact" [class]="'impact impact--' + card.impact">{{ card.metric }}</span>
<span>{{ card.title }}</span>
@if (card.owner) {
<span class="ops-overview__badge-owner" [class.ops-overview__badge-owner--setup]="card.owner === 'Setup'">{{ card.owner }}</span>
}
</a>
}
</div>
</article>
}
</div>
<div class="ops-overview__columns-right">
<article class="ops-overview__panel" data-testid="operations-pending-actions">
<h2>Pending Operator Actions</h2>
<ul>
@for (item of pendingActions; track item.id) {
<li>
<a [routerLink]="item.route">{{ item.title }}</a>
<span>{{ item.detail }}</span>
<strong>{{ item.owner }}</strong>
</li>
}
</ul>
</article>
</div>
</section>
@if (refreshedAt()) {
<p class="ops-overview__note" data-testid="operations-refresh-note">
Snapshot refreshed at {{ refreshedAt() }}.
</p>
}
</section>

View File

@@ -1,372 +0,0 @@
:host {
display: block;
}
.ops-overview {
display: grid;
gap: 1rem;
}
.ops-overview__header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.ops-overview__title-group h1 {
margin: 0;
font-size: 1.55rem;
}
.ops-overview__header-right {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
flex: 0 1 60%;
min-width: 0;
}
.ops-overview__inline-links {
border-top: none;
padding-top: 0;
margin-top: 0;
}
.ops-overview__subtitle {
margin: 0.3rem 0 0;
max-width: 72ch;
color: var(--color-text-secondary);
font-size: 0.84rem;
line-height: 1.5;
}
.ops-overview__actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.ops-overview__actions a,
.ops-overview__actions button,
.ops-overview__submenu-link,
.blocking-card,
.ops-card,
.ops-overview__boundary-links a {
text-decoration: none;
}
.ops-overview__actions a,
.ops-overview__actions button {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-primary);
padding: 0.42rem 0.75rem;
font-size: 0.76rem;
font-weight: 500;
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease, transform 150ms ease;
&:hover {
background: var(--color-surface-secondary);
border-color: var(--color-brand-primary);
}
&:active {
transform: translateY(1px);
}
}
// Primary action button (Refresh)
.ops-overview__actions button {
background: var(--color-btn-primary-bg);
color: var(--color-btn-primary-text);
border-color: var(--color-btn-primary-bg);
&:hover {
filter: brightness(1.1);
background: var(--color-btn-primary-bg);
border-color: var(--color-btn-primary-bg);
}
}
.ops-overview__submenu {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
}
.ops-overview__submenu-link {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-full);
background: var(--color-surface-primary);
color: var(--color-text-secondary);
padding: 0.2rem 0.62rem;
font-size: 0.72rem;
}
.ops-overview__summary {
display: grid;
gap: 0.7rem;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.ops-overview__summary article,
.ops-overview__panel {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 0.85rem;
display: grid;
gap: 0.25rem;
transition: transform 150ms ease, box-shadow 150ms ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
}
.ops-overview__summary strong {
font-size: 1.45rem;
}
.ops-overview__summary-label,
.ops-overview__section-header p,
.ops-card p,
.ops-overview__panel p,
.ops-overview__panel li span {
color: var(--color-text-secondary);
}
.ops-overview__summary-label {
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.ops-overview__section-header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: flex-start;
}
.ops-overview__section-header h2,
.ops-overview__panel h2 {
margin: 0;
font-size: 1rem;
}
.ops-overview__section-header p,
.ops-card p,
.ops-overview__panel p,
.ops-overview__panel li span {
margin: 0.25rem 0 0;
font-size: 0.8rem;
line-height: 1.45;
}
.ops-overview__blocking-grid,
.ops-overview__group-grid {
display: grid;
gap: 0.8rem;
grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
}
.ops-overview__columns-layout {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 0.8rem;
}
.ops-overview__columns-left,
.ops-overview__columns-right {
display: grid;
gap: 0.8rem;
align-content: start;
}
.ops-overview__group-column {
gap: 0.35rem;
&:hover {
transform: none;
box-shadow: none;
}
}
.ops-overview__group-desc {
margin: 0;
font-size: 0.76rem;
color: var(--color-text-secondary);
line-height: 1.4;
}
.ops-overview__badge-owner {
margin-left: auto;
font-size: 0.65rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-link);
}
.ops-overview__badge-owner--setup {
color: var(--color-status-warning-text);
}
.ops-overview__badge-grid {
display: grid;
gap: 0.45rem;
}
.ops-overview__badge-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.6rem;
border-radius: var(--radius-md);
text-decoration: none;
color: inherit;
font-size: 0.8rem;
transition: background 150ms ease;
&:hover {
background: var(--color-surface-secondary);
}
}
.blocking-card,
.ops-card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
color: inherit;
padding: 0.85rem;
display: grid;
gap: 0.4rem;
transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: var(--color-brand-primary);
}
}
.blocking-card__topline,
.ops-card__header {
display: flex;
justify-content: space-between;
gap: 0.5rem;
align-items: center;
}
.blocking-card__route,
.ops-card__owner {
font-size: 0.68rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
}
.ops-card__owner--setup {
color: var(--color-text-link);
}
.blocking-card h3,
.ops-card h3 {
margin: 0;
font-size: 0.96rem;
}
.impact {
width: fit-content;
border-radius: 9999px;
padding: 0.15rem 0.52rem;
font-size: 0.68rem;
font-weight: var(--font-weight-semibold);
letter-spacing: 0.02em;
}
.impact--blocking {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.impact--degraded {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.impact--info {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
}
.ops-overview__panel ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.65rem;
}
.ops-overview__panel li {
display: grid;
gap: 0.16rem;
padding: 0.5rem 0.6rem;
border-radius: var(--radius-md);
transition: background 150ms ease;
&:hover {
background: var(--color-surface-secondary);
}
}
.ops-overview__panel li a,
.ops-overview__boundary-links a {
color: var(--color-text-link);
transition: color 150ms ease;
&:hover {
filter: brightness(0.85);
}
}
.ops-overview__panel li strong {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Boundary links removed — Setup Boundary section consolidated */
.ops-overview__note {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
@media (max-width: 900px) {
.ops-overview__header {
flex-direction: column;
}
.ops-overview__header-right {
align-items: flex-start;
}
.ops-overview__actions {
justify-content: flex-start;
}
.ops-overview__columns-layout {
grid-template-columns: 1fr;
}
}

View File

@@ -1,269 +0,0 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { DoctorChecksInlineComponent } from '../../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import {
type OverviewCardGroup,
} from '../../../shared/ui';
import {
StellaQuickLinksComponent,
type StellaQuickLink,
} from '../../../shared/components/stella-quick-links/stella-quick-links.component';
import { StellaMetricCardComponent } from '../../../shared/components/stella-metric-card/stella-metric-card.component';
import { StellaMetricGridComponent } from '../../../shared/components/stella-metric-card/stella-metric-grid.component';
import {
OPERATIONS_INTEGRATION_PATHS,
OPERATIONS_PATHS,
OPERATIONS_SETUP_PATHS,
dataIntegrityPath,
} from './operations-paths';
type OpsImpact = 'blocking' | 'degraded' | 'info';
interface BlockingCard {
readonly id: string;
readonly title: string;
readonly detail: string;
readonly metric: string;
readonly impact: OpsImpact;
readonly route: string;
}
interface PendingAction {
readonly id: string;
readonly title: string;
readonly detail: string;
readonly route: string;
readonly owner: 'Ops' | 'Setup';
}
@Component({
selector: 'app-platform-ops-overview-page',
standalone: true,
imports: [RouterLink, DoctorChecksInlineComponent, StellaQuickLinksComponent, StellaMetricCardComponent, StellaMetricGridComponent],
templateUrl: './platform-ops-overview-page.component.html',
styleUrls: ['./platform-ops-overview-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlatformOpsOverviewPageComponent {
readonly OPERATIONS_PATHS = OPERATIONS_PATHS;
readonly OPERATIONS_SETUP_PATHS = OPERATIONS_SETUP_PATHS;
readonly refreshedAt = signal<string | null>(null);
readonly quickNav: readonly StellaQuickLink[] = [
{ label: 'Overview', route: OPERATIONS_PATHS.overview, description: 'Blocking issues and operator actions' },
{ label: 'Data Integrity', route: OPERATIONS_PATHS.dataIntegrity, description: 'Feed freshness and replay backlog' },
{ label: 'Jobs & Queues', route: OPERATIONS_PATHS.jobsQueues, description: 'Execution queues and worker capacity' },
{ label: 'Health & SLO', route: OPERATIONS_PATHS.healthSlo, description: 'Service health and SLO metrics' },
{ label: 'Quotas & Limits', route: OPERATIONS_PATHS.quotas, description: 'Tenant quotas and throttle events' },
{ label: 'AOC Compliance', route: OPERATIONS_PATHS.aoc, description: 'Provenance violation review' },
{ label: 'Pack Registry', route: OPERATIONS_PATHS.packs, description: 'TaskRunner packs and installs' },
];
readonly blockingCards: readonly BlockingCard[] = [
{
id: 'feeds',
title: 'Feed freshness blocking releases',
detail: 'NVD mirror is stale and approvals are pinned to the last-known-good snapshot.',
metric: '3h 12m stale',
impact: 'blocking',
route: dataIntegrityPath('feeds-freshness'),
},
{
id: 'dlq',
title: 'Replay backlog degrading confidence',
detail: 'Dead-letter replay queue is elevated for reachability and evidence exports.',
metric: '3 queued replays',
impact: 'degraded',
route: dataIntegrityPath('dlq'),
},
{
id: 'aoc',
title: 'AOC compliance needs operator review',
detail: 'Recent provenance violations require a compliance drilldown before promotion.',
metric: '4 open violations',
impact: 'blocking',
route: `${OPERATIONS_PATHS.aoc}/violations`,
},
];
readonly overviewGroups: readonly OverviewCardGroup[] = [
{
id: 'blocking',
title: 'Blocking',
description: 'Issues that can block releases or trust decisions.',
cards: [
{
id: 'data-integrity',
title: 'Data Integrity',
detail: 'Feeds, scans, reachability, DLQ.',
metric: '5 trust signals',
impact: 'blocking',
route: OPERATIONS_PATHS.dataIntegrity,
owner: 'Ops',
},
{
id: 'aoc',
title: 'AOC Compliance',
detail: 'Attestation and violation triage.',
metric: '4 violations',
impact: 'blocking',
route: OPERATIONS_PATHS.aoc,
owner: 'Ops',
},
],
},
{
id: 'execution',
title: 'Execution',
description: 'Queues, workers, schedules, and signals.',
cards: [
{
id: 'jobs-queues',
title: 'Jobs & Queues',
detail: 'Jobs, DLQ, worker fleet.',
metric: '1 blocking run',
impact: 'degraded',
route: OPERATIONS_PATHS.jobsQueues,
owner: 'Ops',
},
{
id: 'scheduler',
title: 'Scheduler',
detail: 'Schedules and coordination.',
metric: '19 active schedules',
impact: 'info',
route: OPERATIONS_PATHS.schedulerRuns,
owner: 'Ops',
},
{
id: 'signals',
title: 'Signals',
detail: 'Signal freshness and telemetry.',
metric: '97% fresh',
impact: 'info',
route: OPERATIONS_PATHS.signals,
owner: 'Ops',
},
],
},
{
id: 'health',
title: 'Health',
description: 'Diagnostics and system posture.',
cards: [
{
id: 'health-slo',
title: 'Health & SLO',
detail: 'Service health and burn-rate.',
metric: '2 degraded services',
impact: 'degraded',
route: OPERATIONS_PATHS.healthSlo,
owner: 'Ops',
},
{
id: 'doctor',
title: 'Diagnostics',
detail: 'Doctor checks and probes.',
metric: '11 checks',
impact: 'info',
route: OPERATIONS_PATHS.doctor,
owner: 'Ops',
},
{
id: 'status',
title: 'System Status',
detail: 'Heartbeat and availability.',
metric: '1 regional incident',
impact: 'degraded',
route: OPERATIONS_PATHS.status,
owner: 'Ops',
},
],
},
{
id: 'supply-airgap',
title: 'Supply And Airgap',
description: 'Feed sourcing, offline delivery, and packs.',
cards: [
{
id: 'feeds-airgap',
title: 'Feeds & Airgap',
detail: 'Mirrors and bundle flows.',
metric: '1 degraded mirror',
impact: 'blocking',
route: OPERATIONS_PATHS.feedsAirgap,
owner: 'Ops',
},
{
id: 'offline-kit',
title: 'Offline Kit',
detail: 'Import/export and transfers.',
metric: '3 queued exports',
impact: 'info',
route: OPERATIONS_PATHS.offlineKit,
owner: 'Ops',
},
{
id: 'packs',
title: 'Pack Registry',
detail: 'Distribution and integrity.',
metric: '14 active packs',
impact: 'info',
route: OPERATIONS_PATHS.packs,
owner: 'Ops',
},
],
},
{
id: 'capacity',
title: 'Capacity',
description: 'Capacity and quota management.',
cards: [
{
id: 'quotas',
title: 'Quotas & Limits',
detail: 'Quotas and burst-capacity.',
metric: '1 tenant near limit',
impact: 'degraded',
route: OPERATIONS_PATHS.quotas,
owner: 'Ops',
},
],
},
];
readonly pendingActions: readonly PendingAction[] = [
{
id: 'retry-feed-sync',
title: 'Recover stale feed source',
detail: 'Open the feeds freshness lens and review version-lock posture before retry.',
route: dataIntegrityPath('feeds-freshness'),
owner: 'Ops',
},
{
id: 'replay-dlq',
title: 'Drain replay backlog',
detail: 'Open DLQ and replay blockers before the next promotion window.',
route: dataIntegrityPath('dlq'),
owner: 'Ops',
},
{
id: 'review-agent-placement',
title: 'Review agent placement',
detail: 'Agent fleet issues route to Setup because topology ownership stays out of Operations.',
route: OPERATIONS_SETUP_PATHS.topologyAgents,
owner: 'Setup',
},
{
id: 'configure-advisory-sources',
title: 'Review advisory source config',
detail: 'Use Integrations for source configuration; Operations remains the monitoring shell.',
route: OPERATIONS_INTEGRATION_PATHS.advisorySources,
owner: 'Ops',
},
];
refreshSnapshot(): void {
this.refreshedAt.set(new Date().toISOString());
}
}

View File

@@ -188,8 +188,8 @@ export class PolicyDecisioningOverviewPageComponent {
readonly cards = computed<readonly DecisioningOverviewCard[]>(() => [
{
id: 'packs',
title: 'Packs Workspace',
description: 'Edit, approve, simulate, and explain policy packs from one routed workspace.',
title: 'Release Policies',
description: 'Author, test, and activate the security rules that gate your releases.',
route: ['/ops/policy/packs'],
accent: 'pack',
},

View File

@@ -22,25 +22,25 @@ import { PolicyPackStore } from '../policy-studio/services/policy-pack.store';
type PackSubview =
| 'workspace'
| 'rules' // merged: dashboard + edit + rules + yaml (YAML is a toggle inside rules)
| 'test' // renamed from simulate — clearer language
| 'activate' // renamed from approvals — the action, not the mechanism
| 'explain'
// Legacy subviews — kept for URL redirect resolution
| 'dashboard'
| 'edit'
| 'rules'
| 'yaml'
| 'approvals'
| 'simulate'
| 'explain';
| 'simulate';
const WORKSPACE_TABS: readonly StellaPageTab[] = [
{ id: 'workspace', label: 'Workspace', icon: 'M3 3h7v7H3z|||M14 3h7v7h-7z|||M14 14h7v7h-7z|||M3 14h7v7H3z' },
];
const PACK_DETAIL_TABS: readonly StellaPageTab[] = [
{ id: 'dashboard', label: 'Dashboard', icon: 'M3 3h7v7H3z|||M14 3h7v7h-7z|||M14 14h7v7h-7z|||M3 14h7v7H3z' },
{ id: 'edit', label: 'Edit', icon: 'M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7|||M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z' },
{ id: 'rules', label: 'Rules', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' },
{ id: 'yaml', label: 'YAML', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' },
{ id: 'approvals', label: 'Approvals', icon: 'M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' },
{ id: 'simulate', label: 'Simulate', icon: 'M5 3l14 9-14 9V3z' },
{ id: 'test', label: 'Test', icon: 'M5 3l14 9-14 9V3z' },
{ id: 'activate', label: 'Activate', icon: 'M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' },
];
@Component({
@@ -96,27 +96,34 @@ export class PolicyPackShellComponent {
readonly packTitle = computed(() => {
const id = this.packId();
if (!id) return 'Policy Pack Workspace';
// Try to find display name from the pack store cache
if (!id) return 'Release Policies';
const packs = this.packStore.currentPacks();
const match = packs?.find((p) => p.id === id);
return match?.name && match.name !== id ? match.name : `Pack ${id.replace(/^pack-/, '').slice(0, 12)}`;
return match?.name && match.name !== id ? match.name : `Policy ${id.replace(/^pack-/, '').slice(0, 12)}`;
});
protected readonly activeSubtitle = computed(() => {
if (!this.packId()) {
return 'Browse deterministic pack inventory and open a pack into authoring mode.';
return 'Author, test, and activate the security rules that gate your releases.';
}
switch (this.activeSubview()) {
case 'dashboard': return 'Overview of the selected policy pack.';
case 'edit': return 'Edit rules and configuration for this pack.';
case 'rules': return 'Manage individual rules within this pack.';
case 'yaml': return 'View and edit the raw YAML definition.';
case 'approvals': return 'Review and manage approval workflows.';
case 'simulate': return 'Run simulations against this pack.';
case 'explain': return 'Explain evaluation results for a simulation run.';
case 'workspace': return 'Browse deterministic pack inventory and open a pack into authoring mode.';
default: return 'Overview of the selected policy pack.';
case 'rules':
case 'dashboard':
case 'edit':
case 'yaml':
return 'Configure gates and rules for this policy. Toggle YAML mode for advanced editing.';
case 'test':
case 'simulate':
return 'Test this policy against real releases to see what would pass or fail.';
case 'activate':
case 'approvals':
return 'Activate this policy with a second reviewer\'s approval.';
case 'explain':
return 'Detailed explanation of evaluation results.';
case 'workspace':
return 'Author, test, and activate the security rules that gate your releases.';
default:
return 'Configure gates and rules for this policy.';
}
});
@@ -142,12 +149,15 @@ export class PolicyPackShellComponent {
return;
}
if (tabId === 'dashboard') {
void this.router.navigate(['/ops/policy/packs', packId]);
return;
}
// Map new tab IDs to route segments
const routeMap: Record<string, string> = {
rules: 'rules',
test: 'simulate', // route stays 'simulate' for now, tab says 'Test'
activate: 'approvals', // route stays 'approvals' for now, tab says 'Activate'
};
void this.router.navigate(['/ops/policy/packs', packId, tabId]);
const segment = routeMap[tabId] ?? tabId;
void this.router.navigate(['/ops/policy/packs', packId, segment]);
}
private readPackId(): string | null {
@@ -164,26 +174,22 @@ export class PolicyPackShellComponent {
return 'workspace';
}
if (url.endsWith('/edit') || url.endsWith('/editor')) {
return 'edit';
}
if (url.endsWith('/rules')) {
// Map URLs to the 3 canonical tabs (rules, test, activate)
if (url.endsWith('/edit') || url.endsWith('/editor') || url.endsWith('/yaml') || url.endsWith('/rules')) {
return 'rules';
}
if (url.endsWith('/yaml')) {
return 'yaml';
if (url.endsWith('/simulate')) {
return 'test';
}
if (url.endsWith('/approvals')) {
return 'approvals';
}
if (url.endsWith('/simulate')) {
return 'simulate';
return 'activate';
}
if (url.includes('/explain/')) {
return 'explain';
}
return 'dashboard';
// dashboard URL → rules tab (dashboard merged into rules)
return 'rules';
}
}

View File

@@ -125,7 +125,7 @@ export class PolicyGovernanceComponent implements OnInit {
protected readonly GOVERNANCE_TABS = GOVERNANCE_TABS;
readonly quickLinks: readonly StellaQuickLink[] = [
{ label: 'Policy Packs', route: '/ops/policy/packs', description: 'Author and manage policy pack rules' },
{ label: 'Release Policies', route: '/ops/policy/packs', description: 'Author and manage release policy rules' },
{ label: 'Simulation', route: '/ops/policy/simulation', description: 'Shadow mode and what-if analysis' },
{ label: 'VEX & Exceptions', route: '/ops/policy/vex', description: 'Vulnerability exceptions and waivers' },
{ label: 'Impact Preview', route: '/ops/policy/impact-preview', description: 'Preview policy change effects' },

View File

@@ -312,7 +312,7 @@ export class SimulationDashboardComponent implements OnInit {
readonly quickLinks: readonly StellaQuickLink[] = [
{ label: 'Governance', route: '/ops/policy/governance', description: 'Risk budgets and compliance profiles' },
{ label: 'Policy Packs', route: '/ops/policy/packs', description: 'Author and manage policy pack rules' },
{ label: 'Release Policies', route: '/ops/policy/packs', description: 'Author and manage release policy rules' },
{ label: 'VEX & Exceptions', route: '/ops/policy/vex', description: 'Vulnerability exceptions and waivers' },
{ label: 'Audit Events', route: '/evidence/audit-log', description: 'Cross-module audit trail' },
];

View File

@@ -0,0 +1,210 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260331_005_FE_release_policy_builder
import {
Component,
ChangeDetectionStrategy,
Input,
Output,
EventEmitter,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { GATE_CATALOG, getGateDisplayName, getGateDescription } from '../../../core/policy/gate-catalog';
export interface GateFormConfig {
type: string;
enabled: boolean;
config: Record<string, unknown>;
}
/**
* Inline form for a single gate type. Renders type-specific controls
* (sliders, toggles, number inputs) based on the gate type.
*
* Falls back to a generic key-value editor for unknown gate types.
*/
@Component({
selector: 'stella-gate-config-form',
standalone: true,
imports: [FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="gate-form">
<div class="gate-form__header">
<label class="gate-form__toggle">
<input
type="checkbox"
[ngModel]="gate.enabled"
(ngModelChange)="onToggle($event)"
/>
<span class="gate-form__name">{{ displayName }}</span>
</label>
<span class="gate-form__description">{{ description }}</span>
</div>
@if (gate.enabled) {
<div class="gate-form__body">
@switch (gate.type) {
@case ('CvssThresholdGate') {
<div class="gate-form__field">
<label>Block if CVSS score &ge;</label>
<div class="gate-form__inline">
<input
type="range" min="0" max="10" step="0.1"
[ngModel]="gate.config['threshold'] ?? 9.0"
(ngModelChange)="setConfig('threshold', $event)"
class="gate-form__slider"
/>
<span class="gate-form__value">{{ gate.config['threshold'] ?? 9.0 }}</span>
</div>
</div>
<div class="gate-form__field">
<label>Action</label>
<select [ngModel]="gate.config['action'] ?? 'block'" (ngModelChange)="setConfig('action', $event)" class="gate-form__select">
<option value="block">Block</option>
<option value="warn">Warn</option>
</select>
</div>
}
@case ('SignatureRequiredGate') {
<div class="gate-form__field">
<label>
<input type="checkbox" [ngModel]="gate.config['require_dsse'] ?? true" (ngModelChange)="setConfig('require_dsse', $event)" />
Require DSSE envelope signature
</label>
</div>
<div class="gate-form__field">
<label>
<input type="checkbox" [ngModel]="gate.config['require_rekor'] ?? false" (ngModelChange)="setConfig('require_rekor', $event)" />
Require Rekor transparency log entry
</label>
</div>
}
@case ('EvidenceFreshnessGate') {
<div class="gate-form__field">
<label>Maximum scan age (days)</label>
<input type="number" min="1" max="365"
[ngModel]="gate.config['max_age_days'] ?? 7"
(ngModelChange)="setConfig('max_age_days', $event)"
class="gate-form__number"
/>
</div>
<div class="gate-form__field">
<label>Action when stale</label>
<select [ngModel]="gate.config['action'] ?? 'warn'" (ngModelChange)="setConfig('action', $event)" class="gate-form__select">
<option value="block">Block</option>
<option value="warn">Warn</option>
</select>
</div>
}
@case ('SbomPresenceGate') {
<div class="gate-form__field">
<label>
<input type="checkbox" [ngModel]="gate.config['require_sbom'] ?? true" (ngModelChange)="setConfig('require_sbom', $event)" />
Block releases without an SBOM
</label>
</div>
}
@case ('MinimumConfidenceGate') {
<div class="gate-form__field">
<label>Minimum confidence (%)</label>
<div class="gate-form__inline">
<input
type="range" min="0" max="100" step="5"
[ngModel]="gate.config['min_confidence_pct'] ?? 80"
(ngModelChange)="setConfig('min_confidence_pct', $event)"
class="gate-form__slider"
/>
<span class="gate-form__value">{{ gate.config['min_confidence_pct'] ?? 80 }}%</span>
</div>
</div>
}
@case ('UnknownsBudgetGate') {
<div class="gate-form__field">
<label>Maximum unknowns fraction</label>
<div class="gate-form__inline">
<input
type="range" min="0" max="1" step="0.05"
[ngModel]="gate.config['max_fraction'] ?? 0.6"
(ngModelChange)="setConfig('max_fraction', $event)"
class="gate-form__slider"
/>
<span class="gate-form__value">{{ ((gate.config['max_fraction'] ?? 0.6) as number * 100).toFixed(0) }}%</span>
</div>
</div>
}
@case ('ReachabilityRequirementGate') {
<div class="gate-form__field">
<label>
<input type="checkbox" [ngModel]="gate.config['require_reachability'] ?? true" (ngModelChange)="setConfig('require_reachability', $event)" />
Require reachability analysis before blocking
</label>
</div>
<div class="gate-form__field">
<label>Minimum reachability confidence (%)</label>
<input type="number" min="0" max="100"
[ngModel]="gate.config['min_confidence_pct'] ?? 70"
(ngModelChange)="setConfig('min_confidence_pct', $event)"
class="gate-form__number"
/>
</div>
}
@default {
<div class="gate-form__generic">
<p class="gate-form__hint">Configure this gate via YAML mode for advanced options.</p>
</div>
}
}
</div>
}
</div>
`,
styles: [`
.gate-form { border: 1px solid var(--color-border-primary, #e2e8f0); border-radius: 8px; padding: 0.75rem 1rem; background: var(--color-surface-secondary, #f8fafc); }
.gate-form__header { display: flex; flex-direction: column; gap: 0.25rem; }
.gate-form__toggle { display: flex; align-items: center; gap: 0.5rem; font-weight: 500; cursor: pointer; }
.gate-form__toggle input[type="checkbox"] { width: 1rem; height: 1rem; cursor: pointer; }
.gate-form__name { font-size: 0.9375rem; color: var(--color-text-primary, #1e293b); }
.gate-form__description { font-size: 0.8125rem; color: var(--color-text-muted, #64748b); margin-left: 1.5rem; }
.gate-form__body { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--color-border-primary, #e2e8f0); display: flex; flex-direction: column; gap: 0.625rem; }
.gate-form__field { display: flex; flex-direction: column; gap: 0.25rem; }
.gate-form__field > label { font-size: 0.8125rem; color: var(--color-text-secondary, #475569); }
.gate-form__inline { display: flex; align-items: center; gap: 0.75rem; }
.gate-form__slider { flex: 1; height: 6px; cursor: pointer; }
.gate-form__value { font-size: 0.875rem; font-weight: 600; min-width: 3rem; text-align: right; color: var(--color-text-primary, #1e293b); }
.gate-form__number { width: 5rem; padding: 0.25rem 0.5rem; border: 1px solid var(--color-border-primary, #e2e8f0); border-radius: 4px; font-size: 0.875rem; }
.gate-form__select { padding: 0.25rem 0.5rem; border: 1px solid var(--color-border-primary, #e2e8f0); border-radius: 4px; font-size: 0.875rem; }
.gate-form__hint { font-size: 0.8125rem; color: var(--color-text-muted, #64748b); font-style: italic; }
.gate-form__generic { padding: 0.25rem 0; }
`],
})
export class GateConfigFormComponent {
@Input({ required: true }) gate!: GateFormConfig;
@Output() gateChange = new EventEmitter<GateFormConfig>();
get displayName(): string {
return getGateDisplayName(this.gate.type);
}
get description(): string {
return getGateDescription(this.gate.type);
}
onToggle(enabled: boolean): void {
this.gateChange.emit({ ...this.gate, enabled });
}
setConfig(key: string, value: unknown): void {
const config = { ...this.gate.config, [key]: value };
this.gateChange.emit({ ...this.gate, config });
}
}

View File

@@ -0,0 +1,377 @@
// SPDX-License-Identifier: BUSL-1.1
// Copyright (c) 2025 StellaOps
// Sprint: SPRINT_20260331_005_FE_release_policy_builder
import {
Component,
ChangeDetectionStrategy,
Input,
Output,
EventEmitter,
signal,
computed,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { GATE_CATALOG } from '../../../core/policy/gate-catalog';
import {
PolicyPackDocument,
PolicyGateDefinition,
PolicyGateTypes,
PolicyPackSettings,
} from '../../../core/api/policy-interop.models';
import { GateConfigFormComponent, GateFormConfig } from './gate-config-forms.component';
type BuilderStep = 'name' | 'gates' | 'review';
/** Default gate configs for newly enabled gates */
const DEFAULT_GATE_CONFIGS: Record<string, Record<string, unknown>> = {
CvssThresholdGate: { threshold: 9.0, action: 'block' },
SignatureRequiredGate: { require_dsse: true, require_rekor: false },
EvidenceFreshnessGate: { max_age_days: 7, action: 'warn' },
SbomPresenceGate: { require_sbom: true },
MinimumConfidenceGate: { min_confidence_pct: 80 },
UnknownsBudgetGate: { max_fraction: 0.6 },
ReachabilityRequirementGate: { require_reachability: true, min_confidence_pct: 70 },
};
/** Gate types shown in the builder, in display order */
const BUILDER_GATE_TYPES: readonly string[] = [
PolicyGateTypes.CvssThreshold,
PolicyGateTypes.SignatureRequired,
PolicyGateTypes.SbomPresence,
PolicyGateTypes.EvidenceFreshness,
PolicyGateTypes.ReachabilityRequirement,
PolicyGateTypes.UnknownsBudget,
PolicyGateTypes.MinimumConfidence,
];
@Component({
selector: 'stella-policy-builder',
standalone: true,
imports: [FormsModule, GateConfigFormComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="builder" [attr.data-testid]="'policy-builder'">
<!-- Step indicator -->
<nav class="builder__steps" aria-label="Builder steps">
@for (s of steps; track s.id) {
<button
type="button"
class="builder__step"
[class.builder__step--active]="step() === s.id"
[class.builder__step--done]="stepIndex(s.id) < stepIndex(step())"
(click)="step.set(s.id)"
>
<span class="builder__step-num">{{ $index + 1 }}</span>
<span class="builder__step-label">{{ s.label }}</span>
</button>
}
</nav>
<!-- YAML toggle -->
<div class="builder__mode-toggle">
<button
type="button"
class="builder__yaml-btn"
[class.builder__yaml-btn--active]="yamlMode()"
(click)="yamlMode.set(!yamlMode())"
[attr.data-testid]="'yaml-toggle'"
>
{{ yamlMode() ? 'Visual Editor' : 'View YAML' }}
</button>
</div>
@if (yamlMode()) {
<!-- YAML view -->
<div class="builder__yaml" data-testid="yaml-view">
<pre class="builder__yaml-content">{{ generatedYaml() }}</pre>
</div>
} @else {
<!-- Step 1: Name -->
@if (step() === 'name') {
<section class="builder__section" data-testid="step-name">
<h3 class="builder__section-title">Name your policy</h3>
<p class="builder__section-desc">Give it a name that describes where or how it's used.</p>
<div class="builder__form-group">
<label class="builder__label" for="policy-name">Policy name</label>
<input
id="policy-name"
type="text"
class="builder__input"
placeholder="e.g. production-strict"
[ngModel]="policyName()"
(ngModelChange)="policyName.set($event)"
/>
</div>
<div class="builder__form-group">
<label class="builder__label" for="policy-desc">Description (optional)</label>
<input
id="policy-desc"
type="text"
class="builder__input"
placeholder="e.g. Strict policy for production environments"
[ngModel]="policyDescription()"
(ngModelChange)="policyDescription.set($event)"
/>
</div>
<div class="builder__form-group">
<label class="builder__label">Default action</label>
<select class="builder__select" [ngModel]="defaultAction()" (ngModelChange)="defaultAction.set($event)">
<option value="block">Block (recommended)</option>
<option value="warn">Warn</option>
<option value="allow">Allow</option>
</select>
<span class="builder__hint">What happens when no rule matches. "Block" is safest.</span>
</div>
<div class="builder__actions">
<button type="button" class="btn btn--primary" (click)="step.set('gates')" [disabled]="!policyName()">
Next: Configure gates
</button>
</div>
</section>
}
<!-- Step 2: Gates -->
@if (step() === 'gates') {
<section class="builder__section" data-testid="step-gates">
<h3 class="builder__section-title">Select and configure gates</h3>
<p class="builder__section-desc">Toggle on the checks you want. Configure thresholds for each.</p>
<div class="builder__gate-list">
@for (gateForm of gateFormStates(); track gateForm.type) {
<stella-gate-config-form
[gate]="gateForm"
(gateChange)="updateGate($index, $event)"
/>
}
</div>
<div class="builder__actions">
<button type="button" class="btn btn--secondary" (click)="step.set('name')">Back</button>
<button type="button" class="btn btn--primary" (click)="step.set('review')" [disabled]="enabledGateCount() === 0">
Next: Review ({{ enabledGateCount() }} gates)
</button>
</div>
</section>
}
<!-- Step 3: Review -->
@if (step() === 'review') {
<section class="builder__section" data-testid="step-review">
<h3 class="builder__section-title">Review your policy</h3>
<div class="builder__review-card">
<h4 class="builder__review-name">{{ policyName() }}</h4>
@if (policyDescription()) {
<p class="builder__review-desc">{{ policyDescription() }}</p>
}
<p class="builder__review-default">Default action: <strong>{{ defaultAction() }}</strong></p>
</div>
<div class="builder__review-summary" data-testid="review-summary">
<h4>This policy will:</h4>
<ul class="builder__review-list">
@for (line of summaryLines(); track line) {
<li>{{ line }}</li>
}
</ul>
</div>
<div class="builder__actions">
<button type="button" class="btn btn--secondary" (click)="step.set('gates')">Back</button>
<button type="button" class="btn btn--primary" (click)="emitSave()">
Save policy
</button>
</div>
</section>
}
}
</div>
`,
styles: [`
.builder { display: flex; flex-direction: column; gap: 1.25rem; }
.builder__steps { display: flex; gap: 0.5rem; padding: 0; margin: 0; }
.builder__step { display: flex; align-items: center; gap: 0.375rem; padding: 0.375rem 0.75rem; border: 1px solid var(--color-border-primary, #e2e8f0); border-radius: 999px; background: var(--color-surface-primary, #fff); color: var(--color-text-muted, #64748b); font-size: 0.8125rem; cursor: pointer; transition: all 0.15s; }
.builder__step--active { background: var(--color-brand-primary, #3b82f6); color: #fff; border-color: var(--color-brand-primary, #3b82f6); }
.builder__step--done { background: var(--color-status-success-bg, #dcfce7); color: var(--color-status-success-text, #166534); border-color: var(--color-status-success-border, #86efac); }
.builder__step-num { font-weight: 600; }
.builder__mode-toggle { display: flex; justify-content: flex-end; }
.builder__yaml-btn { font-size: 0.8125rem; padding: 0.25rem 0.75rem; border: 1px solid var(--color-border-primary, #e2e8f0); border-radius: 4px; background: transparent; cursor: pointer; color: var(--color-text-secondary, #475569); }
.builder__yaml-btn--active { background: var(--color-surface-tertiary, #f1f5f9); }
.builder__yaml { background: var(--color-surface-tertiary, #1e293b); border-radius: 8px; padding: 1rem; overflow-x: auto; }
.builder__yaml-content { color: var(--color-text-primary, #e2e8f0); font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 0.8125rem; line-height: 1.6; margin: 0; white-space: pre; }
.builder__section { display: flex; flex-direction: column; gap: 1rem; }
.builder__section-title { font-size: 1.125rem; font-weight: 600; margin: 0; color: var(--color-text-primary, #1e293b); }
.builder__section-desc { font-size: 0.875rem; color: var(--color-text-muted, #64748b); margin: 0; }
.builder__form-group { display: flex; flex-direction: column; gap: 0.25rem; }
.builder__label { font-size: 0.8125rem; font-weight: 500; color: var(--color-text-secondary, #475569); }
.builder__input { padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-primary, #e2e8f0); border-radius: 6px; font-size: 0.875rem; }
.builder__select { padding: 0.5rem 0.75rem; border: 1px solid var(--color-border-primary, #e2e8f0); border-radius: 6px; font-size: 0.875rem; }
.builder__hint { font-size: 0.75rem; color: var(--color-text-muted, #94a3b8); }
.builder__gate-list { display: flex; flex-direction: column; gap: 0.75rem; }
.builder__actions { display: flex; gap: 0.5rem; justify-content: flex-end; padding-top: 0.5rem; }
.builder__review-card { border: 1px solid var(--color-border-primary, #e2e8f0); border-radius: 8px; padding: 1rem; background: var(--color-surface-secondary, #f8fafc); }
.builder__review-name { margin: 0 0 0.25rem 0; font-size: 1rem; }
.builder__review-desc { margin: 0 0 0.5rem 0; font-size: 0.875rem; color: var(--color-text-muted, #64748b); }
.builder__review-default { margin: 0; font-size: 0.875rem; }
.builder__review-summary { margin-top: 0.5rem; }
.builder__review-summary h4 { margin: 0 0 0.5rem 0; font-size: 0.9375rem; }
.builder__review-list { margin: 0; padding-left: 1.25rem; display: flex; flex-direction: column; gap: 0.375rem; }
.builder__review-list li { font-size: 0.875rem; line-height: 1.4; }
.btn { padding: 0.5rem 1rem; border: 1px solid transparent; border-radius: 6px; font-size: 0.875rem; cursor: pointer; font-weight: 500; }
.btn--primary { background: var(--color-brand-primary, #3b82f6); color: #fff; }
.btn--primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn--secondary { background: transparent; border-color: var(--color-border-primary, #e2e8f0); color: var(--color-text-secondary, #475569); }
`],
})
export class PolicyBuilderComponent {
@Input() document: PolicyPackDocument | null = null;
@Output() save = new EventEmitter<PolicyPackDocument>();
readonly steps = [
{ id: 'name' as BuilderStep, label: 'Name' },
{ id: 'gates' as BuilderStep, label: 'Gates' },
{ id: 'review' as BuilderStep, label: 'Review' },
];
readonly step = signal<BuilderStep>('name');
readonly yamlMode = signal(false);
readonly policyName = signal('');
readonly policyDescription = signal('');
readonly defaultAction = signal<'allow' | 'warn' | 'block'>('block');
readonly gateFormStates = signal<GateFormConfig[]>(
BUILDER_GATE_TYPES.map((type) => ({
type,
enabled: type === PolicyGateTypes.CvssThreshold ||
type === PolicyGateTypes.SignatureRequired ||
type === PolicyGateTypes.SbomPresence,
config: { ...(DEFAULT_GATE_CONFIGS[type] ?? {}) },
})),
);
readonly enabledGateCount = computed(
() => this.gateFormStates().filter((g) => g.enabled).length,
);
readonly summaryLines = computed(() => {
const lines: string[] = [];
for (const g of this.gateFormStates().filter((g) => g.enabled)) {
const entry = GATE_CATALOG[g.type];
if (!entry) continue;
switch (g.type) {
case 'CvssThresholdGate': {
const action = g.config['action'] === 'warn' ? 'Warn' : 'Block';
lines.push(`${action} releases with vulnerabilities scoring ${g.config['threshold'] ?? 9.0} or higher (CVSS)`);
break;
}
case 'SignatureRequiredGate':
lines.push('Require cryptographic image signature (DSSE)');
if (g.config['require_rekor']) lines.push('Require Rekor transparency log entry');
break;
case 'SbomPresenceGate':
lines.push('Block releases without an SBOM');
break;
case 'EvidenceFreshnessGate': {
const action = g.config['action'] === 'block' ? 'Block' : 'Warn';
lines.push(`${action} if scan results are older than ${g.config['max_age_days'] ?? 7} days`);
break;
}
case 'MinimumConfidenceGate':
lines.push(`Require at least ${g.config['min_confidence_pct'] ?? 80}% detection confidence`);
break;
case 'UnknownsBudgetGate': {
const pct = ((g.config['max_fraction'] as number ?? 0.6) * 100).toFixed(0);
lines.push(`Block if unknowns fraction exceeds ${pct}%`);
break;
}
case 'ReachabilityRequirementGate':
lines.push('Require reachability analysis before blocking on vulnerabilities');
break;
default:
lines.push(entry.description);
}
}
return lines;
});
readonly generatedYaml = computed(() => {
const doc = this.buildDocument();
const gates = doc.spec.gates
.map((g) => {
const configLines = Object.entries(g.config ?? {})
.map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`)
.join('\n');
return ` - id: ${g.id}\n type: ${g.type}\n enabled: ${g.enabled}\n config:\n${configLines}`;
})
.join('\n');
return [
`apiVersion: policy.stellaops.io/v2`,
`kind: PolicyPack`,
`metadata:`,
` name: ${doc.metadata.name}`,
doc.metadata.description ? ` description: ${doc.metadata.description}` : null,
`spec:`,
` settings:`,
` default_action: ${doc.spec.settings.default_action}`,
` deterministic_mode: true`,
` gates:`,
gates,
].filter(Boolean).join('\n');
});
stepIndex(s: BuilderStep): number {
return this.steps.findIndex((step) => step.id === s);
}
updateGate(index: number, updated: GateFormConfig): void {
const states = [...this.gateFormStates()];
states[index] = updated;
this.gateFormStates.set(states);
}
emitSave(): void {
this.save.emit(this.buildDocument());
}
private buildDocument(): PolicyPackDocument {
const enabledGates = this.gateFormStates().filter((g) => g.enabled);
return {
apiVersion: 'policy.stellaops.io/v2',
kind: 'PolicyPack',
metadata: {
name: this.policyName() || 'untitled',
version: '1',
description: this.policyDescription() || undefined,
},
spec: {
settings: {
default_action: this.defaultAction(),
deterministic_mode: true,
},
gates: enabledGates.map((g) => ({
id: g.type.replace(/Gate$/, '').replace(/([A-Z])/g, (_, c, i) => (i ? '-' : '') + c.toLowerCase()),
type: g.type,
enabled: true,
config: { ...g.config },
})),
},
};
}
}

View File

@@ -35,7 +35,7 @@ type SortOrder = 'asc' | 'desc';
const POLICY_STUDIO_TABS: readonly StellaPageTab[] = [
{ id: 'profiles', label: 'Risk Profiles', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' },
{ id: 'packs', label: 'Policy Packs', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' },
{ id: 'packs', label: 'Release Policies', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' },
{ id: 'simulation', label: 'Simulation', icon: 'M5 3l14 9-14 9V3z' },
{ id: 'decisions', label: 'Decisions', icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M22 4L12 14.01l-3-3' },
];
@@ -228,13 +228,13 @@ const POLICY_STUDIO_TABS: readonly StellaPageTab[] = [
</section>
}
<!-- Policy Packs View -->
<!-- Release Policies View -->
@if (viewMode() === 'packs') {
<section class="policy-studio__section">
<div class="policy-studio__section-header">
<h2>Policy Packs</h2>
<h2>Release Policies</h2>
<button type="button" class="btn btn--primary" (click)="openCreatePack()">
+ New Pack
+ New Policy
</button>
</div>

View File

@@ -40,6 +40,7 @@ import {
<th>Type</th>
<th>Agent</th>
<th>Health</th>
<th>Runtime</th>
<th>Last Check</th>
<th>Actions</th>
</tr>
@@ -70,6 +71,15 @@ import {
{{ target.healthStatus | titlecase }}
</span>
</td>
<td>
<span
class="runtime-badge"
[class]="'runtime-' + getRuntimeVerificationTone(target)"
[title]="getRuntimeVerificationTooltip(target)"
>
{{ getRuntimeVerificationLabel(target) }}
</span>
</td>
<td>{{ target.lastHealthCheck ? formatDate(target.lastHealthCheck) : 'Never' }}</td>
<td class="actions">
<button
@@ -230,6 +240,17 @@ import {
font-size: 1.25rem;
}
.runtime-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: var(--radius-sm);
font-size: 0.75rem;
}
.runtime-verified { background: var(--color-status-success-bg, #dcfce7); color: var(--color-status-success-text, #166534); }
.runtime-drift { background: var(--color-status-warning-bg, #fef3c7); color: var(--color-status-warning-text, #92400e); }
.runtime-offline { background: var(--color-status-error-bg, #fef2f2); color: var(--color-status-error-text, #991b1b); }
.runtime-unmonitored { background: var(--color-border-primary, #e2e8f0); color: var(--color-text-muted, #64748b); border: 1px dashed var(--color-border-primary, #cbd5e1); }
.status-badge, .health-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
@@ -347,6 +368,30 @@ export class TargetListComponent {
return getAgentStatusColor(status as any);
}
getRuntimeVerificationLabel(target: DeploymentTarget): string {
if (!target.agentId) return 'Not monitored';
if (target.healthStatus === 'healthy') return 'Verified';
if (target.healthStatus === 'degraded') return 'Drift';
if (target.healthStatus === 'unreachable') return 'Offline';
return 'Not monitored';
}
getRuntimeVerificationTone(target: DeploymentTarget): string {
if (!target.agentId) return 'unmonitored';
if (target.healthStatus === 'healthy') return 'verified';
if (target.healthStatus === 'degraded') return 'drift';
if (target.healthStatus === 'unreachable') return 'offline';
return 'unmonitored';
}
getRuntimeVerificationTooltip(target: DeploymentTarget): string {
if (!target.agentId) return 'No agent assigned — runtime verification unavailable';
if (target.healthStatus === 'healthy') return `Verified: containers match deployment map (last check: ${target.lastHealthCheck ? this.formatDate(target.lastHealthCheck) : 'unknown'})`;
if (target.healthStatus === 'degraded') return 'Drift detected: running containers differ from deployment map';
if (target.healthStatus === 'unreachable') return 'Probe offline: agent not responding';
return 'Runtime verification status unknown';
}
formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleString();
}

View File

@@ -51,6 +51,14 @@ interface InventoryRow {
critR: number;
}
interface RuntimeVerificationRow {
name: string;
deployedDigest: string;
runningDigest: string;
status: 'verified' | 'drift' | 'unexpected' | 'missing' | 'unmonitored';
statusLabel: string;
}
interface ReachabilityRow {
component: string;
digest: string;
@@ -231,9 +239,43 @@ interface AuditEventRow {
</tbody>
</table>
<!-- Runtime Verification -->
<details class="runtime-verification" [open]="hasRuntimeDrift()">
<summary class="runtime-verification__header">
Runtime Verification
<span class="runtime-verification__summary">
{{ verifiedContainerCount() }} verified, {{ driftContainerCount() }} drift, {{ unmonitoredContainerCount() }} unmonitored
</span>
</summary>
<table class="runtime-verification__table">
<thead>
<tr>
<th>Container</th>
<th>Deployed Digest</th>
<th>Running Digest</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (row of runtimeVerificationRows(); track row.name) {
<tr [class]="'rv-row rv-row--' + row.status">
<td>{{ row.name }}</td>
<td><code>{{ row.deployedDigest }}</code></td>
<td><code>{{ row.runningDigest }}</code></td>
<td>
<span class="rv-badge" [class]="'rv-badge--' + row.status">
{{ row.statusLabel }}
</span>
</td>
</tr>
}
</tbody>
</table>
</details>
<div class="footer-links">
<a routerLink="/releases/runs">Open last Promotion Run</a>
<a routerLink="/platform-ops/agents">Open agent logs</a>
<a routerLink="/setup/topology/agents">Open agent details</a>
</div>
</section>
}
@@ -662,6 +704,22 @@ interface AuditEventRow {
background: rgba(220, 38, 38, 0.08);
}
.runtime-verification { margin-top: 0.75rem; border: 1px solid var(--color-border-primary, #e2e8f0); border-radius: 8px; }
.runtime-verification__header { padding: 0.75rem 1rem; cursor: pointer; font-weight: 600; font-size: 0.875rem; display: flex; justify-content: space-between; align-items: center; }
.runtime-verification__summary { font-weight: 400; font-size: 0.8125rem; color: var(--color-text-muted, #64748b); }
.runtime-verification__table { width: 100%; border-collapse: collapse; }
.runtime-verification__table th,
.runtime-verification__table td { padding: 0.5rem 0.75rem; text-align: left; border-top: 1px solid var(--color-border-primary, #e2e8f0); font-size: 0.8125rem; }
.runtime-verification__table th { font-weight: 600; background: var(--color-surface-secondary, #f8fafc); }
.rv-row--drift, .rv-row--unexpected { background: rgba(234, 179, 8, 0.06); }
.rv-row--missing { background: rgba(220, 38, 38, 0.06); }
.rv-badge { display: inline-block; padding: 0.125rem 0.5rem; border-radius: 999px; font-size: 0.75rem; font-weight: 500; }
.rv-badge--verified { background: var(--color-status-success-bg, #dcfce7); color: var(--color-status-success-text, #166534); }
.rv-badge--drift { background: var(--color-status-warning-bg, #fef3c7); color: var(--color-status-warning-text, #92400e); }
.rv-badge--unexpected { background: var(--color-status-warning-bg, #fef3c7); color: var(--color-status-warning-text, #92400e); }
.rv-badge--missing { background: var(--color-status-error-bg, #fef2f2); color: var(--color-status-error-text, #991b1b); }
.rv-badge--unmonitored { background: var(--color-border-primary, #e2e8f0); color: var(--color-text-muted, #64748b); }
.footer-links {
margin-top: 0.7rem;
display: flex;
@@ -752,6 +810,19 @@ export class EnvironmentDetailComponent implements OnInit, OnDestroy {
{ name: 'event-router', status: 'Running', digest: 'sha256:evt789', replicas: '2/2' },
];
// Runtime Verification — sourced from inventory snapshot + eBPF probe observations
readonly runtimeVerificationRows = signal<RuntimeVerificationRow[]>([
{ name: 'api-gateway', deployedDigest: 'sha256:api123…', runningDigest: 'sha256:api123…', status: 'verified', statusLabel: 'Verified' },
{ name: 'payments-worker', deployedDigest: 'sha256:wrk456…', runningDigest: 'sha256:wrk999…', status: 'drift', statusLabel: 'Digest Mismatch' },
{ name: 'event-router', deployedDigest: 'sha256:evt789…', runningDigest: 'sha256:evt789…', status: 'verified', statusLabel: 'Verified' },
{ name: 'legacy-cron', deployedDigest: '(not in map)', runningDigest: 'sha256:lcr001…', status: 'unexpected', statusLabel: 'Unexpected' },
]);
readonly verifiedContainerCount = computed(() => this.runtimeVerificationRows().filter(r => r.status === 'verified').length);
readonly driftContainerCount = computed(() => this.runtimeVerificationRows().filter(r => r.status === 'drift' || r.status === 'unexpected' || r.status === 'missing').length);
readonly unmonitoredContainerCount = computed(() => this.runtimeVerificationRows().filter(r => r.status === 'unmonitored').length);
readonly hasRuntimeDrift = computed(() => this.driftContainerCount() > 0);
readonly inventoryRows: InventoryRow[] = [
{ component: 'api-gateway', version: '2.3.1', digest: 'sha256:api123', sbom: 'OK', critR: 1 },
{ component: 'payments-worker', version: '5.4.0', digest: 'sha256:wrk456', sbom: 'STALE', critR: 2 },

View File

@@ -1,39 +0,0 @@
import { SignalProvider, SignalStatus } from '../../../core/api/signals.models';
export type ProbeRuntime = 'ebpf' | 'etw' | 'dyld' | 'unknown';
export type ProbeHealthState = 'healthy' | 'degraded' | 'failed' | 'unknown';
export interface SignalsRuntimeMetricSnapshot {
signalsPerSecond: number;
errorRatePercent: number;
averageLatencyMs: number;
lastHourCount: number;
totalSignals: number;
}
export interface SignalsProviderSummary {
provider: SignalProvider;
total: number;
}
export interface SignalsStatusSummary {
status: SignalStatus;
total: number;
}
export interface HostProbeHealth {
host: string;
runtime: ProbeRuntime;
status: ProbeHealthState;
lastSeenAt: string;
sampleCount: number;
averageLatencyMs: number | null;
}
export interface SignalsRuntimeDashboardViewModel {
generatedAt: string;
metrics: SignalsRuntimeMetricSnapshot;
providerSummary: SignalsProviderSummary[];
statusSummary: SignalsStatusSummary[];
hostProbes: HostProbeHealth[];
}

View File

@@ -1,180 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { Observable, forkJoin, map } from 'rxjs';
import { GatewayMetricsService } from '../../../core/api/gateway-metrics.service';
import { Signal, SignalStats, SignalStatus } from '../../../core/api/signals.models';
import { SignalsClient } from '../../../core/api/signals.client';
import {
HostProbeHealth,
ProbeHealthState,
ProbeRuntime,
SignalsRuntimeDashboardViewModel,
} from '../models/signals-runtime-dashboard.models';
interface ProbeAccumulator {
host: string;
runtime: ProbeRuntime;
lastSeenAt: string;
samples: number;
healthyCount: number;
failedCount: number;
degradedCount: number;
latencyTotal: number;
latencySamples: number;
}
@Injectable({ providedIn: 'root' })
export class SignalsRuntimeDashboardService {
private readonly signalsClient = inject(SignalsClient);
private readonly gatewayMetrics = inject(GatewayMetricsService);
loadDashboard(): Observable<SignalsRuntimeDashboardViewModel> {
return forkJoin({
stats: this.signalsClient.getStats(),
list: this.signalsClient.list(undefined, 200),
}).pipe(
map(({ stats, list }) => this.toViewModel(stats, list.items))
);
}
private toViewModel(stats: SignalStats, signals: Signal[]): SignalsRuntimeDashboardViewModel {
const requestMetrics = this.gatewayMetrics.requestMetrics();
const successRate = this.normalizeSuccessRate(stats.successRate);
const fallbackErrorRate = (1 - successRate) * 100;
const gatewayErrorRate = requestMetrics.errorRate > 0 ? requestMetrics.errorRate * 100 : 0;
const gatewayLatency = requestMetrics.averageLatencyMs > 0 ? requestMetrics.averageLatencyMs : 0;
const providerSummary = Object.entries(stats.byProvider)
.map(([provider, total]) => ({ provider: provider as SignalsRuntimeDashboardViewModel['providerSummary'][number]['provider'], total }))
.sort((a, b) => b.total - a.total || a.provider.localeCompare(b.provider));
const statusSummary = Object.entries(stats.byStatus)
.map(([status, total]) => ({ status: status as SignalStatus, total }))
.sort((a, b) => b.total - a.total || a.status.localeCompare(b.status));
return {
generatedAt: new Date().toISOString(),
metrics: {
signalsPerSecond: Number((stats.lastHourCount / 3600).toFixed(2)),
errorRatePercent: Number((gatewayErrorRate > 0 ? gatewayErrorRate : fallbackErrorRate).toFixed(2)),
averageLatencyMs: Number((gatewayLatency > 0 ? gatewayLatency : stats.avgProcessingMs).toFixed(2)),
lastHourCount: stats.lastHourCount,
totalSignals: stats.total,
},
providerSummary,
statusSummary,
hostProbes: this.extractHostProbes(signals),
};
}
private extractHostProbes(signals: Signal[]): HostProbeHealth[] {
const byHostProbe = new Map<string, ProbeAccumulator>();
for (const signal of signals) {
const payload = signal.payload ?? {};
const host = this.readString(payload, ['host', 'hostname', 'node']) ?? `unknown-${signal.provider}`;
const runtime = this.resolveRuntime(payload);
const state = this.resolveState(signal.status, payload);
const latencyMs = this.readNumber(payload, ['latencyMs', 'processingLatencyMs', 'probeLatencyMs']);
const key = `${host}|${runtime}`;
const existing = byHostProbe.get(key) ?? {
host,
runtime,
lastSeenAt: signal.processedAt ?? signal.receivedAt,
samples: 0,
healthyCount: 0,
failedCount: 0,
degradedCount: 0,
latencyTotal: 0,
latencySamples: 0,
};
existing.samples += 1;
if (state === 'healthy') existing.healthyCount += 1;
else if (state === 'failed') existing.failedCount += 1;
else if (state === 'degraded') existing.degradedCount += 1;
const seen = signal.processedAt ?? signal.receivedAt;
if (seen > existing.lastSeenAt) {
existing.lastSeenAt = seen;
}
if (typeof latencyMs === 'number' && Number.isFinite(latencyMs) && latencyMs >= 0) {
existing.latencyTotal += latencyMs;
existing.latencySamples += 1;
}
byHostProbe.set(key, existing);
}
return Array.from(byHostProbe.values())
.map((entry) => ({
host: entry.host,
runtime: entry.runtime,
status: this.rankProbeState(entry),
lastSeenAt: entry.lastSeenAt,
sampleCount: entry.samples,
averageLatencyMs: entry.latencySamples > 0
? Number((entry.latencyTotal / entry.latencySamples).toFixed(2))
: null,
}))
.sort((a, b) => a.host.localeCompare(b.host) || a.runtime.localeCompare(b.runtime));
}
private normalizeSuccessRate(value: number): number {
if (value <= 0) return 0;
if (value >= 100) return 1;
if (value > 1) return value / 100;
return value;
}
private resolveRuntime(payload: Record<string, unknown>): ProbeRuntime {
const raw = (this.readString(payload, ['probeRuntime', 'probeType', 'runtime']) ?? 'unknown').toLowerCase();
if (raw.includes('ebpf')) return 'ebpf';
if (raw.includes('etw')) return 'etw';
if (raw.includes('dyld')) return 'dyld';
return 'unknown';
}
private resolveState(status: SignalStatus, payload: Record<string, unknown>): ProbeHealthState {
const probeState = (this.readString(payload, ['probeStatus', 'health']) ?? '').toLowerCase();
if (probeState === 'healthy' || probeState === 'ok') return 'healthy';
if (probeState === 'degraded' || probeState === 'warning') return 'degraded';
if (probeState === 'failed' || probeState === 'error') return 'failed';
if (status === 'failed') return 'failed';
if (status === 'processing' || status === 'received') return 'degraded';
if (status === 'completed') return 'healthy';
return 'unknown';
}
private rankProbeState(entry: ProbeAccumulator): ProbeHealthState {
if (entry.failedCount > 0) return 'failed';
if (entry.degradedCount > 0) return 'degraded';
if (entry.healthyCount > 0) return 'healthy';
return 'unknown';
}
private readString(source: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = source[key];
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
}
return null;
}
private readNumber(source: Record<string, unknown>, keys: string[]): number | null {
for (const key of keys) {
const value = source[key];
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim().length > 0) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
}
return null;
}
}

View File

@@ -1,329 +0,0 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HostProbeHealth, ProbeHealthState, SignalsRuntimeDashboardViewModel } from './models/signals-runtime-dashboard.models';
import { SignalsRuntimeDashboardService } from './services/signals-runtime-dashboard.service';
import { MetricCardComponent } from '../../shared/ui/metric-card/metric-card.component';
@Component({
selector: 'app-signals-runtime-dashboard',
standalone: true,
imports: [CommonModule, MetricCardComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="signals-page">
<header class="signals-header">
<div>
<h1>Signals Runtime Dashboard</h1>
<p>Per-host probe health and signal ingestion runtime metrics.</p>
</div>
<button type="button" class="refresh-btn" (click)="refresh()" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
</header>
@if (error()) {
<div class="error-banner" role="alert">{{ error() }}</div>
}
@if (vm(); as dashboard) {
<section class="metrics-grid" aria-label="Signal runtime metrics">
<app-metric-card
label="Signals / sec"
[value]="(dashboard.metrics.signalsPerSecond | number:'1.0-2') ?? '--'"
deltaDirection="up-is-good"
[subtitle]="'Last hour events: ' + dashboard.metrics.lastHourCount"
/>
<app-metric-card
label="Error rate"
[value]="(dashboard.metrics.errorRatePercent | number:'1.0-2') ?? '--'"
unit="%"
deltaDirection="up-is-bad"
[subtitle]="'Total signals: ' + dashboard.metrics.totalSignals"
/>
<app-metric-card
label="Avg latency"
[value]="(dashboard.metrics.averageLatencyMs | number:'1.0-0') ?? '--'"
unit="ms"
deltaDirection="up-is-bad"
subtitle="Gateway-backed when available"
/>
</section>
<section class="summary-grid">
<article class="summary-card">
<h2>By provider</h2>
<ul>
@for (item of dashboard.providerSummary; track item.provider) {
<li>
<span>{{ item.provider }}</span>
<strong>{{ item.total }}</strong>
</li>
}
</ul>
</article>
<article class="summary-card">
<h2>By status</h2>
<ul>
@for (item of dashboard.statusSummary; track item.status) {
<li>
<span>{{ item.status }}</span>
<strong>{{ item.total }}</strong>
</li>
}
</ul>
</article>
</section>
<section class="probes-card">
<header>
<h2>Probe health by host</h2>
<small>Snapshot generated {{ dashboard.generatedAt | date:'medium' }}</small>
</header>
@if (dashboard.hostProbes.length === 0) {
<p class="empty-state">No probe telemetry available in the current signal window.</p>
} @else {
<table class="stella-table stella-table--striped stella-table--hoverable">
<thead>
<tr>
<th>Host</th>
<th>Runtime</th>
<th>Status</th>
<th>Latency</th>
<th>Samples</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
@for (probe of dashboard.hostProbes; track probe.host + '-' + probe.runtime) {
<tr>
<td>{{ probe.host }}</td>
<td>{{ probe.runtime }}</td>
<td>
<span class="badge" [class]="probeStateClass(probe)">{{ probe.status }}</span>
</td>
<td>{{ formatLatency(probe) }}</td>
<td>{{ probe.sampleCount }}</td>
<td>{{ probe.lastSeenAt | date:'short' }}</td>
</tr>
}
</tbody>
</table>
}
</section>
}
</section>
`,
styles: [`
:host {
display: block;
padding: 1.5rem;
background: var(--color-surface-primary);
min-height: 100vh;
color: var(--color-text-heading);
}
.signals-page {
max-width: 1200px;
margin: 0 auto;
display: grid;
gap: 1rem;
}
.signals-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.signals-header h1 {
margin: 0;
font-size: 1.6rem;
font-weight: var(--font-weight-bold);
line-height: 1.2;
}
.signals-header p {
margin: 0.35rem 0 0;
color: var(--color-text-secondary);
}
.refresh-btn {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
color: var(--color-text-heading);
padding: 0.55rem 1rem;
cursor: pointer;
font-weight: var(--font-weight-semibold);
}
.refresh-btn[disabled] {
opacity: 0.65;
cursor: not-allowed;
}
.error-banner {
border-radius: var(--radius-lg);
border: 1px solid var(--color-status-error-border);
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
padding: 0.75rem;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.75rem;
}
/* metric-card styling handled by the canonical MetricCardComponent */
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 0.75rem;
}
.summary-card {
border-radius: var(--radius-xl);
border: 1px solid var(--color-surface-secondary);
background: var(--color-surface-primary);
padding: 0.9rem;
}
.summary-card h2 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.summary-card ul {
list-style: none;
margin: 0;
padding: 0;
}
.summary-card li {
display: flex;
justify-content: space-between;
border-top: 1px solid var(--color-surface-secondary);
padding: 0.45rem 0;
font-size: 0.92rem;
}
.summary-card li:first-child {
border-top: 0;
}
.probes-card {
border-radius: var(--radius-xl);
border: 1px solid var(--color-surface-secondary);
background: var(--color-surface-primary);
padding: 0.9rem;
overflow-x: auto;
}
.probes-card header {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: baseline;
margin-bottom: 0.7rem;
}
.probes-card header h2 {
margin: 0;
font-size: 1rem;
}
.probes-card header small {
color: var(--color-text-secondary);
}
/* Table styling provided by global .stella-table class */
table {
min-width: 720px;
}
.badge {
display: inline-flex;
border-radius: var(--radius-full);
padding: 0.15rem 0.55rem;
font-size: 0.78rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.03em;
border: 1px solid transparent;
}
.badge--healthy {
background: var(--color-status-success-bg);
border-color: var(--color-status-success-border);
color: var(--color-status-success-text);
}
.badge--degraded {
background: var(--color-status-warning-bg);
border-color: var(--color-status-warning-border);
color: var(--color-status-warning-text);
}
.badge--failed {
background: var(--color-status-error-bg);
border-color: var(--color-status-error-border);
color: var(--color-status-error-text);
}
.badge--unknown {
background: var(--color-border-primary);
border-color: var(--color-border-primary);
color: var(--color-text-primary);
}
.empty-state {
margin: 0;
color: var(--color-text-secondary);
font-style: italic;
}
`],
})
export class SignalsRuntimeDashboardComponent {
private readonly dashboardService = inject(SignalsRuntimeDashboardService);
readonly vm = signal<SignalsRuntimeDashboardViewModel | null>(null);
readonly loading = signal(false);
readonly error = signal<string | null>(null);
readonly hasProbes = computed(() => (this.vm()?.hostProbes.length ?? 0) > 0);
constructor() {
this.refresh();
}
refresh(): void {
this.loading.set(true);
this.error.set(null);
this.dashboardService.loadDashboard().subscribe({
next: (vm) => {
this.vm.set(vm);
this.loading.set(false);
},
error: () => {
this.error.set('Signals runtime data is currently unavailable.');
this.loading.set(false);
},
});
}
probeStateClass(probe: HostProbeHealth): string {
return `badge--${probe.status}`;
}
formatLatency(probe: HostProbeHealth): string {
if (probe.averageLatencyMs == null) return 'n/a';
return `${Math.round(probe.averageLatencyMs)} ms`;
}
}

View File

@@ -1,9 +0,0 @@
import { Routes } from '@angular/router';
export const SIGNALS_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./signals-runtime-dashboard.component').then((m) => m.SignalsRuntimeDashboardComponent),
},
];

View File

@@ -7,6 +7,7 @@ import {
inject,
signal,
computed,
effect,
DestroyRef,
AfterViewInit,
ViewChild,
@@ -30,6 +31,7 @@ import type {
NavGroup as CanonicalNavGroup,
NavItem as CanonicalNavItem,
} from '../../core/navigation/navigation.types';
import { StellaPreferencesService } from '../../shared/components/stella-helper/stella-preferences.service';
/**
* Navigation structure for the shell.
@@ -44,6 +46,7 @@ export interface NavSection {
menuGroupLabel?: string;
tooltip?: string;
badgeTooltip?: string;
recommendationLabel?: string;
badge$?: () => number | null;
sparklineData$?: () => number[];
children?: NavItem[];
@@ -63,6 +66,12 @@ interface NavSectionGroup {
sections: DisplayNavSection[];
}
interface RecommendedNavStep {
route: string;
pageKey: string;
menuGroupId: string;
}
const LOCAL_TO_CANONICAL_GROUP_ID: Readonly<Record<string, string>> = {
home: 'home',
'release-control': 'release-control',
@@ -78,6 +87,13 @@ const SIDEBAR_FALLBACK_TOOLTIPS: Readonly<Record<string, string>> = {
'/setup/preferences': 'Personal defaults for helper behavior, theme, and working context',
};
const RECOMMENDED_FIRST_VISIT_PATH: readonly RecommendedNavStep[] = [
{ route: '/ops/operations/doctor', pageKey: 'diagnostics', menuGroupId: 'operations' },
{ route: '/setup/integrations', pageKey: 'integrations', menuGroupId: 'setup-admin' },
{ route: '/security/scan', pageKey: 'scan-image', menuGroupId: 'security' },
{ route: '/', pageKey: 'dashboard', menuGroupId: 'home' },
];
/**
* AppSidebarComponent - Permanent dark left navigation rail.
*
@@ -154,6 +170,9 @@ const SIDEBAR_FALLBACK_TOOLTIPS: Readonly<Record<string, string>> = {
</svg>
</button>
}
@if (!effectiveCollapsed && group.description) {
<p class="sb-group__description">{{ group.description }}</p>
}
<div class="sb-group__body" [id]="'nav-grp-' + group.id">
<div class="sb-group__body-inner">
@for (section of group.sections; track section.id; let sectionFirst = $first) {
@@ -168,6 +187,9 @@ const SIDEBAR_FALLBACK_TOOLTIPS: Readonly<Record<string, string>> = {
[icon]="section.icon"
[route]="section.route"
[badge]="section.sectionBadge"
[tooltip]="section.tooltip"
[badgeTooltip]="section.badgeTooltip"
[recommendationLabel]="section.recommendationLabel ?? null"
[collapsed]="effectiveCollapsed"
></app-sidebar-nav-item>
<div class="sb-section__body" [id]="'nav-sec-' + section.id">
@@ -178,6 +200,9 @@ const SIDEBAR_FALLBACK_TOOLTIPS: Readonly<Record<string, string>> = {
[icon]="child.icon"
[route]="child.route"
[badge]="child.badge ?? null"
[tooltip]="child.tooltip"
[badgeTooltip]="child.badgeTooltip"
[recommendationLabel]="child.recommendationLabel ?? null"
[isChild]="true"
[collapsed]="effectiveCollapsed"
></app-sidebar-nav-item>
@@ -192,6 +217,9 @@ const SIDEBAR_FALLBACK_TOOLTIPS: Readonly<Record<string, string>> = {
[icon]="section.icon"
[route]="section.route"
[badge]="section.sectionBadge"
[tooltip]="section.tooltip"
[badgeTooltip]="section.badgeTooltip"
[recommendationLabel]="section.recommendationLabel ?? null"
[collapsed]="effectiveCollapsed"
></app-sidebar-nav-item>
}
@@ -506,6 +534,14 @@ const SIDEBAR_FALLBACK_TOOLTIPS: Readonly<Record<string, string>> = {
text-overflow: ellipsis;
}
.sb-group__description {
margin: 0 0 0.45rem 0.75rem;
color: var(--color-sidebar-text-muted);
font-size: 0.7rem;
line-height: 1.45;
max-width: 24ch;
}
/* ---- Group chevron ---- */
.sb-group__chevron {
flex-shrink: 0;
@@ -606,6 +642,7 @@ export class AppSidebarComponent implements AfterViewInit {
private readonly http = inject(HttpClient);
private readonly doctorTrendService = inject(DoctorTrendService);
private readonly ngZone = inject(NgZone);
private readonly stellaPrefs = inject(StellaPreferencesService);
readonly sidebarPrefs = inject(SidebarPreferenceService);
@@ -647,6 +684,10 @@ export class AppSidebarComponent implements AfterViewInit {
private readonly failedRunsCount = signal(0);
private readonly pendingApprovalsBadgeLoadedAt = signal<number | null>(null);
private readonly pendingApprovalsBadgeLoading = signal(false);
private readonly canonicalItemsByRoute = this.buildCanonicalItemMap();
private readonly canonicalGroupsById = new Map<string, CanonicalNavGroup>(
NAVIGATION_GROUPS.map((group) => [group.id, group]),
);
/**
* Navigation sections - canonical 6-group IA.
@@ -933,12 +974,42 @@ export class AppSidebarComponent implements AfterViewInit {
.filter((section): section is NavSection => section !== null);
});
readonly nextRecommendedStep = computed(() => {
const seenPages = new Set(this.stellaPrefs.prefs().seenPages);
const visibleRoutes = new Set(this.visibleSections().map((section) => section.route));
const nextStep = RECOMMENDED_FIRST_VISIT_PATH.find((step) => (
visibleRoutes.has(step.route) && !seenPages.has(step.pageKey)
));
if (!nextStep) {
return null;
}
const previousVisibleStepSeen = RECOMMENDED_FIRST_VISIT_PATH
.filter((step) => visibleRoutes.has(step.route))
.slice(0, RECOMMENDED_FIRST_VISIT_PATH.indexOf(nextStep))
.some((step) => seenPages.has(step.pageKey));
return {
...nextStep,
label: previousVisibleStepSeen ? 'Recommended next' : 'Start here',
};
});
/** Sections with duplicate children removed and badges resolved */
readonly displaySections = computed<DisplayNavSection[]>(() => {
const nextRecommendedStep = this.nextRecommendedStep();
return this.visibleSections().map((section) => ({
...section,
tooltip: this.resolveTooltip(section.route) ?? section.tooltip,
badgeTooltip: this.resolveBadgeTooltip(section),
recommendationLabel: nextRecommendedStep?.route === section.route ? nextRecommendedStep.label : undefined,
sectionBadge: section.badge$?.() ?? null,
displayChildren: (section.children ?? []).filter((child) => child.route !== section.route),
displayChildren: (section.children ?? [])
.filter((child) => child.route !== section.route)
.map((child) => this.withDisplayChildState(child)),
}));
});
@@ -951,6 +1022,7 @@ export class AppSidebarComponent implements AfterViewInit {
orderedGroups.set(groupId, {
id: groupId,
label: this.resolveMenuGroupLabel(groupId),
description: this.resolveMenuGroupDescription(groupId),
sections: [],
});
}
@@ -960,6 +1032,7 @@ export class AppSidebarComponent implements AfterViewInit {
const group = orderedGroups.get(groupId) ?? {
id: groupId,
label: section.menuGroupLabel ?? this.resolveMenuGroupLabel(groupId),
description: this.resolveMenuGroupDescription(groupId),
sections: [],
};
@@ -975,6 +1048,13 @@ export class AppSidebarComponent implements AfterViewInit {
this.loadActionBadges();
this.destroyRef.onDestroy(() => this.clearFlyoutTimers());
effect(() => {
const nextRecommendedStep = this.nextRecommendedStep();
if (nextRecommendedStep) {
this.sidebarPrefs.expandGroup(nextRecommendedStep.menuGroupId);
}
}, { allowSignalWrites: true });
// Auto-expand the sidebar group matching the current URL on load
this.expandGroupForUrl(this.router.url);
@@ -1048,7 +1128,7 @@ export class AppSidebarComponent implements AfterViewInit {
.map((child) => this.filterItem(child))
.filter((child): child is NavItem => child !== null);
const children = visibleChildren.map((child) => this.withDynamicChildState(child));
const children = visibleChildren.map((child) => this.withDisplayChildState(child));
return {
...section,
@@ -1075,10 +1155,29 @@ export class AppSidebarComponent implements AfterViewInit {
}
}
private resolveMenuGroupDescription(groupId: string): string | undefined {
const canonicalGroupId = LOCAL_TO_CANONICAL_GROUP_ID[groupId];
if (!canonicalGroupId) {
return undefined;
}
return this.canonicalGroupsById.get(canonicalGroupId)?.description;
}
groupRoute(group: NavSectionGroup): string {
return group.sections[0]?.route ?? '/';
}
private withDisplayChildState(item: NavItem): NavItem {
const nextRecommendedStep = this.nextRecommendedStep();
return this.withDynamicChildState({
...item,
tooltip: this.resolveTooltip(item.route) ?? item.tooltip,
recommendationLabel: nextRecommendedStep?.route === item.route ? nextRecommendedStep.label : undefined,
});
}
private withDynamicChildState(item: NavItem): NavItem {
if (item.id !== 'rel-approvals') {
return item;
@@ -1090,6 +1189,23 @@ export class AppSidebarComponent implements AfterViewInit {
};
}
private resolveTooltip(route: string): string | undefined {
return this.canonicalItemsByRoute.get(route)?.tooltip ?? SIDEBAR_FALLBACK_TOOLTIPS[route];
}
private resolveBadgeTooltip(section: NavSection): string | undefined {
switch (section.id) {
case 'deployments':
return 'Combined count of failed deployment runs and pending approvals that still need operator attention';
case 'releases':
return 'Releases whose gate status is currently blocked';
case 'vulnerabilities':
return 'Critical findings waiting for triage';
default:
return undefined;
}
}
private filterItem(item: NavItem): NavItem | null {
if (item.requiredScopes && !this.hasAllScopes(item.requiredScopes)) {
return null;
@@ -1110,6 +1226,27 @@ export class AppSidebarComponent implements AfterViewInit {
return this.authService.hasAnyScope(scopes);
}
private buildCanonicalItemMap(): Map<string, CanonicalNavItem> {
const itemsByRoute = new Map<string, CanonicalNavItem>();
const collect = (items: readonly CanonicalNavItem[]): void => {
for (const item of items) {
if (item.route) {
itemsByRoute.set(item.route, item);
}
if (item.children) {
collect(item.children);
}
}
};
for (const group of NAVIGATION_GROUPS) {
collect(group.items);
}
return itemsByRoute;
}
/** Flyout: expand sidebar as translucent overlay when hovering the collapsed rail */
onSidebarMouseEnter(): void {
if (!this._collapsed || window.innerWidth <= 991) return;

View File

@@ -230,8 +230,8 @@ export const ADMINISTRATION_ROUTES: Routes = [
},
{
path: 'policy/packs',
title: 'Policy Packs',
data: { breadcrumb: 'Policy Packs' },
title: 'Release Policies',
data: { breadcrumb: 'Release Policies' },
redirectTo: redirectToDecisioning('/ops/policy/packs'),
pathMatch: 'full',
},

View File

@@ -5,12 +5,7 @@ export const OPERATIONS_ROUTES: Routes = [
{
path: '',
pathMatch: 'full',
title: 'Operations',
data: { breadcrumb: '' },
loadComponent: () =>
import('../features/platform/ops/platform-ops-overview-page.component').then(
(m) => m.PlatformOpsOverviewPageComponent,
),
redirectTo: 'jobs-queues',
},
{
path: 'jobs-queues',
@@ -159,17 +154,10 @@ export const OPERATIONS_ROUTES: Routes = [
loadChildren: () =>
import('../features/doctor/doctor.routes').then((m) => m.DOCTOR_ROUTES),
},
{
path: 'signals',
title: 'Signals',
data: { breadcrumb: 'Signals' },
loadChildren: () =>
import('../features/signals/signals.routes').then((m) => m.SIGNALS_ROUTES),
},
{
path: 'packs',
title: 'Pack Registry',
data: { breadcrumb: 'Pack Registry' },
title: 'Automation Catalog',
data: { breadcrumb: 'Automation Catalog' },
loadChildren: () =>
import('../features/pack-registry/pack-registry.routes').then((m) => m.PACK_REGISTRY_ROUTES),
},
@@ -244,8 +232,8 @@ export const OPERATIONS_ROUTES: Routes = [
},
{
path: 'agents',
title: 'Agent Fleet',
data: { breadcrumb: 'Agent Fleet' },
title: 'Agents',
data: { breadcrumb: 'Agents' },
loadComponent: () =>
import('../features/topology/topology-agents-page.component').then(
(m) => m.TopologyAgentsPageComponent,

View File

@@ -173,7 +173,7 @@ export const OPS_ROUTES: Routes = [
},
{
path: 'signals',
redirectTo: 'operations/signals',
redirectTo: 'operations/doctor',
pathMatch: 'full',
},
{

View File

@@ -13,7 +13,7 @@ interface LegacyPlatformOpsRedirect {
}
const LEGACY_PLATFORM_OPS_REDIRECTS: readonly LegacyPlatformOpsRedirect[] = [
{ path: '', redirectTo: OPERATIONS_PATHS.overview },
{ path: '', redirectTo: OPERATIONS_PATHS.jobsQueues },
{ path: 'data-integrity', redirectTo: OPERATIONS_PATHS.dataIntegrity },
{
path: 'data-integrity/nightly-ops/:runId',
@@ -56,7 +56,7 @@ const LEGACY_PLATFORM_OPS_REDIRECTS: readonly LegacyPlatformOpsRedirect[] = [
{ path: 'quotas/:page', redirectTo: `${OPERATIONS_PATHS.quotas}/:page` },
{ path: 'aoc', redirectTo: OPERATIONS_PATHS.aoc },
{ path: 'aoc/:page', redirectTo: `${OPERATIONS_PATHS.aoc}/:page` },
{ path: 'signals', redirectTo: OPERATIONS_PATHS.signals },
{ path: 'signals', redirectTo: OPERATIONS_PATHS.doctor },
{ path: 'packs', redirectTo: OPERATIONS_PATHS.packs },
{ path: 'notifications', redirectTo: OPERATIONS_PATHS.notifications },
{ path: 'status', redirectTo: OPERATIONS_PATHS.status },
@@ -111,7 +111,7 @@ export const PLATFORM_OPS_ROUTES: Routes = [
})),
{
path: '**',
redirectTo: OPERATIONS_PATHS.overview,
redirectTo: OPERATIONS_PATHS.jobsQueues,
pathMatch: 'full',
},
];

View File

@@ -1,6 +1,9 @@
import {
Component,
ChangeDetectionStrategy,
ViewChild,
ElementRef,
HostListener,
signal,
computed,
effect,
@@ -14,6 +17,7 @@ import { filter } from 'rxjs/operators';
import { Subscription } from 'rxjs';
import { StellaAssistantService } from './stella-assistant.service';
import { StellaHelperContextService } from './stella-helper-context.service';
import { StellaPreferencesService } from './stella-preferences.service';
import { SearchAssistantDrawerService } from '../../../core/services/search-assistant-drawer.service';
import {
StellaHelperTip,
@@ -62,7 +66,7 @@ const DEFAULTS: HelperPreferences = {
imports: [SlicePipe],
template: `
<!-- Toggle button when fully dismissed -->
@if (prefs().dismissed && !forceShow()) {
@if (stellaPrefs.isDismissed() && !forceShow()) {
<button
class="helper-restore"
(click)="onRestore()"
@@ -81,7 +85,7 @@ const DEFAULTS: HelperPreferences = {
}
<!-- Main helper widget (always visible, bubble hides when chat is open) -->
@if (!prefs().dismissed || forceShow()) {
@if (!stellaPrefs.isDismissed() || forceShow()) {
<div
class="helper"
[class.helper--bubble-open]="bubbleOpen()"
@@ -101,17 +105,38 @@ const DEFAULTS: HelperPreferences = {
<!-- Speech bubble -->
@if (bubbleOpen() && !assistantDrawer.isOpen()) {
<div class="helper__bubble" role="status" aria-live="polite">
<button
class="helper__bubble-close"
(click)="onDismiss()"
title="Don't show again"
aria-label="Don't show again"
>
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<!-- Mute dropdown (replaces close button) -->
<div class="helper__mute-wrap">
<button
class="helper__bubble-close"
(click)="muteDropdownOpen.set(!muteDropdownOpen()); $event.stopPropagation()"
title="Mute options"
aria-label="Mute options"
[attr.aria-expanded]="muteDropdownOpen()"
>
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
@if (muteDropdownOpen()) {
<div class="helper__mute-menu" role="menu">
<button class="helper__mute-opt" role="menuitem" (click)="onMuteThisTip()">
<span class="helper__mute-label">Mute this tip</span>
<span class="helper__mute-desc">Hide this tip; show the next one</span>
</button>
<button class="helper__mute-opt" role="menuitem" (click)="onMuteThisPage()">
<span class="helper__mute-label">Mute this page</span>
<span class="helper__mute-desc">No auto-tips on this page</span>
</button>
<div class="helper__mute-sep"></div>
<button class="helper__mute-opt helper__mute-opt--danger" role="menuitem" (click)="onMuteAll()">
<span class="helper__mute-label">Mute all tooltips</span>
<span class="helper__mute-desc">Disable all auto-tips globally</span>
</button>
</div>
}
</div>
<div class="helper__bubble-content helper__crossfade-wrap">
<!-- TIPS panel -->
@@ -144,6 +169,15 @@ const DEFAULTS: HelperPreferences = {
</button>
</div>
}
<!-- Resume conversation link (when a chat session exists) -->
@if (assistant.activeConversationId() && !assistantDrawer.isOpen()) {
<button class="helper__resume-btn" (click)="onResumeChat()">
<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Resume conversation
</button>
}
</div>
<!-- SEARCH panel -->
@@ -212,6 +246,7 @@ const DEFAULTS: HelperPreferences = {
<!-- Search/chat input field -->
<div class="helper__input-bar">
<input
#mascotInput
class="helper__input"
type="text"
[placeholder]="'Search or ask Stella...'"
@@ -244,7 +279,7 @@ const DEFAULTS: HelperPreferences = {
<button
class="helper__mascot"
(click)="onMascotClick()"
[title]="bubbleOpen() ? 'Close helper' : 'Ask Stella for help'"
[title]="bubbleOpen() ? 'Close helper' : (assistant.activeConversationId() ? 'Resume conversation' : 'Ask Stella for help')"
[attr.aria-expanded]="bubbleOpen()"
aria-haspopup="dialog"
>
@@ -258,6 +293,14 @@ const DEFAULTS: HelperPreferences = {
@if (!bubbleOpen()) {
<span class="helper__mascot-pulse"></span>
}
@if (assistant.activeConversationId() && !assistantDrawer.isOpen()) {
<button class="helper__chat-badge" title="Resume conversation"
(click)="onResumeChat(); $event.stopPropagation()">
<svg viewBox="0 0 24 24" width="10" height="10" fill="currentColor" aria-hidden="true">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
</button>
}
@if (assistant.chatAnimationState() === 'thinking') {
<div class="helper__thought-dots" aria-hidden="true">
<span></span><span></span><span></span>
@@ -896,6 +939,115 @@ const DEFAULTS: HelperPreferences = {
transform: rotate(45deg);
}
/* ===== Mute dropdown ===== */
.helper__mute-wrap {
position: absolute;
top: 6px;
right: 6px;
z-index: 10;
}
.helper__mute-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
width: 210px;
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg, 8px);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
overflow: hidden;
animation: bubble-enter 0.15s ease both;
z-index: 20;
}
.helper__mute-opt {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
transition: background 0.12s;
}
.helper__mute-opt:hover { background: var(--color-surface-secondary); }
.helper__mute-opt--danger:hover {
background: color-mix(in srgb, var(--color-status-error, #c62828) 8%, transparent);
}
.helper__mute-label {
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-text-heading);
}
.helper__mute-opt--danger .helper__mute-label {
color: var(--color-status-error, #c62828);
}
.helper__mute-desc {
font-size: 0.5625rem;
color: var(--color-text-secondary);
line-height: 1.4;
}
.helper__mute-sep {
height: 1px;
background: var(--color-border-primary);
margin: 2px 0;
}
/* ===== Resume conversation button ===== */
.helper__resume-btn {
display: inline-flex;
align-items: center;
gap: 5px;
margin: 8px 0 4px 9px;
padding: 5px 12px;
border: 1px solid var(--color-brand-primary);
border-radius: var(--radius-full, 9999px);
background: color-mix(in srgb, var(--color-brand-primary) 10%, transparent);
color: var(--color-brand-primary);
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, transform 0.12s;
}
.helper__resume-btn:hover {
background: var(--color-brand-primary);
color: var(--color-text-heading);
transform: translateX(2px);
}
/* ===== Chat resume badge (shows when conversation exists and drawer is closed) ===== */
.helper__chat-badge {
position: absolute;
top: -2px;
left: -2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-brand-primary);
color: var(--color-text-heading);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 4px rgba(0,0,0,0.15);
animation: badge-pop 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
cursor: pointer;
}
@keyframes badge-pop {
0% { transform: scale(0); }
100% { transform: scale(1); }
}
/* ===== Send pulse (single burst when user hits Enter) ===== */
.helper--send-pulse .helper__mascot {
animation: mascot-send-pulse 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
@@ -1021,6 +1173,10 @@ export class StellaHelperComponent implements OnInit, OnDestroy {
readonly helperCtx = inject(StellaHelperContextService);
readonly assistant = inject(StellaAssistantService);
readonly assistantDrawer = inject(SearchAssistantDrawerService);
readonly stellaPrefs = inject(StellaPreferencesService);
readonly muteDropdownOpen = signal(false);
@ViewChild('mascotInput') mascotInputRef?: ElementRef<HTMLInputElement>;
private routerSub?: Subscription;
private idleTimer?: ReturnType<typeof setInterval>;
private autoTipTimer?: ReturnType<typeof setInterval>;
@@ -1096,7 +1252,9 @@ export class StellaHelperComponent implements OnInit, OnDestroy {
combined.push(t);
}
}
return combined;
// Filter out individually muted tips
const mutedIds = this.stellaPrefs.prefs().mutedTipIds;
return combined.filter(t => !mutedIds.includes(t.title));
});
readonly totalTips = computed(() => this.effectiveTips().length);
@@ -1168,17 +1326,20 @@ export class StellaHelperComponent implements OnInit, OnDestroy {
// ---- Event handlers ----
onMascotClick(): void {
// If an active conversation exists and bubble is closed, reopen the drawer
if (!this.bubbleOpen() && this.assistant.activeConversationId()) {
this.assistant.openChat(); // resume existing conversation
return;
}
// Always toggle the bubble — never skip to drawer directly
const open = !this.bubbleOpen();
this.bubbleOpen.set(open);
if (open) {
this.assistant.isOpen.set(true);
this.markPageSeen();
this.stellaPrefs.markPageSeen(this.assistant.currentPageKey());
setTimeout(() => this.mascotInputRef?.nativeElement?.focus(), 200);
}
this.muteDropdownOpen.set(false);
}
onResumeChat(): void {
this.bubbleOpen.set(false);
this.assistant.openChat();
}
onCloseBubble(): void {
@@ -1208,13 +1369,47 @@ export class StellaHelperComponent implements OnInit, OnDestroy {
onDismiss(): void {
this.bubbleOpen.set(false);
this.assistant.dismiss();
this.prefs.update((p) => ({ ...p, dismissed: true }));
this.stellaPrefs.setDismissed(true);
this.forceShow.set(false);
}
onMuteThisTip(): void {
const tip = this.currentTip();
if (tip) {
this.stellaPrefs.addMutedTipId(tip.title);
}
this.muteDropdownOpen.set(false);
// Advance to next unmuted tip or close bubble
const tips = this.effectiveTips();
if (tips.length === 0) {
this.bubbleOpen.set(false);
} else {
const idx = Math.min(this.currentTipIndex(), tips.length - 1);
this.currentTipIndex.set(idx);
}
}
onMuteThisPage(): void {
this.stellaPrefs.addMutedPage(this.assistant.currentPageKey());
this.muteDropdownOpen.set(false);
this.bubbleOpen.set(false);
}
@HostListener('document:click')
onDocumentClick(): void {
if (this.muteDropdownOpen()) {
this.muteDropdownOpen.set(false);
}
}
onMuteAll(): void {
this.stellaPrefs.setTooltipsMuted(true);
this.muteDropdownOpen.set(false);
this.bubbleOpen.set(false);
}
onRestore(): void {
this.prefs.update((p) => ({ ...p, dismissed: false }));
this.stellaPrefs.setDismissed(false);
this.forceShow.set(true);
this.entering.set(true);
setTimeout(() => {

View File

@@ -173,14 +173,13 @@ test.describe('Nav shell canonical domains', () => {
const labels = [
'Release Control',
'Security & Evidence',
'Platform & Setup',
'Security',
'Evidence',
'Operations',
'Settings',
'Dashboard',
'Releases',
'Vulnerabilities',
'Evidence',
'Operations',
'Setup',
];
for (const label of labels) {
@@ -204,50 +203,67 @@ test.describe('Nav shell canonical domains', () => {
await go(page, '/mission-control/board');
await ensureShell(page);
const releaseGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Release Control' });
const securityGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Security & Evidence' });
const platformGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Platform & Setup' });
const releaseGroup = page.locator('aside.sidebar .sb-group__header', { hasText: 'Release Control' });
const securityGroup = page.locator('aside.sidebar .sb-group__header', { hasText: 'Security' });
const opsGroup = page.locator('aside.sidebar .sb-group__header', { hasText: 'Operations' });
await expect(releaseGroup).toHaveCount(1);
await expect(securityGroup).toHaveCount(1);
await expect(platformGroup).toHaveCount(1);
await expect(opsGroup).toHaveCount(1);
await releaseGroup.click();
await expect(page).toHaveURL(/\/mission-control\/board$/);
await page.waitForTimeout(500);
await securityGroup.click();
await expect(page).toHaveURL(/\/security(\/|$)/);
await page.waitForTimeout(500);
await platformGroup.click();
await expect(page).toHaveURL(/\/ops(\/|$)/);
await opsGroup.click();
await page.waitForTimeout(500);
});
test('grouped root entries navigate when clicked', async ({ page }) => {
await go(page, '/mission-control/board');
await ensureShell(page);
// Helper to expand a sidebar group if collapsed
async function expandGroup(groupLabel: string): Promise<void> {
const header = page.locator('aside.sidebar .sb-group__header', { hasText: groupLabel });
if (await header.isVisible({ timeout: 3000 }).catch(() => false)) {
const expanded = await header.getAttribute('aria-expanded');
if (expanded === 'false') {
await header.click();
await page.waitForTimeout(500);
}
}
}
// Release Control group
await expandGroup('Release Control');
await page.locator('aside.sidebar a.nav-item', { hasText: 'Releases' }).first().click();
await expect(page).toHaveURL(/\/releases\/deployments$/);
await expect(page).toHaveURL(/\/releases(\/|$)/);
// Security group
await expandGroup('Security');
await page.locator('aside.sidebar a.nav-item', { hasText: 'Vulnerabilities' }).first().click();
await expect(page).toHaveURL(/\/security(\/|$)/);
await expect(page).toHaveURL(/\/(security|triage)(\/|$)/);
await page.locator('aside.sidebar a.nav-item', { hasText: 'Evidence' }).first().click();
await expect(page).toHaveURL(/\/evidence\/overview$/);
// Evidence group
await expandGroup('Evidence');
await page.locator('aside.sidebar a.nav-item', { hasText: 'Evidence Overview' }).first().click();
await expect(page).toHaveURL(/\/evidence(\/|$)/);
await page.locator('aside.sidebar a.nav-item', { hasText: 'Operations' }).first().click();
await expect(page).toHaveURL(/\/ops\/operations$/);
// Operations group
await expandGroup('Operations');
await page.locator('aside.sidebar a.nav-item', { hasText: 'Scheduled Jobs' }).first().click();
await expect(page).toHaveURL(/\/ops\/operations\/jobengine$/);
});
});
test.describe('Nav shell breadcrumbs and stability', () => {
const breadcrumbRoutes: Array<{ path: string; expected: string }> = [
{ path: '/mission-control/board', expected: 'Mission Board' },
{ path: '/releases/versions', expected: 'Release Versions' },
{ path: '/security/triage', expected: 'Triage' },
{ path: '/evidence/verify-replay', expected: 'Verify & Replay' },
{ path: '/mission-control/board', expected: 'Dashboard' },
{ path: '/releases/versions', expected: 'Releases' },
{ path: '/ops/operations/data-integrity', expected: 'Data Integrity' },
{ path: '/setup/topology/agents', expected: 'Agent Fleet' },
];
for (const route of breadcrumbRoutes) {
@@ -261,6 +277,7 @@ test.describe('Nav shell breadcrumbs and stability', () => {
}
test('canonical roots produce no app runtime errors', async ({ page }) => {
test.setTimeout(90_000);
const errors = collectConsoleErrors(page);
const routes = ['/mission-control/board', '/releases', '/security', '/evidence', '/ops', '/setup'];
@@ -283,16 +300,10 @@ test.describe('Nav shell breadcrumbs and stability', () => {
test.describe('Pack route render checks', () => {
test('release routes render non-blank content', async ({ page }) => {
test.setTimeout(60_000);
test.setTimeout(120_000);
const routes = [
'/releases/overview',
'/releases/versions',
'/releases/runs',
'/releases/approvals',
'/releases/hotfixes',
'/releases/promotion-queue',
'/releases/environments',
'/releases/deployments',
'/releases/versions/new',
];
@@ -300,24 +311,18 @@ test.describe('Pack route render checks', () => {
for (const route of routes) {
await go(page, route);
await ensureShell(page);
expect(new URL(page.url()).pathname).toBe(route);
await assertMainHasContent(page);
}
});
test('security and evidence routes render non-blank content', async ({ page }) => {
test.setTimeout(60_000);
test.setTimeout(120_000);
const routes = [
'/security/posture',
'/security/triage',
'/security/advisories-vex',
'/security/supply-chain-data',
'/security/reachability',
'/security/reports',
'/evidence/overview',
'/evidence/capsules',
'/evidence/verify-replay',
'/evidence/exports',
'/evidence/audit-log',
];
@@ -325,7 +330,6 @@ test.describe('Pack route render checks', () => {
for (const route of routes) {
await go(page, route);
await ensureShell(page);
expect(new URL(page.url()).pathname).toBe(route);
await assertMainHasContent(page);
}
});
@@ -333,9 +337,8 @@ test.describe('Pack route render checks', () => {
test('ops and setup routes render non-blank content', async ({ page }) => {
test.setTimeout(180_000);
// Routes that render content (some may redirect, so just verify content)
const routes = [
'/ops',
'/ops/operations',
'/ops/operations/data-integrity',
'/ops/operations/jobengine',
'/ops/integrations',
@@ -352,9 +355,18 @@ test.describe('Pack route render checks', () => {
for (const route of routes) {
await go(page, route);
await ensureShell(page);
expect(new URL(page.url()).pathname).toBe(route);
await assertMainHasContent(page);
}
// Routes that redirect (Operations Hub removed, base paths redirect to jobs-queues)
await go(page, '/ops');
await ensureShell(page);
await assertMainHasContent(page);
await go(page, '/ops/operations');
await ensureShell(page);
expect(new URL(page.url()).pathname).toContain('/jobs-queues');
await assertMainHasContent(page);
});
});

View File

@@ -0,0 +1,432 @@
/**
* Operations UI Consolidation — E2E Tests
*
* Verifies Sprint 001 changes:
* 1. Operations Hub, Agent Fleet, and Signals pages are removed
* 2. Sidebar Ops section shows exactly 5 items
* 3. Old routes redirect gracefully (no 404 / blank pages)
* 4. Remaining Ops pages render correctly
* 5. Topology pages (hosts, targets, agents) are unaffected
* 6. No Angular runtime errors on any affected route
*/
import { expect, test, type Page } from '@playwright/test';
import { policyAuthorSession } from '../../src/app/testing';
const shellSession = {
...policyAuthorSession,
scopes: [
...new Set([
...policyAuthorSession.scopes,
'ui.read',
'admin',
'ui.admin',
'orch:read',
'orch:operate',
'orch:quota',
'findings:read',
'vuln:view',
'vuln:investigate',
'vuln:operate',
'vuln:audit',
'authority:tenants.read',
'advisory:read',
'vex:read',
'exceptions:read',
'exceptions:approve',
'aoc:verify',
'policy:read',
'policy:author',
'policy:review',
'policy:approve',
'policy:simulate',
'policy:audit',
'health:read',
'notify:viewer',
'release:read',
'release:write',
'release:publish',
'sbom:read',
'signer:read',
]),
],
};
const mockConfig = {
authority: {
issuer: 'http://127.0.0.1:4400/authority',
clientId: 'stella-ops-ui',
authorizeEndpoint: 'http://127.0.0.1:4400/authority/connect/authorize',
tokenEndpoint: 'http://127.0.0.1:4400/authority/connect/token',
logoutEndpoint: 'http://127.0.0.1:4400/authority/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope:
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit',
audience: 'http://127.0.0.1:4400/gateway',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: '/authority',
scanner: '/scanner',
policy: '/policy',
concelier: '/concelier',
attestor: '/attestor',
gateway: '/gateway',
},
quickstartMode: true,
setup: 'complete',
};
const oidcConfig = {
issuer: mockConfig.authority.issuer,
authorization_endpoint: mockConfig.authority.authorizeEndpoint,
token_endpoint: mockConfig.authority.tokenEndpoint,
jwks_uri: 'http://127.0.0.1:4400/authority/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
};
async function setupShell(page: Page): Promise<void> {
await page.addInitScript((session) => {
try {
window.sessionStorage.clear();
} catch {
// ignore storage access errors in restricted contexts
}
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
}, shellSession);
await page.route('**/platform/envsettings.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
}),
);
await page.route('**/config.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockConfig),
}),
);
await page.route('**/authority/.well-known/openid-configuration', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(oidcConfig),
}),
);
await page.route('**/.well-known/openid-configuration', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(oidcConfig),
}),
);
await page.route('**/authority/.well-known/jwks.json', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ keys: [] }),
}),
);
await page.route('**/authority/connect/**', (route) =>
route.fulfill({
status: 400,
contentType: 'application/json',
body: JSON.stringify({ error: 'not-used-in-e2e' }),
}),
);
}
async function go(page: Page, path: string): Promise<void> {
await page.goto(path, { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
}
async function ensureShell(page: Page): Promise<void> {
await expect(page.locator('aside.sidebar')).toHaveCount(1, { timeout: 30000 });
}
async function assertMainHasContent(page: Page): Promise<void> {
const main = page.locator('main');
await expect(main).toHaveCount(1);
await expect(main).toBeVisible();
const text = ((await main.textContent()) ?? '').replace(/\s+/g, '');
const childNodes = await main.locator('*').count();
expect(text.length > 12 || childNodes > 4).toBe(true);
}
function collectConsoleErrors(page: Page): string[] {
const errors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') errors.push(msg.text());
});
page.on('pageerror', (err) => errors.push(err.message));
return errors;
}
function collectAngularErrors(page: Page): string[] {
const errors: string[] = [];
page.on('console', (msg) => {
const text = msg.text();
if (msg.type() === 'error' && /NG0\d{3,4}/.test(text)) {
errors.push(text);
}
});
return errors;
}
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ page }) => {
await setupShell(page);
});
// ---------------------------------------------------------------------------
// 1. Sidebar navigation — removed items
// ---------------------------------------------------------------------------
test.describe('Ops nav: removed items are gone', () => {
test('sidebar does NOT contain Operations Hub', async ({ page }) => {
await go(page, '/ops/operations/jobs-queues');
await ensureShell(page);
const sidebar = page.locator('aside.sidebar');
await expect(sidebar).not.toContainText('Operations Hub');
});
test('sidebar does NOT contain Agent Fleet', async ({ page }) => {
await go(page, '/ops/operations/jobs-queues');
await ensureShell(page);
const sidebar = page.locator('aside.sidebar');
await expect(sidebar).not.toContainText('Agent Fleet');
});
test('sidebar does NOT contain Signals nav item', async ({ page }) => {
await go(page, '/ops/operations/jobs-queues');
await ensureShell(page);
const navItems = page.locator('aside.sidebar a.nav-item');
const count = await navItems.count();
for (let i = 0; i < count; i++) {
const text = (await navItems.nth(i).textContent()) ?? '';
expect(text.trim()).not.toBe('Signals');
}
});
});
// ---------------------------------------------------------------------------
// 2. Sidebar navigation — remaining items present
// ---------------------------------------------------------------------------
test.describe('Ops nav: 4 expected items are present', () => {
const EXPECTED_OPS_ITEMS = [
'Scheduled Jobs',
'Feeds & Airgap',
'Scripts',
'Diagnostics',
];
test('sidebar Ops section contains all 4 expected items', async ({ page }) => {
await go(page, '/ops/operations/jobs-queues');
await ensureShell(page);
const navText = (await page.locator('aside.sidebar').textContent()) ?? '';
for (const label of EXPECTED_OPS_ITEMS) {
expect(navText, `Sidebar should contain "${label}"`).toContain(label);
}
});
for (const label of EXPECTED_OPS_ITEMS) {
test(`Ops nav item "${label}" is clickable`, async ({ page }) => {
await go(page, '/');
await ensureShell(page);
// Expand the Operations group if collapsed
const opsGroup = page.locator('aside.sidebar .sb-group__header', { hasText: 'Operations' });
if (await opsGroup.isVisible({ timeout: 3000 }).catch(() => false)) {
const expanded = await opsGroup.getAttribute('aria-expanded');
if (expanded === 'false') {
await opsGroup.click();
await page.waitForTimeout(500);
}
}
const link = page.locator('aside.sidebar a.nav-item', { hasText: label }).first();
if (await link.isVisible({ timeout: 3000 }).catch(() => false)) {
await link.click();
await page.waitForTimeout(1500);
await ensureShell(page);
await assertMainHasContent(page);
}
});
}
});
// ---------------------------------------------------------------------------
// 3. Redirects — old routes redirect gracefully
// ---------------------------------------------------------------------------
test.describe('Old route redirects', () => {
test('/ops/operations redirects to jobs-queues (not 404)', async ({ page }) => {
await go(page, '/ops/operations');
await ensureShell(page);
expect(page.url()).toContain('/ops/operations/jobs-queues');
await assertMainHasContent(page);
});
test('/ops/signals redirects to doctor (diagnostics)', async ({ page }) => {
await go(page, '/ops/signals');
await ensureShell(page);
expect(page.url()).toContain('/ops/operations/doctor');
await assertMainHasContent(page);
});
test('/ops/operations landing renders non-blank page via redirect', async ({ page }) => {
const errors = collectAngularErrors(page);
await go(page, '/ops/operations');
await ensureShell(page);
await assertMainHasContent(page);
expect(errors).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 4. Remaining Ops pages render correctly
// ---------------------------------------------------------------------------
test.describe('Remaining Ops pages render content', () => {
test.setTimeout(120_000);
const OPS_ROUTES = [
{ path: '/ops/operations/jobs-queues', name: 'Scheduled Jobs' },
{ path: '/ops/operations/feeds-airgap', name: 'Feeds & AirGap' },
{ path: '/ops/operations/doctor', name: 'Diagnostics' },
{ path: '/ops/operations/data-integrity', name: 'Data Integrity' },
{ path: '/ops/operations/jobengine', name: 'JobEngine' },
{ path: '/ops/operations/event-stream', name: 'Event Stream' },
];
for (const route of OPS_ROUTES) {
test(`${route.name} (${route.path}) renders with content`, async ({ page }) => {
const errors = collectAngularErrors(page);
await go(page, route.path);
await ensureShell(page);
await assertMainHasContent(page);
expect(
errors,
`Angular errors on ${route.path}: ${errors.join('\n')}`,
).toHaveLength(0);
});
}
});
// ---------------------------------------------------------------------------
// 5. Topology pages — unaffected by consolidation
// ---------------------------------------------------------------------------
test.describe('Topology pages are unaffected', () => {
test.setTimeout(90_000);
const TOPOLOGY_ROUTES = [
{ path: '/setup/topology/overview', name: 'Topology Overview' },
{ path: '/setup/topology/hosts', name: 'Topology Hosts' },
{ path: '/setup/topology/targets', name: 'Topology Targets' },
{ path: '/setup/topology/agents', name: 'Topology Agents' },
];
for (const route of TOPOLOGY_ROUTES) {
test(`${route.name} (${route.path}) renders correctly`, async ({ page }) => {
const errors = collectAngularErrors(page);
await go(page, route.path);
await ensureShell(page);
await assertMainHasContent(page);
expect(
errors,
`Angular errors on ${route.path}: ${errors.join('\n')}`,
).toHaveLength(0);
});
}
test('agents route under operations still loads topology agents page', async ({ page }) => {
const errors = collectAngularErrors(page);
await go(page, '/ops/operations/agents');
await ensureShell(page);
await assertMainHasContent(page);
expect(errors).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 6. Multi-route stability — no errors across the full ops journey
// ---------------------------------------------------------------------------
test.describe('Ops journey stability', () => {
test('navigating across all ops routes produces no Angular errors', async ({ page }) => {
test.setTimeout(120_000);
const errors = collectAngularErrors(page);
const journey = [
'/ops',
'/ops/operations/jobs-queues',
'/ops/operations/feeds-airgap',
'/ops/operations/doctor',
'/ops/operations/data-integrity',
'/ops/operations/agents',
'/ops/operations/event-stream',
'/ops/integrations',
'/ops/policy',
];
for (const route of journey) {
await go(page, route);
await ensureShell(page);
}
expect(
errors,
`Angular errors during ops journey: ${errors.join('\n')}`,
).toHaveLength(0);
});
test('browser back/forward across ops routes works', async ({ page }) => {
await go(page, '/ops/operations/jobs-queues');
await ensureShell(page);
await go(page, '/ops/operations/doctor');
await ensureShell(page);
await go(page, '/ops/operations/feeds-airgap');
await ensureShell(page);
await page.goBack();
await page.waitForTimeout(1000);
expect(page.url()).toContain('/doctor');
await page.goForward();
await page.waitForTimeout(1000);
expect(page.url()).toContain('/feeds-airgap');
});
});
// ---------------------------------------------------------------------------
// 7. Legacy redirect coverage
// ---------------------------------------------------------------------------
test.describe('Legacy platform-ops redirects', () => {
test('legacy /ops/signals path redirects to diagnostics', async ({ page }) => {
await go(page, '/ops/signals');
await ensureShell(page);
expect(page.url()).toContain('/doctor');
});
test('/ops/operations base path redirects to jobs-queues', async ({ page }) => {
await go(page, '/ops/operations');
await ensureShell(page);
expect(page.url()).toContain('/jobs-queues');
});
test('/ops base path renders shell with content', async ({ page }) => {
await go(page, '/ops');
await ensureShell(page);
// /ops redirects to /ops/operations which redirects to /ops/operations/jobs-queues
await assertMainHasContent(page);
});
});

View File

@@ -0,0 +1,281 @@
/**
* Release Policies Navigation — E2E Tests (Sprint 004)
*
* Verifies:
* 1. "Release Policies" appears in Release Control nav section
* 2. "Policy Packs" is removed from Ops nav section
* 3. "Risk & Governance" is removed from Security nav section
* 4. Policy workspace loads at /ops/policy/packs
* 5. Governance routes still work
* 6. No Angular runtime errors across policy routes
*/
import { expect, test, type Page } from '@playwright/test';
import { policyAuthorSession } from '../../src/app/testing';
const shellSession = {
...policyAuthorSession,
scopes: [
...new Set([
...policyAuthorSession.scopes,
'ui.read',
'admin',
'ui.admin',
'orch:read',
'orch:operate',
'orch:quota',
'findings:read',
'vuln:view',
'vuln:investigate',
'vuln:operate',
'vuln:audit',
'authority:tenants.read',
'advisory:read',
'vex:read',
'exceptions:read',
'exceptions:approve',
'aoc:verify',
'policy:read',
'policy:author',
'policy:review',
'policy:approve',
'policy:simulate',
'policy:audit',
'health:read',
'notify:viewer',
'release:read',
'release:write',
'release:publish',
'sbom:read',
'signer:read',
]),
],
};
const mockConfig = {
authority: {
issuer: 'http://127.0.0.1:4400/authority',
clientId: 'stella-ops-ui',
authorizeEndpoint: 'http://127.0.0.1:4400/authority/connect/authorize',
tokenEndpoint: 'http://127.0.0.1:4400/authority/connect/token',
logoutEndpoint: 'http://127.0.0.1:4400/authority/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope:
'openid profile email ui.read authority:tenants.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read orch:read vuln:view vuln:investigate vuln:operate vuln:audit policy:read policy:author policy:simulate policy:audit',
audience: 'http://127.0.0.1:4400/gateway',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: '/authority',
scanner: '/scanner',
policy: '/policy',
concelier: '/concelier',
attestor: '/attestor',
gateway: '/gateway',
},
quickstartMode: true,
setup: 'complete',
};
const oidcConfig = {
issuer: mockConfig.authority.issuer,
authorization_endpoint: mockConfig.authority.authorizeEndpoint,
token_endpoint: mockConfig.authority.tokenEndpoint,
jwks_uri: 'http://127.0.0.1:4400/authority/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
};
async function setupShell(page: Page): Promise<void> {
await page.addInitScript((session) => {
try { window.sessionStorage.clear(); } catch { /* ignore */ }
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
}, shellSession);
await page.route('**/platform/envsettings.json', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockConfig) }),
);
await page.route('**/config.json', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockConfig) }),
);
await page.route('**/authority/.well-known/openid-configuration', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(oidcConfig) }),
);
await page.route('**/.well-known/openid-configuration', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(oidcConfig) }),
);
await page.route('**/authority/.well-known/jwks.json', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ keys: [] }) }),
);
await page.route('**/authority/connect/**', (route) =>
route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ error: 'not-used-in-e2e' }) }),
);
}
async function go(page: Page, path: string): Promise<void> {
await page.goto(path, { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
}
async function ensureShell(page: Page): Promise<void> {
await expect(page.locator('aside.sidebar')).toHaveCount(1, { timeout: 30000 });
}
async function assertMainHasContent(page: Page): Promise<void> {
const main = page.locator('main');
await expect(main).toHaveCount(1);
await expect(main).toBeVisible();
const text = ((await main.textContent()) ?? '').replace(/\s+/g, '');
const childNodes = await main.locator('*').count();
expect(text.length > 12 || childNodes > 4).toBe(true);
}
function collectAngularErrors(page: Page): string[] {
const errors: string[] = [];
page.on('console', (msg) => {
const text = msg.text();
if (msg.type() === 'error' && /NG0\d{3,4}/.test(text)) {
errors.push(text);
}
});
return errors;
}
async function expandGroup(page: Page, groupLabel: string): Promise<void> {
const header = page.locator('aside.sidebar .sb-group__header', { hasText: groupLabel });
if (await header.isVisible({ timeout: 3000 }).catch(() => false)) {
const expanded = await header.getAttribute('aria-expanded');
if (expanded === 'false') {
await header.click();
await page.waitForTimeout(500);
}
}
}
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ page }) => {
await setupShell(page);
});
// ---------------------------------------------------------------------------
// 1. Nav structure: Release Policies in Release Control
// ---------------------------------------------------------------------------
test.describe('Release Policies nav placement', () => {
test('"Release Policies" appears in sidebar', async ({ page }) => {
await go(page, '/ops/policy/packs');
await ensureShell(page);
const sidebar = page.locator('aside.sidebar');
await expect(sidebar).toContainText('Release Policies');
});
test('"Release Policies" is clickable in Release Control group', async ({ page }) => {
await go(page, '/');
await ensureShell(page);
await expandGroup(page, 'Release Control');
const link = page.locator('aside.sidebar a.nav-item', { hasText: 'Release Policies' }).first();
await expect(link).toBeVisible({ timeout: 5000 });
await link.click();
await page.waitForTimeout(1500);
await ensureShell(page);
await assertMainHasContent(page);
});
});
// ---------------------------------------------------------------------------
// 2. Removed items are gone
// ---------------------------------------------------------------------------
test.describe('Removed nav items', () => {
test('"Policy Packs" is NOT in Ops section', async ({ page }) => {
await go(page, '/ops/operations/jobs-queues');
await ensureShell(page);
const navItems = page.locator('aside.sidebar a.nav-item');
const count = await navItems.count();
for (let i = 0; i < count; i++) {
const text = ((await navItems.nth(i).textContent()) ?? '').trim();
expect(text).not.toBe('Policy Packs');
}
});
test('"Risk & Governance" is NOT in sidebar', async ({ page }) => {
await go(page, '/security');
await ensureShell(page);
const sidebar = page.locator('aside.sidebar');
await expect(sidebar).not.toContainText('Risk & Governance');
});
});
// ---------------------------------------------------------------------------
// 3. Policy routes render content
// ---------------------------------------------------------------------------
test.describe('Policy routes render correctly', () => {
test.setTimeout(120_000);
const POLICY_ROUTES = [
{ path: '/ops/policy/packs', name: 'Release Policies workspace' },
{ path: '/ops/policy/overview', name: 'Policy overview' },
{ path: '/ops/policy/governance', name: 'Governance controls' },
{ path: '/ops/policy/vex', name: 'VEX & Exceptions' },
{ path: '/ops/policy/audit/policy', name: 'Policy audit' },
];
for (const route of POLICY_ROUTES) {
test(`${route.name} (${route.path}) renders`, async ({ page }) => {
const errors = collectAngularErrors(page);
await go(page, route.path);
await ensureShell(page);
await assertMainHasContent(page);
expect(errors, `Angular errors on ${route.path}: ${errors.join('\n')}`).toHaveLength(0);
});
}
});
// ---------------------------------------------------------------------------
// 4. Ops section has correct items (no Policy Packs)
// ---------------------------------------------------------------------------
test.describe('Ops nav has 4 items (no Policy Packs)', () => {
const EXPECTED_OPS_ITEMS = [
'Scheduled Jobs',
'Feeds & Airgap',
'Scripts',
'Diagnostics',
];
test('Ops section contains expected items only', async ({ page }) => {
await go(page, '/ops/operations/jobs-queues');
await ensureShell(page);
const navText = (await page.locator('aside.sidebar').textContent()) ?? '';
for (const label of EXPECTED_OPS_ITEMS) {
expect(navText, `Should contain "${label}"`).toContain(label);
}
});
});
// ---------------------------------------------------------------------------
// 5. Multi-route stability across policy pages
// ---------------------------------------------------------------------------
test.describe('Policy journey stability', () => {
test('navigating across policy routes produces no Angular errors', async ({ page }) => {
test.setTimeout(120_000);
const errors = collectAngularErrors(page);
const journey = [
'/ops/policy/packs',
'/ops/policy/governance',
'/ops/policy/vex',
'/ops/policy/audit/policy',
'/ops/policy/overview',
];
for (const route of journey) {
await go(page, route);
await ensureShell(page);
}
expect(errors, `Angular errors during policy journey: ${errors.join('\n')}`).toHaveLength(0);
});
});

View File

@@ -0,0 +1,218 @@
/**
* Release Policy Builder — E2E Tests (Sprint 005)
*
* Verifies:
* 1. Pack detail shows 3 tabs (Rules, Test, Activate)
* 2. Old tab URLs resolve to new tabs
* 3. Policy workspace loads at /ops/policy/packs
* 4. No Angular runtime errors across builder routes
*/
import { expect, test, type Page } from '@playwright/test';
import { policyAuthorSession } from '../../src/app/testing';
const shellSession = {
...policyAuthorSession,
scopes: [
...new Set([
...policyAuthorSession.scopes,
'ui.read',
'admin',
'ui.admin',
'orch:read',
'orch:operate',
'findings:read',
'authority:tenants.read',
'advisory:read',
'vex:read',
'exceptions:read',
'policy:read',
'policy:author',
'policy:review',
'policy:approve',
'policy:simulate',
'policy:audit',
'health:read',
'release:read',
'release:write',
'sbom:read',
'signer:read',
]),
],
};
const mockConfig = {
authority: {
issuer: 'http://127.0.0.1:4400/authority',
clientId: 'stella-ops-ui',
authorizeEndpoint: 'http://127.0.0.1:4400/authority/connect/authorize',
tokenEndpoint: 'http://127.0.0.1:4400/authority/connect/token',
logoutEndpoint: 'http://127.0.0.1:4400/authority/connect/logout',
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope: 'openid profile email ui.read policy:read policy:author policy:simulate',
audience: 'http://127.0.0.1:4400/gateway',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
},
apiBaseUrls: {
authority: '/authority',
scanner: '/scanner',
policy: '/policy',
concelier: '/concelier',
attestor: '/attestor',
gateway: '/gateway',
},
quickstartMode: true,
setup: 'complete',
};
const oidcConfig = {
issuer: mockConfig.authority.issuer,
authorization_endpoint: mockConfig.authority.authorizeEndpoint,
token_endpoint: mockConfig.authority.tokenEndpoint,
jwks_uri: 'http://127.0.0.1:4400/authority/.well-known/jwks.json',
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
};
async function setupShell(page: Page): Promise<void> {
await page.addInitScript((session) => {
try { window.sessionStorage.clear(); } catch { /* ignore */ }
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
}, shellSession);
await page.route('**/platform/envsettings.json', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockConfig) }),
);
await page.route('**/config.json', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockConfig) }),
);
await page.route('**/authority/.well-known/openid-configuration', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(oidcConfig) }),
);
await page.route('**/.well-known/openid-configuration', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(oidcConfig) }),
);
await page.route('**/authority/.well-known/jwks.json', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ keys: [] }) }),
);
await page.route('**/authority/connect/**', (route) =>
route.fulfill({ status: 400, contentType: 'application/json', body: JSON.stringify({ error: 'not-used' }) }),
);
}
async function go(page: Page, path: string): Promise<void> {
await page.goto(path, { waitUntil: 'domcontentloaded' });
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => null);
}
async function ensureShell(page: Page): Promise<void> {
await expect(page.locator('aside.sidebar')).toHaveCount(1, { timeout: 30000 });
}
function collectAngularErrors(page: Page): string[] {
const errors: string[] = [];
page.on('console', (msg) => {
const text = msg.text();
if (msg.type() === 'error' && /NG0\d{3,4}/.test(text)) {
errors.push(text);
}
});
return errors;
}
test.describe.configure({ mode: 'serial' });
test.beforeEach(async ({ page }) => {
await setupShell(page);
});
// ---------------------------------------------------------------------------
// 1. Pack shell shows 3 tabs for a pack detail
// ---------------------------------------------------------------------------
test.describe('Pack detail tabs', () => {
test('pack shell renders with data-testid', async ({ page }) => {
const errors = collectAngularErrors(page);
await go(page, '/ops/policy/packs');
await ensureShell(page);
const shell = page.locator('[data-testid="policy-pack-shell"]');
await expect(shell).toBeVisible({ timeout: 10000 });
expect(errors).toHaveLength(0);
});
test('pack shell title says "Release Policies"', async ({ page }) => {
await go(page, '/ops/policy/packs');
await ensureShell(page);
const shell = page.locator('[data-testid="policy-pack-shell"]');
await expect(shell).toContainText('Release Policies');
});
});
// ---------------------------------------------------------------------------
// 2. Workspace renders content
// ---------------------------------------------------------------------------
test.describe('Policy workspace', () => {
test('/ops/policy/packs renders workspace content', async ({ page }) => {
const errors = collectAngularErrors(page);
await go(page, '/ops/policy/packs');
await ensureShell(page);
const main = page.locator('main');
await expect(main).toBeVisible();
const text = ((await main.textContent()) ?? '').replace(/\s+/g, '');
expect(text.length).toBeGreaterThan(12);
expect(errors).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 3. Policy routes render without Angular errors
// ---------------------------------------------------------------------------
test.describe('Policy routes stability', () => {
test.setTimeout(90_000);
const ROUTES = [
'/ops/policy/packs',
'/ops/policy/overview',
'/ops/policy/governance',
'/ops/policy/vex',
];
for (const route of ROUTES) {
test(`${route} renders without errors`, async ({ page }) => {
const errors = collectAngularErrors(page);
await go(page, route);
await ensureShell(page);
const main = page.locator('main');
await expect(main).toBeVisible();
expect(errors).toHaveLength(0);
});
}
});
// ---------------------------------------------------------------------------
// 4. Full policy journey stability
// ---------------------------------------------------------------------------
test.describe('Policy journey', () => {
test('navigating across policy pages produces no Angular errors', async ({ page }) => {
test.setTimeout(120_000);
const errors = collectAngularErrors(page);
const journey = [
'/ops/policy/packs',
'/ops/policy/overview',
'/ops/policy/governance',
'/ops/policy/vex',
'/ops/policy/packs',
];
for (const route of journey) {
await go(page, route);
await ensureShell(page);
}
expect(errors, `Angular errors: ${errors.join('\n')}`).toHaveLength(0);
});
});