Restructure navigation UX: sidebar groups, route aliases, and phase 3-6 polish

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 <noreply@anthropic.com>
This commit is contained in:
master
2026-03-10 17:16:26 +02:00
parent 8a1fb9bd9b
commit 6ef5ff5b43
10 changed files with 314 additions and 108 deletions

View File

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

View File

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

View File

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

View File

@@ -37,19 +37,21 @@ interface ApprovalRequest {
</div>
</header>
<!-- Status Filter -->
<div class="status-filter">
@for (filter of statusFilters(); track filter.id) {
<button
type="button"
class="status-filter__btn"
[class.status-filter__btn--active]="activeFilter() === filter.id"
(click)="setFilter(filter.id)"
>
{{ filter.label }}
<span class="status-filter__count">{{ filter.count }}</span>
</button>
}
<!-- Filter Bar -->
<div class="filter-row">
<div class="status-chips">
@for (filter of statusFilters(); track filter.id) {
<button
type="button"
class="chip"
[class.chip--active]="activeFilter() === filter.id"
(click)="setFilter(filter.id)"
>
{{ filter.label }}
<span class="chip__count">{{ filter.count }}</span>
</button>
}
</div>
</div>
<!-- Approvals List -->
@@ -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; }

View File

@@ -18,8 +18,6 @@ interface HotfixRow {
<p>Dedicated queue for expedited release-control promotions.</p>
</header>
<button type="button" class="create-btn">+ Create Hotfix</button>
@if (hotfixes.length === 0) {
<p class="empty">No active hotfixes.</p>
} @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);

View File

@@ -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';
<p>Export posture snapshots, waiver ledgers, and evidence-linked risk summaries.</p>
</header>
<ul>
<li><a routerLink="/security/triage">Risk Report (Triage)</a></li>
<li><a routerLink="/security/disposition">VEX and Waiver Ledger</a></li>
<li><a routerLink="/evidence/exports">Evidence Export Bundle</a></li>
</ul>
<nav class="tabs" role="tablist">
<button
type="button"
role="tab"
[class.active]="activeTab() === 'risk'"
[attr.aria-selected]="activeTab() === 'risk'"
(click)="activeTab.set('risk')"
>Risk Report</button>
<button
type="button"
role="tab"
[class.active]="activeTab() === 'vex'"
[attr.aria-selected]="activeTab() === 'vex'"
(click)="activeTab.set('vex')"
>VEX Ledger</button>
<button
type="button"
role="tab"
[class.active]="activeTab() === 'evidence'"
[attr.aria-selected]="activeTab() === 'evidence'"
(click)="activeTab.set('evidence')"
>Evidence Export</button>
</nav>
<div class="tab-content" role="tabpanel">
@switch (activeTab()) {
@case ('risk') {
<article class="report-card">
<h2>Risk Report</h2>
<p>Aggregated risk posture across all scanned artifacts, environments, and triage dispositions.</p>
<div class="report-actions">
<a routerLink="/security/triage" class="btn btn--primary">View Full Triage</a>
</div>
</article>
}
@case ('vex') {
<article class="report-card">
<h2>VEX and Waiver Ledger</h2>
<p>Active VEX statements, exception waivers, and disposition history with expiration tracking.</p>
<div class="report-actions">
<a routerLink="/security/disposition" class="btn btn--primary">View Dispositions</a>
</div>
</article>
}
@case ('evidence') {
<article class="report-card">
<h2>Evidence Export Bundle</h2>
<p>Export signed evidence bundles for audit, compliance, and offline verification workflows.</p>
<div class="report-actions">
<a routerLink="/evidence/exports" class="btn btn--primary">Open Export Center</a>
</div>
</article>
}
}
</div>
</section>
`,
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<ReportTab>('risk');
}

View File

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

View File

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

View File

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

View File

@@ -28,18 +28,22 @@ export interface ActiveFilter {
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="filter-bar">
<div class="filter-bar__search">
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
<input
type="text"
class="filter-bar__search-input"
[placeholder]="searchPlaceholder"
[(ngModel)]="searchValue"
(ngModelChange)="onSearchChange($event)"
/>
<div class="filter-bar__top-row">
<div class="filter-bar__search">
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
<input
type="text"
class="filter-bar__search-input"
[placeholder]="searchPlaceholder"
[(ngModel)]="searchValue"
(ngModelChange)="onSearchChange($event)"
(keydown.enter)="onSearchSubmit()"
/>
</div>
<button type="button" class="filter-bar__search-btn" (click)="onSearchSubmit()">Search</button>
</div>
<div class="filter-bar__filters">
@@ -78,8 +82,7 @@ export interface ActiveFilter {
styles: [`
.filter-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
background: var(--color-surface-primary);
@@ -88,12 +91,35 @@ export interface ActiveFilter {
margin-bottom: 1rem;
}
.filter-bar__top-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-bar__search {
position: relative;
flex: 1;
min-width: 200px;
}
.filter-bar__search-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--color-brand-primary);
border-radius: var(--radius-md);
background: var(--color-brand-primary);
color: #fff;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: opacity 0.15s;
}
.filter-bar__search-btn:hover {
opacity: 0.9;
}
.filter-bar__search-icon {
position: absolute;
left: 0.75rem;
@@ -192,6 +218,7 @@ export class FilterBarComponent {
@Input() activeFilters: ActiveFilter[] = [];
@Output() searchChange = new EventEmitter<string>();
@Output() searchSubmit = new EventEmitter<string>();
@Output() filterChange = new EventEmitter<ActiveFilter>();
@Output() filterRemove = new EventEmitter<ActiveFilter>();
@Output() filtersCleared = new EventEmitter<void>();
@@ -202,6 +229,10 @@ export class FilterBarComponent {
this.searchChange.emit(value);
}
onSearchSubmit(): void {
this.searchSubmit.emit(this.searchValue);
}
onFilterChange(key: string, event: Event): void {
const select = event.target as HTMLSelectElement;
const value = select.value;