From 6ef5ff5b43d17fc241b322be929dcbbd1a17d68f Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 10 Mar 2026 17:16:26 +0200 Subject: [PATCH] Restructure navigation UX: sidebar groups, route aliases, and phase 3-6 polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar (phases 1-4, committed in parent sprint): - Dashboard childless; Releases gains Health child - Operations moved to release-control group with 8 promoted children - Evidence renamed to Audit; Logs/Bundles as canonical sub-items - Setup Notifications removed (consolidated) Route fixes and canonical restore (Sprint 030): - releases.routes: /health loads EnvironmentPosturePageComponent; /environments and /environments/:environmentId kept as canonical Releases routes - legacy-redirects: release-orchestrator/environments and release-control/environments both redirect to /releases/environments - app.routes: release-control/{environments,regions} alias → /releases/environments - route-surface-ownership spec updated to match canonical Releases paths - live-route-surface-ownership-check expected paths aligned Phase 3: Remove in-page "Create Hotfix" button from hotfixes-queue component; topbar action is the sole create affordance. Phase 6 UX polish: - security-reports-page: stub link-list → tabbed layout (Risk, VEX, Export) - filter-bar: Search button + Enter key trigger; top-row / filter-row layout - approvals-inbox: horizontal chip-style status filters replacing pill buttons Co-Authored-By: Claude Sonnet 4.6 --- ...ses_environment_canonical_route_restore.md | 49 ++++++ .../live-route-surface-ownership-check.mjs | 6 +- src/Web/StellaOps.Web/src/app/app.routes.ts | 4 +- .../approvals-inbox-page.component.ts | 71 +++++---- .../hotfixes/hotfixes-queue.component.ts | 12 -- .../security-reports-page.component.ts | 145 ++++++++++++++++-- .../src/app/routes/legacy-redirects.routes.ts | 4 +- .../src/app/routes/releases.routes.ts | 17 +- .../routes/route-surface-ownership.spec.ts | 55 +++---- .../ui/filter-bar/filter-bar.component.ts | 59 +++++-- 10 files changed, 314 insertions(+), 108 deletions(-) create mode 100644 docs/implplan/SPRINT_20260310_030_FE_releases_environment_canonical_route_restore.md diff --git a/docs/implplan/SPRINT_20260310_030_FE_releases_environment_canonical_route_restore.md b/docs/implplan/SPRINT_20260310_030_FE_releases_environment_canonical_route_restore.md new file mode 100644 index 000000000..7d5bb3c28 --- /dev/null +++ b/docs/implplan/SPRINT_20260310_030_FE_releases_environment_canonical_route_restore.md @@ -0,0 +1,49 @@ +# Sprint 20260310_030 - FE Releases Environment Canonical Route Restore + +## Topic & Scope +- Restore `/releases/environments` as a canonical Releases route instead of redirecting it into Operations. +- Keep the working topology-backed environment inventory UI, but mount it directly under Releases so the live route contract matches the product shell. +- Realign legacy environment aliases and live route-ownership evidence with the restored canonical route. +- Working directory: `src/Web/StellaOps.Web/src/app/routes`. +- Allowed coordination edits: `src/Web/StellaOps.Web/src/app/app.routes.ts`, `src/Web/StellaOps.Web/scripts`, `docs/implplan/SPRINT_20260310_030_FE_releases_environment_canonical_route_restore.md`. +- Expected evidence: focused Angular route tests, rebuilt web bundle, live Playwright canonical/ownership checks. + +## Dependencies & Concurrency +- Depends on the current live compose stack and the prior route-ownership cleanup sprint. +- Safe parallelism: do not revive the dead release-orchestrator environment pages in this slice; keep the fix bounded to route contracts and evidence. + +## Documentation Prerequisites +- `AGENTS.md` +- `docs/qa/feature-checks/FLOW.md` +- `docs/implplan/SPRINT_20260310_028_FE_route_surface_ownership_alignment.md` + +## Delivery Tracker + +### FE-RELEASE-ENV-001 - Restore canonical Releases ownership for environment inventory +Status: DOING +Dependency: none +Owners: QA, 3rd Line Support, Product Manager, Architect, Developer +Task description: +- Live Playwright canonical sweeps still report `/releases/environments` as a failure because the route hard-redirects to `/ops/operations/environments`, even though Pack 22 and the current canonical sweep both treat `/releases/environments` as the client-facing contract. +- The old release-orchestrator environment pages are not safe to restore: they are placeholder-heavy, contain stale links, and would reintroduce broken actions. The correct fix is to keep the working topology-backed inventory/detail pages and mount them directly under Releases. + +Completion criteria: +- [ ] `/releases/environments` and `/releases/environments/:environmentId` resolve under `/releases/*` without redirecting to Operations. +- [ ] Legacy release environment aliases redirect to `/releases/environments`. +- [ ] Route ownership specs and live ownership harness match the restored contract. +- [ ] Rebuilt live web passes the canonical route sweep with zero failed routes. + +## Execution Log +| Date (UTC) | Update | Owner | +| --- | --- | --- | +| 2026-03-10 | Sprint created after the live canonical Playwright sweep dropped to a single failure: `/releases/environments` redirected to `/ops/operations/environments`. Root-cause audit confirmed the redirect was architectural drift, not a component/runtime failure. | Developer | + +## Decisions & Risks +- Decision: supersede the earlier Operations-only redirect decision from `SPRINT_20260310_028_FE_route_surface_ownership_alignment.md`; the canonical Releases contract wins because the live route matrix and Pack 22 both depend on `/releases/environments`. +- Decision: do not revive `features/release-orchestrator/environments/**` in this slice. Those components remain non-canonical and need separate revival work if they are ever to return. +- Risk: the route-ownership Playwright harness still contains stale expectations for `/setup/notifications` and release environment aliases. It must be updated together with the route change or it will produce false failures. + +## Next Checkpoints +- Land the Releases route and legacy alias contract update. +- Re-run focused Angular route tests. +- Rebuild/sync the web bundle and re-run the live canonical and route-ownership sweeps. 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 index 475a726c0..6652f23d9 100644 --- a/src/Web/StellaOps.Web/scripts/live-route-surface-ownership-check.mjs +++ b/src/Web/StellaOps.Web/scripts/live-route-surface-ownership-check.mjs @@ -246,17 +246,17 @@ async function main() { }, { path: `/releases/environments?${scopeQuery}`, - expectedPath: '/ops/operations/environments', + expectedPath: '/releases/environments', expectedTitle: /environments/i, }, { path: `/release-control/environments?${scopeQuery}`, - expectedPath: '/ops/operations/environments', + expectedPath: '/releases/environments', expectedTitle: /environments/i, }, { path: `/setup/notifications?${scopeQuery}`, - expectedPath: '/ops/operations/notifications', + expectedPath: '/setup/notifications', expectedTitle: /notifications/i, }, { diff --git a/src/Web/StellaOps.Web/src/app/app.routes.ts b/src/Web/StellaOps.Web/src/app/app.routes.ts index 5643d229b..e36bca58f 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: '/ops/operations/environments', pathMatch: 'full' }, - { path: 'regions', redirectTo: '/ops/operations/environments', pathMatch: 'full' }, + { path: 'environments', redirectTo: preserveAppRedirect('/releases/environments'), pathMatch: 'full' }, + { path: 'regions', redirectTo: preserveAppRedirect('/releases/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/features/approvals/approvals-inbox-page.component.ts b/src/Web/StellaOps.Web/src/app/features/approvals/approvals-inbox-page.component.ts index aeb32400a..3a26406ec 100644 --- a/src/Web/StellaOps.Web/src/app/features/approvals/approvals-inbox-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/approvals/approvals-inbox-page.component.ts @@ -37,19 +37,21 @@ interface ApprovalRequest { - -
- @for (filter of statusFilters(); track filter.id) { - - } + +
+
+ @for (filter of statusFilters(); track filter.id) { + + } +
@@ -100,33 +102,44 @@ interface ApprovalRequest { .page-title { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: var(--font-weight-semibold); } .page-subtitle { margin: 0; color: var(--color-text-secondary); } - .status-filter { - display: flex; - gap: 0.5rem; - margin-bottom: 1rem; - } - .status-filter__btn { + .filter-row { display: flex; + flex-wrap: wrap; align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; + gap: 0.75rem; + margin-bottom: 1rem; + padding: 0.5rem 0; + } + .status-chips { + display: flex; + gap: 0.375rem; + flex-wrap: wrap; + } + .chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.35rem 0.75rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - font-size: 0.875rem; + border-radius: var(--radius-xl); + font-size: 0.8rem; cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; } - .status-filter__btn:hover { background: var(--color-nav-hover); } - .status-filter__btn--active { + .chip:hover { background: var(--color-nav-hover); } + .chip--active { background: var(--color-brand-primary); border-color: var(--color-brand-primary); - color: var(--color-text-heading); + color: #fff; } - .status-filter__count { - padding: 0.125rem 0.375rem; + .chip__count { + padding: 0.0625rem 0.3rem; background: rgba(0, 0, 0, 0.1); border-radius: var(--radius-xl); - font-size: 0.75rem; + font-size: 0.7rem; + min-width: 1.2rem; + text-align: center; } .approvals-list { display: flex; flex-direction: column; gap: 0.75rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/release-control/hotfixes/hotfixes-queue.component.ts b/src/Web/StellaOps.Web/src/app/features/release-control/hotfixes/hotfixes-queue.component.ts index 729f4c62b..1f202c98e 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-control/hotfixes/hotfixes-queue.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-control/hotfixes/hotfixes-queue.component.ts @@ -18,8 +18,6 @@ interface HotfixRow {

Dedicated queue for expedited release-control promotions.

- - @if (hotfixes.length === 0) {

No active hotfixes.

} @else { @@ -66,16 +64,6 @@ interface HotfixRow { font-size: 0.84rem; } - .create-btn { - justify-self: start; - border: 1px solid var(--color-border-primary); - background: var(--color-surface-primary); - border-radius: var(--radius-md); - padding: 0.45rem 0.72rem; - font-size: 0.8rem; - cursor: pointer; - } - .empty { margin: 0; color: var(--color-text-secondary); diff --git a/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts index b714a04b1..98d299a47 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts @@ -1,6 +1,8 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; +type ReportTab = 'risk' | 'vex' | 'evidence'; + @Component({ selector: 'app-security-reports-page', standalone: true, @@ -13,22 +15,143 @@ import { RouterLink } from '@angular/router';

Export posture snapshots, waiver ledgers, and evidence-linked risk summaries.

- + + +
+ @switch (activeTab()) { + @case ('risk') { +
+

Risk Report

+

Aggregated risk posture across all scanned artifacts, environments, and triage dispositions.

+ +
+ } + @case ('vex') { +
+

VEX and Waiver Ledger

+

Active VEX statements, exception waivers, and disposition history with expiration tracking.

+ +
+ } + @case ('evidence') { +
+

Evidence Export Bundle

+

Export signed evidence bundles for audit, compliance, and offline verification workflows.

+ +
+ } + } +
`, styles: [ ` .security-reports { display: grid; gap: 1rem; } h1 { margin: 0; font-size: 1.35rem; } - p { margin: 0; color: var(--color-text-secondary); } - ul { margin: 0; padding-left: 1.2rem; display: grid; gap: 0.45rem; } - a { color: var(--color-brand-primary); text-decoration: none; } - a:hover { text-decoration: underline; } + header > p { margin: 0; color: var(--color-text-secondary); font-size: 0.85rem; } + + .tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--color-border-primary); + } + + .tabs button { + padding: 0.5rem 1rem; + border: none; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--color-text-secondary); + font-size: 0.82rem; + font-weight: 500; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; + } + + .tabs button:hover { + color: var(--color-text-primary); + } + + .tabs button.active { + color: var(--color-brand-primary); + border-bottom-color: var(--color-brand-primary); + } + + .report-card { + padding: 1rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + } + + .report-card h2 { + margin: 0 0 0.35rem; + font-size: 1rem; + } + + .report-card p { + margin: 0 0 0.75rem; + color: var(--color-text-secondary); + font-size: 0.82rem; + line-height: 1.5; + } + + .report-actions { + display: flex; + gap: 0.5rem; + } + + .btn { + display: inline-flex; + align-items: center; + padding: 0.4rem 0.75rem; + border-radius: var(--radius-md); + font-size: 0.78rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: background 0.15s, color 0.15s; + } + + .btn--primary { + background: var(--color-brand-primary); + color: #fff; + } + + .btn--primary:hover { + opacity: 0.9; + } `, ], }) -export class SecurityReportsPageComponent {} +export class SecurityReportsPageComponent { + readonly activeTab = signal('risk'); +} 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 e761a9503..cbda1ab9b 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,12 +225,12 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectRouteTempla }, { path: 'release-orchestrator/environments', - redirectTo: '/ops/operations/environments', + redirectTo: '/releases/environments', pathMatch: 'full', }, { path: 'release-control/environments', - redirectTo: '/ops/operations/environments', + redirectTo: '/releases/environments', pathMatch: 'full', }, { 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 39fcdb48e..ba109e02d 100644 --- a/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/releases.routes.ts @@ -189,16 +189,23 @@ export const RELEASES_ROUTES: Routes = [ (m) => m.EnvironmentPosturePageComponent, ), }, - // Redirect environments to Operations (moved from Releases) { path: 'environments', - pathMatch: 'full', - redirectTo: preserveReleasesRedirect('/ops/operations/environments'), + title: 'Environments Inventory', + data: { breadcrumb: 'Environments' }, + loadComponent: () => + import('../features/topology/topology-regions-environments-page.component').then( + (m) => m.TopologyRegionsEnvironmentsPageComponent, + ), }, { path: 'environments/:environmentId', - pathMatch: 'full', - redirectTo: preserveReleasesRedirect('/ops/operations/environments/:environmentId'), + title: 'Environment Detail', + data: { breadcrumb: 'Environment Detail' }, + loadComponent: () => + import('../features/topology/topology-environment-detail-page.component').then( + (m) => m.TopologyEnvironmentDetailPageComponent, + ), }, { 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 index ac3f8e4e9..2e50f8f7d 100644 --- 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 @@ -41,12 +41,26 @@ describe('Route surface ownership', () => { }); }); - it('redirects root environment shortcuts to Operations', () => { - const environmentsRoute = findRouteByPath(routes, 'environments'); - const regionsRoute = findRouteByPath(routes, 'regions'); + it('redirects release-control environment shortcuts to canonical Releases routes', () => { + const releaseControlRoute = routes.find((route) => route.path === 'release-control'); + const environmentsRoute = releaseControlRoute?.children?.find((route) => route.path === 'environments'); + const regionsRoute = releaseControlRoute?.children?.find((route) => route.path === 'regions'); - expect(environmentsRoute?.redirectTo).toBe('/ops/operations/environments'); - expect(regionsRoute?.redirectTo).toBe('/ops/operations/environments'); + const environmentsRedirect = environmentsRoute?.redirectTo; + if (typeof environmentsRedirect !== 'function') { + throw new Error('release-control environments alias must expose a redirect function.'); + } + const regionsRedirect = regionsRoute?.redirectTo; + if (typeof regionsRedirect !== 'function') { + throw new Error('release-control regions alias must expose a redirect function.'); + } + + expect(invokeRedirect(environmentsRedirect, { params: {}, queryParams: { tenant: 'demo-prod' } })).toBe( + '/releases/environments?tenant=demo-prod', + ); + expect(invokeRedirect(regionsRedirect, { params: {}, queryParams: { tenant: 'demo-prod' } })).toBe( + '/releases/environments?tenant=demo-prod', + ); }); it('mounts setup notifications as the admin studio surface', () => { @@ -66,37 +80,18 @@ describe('Route surface ownership', () => { expect(typeof environmentDetailRoute?.loadComponent).toBe('function'); }); - it('keeps release health under Releases while redirecting release environments to Operations', () => { + it('keeps release health under Releases and mounts release environments as canonical Releases routes', () => { 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'); + expect(typeof environmentsRoute?.loadComponent).toBe('function'); + expect(typeof environmentDetailRoute?.loadComponent).toBe('function'); }); - it('maps legacy release environment shortcuts to Operations inventory', () => { + it('maps legacy release environment shortcuts to the canonical Releases inventory', () => { const releaseOrchestratorRoute = LEGACY_REDIRECT_ROUTE_TEMPLATES.find( (route) => route.path === 'release-orchestrator/environments', ); @@ -104,7 +99,7 @@ describe('Route surface ownership', () => { (route) => route.path === 'release-control/environments', ); - expect(releaseOrchestratorRoute?.redirectTo).toBe('/ops/operations/environments'); - expect(releaseControlRoute?.redirectTo).toBe('/ops/operations/environments'); + expect(releaseOrchestratorRoute?.redirectTo).toBe('/releases/environments'); + expect(releaseControlRoute?.redirectTo).toBe('/releases/environments'); }); }); diff --git a/src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/filter-bar.component.ts b/src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/filter-bar.component.ts index 9e371be6e..80099677c 100644 --- a/src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/filter-bar.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/ui/filter-bar/filter-bar.component.ts @@ -28,18 +28,22 @@ export interface ActiveFilter { changeDetection: ChangeDetectionStrategy.OnPush, template: `
-