From 72746e2f7b9e6d4ba80eb5d3c474b9f674f46880 Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 10 Mar 2026 15:32:34 +0200 Subject: [PATCH] Align route ownership and sidebar surface exposure --- ...28_FE_route_surface_ownership_alignment.md | 55 ++++ .../live-route-surface-ownership-check.mjs | 306 ++++++++++++++++++ src/Web/StellaOps.Web/src/app/app.routes.ts | 4 +- .../app/core/navigation/navigation.config.ts | 2 +- .../mission-scope-links.component.spec.ts | 2 +- .../platform-ops-overview-page.component.ts | 9 - .../watchlist-page.component.spec.ts | 11 + .../watchlist/watchlist-page.component.ts | 4 + .../app-sidebar/app-sidebar.component.spec.ts | 89 ++++- .../app-sidebar/app-sidebar.component.ts | 101 +++--- .../src/app/routes/legacy-redirects.routes.ts | 7 +- .../src/app/routes/operations.routes.ts | 20 +- .../src/app/routes/releases.routes.ts | 24 +- .../routes/route-surface-ownership.spec.ts | 123 +++++++ .../src/app/routes/setup.routes.ts | 16 +- .../StellaOps.Web/tsconfig.spec.features.json | 1 + src/Web/StellaOps.Web/vitest.codex.config.ts | 3 + 17 files changed, 687 insertions(+), 90 deletions(-) create mode 100644 docs/implplan/SPRINT_20260310_028_FE_route_surface_ownership_alignment.md create mode 100644 src/Web/StellaOps.Web/scripts/live-route-surface-ownership-check.mjs create mode 100644 src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts diff --git a/docs/implplan/SPRINT_20260310_028_FE_route_surface_ownership_alignment.md b/docs/implplan/SPRINT_20260310_028_FE_route_surface_ownership_alignment.md new file mode 100644 index 000000000..eb9a2e384 --- /dev/null +++ b/docs/implplan/SPRINT_20260310_028_FE_route_surface_ownership_alignment.md @@ -0,0 +1,55 @@ +# Sprint 20260310_028 - FE Route Surface Ownership Alignment + +## Topic & Scope +- Align the live web shell so notifications, environment inventory, release health, and audit/security navigation point to the canonical owning surfaces. +- Repair the route-level and return-navigation regressions left behind by the in-flight cleanup, especially around Mission Control watchlist handoffs. +- Working directory: `src/Web/StellaOps.Web/src/app/routes`. +- Allowed coordination edits: `src/Web/StellaOps.Web/src/app/layout/app-sidebar`, `src/Web/StellaOps.Web/src/app/features/platform/ops`, `src/Web/StellaOps.Web/src/app/features/watchlist`, `src/Web/StellaOps.Web/src/app/core/testing`, `docs/implplan/SPRINT_20260310_028_FE_route_surface_ownership_alignment.md`. +- Expected evidence: focused Angular route/sidebar/watchlist specs, rebuilt web bundle, live Playwright route/action checks on the changed surfaces. + +## Dependencies & Concurrency +- Depends on the live compose stack from the scratch-setup iteration. +- Safe parallelism: do not mix unrelated page-revival edits into this slice; keep it bounded to route ownership, sidebar exposure, and watchlist handoff semantics. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/features/checked/web/left-rail-navigation-shell.md` +- `docs/features/checked/web/identity-watchlist-management-ui.md` +- `docs/features/checked/web/platform-setup-canonical-route-preservation-ui.md` + +## Delivery Tracker + +### FE-ROUTE-OWNERSHIP-001 - Align canonical route ownership and sidebar exposure +Status: DONE +Dependency: none +Owners: QA, Developer +Task description: +- The dirty web slice is consolidating notifications and environment inventory under Operations, adding Release Health under Releases, and re-grouping sidebar ownership between Release Control, Security & Audit, and Platform & Setup. +- Finish the cleanup by validating the route contracts in code, restoring any dropped scope-preservation coverage that is still required, and correcting watchlist return semantics so dedicated Mission Control leaves remain truthful. + +Completion criteria: +- [x] Route specs prove the canonical owners for `/ops/operations/notifications`, `/ops/operations/environments`, `/releases/health`, and the legacy environment redirects. +- [x] Sidebar spec proves the new exposure model without reintroducing removed Mission Control child leaves. +- [x] Watchlist return labels distinguish `Mission Alerts`, `Dashboard`, and `Notifications`. +- [x] Rebuilt live web passes the affected Playwright route/action checks with zero failures. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-10 | Sprint created while auditing the remaining dirty route/sidebar slice after the Mission Control iteration. Confirmed the change set is a route-surface ownership cleanup, not the unrelated OpenAPI/header draft sprint. | Developer | +| 2026-03-10 | Added `route-surface-ownership.spec.ts`, restored the dropped Mission Control scope assertions, and added the missing watchlist return-label coverage. `npx ng test --watch=false --include=src/app/routes/route-surface-ownership.spec.ts --include=src/app/routes/releases.routes.spec.ts --include=src/app/layout/app-sidebar/app-sidebar.component.spec.ts --include=src/app/core/testing/mission-scope-links.component.spec.ts` passed `27/27`; `npx ng test --watch=false --ts-config tsconfig.spec.features.json --include=src/app/features/watchlist/watchlist-page.component.spec.ts` passed `9/9`. | Developer | +| 2026-03-10 | Rebuilt the web bundle, resynced `dist/stellaops-web/browser` into `compose_console-dist`, and verified the live route/sidebar ownership slice with `node ./scripts/live-route-surface-ownership-check.mjs` (`failedActionCount=0`, `runtimeIssueCount=0`). | QA | +| 2026-03-10 | Adjacent live check `node ./scripts/live-notifications-watchlist-recheck.mjs` still fails on the Notifications surface (`Notification Administration`) because the watchlist tuning and alert drilldown links are missing and the page raised a visible `!t.items is not iterable` banner. Kept that defect out of this scoped commit as the next iteration. | QA | + +## Decisions & Risks +- Decision: keep environment inventory under Operations and treat Releases-owned environment routes as compatibility redirects only. +- Decision: preserve dedicated Mission Control alert semantics end to end; restoring the alerts leaf also requires preserving `Mission Alerts` return labels in watchlist drilldowns. +- Decision: keep a dedicated Playwright harness (`live-route-surface-ownership-check.mjs`) for this cleanup so future route/shell ownership changes can be reverified without rerunning the full canonical sweep. +- Risk: unrelated page-level UI edits are still present in the dirty tree. They must stay out of this commit unless they are independently verified. +- Risk: Notifications still has a separate live defect (`!t.items is not iterable`, missing watchlist links). That surface needs its own follow-up iteration before the broader product can be considered clean. + +## Next Checkpoints +- Land focused route/watchlist/spec coverage. +- Rebuild and sync the web bundle into `compose_console-dist`. +- Re-run live Playwright on the changed route/action surfaces and commit the verified slice. diff --git a/src/Web/StellaOps.Web/scripts/live-route-surface-ownership-check.mjs b/src/Web/StellaOps.Web/scripts/live-route-surface-ownership-check.mjs new file mode 100644 index 000000000..475a726c0 --- /dev/null +++ b/src/Web/StellaOps.Web/scripts/live-route-surface-ownership-check.mjs @@ -0,0 +1,306 @@ +#!/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-route-surface-ownership-check.json'); +const authStatePath = path.join(outputDir, 'live-route-surface-ownership-check.state.json'); +const authReportPath = path.join(outputDir, 'live-route-surface-ownership-check.auth.json'); +const scopeQuery = 'tenant=demo-prod®ions=us-east&environments=stage&timeWindow=7d'; + +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; + } + + const errorText = request.failure()?.errorText ?? 'unknown'; + if (errorText === 'net::ERR_ABORTED') { + return; + } + + runtime.requestFailures.push({ + page: page.url(), + method: request.method(), + url: request.url(), + error: errorText, + }); + }); + + 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(1_000); +} + +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, 4); index += 1) { + const text = (await headings.nth(index).innerText().catch(() => '')).trim(); + if (text) { + return text; + } + } + + return ''; +} + +async function captureSnapshot(page, label) { + return { + label, + url: page.url(), + title: await page.title(), + heading: await headingText(page), + }; +} + +async function persistSummary(summary) { + summary.lastUpdatedAtUtc = new Date().toISOString(); + await writeFile(outputPath, `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); +} + +async function runRouteCheck(page, routeCheck) { + const evaluate = async () => { + const currentUrl = new URL(page.url()); + const title = await page.title(); + const heading = await headingText(page); + let ok = currentUrl.pathname === routeCheck.expectedPath; + + if (ok && routeCheck.expectedTitle && !routeCheck.expectedTitle.test(title)) { + ok = false; + } + + if (ok && routeCheck.expectedHeading && !routeCheck.expectedHeading.test(heading)) { + ok = false; + } + + return { + ok, + finalUrl: page.url(), + snapshot: await captureSnapshot(page, `route:${routeCheck.path}`), + }; + }; + + await page.goto(`https://stella-ops.local${routeCheck.path}`, { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + await settle(page); + + let result = await evaluate(); + if (!result.ok && new URL(result.finalUrl).pathname === new URL(`https://stella-ops.local${routeCheck.path}`).pathname) { + await page.waitForTimeout(2_000); + result = await evaluate(); + } + + return { + kind: 'route', + route: routeCheck.path, + ...result, + }; +} + +async function runSidebarCheck(page) { + await page.goto(`https://stella-ops.local/mission-control/board?${scopeQuery}`, { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }); + await settle(page); + + const hrefs = await page.locator('aside.sidebar a').evaluateAll((nodes) => + nodes + .map((node) => node.getAttribute('href')) + .filter((value) => typeof value === 'string'), + ); + + const required = [ + '/releases/health', + '/ops/operations/environments', + '/ops/operations/notifications', + ]; + const forbidden = [ + '/setup/notifications', + '/mission-control/alerts', + '/mission-control/activity', + ]; + + const ok = required.every((href) => hrefs.includes(href)) + && forbidden.every((href) => !hrefs.includes(href)); + + return { + kind: 'sidebar', + route: `/mission-control/board?${scopeQuery}`, + ok, + hrefs, + snapshot: await captureSnapshot(page, 'sidebar:mission-control-board'), + }; +} + +async function runWatchlistLabelCheck(page, returnTo, expectedLabel) { + await page.goto( + `https://stella-ops.local/setup/trust-signing/watchlist/alerts?${scopeQuery}&alertId=alert-001&scope=tenant&tab=alerts&returnTo=${encodeURIComponent(returnTo)}`, + { + waitUntil: 'domcontentloaded', + timeout: 30_000, + }, + ); + await settle(page); + + const labelVisible = await page.getByText(`Return to ${expectedLabel}`, { exact: false }).first().isVisible().catch(() => false); + + return { + kind: 'watchlist-return', + route: page.url(), + ok: labelVisible, + expectedLabel, + snapshot: await captureSnapshot(page, `watchlist-return:${expectedLabel}`), + }; +} + +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 summary = { + generatedAtUtc: new Date().toISOString(), + results: [], + runtime, + }; + + const routeChecks = [ + { + path: `/releases/health?${scopeQuery}`, + expectedPath: '/releases/health', + expectedTitle: /release health/i, + }, + { + path: `/releases/environments?${scopeQuery}`, + expectedPath: '/ops/operations/environments', + expectedTitle: /environments/i, + }, + { + path: `/release-control/environments?${scopeQuery}`, + expectedPath: '/ops/operations/environments', + expectedTitle: /environments/i, + }, + { + path: `/setup/notifications?${scopeQuery}`, + expectedPath: '/ops/operations/notifications', + expectedTitle: /notifications/i, + }, + { + path: `/ops/operations/notifications?${scopeQuery}`, + expectedPath: '/ops/operations/notifications', + expectedTitle: /notifications/i, + }, + ]; + + for (const routeCheck of routeChecks) { + summary.results.push(await runRouteCheck(page, routeCheck)); + await persistSummary(summary); + } + + summary.results.push(await runSidebarCheck(page)); + await persistSummary(summary); + + summary.results.push(await runWatchlistLabelCheck(page, '/mission-control/alerts', 'Mission Alerts')); + await persistSummary(summary); + + summary.results.push(await runWatchlistLabelCheck(page, '/mission-control/board', 'Dashboard')); + await persistSummary(summary); + + await context.close(); + await browser.close(); + + const failedActionCount = summary.results.filter((entry) => !entry.ok).length; + const runtimeIssueCount = runtime.consoleErrors.length + + runtime.pageErrors.length + + runtime.requestFailures.length + + runtime.responseErrors.length; + + summary.failedActionCount = failedActionCount; + summary.runtimeIssueCount = runtimeIssueCount; + await persistSummary(summary); + + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + + if (failedActionCount > 0 || runtimeIssueCount > 0) { + process.exit(1); + } +} + +main().catch((error) => { + process.stderr.write(`[live-route-surface-ownership-check] ${error instanceof Error ? error.message : String(error)}\n`); + process.exit(1); +}); diff --git a/src/Web/StellaOps.Web/src/app/app.routes.ts b/src/Web/StellaOps.Web/src/app/app.routes.ts index bd3de816d..5643d229b 100644 --- a/src/Web/StellaOps.Web/src/app/app.routes.ts +++ b/src/Web/StellaOps.Web/src/app/app.routes.ts @@ -323,8 +323,8 @@ export const routes: Routes = [ redirectTo: preserveAppRedirect('/releases/promotions/:promotionId'), pathMatch: 'full', }, - { path: 'environments', redirectTo: '/releases/environments', pathMatch: 'full' }, - { path: 'regions', redirectTo: '/releases/environments', pathMatch: 'full' }, + { path: 'environments', redirectTo: '/ops/operations/environments', pathMatch: 'full' }, + { path: 'regions', redirectTo: '/ops/operations/environments', pathMatch: 'full' }, { path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' }, { path: 'setup/environments-paths', redirectTo: '/setup/topology/environments', pathMatch: 'full' }, { path: 'setup/targets-agents', redirectTo: '/setup/topology/targets', pathMatch: 'full' }, 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 7097ce56d..59cae4409 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 @@ -588,7 +588,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [ { id: 'admin-notifications', label: 'Notification Admin', - route: '/setup/notifications', + route: '/ops/operations/notifications', icon: 'bell-config', tooltip: 'Configure notification rules, channels, and templates', }, diff --git a/src/Web/StellaOps.Web/src/app/core/testing/mission-scope-links.component.spec.ts b/src/Web/StellaOps.Web/src/app/core/testing/mission-scope-links.component.spec.ts index 4eb95ca0d..fb33384ad 100644 --- a/src/Web/StellaOps.Web/src/app/core/testing/mission-scope-links.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/testing/mission-scope-links.component.spec.ts @@ -4,8 +4,8 @@ import { By } from '@angular/platform-browser'; import { PlatformContextStore } from '../context/platform-context.store'; import { DashboardV3Component } from '../../features/dashboard-v3/dashboard-v3.component'; -import { MissionAlertsPageComponent } from '../../features/mission-control/mission-alerts-page.component'; import { MissionActivityPageComponent } from '../../features/mission-control/mission-activity-page.component'; +import { MissionAlertsPageComponent } from '../../features/mission-control/mission-alerts-page.component'; function routerLinksFor(component: T): RouterLink[] { const fixture = TestBed.createComponent(component as never); 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 index 5ee509985..041caf1aa 100644 --- 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 @@ -113,15 +113,6 @@ export class PlatformOpsOverviewPageComponent { route: OPERATIONS_PATHS.aoc, owner: 'Ops', }, - { - id: 'notifications', - title: 'Notifications', - detail: 'Critical operator alerts, escalation channels, and delivery status.', - metric: '2 paging alerts', - impact: 'degraded', - route: OPERATIONS_PATHS.notifications, - owner: 'Ops', - }, ], }, { diff --git a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.spec.ts index 52298c114..9fc8d8740 100644 --- a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.spec.ts @@ -137,4 +137,15 @@ describe('WatchlistPageComponent', () => { expect(component.testResult()).not.toBeNull(); expect(component.testResult()?.matches).toBeTrue(); }); + + it('labels mission-control return paths precisely', () => { + component.returnTo.set('/mission-control/alerts'); + expect(component.returnToLabel()).toBe('Mission Alerts'); + + component.returnTo.set('/mission-control/board'); + expect(component.returnToLabel()).toBe('Dashboard'); + + component.returnTo.set('/ops/operations/notifications'); + expect(component.returnToLabel()).toBe('Notifications'); + }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts index 83cf374f0..0452d25e7 100644 --- a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts @@ -838,6 +838,10 @@ export class WatchlistPageComponent implements OnInit { return 'Mission Alerts'; } + if (returnTo.includes('/mission-control/board')) { + return 'Dashboard'; + } + if (returnTo.includes('/notifications')) { return 'Notifications'; } diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts index 523d2a49c..b133e4b85 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.spec.ts @@ -35,11 +35,21 @@ describe('AppSidebarComponent', () => { const text = fixture.nativeElement.textContent as string; expect(text).toContain('Dashboard'); - expect(text).toContain('Alerts'); - expect(text).toContain('Activity'); expect(text).not.toContain('Analytics'); }); + it('renders Dashboard as childless root item', () => { + setScopes([StellaOpsScopes.UI_READ, StellaOpsScopes.RELEASE_READ, StellaOpsScopes.SCANNER_READ]); + const fixture = createComponent(); + const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[]; + const hrefs = links.map((link) => link.getAttribute('href')); + + // Dashboard link exists but no alerts/activity children + expect(hrefs).toContain('/mission-control/board'); + expect(hrefs).not.toContain('/mission-control/alerts'); + expect(hrefs).not.toContain('/mission-control/activity'); + }); + it('starts edge auto-scroll animation only when pointer enters edge zone', () => { setScopes([StellaOpsScopes.UI_READ]); const rafSpy = spyOn(window, 'requestAnimationFrame').and.returnValue(1); @@ -70,7 +80,7 @@ describe('AppSidebarComponent', () => { expect(fixture.nativeElement.textContent).toContain('Trust & Signing'); }); - it('surfaces mission, unknowns, and notifications leaves in the live sidebar shells', () => { + it('surfaces unknowns and notifications leaves in the live sidebar shells', () => { setScopes([ StellaOpsScopes.UI_READ, StellaOpsScopes.RELEASE_READ, @@ -83,12 +93,81 @@ describe('AppSidebarComponent', () => { const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[]; const hrefs = links.map((link) => link.getAttribute('href')); - expect(hrefs).toContain('/mission-control/alerts'); - expect(hrefs).toContain('/mission-control/activity'); expect(hrefs).toContain('/security/unknowns'); expect(hrefs).toContain('/ops/operations/notifications'); }); + it('shows Health under Releases section', () => { + setScopes([ + StellaOpsScopes.UI_READ, + StellaOpsScopes.RELEASE_READ, + ]); + const fixture = createComponent(); + const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[]; + const hrefs = links.map((link) => link.getAttribute('href')); + + expect(hrefs).toContain('/releases/health'); + }); + + it('shows Security Posture under Vulnerabilities section', () => { + setScopes([ + StellaOpsScopes.SCANNER_READ, + ]); + const fixture = createComponent(); + const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[]; + const hrefs = links.map((link) => link.getAttribute('href')); + + expect(hrefs).toContain('/security/posture'); + }); + + it('shows Audit section with Logs and Bundles', () => { + setScopes([ + StellaOpsScopes.UI_READ, + StellaOpsScopes.RELEASE_READ, + StellaOpsScopes.POLICY_AUDIT, + ]); + const fixture = createComponent(); + const text = fixture.nativeElement.textContent as string; + + expect(text).toContain('Audit'); + expect(text).toContain('Logs'); + expect(text).toContain('Bundles'); + }); + + it('shows Operations children: Scheduled Jobs, Diagnostics, Signals, Offline Kit, Environments', () => { + setScopes([ + StellaOpsScopes.UI_ADMIN, + StellaOpsScopes.ORCH_READ, + StellaOpsScopes.ORCH_OPERATE, + StellaOpsScopes.HEALTH_READ, + StellaOpsScopes.NOTIFY_VIEWER, + StellaOpsScopes.POLICY_READ, + ]); + const fixture = createComponent(); + const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[]; + const hrefs = links.map((link) => link.getAttribute('href')); + + expect(hrefs).toContain('/ops/operations/jobengine'); + expect(hrefs).toContain('/ops/operations/doctor'); + expect(hrefs).toContain('/ops/operations/signals'); + expect(hrefs).toContain('/ops/operations/offline-kit'); + expect(hrefs).toContain('/ops/operations/environments'); + }); + + it('does not show Notifications under Setup', () => { + setScopes([ + StellaOpsScopes.UI_ADMIN, + StellaOpsScopes.RELEASE_READ, + StellaOpsScopes.ORCH_READ, + StellaOpsScopes.ORCH_OPERATE, + ]); + const fixture = createComponent(); + const links = Array.from(fixture.nativeElement.querySelectorAll('a')) as HTMLAnchorElement[]; + const hrefs = links.map((link) => link.getAttribute('href')); + + expect(hrefs).not.toContain('/setup/notifications'); + }); + function setScopes(scopes: readonly StellaOpsScope[]): void { const baseUser = authService.user(); if (!baseUser) { 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 7c0a34b74..138f84ce4 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 @@ -624,6 +624,7 @@ export class AppSidebarComponent implements AfterViewInit { * Root modules: Mission Control, Releases, Security, Evidence, Ops, Setup. */ readonly navSections: NavSection[] = [ + // ── Release Control ────────────────────────────────────────────── { id: 'dashboard', label: 'Dashboard', @@ -636,12 +637,6 @@ export class AppSidebarComponent implements AfterViewInit { StellaOpsScopes.RELEASE_READ, StellaOpsScopes.SCANNER_READ, ], - children: [ - { id: 'mc-alerts', label: 'Alerts', route: '/mission-control/alerts', icon: 'bell' }, - { id: 'mc-activity', label: 'Activity', route: '/mission-control/activity', icon: 'clock' }, - { id: 'mc-release-health', label: 'Release Health', route: '/mission-control/release-health', icon: 'activity' }, - { id: 'mc-security-posture', label: 'Security Posture', route: '/mission-control/security-posture', icon: 'shield' }, - ], }, { id: 'releases', @@ -658,17 +653,16 @@ export class AppSidebarComponent implements AfterViewInit { children: [ { id: 'rel-versions', - label: 'Release Versions', + label: 'Versions', route: '/releases/versions', icon: 'package', requireAnyScope: [StellaOpsScopes.RELEASE_READ, StellaOpsScopes.RELEASE_WRITE], }, { - id: 'rel-runs', - label: 'Release Runs', - route: '/releases/runs', - icon: 'clock', - requireAnyScope: [StellaOpsScopes.RELEASE_READ, StellaOpsScopes.RELEASE_WRITE], + id: 'rel-health', + label: 'Health', + route: '/releases/health', + icon: 'activity', }, { id: 'rel-approvals', @@ -695,23 +689,43 @@ export class AppSidebarComponent implements AfterViewInit { ], }, { id: 'rel-hotfix-list', label: 'Hotfixes', route: '/releases/hotfixes', icon: 'zap' }, - { id: 'rel-envs', label: 'Environments', route: '/releases/environments', icon: 'globe' }, - { - id: 'rel-create', - label: 'Create Version', - route: '/releases/versions/new', - icon: 'settings', - requireAnyScope: [StellaOpsScopes.RELEASE_WRITE, StellaOpsScopes.RELEASE_PUBLISH], - }, ], }, + { + id: 'ops', + label: 'Operations', + icon: 'settings', + route: '/ops/operations', + menuGroupId: 'release-control', + menuGroupLabel: 'Release Control', + sparklineData$: () => this.doctorTrendService.platformTrend(), + requireAnyScope: [ + StellaOpsScopes.UI_ADMIN, + StellaOpsScopes.ORCH_READ, + StellaOpsScopes.ORCH_OPERATE, + StellaOpsScopes.HEALTH_READ, + StellaOpsScopes.NOTIFY_VIEWER, + StellaOpsScopes.POLICY_READ, + ], + children: [ + { id: 'ops-jobs', label: 'Scheduled Jobs', route: '/ops/operations/jobengine', icon: 'clock' }, + { id: 'ops-diagnostics', label: 'Diagnostics', route: '/ops/operations/doctor', icon: 'stethoscope' }, + { id: 'ops-signals', label: 'Signals', route: '/ops/operations/signals', icon: 'radio' }, + { id: 'ops-offline-kit', label: 'Offline Kit', route: '/ops/operations/offline-kit', icon: 'download-cloud' }, + { id: 'ops-environments', label: 'Environments', route: '/ops/operations/environments', icon: 'globe' }, + { id: 'ops-policy', label: 'Policy', route: '/ops/policy', icon: 'shield' }, + { id: 'ops-platform-setup', label: 'Platform Setup', route: '/ops/platform-setup', icon: 'cog' }, + { id: 'ops-notifications', label: 'Notifications', route: '/ops/operations/notifications', icon: 'bell' }, + ], + }, + // ── Security & Audit ───────────────────────────────────────────── { id: 'vulnerabilities', label: 'Vulnerabilities', icon: 'shield', route: '/security', - menuGroupId: 'security-evidence', - menuGroupLabel: 'Security & Evidence', + menuGroupId: 'security-audit', + menuGroupLabel: 'Security & Audit', sparklineData$: () => this.doctorTrendService.securityTrend(), requireAnyScope: [ StellaOpsScopes.SCANNER_READ, @@ -724,20 +738,20 @@ export class AppSidebarComponent implements AfterViewInit { ], children: [ { id: 'sec-triage', label: 'Triage', route: '/triage/artifacts', icon: 'list' }, - { id: 'sec-audit-bundles', label: 'Audit Bundles', route: '/triage/audit-bundles', icon: 'archive' }, { id: 'sec-supply-chain', label: 'Supply-Chain Data', route: '/security/supply-chain-data', icon: 'graph' }, { id: 'sec-reachability', label: 'Reachability', route: '/security/reachability', icon: 'cpu' }, { id: 'sec-unknowns', label: 'Unknowns', route: '/security/unknowns', icon: 'help-circle' }, { id: 'sec-reports', label: 'Reports', route: '/security/reports', icon: 'book-open' }, + { id: 'sec-posture', label: 'Security Posture', route: '/security/posture', icon: 'shield' }, ], }, { - id: 'evidence', - label: 'Evidence', + id: 'audit', + label: 'Audit', icon: 'file-text', route: '/evidence/overview', - menuGroupId: 'security-evidence', - menuGroupLabel: 'Security & Evidence', + menuGroupId: 'security-audit', + menuGroupLabel: 'Security & Audit', requireAnyScope: [ StellaOpsScopes.RELEASE_READ, StellaOpsScopes.POLICY_AUDIT, @@ -750,31 +764,11 @@ export class AppSidebarComponent implements AfterViewInit { { id: 'ev-capsules', label: 'Decision Capsules', route: '/evidence/capsules', icon: 'archive' }, { id: 'ev-verify', label: 'Replay & Verify', route: '/evidence/verify-replay', icon: 'refresh' }, { id: 'ev-exports', label: 'Export Center', route: '/evidence/exports', icon: 'download' }, - { id: 'ev-audit', label: 'Audit Log', route: '/evidence/audit-log', icon: 'book-open' }, - ], - }, - { - id: 'ops', - label: 'Operations', - icon: 'settings', - route: '/ops/operations', - menuGroupId: 'platform-setup', - menuGroupLabel: 'Platform & Setup', - sparklineData$: () => this.doctorTrendService.platformTrend(), - requireAnyScope: [ - StellaOpsScopes.UI_ADMIN, - StellaOpsScopes.ORCH_READ, - StellaOpsScopes.ORCH_OPERATE, - StellaOpsScopes.HEALTH_READ, - StellaOpsScopes.NOTIFY_VIEWER, - StellaOpsScopes.POLICY_READ, - ], - children: [ - { id: 'ops-policy', label: 'Policy', route: '/ops/policy', icon: 'shield' }, - { id: 'ops-platform-setup', label: 'Platform Setup', route: '/ops/platform-setup', icon: 'cog' }, - { id: 'ops-notifications', label: 'Notifications', route: '/ops/operations/notifications', icon: 'bell' }, + { id: 'ev-audit', label: 'Logs', route: '/evidence/audit-log', icon: 'book-open' }, + { id: 'ev-bundles', label: 'Bundles', route: '/triage/audit-bundles', icon: 'archive' }, ], }, + // ── Platform & Setup ───────────────────────────────────────────── { id: 'setup', label: 'Setup', @@ -794,7 +788,6 @@ export class AppSidebarComponent implements AfterViewInit { { id: 'setup-iam', label: 'Identity & Access', route: '/setup/identity-access', icon: 'user' }, { id: 'setup-trust-signing', label: 'Trust & Signing', route: '/setup/trust-signing', icon: 'shield' }, { id: 'setup-branding', label: 'Tenant & Branding', route: '/setup/tenant-branding', icon: 'paintbrush' }, - { id: 'setup-notifications', label: 'Notifications', route: '/setup/notifications', icon: 'bell' }, ], }, ]; @@ -818,7 +811,7 @@ export class AppSidebarComponent implements AfterViewInit { /** Menu groups rendered in deterministic order for scanability */ readonly displaySectionGroups = computed(() => { const orderedGroups = new Map(); - const groupOrder = ['release-control', 'security-evidence', 'platform-setup', 'misc']; + const groupOrder = ['release-control', 'security-audit', 'platform-setup', 'misc']; for (const groupId of groupOrder) { orderedGroups.set(groupId, { @@ -890,8 +883,8 @@ export class AppSidebarComponent implements AfterViewInit { switch (groupId) { case 'release-control': return 'Release Control'; - case 'security-evidence': - return 'Security & Evidence'; + case 'security-audit': + return 'Security & Audit'; case 'platform-setup': return 'Platform & Setup'; default: diff --git a/src/Web/StellaOps.Web/src/app/routes/legacy-redirects.routes.ts b/src/Web/StellaOps.Web/src/app/routes/legacy-redirects.routes.ts index 69e9a6a89..e761a9503 100644 --- a/src/Web/StellaOps.Web/src/app/routes/legacy-redirects.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/legacy-redirects.routes.ts @@ -225,7 +225,12 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectRouteTempla }, { path: 'release-orchestrator/environments', - redirectTo: '/topology/regions', + redirectTo: '/ops/operations/environments', + pathMatch: 'full', + }, + { + path: 'release-control/environments', + redirectTo: '/ops/operations/environments', 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 6d6b41234..c468e8532 100644 --- a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts @@ -191,8 +191,26 @@ export const OPERATIONS_ROUTES: Routes = [ path: 'notifications', title: 'Notifications', data: { breadcrumb: 'Notifications' }, + loadChildren: () => + import('../features/admin-notifications/admin-notifications.routes').then((m) => m.adminNotificationsRoutes), + }, + { + path: 'environments', + title: 'Environments Inventory', + data: { breadcrumb: 'Environments' }, loadComponent: () => - import('../features/notify/notify-panel.component').then((m) => m.NotifyPanelComponent), + import('../features/topology/topology-regions-environments-page.component').then( + (m) => m.TopologyRegionsEnvironmentsPageComponent, + ), + }, + { + path: 'environments/:environmentId', + title: 'Environment Detail', + data: { breadcrumb: 'Environment Detail' }, + loadComponent: () => + import('../features/topology/topology-environment-detail-page.component').then( + (m) => m.TopologyEnvironmentDetailPageComponent, + ), }, { path: 'status', diff --git a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts index 9a95c0d38..39fcdb48e 100644 --- a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts @@ -181,22 +181,24 @@ export const RELEASES_ROUTES: Routes = [ import('../features/releases/hotfix-detail-page.component').then((m) => m.HotfixDetailPageComponent), }, { - path: 'environments', - title: 'Environments Inventory', - data: { breadcrumb: 'Environments' }, + path: 'health', + title: 'Release Health', + data: { breadcrumb: 'Health' }, loadComponent: () => - import('../features/topology/topology-regions-environments-page.component').then( - (m) => m.TopologyRegionsEnvironmentsPageComponent, + import('../features/topology/environment-posture-page.component').then( + (m) => m.EnvironmentPosturePageComponent, ), }, + // Redirect environments to Operations (moved from Releases) + { + path: 'environments', + pathMatch: 'full', + redirectTo: preserveReleasesRedirect('/ops/operations/environments'), + }, { path: 'environments/:environmentId', - title: 'Environment Detail', - data: { breadcrumb: 'Environment Detail' }, - loadComponent: () => - import('../features/topology/topology-environment-detail-page.component').then( - (m) => m.TopologyEnvironmentDetailPageComponent, - ), + pathMatch: 'full', + redirectTo: preserveReleasesRedirect('/ops/operations/environments/:environmentId'), }, { path: 'deployments', diff --git a/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts b/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts new file mode 100644 index 000000000..4ed9f0334 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/routes/route-surface-ownership.spec.ts @@ -0,0 +1,123 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter, type Route } from '@angular/router'; + +import { routes } from '../app.routes'; +import { OPERATIONS_ROUTES } from './operations.routes'; +import { RELEASES_ROUTES } from './releases.routes'; +import { LEGACY_REDIRECT_ROUTE_TEMPLATES } from './legacy-redirects.routes'; +import { SETUP_ROUTES } from './setup.routes'; + +type RedirectFn = Exclude, string>; + +function invokeRedirect(redirectTo: RedirectFn, snapshot: { + params?: Record; + queryParams?: Record; + fragment?: string | null; +}): string { + return TestBed.runInInjectionContext(() => redirectTo(snapshot as never)).toString(); +} + +function findRouteByPath(routeList: readonly Route[], path: string): Route | undefined { + for (const route of routeList) { + if (route.path === path) { + return route; + } + + if (route.children) { + const nested = findRouteByPath(route.children, path); + if (nested) { + return nested; + } + } + } + + return undefined; +} + +describe('Route surface ownership', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideRouter([])], + }); + }); + + it('redirects root environment shortcuts to Operations', () => { + const environmentsRoute = findRouteByPath(routes, 'environments'); + const regionsRoute = findRouteByPath(routes, 'regions'); + + expect(environmentsRoute?.redirectTo).toBe('/ops/operations/environments'); + expect(regionsRoute?.redirectTo).toBe('/ops/operations/environments'); + }); + + it('preserves setup notifications redirects into Operations notifications', () => { + const notificationsRoute = SETUP_ROUTES.find((route) => route.path === 'notifications'); + + expect(notificationsRoute?.pathMatch).toBe('prefix'); + expect(typeof notificationsRoute?.redirectTo).toBe('function'); + + const notificationsRedirect = notificationsRoute?.redirectTo; + if (typeof notificationsRedirect !== 'function') { + throw new Error('Setup notifications route must expose a redirect function.'); + } + + const target = invokeRedirect(notificationsRedirect, { + params: {}, + queryParams: { tenant: 'demo-prod', regions: 'us-east' }, + fragment: 'channels', + }); + + expect(target).toBe('/ops/operations/notifications?tenant=demo-prod®ions=us-east#channels'); + }); + + it('mounts Operations ownership for notifications and environments', () => { + const notificationsRoute = OPERATIONS_ROUTES.find((route) => route.path === 'notifications'); + const environmentsRoute = OPERATIONS_ROUTES.find((route) => route.path === 'environments'); + const environmentDetailRoute = OPERATIONS_ROUTES.find((route) => route.path === 'environments/:environmentId'); + + expect(typeof notificationsRoute?.loadChildren).toBe('function'); + expect(typeof environmentsRoute?.loadComponent).toBe('function'); + expect(typeof environmentDetailRoute?.loadComponent).toBe('function'); + }); + + it('keeps release health under Releases while redirecting release environments to Operations', () => { + const healthRoute = RELEASES_ROUTES.find((route) => route.path === 'health'); + const environmentsRoute = RELEASES_ROUTES.find((route) => route.path === 'environments'); + const environmentDetailRoute = RELEASES_ROUTES.find((route) => route.path === 'environments/:environmentId'); + + expect(healthRoute?.title).toBe('Release Health'); + expect(typeof healthRoute?.loadComponent).toBe('function'); + + const environmentsRedirect = environmentsRoute?.redirectTo; + if (typeof environmentsRedirect !== 'function') { + throw new Error('Releases environments route must expose a redirect function.'); + } + const environmentDetailRedirect = environmentDetailRoute?.redirectTo; + if (typeof environmentDetailRedirect !== 'function') { + throw new Error('Releases environment detail route must expose a redirect function.'); + } + + const environmentsTarget = invokeRedirect(environmentsRedirect, { + params: {}, + queryParams: { tenant: 'demo-prod' }, + }); + const environmentDetailTarget = invokeRedirect(environmentDetailRedirect, { + params: { environmentId: 'stage' }, + queryParams: { tenant: 'demo-prod' }, + }); + + expect(environmentsTarget).toBe('/ops/operations/environments?tenant=demo-prod'); + expect(environmentDetailTarget).toBe('/ops/operations/environments/stage?tenant=demo-prod'); + }); + + it('maps legacy release environment shortcuts to Operations inventory', () => { + const releaseOrchestratorRoute = LEGACY_REDIRECT_ROUTE_TEMPLATES.find( + (route) => route.path === 'release-orchestrator/environments', + ); + const releaseControlRoute = LEGACY_REDIRECT_ROUTE_TEMPLATES.find( + (route) => route.path === 'release-control/environments', + ); + + expect(releaseOrchestratorRoute?.redirectTo).toBe('/ops/operations/environments'); + expect(releaseControlRoute?.redirectTo).toBe('/ops/operations/environments'); + }); +}); diff --git a/src/Web/StellaOps.Web/src/app/routes/setup.routes.ts b/src/Web/StellaOps.Web/src/app/routes/setup.routes.ts index 9c5c9125e..ad9c1d62f 100644 --- a/src/Web/StellaOps.Web/src/app/routes/setup.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/setup.routes.ts @@ -1,4 +1,5 @@ -import { Routes } from '@angular/router'; +import { inject } from '@angular/core'; +import { Router, Routes } from '@angular/router'; export const SETUP_ROUTES: Routes = [ { @@ -26,12 +27,17 @@ export const SETUP_ROUTES: Routes = [ (m) => m.BrandingSettingsPageComponent, ), }, + // Redirect to consolidated notifications under Operations { path: 'notifications', - title: 'Notifications', - data: { breadcrumb: 'Notifications' }, - loadChildren: () => - import('../features/admin-notifications/admin-notifications.routes').then((m) => m.adminNotificationsRoutes), + pathMatch: 'prefix', + redirectTo: ({ queryParams, fragment }) => { + const router = inject(Router); + const target = router.parseUrl('/ops/operations/notifications'); + target.queryParams = { ...queryParams }; + target.fragment = fragment ?? null; + return target; + }, }, { path: 'usage', diff --git a/src/Web/StellaOps.Web/tsconfig.spec.features.json b/src/Web/StellaOps.Web/tsconfig.spec.features.json index 36d26f90b..9f92cb464 100644 --- a/src/Web/StellaOps.Web/tsconfig.spec.features.json +++ b/src/Web/StellaOps.Web/tsconfig.spec.features.json @@ -13,6 +13,7 @@ "src/app/features/policy-simulation/policy-simulation-defaults.spec.ts", "src/app/features/policy-simulation/simulation-dashboard.component.spec.ts", "src/app/features/registry-admin/registry-admin.component.spec.ts", + "src/app/features/watchlist/watchlist-page.component.spec.ts", "src/app/features/evidence-thread/__tests__/evidence-thread-view.component.spec.ts" ] } diff --git a/src/Web/StellaOps.Web/vitest.codex.config.ts b/src/Web/StellaOps.Web/vitest.codex.config.ts index 8ec6bb86a..ad91718d0 100644 --- a/src/Web/StellaOps.Web/vitest.codex.config.ts +++ b/src/Web/StellaOps.Web/vitest.codex.config.ts @@ -2,6 +2,9 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { + globals: true, + environment: 'jsdom', + setupFiles: ['src/test-setup.ts'], pool: 'threads', poolOptions: { threads: {