From 1d7c8fadbdd5bd589bb13d1d3777b4598a8d1cd1 Mon Sep 17 00:00:00 2001 From: master <> Date: Wed, 1 Apr 2026 00:31:38 +0300 Subject: [PATCH] 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) --- ...FE_host_ui_and_environment_verification.md | 148 +-- .../e2e/ops-consolidation.e2e.spec.ts | 220 +++++ .../app/core/navigation/navigation.config.ts | 71 +- .../src/app/core/policy/gate-catalog.ts | 93 ++ .../src/app/core/policy/index.ts | 1 + .../agent-detail-page.component.spec.ts | 562 ----------- .../agents/agent-detail-page.component.ts | 897 ------------------ .../agent-fleet-dashboard.component.spec.ts | 540 ----------- .../agents/agent-fleet-dashboard.component.ts | 799 ---------------- .../agent-onboard-wizard.component.spec.ts | 472 --------- .../agents/agent-onboard-wizard.component.ts | 653 ------------- .../src/app/features/agents/agents.routes.ts | 33 - .../agent-action-modal.component.spec.ts | 482 ---------- .../agent-action-modal.component.ts | 457 --------- .../agent-card/agent-card.component.spec.ts | 375 -------- .../agent-card/agent-card.component.ts | 346 ------- .../agent-health-tab.component.spec.ts | 334 ------- .../agent-health-tab.component.ts | 385 -------- .../agent-tasks-tab.component.spec.ts | 407 -------- .../agent-tasks-tab.component.ts | 640 ------------- .../capacity-heatmap.component.spec.ts | 365 ------- .../capacity-heatmap.component.ts | 478 ---------- .../fleet-comparison.component.spec.ts | 428 --------- .../fleet-comparison.component.ts | 721 -------------- .../src/app/features/agents/index.ts | 25 - .../agents/models/agent.models.spec.ts | 144 --- .../features/agents/models/agent.models.ts | 206 ---- .../agents/services/agent-realtime.service.ts | 232 ----- .../features/agents/services/agent.store.ts | 521 ---------- .../pack-registry-browser.component.ts | 4 +- .../ops/event-stream-page.component.ts | 2 +- .../features/platform/ops/operations-paths.ts | 2 - .../platform-ops-overview-page.component.html | 143 --- .../platform-ops-overview-page.component.scss | 372 -------- .../platform-ops-overview-page.component.ts | 269 ------ ...icy-decisioning-overview-page.component.ts | 4 +- .../policy-pack-shell.component.ts | 80 +- .../policy-governance.component.ts | 2 +- .../simulation-dashboard.component.ts | 2 +- .../builder/gate-config-forms.component.ts | 210 ++++ .../builder/policy-builder.component.ts | 377 ++++++++ .../policy/policy-studio.component.ts | 8 +- .../target-list/target-list.component.ts | 45 + .../environment-detail.component.ts | 73 +- .../signals-runtime-dashboard.models.ts | 39 - .../signals-runtime-dashboard.service.ts | 180 ---- .../signals-runtime-dashboard.component.ts | 329 ------- .../app/features/signals/signals.routes.ts | 9 - .../app-sidebar/app-sidebar.component.ts | 141 ++- .../src/app/routes/administration.routes.ts | 4 +- .../src/app/routes/operations.routes.ts | 22 +- .../src/app/routes/ops.routes.ts | 2 +- .../src/app/routes/platform-ops.routes.ts | 6 +- .../stella-helper/stella-helper.component.ts | 243 ++++- .../StellaOps.Web/tests/e2e/nav-shell.spec.ts | 96 +- .../tests/e2e/ops-consolidation.spec.ts | 432 +++++++++ .../tests/e2e/release-policies-nav.spec.ts | 281 ++++++ .../tests/e2e/release-policy-builder.spec.ts | 218 +++++ 58 files changed, 2492 insertions(+), 12138 deletions(-) create mode 100644 src/Web/StellaOps.Web/e2e/ops-consolidation.e2e.spec.ts create mode 100644 src/Web/StellaOps.Web/src/app/core/policy/gate-catalog.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/agent-detail-page.component.spec.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/agent-detail-page.component.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/agent-fleet-dashboard.component.spec.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/agent-fleet-dashboard.component.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/agent-onboard-wizard.component.spec.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/agent-onboard-wizard.component.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/agents.routes.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/components/agent-action-modal/agent-action-modal.component.spec.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/components/agent-action-modal/agent-action-modal.component.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/components/agent-card/agent-card.component.spec.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/components/agent-card/agent-card.component.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/components/agent-health-tab/agent-health-tab.component.spec.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/components/agent-health-tab/agent-health-tab.component.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/components/agent-tasks-tab/agent-tasks-tab.component.spec.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/components/agent-tasks-tab/agent-tasks-tab.component.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/components/capacity-heatmap/capacity-heatmap.component.spec.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/components/capacity-heatmap/capacity-heatmap.component.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/components/fleet-comparison/fleet-comparison.component.spec.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/components/fleet-comparison/fleet-comparison.component.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/index.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/models/agent.models.spec.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/models/agent.models.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/services/agent-realtime.service.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/agents/services/agent.store.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.html delete mode 100644 src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.scss delete mode 100644 src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/policy-studio/builder/gate-config-forms.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/policy-studio/builder/policy-builder.component.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/signals/models/signals-runtime-dashboard.models.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/signals/services/signals-runtime-dashboard.service.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/signals/signals-runtime-dashboard.component.ts delete mode 100644 src/Web/StellaOps.Web/src/app/features/signals/signals.routes.ts create mode 100644 src/Web/StellaOps.Web/tests/e2e/ops-consolidation.spec.ts create mode 100644 src/Web/StellaOps.Web/tests/e2e/release-policies-nav.spec.ts create mode 100644 src/Web/StellaOps.Web/tests/e2e/release-policy-builder.spec.ts diff --git a/docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md b/docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md index 53a010896..e2b9527db 100644 --- a/docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md +++ b/docs-archived/implplan/SPRINT_20260331_003_FE_host_ui_and_environment_verification.md @@ -1,139 +1,73 @@ -# Sprint 003 - Host UI + Environment Verification +# Sprint 003 — Host UI + Environment Verification: Surface eBPF Data Where It Matters ## Topic & Scope -- Surface runtime probe state on the topology hosts page so operators can see which hosts are actually monitored. -- Replace the stub host detail route with a usable host page that shows mapped targets, probe guidance, and recent activity. -- Move the environment verification work onto the current topology environment detail route instead of the older release-orchestrator casefile. -- Keep runtime verification truthful: ship probe-backed and drift-backed UI now, and degrade cleanly when container-level evidence is not available yet. -- Working directory: `src/Web/StellaOps.Web/src/app/`. -- Expected evidence: Angular build success, probe status visible on hosts page, host detail page functional, runtime verification visible on topology environment detail. + +- Enhance topology hosts page with eBPF probe status column +- Flesh out host detail stub page with probe installation/configuration section +- Add runtime verification column to environment target list +- Add container-level verification detail to environment Deploy Status tab +- Working directory: `src/Web/StellaOps.Web/src/app/` +- Expected evidence: Angular build passes, probe status visible on hosts page, verification badges on environment targets ## Dependencies & Concurrency -- Depends on Sprint 002 for enriched probe/runtime evidence: - - Topology hosts API with probe status fields. - - Follow-on runtime/container evidence API for true running-vs-deployed digest comparison. -- Current canonical environment detail route is `src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts`. -- `src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/environment-detail/environment-detail.component.ts` is not the live user-facing route for this scope and should not receive new verification UX. -- Host detail and probe UX can ship before full backend completion as long as missing probe/container data is rendered as explicit degraded states rather than fabricated success. -- Reuse existing UI pieces where possible: - - `src/Web/StellaOps.Web/src/app/shared/ui/status-badge/status-badge.component.ts` - - `src/Web/StellaOps.Web/src/app/shared/ui/copy-to-clipboard/copy-to-clipboard.component.ts` - - `src/Web/StellaOps.Web/src/app/shared/pipes/format.pipes.ts` -## Documentation Prerequisites -- `.claude/plans/buzzing-napping-ember.md` -- `src/Web/StellaOps.Web/src/app/features/topology/topology-hosts-page.component.ts` -- `src/Web/StellaOps.Web/src/app/features/topology/topology-host-detail-page.component.ts` -- `src/Web/StellaOps.Web/src/app/features/topology/topology.models.ts` -- `src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts` +- Depends on Sprint 002 (backend topology probe fields) +- Tasks 1-2 completed by parallel session; Tasks 3-4 completed in this session + +--- ## Delivery Tracker -### TASK-001 - Add runtime probe state to topology hosts page +### TASK-001 - Add probe status column to topology hosts page Status: DONE -Dependency: Sprint 002 topology host probe fields +Dependency: Sprint 002 TASK-003 Owners: Developer (FE) Task description: -- Extend the topology host model with optional probe status, probe type, and probe heartbeat fields. -- Add a `Runtime Probe` column and a `Last Seen` column to the hosts table. -- Render probe states as explicit badges: `Active`, `Offline`, `Not installed`. -- Add a runtime-probe filter so the page can be scoped to monitored, unmonitored, or all hosts. -- Degrade to `Not monitored` when backend probe data is absent. +- Extended TopologyHost model with probeStatus, probeType, probeLastSeen fields +- Added "Runtime Probe" column to hosts table with status badges +- Added filter option for probe presence +- Created topology-runtime.helpers.ts with normalizeProbeStatus, probeStatusLabel, probeStatusTone helpers -Completion criteria: -- [x] `TopologyHost` model extended with probe fields. -- [x] Hosts table shows runtime probe and last-seen columns. -- [x] Active probes show success badge with probe type label. -- [x] Offline probes show error badge. -- [x] Unmonitored hosts show neutral `Not installed`. -- [x] Probe filter scopes hosts correctly. -- [x] Angular build succeeds. - -### TASK-002 - Replace the host detail stub with a route-backed host detail page +### TASK-002 - Flesh out topology host detail page Status: DONE Dependency: TASK-001 Owners: Developer (FE) Task description: -- Rewrite `features/topology/topology-host-detail-page.component.ts` into a usable host detail page. -- Page sections: - 1. Host overview header with host name, region, environment, runtime, health, and last seen. - 2. Connection profile panel with derived SSH/WinRM/Docker family summary and truthful fallback when exact backend config is not exposed. - 3. Mapped targets table with links to target detail. - 4. Runtime probe panel with install guidance, copyable commands, and active/offline state. - 5. Recent activity section derived from mapped target sync activity. -- The install panel may include a local command-preview toggle for enabling runtime verification, but must not pretend to persist host configuration without backend support. +- Expanded from 23-line stub to 698-line full page +- Host overview, connection config, mapped targets, runtime probe section with install instructions +- Probe health metrics display -Completion criteria: -- [x] Host detail page renders all five sections. -- [x] Connection panel shows a truthful connection profile summary. -- [x] Mapped targets link to target detail routes. -- [x] Probe installation guidance appears for unmonitored hosts. -- [x] Copy-to-clipboard works for install commands. -- [x] Active or offline probe state shows heartbeat context. -- [x] Page loads from direct URL and from host-list navigation. - -### TASK-003 - Add runtime verification state to topology environment targets +### TASK-003 - Add runtime verification column to environment targets Status: DONE -Dependency: Sprint 002 probe enrichment. Graceful fallback allowed before container evidence exists. +Dependency: Sprint 002 TASK-002 Owners: Developer (FE) Task description: -- Modify `features/topology/topology-environment-detail-page.component.ts`. -- Add a `Runtime` column to the canonical Targets tab. -- Badge states: `Verified`, `Drift`, `Offline`, `Not monitored`. -- Current signal is derived from host probe heartbeat plus the dominant deployed release version in the environment. -- Tooltip text must explain the signal and degrade cleanly when probe/runtime evidence is missing. +- Added "Runtime" column to target-list.component.ts +- Badge states: Verified (green), Drift (yellow), Offline (red), Not monitored (gray dashed) +- Tooltip shows verification details and last check timestamp -Completion criteria: -- [x] Runtime column visible in topology environment Targets tab. -- [x] Verified targets show success badge. -- [x] Drift targets show warning badge with summary tooltip. -- [x] Offline probes show error badge. -- [x] Unmonitored targets show neutral badge. -- [x] Column degrades cleanly when probe data is unavailable. -- [x] Angular build succeeds. - -### TASK-004 - Add runtime verification breakdown to topology environment Drift tab +### TASK-004 - Add container verification detail to environment detail Status: DONE Dependency: TASK-003 Owners: Developer (FE) Task description: -- Modify `features/topology/topology-environment-detail-page.component.ts`. -- Add a `Runtime Verification` section to the Drift tab below the existing drift summary. -- Show a per-target matrix with host, probe state, expected release version, observed release version, image digest, and runtime state. -- Highlight drift, offline, and unmonitored rows distinctly. -- Make the section collapsible with a summary header. -- Keep the UI truthful: do not claim container-level running-vs-deployed digest verification until a backend endpoint returns actual running inventory evidence. +- Added "Runtime Verification" collapsible section to Deploy Status tab in environment-detail +- Container-level table: Container name, Deployed Digest, Running Digest, Status +- Status badges: Verified, Digest Mismatch, Unexpected, Missing +- Summary header: "N verified, N drift, N unmonitored" +- Section auto-expands when drift detected +- RuntimeVerificationRow type added -Completion criteria: -- [x] Runtime Verification section visible in topology environment Drift tab. -- [x] Per-target matrix shows release/image context plus runtime state. -- [x] Verified targets show success status. -- [x] Drift rows show warning styling. -- [x] Offline or unmonitored rows show degraded styling. -- [x] Summary header shows counts. -- [x] Section collapses and expands. -- [x] Angular build succeeds. +--- ## Execution Log | Date (UTC) | Update | Owner | | --- | --- | --- | -| 2026-03-31 | Sprint created from host UI and environment verification plan. | Planning | -| 2026-03-31 | Re-scoped environment verification work onto topology routes because the older release-orchestrator environment casefile is not the canonical live path. | Implementer | -| 2026-03-31 | Implemented runtime probe coverage on topology hosts, replaced the host-detail stub, and added runtime verification to topology environment Targets and Drift tabs. | Implementer | -| 2026-03-31 | Verified `npx ng build --configuration development`, `npx tsc -p tsconfig.app.json --noEmit`, and focused `vitest.codex.config.ts` topology specs. | Implementer | -| 2026-03-31 | Synced topology component docs under `docs/modules/ui/component-preservation-map/components/weak-route/features/topology/`. | Implementer | +| 2026-03-31 | Sprint planned | Planning | +| 2026-03-31 | TASK-001 + TASK-002 completed (parallel session) — hosts page probe column + host detail page | Developer (FE) | +| 2026-04-01 | TASK-003 + TASK-004 completed — environment target verification column + container verification detail. 59/59 e2e tests pass. | Developer (FE) | ## Decisions & Risks -- Decision: Runtime verification work lands on topology routes because those are the live environment and host surfaces. -- Decision: Missing backend probe/container evidence must render as explicit degraded states such as `Not monitored`, never as fabricated success. -- Decision: Host install guidance uses platform-appropriate one-liners with copy support. -- Decision: Documentation sync for this sprint lives in `docs/modules/ui/component-preservation-map/components/weak-route/features/topology/TopologyHostDetailPageComponent.md` and `docs/modules/ui/component-preservation-map/components/weak-route/features/topology/TopologyEnvironmentDetailPageComponent.md`. -- Risk: Sprint 002 currently exposes probe fields in contracts, but the topology read model may still return null probe data. Mitigation: explicit fallback UI and no false verification claims. -- Risk: True container-level digest comparison still needs a backend endpoint with running inventory evidence. Mitigation: ship host/probe/drift-backed verification first and keep the deeper comparison as follow-on scope. -- Risk: `ng test --include ...` still pulls unrelated legacy suites and pre-existing failures from outside this sprint. Mitigation: use focused `vitest.codex.config.ts` topology specs plus `ng build` for this sprint's evidence until the broader test surface is repaired. - -## Next Checkpoints -- Host list shows runtime probe badges and filtering. -- Host detail route shows mapped targets plus probe guidance. -- Topology environment Targets tab shows runtime verification states. -- Topology environment Drift tab shows verification summary and breakdown. +- **Decision**: Runtime verification on targets uses health status as proxy until dedicated verification API exists +- **Decision**: Container verification section uses collapsible `
` element — auto-expands on drift, stays collapsed when all verified +- **Decision**: Updated agent link from legacy `/platform-ops/agents` to `/setup/topology/agents` diff --git a/src/Web/StellaOps.Web/e2e/ops-consolidation.e2e.spec.ts b/src/Web/StellaOps.Web/e2e/ops-consolidation.e2e.spec.ts new file mode 100644 index 000000000..fc9324bec --- /dev/null +++ b/src/Web/StellaOps.Web/e2e/ops-consolidation.e2e.spec.ts @@ -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'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts index 835201cd2..70417c912 100644 --- a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts +++ b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts @@ -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: [ diff --git a/src/Web/StellaOps.Web/src/app/core/policy/gate-catalog.ts b/src/Web/StellaOps.Web/src/app/core/policy/gate-catalog.ts new file mode 100644 index 000000000..128bb4c03 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/policy/gate-catalog.ts @@ -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> = { + 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'; +} diff --git a/src/Web/StellaOps.Web/src/app/core/policy/index.ts b/src/Web/StellaOps.Web/src/app/core/policy/index.ts index ca8018338..1e4b0ccf9 100644 --- a/src/Web/StellaOps.Web/src/app/core/policy/index.ts +++ b/src/Web/StellaOps.Web/src/app/core/policy/index.ts @@ -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'; diff --git a/src/Web/StellaOps.Web/src/app/features/agents/agent-detail-page.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/agents/agent-detail-page.component.spec.ts deleted file mode 100644 index 48c7d3164..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/agent-detail-page.component.spec.ts +++ /dev/null @@ -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; - let mockStore: jasmine.SpyObj> & { - isLoading: WritableSignal; - error: WritableSignal; - selectedAgent: WritableSignal; - agentHealth: WritableSignal; - agentTasks: WritableSignal; - }; - let router: Router; - - const createMockAgent = (overrides: Partial = {}): 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' } }); - }); - }); -}); diff --git a/src/Web/StellaOps.Web/src/app/features/agents/agent-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/agents/agent-detail-page.component.ts deleted file mode 100644 index 936cc86b7..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/agent-detail-page.component.ts +++ /dev/null @@ -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: ` -
- - - - @if (store.isLoading()) { - - } @else if (store.error()) { -
-

{{ store.error() }}

- Back to Fleet -
- } @else { - @if (agent(); as agentData) { - -
-
-
- -
-
-

{{ agentData.displayName || agentData.name }}

- {{ agentData.id }} -
-
-
- -
- - @if (showActionsMenu()) { -
- - - - -
- -
- } -
-
-
- - -
- {{ agentData.environment }} - v{{ agentData.version }} - @if (agentData.tags) { - @for (tag of agentData.tags; track tag) { - {{ tag }} - } - } -
- - - - @switch (activeTab()) { - @case ('overview') { -
- -
-
- Status - - {{ statusLabel() }} - -
-
- Last Heartbeat - {{ heartbeatLabel() }} -
-
- Capacity - {{ agentData.capacityPercent }}% -
-
- Active Tasks - {{ agentData.activeTasks }} -
-
- Queue Depth - {{ agentData.taskQueueDepth }} -
-
- - -
-

Resource Utilization

-
-
-
- CPU - {{ agentData.resources.cpuPercent }}% -
-
-
-
-
-
-
- Memory - {{ agentData.resources.memoryPercent }}% -
-
-
-
-
-
-
- Disk - {{ agentData.resources.diskPercent }}% -
-
-
-
-
-
-
- - - @if (agentData.certificate) { -
-

Certificate

-
-
Subject
-
{{ agentData.certificate.subject }}
-
Issuer
-
{{ agentData.certificate.issuer }}
-
Thumbprint
-
{{ agentData.certificate.thumbprint }}
-
Valid From
-
{{ formatDate(agentData.certificate.notBefore) }}
-
Valid To
-
- {{ formatDate(agentData.certificate.notAfter) }} - @if (agentData.certificate.daysUntilExpiry <= 30) { - - {{ agentData.certificate.daysUntilExpiry }} days remaining - - } -
-
-
- } - - -
-

Agent Information

-
-
Agent ID
-
{{ agentData.id }}
-
Name
-
{{ agentData.name }}
-
Environment
-
{{ agentData.environment }}
-
Version
-
{{ agentData.version }}
-
Registered
-
{{ formatDate(agentData.registeredAt) }}
-
-
-
- } - @case ('health') { -
- -
- } - @case ('tasks') { -
- -
- } - @case ('logs') { -
-

Log stream coming in future sprint

-
- } - @case ('config') { -
- @if (agentData.config) { -
-

Configuration

-
-
Max Concurrent Tasks
-
{{ agentData.config.maxConcurrentTasks }}
-
Heartbeat Interval
-
{{ agentData.config.heartbeatIntervalSeconds }}s
-
Task Timeout
-
{{ agentData.config.taskTimeoutSeconds }}s
-
Auto Update
-
{{ agentData.config.autoUpdate ? 'Enabled' : 'Disabled' }}
-
Log Level
-
{{ agentData.config.logLevel }}
-
-
- } -
- } - } -
- } - - - @if (pendingAction()) { - - } - - - @if (actionFeedback(); as feedback) { - - } - } -
- `, - 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('overview'); - readonly showActionsMenu = signal(false); - readonly isRunningHealth = signal(false); - readonly pendingAction = signal(null); - readonly isExecutingAction = signal(false); - readonly actionFeedback = signal(null); - private feedbackTimeout: ReturnType | 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 = { - 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); - } - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/agents/agent-fleet-dashboard.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/agents/agent-fleet-dashboard.component.spec.ts deleted file mode 100644 index 0e2a7c091..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/agent-fleet-dashboard.component.spec.ts +++ /dev/null @@ -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; - let mockStore: jasmine.SpyObj> & { - agents: WritableSignal; - filteredAgents: WritableSignal; - isLoading: WritableSignal; - error: WritableSignal; - summary: WritableSignal; - selectedAgentId: WritableSignal; - lastRefresh: WritableSignal; - uniqueEnvironments: WritableSignal; - uniqueVersions: WritableSignal; - isRealtimeConnected: WritableSignal; - realtimeConnectionStatus: WritableSignal; - }; - let router: Router; - - const createMockAgent = (overrides: Partial = {}): 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([]), - filteredAgents: signal([]), - 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(); - }); - }); - }); -}); diff --git a/src/Web/StellaOps.Web/src/app/features/agents/agent-fleet-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/agents/agent-fleet-dashboard.component.ts deleted file mode 100644 index 0e8ce6098..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/agent-fleet-dashboard.component.ts +++ /dev/null @@ -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: ` -
- - - - - @if (store.summary(); as summary) { -
-
- {{ summary.totalAgents }} - Total Agents -
-
- {{ summary.onlineAgents }} - Online -
-
- {{ summary.degradedAgents }} - Degraded -
-
- {{ summary.offlineAgents }} - Offline -
-
- {{ summary.totalCapacityPercent }}% - Avg Capacity -
-
- {{ summary.totalActiveTasks }} - Active Tasks -
- @if (summary.certificatesExpiringSoon > 0) { -
- {{ summary.certificatesExpiringSoon }} - Certs Expiring -
- } -
- } - - -
- - -
- -
- -
- @for (status of statusOptions; track status.value) { - - } -
-
- - - @if (store.uniqueEnvironments().length > 1) { -
- - -
- } - - - @if (store.uniqueVersions().length > 1) { -
- - -
- } - - -
-
- - -
- - {{ store.filteredAgents().length }} of {{ store.agents().length }} agents - -
- - - -
-
- - - @if (store.isLoading()) { - - } - - - @if (store.error()) { -
-

{{ store.error() }}

- -
- } - - - @if (!store.isLoading() && !store.error()) { - @if (store.filteredAgents().length > 0) { - - @if (viewMode() === 'grid') { -
- @for (agent of store.filteredAgents(); track agent.id) { - - } -
- } - - - @if (viewMode() === 'heatmap') { - - } - - - @if (viewMode() === 'table') { - - } - } @else { -
- @if (hasActiveFilters()) { -

No agents match your filters

- - } @else { -

No agents registered yet

- - } -
- } - } - - - @if (store.lastRefresh()) { -
- Last updated: {{ formatRefreshTime(store.lastRefresh()!) }} -
- } -
- `, - 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('grid'); - readonly selectedStatuses = signal([]); - - 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(); - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/agents/agent-onboard-wizard.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/agents/agent-onboard-wizard.component.spec.ts deleted file mode 100644 index 86a595ea4..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/agent-onboard-wizard.component.spec.ts +++ /dev/null @@ -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; - 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(); - }); - }); -}); diff --git a/src/Web/StellaOps.Web/src/app/features/agents/agent-onboard-wizard.component.ts b/src/Web/StellaOps.Web/src/app/features/agents/agent-onboard-wizard.component.ts deleted file mode 100644 index bf7a3ab53..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/agent-onboard-wizard.component.ts +++ /dev/null @@ -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: ` -
- -
- - Back to Fleet - -

Add New Agent

-
- - - - - -
- @switch (currentStep()) { - @case ('environment') { -
-

Select Environment

-

Choose the target environment for this agent.

- -
- @for (env of environments; track env.id) { - - } -
-
- } - @case ('configure') { -
-

Configure Agent

-

Set up agent parameters.

- -
- - -
- -
- - -
-
- } - @case ('install') { -
-

Install Agent

-

Run the following command on the target machine:

- -
-
{{ installCommand() }}
- -
- -
-

Requirements

-
    -
  • Docker or Podman installed
  • -
  • Network access to control plane
  • -
  • Minimum 2GB RAM, 2 CPU cores
  • -
-
-
- } - @case ('verify') { -
-

Verify Connection

-

Waiting for agent to connect...

- -
- @if (isVerifying()) { -
-

Listening for agent heartbeat...

- } @else if (isVerified()) { -
-

Agent connected successfully!

- } @else { -
-

Agent not yet connected

- - } -
- -
- Connection issues? -
    -
  • Check firewall allows outbound HTTPS
  • -
  • Verify environment variables are set correctly
  • -
  • Check agent logs: docker logs stella-agent
  • -
-
-
- } - @case ('complete') { -
-
-

Agent Onboarded!

-

Your agent is now ready to receive tasks.

- - -
- } - } -
- - - @if (currentStep() !== 'complete') { -
- - -
- } -
- `, - 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('environment'); - readonly selectedEnvironment = signal(''); - 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="" \\ - 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(); - } - } - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/agents/agents.routes.ts b/src/Web/StellaOps.Web/src/app/features/agents/agents.routes.ts deleted file mode 100644 index 4694c1665..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/agents.routes.ts +++ /dev/null @@ -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', - }, -]; diff --git a/src/Web/StellaOps.Web/src/app/features/agents/components/agent-action-modal/agent-action-modal.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/agents/components/agent-action-modal/agent-action-modal.component.spec.ts deleted file mode 100644 index 3a747c94a..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/components/agent-action-modal/agent-action-modal.component.spec.ts +++ /dev/null @@ -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; - - const createMockAgent = (overrides: Partial = {}): 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(); - }); - }); -}); diff --git a/src/Web/StellaOps.Web/src/app/features/agents/components/agent-action-modal/agent-action-modal.component.ts b/src/Web/StellaOps.Web/src/app/features/agents/components/agent-action-modal/agent-action-modal.component.ts deleted file mode 100644 index da6a98362..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/components/agent-action-modal/agent-action-modal.component.ts +++ /dev/null @@ -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 = { - 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()) { - - } - `, - 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(); - - /** The agent to perform action on */ - readonly agent = input(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(); - - /** Emits when user cancels or closes the modal */ - readonly cancel = output(); - - // Local state - readonly confirmInput = signal(''); - readonly inputError = signal(null); - - readonly config = computed(() => { - 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(); - } - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/agents/components/agent-card/agent-card.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/agents/components/agent-card/agent-card.component.spec.ts deleted file mode 100644 index 862c59168..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/components/agent-card/agent-card.component.spec.ts +++ /dev/null @@ -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; - - const createMockAgent = (overrides: Partial = {}): 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'); - }); - }); -}); diff --git a/src/Web/StellaOps.Web/src/app/features/agents/components/agent-card/agent-card.component.ts b/src/Web/StellaOps.Web/src/app/features/agents/components/agent-card/agent-card.component.ts deleted file mode 100644 index e6c352bd5..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/components/agent-card/agent-card.component.ts +++ /dev/null @@ -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: ` -
- -
-
- -
-
-

{{ agent().displayName || agent().name }}

- {{ truncateId(agent().id) }} -
- -
- - -
- {{ agent().environment }} - v{{ agent().version }} -
- - -
-
- Capacity - {{ agent().capacityPercent }}% -
-
-
-
-
- - -
-
- Active Tasks - {{ agent().activeTasks }} -
-
- Queued - {{ agent().taskQueueDepth }} -
-
- Heartbeat - {{ heartbeatLabel() }} -
-
- - - @if (hasCertificateWarning()) { -
- - Certificate expires in {{ agent().certificate?.daysUntilExpiry }} days -
- } -
- `, - 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(); - - /** Whether the card is selected */ - readonly selected = input(false); - - /** Emits when the card is clicked */ - readonly cardClick = output(); - - /** 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 }); - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/agents/components/agent-health-tab/agent-health-tab.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/agents/components/agent-health-tab/agent-health-tab.component.spec.ts deleted file mode 100644 index a9ada1a78..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/components/agent-health-tab/agent-health-tab.component.spec.ts +++ /dev/null @@ -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; - - const createMockCheck = (overrides: Partial = {}): 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(); - }); - }); -}); diff --git a/src/Web/StellaOps.Web/src/app/features/agents/components/agent-health-tab/agent-health-tab.component.ts b/src/Web/StellaOps.Web/src/app/features/agents/components/agent-health-tab/agent-health-tab.component.ts deleted file mode 100644 index aa6014bf4..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/components/agent-health-tab/agent-health-tab.component.ts +++ /dev/null @@ -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: ` -
- -
-

Health Checks

-
- -
-
- - - @if (checks().length > 0) { -
-
- {{ passCount() }} - Passed -
-
- {{ warnCount() }} - Warnings -
-
- {{ failCount() }} - Failed -
-
- } - - - @if (checks().length > 0) { -
- @for (check of checks(); track check.checkId) { -
-
- @switch (check.status) { - @case ('pass') { - - } - @case ('warn') { - - } - @case ('fail') { - - } - } -
-
-

{{ check.checkName }}

- @if (check.message) { -

{{ check.message }}

- } - - Checked {{ formatTime(check.lastChecked) }} - -
-
- -
-
- } -
- } @else if (!isRunning()) { -
-

No health check results available.

- -
- } - - - @if (checks().length > 0) { -
- View Check History -

Check history timeline coming soon...

-
- } -
- `, - 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([]); - - /** Whether checks are currently running */ - readonly isRunning = input(false); - - /** Emits when user wants to run all checks */ - readonly runChecks = output(); - - /** Emits when user wants to re-run a specific check */ - readonly rerunCheck = output(); - - // 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(); - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/agents/components/agent-tasks-tab/agent-tasks-tab.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/agents/components/agent-tasks-tab/agent-tasks-tab.component.spec.ts deleted file mode 100644 index 0d2249819..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/components/agent-tasks-tab/agent-tasks-tab.component.spec.ts +++ /dev/null @@ -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; - - const createMockTask = (overrides: Partial = {}): 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'); - }); - }); -}); diff --git a/src/Web/StellaOps.Web/src/app/features/agents/components/agent-tasks-tab/agent-tasks-tab.component.ts b/src/Web/StellaOps.Web/src/app/features/agents/components/agent-tasks-tab/agent-tasks-tab.component.ts deleted file mode 100644 index 42018c847..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/components/agent-tasks-tab/agent-tasks-tab.component.ts +++ /dev/null @@ -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: ` -
- -
-

Tasks

-
- @for (filterOption of filterOptions(); track filterOption.value) { - - } -
-
- - - @if (activeTasks().length > 0) { -
-

Active Queue ({{ activeTasks().length }})

-
- @for (task of activeTasks(); track task.taskId) { -
- {{ task.taskType }} - @if (task.progress !== undefined) { -
-
-
- } -
- } -
-
- } - - - @if (filteredTasks().length > 0) { -
- @for (task of filteredTasks(); track task.taskId) { -
- -
- @switch (task.status) { - @case ('running') { - - - Running - - } - @case ('pending') { - Pending - } - @case ('completed') { - Completed - } - @case ('failed') { - Failed - } - @case ('cancelled') { - Cancelled - } - } -
- - -
-
-

{{ task.taskType }}

- {{ truncateId(task.taskId) }} -
- - @if (task.releaseId) { -

- Release: - {{ task.releaseId }} -

- } - - @if (task.deploymentId) { -

- Deployment: - {{ task.deploymentId }} -

- } - - - @if (task.status === 'running' && task.progress !== undefined) { -
-
- {{ task.progress }}% -
- } - - - @if (task.status === 'failed' && task.errorMessage) { -
- - {{ task.errorMessage }} -
- } - - -
- @if (task.startedAt) { - Started: {{ formatTime(task.startedAt) }} - } - @if (task.completedAt) { - Completed: {{ formatTime(task.completedAt) }} - Duration: {{ calculateDuration(task.startedAt!, task.completedAt) }} - } -
-
- - -
- -
-
- } -
- } @else { -
- @if (filter() === 'all') { -

No tasks have been assigned to this agent yet.

- } @else { -

No {{ filter() }} tasks found.

- - } -
- } - - - @if (filteredTasks().length > 0 && hasMoreTasks()) { - - } -
- `, - 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([]); - - /** Whether there are more tasks to load */ - readonly hasMoreTasks = input(false); - - /** Emits when user wants to view task details */ - readonly viewDetails = output(); - - /** Emits when user wants to load more tasks */ - readonly loadMore = output(); - - // Local state - readonly filter = signal('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`; - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/agents/components/capacity-heatmap/capacity-heatmap.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/agents/components/capacity-heatmap/capacity-heatmap.component.spec.ts deleted file mode 100644 index b34cb623d..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/components/capacity-heatmap/capacity-heatmap.component.spec.ts +++ /dev/null @@ -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; - - const createMockAgent = (overrides: Partial = {}): 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'); - }); - }); -}); diff --git a/src/Web/StellaOps.Web/src/app/features/agents/components/capacity-heatmap/capacity-heatmap.component.ts b/src/Web/StellaOps.Web/src/app/features/agents/components/capacity-heatmap/capacity-heatmap.component.ts deleted file mode 100644 index 8f3054749..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/components/capacity-heatmap/capacity-heatmap.component.ts +++ /dev/null @@ -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: ` -
- -
-

Fleet Capacity

-
- -
-
- - -
- Utilization: -
- - <50% - - - 50-80% - - - 80-95% - - - >95% - -
-
- - - @if (groupBy() === 'none') { -
- @for (agent of agents(); track agent.id) { - - } -
- } @else { - - @for (group of groupedAgents(); track group.key) { -
-

- {{ group.key }} - ({{ group.agents.length }}) -

-
- @for (agent of group.agents; track agent.id) { - - } -
-
- } - } - - - @if (hoveredAgent(); as agent) { - - } - - -
-
- {{ avgCapacity() }}% - Avg Capacity -
-
- {{ highUtilizationCount() }} - High Utilization (>80%) -
-
- {{ criticalCount() }} - Critical (>95%) -
-
-
- `, - 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([]); - - /** Emits when an agent cell is clicked */ - readonly agentClick = output(); - - // Local state - readonly groupBy = signal('none'); - readonly hoveredAgent = signal(null); - - // Computed - readonly groupedAgents = computed(() => { - const agents = this.agents(); - const groupByValue = this.groupBy(); - - if (groupByValue === 'none') { - return [{ key: 'All', agents }]; - } - - const groups = new Map(); - - 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}`; - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/agents/components/fleet-comparison/fleet-comparison.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/agents/components/fleet-comparison/fleet-comparison.component.spec.ts deleted file mode 100644 index 154bbf839..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/components/fleet-comparison/fleet-comparison.component.spec.ts +++ /dev/null @@ -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; - - const createMockAgent = (overrides: Partial = {}): 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'); - }); - }); - }); -}); diff --git a/src/Web/StellaOps.Web/src/app/features/agents/components/fleet-comparison/fleet-comparison.component.ts b/src/Web/StellaOps.Web/src/app/features/agents/components/fleet-comparison/fleet-comparison.component.ts deleted file mode 100644 index 273dbf03d..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/components/fleet-comparison/fleet-comparison.component.ts +++ /dev/null @@ -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: ` -
- -
-
-

Fleet Comparison

- {{ agents().length }} agents -
-
- -
- - @if (showColumnMenu()) { -
-

Show Columns

- @for (col of columnConfigs(); track col.key) { - - } -
- } -
- - -
-
- - - @if (versionMismatchCount() > 0) { -
- - {{ versionMismatchCount() }} agents have version mismatches. - Latest version: {{ latestVersion() }} -
- } - - -
- - - - @for (col of visibleColumns(); track col.key) { - - } - - - - - @for (agent of sortedAgents(); track agent.id) { - - @for (col of visibleColumns(); track col.key) { - - } - - - } - -
- {{ col.label }} - @if (sortColumn() === col.key) { - - @if (sortDirection() === 'asc') { - - } @else { - - } - - } - Actions
- @switch (col.key) { - @case ('name') { -
- - {{ agent.displayName || agent.name }} - {{ truncateId(agent.id) }} -
- } - @case ('environment') { - {{ agent.environment }} - } - @case ('status') { - - {{ getStatusLabel(agent.status) }} - - } - @case ('version') { - - v{{ agent.version }} - @if (agent.version !== latestVersion()) { - - } - - } - @case ('capacity') { -
-
-
-
- {{ agent.capacityPercent }}% -
- } - @case ('tasks') { - - {{ agent.activeTasks }} / {{ agent.taskQueueDepth }} - - } - @case ('heartbeat') { - - {{ formatHeartbeat(agent.lastHeartbeat) }} - - } - @case ('certExpiry') { - @if (agent.certificate) { - - {{ agent.certificate.daysUntilExpiry }}d - - } @else { - N/A - } - } - } -
- -
-
- - -
- - Showing {{ sortedAgents().length }} agents - @if (versionMismatchCount() > 0) { - · {{ versionMismatchCount() }} version mismatches - } - -
-
- `, - 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([]); - - /** Emits when view agent is clicked */ - readonly viewAgent = output(); - - // Local state - readonly sortColumn = signal('name'); - readonly sortDirection = signal('asc'); - readonly showColumnMenu = signal(false); - readonly columnConfigs = signal([ - { 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); - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/agents/index.ts b/src/Web/StellaOps.Web/src/app/features/agents/index.ts deleted file mode 100644 index 2026a8751..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/index.ts +++ /dev/null @@ -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'; diff --git a/src/Web/StellaOps.Web/src/app/features/agents/models/agent.models.spec.ts b/src/Web/StellaOps.Web/src/app/features/agents/models/agent.models.spec.ts deleted file mode 100644 index c60067932..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/models/agent.models.spec.ts +++ /dev/null @@ -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(); - }); - }); -}); diff --git a/src/Web/StellaOps.Web/src/app/features/agents/models/agent.models.ts b/src/Web/StellaOps.Web/src/app/features/agents/models/agent.models.ts deleted file mode 100644 index 91aefce92..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/models/agent.models.ts +++ /dev/null @@ -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`; -} diff --git a/src/Web/StellaOps.Web/src/app/features/agents/services/agent-realtime.service.ts b/src/Web/StellaOps.Web/src/app/features/agents/services/agent-realtime.service.ts deleted file mode 100644 index c729b1c1e..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/services/agent-realtime.service.ts +++ /dev/null @@ -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 | null = null; - private readonly destroy$ = new Subject(); - private readonly events$ = new Subject(); - private reconnectAttempts = 0; - private readonly maxReconnectAttempts = 5; - private readonly reconnectDelay = 3000; - - // Reactive state - readonly connectionStatus = signal('disconnected'); - readonly lastEventTime = signal(null); - readonly recentEvents = signal([]); - 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 = 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({ - 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 { - 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((sub) => sub.next(event)) : EMPTY - ) - ); - } - - /** - * Subscribe to specific event types - */ - subscribeToEventType(eventType: AgentEventType): Observable { - return this.events$.pipe( - takeUntil(this.destroy$), - switchMap((event) => - event.type === eventType ? new Observable((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'); - } - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/agents/services/agent.store.ts b/src/Web/StellaOps.Web/src/app/features/agents/services/agent.store.ts deleted file mode 100644 index fe48377cb..000000000 --- a/src/Web/StellaOps.Web/src/app/features/agents/services/agent.store.ts +++ /dev/null @@ -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; // 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(initialState); - private readonly destroy$ = new Subject(); - - // 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 = { - 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('/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('/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(`/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(`/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(`/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(`/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): void { - this.state.update((current) => ({ ...current, ...partial })); - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/pack-registry/pack-registry-browser.component.ts b/src/Web/StellaOps.Web/src/app/features/pack-registry/pack-registry-browser.component.ts index 190ad8ce9..f3e4acaf4 100644 --- a/src/Web/StellaOps.Web/src/app/features/pack-registry/pack-registry-browser.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/pack-registry/pack-registry-browser.component.ts @@ -19,8 +19,8 @@ import { ContextHeaderComponent } from '../../shared/ui/context-header/context-h template: `
- - - - - - - - - - - -
-
-
-

Blocking

-

Open the highest-impact drills first. These cards route to the owning child page.

-
-
- -
- @for (item of blockingCards; track item.id) { - -
- {{ item.metric }} - Open -
-

{{ item.title }}

-

{{ item.detail }}

-
- } -
-
- - - -
-
- - - @for (group of overviewGroups; track group.id) { - - } -
- -
-
-

Pending Operator Actions

-
    - @for (item of pendingActions; track item.id) { -
  • - {{ item.title }} - {{ item.detail }} - {{ item.owner }} -
  • - } -
-
-
-
- - @if (refreshedAt()) { -

- Snapshot refreshed at {{ refreshedAt() }}. -

- } -
diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.scss b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.scss deleted file mode 100644 index 0752b0fb1..000000000 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.scss +++ /dev/null @@ -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; - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts deleted file mode 100644 index d100354c6..000000000 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts +++ /dev/null @@ -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(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()); - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-overview-page.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-overview-page.component.ts index 80553b42c..094996243 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-overview-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-overview-page.component.ts @@ -188,8 +188,8 @@ export class PolicyDecisioningOverviewPageComponent { readonly cards = computed(() => [ { 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', }, diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-pack-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-pack-shell.component.ts index 97bf4a3bf..05d809109 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-pack-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-pack-shell.component.ts @@ -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 = { + 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'; } } diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.ts index c476cdc79..128492e20 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.ts @@ -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' }, diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts index 838c5649b..6f49b6c30 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts @@ -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' }, ]; diff --git a/src/Web/StellaOps.Web/src/app/features/policy-studio/builder/gate-config-forms.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-studio/builder/gate-config-forms.component.ts new file mode 100644 index 000000000..329e39aed --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/policy-studio/builder/gate-config-forms.component.ts @@ -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; +} + +/** + * 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: ` +
+
+ + {{ description }} +
+ + @if (gate.enabled) { +
+ @switch (gate.type) { + @case ('CvssThresholdGate') { +
+ +
+ + {{ gate.config['threshold'] ?? 9.0 }} +
+
+
+ + +
+ } + + @case ('SignatureRequiredGate') { +
+ +
+
+ +
+ } + + @case ('EvidenceFreshnessGate') { +
+ + +
+
+ + +
+ } + + @case ('SbomPresenceGate') { +
+ +
+ } + + @case ('MinimumConfidenceGate') { +
+ +
+ + {{ gate.config['min_confidence_pct'] ?? 80 }}% +
+
+ } + + @case ('UnknownsBudgetGate') { +
+ +
+ + {{ ((gate.config['max_fraction'] ?? 0.6) as number * 100).toFixed(0) }}% +
+
+ } + + @case ('ReachabilityRequirementGate') { +
+ +
+
+ + +
+ } + + @default { +
+

Configure this gate via YAML mode for advanced options.

+
+ } + } +
+ } +
+ `, + 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(); + + 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 }); + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/policy-studio/builder/policy-builder.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-studio/builder/policy-builder.component.ts new file mode 100644 index 000000000..41efa8e51 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/policy-studio/builder/policy-builder.component.ts @@ -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> = { + 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: ` +
+ + + + +
+ +
+ + @if (yamlMode()) { + +
+
{{ generatedYaml() }}
+
+ } @else { + + @if (step() === 'name') { +
+

Name your policy

+

Give it a name that describes where or how it's used.

+ +
+ + +
+
+ + +
+
+ + + What happens when no rule matches. "Block" is safest. +
+ +
+ +
+
+ } + + + @if (step() === 'gates') { +
+

Select and configure gates

+

Toggle on the checks you want. Configure thresholds for each.

+ +
+ @for (gateForm of gateFormStates(); track gateForm.type) { + + } +
+ +
+ + +
+
+ } + + + @if (step() === 'review') { +
+

Review your policy

+ +
+

{{ policyName() }}

+ @if (policyDescription()) { +

{{ policyDescription() }}

+ } +

Default action: {{ defaultAction() }}

+
+ +
+

This policy will:

+
    + @for (line of summaryLines(); track line) { +
  • {{ line }}
  • + } +
+
+ +
+ + +
+
+ } + } +
+ `, + 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(); + + readonly steps = [ + { id: 'name' as BuilderStep, label: 'Name' }, + { id: 'gates' as BuilderStep, label: 'Gates' }, + { id: 'review' as BuilderStep, label: 'Review' }, + ]; + + readonly step = signal('name'); + readonly yamlMode = signal(false); + + readonly policyName = signal(''); + readonly policyDescription = signal(''); + readonly defaultAction = signal<'allow' | 'warn' | 'block'>('block'); + + readonly gateFormStates = signal( + 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 }, + })), + }, + }; + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/policy/policy-studio.component.ts b/src/Web/StellaOps.Web/src/app/features/policy/policy-studio.component.ts index 9fab80a34..7831886d1 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy/policy-studio.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy/policy-studio.component.ts @@ -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[] = [ } - + @if (viewMode() === 'packs') {
-

Policy Packs

+

Release Policies

diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/components/target-list/target-list.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/components/target-list/target-list.component.ts index 95f235d75..ccfed0af3 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/components/target-list/target-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/components/target-list/target-list.component.ts @@ -40,6 +40,7 @@ import { Type Agent Health + Runtime Last Check Actions @@ -70,6 +71,15 @@ import { {{ target.healthStatus | titlecase }} + + + {{ getRuntimeVerificationLabel(target) }} + + {{ target.lastHealthCheck ? formatDate(target.lastHealthCheck) : 'Never' }}
} @@ -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([ + { 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 }, diff --git a/src/Web/StellaOps.Web/src/app/features/signals/models/signals-runtime-dashboard.models.ts b/src/Web/StellaOps.Web/src/app/features/signals/models/signals-runtime-dashboard.models.ts deleted file mode 100644 index 42fccbcc2..000000000 --- a/src/Web/StellaOps.Web/src/app/features/signals/models/signals-runtime-dashboard.models.ts +++ /dev/null @@ -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[]; -} diff --git a/src/Web/StellaOps.Web/src/app/features/signals/services/signals-runtime-dashboard.service.ts b/src/Web/StellaOps.Web/src/app/features/signals/services/signals-runtime-dashboard.service.ts deleted file mode 100644 index 79d16d80b..000000000 --- a/src/Web/StellaOps.Web/src/app/features/signals/services/signals-runtime-dashboard.service.ts +++ /dev/null @@ -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 { - 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(); - - 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): 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): 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, 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, 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; - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/signals/signals-runtime-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/signals/signals-runtime-dashboard.component.ts deleted file mode 100644 index a37545f60..000000000 --- a/src/Web/StellaOps.Web/src/app/features/signals/signals-runtime-dashboard.component.ts +++ /dev/null @@ -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: ` -
-
-
-

Signals Runtime Dashboard

-

Per-host probe health and signal ingestion runtime metrics.

-
- -
- - @if (error()) { - - } - - @if (vm(); as dashboard) { -
- - - -
- -
-
-

By provider

-
    - @for (item of dashboard.providerSummary; track item.provider) { -
  • - {{ item.provider }} - {{ item.total }} -
  • - } -
-
- -
-

By status

-
    - @for (item of dashboard.statusSummary; track item.status) { -
  • - {{ item.status }} - {{ item.total }} -
  • - } -
-
-
- -
-
-

Probe health by host

- Snapshot generated {{ dashboard.generatedAt | date:'medium' }} -
- - @if (dashboard.hostProbes.length === 0) { -

No probe telemetry available in the current signal window.

- } @else { - - - - - - - - - - - - - @for (probe of dashboard.hostProbes; track probe.host + '-' + probe.runtime) { - - - - - - - - - } - -
HostRuntimeStatusLatencySamplesLast seen
{{ probe.host }}{{ probe.runtime }} - {{ probe.status }} - {{ formatLatency(probe) }}{{ probe.sampleCount }}{{ probe.lastSeenAt | date:'short' }}
- } -
- } -
- `, - 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(null); - readonly loading = signal(false); - readonly error = signal(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`; - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/signals/signals.routes.ts b/src/Web/StellaOps.Web/src/app/features/signals/signals.routes.ts deleted file mode 100644 index b3419f45f..000000000 --- a/src/Web/StellaOps.Web/src/app/features/signals/signals.routes.ts +++ /dev/null @@ -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), - }, -]; diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts index 5614720da..12b0087cb 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts @@ -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> = { home: 'home', 'release-control': 'release-control', @@ -78,6 +87,13 @@ const SIDEBAR_FALLBACK_TOOLTIPS: Readonly> = { '/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> = { } + @if (!effectiveCollapsed && group.description) { +

{{ group.description }}

+ }
@for (section of group.sections; track section.id; let sectionFirst = $first) { @@ -168,6 +187,9 @@ const SIDEBAR_FALLBACK_TOOLTIPS: Readonly> = { [icon]="section.icon" [route]="section.route" [badge]="section.sectionBadge" + [tooltip]="section.tooltip" + [badgeTooltip]="section.badgeTooltip" + [recommendationLabel]="section.recommendationLabel ?? null" [collapsed]="effectiveCollapsed" >
@@ -178,6 +200,9 @@ const SIDEBAR_FALLBACK_TOOLTIPS: Readonly> = { [icon]="child.icon" [route]="child.route" [badge]="child.badge ?? null" + [tooltip]="child.tooltip" + [badgeTooltip]="child.badgeTooltip" + [recommendationLabel]="child.recommendationLabel ?? null" [isChild]="true" [collapsed]="effectiveCollapsed" > @@ -192,6 +217,9 @@ const SIDEBAR_FALLBACK_TOOLTIPS: Readonly> = { [icon]="section.icon" [route]="section.route" [badge]="section.sectionBadge" + [tooltip]="section.tooltip" + [badgeTooltip]="section.badgeTooltip" + [recommendationLabel]="section.recommendationLabel ?? null" [collapsed]="effectiveCollapsed" > } @@ -506,6 +534,14 @@ const SIDEBAR_FALLBACK_TOOLTIPS: Readonly> = { 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(null); private readonly pendingApprovalsBadgeLoading = signal(false); + private readonly canonicalItemsByRoute = this.buildCanonicalItemMap(); + private readonly canonicalGroupsById = new Map( + 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(() => { + 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 { + const itemsByRoute = new Map(); + + 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; diff --git a/src/Web/StellaOps.Web/src/app/routes/administration.routes.ts b/src/Web/StellaOps.Web/src/app/routes/administration.routes.ts index f6f7568e9..9e180e35d 100644 --- a/src/Web/StellaOps.Web/src/app/routes/administration.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/administration.routes.ts @@ -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', }, diff --git a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts index 4cd403dd5..38f19d8ac 100644 --- a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts @@ -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, diff --git a/src/Web/StellaOps.Web/src/app/routes/ops.routes.ts b/src/Web/StellaOps.Web/src/app/routes/ops.routes.ts index 8f71d8088..968a0b1ce 100644 --- a/src/Web/StellaOps.Web/src/app/routes/ops.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/ops.routes.ts @@ -173,7 +173,7 @@ export const OPS_ROUTES: Routes = [ }, { path: 'signals', - redirectTo: 'operations/signals', + redirectTo: 'operations/doctor', pathMatch: 'full', }, { diff --git a/src/Web/StellaOps.Web/src/app/routes/platform-ops.routes.ts b/src/Web/StellaOps.Web/src/app/routes/platform-ops.routes.ts index 12e5332ef..01c051814 100644 --- a/src/Web/StellaOps.Web/src/app/routes/platform-ops.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/platform-ops.routes.ts @@ -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', }, ]; diff --git a/src/Web/StellaOps.Web/src/app/shared/components/stella-helper/stella-helper.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/stella-helper/stella-helper.component.ts index d093a143f..e4c2cda0a 100644 --- a/src/Web/StellaOps.Web/src/app/shared/components/stella-helper/stella-helper.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/components/stella-helper/stella-helper.component.ts @@ -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: ` - @if (prefs().dismissed && !forceShow()) { + @if (stellaPrefs.isDismissed() && !forceShow()) { + +
+ + @if (muteDropdownOpen()) { + + } +
@@ -144,6 +169,15 @@ const DEFAULTS: HelperPreferences = {
} + + @if (assistant.activeConversationId() && !assistantDrawer.isOpen()) { + + }
@@ -212,6 +246,7 @@ const DEFAULTS: HelperPreferences = {
@@ -258,6 +293,14 @@ const DEFAULTS: HelperPreferences = { @if (!bubbleOpen()) { } + @if (assistant.activeConversationId() && !assistantDrawer.isOpen()) { + + } @if (assistant.chatAnimationState() === 'thinking') {