Manual approvals required: {{ promotionSummary().manualApprovalCount }}
- Open Promotion Paths + Open Promotion Pathsdiff --git a/docs/implplan/SPRINT_20260310_004_FE_setup_topology_live_action_sweep.md b/docs/implplan/SPRINT_20260310_004_FE_setup_topology_live_action_sweep.md
new file mode 100644
index 000000000..437413a17
--- /dev/null
+++ b/docs/implplan/SPRINT_20260310_004_FE_setup_topology_live_action_sweep.md
@@ -0,0 +1,74 @@
+# Sprint 20260310_004 - Setup Topology Live Action Sweep
+
+## Topic & Scope
+- Verify the Setup/Topology slice against the rebuilt `https://stella-ops.local` stack with real Playwright interactions, not route-only checks.
+- Treat scope preservation as part of correctness: topology tabs and operator actions must keep the active tenant/region/environment/time-window context.
+- Working directory: `src/Web/StellaOps.Web`.
+- Expected evidence: live Playwright sweep JSON, focused Angular tests, execution log updates, and a scoped commit.
+
+## Dependencies & Concurrency
+- Depends on the rebuilt web bundle and healthy frontdoor stack already running through `devops/compose/docker-compose.stella-ops.yml`.
+- Safe to run in parallel with backend/search work as long as edits stay inside `src/Web/StellaOps.Web` and this sprint file.
+
+## Documentation Prerequisites
+- `docs/qa/feature-checks/FLOW.md`
+- `docs/modules/platform/architecture-overview.md`
+
+## Delivery Tracker
+
+### FE-TOPO-LIVE-001 - Capture live topology action evidence
+Status: DONE
+Dependency: none
+Owners: QA, Developer
+Task description:
+- Add a dedicated live Playwright script for Setup/Topology that exercises the shell tabs, overview CTAs, environment inventory actions, and environment detail actions on the authenticated frontdoor.
+- The sweep must fail when routes misnavigate, when runtime errors surface, or when actions drop active scope query parameters that should remain stable across topology flows.
+
+Completion criteria:
+- [x] A committed live sweep script exists under `src/Web/StellaOps.Web/scripts/`.
+- [x] The sweep captures fresh evidence under `src/Web/StellaOps.Web/output/playwright/`.
+- [x] Any failures are diagnosed to code-level root causes before implementation changes begin.
+
+### FE-TOPO-LIVE-002 - Repair topology scope-preserving navigation
+Status: DONE
+Dependency: FE-TOPO-LIVE-001
+Owners: Developer
+Task description:
+- Apply the existing Stella Ops scope-preserving navigation pattern to the topology shell and topology operator actions so the live context survives shell navigation and drilldowns.
+- Keep the fix scoped to topology unless a broader shared change is clearly required and low risk.
+
+Completion criteria:
+- [x] Topology shell navigation preserves active scope.
+- [x] Topology CTA/drilldown actions preserve active scope while adding route-specific parameters.
+- [x] Focused tests cover the changed navigation contracts.
+
+### FE-TOPO-LIVE-003 - Reverify live topology slice after fixes
+Status: DONE
+Dependency: FE-TOPO-LIVE-002
+Owners: QA
+Task description:
+- Rebuild the web bundle if needed, sync it into the live stack, rerun the exact topology sweep, and confirm the slice is clean.
+
+Completion criteria:
+- [x] The topology sweep passes with zero failed actions.
+- [x] The topology sweep reports zero runtime issues.
+- [x] Execution Log records the before/after evidence and the commit hash.
+
+## Execution Log
+| Date (UTC) | Update | Owner |
+| --- | --- | --- |
+| 2026-03-10 | Sprint created for the next live QA/developer iteration on Setup/Topology after the clean canonical frontdoor, policy, releases, notifications, and mission-control sweeps. | QA |
+| 2026-03-10 | First authenticated live topology sweep failed 25 actions with 0 runtime issues. Every failure traced to scope loss across topology shell tabs, CTA/drilldown links, or environment detail operator actions; two tab failures were harness selector collisions on partial `Security` and `Evidence` matches. | QA |
+| 2026-03-10 | Root cause analysis found two product defects: Topology links were not consistently using scope-preserving navigation, and `SecurityFindingsPageComponent.reloadFromFilters()` rewrote `/security/triage` without merge semantics, stripping the incoming topology scope. | Developer |
+| 2026-03-10 | Added focused navigation regressions for topology and security findings. Focused Angular run passed `6/6` assertions across `2` spec files. | Test Automation |
+| 2026-03-10 | Rebuilt the web bundle, synced `dist/stellaops-web/browser` into `compose_console-dist`, and reran the same live topology sweep. Final evidence is clean with `0` failed actions and `0` runtime issues in `src/Web/StellaOps.Web/output/playwright/live-setup-topology-action-sweep.json`. Commit hash pending local commit. | QA |
+
+## Decisions & Risks
+- Decision: treat scope preservation as a correctness requirement in topology because the active platform context changes the data surface on every page and drilldown.
+- Risk: `TabbedNavComponent` is shared across multiple shells. If topology needs scope-preserving shell tabs, prefer an opt-in contract instead of a silent repo-wide behavior change.
+- Decision: `TabItem` now supports opt-in `queryParamsHandling`, and Topology explicitly sets `merge` on its shell tabs. This preserves scope without changing every other shared tabbed navigation surface.
+- Decision: fixing the destination rewrite in `/security/triage` is mandatory. Accepting a scoped entry link is not sufficient if the landing page immediately discards the topology context.
+
+## Next Checkpoints
+- Capture the first failing topology live sweep.
+- Repair the navigation contracts and re-run the same sweep before committing.
diff --git a/docs/modules/ui/v2-rewire/pack-22.md b/docs/modules/ui/v2-rewire/pack-22.md
index d29dbbae6..1fd2e6762 100644
--- a/docs/modules/ui/v2-rewire/pack-22.md
+++ b/docs/modules/ui/v2-rewire/pack-22.md
@@ -156,6 +156,7 @@ Implementation update (2026-02-20):
- `/topology/promotion-paths`.
- Generic inventory fallback remains only for non-primary Topology routes (`/topology/workflows`, `/topology/gate-profiles`).
- Region/environment global multi-select filters propagate as comma-joined query scope on Topology reads.
+- Topology shell tabs, drilldowns, and downstream triage handoffs preserve the active query scope so operator flows stay bound to the same tenant/region/environment/time-window context.
### Operations
diff --git a/src/Web/StellaOps.Web/scripts/live-setup-topology-action-sweep.mjs b/src/Web/StellaOps.Web/scripts/live-setup-topology-action-sweep.mjs
new file mode 100644
index 000000000..149c92020
--- /dev/null
+++ b/src/Web/StellaOps.Web/scripts/live-setup-topology-action-sweep.mjs
@@ -0,0 +1,522 @@
+#!/usr/bin/env node
+
+import { mkdir, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { chromium } from 'playwright';
+
+import { authenticateFrontdoor, createAuthenticatedContext } from './live-frontdoor-auth.mjs';
+
+const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const outputDir = path.join(webRoot, 'output', 'playwright');
+const outputPath = path.join(outputDir, 'live-setup-topology-action-sweep.json');
+const authStatePath = path.join(outputDir, 'live-setup-topology-action-sweep.state.json');
+const authReportPath = path.join(outputDir, 'live-setup-topology-action-sweep.auth.json');
+const topologyScope = {
+ tenant: 'demo-prod',
+ regions: 'us-east',
+ environments: 'stage',
+ timeWindow: '7d',
+};
+const topologyScopeQuery = new URLSearchParams(topologyScope).toString();
+const STEP_TIMEOUT_MS = 30_000;
+
+function isStaticAsset(url) {
+ return /\.(?:css|js|map|png|jpg|jpeg|svg|woff2?)(?:$|\?)/i.test(url);
+}
+
+function createRuntime() {
+ return {
+ consoleErrors: [],
+ pageErrors: [],
+ requestFailures: [],
+ responseErrors: [],
+ };
+}
+
+function attachRuntimeObservers(page, runtime) {
+ page.on('console', (message) => {
+ if (message.type() === 'error') {
+ runtime.consoleErrors.push({
+ page: page.url(),
+ text: message.text(),
+ });
+ }
+ });
+
+ page.on('pageerror', (error) => {
+ if (page.url() === 'about:blank' && String(error).includes('sessionStorage')) {
+ return;
+ }
+
+ runtime.pageErrors.push({
+ page: page.url(),
+ text: error instanceof Error ? error.message : String(error),
+ });
+ });
+
+ page.on('requestfailed', (request) => {
+ if (isStaticAsset(request.url())) {
+ return;
+ }
+
+ runtime.requestFailures.push({
+ page: page.url(),
+ method: request.method(),
+ url: request.url(),
+ error: request.failure()?.errorText ?? 'unknown',
+ });
+ });
+
+ page.on('response', (response) => {
+ if (isStaticAsset(response.url())) {
+ return;
+ }
+
+ if (response.status() >= 400) {
+ runtime.responseErrors.push({
+ page: page.url(),
+ method: response.request().method(),
+ status: response.status(),
+ url: response.url(),
+ });
+ }
+ });
+}
+
+async function settle(page) {
+ await page.waitForLoadState('domcontentloaded', { timeout: 15_000 }).catch(() => {});
+ await page.waitForTimeout(750);
+
+ const loadingBanners = page.locator('text=/Loading /i');
+ const count = await loadingBanners.count().catch(() => 0);
+ if (count > 0) {
+ await loadingBanners.first().waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {});
+ }
+
+ await page.waitForTimeout(750);
+}
+
+async function headingText(page) {
+ const headings = page.locator('h1, h2, [data-testid="page-title"], .page-title');
+ const count = await headings.count();
+ for (let index = 0; index < Math.min(count, 5); index += 1) {
+ const text = (await headings.nth(index).innerText().catch(() => '')).trim();
+ if (text) {
+ return text;
+ }
+ }
+
+ return '';
+}
+
+async function captureSnapshot(page, label) {
+ const alerts = await page
+ .locator('[role="alert"], .banner--error, .error-banner, .toast, .notification')
+ .evaluateAll((nodes) =>
+ nodes
+ .map((node) => (node.textContent || '').trim().replace(/\s+/g, ' '))
+ .filter(Boolean)
+ .slice(0, 8),
+ )
+ .catch(() => []);
+
+ return {
+ label,
+ url: page.url(),
+ title: await page.title(),
+ heading: await headingText(page),
+ alerts,
+ };
+}
+
+async function persistSummary(summary) {
+ summary.lastUpdatedAtUtc = new Date().toISOString();
+ await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8');
+}
+
+function withScope(route) {
+ const separator = route.includes('?') ? '&' : '?';
+ return `https://stella-ops.local${route}${separator}${topologyScopeQuery}`;
+}
+
+async function navigate(page, route) {
+ await page.goto(withScope(route), {
+ waitUntil: 'domcontentloaded',
+ timeout: 30_000,
+ });
+ await settle(page);
+}
+
+function hasExpectedQuery(urlString, expectedQuery = {}) {
+ const url = new URL(urlString);
+ for (const [key, value] of Object.entries(expectedQuery)) {
+ if (url.searchParams.get(key) !== value) {
+ return false;
+ }
+ }
+ return true;
+}
+
+async function resolveLink(page, options) {
+ if (options.hrefIncludes) {
+ const candidates = page.locator(`a[href*="${options.hrefIncludes}"]`);
+ const count = await candidates.count();
+ for (let index = 0; index < count; index += 1) {
+ const candidate = candidates.nth(index);
+ const text = ((await candidate.innerText().catch(() => '')) || '').trim();
+ if (!options.name || text === options.name || text.includes(options.name)) {
+ return candidate;
+ }
+ }
+ }
+
+ if (options.name) {
+ const link = page.getByRole('link', { name: options.name }).first();
+ if ((await link.count()) > 0) {
+ return link;
+ }
+ }
+
+ return null;
+}
+
+async function clickLinkAction(page, route, options) {
+ await navigate(page, route);
+ const link = await resolveLink(page, options);
+ if (!link) {
+ return {
+ action: options.action,
+ ok: false,
+ reason: 'missing-link',
+ snapshot: await captureSnapshot(page, `missing:${options.action}`),
+ };
+ }
+
+ await link.click({ timeout: 10_000 });
+ await settle(page);
+
+ const url = new URL(page.url());
+ const ok = url.pathname === options.expectedPath && hasExpectedQuery(page.url(), options.expectedQuery);
+
+ return {
+ action: options.action,
+ ok,
+ finalUrl: page.url(),
+ snapshot: await captureSnapshot(page, `after:${options.action}`),
+ };
+}
+
+async function fillOverviewSearch(page) {
+ await navigate(page, '/setup/topology/overview');
+ const input = page.locator('#topology-overview-search');
+ await input.fill('stage');
+ await page.getByRole('button', { name: 'Go' }).click({ timeout: 10_000 });
+ await settle(page);
+
+ const ok =
+ new URL(page.url()).pathname === '/setup/topology/environments/stage/posture' &&
+ hasExpectedQuery(page.url(), topologyScope);
+
+ return {
+ action: 'overview-search:Go',
+ ok,
+ finalUrl: page.url(),
+ snapshot: await captureSnapshot(page, 'after:overview-search:Go'),
+ };
+}
+
+async function clickEnvironmentDetailTab(page, tabLabel, expectedText) {
+ await navigate(page, '/setup/topology/environments/stage/posture');
+ await page.getByRole('button', { name: tabLabel, exact: true }).click({ timeout: 10_000 });
+ await settle(page);
+
+ const ok = await page.locator(`text=${expectedText}`).first().isVisible().catch(() => false);
+ return {
+ action: `environment-tab:${tabLabel}`,
+ ok,
+ finalUrl: page.url(),
+ snapshot: await captureSnapshot(page, `after:environment-tab:${tabLabel}`),
+ };
+}
+
+async function verifyEmptyInventoryState(page, route, expectedText) {
+ await navigate(page, route);
+ const ok = await page.locator(`text=${expectedText}`).first().isVisible().catch(() => false);
+ return {
+ action: `empty-state:${route}`,
+ ok,
+ finalUrl: page.url(),
+ snapshot: await captureSnapshot(page, `after:empty-state:${route}`),
+ };
+}
+
+async function runAction(page, route, actionFactory) {
+ const startedAtUtc = new Date().toISOString();
+ const startedAt = Date.now();
+ process.stdout.write(`[live-setup-topology-action-sweep] START ${route}\n`);
+
+ try {
+ const result = await Promise.race([
+ actionFactory(page, route),
+ new Promise((_, reject) => {
+ setTimeout(() => reject(new Error(`Timed out after ${STEP_TIMEOUT_MS}ms.`)), STEP_TIMEOUT_MS);
+ }),
+ ]);
+
+ const completed = {
+ ...result,
+ startedAtUtc,
+ durationMs: Date.now() - startedAt,
+ };
+ process.stdout.write(
+ `[live-setup-topology-action-sweep] DONE ${completed.action} ok=${completed.ok} durationMs=${completed.durationMs}\n`,
+ );
+ return completed;
+ } catch (error) {
+ const failed = {
+ action: route,
+ ok: false,
+ reason: 'exception',
+ error: error instanceof Error ? error.message : String(error),
+ startedAtUtc,
+ durationMs: Date.now() - startedAt,
+ snapshot: await captureSnapshot(page, `failure:${route}`),
+ };
+ process.stdout.write(
+ `[live-setup-topology-action-sweep] FAIL ${route} error=${failed.error} durationMs=${failed.durationMs}\n`,
+ );
+ return failed;
+ }
+}
+
+async function main() {
+ await mkdir(outputDir, { recursive: true });
+
+ const authReport = await authenticateFrontdoor({
+ statePath: authStatePath,
+ reportPath: authReportPath,
+ });
+
+ const browser = await chromium.launch({
+ headless: true,
+ args: ['--ignore-certificate-errors', '--disable-dev-shm-usage'],
+ });
+
+ const context = await createAuthenticatedContext(browser, authReport, {
+ statePath: authStatePath,
+ });
+ const runtime = createRuntime();
+ context.on('page', (page) => attachRuntimeObservers(page, runtime));
+
+ const page = await context.newPage();
+ attachRuntimeObservers(page, runtime);
+
+ const actions = [
+ ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
+ action: 'tab:Map',
+ hrefIncludes: '/setup/topology/map',
+ expectedPath: '/setup/topology/map',
+ expectedQuery: topologyScope,
+ })],
+ ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
+ action: 'tab:Regions & Environments',
+ hrefIncludes: '/setup/topology/regions',
+ expectedPath: '/setup/topology/regions',
+ expectedQuery: topologyScope,
+ })],
+ ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
+ action: 'tab:Targets',
+ name: 'Targets',
+ hrefIncludes: '/setup/topology/targets',
+ expectedPath: '/setup/topology/targets',
+ expectedQuery: topologyScope,
+ })],
+ ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
+ action: 'tab:Hosts',
+ hrefIncludes: '/setup/topology/hosts',
+ expectedPath: '/setup/topology/hosts',
+ expectedQuery: topologyScope,
+ })],
+ ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
+ action: 'tab:Agents',
+ hrefIncludes: '/setup/topology/agents',
+ expectedPath: '/setup/topology/agents',
+ expectedQuery: topologyScope,
+ })],
+ ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
+ action: 'tab:Promotion Graph',
+ hrefIncludes: '/setup/topology/promotion-graph',
+ expectedPath: '/setup/topology/promotion-graph',
+ expectedQuery: topologyScope,
+ })],
+ ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
+ action: 'tab:Workflows',
+ hrefIncludes: '/setup/topology/workflows',
+ expectedPath: '/setup/topology/workflows',
+ expectedQuery: topologyScope,
+ })],
+ ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
+ action: 'tab:Gate Profiles',
+ hrefIncludes: '/setup/topology/gate-profiles',
+ expectedPath: '/setup/topology/gate-profiles',
+ expectedQuery: topologyScope,
+ })],
+ ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
+ action: 'tab:Connectivity',
+ hrefIncludes: '/setup/topology/connectivity',
+ expectedPath: '/setup/topology/connectivity',
+ expectedQuery: topologyScope,
+ })],
+ ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
+ action: 'tab:Runtime Drift',
+ hrefIncludes: '/setup/topology/runtime-drift',
+ expectedPath: '/setup/topology/runtime-drift',
+ expectedQuery: topologyScope,
+ })],
+ ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
+ action: 'overview:Open Regions & Environments',
+ name: 'Open Regions & Environments',
+ expectedPath: '/setup/topology/regions',
+ expectedQuery: topologyScope,
+ })],
+ ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
+ action: 'overview:Open Environment Inventory',
+ name: 'Open Environment Inventory',
+ expectedPath: '/setup/topology/environments',
+ expectedQuery: topologyScope,
+ })],
+ ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
+ action: 'overview:Open Agents',
+ name: 'Open Agents',
+ expectedPath: '/setup/topology/agents',
+ expectedQuery: topologyScope,
+ })],
+ ['/setup/topology/overview', (currentPage) => clickLinkAction(currentPage, '/setup/topology/overview', {
+ action: 'overview:Open Promotion Paths',
+ name: 'Open Promotion Paths',
+ expectedPath: '/setup/topology/promotion-graph',
+ expectedQuery: topologyScope,
+ })],
+ ['/setup/topology/overview', (currentPage) => fillOverviewSearch(currentPage)],
+ ['/setup/topology/environments', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments', {
+ action: 'environments:Open',
+ name: 'Open',
+ hrefIncludes: '/setup/topology/environments/stage/posture',
+ expectedPath: '/setup/topology/environments/stage/posture',
+ expectedQuery: topologyScope,
+ })],
+ ['/setup/topology/environments', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments', {
+ action: 'environments:Open Targets',
+ name: 'Open Targets',
+ hrefIncludes: '/setup/topology/targets',
+ expectedPath: '/setup/topology/targets',
+ expectedQuery: {
+ ...topologyScope,
+ environment: 'stage',
+ },
+ })],
+ ['/setup/topology/environments', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments', {
+ action: 'environments:Open Agents',
+ name: 'Open Agents',
+ hrefIncludes: '/setup/topology/agents',
+ expectedPath: '/setup/topology/agents',
+ expectedQuery: {
+ ...topologyScope,
+ environment: 'stage',
+ },
+ })],
+ ['/setup/topology/environments', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments', {
+ action: 'environments:Open Runs',
+ name: 'Open Runs',
+ hrefIncludes: '/releases/runs',
+ expectedPath: '/releases/runs',
+ expectedQuery: {
+ ...topologyScope,
+ environment: 'stage',
+ },
+ })],
+ ['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Overview', 'Operator Actions')],
+ ['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Targets', 'Targets')],
+ ['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Runs', 'Runs')],
+ ['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Agents', 'Agents')],
+ ['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Security', 'Security')],
+ ['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Evidence', 'Evidence')],
+ ['/setup/topology/environments/stage/posture', (currentPage) => clickEnvironmentDetailTab(currentPage, 'Data Quality', 'Data Quality')],
+ ['/setup/topology/environments/stage/posture', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments/stage/posture', {
+ action: 'environment-detail:Open Targets',
+ name: 'Open Targets',
+ hrefIncludes: '/setup/topology/targets',
+ expectedPath: '/setup/topology/targets',
+ expectedQuery: {
+ ...topologyScope,
+ environment: 'stage',
+ },
+ })],
+ ['/setup/topology/environments/stage/posture', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments/stage/posture', {
+ action: 'environment-detail:Open Agents',
+ name: 'Open Agents',
+ hrefIncludes: '/setup/topology/agents',
+ expectedPath: '/setup/topology/agents',
+ expectedQuery: {
+ ...topologyScope,
+ environment: 'stage',
+ },
+ })],
+ ['/setup/topology/environments/stage/posture', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments/stage/posture', {
+ action: 'environment-detail:Open Runs',
+ name: 'Open Runs',
+ hrefIncludes: '/releases/runs',
+ expectedPath: '/releases/runs',
+ expectedQuery: {
+ ...topologyScope,
+ environment: 'stage',
+ },
+ })],
+ ['/setup/topology/environments/stage/posture', (currentPage) => clickLinkAction(currentPage, '/setup/topology/environments/stage/posture', {
+ action: 'environment-detail:Open Security Triage',
+ name: 'Open Security Triage',
+ hrefIncludes: '/security/triage',
+ expectedPath: '/security/triage',
+ expectedQuery: {
+ ...topologyScope,
+ environment: 'stage',
+ },
+ })],
+ ['/setup/topology/targets', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/targets', 'No targets for current filters.')],
+ ['/setup/topology/hosts', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/hosts', 'No hosts for current filters.')],
+ ['/setup/topology/agents', (currentPage) => verifyEmptyInventoryState(currentPage, '/setup/topology/agents', 'No groups for current filters.')],
+ ];
+
+ const summary = {
+ generatedAtUtc: new Date().toISOString(),
+ baseUrl: 'https://stella-ops.local',
+ scope: topologyScope,
+ actions: [],
+ runtime: runtime,
+ };
+
+ for (const [route, actionFactory] of actions) {
+ const result = await runAction(page, route, actionFactory);
+ summary.actions.push(result);
+ await persistSummary(summary);
+ }
+
+ summary.failedActionCount = summary.actions.filter((action) => !action.ok).length;
+ summary.runtimeIssueCount =
+ runtime.consoleErrors.length +
+ runtime.pageErrors.length +
+ runtime.requestFailures.length +
+ runtime.responseErrors.length;
+
+ await persistSummary(summary);
+ await context.close();
+ await browser.close();
+
+ if (summary.failedActionCount > 0 || summary.runtimeIssueCount > 0) {
+ process.exitCode = 1;
+ }
+}
+
+await main();
diff --git a/src/Web/StellaOps.Web/src/app/core/testing/security-findings-page.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/security-findings-page.component.spec.ts
new file mode 100644
index 000000000..d9318bf64
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/core/testing/security-findings-page.component.spec.ts
@@ -0,0 +1,89 @@
+import { HttpClient } from '@angular/common/http';
+import { signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
+import { BehaviorSubject, of } from 'rxjs';
+
+import { PlatformContextStore } from '../context/platform-context.store';
+import { SecurityFindingsPageComponent } from '../../features/security/security-findings-page.component';
+
+describe('SecurityFindingsPageComponent', () => {
+ const queryParamMap$ = new BehaviorSubject(
+ convertToParamMap({
+ tenant: 'demo-prod',
+ regions: 'us-east',
+ environments: 'stage',
+ timeWindow: '7d',
+ environment: 'stage',
+ pivot: 'cve',
+ }),
+ );
+
+ beforeEach(() => {
+ queryParamMap$.next(
+ convertToParamMap({
+ tenant: 'demo-prod',
+ regions: 'us-east',
+ environments: 'stage',
+ timeWindow: '7d',
+ environment: 'stage',
+ pivot: 'cve',
+ }),
+ );
+
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({
+ imports: [SecurityFindingsPageComponent],
+ providers: [
+ provideRouter([]),
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ queryParamMap: queryParamMap$.asObservable(),
+ },
+ },
+ {
+ provide: HttpClient,
+ useValue: {
+ get: () => of({ items: [], total: 0, pivot: 'cve', facets: [] }),
+ },
+ },
+ {
+ provide: PlatformContextStore,
+ useValue: {
+ initialize: () => undefined,
+ contextVersion: signal(0),
+ selectedRegions: () => ['us-east'],
+ selectedEnvironments: () => ['stage'],
+ },
+ },
+ ],
+ });
+ });
+
+ it('merges the active query scope when triage filters rewrite the url', () => {
+ const fixture = TestBed.createComponent(SecurityFindingsPageComponent);
+ const component = fixture.componentInstance;
+ const router = TestBed.inject(Router);
+ const route = TestBed.inject(ActivatedRoute);
+ const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
+
+ fixture.detectChanges();
+ component.reloadFromFilters();
+
+ expect(navigateSpy).toHaveBeenCalledWith([], {
+ relativeTo: route,
+ replaceUrl: true,
+ queryParamsHandling: 'merge',
+ queryParams: {
+ pivot: 'cve',
+ q: null,
+ severity: null,
+ reachability: null,
+ vex: null,
+ exception: null,
+ blocks: null,
+ },
+ });
+ });
+});
diff --git a/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts
new file mode 100644
index 000000000..a70bdc7d6
--- /dev/null
+++ b/src/Web/StellaOps.Web/src/app/core/testing/topology-scope-links.component.spec.ts
@@ -0,0 +1,322 @@
+import { HttpClient } from '@angular/common/http';
+import { signal, Type } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { ActivatedRoute, Router, RouterLink, convertToParamMap, provideRouter } from '@angular/router';
+import { BehaviorSubject, of } from 'rxjs';
+
+import { PlatformContextStore } from '../context/platform-context.store';
+import { TopologyDataService } from '../../features/topology/topology-data.service';
+import { EnvironmentPosturePageComponent } from '../../features/topology/environment-posture-page.component';
+import { TopologyAgentsPageComponent } from '../../features/topology/topology-agents-page.component';
+import { TopologyEnvironmentDetailPageComponent } from '../../features/topology/topology-environment-detail-page.component';
+import { TopologyHostsPageComponent } from '../../features/topology/topology-hosts-page.component';
+import { TopologyMapPageComponent } from '../../features/topology/topology-map-page.component';
+import { TopologyOverviewPageComponent } from '../../features/topology/topology-overview-page.component';
+import { TopologyPromotionPathsPageComponent } from '../../features/topology/topology-promotion-paths-page.component';
+import { TopologyRegionsEnvironmentsPageComponent } from '../../features/topology/topology-regions-environments-page.component';
+import { TopologyShellComponent } from '../../features/topology/topology-shell.component';
+import { TopologyTargetsPageComponent } from '../../features/topology/topology-targets-page.component';
+
+const routeData$ = new BehaviorSubject
- {{ selected()!.cveId }} · {{ selected()!.componentName }} · {{ selected()!.region }}/{{ selected()!.environment }}
+ {{ selected()!.cveId }} · {{ selected()!.componentName }} · {{ selected()!.region }}/{{ selected()!.environment }}
Evidence Rail
@if (selected()) {
Targets under non-active agents: {{ impactedTargetsByAgentHealth() }}
- Open Agents + Open AgentsManual approvals required: {{ promotionSummary().manualApprovalCount }}
- Open Promotion Paths + Open Promotion PathsHost: {{ selectedHostName() }}
Agent: {{ selectedAgentName() }}
} @else {Select a target row to view its topology mapping details.
diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/tabbed-nav/tabbed-nav.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/tabbed-nav/tabbed-nav.component.ts index 69d6cfcde..3eb610b22 100644 --- a/src/Web/StellaOps.Web/src/app/shared/ui/tabbed-nav/tabbed-nav.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/ui/tabbed-nav/tabbed-nav.component.ts @@ -7,7 +7,7 @@ import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; -import { RouterLink, RouterLinkActive } from '@angular/router'; +import { QueryParamsHandling, RouterLink, RouterLinkActive } from '@angular/router'; export interface TabItem { id: string; @@ -15,6 +15,7 @@ export interface TabItem { icon?: string; route?: string | readonly unknown[]; // If set, uses router navigation queryParams?: Record