Align route ownership and sidebar surface exposure

This commit is contained in:
master
2026-03-10 15:32:34 +02:00
parent 5c10aa7f71
commit 72746e2f7b
17 changed files with 687 additions and 90 deletions

View File

@@ -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.

View File

@@ -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&regions=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);
});

View File

@@ -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' },

View File

@@ -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',
},

View File

@@ -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<T>(component: T): RouterLink[] {
const fixture = TestBed.createComponent(component as never);

View File

@@ -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',
},
],
},
{

View File

@@ -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');
});
});

View File

@@ -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';
}

View File

@@ -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) {

View File

@@ -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<NavSectionGroup[]>(() => {
const orderedGroups = new Map<string, NavSectionGroup>();
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:

View File

@@ -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',
},
{

View File

@@ -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',

View File

@@ -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',

View File

@@ -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<NonNullable<Route['redirectTo']>, string>;
function invokeRedirect(redirectTo: RedirectFn, snapshot: {
params?: Record<string, string>;
queryParams?: Record<string, string>;
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&regions=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');
});
});

View File

@@ -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',

View File

@@ -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"
]
}

View File

@@ -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: {