feat(ui): ship contextual action primitives

This commit is contained in:
master
2026-03-08 00:02:02 +02:00
parent a295841d25
commit f709d519ec
30 changed files with 1446 additions and 392 deletions

View File

@@ -31,7 +31,7 @@
## Delivery Tracker
### FE-CA-001 - Implement the shared route-state contract
Status: TODO
Status: DONE
Dependency: none
Owners: FE Architect, Product Manager
Task description:
@@ -39,12 +39,12 @@ Task description:
- Make the restoration topics consume one working route-state model instead of each inventing bespoke state handling.
Completion criteria:
- [ ] Shared route-state helpers exist in code.
- [ ] Restoration topics can consume one route-state contract instead of bespoke state rules.
- [ ] The placement hierarchy remains documented as the policy for using the new helpers.
- [x] Shared route-state helpers exist in code.
- [x] Restoration topics can consume one route-state contract instead of bespoke state rules.
- [x] The placement hierarchy remains documented as the policy for using the new helpers.
### FE-CA-002 - Ship the shared contextual drawer host
Status: TODO
Status: DONE
Dependency: FE-CA-001
Owners: Developer, FE Architect
Task description:
@@ -52,12 +52,12 @@ Task description:
- Standardize size, close behavior, route-state binding, keyboard handling, and history interactions in working code.
Completion criteria:
- [ ] Drawer host is available for adoption in the restoration features.
- [ ] Route-state open and close behavior works in code.
- [ ] Accessibility and keyboard behavior are verified for the shared host.
- [x] Drawer host is available for adoption in the restoration features.
- [x] Route-state open and close behavior works in code.
- [x] Accessibility and keyboard behavior are verified for the shared host.
### FE-CA-003 - Ship split-view, right-rail, and context-header primitives
Status: TODO
Status: DONE
Dependency: FE-CA-001
Owners: Developer, FE Architect
Task description:
@@ -65,12 +65,12 @@ Task description:
- Ensure responsive behavior works in the shipped components rather than remaining a note in docs.
Completion criteria:
- [ ] Split-view, right-rail, and context-header primitives exist in code.
- [ ] Panel-stack behavior is usable in the shipped primitives.
- [ ] Responsive fallback behavior works in the adopted surfaces.
- [x] Split-view, right-rail, and context-header primitives exist in code.
- [x] Panel-stack behavior is usable in the shipped primitives.
- [x] Responsive fallback behavior works in the adopted surfaces.
### FE-CA-004 - Ship grouped overview-card and submenu primitives
Status: TODO
Status: DONE
Dependency: FE-CA-001
Owners: Product Manager, Developer
Task description:
@@ -78,12 +78,12 @@ Task description:
- Standardize one-card-to-one-route and one-submenu-to-one-owner patterns in working components.
Completion criteria:
- [ ] Grouped overview-card primitives exist in code.
- [ ] Submenu patterns are usable by owner shells.
- [ ] Card-to-route and submenu-to-owner behavior is consistent in the shipped implementation.
- [x] Grouped overview-card primitives exist in code.
- [x] Submenu patterns are usable by owner shells.
- [x] Card-to-route and submenu-to-owner behavior is consistent in the shipped implementation.
### FE-CA-005 - Adopt the shared primitives into the restoration features
Status: TODO
Status: DONE
Dependency: FE-CA-001
Owners: FE Architect, Developer
Task description:
@@ -91,12 +91,12 @@ Task description:
- Do not count this sprint complete until the primitives are used by the first shipped feature set rather than sitting unused in `shared`.
Completion criteria:
- [ ] At least the Watchlist, Reachability, and Triage or Workflow surfaces adopt the shared primitives.
- [ ] Shared primitives replace bespoke implementations where the new restoration work lands.
- [ ] Topic-specific adoption is visible in the shipped feature code.
- [x] At least the Watchlist, Reachability, and Triage or Workflow surfaces adopt the shared primitives.
- [x] Shared primitives replace bespoke implementations where the new restoration work lands.
- [x] Topic-specific adoption is visible in the shipped feature code.
### FE-CA-006 - Verify, document, and enforce shared usage
Status: TODO
Status: DONE
Dependency: FE-CA-003
Owners: QA, Documentation author
Task description:
@@ -104,14 +104,19 @@ Task description:
- Update docs so future restoration work treats these primitives as required building blocks, not optional helpers.
Completion criteria:
- [ ] Shared verification covers the adopted primitives.
- [ ] Restoration sprints reference and consume the shared primitives.
- [ ] Shared docs are updated to reflect the shipped primitive set.
- [x] Shared verification covers the adopted primitives.
- [x] Restoration sprints reference and consume the shared primitives.
- [x] Shared docs are updated to reflect the shipped primitive set.
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-07 | Sprint created to ship the shared primitives that let restored but narrow functionality become usable submenus, tabs, drawers, right rails, and detail pages instead of spawning new top-level products. | Project Manager |
| 2026-03-07 | Implemented shared contextual primitives in `src/app/shared/ui`, including route-state helpers, contextual header, drawer host, split list-detail shell, grouped overview cards, and submenu-capable tabs. | Developer |
| 2026-03-07 | Adopted the shared primitives into Watchlist, Reachability, Operations, and Workflow Replay so the first restoration shells no longer depend on bespoke route-state or layout wiring. | Developer |
| 2026-03-07 | Tier 1 verification passed via `npx ng test --watch=false --include src/tests/shared/contextual-actions-patterns-ui.spec.ts --include src/tests/watchlist/identity-watchlist-management-ui.component.spec.ts --include src/tests/reachability_center/reachability-center.component.spec.ts --include src/tests/platform-ops/platform-ops-overview-page.component.spec.ts --include src/tests/workflow_visualization/run-graph-replay-page.behavior.spec.ts` with 24 focused tests passing. | QA |
| 2026-03-07 | Tier 2 verification passed via `npx playwright test tests/e2e/watchlist-shell.spec.ts tests/e2e/reachability-witnessing.spec.ts tests/e2e/operations-consolidation.spec.ts tests/e2e/workflow-visualization-replay.spec.ts --workers=1` with 9 behavior checks passing across the adopted surfaces. | QA |
| 2026-03-07 | Sprint archived after docs sync and checked-feature note publication for the shipped contextual action primitives. | Project Manager |
## Decisions & Risks
- Decision: contextual placement is a shared FE concern and should not be reinvented per topic.
@@ -122,8 +127,15 @@ Completion criteria:
- Mitigation: require responsive fallback rules in the shared primitive contract before implementation begins.
- Delivery rule: this sprint is only complete when the shared primitives are implemented and adopted by the restoration features, not when the contract is only documented.
- Reference design note: `docs/modules/ui/contextual-actions-patterns/README.md`.
- Decision: the stable cross-shell contract is now `tab`, `panel`, `drawer`, `returnTo`, `scope`, and `view`, implemented centrally under `src/app/shared/ui/context-route-state`.
- Decision: route-aware contextual headers and drawer hosts replaced bespoke implementations in Watchlist, Reachability, Workflow Replay, and Operations before the sprint was closed.
- Docs synced:
- `docs/modules/ui/contextual-actions-patterns/README.md`
- `docs/modules/ui/restoration-topics/README.md`
- `docs/modules/ui/component-preservation-map/RESTORATION_PRIORITIES.md`
- `docs/modules/ui/implementation_plan.md`
- `docs/modules/ui/TASKS.md`
- `docs/features/checked/web/contextual-actions-patterns-ui.md`
## Next Checkpoints
- 2026-03-08: confirm placement hierarchy and route-state contract.
- 2026-03-09: freeze drawer, right-rail, split-view, and context-header primitives.
- 2026-03-10: finalize adoption map and QA expectations for the restoration sprints.
- Archived on 2026-03-07 after implementation, adoption, verification, and docs sync.

View File

@@ -0,0 +1,55 @@
# Contextual Actions Patterns UI
## Module
Web
## Status
VERIFIED
## Description
Shipped the shared contextual placement primitives used to turn restored but narrow functionality into usable owner-shell tabs, drawers, submenu pills, and list-detail layouts instead of standalone products. Watchlist, Reachability, Operations, and Workflow Replay now share one route-state contract, one contextual header model, and one drawer or detail-shell behavior.
## Implementation Details
- **Shared feature directory**: `src/Web/StellaOps.Web/src/app/shared/ui/`
- **Primary shared primitives**:
- `context-route-state` (`src/Web/StellaOps.Web/src/app/shared/ui/context-route-state/context-route-state.ts`)
- `context-header` (`src/Web/StellaOps.Web/src/app/shared/ui/context-header/context-header.component.ts`)
- `context-drawer-host` (`src/Web/StellaOps.Web/src/app/shared/ui/context-drawer-host/context-drawer-host.component.ts`)
- `list-detail-shell` (`src/Web/StellaOps.Web/src/app/shared/ui/list-detail-shell/list-detail-shell.component.ts`)
- `overview-card-groups` (`src/Web/StellaOps.Web/src/app/shared/ui/overview-card-groups/overview-card-groups.component.ts`)
- `tabbed-nav` (`src/Web/StellaOps.Web/src/app/shared/ui/tabbed-nav/tabbed-nav.component.ts`)
- **Shared route-state keys**:
- `tab`
- `panel`
- `drawer`
- `returnTo`
- `scope`
- `view`
- **Adopted surfaces**:
- `Setup > Trust & Signing > Identity Watchlist`
- `Security > Reachability`
- `Ops > Operations`
- `Releases > Runs`
## E2E Test Plan
- **Setup**:
- [ ] Log in with a user that can access `Setup`, `Security`, `Ops`, and `Releases`.
- [ ] Ensure watchlist, reachability, operations, and release-run fixtures or seeded data exist.
- **Core verification**:
- [ ] Verify shared tab and submenu primitives render the adopted shells without bespoke route-state code.
- [ ] Verify contextual headers preserve return-to-context actions where shells deep-link into another owner flow.
- [ ] Verify route-aware drawers and detail layouts preserve URL state and close behavior.
- **Adoption verification**:
- [ ] Verify Watchlist entries use the shared list-detail and header patterns.
- [ ] Verify Reachability tabs and PoE drill-ins use the shared contextual route-state.
- [ ] Verify Operations overview groups use the shared grouped-card and submenu patterns.
- [ ] Verify Workflow Replay step-detail and evidence handoff keep shared `returnTo` and drawer semantics.
## Verification
- Run:
- `npx ng test --watch=false --include src/tests/shared/contextual-actions-patterns-ui.spec.ts --include src/tests/watchlist/identity-watchlist-management-ui.component.spec.ts --include src/tests/reachability_center/reachability-center.component.spec.ts --include src/tests/platform-ops/platform-ops-overview-page.component.spec.ts --include src/tests/workflow_visualization/run-graph-replay-page.behavior.spec.ts`
- `npx playwright test tests/e2e/watchlist-shell.spec.ts tests/e2e/reachability-witnessing.spec.ts tests/e2e/operations-consolidation.spec.ts tests/e2e/workflow-visualization-replay.spec.ts --workers=1`
- Tier 0 (source): pass
- Tier 1 (build/tests): pass
- Tier 2 (behavior): pass
- Verified on (UTC): 2026-03-07T21:56:33.4410778Z

View File

@@ -22,6 +22,8 @@ The Console presents operator dashboards for scans, policies, VEX evidence, runt
- Added checked-feature verification for reachability witnessing at `../../features/checked/web/reachability-witnessing-ui.md`.
- Shipped the consolidated `Ops > Operations` shell with grouped overview cards, canonical `/ops/operations/*` routes, and legacy `platform-ops` alias cutover.
- Added checked-feature verification for operations consolidation at `../../features/checked/web/operations-consolidation-ui.md`.
- Shipped the shared contextual placement primitives for tabs, submenu pills, route-aware drawers, list-detail shells, grouped overview cards, and return-to-context headers under `src/Web/StellaOps.Web/src/app/shared/ui/`.
- Added checked-feature verification for the contextual primitives and their first adopted surfaces at `../../features/checked/web/contextual-actions-patterns-ui.md`.
## Latest updates (2026-02-21)
- Runtime mock cutover completed for policy simulation history/conflict/batch flows and graph explorer data loading in `src/Web/StellaOps.Web/src/app/**`.

View File

@@ -9,7 +9,6 @@
- `docs/implplan/SPRINT_20260307_009_DOCS_ui_component_preservation_map.md`
- `docs/implplan/SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md`
- `docs/implplan/SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md`
- `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`
- `docs/implplan/SPRINT_20260307_035_DOCS_search_first_final_correction_phases.md`
- `docs/implplan/SPRINT_20260307_036_FE_search_first_shell_consolidation.md`
- `docs/implplan/SPRINT_20260307_038_FE_live_search_supported_route_execution_matrix.md`
@@ -107,9 +106,9 @@
- [DONE] FE-WV-004 Step-detail drawer and deep-link behavior
- [DONE] FE-WV-005 Workflow-editor preview reuse boundary
- [DONE] FE-WV-006 QA, rollout, alias migration, and docs sync for workflow visualization
- [TODO] FE-CA-001 Freeze contextual placement decision matrix and route-state contract
- [TODO] FE-CA-002 Shared contextual drawer host
- [TODO] FE-CA-003 Split list-detail and right-rail primitives
- [TODO] FE-CA-004 Context header and return-to-context contract
- [TODO] FE-CA-005 Grouped overview-card and submenu patterns
- [TODO] FE-CA-006 Adoption map, QA, and docs sync for contextual action patterns
- [DONE] FE-CA-001 Freeze contextual placement decision matrix and route-state contract
- [DONE] FE-CA-002 Shared contextual drawer host
- [DONE] FE-CA-003 Split list-detail and right-rail primitives
- [DONE] FE-CA-004 Context header and return-to-context contract
- [DONE] FE-CA-005 Grouped overview-card and submenu patterns
- [DONE] FE-CA-006 Adoption map, QA, and docs sync for contextual action patterns

View File

@@ -54,7 +54,7 @@ The order is by confidence that the capability should exist in the final Stella
- `Security / Reachability` with evidence-side drill-down links
- Notes:
- Detailed UX dossier: `docs/modules/ui/reachability-witnessing/README.md`
- Implementation sprint: `docs/implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md`
- Implementation sprint: `docs-archived/implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md`
### 4. Platform Ops Consolidation
- Type: `merge`
@@ -67,7 +67,7 @@ The order is by confidence that the capability should exist in the final Stella
- `Ops > Operations`
- Notes:
- Detailed UX dossier: `docs/modules/ui/platform-ops-consolidation/README.md`
- Implementation sprint: `docs/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md`
- Implementation sprint: `docs-archived/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md`
### 5. Triage Explainability Workbench
- Type: `merge`
@@ -80,7 +80,7 @@ The order is by confidence that the capability should exist in the final Stella
- `/triage/artifacts` and `/triage/audit-bundles`
- Notes:
- Detailed UX dossier: `docs/modules/ui/triage-explainability-workspace/README.md`
- Implementation sprint: `docs/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md`
- Implementation sprint: `docs-archived/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md`
### 6. Workflow Visualization And Replay UX
- Type: `merge`
@@ -290,4 +290,4 @@ These branches probably contain valuable pieces, but the right home needs one mo
8. After that, tackle the big surfacing debt bucket: audit, offline, scanner, quota, topology, trust, unknowns.
Detailed topic-shape notes for items 2 through 6 now live under `docs/modules/ui/restoration-topics/`.
The shared placement contract for stray actions, drawers, tabs, and detail pages is captured in `docs/modules/ui/contextual-actions-patterns/README.md` and implementation sprint `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`.
The shared placement contract for stray actions, drawers, tabs, and detail pages is captured in `docs/modules/ui/contextual-actions-patterns/README.md`, shipped implementation sprint `docs-archived/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`, and verification note `docs/features/checked/web/contextual-actions-patterns-ui.md`.

View File

@@ -115,6 +115,36 @@ Use stable, predictable query params and child routes instead of ad hoc local st
- row or bulk actions that preserve page context
- confirmation only for destructive or privilege-sensitive actions
## Shipped Implementation
The shared contract is now implemented under `src/Web/StellaOps.Web/src/app/shared/ui/` and should be treated as the default building block set for restoration work.
### Shipped primitives
- `context-route-state`
- central helpers for `tab`, `panel`, `drawer`, `returnTo`, `scope`, and `view`
- includes `readContextRouteState`, `readContextRouteParam`, `buildContextRouteParams`, and `buildContextReturnTo`
- `context-header`
- stable subject header with chips, context note, and return-to-context action
- `context-drawer-host`
- overlay or rail presentation with shared close behavior, escape handling, and testable route-state integration
- `list-detail-shell`
- responsive split list/detail layout for owner shells with one dominant list workflow
- `overview-card-groups`
- grouped overview cards with one-card-to-one-route behavior
- `tabbed-nav`
- now supports both classic tabs and owner-shell submenu pills, plus route command arrays and shared query params
### First adopted surfaces
- Watchlist uses the shared route-state, contextual header, tabs, and list-detail shell.
- Reachability uses the shared route-state, contextual header, and tabs.
- Operations uses the shared submenu and grouped overview-card patterns.
- Workflow Replay uses the shared route-state, contextual header, tabs, and drawer host.
### Delivery rule
- New restoration work should adopt these primitives before introducing new feature-local panel or route-state helpers.
- Context-preserving deep links should use `returnTo` instead of bespoke navigation metadata.
- Owner shells should prefer submenu pills, tabs, list-detail layouts, or drawers before creating another top-level route tree.
## Topic Mapping
### Watchlist
@@ -153,3 +183,4 @@ Use stable, predictable query params and child routes instead of ad hoc local st
- `docs/ui-analysis/05_ROUTE_SUMMARY_AND_OBSERVATIONS.md`
- `docs/modules/ui/architecture.md`
- `docs/modules/ui/architecture-rework.md`
- `docs/features/checked/web/contextual-actions-patterns-ui.md`

View File

@@ -13,7 +13,6 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
- `SPRINT_20260307_009_DOCS_ui_component_preservation_map.md` - per-component preservation dossiers for unused and weakly surfaced console UI components.
- `SPRINT_20260307_022_FE_policy_vex_release_decisioning_studio.md` - canonical Decisioning Studio shell to unify policy, simulation, VEX decisioning, and release-context gate explanation.
- `SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.md` - documentation prerequisite for shell/menu/tab placements; not a product-delivery sprint by itself.
- `SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md` - ship the shared tabs, drawers, right rails, split views, and contextual detail primitives adopted by the restoration features.
## Latest evidence
- `docs/modules/ui/component-preservation-map/README.md` - root index for the first-pass preservation map.
@@ -27,6 +26,7 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
- `docs/features/checked/web/operations-consolidation-ui.md` - shipped verification note for the canonical Operations shell, overview grouping, and legacy alias cutover.
- `docs/features/checked/web/triage-explainability-workspace-ui.md` - shipped verification note for the canonical triage artifact workspace, explainability rail, audit bundles, and security alias cutover.
- `docs/features/checked/web/workflow-visualization-replay-ui.md` - shipped verification note for the canonical run-detail graph, timeline, replay, evidence tabs, and workflow-editor preview reuse boundary.
- `docs/features/checked/web/contextual-actions-patterns-ui.md` - shipped verification note for the shared contextual route-state, headers, drawers, list-detail shells, grouped overview cards, and first adopted restoration surfaces.
- `docs/modules/ui/reachability-witnessing/README.md` - detailed witness and proof UX dossier plus cross-shell deep-link contract.
- `docs/modules/ui/platform-ops-consolidation/README.md` - detailed Operations overview taxonomy and legacy absorption plan.
- `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier.

View File

@@ -28,11 +28,11 @@ It answers four questions for each topic:
## Implementation Sprint Set
- `docs-archived/implplan/SPRINT_20260307_024_FE_identity_watchlist_shell.md` - shipped watchlist restoration
- `docs/implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md`
- `docs/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md`
- `docs/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md`
- `docs-archived/implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md` - shipped reachability witnessing restoration
- `docs-archived/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md` - shipped platform ops consolidation
- `docs-archived/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md` - shipped triage explainability restoration
- `docs-archived/implplan/SPRINT_20260307_028_FE_workflow_visualization_replay.md` - shipped workflow visualization and replay restoration
- `docs/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md`
- `docs-archived/implplan/SPRINT_20260307_029_FE_contextual_actions_and_stray_surfaces.md` - shipped shared contextual primitives
## Placement Matrix

View File

@@ -93,7 +93,7 @@ One overview page plus grouped subroutes is enough.
## Detailed UX And Sprint
- Detailed UX dossier: `../platform-ops-consolidation/README.md`
- Implementation sprint: `../../../implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md`
- Implementation sprint: `../../../docs-archived/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md`
## Corroborating Inputs

View File

@@ -97,7 +97,7 @@ These should deep-link into the same reachability surfaces:
## Detailed UX And Sprint
- Detailed UX dossier: `../reachability-witnessing/README.md`
- Implementation sprint: `../../../implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md`
- Implementation sprint: `../../../docs-archived/implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md`
## Corroborating Inputs

View File

@@ -108,7 +108,7 @@ These should be secondary tabs or a right-rail stack, not standalone routes.
## Detailed UX And Sprint
- Detailed UX dossier: `../triage-explainability-workspace/README.md`
- Implementation sprint: `../../../implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md`
- Implementation sprint: `../../../docs-archived/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md`
## Corroborating Inputs

View File

@@ -23,17 +23,7 @@
</div>
</header>
<nav class="ops-overview__submenu" aria-label="Operations navigation">
@for (item of quickNav; track item.id) {
<a
class="ops-overview__submenu-link"
[routerLink]="item.route"
[attr.data-testid]="'operations-nav-' + item.id"
>
{{ item.label }}
</a>
}
</nav>
<app-tabbed-nav [tabs]="quickNav" variant="submenu" />
<section class="ops-overview__summary" aria-label="Operations posture summary">
<article>
@@ -86,36 +76,11 @@
<st-doctor-checks-inline category="core" heading="Critical diagnostics" />
@for (group of overviewGroups; track group.id) {
<section class="ops-overview__group" [attr.data-testid]="'operations-group-' + group.id">
<div class="ops-overview__section-header">
<div>
<h2>{{ group.title }}</h2>
<p>{{ group.description }}</p>
</div>
</div>
<div class="ops-overview__group-grid">
@for (card of group.cards; track card.id) {
<a
class="ops-card"
[routerLink]="card.route"
[attr.data-testid]="'operations-card-' + card.id"
>
<div class="ops-card__header">
<span class="ops-card__owner" [class.ops-card__owner--setup]="card.owner === 'Setup'">
{{ card.owner }}
</span>
<span class="impact" [class]="'impact impact--' + card.impact">{{ card.metric }}</span>
</div>
<h3>{{ card.title }}</h3>
<p>{{ card.detail }}</p>
</a>
}
</div>
</section>
}
<app-overview-card-groups
[groups]="overviewGroups"
groupTestIdPrefix="operations-group"
cardTestIdPrefix="operations-card"
/>
<section class="ops-overview__footer-grid">
<article class="ops-overview__panel" data-testid="operations-pending-actions">

View File

@@ -2,6 +2,12 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { DoctorChecksInlineComponent } from '../../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
import {
OverviewCardGroupsComponent,
type OverviewCardGroup,
type TabItem,
TabbedNavComponent,
} from '../../../shared/ui';
import {
OPERATIONS_INTEGRATION_PATHS,
OPERATIONS_PATHS,
@@ -11,12 +17,6 @@ import {
type OpsImpact = 'blocking' | 'degraded' | 'info';
interface OverviewNavItem {
readonly id: string;
readonly label: string;
readonly route: string;
}
interface BlockingCard {
readonly id: string;
readonly title: string;
@@ -26,23 +26,6 @@ interface BlockingCard {
readonly route: string;
}
interface OperationsCard {
readonly id: string;
readonly title: string;
readonly detail: string;
readonly metric: string;
readonly impact: OpsImpact;
readonly route: string;
readonly owner: 'Ops' | 'Setup';
}
interface OperationsGroup {
readonly id: string;
readonly title: string;
readonly description: string;
readonly cards: readonly OperationsCard[];
}
interface PendingAction {
readonly id: string;
readonly title: string;
@@ -54,7 +37,7 @@ interface PendingAction {
@Component({
selector: 'app-platform-ops-overview-page',
standalone: true,
imports: [RouterLink, DoctorChecksInlineComponent],
imports: [RouterLink, DoctorChecksInlineComponent, TabbedNavComponent, OverviewCardGroupsComponent],
templateUrl: './platform-ops-overview-page.component.html',
styleUrls: ['./platform-ops-overview-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -64,19 +47,19 @@ export class PlatformOpsOverviewPageComponent {
readonly OPERATIONS_SETUP_PATHS = OPERATIONS_SETUP_PATHS;
readonly refreshedAt = signal<string | null>(null);
readonly quickNav: readonly OverviewNavItem[] = [
{ id: 'overview', label: 'Overview', route: OPERATIONS_PATHS.overview },
{ id: 'data-integrity', label: 'Data Integrity', route: OPERATIONS_PATHS.dataIntegrity },
{ id: 'jobs-queues', label: 'Jobs & Queues', route: OPERATIONS_PATHS.jobsQueues },
{ id: 'health-slo', label: 'Health & SLO', route: OPERATIONS_PATHS.healthSlo },
{ id: 'feeds-airgap', label: 'Feeds & Airgap', route: OPERATIONS_PATHS.feedsAirgap },
{ id: 'offline-kit', label: 'Offline Kit', route: OPERATIONS_PATHS.offlineKit },
{ id: 'quotas', label: 'Quotas & Limits', route: OPERATIONS_PATHS.quotas },
{ id: 'aoc', label: 'AOC Compliance', route: OPERATIONS_PATHS.aoc },
{ id: 'doctor', label: 'Diagnostics', route: OPERATIONS_PATHS.doctor },
{ id: 'signals', label: 'Signals', route: OPERATIONS_PATHS.signals },
{ id: 'packs', label: 'Pack Registry', route: OPERATIONS_PATHS.packs },
{ id: 'notifications', label: 'Notifications', route: OPERATIONS_PATHS.notifications },
readonly quickNav: readonly TabItem[] = [
{ id: 'overview', label: 'Overview', route: OPERATIONS_PATHS.overview, testId: 'operations-nav-overview' },
{ id: 'data-integrity', label: 'Data Integrity', route: OPERATIONS_PATHS.dataIntegrity, testId: 'operations-nav-data-integrity' },
{ id: 'jobs-queues', label: 'Jobs & Queues', route: OPERATIONS_PATHS.jobsQueues, testId: 'operations-nav-jobs-queues' },
{ id: 'health-slo', label: 'Health & SLO', route: OPERATIONS_PATHS.healthSlo, testId: 'operations-nav-health-slo' },
{ id: 'feeds-airgap', label: 'Feeds & Airgap', route: OPERATIONS_PATHS.feedsAirgap, testId: 'operations-nav-feeds-airgap' },
{ id: 'offline-kit', label: 'Offline Kit', route: OPERATIONS_PATHS.offlineKit, testId: 'operations-nav-offline-kit' },
{ id: 'quotas', label: 'Quotas & Limits', route: OPERATIONS_PATHS.quotas, testId: 'operations-nav-quotas' },
{ id: 'aoc', label: 'AOC Compliance', route: OPERATIONS_PATHS.aoc, testId: 'operations-nav-aoc' },
{ id: 'doctor', label: 'Diagnostics', route: OPERATIONS_PATHS.doctor, testId: 'operations-nav-doctor' },
{ id: 'signals', label: 'Signals', route: OPERATIONS_PATHS.signals, testId: 'operations-nav-signals' },
{ id: 'packs', label: 'Pack Registry', route: OPERATIONS_PATHS.packs, testId: 'operations-nav-packs' },
{ id: 'notifications', label: 'Notifications', route: OPERATIONS_PATHS.notifications, testId: 'operations-nav-notifications' },
];
readonly blockingCards: readonly BlockingCard[] = [
@@ -106,7 +89,7 @@ export class PlatformOpsOverviewPageComponent {
},
];
readonly overviewGroups: readonly OperationsGroup[] = [
readonly overviewGroups: readonly OverviewCardGroup[] = [
{
id: 'blocking',
title: 'Blocking',

View File

@@ -1,34 +1,21 @@
<section class="reachability-shell" data-testid="reachability-page">
<header class="shell-header">
<div>
<p class="eyebrow">Security / Reachability</p>
<h1>Reachability</h1>
<p class="subtitle">
Coverage, witnesses, proof-of-exposure artifacts, and sensor gaps stay in one investigation shell.
</p>
</div>
<div class="header-actions">
@if (returnTo()) {
<button
type="button"
class="btn-secondary"
(click)="returnToSource()"
data-testid="reachability-return-btn"
>
Return to {{ returnToLabel() }}
</button>
}
<button
type="button"
class="btn-secondary"
(click)="loadWitnesses()"
data-testid="reachability-refresh-btn"
>
Refresh witnesses
</button>
</div>
</header>
<app-context-header
eyebrow="Security / Reachability"
title="Reachability"
subtitle="Coverage, witnesses, proof-of-exposure artifacts, and sensor gaps stay in one investigation shell."
[backLabel]="returnTo() ? 'Return to ' + returnToLabel() : null"
(backClick)="returnToSource()"
>
<button
header-actions
type="button"
class="btn-secondary"
(click)="loadWitnesses()"
data-testid="reachability-refresh-btn"
>
Refresh witnesses
</button>
</app-context-header>
@if (message()) {
<div class="message-banner" [class.error]="messageType() === 'error'">
@@ -60,40 +47,11 @@
</article>
</section>
<nav class="tab-strip" aria-label="Reachability tabs">
<button
type="button"
data-testid="reachability-tab-coverage"
[class.active]="activeTab() === 'coverage'"
(click)="showCoverage()"
>
Coverage
</button>
<button
type="button"
data-testid="reachability-tab-witnesses"
[class.active]="activeTab() === 'witnesses'"
(click)="showWitnesses()"
>
Witnesses
</button>
<button
type="button"
data-testid="reachability-tab-poe"
[class.active]="activeTab() === 'poe'"
(click)="showPoE()"
>
PoE / Exposure
</button>
<button
type="button"
data-testid="reachability-tab-gaps"
[class.active]="activeTab() === 'gaps'"
(click)="showGaps()"
>
Sensor Gaps
</button>
</nav>
<app-tabbed-nav
[tabs]="tabItems"
[activeTab]="activeTab()"
(tabChange)="onTabSelected($event.id)"
/>
@if (activeTab() === 'coverage') {
<section class="panel-stack">

View File

@@ -17,6 +17,16 @@ import type {
ConfidenceTier,
ReachabilityWitness,
} from '../../core/api/witness.models';
import {
ContextHeaderComponent,
type TabItem,
TabbedNavComponent,
} from '../../shared/ui';
import {
buildContextRouteParams,
readContextRouteParam,
readContextRouteState,
} from '../../shared/ui/context-route-state/context-route-state';
import { PoEDrawerComponent } from './poe-drawer.component';
import {
type CoverageStatus,
@@ -55,7 +65,13 @@ const TIER_FILTERS: readonly TierFilter[] = [
@Component({
selector: 'app-reachability-center',
standalone: true,
imports: [CommonModule, RouterLink, PoEDrawerComponent],
imports: [
CommonModule,
RouterLink,
PoEDrawerComponent,
ContextHeaderComponent,
TabbedNavComponent,
],
templateUrl: './reachability-center.component.html',
styleUrls: ['./reachability-center.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -81,6 +97,12 @@ export class ReachabilityCenterComponent implements OnInit {
readonly coverageRows = signal([...REACHABILITY_COVERAGE_ROWS]);
readonly gapRows = signal([...REACHABILITY_GAP_ROWS]);
readonly witnesses = signal<ReachabilityWitness[]>([]);
readonly tabItems: readonly TabItem[] = [
{ id: 'coverage', label: 'Coverage', testId: 'reachability-tab-coverage' },
{ id: 'witnesses', label: 'Witnesses', testId: 'reachability-tab-witnesses' },
{ id: 'poe', label: 'PoE / Exposure', testId: 'reachability-tab-poe' },
{ id: 'gaps', label: 'Sensor Gaps', testId: 'reachability-tab-gaps' },
];
readonly filteredCoverageRows = computed(() => {
const status = this.coverageStatusFilter();
@@ -243,6 +265,24 @@ export class ReachabilityCenterComponent implements OnInit {
void this.navigateToTab('gaps');
}
onTabSelected(tabId: string): void {
switch (tabId as ReachabilityTab) {
case 'witnesses':
this.showWitnesses();
break;
case 'poe':
this.showPoE();
break;
case 'gaps':
this.showGaps();
break;
case 'coverage':
default:
this.showCoverage();
break;
}
}
openPoeArtifact(artifactId: string): void {
this.selectedPoeArtifactId.set(artifactId);
void this.navigateToTab('poe', artifactId);
@@ -357,29 +397,28 @@ export class ReachabilityCenterComponent implements OnInit {
params: ParamMap,
queryParams: ParamMap
): void {
const tab = this.parseTab(segments, queryParams.get('tab'));
const tab = this.parseTab(segments, queryParams);
this.activeTab.set(tab);
this.returnTo.set(queryParams.get('returnTo'));
this.witnessSearch.set(queryParams.get('search') ?? '');
this.tierFilter.set(this.parseTier(queryParams.get('tier')));
this.returnTo.set(readContextRouteParam(queryParams, 'returnTo'));
this.witnessSearch.set(readContextRouteParam(queryParams, 'search') ?? '');
this.tierFilter.set(this.parseTier(readContextRouteParam(queryParams, 'tier')));
this.selectedPoeArtifactId.set(
tab === 'poe' ? params.get('artifactId') : null
tab === 'poe' ? readContextRouteParam(params, 'artifactId') : null
);
}
private parseTab(
segments: readonly string[],
queryValue: string | null
queryParams: ParamMap
): ReachabilityTab {
if (queryValue && REACHABILITY_TABS.includes(queryValue as ReachabilityTab)) {
return queryValue as ReachabilityTab;
}
const firstRecognized = segments.find((segment) =>
REACHABILITY_TABS.includes(segment as ReachabilityTab)
);
return (firstRecognized as ReachabilityTab | undefined) ?? 'coverage';
return (
(firstRecognized as ReachabilityTab | undefined) ??
readContextRouteState(queryParams, 'tab', REACHABILITY_TABS, 'coverage')
);
}
private parseTier(value: string | null): TierFilter {
@@ -406,21 +445,12 @@ export class ReachabilityCenterComponent implements OnInit {
}
private buildQueryParams(tab: ReachabilityTab): Record<string, string> {
const params: Record<string, string> = {
return buildContextRouteParams({
tab,
};
if (this.returnTo()) {
params['returnTo'] = this.returnTo()!;
}
if (this.witnessSearch().trim()) {
params['search'] = this.witnessSearch().trim();
}
if (this.tierFilter()) {
params['tier'] = this.tierFilter();
}
return params;
returnTo: this.returnTo(),
search: this.witnessSearch().trim() || null,
tier: this.tierFilter() || null,
}) as Record<string, string>;
}
private async verifyWitnessFallbackAware(

View File

@@ -1,25 +1,22 @@
<div class="watchlist-page" data-testid="watchlist-page">
<header class="page-header">
<div>
<p class="eyebrow">Trust &amp; Signing</p>
<h1>Identity Watchlist</h1>
<p class="subtitle">
Monitor signer identities, triage watchlist alerts, and tune dedup or routing controls from the trust shell.
</p>
<p class="mode">{{ currentModeLabel() }}</p>
</div>
<div class="header-actions">
@if (returnTo()) {
<button type="button" class="btn-secondary" (click)="returnToSource()">
Return to {{ returnToLabel() }}
</button>
}
<button type="button" class="btn-secondary" (click)="refreshCurrentTab()" [disabled]="loading()">
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
</div>
</header>
<app-context-header
eyebrow="Trust &amp; Signing"
title="Identity Watchlist"
subtitle="Monitor signer identities, triage watchlist alerts, and tune dedup or routing controls from the trust shell."
[contextNote]="currentModeLabel()"
[backLabel]="returnTo() ? 'Return to ' + returnToLabel() : null"
(backClick)="returnToSource()"
>
<button
header-actions
type="button"
class="btn-secondary"
(click)="refreshCurrentTab()"
[disabled]="loading()"
>
{{ loading() ? 'Refreshing...' : 'Refresh' }}
</button>
</app-context-header>
@if (message()) {
<div
@@ -72,32 +69,11 @@
</div>
</section>
<nav class="tabs" aria-label="Watchlist tabs">
<button
type="button"
data-testid="watchlist-tab-entries"
[class.active]="activeTab() === 'entries'"
(click)="showList()"
>
Entries
</button>
<button
type="button"
data-testid="watchlist-tab-alerts"
[class.active]="activeTab() === 'alerts'"
(click)="showAlerts()"
>
Alerts
</button>
<button
type="button"
data-testid="watchlist-tab-tuning"
[class.active]="activeTab() === 'tuning'"
(click)="showTuning()"
>
Tuning
</button>
</nav>
<app-tabbed-nav
[tabs]="tabItems"
[activeTab]="activeTab()"
(tabChange)="onTabSelected($event.id)"
/>
@if (activeTab() === 'entries') {
<section class="workspace">
@@ -132,8 +108,8 @@
</label>
</div>
<div class="workspace-grid" [class.workspace-grid--detail]="!!entryPanelMode()">
<div class="workspace-primary">
<app-list-detail-shell [detailVisible]="!!entryPanelMode()">
<div shell-primary class="workspace-primary">
@if (entriesLoading() && !entries().length) {
<div class="empty-state">Loading watchlist rules...</div>
} @else if (!filteredEntries().length) {
@@ -218,7 +194,7 @@
</div>
@if (entryPanelMode()) {
<aside class="detail-panel" data-testid="entry-detail">
<aside shell-detail class="detail-panel" data-testid="entry-detail">
<header class="detail-panel__header">
<div>
<p class="detail-eyebrow">Entries</p>
@@ -373,7 +349,7 @@
</section>
</aside>
}
</div>
</app-list-detail-shell>
</section>
}
@@ -422,8 +398,8 @@
</label>
</div>
<div class="workspace-grid" [class.workspace-grid--detail]="!!selectedAlert()">
<div class="workspace-primary">
<app-list-detail-shell [detailVisible]="!!selectedAlert()">
<div shell-primary class="workspace-primary">
@if (alertsLoading() && !alerts().length) {
<div class="empty-state">Loading watchlist alerts...</div>
} @else if (!filteredAlerts().length) {
@@ -481,7 +457,7 @@
</div>
@if (selectedAlert(); as alert) {
<aside class="detail-panel" data-testid="alert-detail">
<aside shell-detail class="detail-panel" data-testid="alert-detail">
<header class="detail-panel__header">
<div>
<p class="detail-eyebrow">Alert detail</p>
@@ -548,7 +524,7 @@
</div>
</aside>
}
</div>
</app-list-detail-shell>
</section>
}

View File

@@ -32,6 +32,17 @@ import {
WatchlistMatchMode,
WatchlistScope,
} from '../../core/api/watchlist.models';
import {
ContextHeaderComponent,
ListDetailShellComponent,
type TabItem,
TabbedNavComponent,
} from '../../shared/ui';
import {
buildContextRouteParams,
readContextRouteParam,
readContextRouteState,
} from '../../shared/ui/context-route-state/context-route-state';
type ViewMode = 'list' | 'edit' | 'alerts';
type WatchlistTab = 'entries' | 'alerts' | 'tuning';
@@ -62,7 +73,13 @@ const ALERT_SORT_ORDERS: readonly AlertSortOrder[] = ['newest', 'oldest'];
@Component({
selector: 'app-watchlist-page',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
imports: [
CommonModule,
ReactiveFormsModule,
ContextHeaderComponent,
ListDetailShellComponent,
TabbedNavComponent,
],
templateUrl: './watchlist-page.component.html',
styleUrls: ['./watchlist-page.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -118,6 +135,11 @@ export class WatchlistPageComponent implements OnInit {
'Critical',
];
readonly tabOptions: readonly WatchlistTab[] = WATCHLIST_TABS;
readonly tabItems: readonly TabItem[] = [
{ id: 'entries', label: 'Entries', testId: 'watchlist-tab-entries' },
{ id: 'alerts', label: 'Alerts', testId: 'watchlist-tab-alerts' },
{ id: 'tuning', label: 'Tuning', testId: 'watchlist-tab-tuning' },
];
readonly scopeOptions: readonly WatchlistScopeFilter[] = WATCHLIST_SCOPES;
readonly alertWindows: readonly AlertWindow[] = ALERT_WINDOWS;
readonly alertSortOptions: readonly AlertSortOrder[] = ALERT_SORT_ORDERS;
@@ -729,6 +751,21 @@ export class WatchlistPageComponent implements OnInit {
void this.loadEntries();
}
onTabSelected(tabId: string): void {
switch (tabId as WatchlistTab) {
case 'alerts':
this.showAlerts();
break;
case 'tuning':
this.showTuning();
break;
case 'entries':
default:
this.showList();
break;
}
}
changeScope(scope: WatchlistScopeFilter): void {
const entry = this.selectedEntry();
const alert = this.selectedAlert();
@@ -886,15 +923,15 @@ export class WatchlistPageComponent implements OnInit {
segments: readonly string[],
params: ParamMap
): void {
const tab = this.parseTab(segments, params.get('tab'));
const scope = this.parseScope(params.get('scope'));
const entryId = params.get('entryId');
const duplicateOf = params.get('duplicateOf');
const alertId = params.get('alertId');
const tab = this.parseTab(segments, params);
const scope = readContextRouteState(params, 'scope', WATCHLIST_SCOPES, 'tenant');
const entryId = readContextRouteParam(params, 'entryId');
const duplicateOf = readContextRouteParam(params, 'duplicateOf');
const alertId = readContextRouteParam(params, 'alertId');
this.activeTab.set(tab);
this.scopeFilter.set(scope);
this.returnTo.set(params.get('returnTo'));
this.returnTo.set(readContextRouteParam(params, 'returnTo'));
if (tab === 'alerts') {
this.entryPanelMode.set(null);
@@ -1033,25 +1070,14 @@ export class WatchlistPageComponent implements OnInit {
private parseTab(
segments: readonly string[],
queryValue: string | null
params: ParamMap
): WatchlistTab {
if (queryValue && WATCHLIST_TABS.includes(queryValue as WatchlistTab)) {
return queryValue as WatchlistTab;
}
const finalSegment = segments.at(-1);
if (finalSegment && WATCHLIST_TABS.includes(finalSegment as WatchlistTab)) {
return finalSegment as WatchlistTab;
}
return 'entries';
}
private parseScope(rawValue: string | null): WatchlistScopeFilter {
if (rawValue && WATCHLIST_SCOPES.includes(rawValue as WatchlistScopeFilter)) {
return rawValue as WatchlistScopeFilter;
}
return 'tenant';
return readContextRouteState(params, 'tab', WATCHLIST_TABS, 'entries');
}
private resolveAlertThreshold(window: AlertWindow): number {
@@ -1112,29 +1138,14 @@ export class WatchlistPageComponent implements OnInit {
const alertId = overrides.alertId ?? this.selectedAlertId();
const duplicateOf = overrides.duplicateOf ?? this.duplicateSourceId();
const params: Record<string, string> = {
return buildContextRouteParams({
scope,
tab,
};
if (returnTo) {
params['returnTo'] = returnTo;
}
if (tab === 'entries' && entryId) {
params['entryId'] = entryId;
}
if (tab === 'entries' && entryId === 'new' && duplicateOf) {
params['duplicateOf'] = duplicateOf;
}
if (tab === 'alerts' && alertId) {
params['alertId'] = alertId;
}
if (tab === 'tuning' && entryId) {
params['entryId'] = entryId;
}
return params;
returnTo,
entryId: tab === 'entries' || tab === 'tuning' ? entryId : null,
duplicateOf: tab === 'entries' && entryId === 'new' ? duplicateOf : null,
alertId: tab === 'alerts' ? alertId : null,
}) as Record<string, string>;
}
private showSuccess(message: string): void {

View File

@@ -1,44 +1,20 @@
<section class="run-workspace" data-testid="run-graph-replay-page">
<header class="run-workspace__header">
<div>
<a routerLink="/releases/runs" class="run-workspace__back-link">Back to release runs</a>
<h1>{{ context()?.detail?.releaseName || 'Release Run' }}</h1>
<p class="run-workspace__subtitle">
Runtime graphing, replay, and evidence for
<code>{{ runId() }}</code>
</p>
</div>
<app-context-header
eyebrow="Releases / Runs"
[title]="context()?.detail?.releaseName || 'Release Run'"
[subtitle]="'Runtime graphing, replay, and evidence for ' + runId()"
[chips]="headerChips()"
[backLabel]="returnTo() ? 'Return to previous context' : null"
(backClick)="returnToSource()"
>
<a header-actions routerLink="/releases/runs" class="run-workspace__back-link">Back to release runs</a>
</app-context-header>
@if (context(); as context) {
<div class="run-workspace__meta">
<span class="chip">{{ context.detail.releaseType }}</span>
<span class="chip">{{ context.detail.status }}</span>
<span class="chip">{{ context.detail.outcome }}</span>
<span class="chip">{{ context.detail.targetRegion || 'global' }}/{{ context.detail.targetEnvironment || 'all' }}</span>
</div>
}
</header>
@if (returnTo()) {
<div class="return-banner">
<span>Opened from another operator flow.</span>
<a [routerLink]="returnTo()">Return to previous context</a>
</div>
}
<nav class="run-tabs" aria-label="Run graph workspace tabs">
@for (tab of tabs; track tab.id) {
<button
type="button"
class="run-tab"
[class.run-tab--active]="activeTab() === tab.id"
[attr.data-testid]="'run-workspace-tab-' + tab.id"
(click)="setTab(tab.id)"
>
{{ tab.label }}
</button>
}
</nav>
<app-tabbed-nav
[tabs]="tabs"
[activeTab]="activeTab()"
(tabChange)="onTabSelected($event.id)"
/>
@if (loading()) {
<div class="state-banner">Loading run graph and replay context...</div>
@@ -241,22 +217,24 @@
}
</main>
@if (selectedStep()) {
<aside class="step-drawer" data-testid="run-step-drawer">
<div class="step-drawer__header">
<h2>Step detail</h2>
<button type="button" class="btn btn--secondary" (click)="closeStep()">Close</button>
</div>
<app-step-detail-panel
[runId]="runId()"
[stepId]="selectedStepId() ?? undefined"
[stepData]="selectedStep()"
[logsData]="selectedStepLogs()"
(stepSelected)="openStep($event)"
/>
</aside>
}
<app-context-drawer-host
[open]="!!selectedStep()"
presentation="rail"
size="md"
eyebrow="Run step"
[title]="selectedStep()?.stepName || 'Step detail'"
description="Inspect inputs, outputs, dependency state, and logs without leaving the run workspace."
testId="run-step-drawer"
(closed)="closeStep()"
>
<app-step-detail-panel
[runId]="runId()"
[stepId]="selectedStepId() ?? undefined"
[stepData]="selectedStep()"
[logsData]="selectedStepLogs()"
(stepSelected)="openStep($event)"
/>
</app-context-drawer-host>
</div>
}
</section>

View File

@@ -4,6 +4,17 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { ReplayControlsComponent } from '../evidence-export/replay-controls.component';
import {
ContextDrawerHostComponent,
ContextHeaderComponent,
type TabItem,
TabbedNavComponent,
} from '../../shared/ui';
import {
buildContextReturnTo,
buildContextRouteParams,
readContextRouteParam,
} from '../../shared/ui/context-route-state/context-route-state';
import { StepDetailPanelComponent } from './components/step-detail-panel/step-detail-panel.component';
import { TimeTravelControlsComponent } from './components/time-travel-controls/time-travel-controls.component';
import { WorkflowVisualizerComponent } from './components/workflow-visualizer/workflow-visualizer.component';
@@ -20,6 +31,9 @@ import {
imports: [
CommonModule,
RouterLink,
ContextHeaderComponent,
ContextDrawerHostComponent,
TabbedNavComponent,
WorkflowVisualizerComponent,
StepDetailPanelComponent,
TimeTravelControlsComponent,
@@ -45,14 +59,27 @@ export class RunGraphReplayPageComponent {
readonly criticalPathOnly = signal(false);
readonly returnTo = signal<string | null>(null);
readonly tabs: readonly { id: RunWorkspaceTab; label: string }[] = [
{ id: 'summary', label: 'Summary' },
{ id: 'graph', label: 'Graph' },
{ id: 'timeline', label: 'Timeline' },
{ id: 'critical-path', label: 'Critical Path' },
{ id: 'replay', label: 'Replay' },
{ id: 'evidence', label: 'Evidence' },
readonly tabs: readonly TabItem[] = [
{ id: 'summary', label: 'Summary', testId: 'run-workspace-tab-summary' },
{ id: 'graph', label: 'Graph', testId: 'run-workspace-tab-graph' },
{ id: 'timeline', label: 'Timeline', testId: 'run-workspace-tab-timeline' },
{ id: 'critical-path', label: 'Critical Path', testId: 'run-workspace-tab-critical-path' },
{ id: 'replay', label: 'Replay', testId: 'run-workspace-tab-replay' },
{ id: 'evidence', label: 'Evidence', testId: 'run-workspace-tab-evidence' },
];
readonly headerChips = computed(() => {
const detail = this.context()?.detail;
if (!detail) {
return [];
}
return [
detail.releaseType,
detail.status,
detail.outcome,
`${detail.targetRegion || 'global'}/${detail.targetEnvironment || 'all'}`,
];
});
readonly filteredGraph = computed(() => {
const context = this.context();
@@ -133,27 +160,32 @@ export class RunGraphReplayPageComponent {
});
this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe((params) => {
this.returnTo.set(params.get('returnTo'));
this.selectedStepId.set(params.get('step'));
this.returnTo.set(readContextRouteParam(params, 'returnTo'));
this.selectedStepId.set(readContextRouteParam(params, 'step'));
});
}
setTab(tab: RunWorkspaceTab): void {
void this.router.navigate(['/releases/runs', this.runId(), tab], {
queryParams: {
queryParams: buildContextRouteParams({
drawer: this.selectedStepId() ? 'step' : null,
step: this.selectedStepId(),
returnTo: this.returnTo(),
},
}),
queryParamsHandling: 'merge',
replaceUrl: true,
});
}
onTabSelected(tabId: string): void {
this.setTab(this.normalizeTab(tabId));
}
openStep(stepId: string): void {
this.selectedStepId.set(stepId);
void this.router.navigate([], {
relativeTo: this.route,
queryParams: { step: stepId },
queryParams: buildContextRouteParams({ drawer: 'step', step: stepId }),
queryParamsHandling: 'merge',
replaceUrl: true,
});
@@ -163,7 +195,7 @@ export class RunGraphReplayPageComponent {
this.selectedStepId.set(null);
void this.router.navigate([], {
relativeTo: this.route,
queryParams: { step: null },
queryParams: buildContextRouteParams({ drawer: null, step: null }),
queryParamsHandling: 'merge',
replaceUrl: true,
});
@@ -183,6 +215,14 @@ export class RunGraphReplayPageComponent {
queryParams: {
releaseId: context?.detail.releaseId,
runId: this.runId(),
returnTo: buildContextReturnTo(
this.router,
['/releases/runs', this.runId(), this.activeTab()],
{
drawer: this.selectedStepId() ? 'step' : null,
step: this.selectedStepId(),
},
),
},
});
}
@@ -201,6 +241,15 @@ export class RunGraphReplayPageComponent {
}
}
returnToSource(): void {
const returnTo = this.returnTo();
if (!returnTo) {
return;
}
void this.router.navigateByUrl(returnTo).catch(() => undefined);
}
formatWhen(value: string | null | undefined): string {
if (!value) {
return 'n/a';

View File

@@ -0,0 +1,243 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
HostListener,
Input,
Output,
} from '@angular/core';
let nextContextDrawerId = 0;
@Component({
selector: 'app-context-drawer-host',
standalone: true,
template: `
@if (open) {
<div
class="context-drawer-host"
[class.context-drawer-host--overlay]="presentation === 'overlay'"
[class.context-drawer-host--rail]="presentation === 'rail'"
>
@if (presentation === 'overlay' && showBackdrop) {
<button
type="button"
class="context-drawer-host__backdrop"
aria-label="Close panel"
(click)="requestClose()"
></button>
}
<section
class="context-drawer-host__panel"
[class.context-drawer-host__panel--md]="size === 'md'"
[class.context-drawer-host__panel--lg]="size === 'lg'"
[class.context-drawer-host__panel--xl]="size === 'xl'"
[attr.role]="presentation === 'overlay' ? 'dialog' : 'complementary'"
[attr.aria-modal]="presentation === 'overlay' ? 'true' : null"
[attr.aria-labelledby]="titleId"
[attr.data-testid]="testId || null"
>
<header class="context-drawer-host__header">
<div class="context-drawer-host__title-block">
@if (eyebrow) {
<p class="context-drawer-host__eyebrow">{{ eyebrow }}</p>
}
<h2 [id]="titleId" class="context-drawer-host__title">{{ title }}</h2>
@if (description) {
<p class="context-drawer-host__description">{{ description }}</p>
}
</div>
<div class="context-drawer-host__header-actions">
<ng-content select="[drawer-actions]"></ng-content>
<button
type="button"
class="context-drawer-host__close"
[attr.data-testid]="closeTestId || null"
(click)="requestClose()"
>
{{ closeLabel }}
</button>
</div>
</header>
<div class="context-drawer-host__body">
<ng-content></ng-content>
</div>
<ng-content select="[drawer-footer]"></ng-content>
</section>
</div>
}
`,
styles: [`
.context-drawer-host {
min-width: 0;
}
.context-drawer-host--overlay {
inset: 0;
pointer-events: none;
position: fixed;
z-index: 40;
}
.context-drawer-host--rail {
display: block;
height: 100%;
min-width: 0;
}
.context-drawer-host__backdrop {
background: color-mix(in srgb, #09101d 58%, transparent);
border: none;
inset: 0;
pointer-events: auto;
position: absolute;
}
.context-drawer-host__panel {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
box-shadow: 0 24px 48px rgba(4, 16, 32, 0.22);
min-width: 0;
pointer-events: auto;
}
.context-drawer-host--overlay .context-drawer-host__panel {
border-bottom-left-radius: 1rem;
border-top-left-radius: 1rem;
height: 100%;
margin-left: auto;
overflow: auto;
position: relative;
}
.context-drawer-host--rail .context-drawer-host__panel {
border-radius: 1rem;
height: 100%;
overflow: auto;
position: sticky;
top: 1rem;
}
.context-drawer-host__panel--md {
width: min(28rem, 92vw);
}
.context-drawer-host__panel--lg {
width: min(36rem, 94vw);
}
.context-drawer-host__panel--xl {
width: min(46rem, 96vw);
}
.context-drawer-host--rail .context-drawer-host__panel--md,
.context-drawer-host--rail .context-drawer-host__panel--lg,
.context-drawer-host--rail .context-drawer-host__panel--xl {
width: 100%;
}
.context-drawer-host__header {
align-items: flex-start;
border-bottom: 1px solid var(--color-border-primary);
display: flex;
gap: 1rem;
justify-content: space-between;
padding: 1rem 1rem 0.85rem;
}
.context-drawer-host__title-block {
display: grid;
gap: 0.35rem;
min-width: 0;
}
.context-drawer-host__eyebrow {
margin: 0;
color: var(--color-status-info, var(--color-brand-primary));
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.context-drawer-host__title {
margin: 0;
font-size: 1.05rem;
}
.context-drawer-host__description {
color: var(--color-text-secondary);
margin: 0;
line-height: 1.45;
}
.context-drawer-host__header-actions {
align-items: center;
display: flex;
gap: 0.65rem;
justify-content: flex-end;
flex-shrink: 0;
}
.context-drawer-host__close {
background: var(--color-surface-secondary, var(--color-surface-primary));
border: 1px solid var(--color-border-primary);
border-radius: 0.75rem;
color: var(--color-text-primary);
cursor: pointer;
padding: 0.55rem 0.85rem;
}
.context-drawer-host__body {
display: grid;
gap: 1rem;
padding: 1rem;
}
@media (max-width: 900px) {
.context-drawer-host__header {
display: grid;
}
.context-drawer-host__header-actions {
justify-content: flex-start;
}
.context-drawer-host--rail .context-drawer-host__panel {
position: static;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ContextDrawerHostComponent {
@Input() open = false;
@Input() presentation: 'overlay' | 'rail' = 'overlay';
@Input() size: 'md' | 'lg' | 'xl' = 'lg';
@Input() eyebrow = '';
@Input() title = '';
@Input() description = '';
@Input() closeLabel = 'Close';
@Input() showBackdrop = true;
@Input() testId: string | null = null;
@Input() closeTestId: string | null = null;
@Output() readonly closed = new EventEmitter<void>();
readonly titleId = `context-drawer-title-${nextContextDrawerId++}`;
@HostListener('document:keydown.escape')
onEscape(): void {
if (this.open) {
this.requestClose();
}
}
requestClose(): void {
this.closed.emit();
}
}

View File

@@ -0,0 +1,153 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
@Component({
selector: 'app-context-header',
standalone: true,
template: `
<header class="context-header">
<div class="context-header__copy">
@if (eyebrow) {
<p class="context-header__eyebrow">{{ eyebrow }}</p>
}
<div class="context-header__title-row">
<h1 class="context-header__title">{{ title }}</h1>
@if (chips.length) {
<div class="context-header__chips" aria-label="Context chips">
@for (chip of chips; track chip) {
<span class="context-header__chip">{{ chip }}</span>
}
</div>
}
</div>
@if (subtitle) {
<p class="context-header__subtitle">{{ subtitle }}</p>
}
@if (contextNote) {
<p class="context-header__note">{{ contextNote }}</p>
}
</div>
<div class="context-header__actions">
@if (backLabel) {
<button
type="button"
class="context-header__return"
(click)="backClick.emit()"
>
{{ backLabel }}
</button>
}
<ng-content select="[header-actions]"></ng-content>
</div>
</header>
`,
styles: [`
.context-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.context-header__copy {
display: grid;
gap: 0.35rem;
min-width: 0;
}
.context-header__eyebrow {
margin: 0;
color: var(--color-status-info, var(--color-brand-primary));
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.context-header__title-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.context-header__title {
margin: 0;
color: var(--color-text-heading, var(--color-text-primary));
font-size: 1.6rem;
}
.context-header__subtitle,
.context-header__note {
margin: 0;
color: var(--color-text-secondary);
line-height: 1.45;
}
.context-header__note {
font-size: 0.92rem;
}
.context-header__chips {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.context-header__chip {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid var(--color-border-primary);
padding: 0.2rem 0.55rem;
background: var(--color-surface-secondary, var(--color-surface-primary));
color: var(--color-text-secondary);
font-size: 0.76rem;
}
.context-header__actions {
display: flex;
align-items: flex-start;
gap: 0.75rem;
flex-wrap: wrap;
flex-shrink: 0;
}
.context-header__return {
border: 1px solid var(--color-border-primary);
border-radius: 0.75rem;
background: var(--color-surface-secondary, var(--color-surface-primary));
color: var(--color-text-primary);
cursor: pointer;
font-weight: 600;
padding: 0.6rem 0.9rem;
}
@media (max-width: 860px) {
.context-header {
display: grid;
}
.context-header__actions {
justify-content: flex-start;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ContextHeaderComponent {
@Input() eyebrow = '';
@Input() title = '';
@Input() subtitle = '';
@Input() contextNote = '';
@Input() chips: readonly string[] = [];
@Input() backLabel: string | null = null;
@Output() readonly backClick = new EventEmitter<void>();
}

View File

@@ -0,0 +1,81 @@
import type { Params, Router } from '@angular/router';
export type ContextRouteStateKey =
| 'tab'
| 'panel'
| 'drawer'
| 'returnTo'
| 'scope'
| 'view';
export interface ContextRouteStateReader {
get(name: string): string | null;
}
export function coerceContextRouteState<T extends string>(
value: string | null | undefined,
allowed: readonly T[],
fallback: T,
): T {
if (!value) {
return fallback;
}
return allowed.includes(value as T) ? (value as T) : fallback;
}
export function readContextRouteState<T extends string>(
reader: ContextRouteStateReader,
key: ContextRouteStateKey | string,
allowed: readonly T[],
fallback: T,
): T {
return coerceContextRouteState(reader.get(key), allowed, fallback);
}
export function readContextRouteParam(
reader: ContextRouteStateReader,
key: ContextRouteStateKey | string,
): string | null {
const value = reader.get(key);
if (!value) {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function buildContextRouteParams(
values: Record<string, string | null | undefined>,
): Params {
const params: Params = {};
for (const [key, value] of Object.entries(values)) {
if (value === null) {
params[key] = null;
continue;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length > 0) {
params[key] = trimmed;
}
}
}
return params;
}
export function buildContextReturnTo(
router: Router,
commands: readonly unknown[],
queryParams?: Record<string, string | null | undefined>,
): string {
return router.serializeUrl(
router.createUrlTree(commands as readonly string[], {
queryParams: queryParams ? buildContextRouteParams(queryParams) : undefined,
}),
);
}

View File

@@ -7,9 +7,14 @@
// Layout primitives
export * from './page-header/page-header.component';
export * from './context-header/context-header.component';
export * from './context-drawer-host/context-drawer-host.component';
export * from './filter-bar/filter-bar.component';
export * from './list-detail-shell/list-detail-shell.component';
export * from './split-pane/split-pane.component';
export * from './tabbed-nav/tabbed-nav.component';
export * from './overview-card-groups/overview-card-groups.component';
export * from './context-route-state/context-route-state';
// Data display
export * from './status-badge/status-badge.component';

View File

@@ -0,0 +1,52 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
@Component({
selector: 'app-list-detail-shell',
standalone: true,
template: `
<div
class="list-detail-shell"
[class.list-detail-shell--with-detail]="detailVisible"
[style.--list-detail-shell-detail-width]="detailWidth"
>
<div class="list-detail-shell__primary">
<ng-content select="[shell-primary]"></ng-content>
</div>
@if (detailVisible) {
<div class="list-detail-shell__detail">
<ng-content select="[shell-detail]"></ng-content>
</div>
}
</div>
`,
styles: [`
.list-detail-shell {
display: grid;
gap: 1rem;
grid-template-columns: minmax(0, 1fr);
}
.list-detail-shell--with-detail {
grid-template-columns: minmax(0, 1.7fr) minmax(20rem, var(--list-detail-shell-detail-width, 24rem));
align-items: start;
}
.list-detail-shell__primary,
.list-detail-shell__detail {
min-width: 0;
}
@media (max-width: 1100px) {
.list-detail-shell,
.list-detail-shell--with-detail {
grid-template-columns: minmax(0, 1fr);
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ListDetailShellComponent {
@Input() detailVisible = false;
@Input() detailWidth = '24rem';
}

View File

@@ -0,0 +1,161 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { RouterLink } from '@angular/router';
export type OverviewCardImpact = 'blocking' | 'degraded' | 'info';
export interface OverviewCardGroupItem {
readonly id: string;
readonly title: string;
readonly detail: string;
readonly metric: string;
readonly impact: OverviewCardImpact;
readonly route: string;
readonly owner?: string;
}
export interface OverviewCardGroup {
readonly id: string;
readonly title: string;
readonly description: string;
readonly cards: readonly OverviewCardGroupItem[];
}
@Component({
selector: 'app-overview-card-groups',
standalone: true,
imports: [RouterLink],
template: `
@for (group of groups; track group.id) {
<section class="overview-card-groups__group" [attr.data-testid]="groupTestIdPrefix + '-' + group.id">
<div class="overview-card-groups__header">
<div>
<h2>{{ group.title }}</h2>
<p>{{ group.description }}</p>
</div>
</div>
<div class="overview-card-groups__grid">
@for (card of group.cards; track card.id) {
<a
class="overview-card-groups__card"
[routerLink]="card.route"
[attr.data-testid]="cardTestIdPrefix + '-' + card.id"
>
<div class="overview-card-groups__card-topline">
@if (card.owner) {
<span
class="overview-card-groups__owner"
[class.overview-card-groups__owner--setup]="card.owner === 'Setup'"
>
{{ card.owner }}
</span>
}
<span class="overview-card-groups__impact" [class]="'overview-card-groups__impact overview-card-groups__impact--' + card.impact">
{{ card.metric }}
</span>
</div>
<h3>{{ card.title }}</h3>
<p>{{ card.detail }}</p>
</a>
}
</div>
</section>
}
`,
styles: [`
.overview-card-groups__group {
display: grid;
gap: 0.9rem;
}
.overview-card-groups__header h2,
.overview-card-groups__card h3 {
margin: 0;
}
.overview-card-groups__header p,
.overview-card-groups__card p {
color: var(--color-text-secondary);
margin: 0.35rem 0 0;
line-height: 1.45;
}
.overview-card-groups__grid {
display: grid;
gap: 0.9rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.overview-card-groups__card {
display: grid;
gap: 0.8rem;
padding: 1rem;
border: 1px solid var(--color-border-primary);
border-radius: 1rem;
background: var(--color-surface-primary);
color: inherit;
text-decoration: none;
min-width: 0;
transition: border-color 120ms ease, transform 120ms ease;
}
.overview-card-groups__card:hover {
border-color: var(--color-brand-primary);
transform: translateY(-1px);
}
.overview-card-groups__card-topline {
align-items: center;
display: flex;
gap: 0.5rem;
justify-content: space-between;
}
.overview-card-groups__owner,
.overview-card-groups__impact {
display: inline-flex;
align-items: center;
border-radius: 999px;
padding: 0.18rem 0.55rem;
font-size: 0.76rem;
font-weight: 600;
}
.overview-card-groups__owner {
background: color-mix(in srgb, var(--color-brand-primary) 10%, transparent);
color: var(--color-brand-primary);
}
.overview-card-groups__owner--setup {
background: color-mix(in srgb, var(--color-status-warning-bg) 72%, transparent);
color: var(--color-status-warning-text);
}
.overview-card-groups__impact {
background: var(--color-surface-secondary);
color: var(--color-text-secondary);
}
.overview-card-groups__impact--blocking {
background: var(--color-status-error-bg);
color: var(--color-status-error-text);
}
.overview-card-groups__impact--degraded {
background: var(--color-status-warning-bg);
color: var(--color-status-warning-text);
}
.overview-card-groups__impact--info {
background: var(--color-status-info-bg);
color: var(--color-status-info-text);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OverviewCardGroupsComponent {
@Input() groups: readonly OverviewCardGroup[] = [];
@Input() groupTestIdPrefix = 'overview-group';
@Input() cardTestIdPrefix = 'overview-card';
}

View File

@@ -13,8 +13,10 @@ export interface TabItem {
id: string;
label: string;
icon?: string;
route?: string; // If set, uses router navigation
route?: string | readonly unknown[]; // If set, uses router navigation
queryParams?: Record<string, unknown>;
disabled?: boolean;
testId?: string;
}
@Component({
@@ -23,16 +25,18 @@ export interface TabItem {
imports: [RouterLink, RouterLinkActive],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<nav class="tabbed-nav" role="tablist">
<nav class="tabbed-nav" [class.tabbed-nav--submenu]="variant === 'submenu'" [attr.role]="variant === 'submenu' ? 'navigation' : 'tablist'">
@for (tab of tabs; track tab.id) {
@if (tab.route) {
<a
class="tabbed-nav__tab"
[routerLink]="tab.route"
[queryParams]="tab.queryParams"
routerLinkActive="tabbed-nav__tab--active"
[class.tabbed-nav__tab--disabled]="tab.disabled"
role="tab"
[attr.role]="variant === 'submenu' ? null : 'tab'"
[attr.aria-disabled]="tab.disabled"
[attr.data-testid]="tab.testId || null"
>
@if (tab.icon) {
<span class="tabbed-nav__icon">{{ tab.icon }}</span>
@@ -46,8 +50,9 @@ export interface TabItem {
[class.tabbed-nav__tab--active]="activeTab === tab.id"
[class.tabbed-nav__tab--disabled]="tab.disabled"
[disabled]="tab.disabled"
role="tab"
[attr.aria-selected]="activeTab === tab.id"
[attr.role]="variant === 'submenu' ? null : 'tab'"
[attr.aria-selected]="variant === 'submenu' ? null : activeTab === tab.id"
[attr.data-testid]="tab.testId || null"
(click)="selectTab(tab)"
>
@if (tab.icon) {
@@ -65,6 +70,12 @@ export interface TabItem {
gap: 0.25rem;
border-bottom: 1px solid var(--color-border-primary);
margin-bottom: 1rem;
flex-wrap: wrap;
}
.tabbed-nav--submenu {
border-bottom: none;
gap: 0.5rem;
}
.tabbed-nav__tab {
@@ -84,6 +95,13 @@ export interface TabItem {
text-decoration: none;
}
.tabbed-nav--submenu .tabbed-nav__tab {
border: 1px solid var(--color-border-primary);
border-radius: 999px;
margin-bottom: 0;
padding: 0.45rem 0.9rem;
}
.tabbed-nav__tab:hover:not(.tabbed-nav__tab--disabled) {
color: var(--color-text-primary);
}
@@ -106,6 +124,7 @@ export interface TabItem {
export class TabbedNavComponent {
@Input() tabs: TabItem[] = [];
@Input() activeTab?: string;
@Input() variant: 'tabs' | 'submenu' = 'tabs';
@Output() tabChange = new EventEmitter<TabItem>();

View File

@@ -0,0 +1,290 @@
import { Component } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { ContextDrawerHostComponent } from '../../app/shared/ui/context-drawer-host/context-drawer-host.component';
import { ContextHeaderComponent } from '../../app/shared/ui/context-header/context-header.component';
import {
buildContextRouteParams,
buildContextReturnTo,
coerceContextRouteState,
readContextRouteParam,
readContextRouteState,
} from '../../app/shared/ui/context-route-state/context-route-state';
import { ListDetailShellComponent } from '../../app/shared/ui/list-detail-shell/list-detail-shell.component';
import {
OverviewCardGroupsComponent,
type OverviewCardGroup,
} from '../../app/shared/ui/overview-card-groups/overview-card-groups.component';
import { TabbedNavComponent } from '../../app/shared/ui/tabbed-nav/tabbed-nav.component';
describe('context route state helpers', () => {
const reader = {
get(name: string): string | null {
return (
{
tab: 'graph',
returnTo: '/ops/operations',
}[name] ?? null
);
},
};
it('coerces stable enum values and falls back for invalid state', () => {
expect(coerceContextRouteState('graph', ['summary', 'graph'] as const, 'summary')).toBe('graph');
expect(coerceContextRouteState('unknown', ['summary', 'graph'] as const, 'summary')).toBe('summary');
expect(readContextRouteState(reader, 'tab', ['summary', 'graph'] as const, 'summary')).toBe('graph');
});
it('reads optional params and omits empty values when building query params', () => {
expect(readContextRouteParam(reader, 'returnTo')).toBe('/ops/operations');
expect(
buildContextRouteParams({
tab: 'graph',
panel: '',
drawer: null,
returnTo: '/ops/operations',
}),
).toEqual({
tab: 'graph',
drawer: null,
returnTo: '/ops/operations',
});
});
it('serializes a returnTo URL with the shared query-state contract', async () => {
await TestBed.configureTestingModule({
providers: [provideRouter([])],
}).compileComponents();
const router = TestBed.inject(Router);
const returnTo = buildContextReturnTo(router, ['/releases/runs', 'run-42', 'graph'], {
drawer: 'step',
step: 'gate',
});
expect(returnTo).toContain('/releases/runs/run-42/graph');
expect(returnTo).toContain('drawer=step');
expect(returnTo).toContain('step=gate');
});
});
@Component({
standalone: true,
imports: [ContextHeaderComponent],
template: `
<app-context-header
eyebrow="Ops"
title="Shared Header"
subtitle="Reusable contextual header"
contextNote="Tenant scope"
[chips]="['running', 'prod']"
backLabel="Return to parent"
(backClick)="clicked = true"
/>
`,
})
class ContextHeaderHostComponent {
clicked = false;
}
describe('ContextHeaderComponent', () => {
let fixture: ComponentFixture<ContextHeaderHostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ContextHeaderHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(ContextHeaderHostComponent);
fixture.detectChanges();
});
it('renders context copy, chips, and emits return clicks', () => {
const text = fixture.nativeElement.textContent as string;
expect(text).toContain('Shared Header');
expect(text).toContain('Tenant scope');
expect(text).toContain('running');
const button = fixture.nativeElement.querySelector('button') as HTMLButtonElement | null;
expect(button?.textContent).toContain('Return to parent');
button?.click();
fixture.detectChanges();
expect(fixture.componentInstance.clicked).toBeTrue();
});
});
@Component({
standalone: true,
imports: [ContextDrawerHostComponent],
template: `
<app-context-drawer-host
[open]="open"
testId="shared-drawer"
closeTestId="shared-drawer-close"
eyebrow="Evidence"
title="Shared Drawer"
description="Route-aware overlay"
(closed)="closeCount = closeCount + 1"
>
<p>Drawer content</p>
</app-context-drawer-host>
`,
})
class ContextDrawerHostTestComponent {
open = true;
closeCount = 0;
}
describe('ContextDrawerHostComponent', () => {
let fixture: ComponentFixture<ContextDrawerHostTestComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ContextDrawerHostTestComponent],
}).compileComponents();
fixture = TestBed.createComponent(ContextDrawerHostTestComponent);
fixture.detectChanges();
});
it('renders the shared drawer shell and closes from explicit actions', () => {
expect(fixture.nativeElement.querySelector('[data-testid="shared-drawer"]')).toBeTruthy();
const closeButton = fixture.nativeElement.querySelector(
'[data-testid="shared-drawer-close"]',
) as HTMLButtonElement | null;
closeButton?.click();
fixture.detectChanges();
expect(fixture.componentInstance.closeCount).toBe(1);
});
it('closes on escape for accessible keyboard dismissal', () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
fixture.detectChanges();
expect(fixture.componentInstance.closeCount).toBe(1);
});
});
@Component({
standalone: true,
imports: [ListDetailShellComponent],
template: `
<app-list-detail-shell [detailVisible]="detailVisible">
<section shell-primary>Primary list</section>
@if (detailVisible) {
<aside shell-detail>Detail rail</aside>
}
</app-list-detail-shell>
`,
})
class ListDetailShellHostComponent {
detailVisible = true;
}
describe('ListDetailShellComponent', () => {
let fixture: ComponentFixture<ListDetailShellHostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ListDetailShellHostComponent],
}).compileComponents();
fixture = TestBed.createComponent(ListDetailShellHostComponent);
fixture.detectChanges();
});
it('renders primary and detail slots when detail is visible', () => {
expect(fixture.nativeElement.textContent).toContain('Primary list');
expect(fixture.nativeElement.textContent).toContain('Detail rail');
expect(fixture.nativeElement.querySelector('.list-detail-shell--with-detail')).toBeTruthy();
});
});
describe('TabbedNavComponent', () => {
let fixture: ComponentFixture<TabbedNavComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TabbedNavComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(TabbedNavComponent);
});
it('supports submenu-style routed links with stable test ids', () => {
fixture.componentRef.setInput('variant', 'submenu');
fixture.componentRef.setInput('tabs', [
{ id: 'ops', label: 'Operations', route: '/ops/operations', testId: 'submenu-ops' },
]);
fixture.detectChanges();
const nav = fixture.nativeElement.querySelector('.tabbed-nav--submenu');
const link = fixture.nativeElement.querySelector('[data-testid="submenu-ops"]') as HTMLAnchorElement | null;
expect(nav).toBeTruthy();
expect(link?.getAttribute('href')).toContain('/ops/operations');
});
});
@Component({
standalone: true,
imports: [OverviewCardGroupsComponent],
template: `
<app-overview-card-groups
[groups]="groups"
groupTestIdPrefix="test-group"
cardTestIdPrefix="test-card"
/>
`,
})
class OverviewCardGroupsHostComponent {
readonly groups: readonly OverviewCardGroup[] = [
{
id: 'ops',
title: 'Operations',
description: 'Shared grouped overview cards',
cards: [
{
id: 'jobs',
title: 'Jobs & Queues',
detail: 'Queue ownership',
metric: '4 pending',
impact: 'degraded',
route: '/ops/operations/jobs-queues',
owner: 'Ops',
},
],
},
];
}
describe('OverviewCardGroupsComponent', () => {
let fixture: ComponentFixture<OverviewCardGroupsHostComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [OverviewCardGroupsHostComponent],
providers: [provideRouter([])],
}).compileComponents();
fixture = TestBed.createComponent(OverviewCardGroupsHostComponent);
fixture.detectChanges();
});
it('renders grouped overview sections with stable route targets', () => {
expect(fixture.nativeElement.querySelector('[data-testid="test-group-ops"]')).toBeTruthy();
const card = fixture.nativeElement.querySelector(
'[data-testid="test-card-jobs"]',
) as HTMLAnchorElement | null;
expect(card?.getAttribute('href')).toContain('/ops/operations/jobs-queues');
expect(fixture.nativeElement.textContent).toContain('Shared grouped overview cards');
});
});

View File

@@ -372,7 +372,7 @@ describe('RunGraphReplayPageComponent', () => {
expect(fixture.nativeElement.querySelector('[data-testid="run-step-drawer"]')).toBeTruthy();
expect(fixture.nativeElement.textContent).toContain('Policy Gate');
expect(
fixture.nativeElement.querySelector('[data-testid="run-workspace-tab-graph"].run-tab--active'),
fixture.nativeElement.querySelector('[data-testid="run-workspace-tab-graph"].tabbed-nav__tab--active'),
).toBeTruthy();
});
@@ -391,6 +391,7 @@ describe('RunGraphReplayPageComponent', () => {
queryParams: {
releaseId: 'rel-demo',
runId: 'run-demo',
returnTo: '/releases/runs/run-demo/replay',
},
});
});
@@ -411,7 +412,7 @@ describe('RunGraphReplayPageComponent', () => {
expect(component.selectedStepId()).toBe('approval');
expect(navigateSpy).toHaveBeenCalledWith([], {
relativeTo: activatedRouteStub as unknown as ActivatedRoute,
queryParams: { step: 'approval' },
queryParams: { drawer: 'step', step: 'approval' },
queryParamsHandling: 'merge',
replaceUrl: true,
});

View File

@@ -299,10 +299,10 @@ test('watchlist shell supports entries, alerts, and tuning in one routed page',
await page.goto('/setup/trust-signing/watchlist/entries', { waitUntil: 'networkidle' });
await expect(page.getByTestId('watchlist-page')).toBeVisible();
await expect(page.getByRole('button', { name: 'Entries' })).toHaveClass(/active/);
await expect(page.getByTestId('watchlist-tab-entries')).toHaveClass(/active/);
await expect(page.getByTestId('create-entry-btn')).toBeVisible();
await page.getByRole('button', { name: 'Alerts' }).click();
await page.getByTestId('watchlist-tab-alerts').click();
await expect(page).toHaveURL(/\/setup\/trust-signing\/watchlist\/alerts/);
await expect(page.getByTestId('alerts-window-select')).toHaveValue('24h');
await page.getByRole('button', { name: 'View' }).first().click();
@@ -311,7 +311,7 @@ test('watchlist shell supports entries, alerts, and tuning in one routed page',
await expect(page).toHaveURL(/\/setup\/trust-signing\/watchlist\/entries/);
await expect(page.getByTestId('entry-form')).toBeVisible();
await page.getByRole('button', { name: 'Tuning' }).click();
await page.getByTestId('watchlist-tab-tuning').click();
await expect(page).toHaveURL(/\/setup\/trust-signing\/watchlist\/tuning/);
await expect(page.getByTestId('tuning-form')).toBeVisible();
});

View File

@@ -310,7 +310,7 @@ test('run workspace supports timeline drill-in and evidence replay handoff', asy
await page.goto('/releases/runs/run-e2e/graph?step=gate', { waitUntil: 'domcontentloaded' });
await expect(page.getByTestId('run-graph-replay-page')).toBeVisible();
await expect(page.getByTestId('run-workspace-tab-graph')).toHaveClass(/run-tab--active/);
await expect(page.getByTestId('run-workspace-tab-graph')).toHaveClass(/active/);
await expect(page.getByRole('button', { name: 'Critical path only' })).toBeVisible();
await page.getByTestId('run-workspace-tab-timeline').click();
@@ -330,12 +330,12 @@ test('run workspace supports timeline drill-in and evidence replay handoff', asy
await page.getByRole('button', { name: 'Open run replay workspace' }).click();
await expect(page).toHaveURL(/\/releases\/runs\/run-e2e\/replay\?.*releaseId=rel-e2e.*returnTo=/);
await expect(page.getByText('Opened from another operator flow.')).toBeVisible();
await expect(page.getByRole('button', { name: 'Return to previous context' })).toBeVisible();
});
test('legacy workflow visualization aliases redirect into the canonical run shell', async ({ page }) => {
await page.goto('/workflow-visualization/run-e2e/graph?step=evidence', { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/releases\/runs\/run-e2e\/graph\?.*step=evidence/);
await expect(page.getByTestId('run-workspace-tab-graph')).toHaveClass(/run-tab--active/);
await expect(page.getByTestId('run-workspace-tab-graph')).toHaveClass(/active/);
});