feat(ui): ship contextual action primitives
This commit is contained in:
@@ -31,7 +31,7 @@
|
|||||||
## Delivery Tracker
|
## Delivery Tracker
|
||||||
|
|
||||||
### FE-CA-001 - Implement the shared route-state contract
|
### FE-CA-001 - Implement the shared route-state contract
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: none
|
Dependency: none
|
||||||
Owners: FE Architect, Product Manager
|
Owners: FE Architect, Product Manager
|
||||||
Task description:
|
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.
|
- Make the restoration topics consume one working route-state model instead of each inventing bespoke state handling.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Shared route-state helpers exist in code.
|
- [x] Shared route-state helpers exist in code.
|
||||||
- [ ] Restoration topics can consume one route-state contract instead of bespoke state rules.
|
- [x] 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] The placement hierarchy remains documented as the policy for using the new helpers.
|
||||||
|
|
||||||
### FE-CA-002 - Ship the shared contextual drawer host
|
### FE-CA-002 - Ship the shared contextual drawer host
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-CA-001
|
Dependency: FE-CA-001
|
||||||
Owners: Developer, FE Architect
|
Owners: Developer, FE Architect
|
||||||
Task description:
|
Task description:
|
||||||
@@ -52,12 +52,12 @@ Task description:
|
|||||||
- Standardize size, close behavior, route-state binding, keyboard handling, and history interactions in working code.
|
- Standardize size, close behavior, route-state binding, keyboard handling, and history interactions in working code.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Drawer host is available for adoption in the restoration features.
|
- [x] Drawer host is available for adoption in the restoration features.
|
||||||
- [ ] Route-state open and close behavior works in code.
|
- [x] Route-state open and close behavior works in code.
|
||||||
- [ ] Accessibility and keyboard behavior are verified for the shared host.
|
- [x] Accessibility and keyboard behavior are verified for the shared host.
|
||||||
|
|
||||||
### FE-CA-003 - Ship split-view, right-rail, and context-header primitives
|
### FE-CA-003 - Ship split-view, right-rail, and context-header primitives
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-CA-001
|
Dependency: FE-CA-001
|
||||||
Owners: Developer, FE Architect
|
Owners: Developer, FE Architect
|
||||||
Task description:
|
Task description:
|
||||||
@@ -65,12 +65,12 @@ Task description:
|
|||||||
- Ensure responsive behavior works in the shipped components rather than remaining a note in docs.
|
- Ensure responsive behavior works in the shipped components rather than remaining a note in docs.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Split-view, right-rail, and context-header primitives exist in code.
|
- [x] Split-view, right-rail, and context-header primitives exist in code.
|
||||||
- [ ] Panel-stack behavior is usable in the shipped primitives.
|
- [x] Panel-stack behavior is usable in the shipped primitives.
|
||||||
- [ ] Responsive fallback behavior works in the adopted surfaces.
|
- [x] Responsive fallback behavior works in the adopted surfaces.
|
||||||
|
|
||||||
### FE-CA-004 - Ship grouped overview-card and submenu primitives
|
### FE-CA-004 - Ship grouped overview-card and submenu primitives
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-CA-001
|
Dependency: FE-CA-001
|
||||||
Owners: Product Manager, Developer
|
Owners: Product Manager, Developer
|
||||||
Task description:
|
Task description:
|
||||||
@@ -78,12 +78,12 @@ Task description:
|
|||||||
- Standardize one-card-to-one-route and one-submenu-to-one-owner patterns in working components.
|
- Standardize one-card-to-one-route and one-submenu-to-one-owner patterns in working components.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Grouped overview-card primitives exist in code.
|
- [x] Grouped overview-card primitives exist in code.
|
||||||
- [ ] Submenu patterns are usable by owner shells.
|
- [x] Submenu patterns are usable by owner shells.
|
||||||
- [ ] Card-to-route and submenu-to-owner behavior is consistent in the shipped implementation.
|
- [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
|
### FE-CA-005 - Adopt the shared primitives into the restoration features
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-CA-001
|
Dependency: FE-CA-001
|
||||||
Owners: FE Architect, Developer
|
Owners: FE Architect, Developer
|
||||||
Task description:
|
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`.
|
- 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:
|
Completion criteria:
|
||||||
- [ ] At least the Watchlist, Reachability, and Triage or Workflow surfaces adopt the shared primitives.
|
- [x] 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.
|
- [x] Shared primitives replace bespoke implementations where the new restoration work lands.
|
||||||
- [ ] Topic-specific adoption is visible in the shipped feature code.
|
- [x] Topic-specific adoption is visible in the shipped feature code.
|
||||||
|
|
||||||
### FE-CA-006 - Verify, document, and enforce shared usage
|
### FE-CA-006 - Verify, document, and enforce shared usage
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-CA-003
|
Dependency: FE-CA-003
|
||||||
Owners: QA, Documentation author
|
Owners: QA, Documentation author
|
||||||
Task description:
|
Task description:
|
||||||
@@ -104,14 +104,19 @@ Task description:
|
|||||||
- Update docs so future restoration work treats these primitives as required building blocks, not optional helpers.
|
- Update docs so future restoration work treats these primitives as required building blocks, not optional helpers.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Shared verification covers the adopted primitives.
|
- [x] Shared verification covers the adopted primitives.
|
||||||
- [ ] Restoration sprints reference and consume the shared primitives.
|
- [x] Restoration sprints reference and consume the shared primitives.
|
||||||
- [ ] Shared docs are updated to reflect the shipped primitive set.
|
- [x] Shared docs are updated to reflect the shipped primitive set.
|
||||||
|
|
||||||
## Execution Log
|
## Execution Log
|
||||||
| Date (UTC) | Update | Owner |
|
| 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 | 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
|
## Decisions & Risks
|
||||||
- Decision: contextual placement is a shared FE concern and should not be reinvented per topic.
|
- 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.
|
- 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.
|
- 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`.
|
- 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
|
## Next Checkpoints
|
||||||
- 2026-03-08: confirm placement hierarchy and route-state contract.
|
- Archived on 2026-03-07 after implementation, adoption, verification, and docs sync.
|
||||||
- 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.
|
|
||||||
55
docs/features/checked/web/contextual-actions-patterns-ui.md
Normal file
55
docs/features/checked/web/contextual-actions-patterns-ui.md
Normal 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
|
||||||
@@ -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`.
|
- 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.
|
- 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`.
|
- 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)
|
## 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/**`.
|
- Runtime mock cutover completed for policy simulation history/conflict/batch flows and graph explorer data loading in `src/Web/StellaOps.Web/src/app/**`.
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
- `docs/implplan/SPRINT_20260307_009_DOCS_ui_component_preservation_map.md`
|
- `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_022_FE_policy_vex_release_decisioning_studio.md`
|
||||||
- `docs/implplan/SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.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_035_DOCS_search_first_final_correction_phases.md`
|
||||||
- `docs/implplan/SPRINT_20260307_036_FE_search_first_shell_consolidation.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`
|
- `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-004 Step-detail drawer and deep-link behavior
|
||||||
- [DONE] FE-WV-005 Workflow-editor preview reuse boundary
|
- [DONE] FE-WV-005 Workflow-editor preview reuse boundary
|
||||||
- [DONE] FE-WV-006 QA, rollout, alias migration, and docs sync for workflow visualization
|
- [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
|
- [DONE] FE-CA-001 Freeze contextual placement decision matrix and route-state contract
|
||||||
- [TODO] FE-CA-002 Shared contextual drawer host
|
- [DONE] FE-CA-002 Shared contextual drawer host
|
||||||
- [TODO] FE-CA-003 Split list-detail and right-rail primitives
|
- [DONE] FE-CA-003 Split list-detail and right-rail primitives
|
||||||
- [TODO] FE-CA-004 Context header and return-to-context contract
|
- [DONE] FE-CA-004 Context header and return-to-context contract
|
||||||
- [TODO] FE-CA-005 Grouped overview-card and submenu patterns
|
- [DONE] 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-006 Adoption map, QA, and docs sync for contextual action patterns
|
||||||
|
|||||||
@@ -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
|
- `Security / Reachability` with evidence-side drill-down links
|
||||||
- Notes:
|
- Notes:
|
||||||
- Detailed UX dossier: `docs/modules/ui/reachability-witnessing/README.md`
|
- 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
|
### 4. Platform Ops Consolidation
|
||||||
- Type: `merge`
|
- Type: `merge`
|
||||||
@@ -67,7 +67,7 @@ The order is by confidence that the capability should exist in the final Stella
|
|||||||
- `Ops > Operations`
|
- `Ops > Operations`
|
||||||
- Notes:
|
- Notes:
|
||||||
- Detailed UX dossier: `docs/modules/ui/platform-ops-consolidation/README.md`
|
- 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
|
### 5. Triage Explainability Workbench
|
||||||
- Type: `merge`
|
- 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`
|
- `/triage/artifacts` and `/triage/audit-bundles`
|
||||||
- Notes:
|
- Notes:
|
||||||
- Detailed UX dossier: `docs/modules/ui/triage-explainability-workspace/README.md`
|
- 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
|
### 6. Workflow Visualization And Replay UX
|
||||||
- Type: `merge`
|
- 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.
|
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/`.
|
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`.
|
||||||
|
|||||||
@@ -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
|
- row or bulk actions that preserve page context
|
||||||
- confirmation only for destructive or privilege-sensitive actions
|
- 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
|
## Topic Mapping
|
||||||
|
|
||||||
### Watchlist
|
### 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/ui-analysis/05_ROUTE_SUMMARY_AND_OBSERVATIONS.md`
|
||||||
- `docs/modules/ui/architecture.md`
|
- `docs/modules/ui/architecture.md`
|
||||||
- `docs/modules/ui/architecture-rework.md`
|
- `docs/modules/ui/architecture-rework.md`
|
||||||
|
- `docs/features/checked/web/contextual-actions-patterns-ui.md`
|
||||||
|
|||||||
@@ -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_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_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_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
|
## Latest evidence
|
||||||
- `docs/modules/ui/component-preservation-map/README.md` - root index for the first-pass preservation map.
|
- `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/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/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/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/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/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.
|
- `docs/modules/ui/triage-explainability-workspace/README.md` - detailed artifact workspace and audit-bundle UX dossier.
|
||||||
|
|||||||
@@ -28,11 +28,11 @@ It answers four questions for each topic:
|
|||||||
## Implementation Sprint Set
|
## Implementation Sprint Set
|
||||||
|
|
||||||
- `docs-archived/implplan/SPRINT_20260307_024_FE_identity_watchlist_shell.md` - shipped watchlist restoration
|
- `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-archived/implplan/SPRINT_20260307_025_FE_reachability_witnessing_merge.md` - shipped reachability witnessing restoration
|
||||||
- `docs/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md`
|
- `docs-archived/implplan/SPRINT_20260307_026_FE_platform_ops_consolidation.md` - shipped platform ops consolidation
|
||||||
- `docs/implplan/SPRINT_20260307_027_FE_triage_explainability_workspace.md`
|
- `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-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
|
## Placement Matrix
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ One overview page plus grouped subroutes is enough.
|
|||||||
## Detailed UX And Sprint
|
## Detailed UX And Sprint
|
||||||
|
|
||||||
- Detailed UX dossier: `../platform-ops-consolidation/README.md`
|
- 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
|
## Corroborating Inputs
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ These should deep-link into the same reachability surfaces:
|
|||||||
## Detailed UX And Sprint
|
## Detailed UX And Sprint
|
||||||
|
|
||||||
- Detailed UX dossier: `../reachability-witnessing/README.md`
|
- 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
|
## Corroborating Inputs
|
||||||
|
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ These should be secondary tabs or a right-rail stack, not standalone routes.
|
|||||||
## Detailed UX And Sprint
|
## Detailed UX And Sprint
|
||||||
|
|
||||||
- Detailed UX dossier: `../triage-explainability-workspace/README.md`
|
- 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
|
## Corroborating Inputs
|
||||||
|
|
||||||
|
|||||||
@@ -23,17 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="ops-overview__submenu" aria-label="Operations navigation">
|
<app-tabbed-nav [tabs]="quickNav" variant="submenu" />
|
||||||
@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>
|
|
||||||
|
|
||||||
<section class="ops-overview__summary" aria-label="Operations posture summary">
|
<section class="ops-overview__summary" aria-label="Operations posture summary">
|
||||||
<article>
|
<article>
|
||||||
@@ -86,36 +76,11 @@
|
|||||||
|
|
||||||
<st-doctor-checks-inline category="core" heading="Critical diagnostics" />
|
<st-doctor-checks-inline category="core" heading="Critical diagnostics" />
|
||||||
|
|
||||||
@for (group of overviewGroups; track group.id) {
|
<app-overview-card-groups
|
||||||
<section class="ops-overview__group" [attr.data-testid]="'operations-group-' + group.id">
|
[groups]="overviewGroups"
|
||||||
<div class="ops-overview__section-header">
|
groupTestIdPrefix="operations-group"
|
||||||
<div>
|
cardTestIdPrefix="operations-card"
|
||||||
<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>
|
|
||||||
}
|
|
||||||
|
|
||||||
<section class="ops-overview__footer-grid">
|
<section class="ops-overview__footer-grid">
|
||||||
<article class="ops-overview__panel" data-testid="operations-pending-actions">
|
<article class="ops-overview__panel" data-testid="operations-pending-actions">
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
|
|||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
|
|
||||||
import { DoctorChecksInlineComponent } from '../../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
|
import { DoctorChecksInlineComponent } from '../../doctor/components/doctor-checks-inline/doctor-checks-inline.component';
|
||||||
|
import {
|
||||||
|
OverviewCardGroupsComponent,
|
||||||
|
type OverviewCardGroup,
|
||||||
|
type TabItem,
|
||||||
|
TabbedNavComponent,
|
||||||
|
} from '../../../shared/ui';
|
||||||
import {
|
import {
|
||||||
OPERATIONS_INTEGRATION_PATHS,
|
OPERATIONS_INTEGRATION_PATHS,
|
||||||
OPERATIONS_PATHS,
|
OPERATIONS_PATHS,
|
||||||
@@ -11,12 +17,6 @@ import {
|
|||||||
|
|
||||||
type OpsImpact = 'blocking' | 'degraded' | 'info';
|
type OpsImpact = 'blocking' | 'degraded' | 'info';
|
||||||
|
|
||||||
interface OverviewNavItem {
|
|
||||||
readonly id: string;
|
|
||||||
readonly label: string;
|
|
||||||
readonly route: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BlockingCard {
|
interface BlockingCard {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
@@ -26,23 +26,6 @@ interface BlockingCard {
|
|||||||
readonly route: string;
|
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 {
|
interface PendingAction {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly title: string;
|
readonly title: string;
|
||||||
@@ -54,7 +37,7 @@ interface PendingAction {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-platform-ops-overview-page',
|
selector: 'app-platform-ops-overview-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterLink, DoctorChecksInlineComponent],
|
imports: [RouterLink, DoctorChecksInlineComponent, TabbedNavComponent, OverviewCardGroupsComponent],
|
||||||
templateUrl: './platform-ops-overview-page.component.html',
|
templateUrl: './platform-ops-overview-page.component.html',
|
||||||
styleUrls: ['./platform-ops-overview-page.component.scss'],
|
styleUrls: ['./platform-ops-overview-page.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -64,19 +47,19 @@ export class PlatformOpsOverviewPageComponent {
|
|||||||
readonly OPERATIONS_SETUP_PATHS = OPERATIONS_SETUP_PATHS;
|
readonly OPERATIONS_SETUP_PATHS = OPERATIONS_SETUP_PATHS;
|
||||||
readonly refreshedAt = signal<string | null>(null);
|
readonly refreshedAt = signal<string | null>(null);
|
||||||
|
|
||||||
readonly quickNav: readonly OverviewNavItem[] = [
|
readonly quickNav: readonly TabItem[] = [
|
||||||
{ id: 'overview', label: 'Overview', route: OPERATIONS_PATHS.overview },
|
{ id: 'overview', label: 'Overview', route: OPERATIONS_PATHS.overview, testId: 'operations-nav-overview' },
|
||||||
{ id: 'data-integrity', label: 'Data Integrity', route: OPERATIONS_PATHS.dataIntegrity },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ 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 },
|
{ id: 'offline-kit', label: 'Offline Kit', route: OPERATIONS_PATHS.offlineKit, testId: 'operations-nav-offline-kit' },
|
||||||
{ id: 'quotas', label: 'Quotas & Limits', route: OPERATIONS_PATHS.quotas },
|
{ id: 'quotas', label: 'Quotas & Limits', route: OPERATIONS_PATHS.quotas, testId: 'operations-nav-quotas' },
|
||||||
{ id: 'aoc', label: 'AOC Compliance', route: OPERATIONS_PATHS.aoc },
|
{ id: 'aoc', label: 'AOC Compliance', route: OPERATIONS_PATHS.aoc, testId: 'operations-nav-aoc' },
|
||||||
{ id: 'doctor', label: 'Diagnostics', route: OPERATIONS_PATHS.doctor },
|
{ id: 'doctor', label: 'Diagnostics', route: OPERATIONS_PATHS.doctor, testId: 'operations-nav-doctor' },
|
||||||
{ id: 'signals', label: 'Signals', route: OPERATIONS_PATHS.signals },
|
{ id: 'signals', label: 'Signals', route: OPERATIONS_PATHS.signals, testId: 'operations-nav-signals' },
|
||||||
{ id: 'packs', label: 'Pack Registry', route: OPERATIONS_PATHS.packs },
|
{ id: 'packs', label: 'Pack Registry', route: OPERATIONS_PATHS.packs, testId: 'operations-nav-packs' },
|
||||||
{ id: 'notifications', label: 'Notifications', route: OPERATIONS_PATHS.notifications },
|
{ id: 'notifications', label: 'Notifications', route: OPERATIONS_PATHS.notifications, testId: 'operations-nav-notifications' },
|
||||||
];
|
];
|
||||||
|
|
||||||
readonly blockingCards: readonly BlockingCard[] = [
|
readonly blockingCards: readonly BlockingCard[] = [
|
||||||
@@ -106,7 +89,7 @@ export class PlatformOpsOverviewPageComponent {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
readonly overviewGroups: readonly OperationsGroup[] = [
|
readonly overviewGroups: readonly OverviewCardGroup[] = [
|
||||||
{
|
{
|
||||||
id: 'blocking',
|
id: 'blocking',
|
||||||
title: 'Blocking',
|
title: 'Blocking',
|
||||||
|
|||||||
@@ -1,34 +1,21 @@
|
|||||||
<section class="reachability-shell" data-testid="reachability-page">
|
<section class="reachability-shell" data-testid="reachability-page">
|
||||||
<header class="shell-header">
|
<app-context-header
|
||||||
<div>
|
eyebrow="Security / Reachability"
|
||||||
<p class="eyebrow">Security / Reachability</p>
|
title="Reachability"
|
||||||
<h1>Reachability</h1>
|
subtitle="Coverage, witnesses, proof-of-exposure artifacts, and sensor gaps stay in one investigation shell."
|
||||||
<p class="subtitle">
|
[backLabel]="returnTo() ? 'Return to ' + returnToLabel() : null"
|
||||||
Coverage, witnesses, proof-of-exposure artifacts, and sensor gaps stay in one investigation shell.
|
(backClick)="returnToSource()"
|
||||||
</p>
|
>
|
||||||
</div>
|
<button
|
||||||
|
header-actions
|
||||||
<div class="header-actions">
|
type="button"
|
||||||
@if (returnTo()) {
|
class="btn-secondary"
|
||||||
<button
|
(click)="loadWitnesses()"
|
||||||
type="button"
|
data-testid="reachability-refresh-btn"
|
||||||
class="btn-secondary"
|
>
|
||||||
(click)="returnToSource()"
|
Refresh witnesses
|
||||||
data-testid="reachability-return-btn"
|
</button>
|
||||||
>
|
</app-context-header>
|
||||||
Return to {{ returnToLabel() }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn-secondary"
|
|
||||||
(click)="loadWitnesses()"
|
|
||||||
data-testid="reachability-refresh-btn"
|
|
||||||
>
|
|
||||||
Refresh witnesses
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
@if (message()) {
|
@if (message()) {
|
||||||
<div class="message-banner" [class.error]="messageType() === 'error'">
|
<div class="message-banner" [class.error]="messageType() === 'error'">
|
||||||
@@ -60,40 +47,11 @@
|
|||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<nav class="tab-strip" aria-label="Reachability tabs">
|
<app-tabbed-nav
|
||||||
<button
|
[tabs]="tabItems"
|
||||||
type="button"
|
[activeTab]="activeTab()"
|
||||||
data-testid="reachability-tab-coverage"
|
(tabChange)="onTabSelected($event.id)"
|
||||||
[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>
|
|
||||||
|
|
||||||
@if (activeTab() === 'coverage') {
|
@if (activeTab() === 'coverage') {
|
||||||
<section class="panel-stack">
|
<section class="panel-stack">
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ import type {
|
|||||||
ConfidenceTier,
|
ConfidenceTier,
|
||||||
ReachabilityWitness,
|
ReachabilityWitness,
|
||||||
} from '../../core/api/witness.models';
|
} 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 { PoEDrawerComponent } from './poe-drawer.component';
|
||||||
import {
|
import {
|
||||||
type CoverageStatus,
|
type CoverageStatus,
|
||||||
@@ -55,7 +65,13 @@ const TIER_FILTERS: readonly TierFilter[] = [
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-reachability-center',
|
selector: 'app-reachability-center',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, RouterLink, PoEDrawerComponent],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterLink,
|
||||||
|
PoEDrawerComponent,
|
||||||
|
ContextHeaderComponent,
|
||||||
|
TabbedNavComponent,
|
||||||
|
],
|
||||||
templateUrl: './reachability-center.component.html',
|
templateUrl: './reachability-center.component.html',
|
||||||
styleUrls: ['./reachability-center.component.scss'],
|
styleUrls: ['./reachability-center.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -81,6 +97,12 @@ export class ReachabilityCenterComponent implements OnInit {
|
|||||||
readonly coverageRows = signal([...REACHABILITY_COVERAGE_ROWS]);
|
readonly coverageRows = signal([...REACHABILITY_COVERAGE_ROWS]);
|
||||||
readonly gapRows = signal([...REACHABILITY_GAP_ROWS]);
|
readonly gapRows = signal([...REACHABILITY_GAP_ROWS]);
|
||||||
readonly witnesses = signal<ReachabilityWitness[]>([]);
|
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(() => {
|
readonly filteredCoverageRows = computed(() => {
|
||||||
const status = this.coverageStatusFilter();
|
const status = this.coverageStatusFilter();
|
||||||
@@ -243,6 +265,24 @@ export class ReachabilityCenterComponent implements OnInit {
|
|||||||
void this.navigateToTab('gaps');
|
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 {
|
openPoeArtifact(artifactId: string): void {
|
||||||
this.selectedPoeArtifactId.set(artifactId);
|
this.selectedPoeArtifactId.set(artifactId);
|
||||||
void this.navigateToTab('poe', artifactId);
|
void this.navigateToTab('poe', artifactId);
|
||||||
@@ -357,29 +397,28 @@ export class ReachabilityCenterComponent implements OnInit {
|
|||||||
params: ParamMap,
|
params: ParamMap,
|
||||||
queryParams: ParamMap
|
queryParams: ParamMap
|
||||||
): void {
|
): void {
|
||||||
const tab = this.parseTab(segments, queryParams.get('tab'));
|
const tab = this.parseTab(segments, queryParams);
|
||||||
this.activeTab.set(tab);
|
this.activeTab.set(tab);
|
||||||
this.returnTo.set(queryParams.get('returnTo'));
|
this.returnTo.set(readContextRouteParam(queryParams, 'returnTo'));
|
||||||
this.witnessSearch.set(queryParams.get('search') ?? '');
|
this.witnessSearch.set(readContextRouteParam(queryParams, 'search') ?? '');
|
||||||
this.tierFilter.set(this.parseTier(queryParams.get('tier')));
|
this.tierFilter.set(this.parseTier(readContextRouteParam(queryParams, 'tier')));
|
||||||
this.selectedPoeArtifactId.set(
|
this.selectedPoeArtifactId.set(
|
||||||
tab === 'poe' ? params.get('artifactId') : null
|
tab === 'poe' ? readContextRouteParam(params, 'artifactId') : null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseTab(
|
private parseTab(
|
||||||
segments: readonly string[],
|
segments: readonly string[],
|
||||||
queryValue: string | null
|
queryParams: ParamMap
|
||||||
): ReachabilityTab {
|
): ReachabilityTab {
|
||||||
if (queryValue && REACHABILITY_TABS.includes(queryValue as ReachabilityTab)) {
|
|
||||||
return queryValue as ReachabilityTab;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstRecognized = segments.find((segment) =>
|
const firstRecognized = segments.find((segment) =>
|
||||||
REACHABILITY_TABS.includes(segment as ReachabilityTab)
|
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 {
|
private parseTier(value: string | null): TierFilter {
|
||||||
@@ -406,21 +445,12 @@ export class ReachabilityCenterComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildQueryParams(tab: ReachabilityTab): Record<string, string> {
|
private buildQueryParams(tab: ReachabilityTab): Record<string, string> {
|
||||||
const params: Record<string, string> = {
|
return buildContextRouteParams({
|
||||||
tab,
|
tab,
|
||||||
};
|
returnTo: this.returnTo(),
|
||||||
|
search: this.witnessSearch().trim() || null,
|
||||||
if (this.returnTo()) {
|
tier: this.tierFilter() || null,
|
||||||
params['returnTo'] = this.returnTo()!;
|
}) as Record<string, string>;
|
||||||
}
|
|
||||||
if (this.witnessSearch().trim()) {
|
|
||||||
params['search'] = this.witnessSearch().trim();
|
|
||||||
}
|
|
||||||
if (this.tierFilter()) {
|
|
||||||
params['tier'] = this.tierFilter();
|
|
||||||
}
|
|
||||||
|
|
||||||
return params;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async verifyWitnessFallbackAware(
|
private async verifyWitnessFallbackAware(
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
<div class="watchlist-page" data-testid="watchlist-page">
|
<div class="watchlist-page" data-testid="watchlist-page">
|
||||||
<header class="page-header">
|
<app-context-header
|
||||||
<div>
|
eyebrow="Trust & Signing"
|
||||||
<p class="eyebrow">Trust & Signing</p>
|
title="Identity Watchlist"
|
||||||
<h1>Identity Watchlist</h1>
|
subtitle="Monitor signer identities, triage watchlist alerts, and tune dedup or routing controls from the trust shell."
|
||||||
<p class="subtitle">
|
[contextNote]="currentModeLabel()"
|
||||||
Monitor signer identities, triage watchlist alerts, and tune dedup or routing controls from the trust shell.
|
[backLabel]="returnTo() ? 'Return to ' + returnToLabel() : null"
|
||||||
</p>
|
(backClick)="returnToSource()"
|
||||||
<p class="mode">{{ currentModeLabel() }}</p>
|
>
|
||||||
</div>
|
<button
|
||||||
|
header-actions
|
||||||
<div class="header-actions">
|
type="button"
|
||||||
@if (returnTo()) {
|
class="btn-secondary"
|
||||||
<button type="button" class="btn-secondary" (click)="returnToSource()">
|
(click)="refreshCurrentTab()"
|
||||||
Return to {{ returnToLabel() }}
|
[disabled]="loading()"
|
||||||
</button>
|
>
|
||||||
}
|
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||||
<button type="button" class="btn-secondary" (click)="refreshCurrentTab()" [disabled]="loading()">
|
</button>
|
||||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
</app-context-header>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
@if (message()) {
|
@if (message()) {
|
||||||
<div
|
<div
|
||||||
@@ -72,32 +69,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<nav class="tabs" aria-label="Watchlist tabs">
|
<app-tabbed-nav
|
||||||
<button
|
[tabs]="tabItems"
|
||||||
type="button"
|
[activeTab]="activeTab()"
|
||||||
data-testid="watchlist-tab-entries"
|
(tabChange)="onTabSelected($event.id)"
|
||||||
[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>
|
|
||||||
|
|
||||||
@if (activeTab() === 'entries') {
|
@if (activeTab() === 'entries') {
|
||||||
<section class="workspace">
|
<section class="workspace">
|
||||||
@@ -132,8 +108,8 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="workspace-grid" [class.workspace-grid--detail]="!!entryPanelMode()">
|
<app-list-detail-shell [detailVisible]="!!entryPanelMode()">
|
||||||
<div class="workspace-primary">
|
<div shell-primary class="workspace-primary">
|
||||||
@if (entriesLoading() && !entries().length) {
|
@if (entriesLoading() && !entries().length) {
|
||||||
<div class="empty-state">Loading watchlist rules...</div>
|
<div class="empty-state">Loading watchlist rules...</div>
|
||||||
} @else if (!filteredEntries().length) {
|
} @else if (!filteredEntries().length) {
|
||||||
@@ -218,7 +194,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (entryPanelMode()) {
|
@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">
|
<header class="detail-panel__header">
|
||||||
<div>
|
<div>
|
||||||
<p class="detail-eyebrow">Entries</p>
|
<p class="detail-eyebrow">Entries</p>
|
||||||
@@ -373,7 +349,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
}
|
}
|
||||||
</div>
|
</app-list-detail-shell>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,8 +398,8 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="workspace-grid" [class.workspace-grid--detail]="!!selectedAlert()">
|
<app-list-detail-shell [detailVisible]="!!selectedAlert()">
|
||||||
<div class="workspace-primary">
|
<div shell-primary class="workspace-primary">
|
||||||
@if (alertsLoading() && !alerts().length) {
|
@if (alertsLoading() && !alerts().length) {
|
||||||
<div class="empty-state">Loading watchlist alerts...</div>
|
<div class="empty-state">Loading watchlist alerts...</div>
|
||||||
} @else if (!filteredAlerts().length) {
|
} @else if (!filteredAlerts().length) {
|
||||||
@@ -481,7 +457,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (selectedAlert(); as alert) {
|
@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">
|
<header class="detail-panel__header">
|
||||||
<div>
|
<div>
|
||||||
<p class="detail-eyebrow">Alert detail</p>
|
<p class="detail-eyebrow">Alert detail</p>
|
||||||
@@ -548,7 +524,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
}
|
}
|
||||||
</div>
|
</app-list-detail-shell>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ import {
|
|||||||
WatchlistMatchMode,
|
WatchlistMatchMode,
|
||||||
WatchlistScope,
|
WatchlistScope,
|
||||||
} from '../../core/api/watchlist.models';
|
} 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 ViewMode = 'list' | 'edit' | 'alerts';
|
||||||
type WatchlistTab = 'entries' | 'alerts' | 'tuning';
|
type WatchlistTab = 'entries' | 'alerts' | 'tuning';
|
||||||
@@ -62,7 +73,13 @@ const ALERT_SORT_ORDERS: readonly AlertSortOrder[] = ['newest', 'oldest'];
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-watchlist-page',
|
selector: 'app-watchlist-page',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, ReactiveFormsModule],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
ContextHeaderComponent,
|
||||||
|
ListDetailShellComponent,
|
||||||
|
TabbedNavComponent,
|
||||||
|
],
|
||||||
templateUrl: './watchlist-page.component.html',
|
templateUrl: './watchlist-page.component.html',
|
||||||
styleUrls: ['./watchlist-page.component.scss'],
|
styleUrls: ['./watchlist-page.component.scss'],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@@ -118,6 +135,11 @@ export class WatchlistPageComponent implements OnInit {
|
|||||||
'Critical',
|
'Critical',
|
||||||
];
|
];
|
||||||
readonly tabOptions: readonly WatchlistTab[] = WATCHLIST_TABS;
|
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 scopeOptions: readonly WatchlistScopeFilter[] = WATCHLIST_SCOPES;
|
||||||
readonly alertWindows: readonly AlertWindow[] = ALERT_WINDOWS;
|
readonly alertWindows: readonly AlertWindow[] = ALERT_WINDOWS;
|
||||||
readonly alertSortOptions: readonly AlertSortOrder[] = ALERT_SORT_ORDERS;
|
readonly alertSortOptions: readonly AlertSortOrder[] = ALERT_SORT_ORDERS;
|
||||||
@@ -729,6 +751,21 @@ export class WatchlistPageComponent implements OnInit {
|
|||||||
void this.loadEntries();
|
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 {
|
changeScope(scope: WatchlistScopeFilter): void {
|
||||||
const entry = this.selectedEntry();
|
const entry = this.selectedEntry();
|
||||||
const alert = this.selectedAlert();
|
const alert = this.selectedAlert();
|
||||||
@@ -886,15 +923,15 @@ export class WatchlistPageComponent implements OnInit {
|
|||||||
segments: readonly string[],
|
segments: readonly string[],
|
||||||
params: ParamMap
|
params: ParamMap
|
||||||
): void {
|
): void {
|
||||||
const tab = this.parseTab(segments, params.get('tab'));
|
const tab = this.parseTab(segments, params);
|
||||||
const scope = this.parseScope(params.get('scope'));
|
const scope = readContextRouteState(params, 'scope', WATCHLIST_SCOPES, 'tenant');
|
||||||
const entryId = params.get('entryId');
|
const entryId = readContextRouteParam(params, 'entryId');
|
||||||
const duplicateOf = params.get('duplicateOf');
|
const duplicateOf = readContextRouteParam(params, 'duplicateOf');
|
||||||
const alertId = params.get('alertId');
|
const alertId = readContextRouteParam(params, 'alertId');
|
||||||
|
|
||||||
this.activeTab.set(tab);
|
this.activeTab.set(tab);
|
||||||
this.scopeFilter.set(scope);
|
this.scopeFilter.set(scope);
|
||||||
this.returnTo.set(params.get('returnTo'));
|
this.returnTo.set(readContextRouteParam(params, 'returnTo'));
|
||||||
|
|
||||||
if (tab === 'alerts') {
|
if (tab === 'alerts') {
|
||||||
this.entryPanelMode.set(null);
|
this.entryPanelMode.set(null);
|
||||||
@@ -1033,25 +1070,14 @@ export class WatchlistPageComponent implements OnInit {
|
|||||||
|
|
||||||
private parseTab(
|
private parseTab(
|
||||||
segments: readonly string[],
|
segments: readonly string[],
|
||||||
queryValue: string | null
|
params: ParamMap
|
||||||
): WatchlistTab {
|
): WatchlistTab {
|
||||||
if (queryValue && WATCHLIST_TABS.includes(queryValue as WatchlistTab)) {
|
|
||||||
return queryValue as WatchlistTab;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalSegment = segments.at(-1);
|
const finalSegment = segments.at(-1);
|
||||||
if (finalSegment && WATCHLIST_TABS.includes(finalSegment as WatchlistTab)) {
|
if (finalSegment && WATCHLIST_TABS.includes(finalSegment as WatchlistTab)) {
|
||||||
return finalSegment as WatchlistTab;
|
return finalSegment as WatchlistTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'entries';
|
return readContextRouteState(params, 'tab', WATCHLIST_TABS, 'entries');
|
||||||
}
|
|
||||||
|
|
||||||
private parseScope(rawValue: string | null): WatchlistScopeFilter {
|
|
||||||
if (rawValue && WATCHLIST_SCOPES.includes(rawValue as WatchlistScopeFilter)) {
|
|
||||||
return rawValue as WatchlistScopeFilter;
|
|
||||||
}
|
|
||||||
return 'tenant';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveAlertThreshold(window: AlertWindow): number {
|
private resolveAlertThreshold(window: AlertWindow): number {
|
||||||
@@ -1112,29 +1138,14 @@ export class WatchlistPageComponent implements OnInit {
|
|||||||
const alertId = overrides.alertId ?? this.selectedAlertId();
|
const alertId = overrides.alertId ?? this.selectedAlertId();
|
||||||
const duplicateOf = overrides.duplicateOf ?? this.duplicateSourceId();
|
const duplicateOf = overrides.duplicateOf ?? this.duplicateSourceId();
|
||||||
|
|
||||||
const params: Record<string, string> = {
|
return buildContextRouteParams({
|
||||||
scope,
|
scope,
|
||||||
tab,
|
tab,
|
||||||
};
|
returnTo,
|
||||||
|
entryId: tab === 'entries' || tab === 'tuning' ? entryId : null,
|
||||||
if (returnTo) {
|
duplicateOf: tab === 'entries' && entryId === 'new' ? duplicateOf : null,
|
||||||
params['returnTo'] = returnTo;
|
alertId: tab === 'alerts' ? alertId : null,
|
||||||
}
|
}) as Record<string, string>;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private showSuccess(message: string): void {
|
private showSuccess(message: string): void {
|
||||||
|
|||||||
@@ -1,44 +1,20 @@
|
|||||||
<section class="run-workspace" data-testid="run-graph-replay-page">
|
<section class="run-workspace" data-testid="run-graph-replay-page">
|
||||||
<header class="run-workspace__header">
|
<app-context-header
|
||||||
<div>
|
eyebrow="Releases / Runs"
|
||||||
<a routerLink="/releases/runs" class="run-workspace__back-link">Back to release runs</a>
|
[title]="context()?.detail?.releaseName || 'Release Run'"
|
||||||
<h1>{{ context()?.detail?.releaseName || 'Release Run' }}</h1>
|
[subtitle]="'Runtime graphing, replay, and evidence for ' + runId()"
|
||||||
<p class="run-workspace__subtitle">
|
[chips]="headerChips()"
|
||||||
Runtime graphing, replay, and evidence for
|
[backLabel]="returnTo() ? 'Return to previous context' : null"
|
||||||
<code>{{ runId() }}</code>
|
(backClick)="returnToSource()"
|
||||||
</p>
|
>
|
||||||
</div>
|
<a header-actions routerLink="/releases/runs" class="run-workspace__back-link">Back to release runs</a>
|
||||||
|
</app-context-header>
|
||||||
|
|
||||||
@if (context(); as context) {
|
<app-tabbed-nav
|
||||||
<div class="run-workspace__meta">
|
[tabs]="tabs"
|
||||||
<span class="chip">{{ context.detail.releaseType }}</span>
|
[activeTab]="activeTab()"
|
||||||
<span class="chip">{{ context.detail.status }}</span>
|
(tabChange)="onTabSelected($event.id)"
|
||||||
<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>
|
|
||||||
|
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="state-banner">Loading run graph and replay context...</div>
|
<div class="state-banner">Loading run graph and replay context...</div>
|
||||||
@@ -241,22 +217,24 @@
|
|||||||
}
|
}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@if (selectedStep()) {
|
<app-context-drawer-host
|
||||||
<aside class="step-drawer" data-testid="run-step-drawer">
|
[open]="!!selectedStep()"
|
||||||
<div class="step-drawer__header">
|
presentation="rail"
|
||||||
<h2>Step detail</h2>
|
size="md"
|
||||||
<button type="button" class="btn btn--secondary" (click)="closeStep()">Close</button>
|
eyebrow="Run step"
|
||||||
</div>
|
[title]="selectedStep()?.stepName || 'Step detail'"
|
||||||
|
description="Inspect inputs, outputs, dependency state, and logs without leaving the run workspace."
|
||||||
<app-step-detail-panel
|
testId="run-step-drawer"
|
||||||
[runId]="runId()"
|
(closed)="closeStep()"
|
||||||
[stepId]="selectedStepId() ?? undefined"
|
>
|
||||||
[stepData]="selectedStep()"
|
<app-step-detail-panel
|
||||||
[logsData]="selectedStepLogs()"
|
[runId]="runId()"
|
||||||
(stepSelected)="openStep($event)"
|
[stepId]="selectedStepId() ?? undefined"
|
||||||
/>
|
[stepData]="selectedStep()"
|
||||||
</aside>
|
[logsData]="selectedStepLogs()"
|
||||||
}
|
(stepSelected)="openStep($event)"
|
||||||
|
/>
|
||||||
|
</app-context-drawer-host>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
import { ReplayControlsComponent } from '../evidence-export/replay-controls.component';
|
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 { StepDetailPanelComponent } from './components/step-detail-panel/step-detail-panel.component';
|
||||||
import { TimeTravelControlsComponent } from './components/time-travel-controls/time-travel-controls.component';
|
import { TimeTravelControlsComponent } from './components/time-travel-controls/time-travel-controls.component';
|
||||||
import { WorkflowVisualizerComponent } from './components/workflow-visualizer/workflow-visualizer.component';
|
import { WorkflowVisualizerComponent } from './components/workflow-visualizer/workflow-visualizer.component';
|
||||||
@@ -20,6 +31,9 @@ import {
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
|
ContextHeaderComponent,
|
||||||
|
ContextDrawerHostComponent,
|
||||||
|
TabbedNavComponent,
|
||||||
WorkflowVisualizerComponent,
|
WorkflowVisualizerComponent,
|
||||||
StepDetailPanelComponent,
|
StepDetailPanelComponent,
|
||||||
TimeTravelControlsComponent,
|
TimeTravelControlsComponent,
|
||||||
@@ -45,14 +59,27 @@ export class RunGraphReplayPageComponent {
|
|||||||
readonly criticalPathOnly = signal(false);
|
readonly criticalPathOnly = signal(false);
|
||||||
readonly returnTo = signal<string | null>(null);
|
readonly returnTo = signal<string | null>(null);
|
||||||
|
|
||||||
readonly tabs: readonly { id: RunWorkspaceTab; label: string }[] = [
|
readonly tabs: readonly TabItem[] = [
|
||||||
{ id: 'summary', label: 'Summary' },
|
{ id: 'summary', label: 'Summary', testId: 'run-workspace-tab-summary' },
|
||||||
{ id: 'graph', label: 'Graph' },
|
{ id: 'graph', label: 'Graph', testId: 'run-workspace-tab-graph' },
|
||||||
{ id: 'timeline', label: 'Timeline' },
|
{ id: 'timeline', label: 'Timeline', testId: 'run-workspace-tab-timeline' },
|
||||||
{ id: 'critical-path', label: 'Critical Path' },
|
{ id: 'critical-path', label: 'Critical Path', testId: 'run-workspace-tab-critical-path' },
|
||||||
{ id: 'replay', label: 'Replay' },
|
{ id: 'replay', label: 'Replay', testId: 'run-workspace-tab-replay' },
|
||||||
{ id: 'evidence', label: 'Evidence' },
|
{ 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(() => {
|
readonly filteredGraph = computed(() => {
|
||||||
const context = this.context();
|
const context = this.context();
|
||||||
@@ -133,27 +160,32 @@ export class RunGraphReplayPageComponent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe((params) => {
|
this.route.queryParamMap.pipe(takeUntilDestroyed()).subscribe((params) => {
|
||||||
this.returnTo.set(params.get('returnTo'));
|
this.returnTo.set(readContextRouteParam(params, 'returnTo'));
|
||||||
this.selectedStepId.set(params.get('step'));
|
this.selectedStepId.set(readContextRouteParam(params, 'step'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setTab(tab: RunWorkspaceTab): void {
|
setTab(tab: RunWorkspaceTab): void {
|
||||||
void this.router.navigate(['/releases/runs', this.runId(), tab], {
|
void this.router.navigate(['/releases/runs', this.runId(), tab], {
|
||||||
queryParams: {
|
queryParams: buildContextRouteParams({
|
||||||
|
drawer: this.selectedStepId() ? 'step' : null,
|
||||||
step: this.selectedStepId(),
|
step: this.selectedStepId(),
|
||||||
returnTo: this.returnTo(),
|
returnTo: this.returnTo(),
|
||||||
},
|
}),
|
||||||
queryParamsHandling: 'merge',
|
queryParamsHandling: 'merge',
|
||||||
replaceUrl: true,
|
replaceUrl: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onTabSelected(tabId: string): void {
|
||||||
|
this.setTab(this.normalizeTab(tabId));
|
||||||
|
}
|
||||||
|
|
||||||
openStep(stepId: string): void {
|
openStep(stepId: string): void {
|
||||||
this.selectedStepId.set(stepId);
|
this.selectedStepId.set(stepId);
|
||||||
void this.router.navigate([], {
|
void this.router.navigate([], {
|
||||||
relativeTo: this.route,
|
relativeTo: this.route,
|
||||||
queryParams: { step: stepId },
|
queryParams: buildContextRouteParams({ drawer: 'step', step: stepId }),
|
||||||
queryParamsHandling: 'merge',
|
queryParamsHandling: 'merge',
|
||||||
replaceUrl: true,
|
replaceUrl: true,
|
||||||
});
|
});
|
||||||
@@ -163,7 +195,7 @@ export class RunGraphReplayPageComponent {
|
|||||||
this.selectedStepId.set(null);
|
this.selectedStepId.set(null);
|
||||||
void this.router.navigate([], {
|
void this.router.navigate([], {
|
||||||
relativeTo: this.route,
|
relativeTo: this.route,
|
||||||
queryParams: { step: null },
|
queryParams: buildContextRouteParams({ drawer: null, step: null }),
|
||||||
queryParamsHandling: 'merge',
|
queryParamsHandling: 'merge',
|
||||||
replaceUrl: true,
|
replaceUrl: true,
|
||||||
});
|
});
|
||||||
@@ -183,6 +215,14 @@ export class RunGraphReplayPageComponent {
|
|||||||
queryParams: {
|
queryParams: {
|
||||||
releaseId: context?.detail.releaseId,
|
releaseId: context?.detail.releaseId,
|
||||||
runId: this.runId(),
|
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 {
|
formatWhen(value: string | null | undefined): string {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return 'n/a';
|
return 'n/a';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,9 +7,14 @@
|
|||||||
|
|
||||||
// Layout primitives
|
// Layout primitives
|
||||||
export * from './page-header/page-header.component';
|
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 './filter-bar/filter-bar.component';
|
||||||
|
export * from './list-detail-shell/list-detail-shell.component';
|
||||||
export * from './split-pane/split-pane.component';
|
export * from './split-pane/split-pane.component';
|
||||||
export * from './tabbed-nav/tabbed-nav.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
|
// Data display
|
||||||
export * from './status-badge/status-badge.component';
|
export * from './status-badge/status-badge.component';
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -13,8 +13,10 @@ export interface TabItem {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon?: 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;
|
disabled?: boolean;
|
||||||
|
testId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -23,16 +25,18 @@ export interface TabItem {
|
|||||||
imports: [RouterLink, RouterLinkActive],
|
imports: [RouterLink, RouterLinkActive],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
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) {
|
@for (tab of tabs; track tab.id) {
|
||||||
@if (tab.route) {
|
@if (tab.route) {
|
||||||
<a
|
<a
|
||||||
class="tabbed-nav__tab"
|
class="tabbed-nav__tab"
|
||||||
[routerLink]="tab.route"
|
[routerLink]="tab.route"
|
||||||
|
[queryParams]="tab.queryParams"
|
||||||
routerLinkActive="tabbed-nav__tab--active"
|
routerLinkActive="tabbed-nav__tab--active"
|
||||||
[class.tabbed-nav__tab--disabled]="tab.disabled"
|
[class.tabbed-nav__tab--disabled]="tab.disabled"
|
||||||
role="tab"
|
[attr.role]="variant === 'submenu' ? null : 'tab'"
|
||||||
[attr.aria-disabled]="tab.disabled"
|
[attr.aria-disabled]="tab.disabled"
|
||||||
|
[attr.data-testid]="tab.testId || null"
|
||||||
>
|
>
|
||||||
@if (tab.icon) {
|
@if (tab.icon) {
|
||||||
<span class="tabbed-nav__icon">{{ tab.icon }}</span>
|
<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--active]="activeTab === tab.id"
|
||||||
[class.tabbed-nav__tab--disabled]="tab.disabled"
|
[class.tabbed-nav__tab--disabled]="tab.disabled"
|
||||||
[disabled]="tab.disabled"
|
[disabled]="tab.disabled"
|
||||||
role="tab"
|
[attr.role]="variant === 'submenu' ? null : 'tab'"
|
||||||
[attr.aria-selected]="activeTab === tab.id"
|
[attr.aria-selected]="variant === 'submenu' ? null : activeTab === tab.id"
|
||||||
|
[attr.data-testid]="tab.testId || null"
|
||||||
(click)="selectTab(tab)"
|
(click)="selectTab(tab)"
|
||||||
>
|
>
|
||||||
@if (tab.icon) {
|
@if (tab.icon) {
|
||||||
@@ -65,6 +70,12 @@ export interface TabItem {
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
border-bottom: 1px solid var(--color-border-primary);
|
border-bottom: 1px solid var(--color-border-primary);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbed-nav--submenu {
|
||||||
|
border-bottom: none;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tabbed-nav__tab {
|
.tabbed-nav__tab {
|
||||||
@@ -84,6 +95,13 @@ export interface TabItem {
|
|||||||
text-decoration: none;
|
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) {
|
.tabbed-nav__tab:hover:not(.tabbed-nav__tab--disabled) {
|
||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
@@ -106,6 +124,7 @@ export interface TabItem {
|
|||||||
export class TabbedNavComponent {
|
export class TabbedNavComponent {
|
||||||
@Input() tabs: TabItem[] = [];
|
@Input() tabs: TabItem[] = [];
|
||||||
@Input() activeTab?: string;
|
@Input() activeTab?: string;
|
||||||
|
@Input() variant: 'tabs' | 'submenu' = 'tabs';
|
||||||
|
|
||||||
@Output() tabChange = new EventEmitter<TabItem>();
|
@Output() tabChange = new EventEmitter<TabItem>();
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -372,7 +372,7 @@ describe('RunGraphReplayPageComponent', () => {
|
|||||||
expect(fixture.nativeElement.querySelector('[data-testid="run-step-drawer"]')).toBeTruthy();
|
expect(fixture.nativeElement.querySelector('[data-testid="run-step-drawer"]')).toBeTruthy();
|
||||||
expect(fixture.nativeElement.textContent).toContain('Policy Gate');
|
expect(fixture.nativeElement.textContent).toContain('Policy Gate');
|
||||||
expect(
|
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();
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -391,6 +391,7 @@ describe('RunGraphReplayPageComponent', () => {
|
|||||||
queryParams: {
|
queryParams: {
|
||||||
releaseId: 'rel-demo',
|
releaseId: 'rel-demo',
|
||||||
runId: 'run-demo',
|
runId: 'run-demo',
|
||||||
|
returnTo: '/releases/runs/run-demo/replay',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -411,7 +412,7 @@ describe('RunGraphReplayPageComponent', () => {
|
|||||||
expect(component.selectedStepId()).toBe('approval');
|
expect(component.selectedStepId()).toBe('approval');
|
||||||
expect(navigateSpy).toHaveBeenCalledWith([], {
|
expect(navigateSpy).toHaveBeenCalledWith([], {
|
||||||
relativeTo: activatedRouteStub as unknown as ActivatedRoute,
|
relativeTo: activatedRouteStub as unknown as ActivatedRoute,
|
||||||
queryParams: { step: 'approval' },
|
queryParams: { drawer: 'step', step: 'approval' },
|
||||||
queryParamsHandling: 'merge',
|
queryParamsHandling: 'merge',
|
||||||
replaceUrl: true,
|
replaceUrl: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 page.goto('/setup/trust-signing/watchlist/entries', { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
await expect(page.getByTestId('watchlist-page')).toBeVisible();
|
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 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).toHaveURL(/\/setup\/trust-signing\/watchlist\/alerts/);
|
||||||
await expect(page.getByTestId('alerts-window-select')).toHaveValue('24h');
|
await expect(page.getByTestId('alerts-window-select')).toHaveValue('24h');
|
||||||
await page.getByRole('button', { name: 'View' }).first().click();
|
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).toHaveURL(/\/setup\/trust-signing\/watchlist\/entries/);
|
||||||
await expect(page.getByTestId('entry-form')).toBeVisible();
|
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).toHaveURL(/\/setup\/trust-signing\/watchlist\/tuning/);
|
||||||
await expect(page.getByTestId('tuning-form')).toBeVisible();
|
await expect(page.getByTestId('tuning-form')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 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-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 expect(page.getByRole('button', { name: 'Critical path only' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByTestId('run-workspace-tab-timeline').click();
|
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 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).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 }) => {
|
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 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).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/);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user