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:
@@ -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.
|
||||||
@@ -246,17 +246,17 @@ async function main() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: `/releases/environments?${scopeQuery}`,
|
path: `/releases/environments?${scopeQuery}`,
|
||||||
expectedPath: '/ops/operations/environments',
|
expectedPath: '/releases/environments',
|
||||||
expectedTitle: /environments/i,
|
expectedTitle: /environments/i,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: `/release-control/environments?${scopeQuery}`,
|
path: `/release-control/environments?${scopeQuery}`,
|
||||||
expectedPath: '/ops/operations/environments',
|
expectedPath: '/releases/environments',
|
||||||
expectedTitle: /environments/i,
|
expectedTitle: /environments/i,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: `/setup/notifications?${scopeQuery}`,
|
path: `/setup/notifications?${scopeQuery}`,
|
||||||
expectedPath: '/ops/operations/notifications',
|
expectedPath: '/setup/notifications',
|
||||||
expectedTitle: /notifications/i,
|
expectedTitle: /notifications/i,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -323,8 +323,8 @@ export const routes: Routes = [
|
|||||||
redirectTo: preserveAppRedirect('/releases/promotions/:promotionId'),
|
redirectTo: preserveAppRedirect('/releases/promotions/:promotionId'),
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{ path: 'environments', redirectTo: '/ops/operations/environments', pathMatch: 'full' },
|
{ path: 'environments', redirectTo: preserveAppRedirect('/releases/environments'), pathMatch: 'full' },
|
||||||
{ path: 'regions', redirectTo: '/ops/operations/environments', pathMatch: 'full' },
|
{ path: 'regions', redirectTo: preserveAppRedirect('/releases/environments'), pathMatch: 'full' },
|
||||||
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
{ path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' },
|
||||||
{ path: 'setup/environments-paths', redirectTo: '/setup/topology/environments', pathMatch: 'full' },
|
{ path: 'setup/environments-paths', redirectTo: '/setup/topology/environments', pathMatch: 'full' },
|
||||||
{ path: 'setup/targets-agents', redirectTo: '/setup/topology/targets', pathMatch: 'full' },
|
{ path: 'setup/targets-agents', redirectTo: '/setup/topology/targets', pathMatch: 'full' },
|
||||||
|
|||||||
@@ -37,19 +37,21 @@ interface ApprovalRequest {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Status Filter -->
|
<!-- Filter Bar -->
|
||||||
<div class="status-filter">
|
<div class="filter-row">
|
||||||
@for (filter of statusFilters(); track filter.id) {
|
<div class="status-chips">
|
||||||
<button
|
@for (filter of statusFilters(); track filter.id) {
|
||||||
type="button"
|
<button
|
||||||
class="status-filter__btn"
|
type="button"
|
||||||
[class.status-filter__btn--active]="activeFilter() === filter.id"
|
class="chip"
|
||||||
(click)="setFilter(filter.id)"
|
[class.chip--active]="activeFilter() === filter.id"
|
||||||
>
|
(click)="setFilter(filter.id)"
|
||||||
{{ filter.label }}
|
>
|
||||||
<span class="status-filter__count">{{ filter.count }}</span>
|
{{ filter.label }}
|
||||||
</button>
|
<span class="chip__count">{{ filter.count }}</span>
|
||||||
}
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Approvals List -->
|
<!-- 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-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); }
|
.page-subtitle { margin: 0; color: var(--color-text-secondary); }
|
||||||
|
|
||||||
.status-filter {
|
.filter-row {
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.status-filter__btn {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.75rem;
|
||||||
padding: 0.5rem 1rem;
|
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);
|
background: var(--color-surface-primary);
|
||||||
border: 1px solid var(--color-border-primary);
|
border: 1px solid var(--color-border-primary);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-xl);
|
||||||
font-size: 0.875rem;
|
font-size: 0.8rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.status-filter__btn:hover { background: var(--color-nav-hover); }
|
.chip:hover { background: var(--color-nav-hover); }
|
||||||
.status-filter__btn--active {
|
.chip--active {
|
||||||
background: var(--color-brand-primary);
|
background: var(--color-brand-primary);
|
||||||
border-color: var(--color-brand-primary);
|
border-color: var(--color-brand-primary);
|
||||||
color: var(--color-text-heading);
|
color: #fff;
|
||||||
}
|
}
|
||||||
.status-filter__count {
|
.chip__count {
|
||||||
padding: 0.125rem 0.375rem;
|
padding: 0.0625rem 0.3rem;
|
||||||
background: rgba(0, 0, 0, 0.1);
|
background: rgba(0, 0, 0, 0.1);
|
||||||
border-radius: var(--radius-xl);
|
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; }
|
.approvals-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ interface HotfixRow {
|
|||||||
<p>Dedicated queue for expedited release-control promotions.</p>
|
<p>Dedicated queue for expedited release-control promotions.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<button type="button" class="create-btn">+ Create Hotfix</button>
|
|
||||||
|
|
||||||
@if (hotfixes.length === 0) {
|
@if (hotfixes.length === 0) {
|
||||||
<p class="empty">No active hotfixes.</p>
|
<p class="empty">No active hotfixes.</p>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -66,16 +64,6 @@ interface HotfixRow {
|
|||||||
font-size: 0.84rem;
|
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 {
|
.empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
type ReportTab = 'risk' | 'vex' | 'evidence';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-security-reports-page',
|
selector: 'app-security-reports-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
@@ -13,22 +15,143 @@ import { RouterLink } from '@angular/router';
|
|||||||
<p>Export posture snapshots, waiver ledgers, and evidence-linked risk summaries.</p>
|
<p>Export posture snapshots, waiver ledgers, and evidence-linked risk summaries.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ul>
|
<nav class="tabs" role="tablist">
|
||||||
<li><a routerLink="/security/triage">Risk Report (Triage)</a></li>
|
<button
|
||||||
<li><a routerLink="/security/disposition">VEX and Waiver Ledger</a></li>
|
type="button"
|
||||||
<li><a routerLink="/evidence/exports">Evidence Export Bundle</a></li>
|
role="tab"
|
||||||
</ul>
|
[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>
|
</section>
|
||||||
`,
|
`,
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
.security-reports { display: grid; gap: 1rem; }
|
.security-reports { display: grid; gap: 1rem; }
|
||||||
h1 { margin: 0; font-size: 1.35rem; }
|
h1 { margin: 0; font-size: 1.35rem; }
|
||||||
p { margin: 0; color: var(--color-text-secondary); }
|
header > p { margin: 0; color: var(--color-text-secondary); font-size: 0.85rem; }
|
||||||
ul { margin: 0; padding-left: 1.2rem; display: grid; gap: 0.45rem; }
|
|
||||||
a { color: var(--color-brand-primary); text-decoration: none; }
|
.tabs {
|
||||||
a:hover { text-decoration: underline; }
|
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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -225,12 +225,12 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectRouteTempla
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'release-orchestrator/environments',
|
path: 'release-orchestrator/environments',
|
||||||
redirectTo: '/ops/operations/environments',
|
redirectTo: '/releases/environments',
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'release-control/environments',
|
path: 'release-control/environments',
|
||||||
redirectTo: '/ops/operations/environments',
|
redirectTo: '/releases/environments',
|
||||||
pathMatch: 'full',
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -189,16 +189,23 @@ export const RELEASES_ROUTES: Routes = [
|
|||||||
(m) => m.EnvironmentPosturePageComponent,
|
(m) => m.EnvironmentPosturePageComponent,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
// Redirect environments to Operations (moved from Releases)
|
|
||||||
{
|
{
|
||||||
path: 'environments',
|
path: 'environments',
|
||||||
pathMatch: 'full',
|
title: 'Environments Inventory',
|
||||||
redirectTo: preserveReleasesRedirect('/ops/operations/environments'),
|
data: { breadcrumb: 'Environments' },
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../features/topology/topology-regions-environments-page.component').then(
|
||||||
|
(m) => m.TopologyRegionsEnvironmentsPageComponent,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'environments/:environmentId',
|
path: 'environments/:environmentId',
|
||||||
pathMatch: 'full',
|
title: 'Environment Detail',
|
||||||
redirectTo: preserveReleasesRedirect('/ops/operations/environments/:environmentId'),
|
data: { breadcrumb: 'Environment Detail' },
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../features/topology/topology-environment-detail-page.component').then(
|
||||||
|
(m) => m.TopologyEnvironmentDetailPageComponent,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'deployments',
|
path: 'deployments',
|
||||||
|
|||||||
@@ -41,12 +41,26 @@ describe('Route surface ownership', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects root environment shortcuts to Operations', () => {
|
it('redirects release-control environment shortcuts to canonical Releases routes', () => {
|
||||||
const environmentsRoute = findRouteByPath(routes, 'environments');
|
const releaseControlRoute = routes.find((route) => route.path === 'release-control');
|
||||||
const regionsRoute = findRouteByPath(routes, 'regions');
|
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');
|
const environmentsRedirect = environmentsRoute?.redirectTo;
|
||||||
expect(regionsRoute?.redirectTo).toBe('/ops/operations/environments');
|
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', () => {
|
it('mounts setup notifications as the admin studio surface', () => {
|
||||||
@@ -66,37 +80,18 @@ describe('Route surface ownership', () => {
|
|||||||
expect(typeof environmentDetailRoute?.loadComponent).toBe('function');
|
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 healthRoute = RELEASES_ROUTES.find((route) => route.path === 'health');
|
||||||
const environmentsRoute = RELEASES_ROUTES.find((route) => route.path === 'environments');
|
const environmentsRoute = RELEASES_ROUTES.find((route) => route.path === 'environments');
|
||||||
const environmentDetailRoute = RELEASES_ROUTES.find((route) => route.path === 'environments/:environmentId');
|
const environmentDetailRoute = RELEASES_ROUTES.find((route) => route.path === 'environments/:environmentId');
|
||||||
|
|
||||||
expect(healthRoute?.title).toBe('Release Health');
|
expect(healthRoute?.title).toBe('Release Health');
|
||||||
expect(typeof healthRoute?.loadComponent).toBe('function');
|
expect(typeof healthRoute?.loadComponent).toBe('function');
|
||||||
|
expect(typeof environmentsRoute?.loadComponent).toBe('function');
|
||||||
const environmentsRedirect = environmentsRoute?.redirectTo;
|
expect(typeof environmentDetailRoute?.loadComponent).toBe('function');
|
||||||
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', () => {
|
it('maps legacy release environment shortcuts to the canonical Releases inventory', () => {
|
||||||
const releaseOrchestratorRoute = LEGACY_REDIRECT_ROUTE_TEMPLATES.find(
|
const releaseOrchestratorRoute = LEGACY_REDIRECT_ROUTE_TEMPLATES.find(
|
||||||
(route) => route.path === 'release-orchestrator/environments',
|
(route) => route.path === 'release-orchestrator/environments',
|
||||||
);
|
);
|
||||||
@@ -104,7 +99,7 @@ describe('Route surface ownership', () => {
|
|||||||
(route) => route.path === 'release-control/environments',
|
(route) => route.path === 'release-control/environments',
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(releaseOrchestratorRoute?.redirectTo).toBe('/ops/operations/environments');
|
expect(releaseOrchestratorRoute?.redirectTo).toBe('/releases/environments');
|
||||||
expect(releaseControlRoute?.redirectTo).toBe('/ops/operations/environments');
|
expect(releaseControlRoute?.redirectTo).toBe('/releases/environments');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,18 +28,22 @@ export interface ActiveFilter {
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
<div class="filter-bar__search">
|
<div class="filter-bar__top-row">
|
||||||
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<div class="filter-bar__search">
|
||||||
<circle cx="11" cy="11" r="8"></circle>
|
<svg class="filter-bar__search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="m21 21-4.3-4.3"></path>
|
<circle cx="11" cy="11" r="8"></circle>
|
||||||
</svg>
|
<path d="m21 21-4.3-4.3"></path>
|
||||||
<input
|
</svg>
|
||||||
type="text"
|
<input
|
||||||
class="filter-bar__search-input"
|
type="text"
|
||||||
[placeholder]="searchPlaceholder"
|
class="filter-bar__search-input"
|
||||||
[(ngModel)]="searchValue"
|
[placeholder]="searchPlaceholder"
|
||||||
(ngModelChange)="onSearchChange($event)"
|
[(ngModel)]="searchValue"
|
||||||
/>
|
(ngModelChange)="onSearchChange($event)"
|
||||||
|
(keydown.enter)="onSearchSubmit()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="filter-bar__search-btn" (click)="onSearchSubmit()">Search</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-bar__filters">
|
<div class="filter-bar__filters">
|
||||||
@@ -78,8 +82,7 @@ export interface ActiveFilter {
|
|||||||
styles: [`
|
styles: [`
|
||||||
.filter-bar {
|
.filter-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: var(--color-surface-primary);
|
background: var(--color-surface-primary);
|
||||||
@@ -88,12 +91,35 @@ export interface ActiveFilter {
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filter-bar__top-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-bar__search {
|
.filter-bar__search {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 200px;
|
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 {
|
.filter-bar__search-icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0.75rem;
|
left: 0.75rem;
|
||||||
@@ -192,6 +218,7 @@ export class FilterBarComponent {
|
|||||||
@Input() activeFilters: ActiveFilter[] = [];
|
@Input() activeFilters: ActiveFilter[] = [];
|
||||||
|
|
||||||
@Output() searchChange = new EventEmitter<string>();
|
@Output() searchChange = new EventEmitter<string>();
|
||||||
|
@Output() searchSubmit = new EventEmitter<string>();
|
||||||
@Output() filterChange = new EventEmitter<ActiveFilter>();
|
@Output() filterChange = new EventEmitter<ActiveFilter>();
|
||||||
@Output() filterRemove = new EventEmitter<ActiveFilter>();
|
@Output() filterRemove = new EventEmitter<ActiveFilter>();
|
||||||
@Output() filtersCleared = new EventEmitter<void>();
|
@Output() filtersCleared = new EventEmitter<void>();
|
||||||
@@ -202,6 +229,10 @@ export class FilterBarComponent {
|
|||||||
this.searchChange.emit(value);
|
this.searchChange.emit(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSearchSubmit(): void {
|
||||||
|
this.searchSubmit.emit(this.searchValue);
|
||||||
|
}
|
||||||
|
|
||||||
onFilterChange(key: string, event: Event): void {
|
onFilterChange(key: string, event: Event): void {
|
||||||
const select = event.target as HTMLSelectElement;
|
const select = event.target as HTMLSelectElement;
|
||||||
const value = select.value;
|
const value = select.value;
|
||||||
|
|||||||
Reference in New Issue
Block a user