feat(ui): ship policy decisioning studio
This commit is contained in:
@@ -33,7 +33,7 @@
|
|||||||
## Delivery Tracker
|
## Delivery Tracker
|
||||||
|
|
||||||
### FE-PD-001 - Build the canonical `/ops/policy` shell
|
### FE-PD-001 - Build the canonical `/ops/policy` shell
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: none
|
Dependency: none
|
||||||
Owners: Product Manager, FE Architect
|
Owners: Product Manager, FE Architect
|
||||||
Task description:
|
Task description:
|
||||||
@@ -41,12 +41,12 @@ Task description:
|
|||||||
- Make the shell usable in global, pack, and release-context modes from the first shipped route.
|
- Make the shell usable in global, pack, and release-context modes from the first shipped route.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] `/ops/policy` renders as the canonical shell with working top-level navigation.
|
- [x] `/ops/policy` renders as the canonical shell with working top-level navigation.
|
||||||
- [ ] Primary tabs and shared context header are wired in code.
|
- [x] Primary tabs and shared context header are wired in code.
|
||||||
- [ ] Release-context mode can be entered without creating a separate product shell.
|
- [x] Release-context mode can be entered without creating a separate product shell.
|
||||||
|
|
||||||
### FE-PD-002 - Migrate routes and legacy aliases into the new tree
|
### FE-PD-002 - Migrate routes and legacy aliases into the new tree
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-PD-001
|
Dependency: FE-PD-001
|
||||||
Owners: FE Architect, Documentation author
|
Owners: FE Architect, Documentation author
|
||||||
Task description:
|
Task description:
|
||||||
@@ -54,12 +54,12 @@ Task description:
|
|||||||
- Wire redirects from `/policy-studio/*`, `/admin/policy/*`, and `/admin/vex-hub/*` so old entry points land on usable new pages.
|
- Wire redirects from `/policy-studio/*`, `/admin/policy/*`, and `/admin/vex-hub/*` so old entry points land on usable new pages.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Canonical child routes exist in the active router.
|
- [x] Canonical child routes exist in the active router.
|
||||||
- [ ] Legacy aliases redirect into working `/ops/policy` subviews.
|
- [x] Legacy aliases redirect into working `/ops/policy` subviews.
|
||||||
- [ ] No mutable policy or VEX workflow remains dependent on an orphan route.
|
- [x] No mutable policy or VEX workflow remains dependent on an orphan route.
|
||||||
|
|
||||||
### FE-PD-003 - Ship Packs and Governance functionality
|
### FE-PD-003 - Ship Packs and Governance functionality
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-PD-002
|
Dependency: FE-PD-002
|
||||||
Owners: FE Architect, Documentation author
|
Owners: FE Architect, Documentation author
|
||||||
Task description:
|
Task description:
|
||||||
@@ -67,12 +67,12 @@ Task description:
|
|||||||
- Ensure these flows remain usable, not just reachable, after the shell cutover.
|
- Ensure these flows remain usable, not just reachable, after the shell cutover.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Packs and Governance tabs are functional under `/ops/policy`.
|
- [x] Packs and Governance tabs are functional under `/ops/policy`.
|
||||||
- [ ] Editing, approvals, governance settings, and explain flows are usable from the new shell.
|
- [x] Editing, approvals, governance settings, and explain flows are usable from the new shell.
|
||||||
- [ ] Superseded pack and governance shells can be retired or redirected after cutover.
|
- [x] Superseded pack and governance shells can be retired or redirected after cutover.
|
||||||
|
|
||||||
### FE-PD-004 - Ship Simulation, VEX, Exceptions, Gates, and Audit functionality
|
### FE-PD-004 - Ship Simulation, VEX, Exceptions, Gates, and Audit functionality
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-PD-001
|
Dependency: FE-PD-001
|
||||||
Owners: Product Manager, FE Architect
|
Owners: Product Manager, FE Architect
|
||||||
Task description:
|
Task description:
|
||||||
@@ -80,12 +80,12 @@ Task description:
|
|||||||
- Ensure operators can complete the key workflows from the new tabs without falling back to dead or duplicate screens.
|
- Ensure operators can complete the key workflows from the new tabs without falling back to dead or duplicate screens.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Simulation, VEX, Exceptions, Release Gates, and Audit tabs are functional under `/ops/policy`.
|
- [x] Simulation, VEX, Exceptions, Release Gates, and Audit tabs are functional under `/ops/policy`.
|
||||||
- [ ] Conflict resolution, exception handling, and gate review are usable from the new shell.
|
- [x] Conflict resolution, exception handling, and gate review are usable from the new shell.
|
||||||
- [ ] Old mutable VEX and policy action pages are no longer required for those workflows.
|
- [x] Old mutable VEX and policy action pages are no longer required for those workflows.
|
||||||
|
|
||||||
### FE-PD-005 - Wire Release Orchestrator into Decisioning Studio
|
### FE-PD-005 - Wire Release Orchestrator into Decisioning Studio
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-PD-002
|
Dependency: FE-PD-002
|
||||||
Owners: Developer, FE Architect
|
Owners: Developer, FE Architect
|
||||||
Task description:
|
Task description:
|
||||||
@@ -93,12 +93,12 @@ Task description:
|
|||||||
- Keep Release Orchestrator as the owner of release state while Decisioning Studio owns policy and VEX actions.
|
- Keep Release Orchestrator as the owner of release state while Decisioning Studio owns policy and VEX actions.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Release-context entry points are wired from active release surfaces.
|
- [x] Release-context entry points are wired from active release surfaces.
|
||||||
- [ ] Release-context header shows the required release, environment, artifact, and gate state.
|
- [x] Release-context header shows the required release, environment, artifact, and gate state.
|
||||||
- [ ] Operators can return to the release workflow after taking policy or VEX actions.
|
- [x] Operators can return to the release workflow after taking policy or VEX actions.
|
||||||
|
|
||||||
### FE-PD-006 - Verify cutover, redirects, and core operator journeys
|
### FE-PD-006 - Verify cutover, redirects, and core operator journeys
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-PD-005
|
Dependency: FE-PD-005
|
||||||
Owners: QA, Test Automation
|
Owners: QA, Test Automation
|
||||||
Task description:
|
Task description:
|
||||||
@@ -106,12 +106,12 @@ Task description:
|
|||||||
- Validate that the new shell is the working owner for the core operator journeys, not just a shell around dead components.
|
- Validate that the new shell is the working owner for the core operator journeys, not just a shell around dead components.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Playwright scenarios cover all three shell modes.
|
- [x] Playwright scenarios cover all three shell modes.
|
||||||
- [ ] Legacy aliases and old bookmarks land on usable new pages.
|
- [x] Legacy aliases and old bookmarks land on usable new pages.
|
||||||
- [ ] Scope-based visibility and the main policy/VEX operator journeys are explicitly verified.
|
- [x] Scope-based visibility and the main policy/VEX operator journeys are explicitly verified.
|
||||||
|
|
||||||
### FE-PD-007 - Complete docs sync and retire superseded shells
|
### FE-PD-007 - Complete docs sync and retire superseded shells
|
||||||
Status: TODO
|
Status: DONE
|
||||||
Dependency: FE-PD-003
|
Dependency: FE-PD-003
|
||||||
Owners: Documentation author, Project Manager
|
Owners: Documentation author, Project Manager
|
||||||
Task description:
|
Task description:
|
||||||
@@ -119,14 +119,18 @@ Task description:
|
|||||||
- Record which legacy names remain as temporary aliases and which old product shells are fully retired after the move.
|
- Record which legacy names remain as temporary aliases and which old product shells are fully retired after the move.
|
||||||
|
|
||||||
Completion criteria:
|
Completion criteria:
|
||||||
- [ ] Cross-doc references are updated for the shipped shell.
|
- [x] Cross-doc references are updated for the shipped shell.
|
||||||
- [ ] User-facing naming and alias lifetimes are documented.
|
- [x] User-facing naming and alias lifetimes are documented.
|
||||||
- [ ] Retired sibling-product labels and routes are explicitly listed after cutover.
|
- [x] Retired sibling-product labels and routes are explicitly listed after cutover.
|
||||||
|
|
||||||
## Execution Log
|
## Execution Log
|
||||||
| Date (UTC) | Update | Owner |
|
| Date (UTC) | Update | Owner |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 2026-03-07 | Sprint created to ship a single Decisioning Studio shell spanning policy authoring, governance, simulation, actionable VEX resolution, and release-context gate explanation. | Project Manager |
|
| 2026-03-07 | Sprint created to ship a single Decisioning Studio shell spanning policy authoring, governance, simulation, actionable VEX resolution, and release-context gate explanation. | Project Manager |
|
||||||
|
| 2026-03-07 | Implementation started. Route inventory confirmed: `/ops/policy` is still fragmented across Policy Studio, Governance, Simulation, VEX, Exceptions, and Gate leaves, so the sprint is moving into shell and alias cutover. | FE |
|
||||||
|
| 2026-03-07 | Shipped the canonical `Decisioning Studio` shell at `/ops/policy` with primary tabs, pack and release-context modes, canonical VEX and audit ownership, and legacy redirects from `policy-studio`, `policy/*`, `admin/policy/*`, `admin/vex-hub/*`, and `security/vex*`. | FE |
|
||||||
|
| 2026-03-07 | Wired active release, approval, promotion, workflow-editor, evidence, security, home, settings, search, and timeline entry points into the shared shell so mutable policy and VEX actions no longer depend on orphan routes. | FE |
|
||||||
|
| 2026-03-07 | Verification passed: `npm test -- --watch=false --include ...` (10 targeted spec files, 94 tests), `npx playwright test tests/e2e/policy-decisioning-studio.spec.ts` (4/4), and `npm run build` (production build pass; existing size-budget warnings remain). | QA |
|
||||||
|
|
||||||
## Decisions & Risks
|
## Decisions & Risks
|
||||||
- Decision: the preferred product shape is one dynamic shell with deep-linkable tabs, not one giant page and not separate sibling products.
|
- Decision: the preferred product shape is one dynamic shell with deep-linkable tabs, not one giant page and not separate sibling products.
|
||||||
@@ -138,6 +142,18 @@ Completion criteria:
|
|||||||
- Mitigation: freeze redirects and verification scenarios before implementation starts.
|
- Mitigation: freeze redirects and verification scenarios before implementation starts.
|
||||||
- Risk: Release Orchestrator could grow duplicate gate/policy UI while this consolidation is in flight.
|
- Risk: Release Orchestrator could grow duplicate gate/policy UI while this consolidation is in flight.
|
||||||
- Mitigation: require release-facing FE work to deep-link into the shared shell rather than add new standalone policy/VEX pages.
|
- Mitigation: require release-facing FE work to deep-link into the shared shell rather than add new standalone policy/VEX pages.
|
||||||
|
- Decision: canonical mutable VEX ownership is now `/ops/policy/vex`; Security keeps read-only exploratory entry points only where needed.
|
||||||
|
- Decision: legacy `policy-studio` and `policy/*` bookmarks are preserved through redirect coverage rather than leaving parallel writable routes mounted.
|
||||||
|
- Documentation sync:
|
||||||
|
- `docs/modules/ui/policy-decisioning-studio/README.md`
|
||||||
|
- `docs/features/checked/web/policy-decisioning-studio-ui.md`
|
||||||
|
- `docs/modules/ui/TASKS.md`
|
||||||
|
- `docs/modules/ui/implementation_plan.md`
|
||||||
|
- Verification commands:
|
||||||
|
- `npm test -- --watch=false --include src/tests/policy_decisioning/policy-decisioning-shell.component.spec.ts --include src/tests/policy_decisioning/policy-decisioning-routes.spec.ts --include src/tests/release_orchestrator/evidence-detail.behavior.spec.ts --include src/tests/release_orchestrator/visual-workflow-editor.behavior.spec.ts --include src/tests/security/security-overview-dashboard.behavior.spec.ts --include src/tests/global_search/search-route-matrix.spec.ts --include src/tests/navigation/legacy-redirects.spec.ts --include src/tests/routes/legacy-route-migration-framework.component.spec.ts --include src/tests/administration/administration-routes.spec.ts --include src/tests/security-risk/security-risk-routes.spec.ts`
|
||||||
|
- `npx playwright test tests/e2e/policy-decisioning-studio.spec.ts`
|
||||||
|
- `npm run build`
|
||||||
|
- Residual risk: the production build still reports pre-existing bundle-budget warnings unrelated to this sprint.
|
||||||
- Delivery rule: this sprint is only complete when the canonical shell is routable, usable, verified, and old mutable policy or VEX action paths are no longer required.
|
- Delivery rule: this sprint is only complete when the canonical shell is routable, usable, verified, and old mutable policy or VEX action paths are no longer required.
|
||||||
- Reference design note: `docs/modules/ui/policy-decisioning-studio/README.md`.
|
- Reference design note: `docs/modules/ui/policy-decisioning-studio/README.md`.
|
||||||
|
|
||||||
67
docs/features/checked/web/policy-decisioning-studio-ui.md
Normal file
67
docs/features/checked/web/policy-decisioning-studio-ui.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Policy Decisioning Studio UI
|
||||||
|
|
||||||
|
## Module
|
||||||
|
Web
|
||||||
|
|
||||||
|
## Status
|
||||||
|
VERIFIED
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Shipped the canonical `Policy Decisioning Studio` shell at `/ops/policy` and made it the mutable owner for policy packs, governance, simulation, VEX, exceptions, release gates, and policy/VEX audit. Legacy `policy-studio`, `policy/*`, `admin/policy/*`, `admin/vex-hub/*`, and security VEX aliases now resolve into the same routed shell instead of leaving writable sibling products active.
|
||||||
|
|
||||||
|
## Implementation Details
|
||||||
|
- **Feature directory**: `src/Web/StellaOps.Web/src/app/features/policy-decisioning/`
|
||||||
|
- **Primary components**:
|
||||||
|
- `policy-decisioning-shell.component.ts`
|
||||||
|
- `policy-decisioning-overview-page.component.ts`
|
||||||
|
- `policy-pack-shell.component.ts`
|
||||||
|
- `policy-decisioning-vex-shell.component.ts`
|
||||||
|
- `policy-decisioning-gates-page.component.ts`
|
||||||
|
- `policy-decisioning-audit-shell.component.ts`
|
||||||
|
- **Canonical routes**:
|
||||||
|
- `/ops/policy/overview`
|
||||||
|
- `/ops/policy/packs/*`
|
||||||
|
- `/ops/policy/governance/*`
|
||||||
|
- `/ops/policy/simulation/*`
|
||||||
|
- `/ops/policy/vex/*`
|
||||||
|
- `/ops/policy/gates/*`
|
||||||
|
- `/ops/policy/audit/*`
|
||||||
|
- **Legacy aliases**:
|
||||||
|
- `/policy-studio/*`
|
||||||
|
- `/policy/*`
|
||||||
|
- `/admin/policy/*`
|
||||||
|
- `/admin/vex-hub/*`
|
||||||
|
- `/security/vex*`
|
||||||
|
- `/security/exceptions*`
|
||||||
|
- **Release-context entry points**:
|
||||||
|
- approvals detail
|
||||||
|
- promotion request
|
||||||
|
- release detail
|
||||||
|
- workflow editor
|
||||||
|
- evidence detail
|
||||||
|
|
||||||
|
## E2E Test Plan
|
||||||
|
- **Setup**:
|
||||||
|
- [x] Log in with a user that has policy, VEX, exception, and release read scopes.
|
||||||
|
- [x] Seed pack dashboard data or route fixtures for `/api/policy/packs` and `/api/policy/packs/:packId/dashboard`.
|
||||||
|
- [x] Start the local UI harness on `https://127.0.0.1:4400`.
|
||||||
|
- **Core verification**:
|
||||||
|
- [x] Verify `/ops/policy/overview` renders the canonical shell and primary tabs.
|
||||||
|
- [x] Verify a legacy pack bookmark lands inside pack-mode decisioning.
|
||||||
|
- [x] Verify a release-context gate URL renders context chips and return-to-source affordance.
|
||||||
|
- [x] Verify `/security/vex` redirects into the canonical VEX shell.
|
||||||
|
- **Regression verification**:
|
||||||
|
- [x] Verify targeted Angular route and redirect specs cover the alias contract.
|
||||||
|
- [x] Verify workflow-editor and evidence-detail deep links open the shared shell.
|
||||||
|
- [x] Verify global search VEX normalization lands in the canonical shell.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Run:
|
||||||
|
- `npm test -- --watch=false --include src/tests/policy_decisioning/policy-decisioning-shell.component.spec.ts --include src/tests/policy_decisioning/policy-decisioning-routes.spec.ts --include src/tests/release_orchestrator/evidence-detail.behavior.spec.ts --include src/tests/release_orchestrator/visual-workflow-editor.behavior.spec.ts --include src/tests/security/security-overview-dashboard.behavior.spec.ts --include src/tests/global_search/search-route-matrix.spec.ts --include src/tests/navigation/legacy-redirects.spec.ts --include src/tests/routes/legacy-route-migration-framework.component.spec.ts --include src/tests/administration/administration-routes.spec.ts --include src/tests/security-risk/security-risk-routes.spec.ts`
|
||||||
|
- `npx playwright test tests/e2e/policy-decisioning-studio.spec.ts`
|
||||||
|
- `npm run build`
|
||||||
|
- Tier 0 (source): pass
|
||||||
|
- Tier 1 (build/tests): pass
|
||||||
|
- Tier 2 (behavior): pass
|
||||||
|
- Note: the production build still emits existing bundle-budget warnings outside this feature scope; the build itself completes successfully.
|
||||||
|
- Verified on (UTC): 2026-03-07T23:22:53Z
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
- `docs/implplan/SPRINT_20260307_004_FE_self_serve_search_answer_first.md`
|
- `docs/implplan/SPRINT_20260307_004_FE_self_serve_search_answer_first.md`
|
||||||
- `docs/implplan/SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md`
|
- `docs/implplan/SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md`
|
||||||
- `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_023_DOCS_ui_restoration_topic_shapes.md`
|
- `docs/implplan/SPRINT_20260307_023_DOCS_ui_restoration_topic_shapes.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`
|
||||||
|
|
||||||
@@ -65,13 +64,13 @@
|
|||||||
- [DONE] DOCS-RTS-005 Triage explainability restoration placement note
|
- [DONE] DOCS-RTS-005 Triage explainability restoration placement note
|
||||||
- [DONE] DOCS-RTS-006 Workflow visualization and replay placement note
|
- [DONE] DOCS-RTS-006 Workflow visualization and replay placement note
|
||||||
- [DONE] DOCS-RTS-007 Deeper corroboration and implementation-sprint cutover for restoration topics
|
- [DONE] DOCS-RTS-007 Deeper corroboration and implementation-sprint cutover for restoration topics
|
||||||
- [DOING] FE-PD-001 Freeze Policy Decisioning Studio shell shape and ownership
|
- [DONE] FE-PD-001 Freeze Policy Decisioning Studio shell shape and ownership
|
||||||
- [DOING] FE-PD-002 Canonical route and alias contract for policy / VEX / release decisioning
|
- [DONE] FE-PD-002 Canonical route and alias contract for policy / VEX / release decisioning
|
||||||
- [DOING] FE-PD-003 Component merge matrix for Policy Studio, Governance, Simulation, and VEX
|
- [DONE] FE-PD-003 Component merge matrix for Policy Studio, Governance, Simulation, and VEX
|
||||||
- [DOING] FE-PD-004 Release-context UX contract for Release Orchestrator deep links
|
- [DONE] FE-PD-004 Release-context UX contract for Release Orchestrator deep links
|
||||||
- [DOING] FE-PD-005 FE implementation slices for Decisioning Studio shell and cutover
|
- [DONE] FE-PD-005 FE implementation slices for Decisioning Studio shell and cutover
|
||||||
- [TODO] FE-PD-006 QA and rollout contract for Decisioning Studio
|
- [DONE] FE-PD-006 QA and rollout contract for Decisioning Studio
|
||||||
- [TODO] FE-PD-007 Docs and deprecation plan for legacy policy / VEX product labels
|
- [DONE] FE-PD-007 Docs and deprecation plan for legacy policy / VEX product labels
|
||||||
- [DONE] FE-WL-001 Freeze Watchlist shell ownership and route contract
|
- [DONE] FE-WL-001 Freeze Watchlist shell ownership and route contract
|
||||||
- [DONE] FE-WL-002 Entries tab list-detail implementation slice
|
- [DONE] FE-WL-002 Entries tab list-detail implementation slice
|
||||||
- [DONE] FE-WL-003 Alerts tab and alert-detail drill-in
|
- [DONE] FE-WL-003 Alerts tab and alert-detail drill-in
|
||||||
|
|||||||
@@ -11,19 +11,19 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
|
|||||||
- `SPRINT_20260307_004_FE_self_serve_search_answer_first.md` - answer-first search shell, page-owned self-serve questions, and explicit fallback states.
|
- `SPRINT_20260307_004_FE_self_serve_search_answer_first.md` - answer-first search shell, page-owned self-serve questions, and explicit fallback states.
|
||||||
- `SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md` - page rollout, guided handoffs, and telemetry-driven gap closure.
|
- `SPRINT_20260307_006_FE_self_serve_rollout_and_gap_closure.md` - page rollout, guided handoffs, and telemetry-driven gap closure.
|
||||||
- `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_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.
|
||||||
|
|
||||||
## 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.
|
||||||
- `docs/modules/ui/component-preservation-map/SUMMARY_TREE.md` - branch-level keep / merge / wire / archive guidance.
|
- `docs/modules/ui/component-preservation-map/SUMMARY_TREE.md` - branch-level keep / merge / wire / archive guidance.
|
||||||
- `docs/modules/ui/component-preservation-map/inventory.json` - deterministic machine-readable inventory for 303 candidate components.
|
- `docs/modules/ui/component-preservation-map/inventory.json` - deterministic machine-readable inventory for 303 candidate components.
|
||||||
- `docs/modules/ui/policy-decisioning-studio/README.md` - proposed Decisioning Studio product shape, tab model, route contract, and Release Orchestrator integration boundary.
|
- `docs/modules/ui/policy-decisioning-studio/README.md` - shipped Decisioning Studio product shape, canonical routes, alias coverage, and release-context entry-point contract.
|
||||||
- `docs/modules/ui/restoration-topics/README.md` - detailed placement notes for the next restoration topics after Decisioning Studio.
|
- `docs/modules/ui/restoration-topics/README.md` - detailed placement notes for the next restoration topics after Decisioning Studio.
|
||||||
- `docs/modules/ui/watchlist-operations/README.md` - detailed watchlist UX dossier and owner-shell contract.
|
- `docs/modules/ui/watchlist-operations/README.md` - detailed watchlist UX dossier and owner-shell contract.
|
||||||
- `docs/features/checked/web/reachability-witnessing-ui.md` - shipped verification note for the canonical Reachability witness and PoE shell.
|
- `docs/features/checked/web/reachability-witnessing-ui.md` - shipped verification note for the canonical Reachability witness and PoE shell.
|
||||||
- `docs/features/checked/web/identity-watchlist-management-ui.md` - shipped verification note for the Trust & Signing watchlist shell and its Mission Control / Notifications handoffs.
|
- `docs/features/checked/web/identity-watchlist-management-ui.md` - shipped verification note for the Trust & Signing watchlist shell and its Mission Control / Notifications handoffs.
|
||||||
- `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/policy-decisioning-studio-ui.md` - shipped verification note for the canonical Decisioning Studio shell, redirect cutover, release-context deep links, and VEX ownership merge.
|
||||||
- `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/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.
|
||||||
|
|||||||
@@ -1,172 +1,132 @@
|
|||||||
# Policy Decisioning Studio
|
# Policy Decisioning Studio
|
||||||
|
|
||||||
## Recommendation
|
## Status
|
||||||
|
Shipped on 2026-03-07.
|
||||||
Create one dynamic sub-product shell, not one giant page and not three separate sibling products.
|
|
||||||
|
|
||||||
|
## Product Shape
|
||||||
- Canonical mount: `/ops/policy`
|
- Canonical mount: `/ops/policy`
|
||||||
- Suggested user-facing title: `Decisioning Studio`
|
- User-facing title: `Policy Decisioning Studio`
|
||||||
- Suggested nav label for now: keep `Policy` to avoid unnecessary IA churn during rollout
|
- Active primary tabs: `Overview`, `Packs`, `Governance`, `Simulation`, `VEX & Exceptions`, `Release Gates`, `Audit`
|
||||||
|
- Supported modes: `global`, `pack`, `release-context`, plus non-owning `approval`, `workflow`, and `evidence` context chips
|
||||||
|
|
||||||
This shell should unify the current policy workspace, policy governance, policy simulation, and the actionable parts of VEX conflict resolution into one product surface with deep-linkable tabs and a shared context model.
|
This is now the canonical mutable owner for policy packs, governance controls, policy simulation, VEX resolution, exception handling, release-gate review, and policy/VEX audit.
|
||||||
|
|
||||||
## Why This Is The Right Shape
|
## Shipped Route Contract
|
||||||
|
|
||||||
- Policy authoring, governance, simulation, VEX overrides, exceptions, and release gates are all parts of one decisioning workflow.
|
|
||||||
- Release Orchestrator is a consumer of policy/VEX decisions, not a second owner of those UIs.
|
|
||||||
- The current split creates duplicated mental models: packs live in one branch, governance in another, simulation in another, and VEX conflicts off to the side.
|
|
||||||
- A single shell allows one shared context header for tenant, pack, environment, artifact digest, and release.
|
|
||||||
- Deep-linkable child routes preserve auditability and operator workflows better than a modal-heavy or single-scroll design.
|
|
||||||
|
|
||||||
## Product Modes
|
|
||||||
|
|
||||||
The shell should support three modes without forking the UI into separate apps.
|
|
||||||
|
|
||||||
### 1. Global Mode
|
|
||||||
- Used by policy admins and security operators.
|
|
||||||
- Focus: pack inventory, governance controls, simulations, trust/VEX posture, gate policy defaults.
|
|
||||||
|
|
||||||
### 2. Pack Mode
|
|
||||||
- Used when the user is working on a specific policy pack.
|
|
||||||
- Focus: editing, YAML, rule builder, approvals, explain traces, simulation for one pack.
|
|
||||||
|
|
||||||
### 3. Release Context Mode
|
|
||||||
- Entered from Release Orchestrator deep links.
|
|
||||||
- Context is pinned in the shell header: `releaseId`, `environment`, `artifactDigest`, `approvalId`, and effective policy bundle.
|
|
||||||
- Focus: "why is this promotion blocked / allowed?" rather than general administration.
|
|
||||||
|
|
||||||
## Recommended IA
|
|
||||||
|
|
||||||
### Primary tabs
|
|
||||||
- `Overview`
|
|
||||||
- decision board for active packs, pending approvals, open conflicts, shadow-mode readiness, and gate health
|
|
||||||
- `Packs`
|
|
||||||
- policy pack inventory and pack-scoped workspace
|
|
||||||
- `Governance`
|
|
||||||
- risk budgets, trust weights, staleness, sealed mode, profiles, validator, governance audit
|
|
||||||
- `Simulation`
|
|
||||||
- shadow mode, console, coverage, effective policy, diff, merge preview, history, batch evaluation
|
|
||||||
- `VEX & Exceptions`
|
|
||||||
- VEX conflicts, consensus review, overrides, exception queue, exception detail, rationale capture
|
|
||||||
- `Release Gates`
|
|
||||||
- gate catalog, environment-specific gate policy, release-context gate evaluation, promotion readiness
|
|
||||||
- `Audit`
|
|
||||||
- immutable policy/VEX decision history, evidence pointers, exports, explain traces
|
|
||||||
|
|
||||||
### Secondary navigation
|
|
||||||
- Use child tabs inside the active primary tab.
|
|
||||||
- Use a contextual right rail for evidence, explain traces, or release summary.
|
|
||||||
- Never hide critical release-decision screens behind modal-only flows.
|
|
||||||
|
|
||||||
## Route Contract
|
|
||||||
|
|
||||||
Keep `/ops/policy` as the canonical root and move the product to a single route tree.
|
|
||||||
|
|
||||||
### Canonical routes
|
### Canonical routes
|
||||||
- `/ops/policy`
|
- `/ops/policy/overview`
|
||||||
- `/ops/policy/packs`
|
- `/ops/policy/packs`
|
||||||
- `/ops/policy/packs/:packId`
|
- `/ops/policy/packs/:packId`
|
||||||
- `/ops/policy/packs/:packId/edit`
|
- `/ops/policy/packs/:packId/edit`
|
||||||
- `/ops/policy/packs/:packId/rules`
|
- `/ops/policy/packs/:packId/rules`
|
||||||
- `/ops/policy/packs/:packId/yaml`
|
- `/ops/policy/packs/:packId/yaml`
|
||||||
- `/ops/policy/packs/:packId/approvals`
|
- `/ops/policy/packs/:packId/approvals`
|
||||||
|
- `/ops/policy/packs/:packId/simulate`
|
||||||
- `/ops/policy/packs/:packId/explain/:runId`
|
- `/ops/policy/packs/:packId/explain/:runId`
|
||||||
- `/ops/policy/governance/...`
|
- `/ops/policy/governance/...`
|
||||||
- `/ops/policy/simulation/...`
|
- `/ops/policy/simulation/...`
|
||||||
- `/ops/policy/vex`
|
- `/ops/policy/vex`
|
||||||
|
- `/ops/policy/vex/search`
|
||||||
|
- `/ops/policy/vex/search/detail/:id`
|
||||||
|
- `/ops/policy/vex/create`
|
||||||
|
- `/ops/policy/vex/stats`
|
||||||
|
- `/ops/policy/vex/consensus`
|
||||||
|
- `/ops/policy/vex/explorer`
|
||||||
- `/ops/policy/vex/conflicts`
|
- `/ops/policy/vex/conflicts`
|
||||||
- `/ops/policy/vex/conflicts/:conflictId`
|
- `/ops/policy/vex/exceptions`
|
||||||
- `/ops/policy/exceptions`
|
- `/ops/policy/vex/exceptions/approvals`
|
||||||
- `/ops/policy/exceptions/:exceptionId`
|
- `/ops/policy/vex/exceptions/:exceptionId`
|
||||||
- `/ops/policy/gates`
|
- `/ops/policy/gates`
|
||||||
|
- `/ops/policy/gates/catalog`
|
||||||
|
- `/ops/policy/gates/simulate/:promotionId`
|
||||||
- `/ops/policy/gates/environments/:environment`
|
- `/ops/policy/gates/environments/:environment`
|
||||||
- `/ops/policy/gates/releases/:releaseId`
|
- `/ops/policy/gates/releases/:releaseId`
|
||||||
- `/ops/policy/audit`
|
- `/ops/policy/gates/approvals/:approvalId`
|
||||||
|
- `/ops/policy/audit/policy`
|
||||||
|
- `/ops/policy/audit/vex`
|
||||||
|
- `/ops/policy/audit/log`
|
||||||
|
- `/ops/policy/audit/log/events`
|
||||||
|
|
||||||
### Alias and migration rules
|
### Legacy aliases kept live
|
||||||
- Legacy `/policy-studio/*` routes redirect into `/ops/policy/packs/*`
|
- `/policy-studio/*`
|
||||||
- `/admin/policy/governance` and `/admin/policy/simulation` redirect into `/ops/policy/governance/*` and `/ops/policy/simulation/*`
|
- `/policy/*`
|
||||||
- `/admin/vex-hub/*` should redirect into `/ops/policy/vex/*` for mutating and conflict-resolution flows
|
- `/admin/policy/governance*`
|
||||||
- If Analyze keeps a VEX entry point, it should deep-link into the same shell in read-only context instead of owning a separate VEX product
|
- `/admin/policy/simulation*`
|
||||||
|
- `/admin/vex-hub*`
|
||||||
|
- `/security/vex*`
|
||||||
|
- `/security/exceptions*`
|
||||||
|
- `/administration/policy*`
|
||||||
|
- `/administration/policy-governance*`
|
||||||
|
|
||||||
## What To Merge
|
## Shipped Merge Boundary
|
||||||
|
|
||||||
### Merge into `Packs`
|
### Packs
|
||||||
- `PolicyWorkspaceComponent`
|
- `PolicyWorkspaceComponent`
|
||||||
|
- `PolicyDashboardComponent`
|
||||||
- `PolicyEditorComponent`
|
- `PolicyEditorComponent`
|
||||||
- `PolicyYamlEditorComponent`
|
|
||||||
- `PolicyRuleBuilderComponent`
|
- `PolicyRuleBuilderComponent`
|
||||||
|
- `PolicyYamlEditorComponent`
|
||||||
- `PolicyApprovalsComponent`
|
- `PolicyApprovalsComponent`
|
||||||
- `PolicyExplainComponent`
|
- `PolicyExplainComponent`
|
||||||
- `PolicyDashboardComponent`
|
|
||||||
|
|
||||||
### Merge into `Governance`
|
### Governance
|
||||||
- `PolicyGovernanceComponent` shell
|
- Existing `policy-governance.routes.ts` subtree mounted under `/ops/policy/governance`
|
||||||
- risk budget, trust weighting, staleness, sealed mode, profiles, validator, audit, conflicts, schema tools
|
- Settings, impact-preview, profile, trust-weight, and schema surfaces now point to the canonical shell
|
||||||
|
|
||||||
### Merge into `Simulation`
|
### Simulation
|
||||||
- `SimulationDashboardComponent` shell
|
- Existing `policy-simulation.routes.ts` subtree mounted under `/ops/policy/simulation`
|
||||||
- shadow mode, console, lint, coverage, effective policy, audit, diff, promotion, merge preview, history, batch
|
- Internal simulation navigation updated to stay inside the canonical route family
|
||||||
|
|
||||||
### Merge into `VEX & Exceptions`
|
### VEX and exceptions
|
||||||
- `VexConflictResolutionComponent`
|
- Existing `vex-hub` components mounted under `/ops/policy/vex`
|
||||||
- preserved ideas from `VexConflictStudioComponent`
|
- Security VEX and exception aliases now redirect into the canonical VEX subtree
|
||||||
- exception queue and exception detail flows
|
- Mutable VEX actions are no longer owned by a separate Security shell
|
||||||
- VEX consensus and trust-weighted decision support
|
|
||||||
|
|
||||||
### Merge into `Release Gates`
|
### Gates and audit
|
||||||
- promotion gate surfaces from policy simulation
|
- Canonical release-gate page at `/ops/policy/gates*`
|
||||||
- environment gate policy editors
|
- Canonical policy/VEX audit owner under `/ops/policy/audit*`
|
||||||
- release-context verdict page used by Release Orchestrator
|
|
||||||
|
|
||||||
## Release Orchestrator Integration
|
## Release Orchestrator Integration
|
||||||
|
|
||||||
Release Orchestrator should link into this shell instead of growing a parallel policy UI.
|
### Shipped entry points
|
||||||
|
- approvals detail
|
||||||
|
- promotion request
|
||||||
|
- release detail
|
||||||
|
- workflow editor
|
||||||
|
- evidence detail
|
||||||
|
|
||||||
### Entry points from releases
|
### Shipped context fields
|
||||||
- approval detail -> open gate verdict in release context mode
|
- `releaseId`
|
||||||
- promotion request -> open readiness checklist in release context mode
|
- `approvalId`
|
||||||
- release detail -> open effective policy + VEX posture for this artifact
|
- `environment`
|
||||||
- workflow editor -> deep link to gate catalog / policy pack used by the workflow
|
- `artifact` / `bundleDigest`
|
||||||
- evidence detail -> deep link to policy and VEX rationale bound to the promotion
|
- `workflowId`
|
||||||
|
- `evidenceId`
|
||||||
|
- `returnTo`
|
||||||
|
|
||||||
### Required release-context panel
|
Release Orchestrator still owns promotion state and workflow execution. Decisioning Studio owns policy and VEX authoring, mutation, and explanation.
|
||||||
- active release / approval identifier
|
|
||||||
- environment lane
|
|
||||||
- artifact digest / subject digest
|
|
||||||
- effective policy pack and version
|
|
||||||
- gate verdict summary
|
|
||||||
- open conflicts or missing evidence
|
|
||||||
- CTA back to release flow
|
|
||||||
|
|
||||||
### Ownership rule
|
## Secondary Entry Points Updated
|
||||||
- Release Orchestrator owns promotion state and workflow execution
|
- `Security Overview`
|
||||||
- Decisioning Studio owns policy authoring, governance, VEX resolution, exceptions, and gate explanation
|
- `Security Exceptions`
|
||||||
|
- `Vulnerability Detail`
|
||||||
|
- `Home Dashboard`
|
||||||
|
- `Policy Governance Settings`
|
||||||
|
- `Evidence Audit`
|
||||||
|
- `Timeline Evidence Links`
|
||||||
|
- `Policy baseline chip`
|
||||||
|
- global search VEX normalization
|
||||||
|
|
||||||
## UI Standards For Implementation
|
## Retired Or Superseded Writable Owners
|
||||||
|
- standalone `Policy Studio` product label
|
||||||
|
- standalone `VEX Hub` mutable owner
|
||||||
|
- mutable `policy/*` writable paths
|
||||||
|
- mutable `security/vex*` owner paths
|
||||||
|
|
||||||
- One shell component with child router outlets, not duplicated top-level pages
|
These names survive only as temporary redirect aliases where needed for bookmark continuity.
|
||||||
- Page-owned context and self-serve guidance
|
|
||||||
- Stable deep links for every tab and subview
|
|
||||||
- Scope-aware tabs that hide or disable only what the operator cannot act on
|
|
||||||
- Shared evidence and explain cards reused across policy, VEX, and release contexts
|
|
||||||
- Deterministic loading order and route aliases so legacy bookmarks remain functional during rollout
|
|
||||||
|
|
||||||
## Non-Goals
|
## Verification Evidence
|
||||||
|
- feature verification note: `docs/features/checked/web/policy-decisioning-studio-ui.md`
|
||||||
- Do not move all security exploration into this shell; read-only security analytics can remain elsewhere if they deep-link into the same canonical decisioning routes when action is required.
|
- targeted Angular tests: `94` passing assertions across route, shell, redirect, workflow, evidence, and search coverage
|
||||||
- Do not let Release Orchestrator fork its own policy editor or VEX conflict UI.
|
- Playwright: `4/4` passing scenarios for global mode, pack mode, release-context mode, and security VEX alias redirect
|
||||||
- Do not collapse everything into one scroll page; operators need stable, bookmarkable subviews.
|
- production build: pass, with existing unrelated bundle-budget warnings
|
||||||
|
|
||||||
## Source Inputs
|
|
||||||
|
|
||||||
- `docs/contracts/policy-studio.md`
|
|
||||||
- `docs/security/policy-governance.md`
|
|
||||||
- `docs/modules/release-orchestrator/ui/overview.md`
|
|
||||||
- `docs/modules/release-orchestrator/workflow/evidence-based-release-gates.md`
|
|
||||||
- `docs/modules/ui/component-preservation-map/README.md`
|
|
||||||
- `src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.routes.ts`
|
|
||||||
- `src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-simulation.routes.ts`
|
|
||||||
- `src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub.routes.ts`
|
|
||||||
- `src/Web/StellaOps.Web/src/app/routes/ops.routes.ts`
|
|
||||||
- `src/Web/StellaOps.Web/src/app/routes/administration.routes.ts`
|
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
|
|||||||
shortcut: '>policy',
|
shortcut: '>policy',
|
||||||
description: 'Create new policy pack',
|
description: 'Create new policy pack',
|
||||||
icon: 'shield',
|
icon: 'shield',
|
||||||
route: '/ops/policy/baselines',
|
route: '/ops/policy/packs',
|
||||||
keywords: ['policy', 'new', 'pack', 'create'],
|
keywords: ['policy', 'new', 'pack', 'create'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -68,10 +68,10 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vex-hub',
|
id: 'vex-hub',
|
||||||
label: 'VEX Hub',
|
label: 'VEX & Exceptions',
|
||||||
route: '/admin/vex-hub',
|
route: '/ops/policy/vex',
|
||||||
icon: 'shield-check',
|
icon: 'shield-check',
|
||||||
tooltip: 'Explore VEX statements and consensus',
|
tooltip: 'Resolve VEX statements, conflicts, and exceptions in Decisioning Studio',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'unknowns',
|
id: 'unknowns',
|
||||||
@@ -157,37 +157,37 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
|||||||
icon: 'policy',
|
icon: 'policy',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: 'policy-studio',
|
id: 'policy-decisioning',
|
||||||
label: 'Policy Studio',
|
label: 'Policy Decisioning',
|
||||||
icon: 'edit',
|
icon: 'edit',
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: 'policy-editor',
|
id: 'policy-editor',
|
||||||
label: 'Editor',
|
label: 'Packs',
|
||||||
route: '/policy-studio/packs',
|
route: '/ops/policy/packs',
|
||||||
requiredScopes: ['policy:author'],
|
requiredScopes: ['policy:author'],
|
||||||
tooltip: 'Author and edit policies',
|
tooltip: 'Author and edit policy packs',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'policy-simulate',
|
id: 'policy-simulate',
|
||||||
label: 'Simulate',
|
label: 'Simulate',
|
||||||
route: '/policy-studio/simulate',
|
route: '/ops/policy/simulation',
|
||||||
requiredScopes: ['policy:simulate'],
|
requiredScopes: ['policy:simulate'],
|
||||||
tooltip: 'Test policies with simulations',
|
tooltip: 'Test policies with simulations',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'policy-approvals',
|
id: 'policy-approvals',
|
||||||
label: 'Approvals',
|
label: 'VEX & Exceptions',
|
||||||
route: '/policy-studio/approvals',
|
route: '/ops/policy/vex/exceptions',
|
||||||
requireAnyScope: ['policy:review', 'policy:approve'],
|
requireAnyScope: ['policy:review', 'policy:approve'],
|
||||||
tooltip: 'Review and approve policy changes',
|
tooltip: 'Review and resolve policy exceptions',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'policy-dashboard',
|
id: 'policy-dashboard',
|
||||||
label: 'Dashboard',
|
label: 'Overview',
|
||||||
route: '/policy-studio/dashboard',
|
route: '/ops/policy/overview',
|
||||||
requiredScopes: ['policy:read'],
|
requiredScopes: ['policy:read'],
|
||||||
tooltip: 'Policy metrics and status',
|
tooltip: 'Policy metrics, packs, gates, and VEX status',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -596,14 +596,14 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
id: 'policy-governance',
|
id: 'policy-governance',
|
||||||
label: 'Policy Governance',
|
label: 'Policy Governance',
|
||||||
route: '/admin/policy/governance',
|
route: '/ops/policy/governance',
|
||||||
icon: 'policy-config',
|
icon: 'policy-config',
|
||||||
tooltip: 'Risk budgets, trust weights, and sealed mode',
|
tooltip: 'Risk budgets, trust weights, and sealed mode',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'policy-simulation',
|
id: 'policy-simulation',
|
||||||
label: 'Policy Simulation',
|
label: 'Policy Simulation',
|
||||||
route: '/admin/policy/simulation',
|
route: '/ops/policy/simulation',
|
||||||
icon: 'test-tube',
|
icon: 'test-tube',
|
||||||
tooltip: 'Shadow mode and policy simulation studio',
|
tooltip: 'Shadow mode and policy simulation studio',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ export const SEARCH_CONTEXT_DEFINITIONS: readonly SearchContextDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vex',
|
id: 'vex',
|
||||||
routePrefixes: ['/security/advisories-vex', '/vex-hub'],
|
routePrefixes: ['/ops/policy/vex', '/security/advisories-vex', '/vex-hub'],
|
||||||
presentation: {
|
presentation: {
|
||||||
titleKey: 'ui.search.context.vex.title',
|
titleKey: 'ui.search.context.vex.title',
|
||||||
titleFallback: 'VEX intelligence',
|
titleFallback: 'VEX intelligence',
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state';
|
||||||
|
|
||||||
type GateResult = 'PASS' | 'WARN' | 'BLOCK';
|
type GateResult = 'PASS' | 'WARN' | 'BLOCK';
|
||||||
type HealthStatus = 'OK' | 'WARN' | 'FAIL';
|
type HealthStatus = 'OK' | 'WARN' | 'FAIL';
|
||||||
@@ -43,7 +45,7 @@ interface GateTraceRow {
|
|||||||
inputs: string[];
|
inputs: string[];
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
evidenceAge: string;
|
evidenceAge: string;
|
||||||
fixLinks: Array<{ label: string; route: string }>;
|
fixLinks: Array<{ label: string; route: string; queryParams?: Record<string, string> }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SecurityFindingRow {
|
interface SecurityFindingRow {
|
||||||
@@ -229,7 +231,7 @@ interface HistoryEvent {
|
|||||||
@if (row.result === 'BLOCK') {
|
@if (row.result === 'BLOCK') {
|
||||||
<div class="fix-links">
|
<div class="fix-links">
|
||||||
@for (link of row.fixLinks; track link.label) {
|
@for (link of row.fixLinks; track link.label) {
|
||||||
<a [routerLink]="link.route">{{ link.label }}</a>
|
<a [routerLink]="link.route" [queryParams]="link.queryParams || null">{{ link.label }}</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -296,8 +298,8 @@ interface HistoryEvent {
|
|||||||
|
|
||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a routerLink="/security/findings">Open Findings (filtered)</a>
|
<a routerLink="/security/findings">Open Findings (filtered)</a>
|
||||||
<a routerLink="/security/vex">Open VEX Hub</a>
|
<a routerLink="/ops/policy/vex" [queryParams]="decisioningContextParams()">Open VEX Hub</a>
|
||||||
<a routerLink="/administration/policy-governance/exceptions">Open Exceptions</a>
|
<a routerLink="/ops/policy/vex/exceptions" [queryParams]="decisioningContextParams()">Open Exceptions</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
@@ -785,6 +787,7 @@ interface HistoryEvent {
|
|||||||
})
|
})
|
||||||
export class ApprovalDetailPageComponent implements OnInit {
|
export class ApprovalDetailPageComponent implements OnInit {
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
readonly minDecisionReasonLength = 10;
|
readonly minDecisionReasonLength = 10;
|
||||||
readonly activeTab = signal<ApprovalTabId>('overview');
|
readonly activeTab = signal<ApprovalTabId>('overview');
|
||||||
@@ -843,7 +846,11 @@ export class ApprovalDetailPageComponent implements OnInit {
|
|||||||
fixLinks: [
|
fixLinks: [
|
||||||
{ label: 'Trigger SBOM Scan', route: '/platform-ops/data-integrity/scan-pipeline' },
|
{ label: 'Trigger SBOM Scan', route: '/platform-ops/data-integrity/scan-pipeline' },
|
||||||
{ label: 'Open Finding', route: '/security/findings' },
|
{ label: 'Open Finding', route: '/security/findings' },
|
||||||
{ label: 'Request Exception', route: '/administration/policy-governance/exceptions' },
|
{
|
||||||
|
label: 'Request Exception',
|
||||||
|
route: '/ops/policy/vex/exceptions',
|
||||||
|
queryParams: this.decisioningContextParams({ create: '1' }),
|
||||||
|
},
|
||||||
{ label: 'Open Data Integrity', route: '/platform-ops/data-integrity' },
|
{ label: 'Open Data Integrity', route: '/platform-ops/data-integrity' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -1002,6 +1009,9 @@ export class ApprovalDetailPageComponent implements OnInit {
|
|||||||
|
|
||||||
requestExceptionAction(): void {
|
requestExceptionAction(): void {
|
||||||
this.requestException = true;
|
this.requestException = true;
|
||||||
|
void this.router.navigate(['/ops/policy/vex/exceptions'], {
|
||||||
|
queryParams: this.decisioningContextParams({ create: '1' }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
exportPacket(): void {
|
exportPacket(): void {
|
||||||
@@ -1037,4 +1047,18 @@ export class ApprovalDetailPageComponent implements OnInit {
|
|||||||
decidedAt: 'Just now',
|
decidedAt: 'Just now',
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
decisioningContextParams(extra: Record<string, string> = {}): Record<string, string> {
|
||||||
|
const approval = this.approval();
|
||||||
|
const returnTo = buildContextReturnTo(this.router, ['/releases/approvals', approval.id]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
approvalId: approval.id,
|
||||||
|
releaseId: approval.bundleVersion,
|
||||||
|
environment: approval.targetEnvironment,
|
||||||
|
artifact: approval.bundleDigest,
|
||||||
|
returnTo,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ import {
|
|||||||
<a
|
<a
|
||||||
class="policy-badge"
|
class="policy-badge"
|
||||||
[class]="getPolicyClass(hit)"
|
[class]="getPolicyClass(hit)"
|
||||||
[routerLink]="['/policy/gates', hit.gate]"
|
[routerLink]="['/ops/policy/gates/catalog']"
|
||||||
[title]="hit.message"
|
[title]="hit.message"
|
||||||
>
|
>
|
||||||
<span class="policy-result">{{ hit.result | uppercase }}</span>
|
<span class="policy-result">{{ hit.result | uppercase }}</span>
|
||||||
@@ -367,4 +367,3 @@ export class PolicyHitAnnotationComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -166,10 +166,10 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty';
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a routerLink="/administration/policy-governance" class="cross-link">
|
<a routerLink="/ops/policy/governance" class="cross-link">
|
||||||
<span class="cross-link-icon" aria-hidden="true">◆</span>
|
<span class="cross-link-icon" aria-hidden="true">◆</span>
|
||||||
<div class="cross-link-body">
|
<div class="cross-link-body">
|
||||||
<div class="cross-link-title">Administration > Policy Governance</div>
|
<div class="cross-link-title">Ops > Policy > Governance</div>
|
||||||
<div class="cross-link-desc">Policy packs driving evidence requirements</div>
|
<div class="cross-link-desc">Policy packs driving evidence requirements</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -275,12 +275,12 @@ import { SkeletonComponent } from '../../shared/components/skeleton/skeleton.com
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Exception Queue</span>
|
<span>Exception Queue</span>
|
||||||
</a>
|
</a>
|
||||||
<a routerLink="/policy-studio/packs" class="quick-action">
|
<a routerLink="/ops/policy" class="quick-action">
|
||||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" fill="none" stroke="currentColor" stroke-width="2"/>
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||||
<polyline points="14 2 14 8 20 8" fill="none" stroke="currentColor" stroke-width="2"/>
|
<polyline points="14 2 14 8 20 8" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span>Policy Studio</span>
|
<span>Policy Decisioning</span>
|
||||||
</a>
|
</a>
|
||||||
<a routerLink="/graph" class="quick-action">
|
<a routerLink="/graph" class="quick-action">
|
||||||
<svg viewBox="0 0 24 24" width="24" height="24">
|
<svg viewBox="0 0 24 24" width="24" height="24">
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import {
|
||||||
|
ActivatedRoute,
|
||||||
|
NavigationEnd,
|
||||||
|
Router,
|
||||||
|
RouterOutlet,
|
||||||
|
} from '@angular/router';
|
||||||
|
import { filter, startWith } from 'rxjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildContextRouteParams,
|
||||||
|
} from '../../shared/ui/context-route-state/context-route-state';
|
||||||
|
import {
|
||||||
|
TabItem,
|
||||||
|
TabbedNavComponent,
|
||||||
|
} from '../../shared/ui';
|
||||||
|
|
||||||
|
type AuditSubview = 'policy' | 'vex' | 'log';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-policy-decisioning-audit-shell',
|
||||||
|
imports: [CommonModule, RouterOutlet, TabbedNavComponent],
|
||||||
|
template: `
|
||||||
|
<section class="policy-audit-shell" data-testid="policy-audit-shell">
|
||||||
|
<header class="section-header">
|
||||||
|
<div>
|
||||||
|
<p class="section-header__eyebrow">Audit</p>
|
||||||
|
<h2>Policy and VEX audit now share the same owner shell</h2>
|
||||||
|
<p>
|
||||||
|
Review mutable policy and VEX actions after the cutover without routing operators back
|
||||||
|
into retired sibling products.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<app-tabbed-nav
|
||||||
|
[tabs]="tabItems()"
|
||||||
|
[activeTab]="activeSubview()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="policy-audit-shell__content">
|
||||||
|
<router-outlet />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.policy-audit-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header__eyebrow {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
color: var(--color-status-info);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header p {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class PolicyDecisioningAuditShellComponent {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
readonly activeSubview = signal<AuditSubview>(this.readSubview());
|
||||||
|
|
||||||
|
readonly tabItems = computed<readonly TabItem[]>(() => {
|
||||||
|
const queryParams = buildContextRouteParams({
|
||||||
|
releaseId: coerceString(this.route.snapshot.root.queryParams['releaseId']),
|
||||||
|
approvalId: coerceString(this.route.snapshot.root.queryParams['approvalId']),
|
||||||
|
environment: coerceString(this.route.snapshot.root.queryParams['environment']),
|
||||||
|
artifact: coerceString(this.route.snapshot.root.queryParams['artifact']),
|
||||||
|
returnTo: coerceString(this.route.snapshot.root.queryParams['returnTo']),
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'policy',
|
||||||
|
label: 'Policy Audit',
|
||||||
|
route: ['/ops/policy/audit/policy'],
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-audit-tab-policy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vex',
|
||||||
|
label: 'VEX Audit',
|
||||||
|
route: ['/ops/policy/audit/vex'],
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-audit-tab-vex',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'log',
|
||||||
|
label: 'Unified Log',
|
||||||
|
route: ['/ops/policy/audit/log'],
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-audit-tab-log',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.router.events
|
||||||
|
.pipe(
|
||||||
|
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||||
|
startWith(null),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.activeSubview.set(this.readSubview());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private readSubview(): AuditSubview {
|
||||||
|
const url = this.router.url.split('?')[0] ?? '';
|
||||||
|
|
||||||
|
if (url.includes('/audit/vex')) {
|
||||||
|
return 'vex';
|
||||||
|
}
|
||||||
|
if (url.includes('/audit/log')) {
|
||||||
|
return 'log';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'policy';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceString(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
ActivatedRoute,
|
||||||
|
Router,
|
||||||
|
RouterLink,
|
||||||
|
} from '@angular/router';
|
||||||
|
|
||||||
|
import {
|
||||||
|
GateSummaryPanelComponent,
|
||||||
|
type GateResult,
|
||||||
|
} from '../../shared/domain/gate-summary-panel/gate-summary-panel.component';
|
||||||
|
import {
|
||||||
|
GateEvaluation,
|
||||||
|
GateExplainDrawerComponent,
|
||||||
|
} from '../../shared/overlays/gate-explain-drawer/gate-explain-drawer.component';
|
||||||
|
import {
|
||||||
|
buildContextRouteParams,
|
||||||
|
} from '../../shared/ui/context-route-state/context-route-state';
|
||||||
|
|
||||||
|
type DecisioningGateScope = 'global' | 'environment' | 'release' | 'approval';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-policy-decisioning-gates-page',
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterLink,
|
||||||
|
GateSummaryPanelComponent,
|
||||||
|
GateExplainDrawerComponent,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<section class="policy-gates-page" data-testid="policy-gates-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<div>
|
||||||
|
<p class="page-header__eyebrow">Release Gates</p>
|
||||||
|
<h2>{{ pageTitle() }}</h2>
|
||||||
|
<p>{{ pageSubtitle() }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="page-actions">
|
||||||
|
<a
|
||||||
|
class="page-action"
|
||||||
|
[routerLink]="['/ops/policy/gates/catalog']"
|
||||||
|
[queryParams]="contextQueryParams()"
|
||||||
|
>
|
||||||
|
Open gate catalog
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="page-action"
|
||||||
|
[routerLink]="['/ops/policy/vex/exceptions']"
|
||||||
|
[queryParams]="contextQueryParams()"
|
||||||
|
>
|
||||||
|
Open exceptions
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
class="page-action page-action--primary"
|
||||||
|
[routerLink]="['/ops/policy/simulation/promotion']"
|
||||||
|
[queryParams]="contextQueryParams()"
|
||||||
|
>
|
||||||
|
Open promotion simulation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="gates-grid">
|
||||||
|
<app-gate-summary-panel
|
||||||
|
[gates]="gateResults()"
|
||||||
|
[policyRef]="policyReference()"
|
||||||
|
[snapshotRef]="snapshotReference()"
|
||||||
|
(openExplain)="openExplain($event)"
|
||||||
|
(openEvidence)="openEvidence()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<aside class="context-panel">
|
||||||
|
<h3>Decision context</h3>
|
||||||
|
<dl>
|
||||||
|
<div>
|
||||||
|
<dt>Scope</dt>
|
||||||
|
<dd>{{ scopeLabel() }}</dd>
|
||||||
|
</div>
|
||||||
|
@if (releaseId()) {
|
||||||
|
<div>
|
||||||
|
<dt>Release</dt>
|
||||||
|
<dd>{{ releaseId() }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (approvalId()) {
|
||||||
|
<div>
|
||||||
|
<dt>Approval</dt>
|
||||||
|
<dd>{{ approvalId() }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (environmentId()) {
|
||||||
|
<div>
|
||||||
|
<dt>Environment</dt>
|
||||||
|
<dd>{{ environmentId() }}</dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (artifactDigest()) {
|
||||||
|
<div>
|
||||||
|
<dt>Artifact</dt>
|
||||||
|
<dd><code>{{ artifactDigest() }}</code></dd>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="context-links">
|
||||||
|
<a
|
||||||
|
[routerLink]="['/ops/policy/vex']"
|
||||||
|
[queryParams]="contextQueryParams()"
|
||||||
|
>
|
||||||
|
Review VEX posture
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
[routerLink]="['/ops/policy/audit/policy']"
|
||||||
|
[queryParams]="contextQueryParams()"
|
||||||
|
>
|
||||||
|
Review audit trail
|
||||||
|
</a>
|
||||||
|
@if (returnTo()) {
|
||||||
|
<button type="button" class="return-button" (click)="returnToSource()">
|
||||||
|
Return to source
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="details-panel">
|
||||||
|
<article class="details-card">
|
||||||
|
<h3>Blocking reasons</h3>
|
||||||
|
<ul>
|
||||||
|
@for (reason of blockingReasons(); track reason) {
|
||||||
|
<li>{{ reason }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="details-card">
|
||||||
|
<h3>Recommended next actions</h3>
|
||||||
|
<ul>
|
||||||
|
@for (action of recommendedActions(); track action) {
|
||||||
|
<li>{{ action }}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<app-gate-explain-drawer
|
||||||
|
[open]="drawerOpen()"
|
||||||
|
[gateEvaluation]="selectedEvaluation()"
|
||||||
|
(closed)="drawerOpen.set(false)"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.policy-gates-page {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header__eyebrow {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
color: var(--color-status-error);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header p {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
max-width: 56rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-action,
|
||||||
|
.return-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--color-surface-secondary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
min-height: 2.6rem;
|
||||||
|
padding: 0.6rem 0.95rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-action--primary {
|
||||||
|
border-color: var(--color-brand-primary);
|
||||||
|
background: var(--color-brand-primary);
|
||||||
|
color: var(--color-text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.gates-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-panel,
|
||||||
|
.details-card {
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-panel h3,
|
||||||
|
.details-card h3 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: var(--color-text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-panel dl {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-panel dt {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-panel dd {
|
||||||
|
margin: 0.12rem 0 0;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-links {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-links a {
|
||||||
|
color: var(--color-brand-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-card ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
display: grid;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.page-header,
|
||||||
|
.gates-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class PolicyDecisioningGatesPageComponent {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
|
||||||
|
readonly drawerOpen = signal(false);
|
||||||
|
readonly selectedGateId = signal<string | null>(null);
|
||||||
|
|
||||||
|
readonly releaseId = computed(() =>
|
||||||
|
coerceString(this.route.snapshot.root.queryParams['releaseId'])
|
||||||
|
?? coerceString(this.route.snapshot.paramMap.get('releaseId'))
|
||||||
|
);
|
||||||
|
readonly approvalId = computed(() =>
|
||||||
|
coerceString(this.route.snapshot.root.queryParams['approvalId'])
|
||||||
|
?? coerceString(this.route.snapshot.paramMap.get('approvalId'))
|
||||||
|
);
|
||||||
|
readonly environmentId = computed(() =>
|
||||||
|
coerceString(this.route.snapshot.root.queryParams['environment'])
|
||||||
|
?? coerceString(this.route.snapshot.paramMap.get('environment'))
|
||||||
|
);
|
||||||
|
readonly artifactDigest = computed(() =>
|
||||||
|
coerceString(this.route.snapshot.root.queryParams['artifact'])
|
||||||
|
?? coerceString(this.route.snapshot.root.queryParams['artifactDigest'])
|
||||||
|
?? coerceString(this.route.snapshot.root.queryParams['bundleDigest'])
|
||||||
|
);
|
||||||
|
readonly returnTo = computed(() =>
|
||||||
|
coerceString(this.route.snapshot.root.queryParams['returnTo'])
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly scope = computed<DecisioningGateScope>(() => {
|
||||||
|
if (this.approvalId()) {
|
||||||
|
return 'approval';
|
||||||
|
}
|
||||||
|
if (this.releaseId()) {
|
||||||
|
return 'release';
|
||||||
|
}
|
||||||
|
if (this.environmentId()) {
|
||||||
|
return 'environment';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'global';
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly pageTitle = computed(() => {
|
||||||
|
switch (this.scope()) {
|
||||||
|
case 'approval':
|
||||||
|
return `Approval ${this.approvalId()} gate review`;
|
||||||
|
case 'release':
|
||||||
|
return `Release ${this.releaseId()} gate review`;
|
||||||
|
case 'environment':
|
||||||
|
return `${this.environmentId()} environment gate posture`;
|
||||||
|
default:
|
||||||
|
return 'Policy gate catalog and release posture';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly pageSubtitle = computed(() => {
|
||||||
|
switch (this.scope()) {
|
||||||
|
case 'approval':
|
||||||
|
return 'Inspect why the approval is blocked or warned, then move directly into exceptions, VEX, simulation, or audit.';
|
||||||
|
case 'release':
|
||||||
|
return 'Review release-specific blockers and explanation before returning to the release workflow.';
|
||||||
|
case 'environment':
|
||||||
|
return 'Check how a target environment is constrained by policy, VEX, feed freshness, and witness confidence.';
|
||||||
|
default:
|
||||||
|
return 'Use this route to inspect gate policy, promotion simulation, and the supporting evidence before release actions.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly scopeLabel = computed(() => {
|
||||||
|
switch (this.scope()) {
|
||||||
|
case 'approval':
|
||||||
|
return 'Approval-context decision';
|
||||||
|
case 'release':
|
||||||
|
return 'Release-context decision';
|
||||||
|
case 'environment':
|
||||||
|
return 'Environment review';
|
||||||
|
default:
|
||||||
|
return 'Global gate catalog';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly gateResults = computed<readonly GateResult[]>(() => {
|
||||||
|
const blocking = this.scope() === 'global' ? 'WARN' : 'BLOCK';
|
||||||
|
const artifact = this.artifactDigest();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'policy',
|
||||||
|
name: 'Policy Pack',
|
||||||
|
state: 'PASS',
|
||||||
|
reason: 'Baseline policy and attestations satisfy the selected promotion contract.',
|
||||||
|
ruleHits: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'reachability',
|
||||||
|
name: 'Reachability Confidence',
|
||||||
|
state: blocking,
|
||||||
|
reason: this.scope() === 'global'
|
||||||
|
? 'Runtime witness coverage needs review before strict promotion rules are enabled.'
|
||||||
|
: 'Runtime witness coverage is below the threshold required for this promotion.',
|
||||||
|
witnessMetrics: {
|
||||||
|
totalPaths: 12,
|
||||||
|
witnessedPaths: this.scope() === 'global' ? 9 : 7,
|
||||||
|
unwitnessedPaths: this.scope() === 'global' ? 3 : 5,
|
||||||
|
stalePaths: this.scope() === 'global' ? 1 : 2,
|
||||||
|
unwitnessedPathDetails: [
|
||||||
|
{
|
||||||
|
pathId: 'path-1',
|
||||||
|
entrypoint: 'gateway.verifyRelease',
|
||||||
|
sink: 'runtime.exec.promote',
|
||||||
|
severity: 'critical',
|
||||||
|
vulnId: 'CVE-2026-1234',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
pathId: 'path-2',
|
||||||
|
entrypoint: 'worker.issueDecision',
|
||||||
|
sink: 'prod-rollout.apply',
|
||||||
|
severity: 'high',
|
||||||
|
vulnId: 'CVE-2026-3101',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
gateType: 'witness',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vex',
|
||||||
|
name: 'VEX Consensus',
|
||||||
|
state: this.scope() === 'global' ? 'WARN' : 'PASS',
|
||||||
|
reason: this.scope() === 'global'
|
||||||
|
? 'Two statements still need explicit consensus before release promotion is hardened.'
|
||||||
|
: 'VEX posture is consistent with the current release snapshot.',
|
||||||
|
ruleHits: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'freshness',
|
||||||
|
name: 'Feed Freshness',
|
||||||
|
state: 'WARN',
|
||||||
|
reason: artifact
|
||||||
|
? `Snapshot for ${truncateDigest(artifact)} uses stale NVD data beyond the preferred SLO.`
|
||||||
|
: 'Feed freshness is warning-only but should be reviewed before approval.',
|
||||||
|
ruleHits: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly policyReference = computed(() => {
|
||||||
|
if (this.releaseId()) {
|
||||||
|
return `release-${this.releaseId()}-baseline`;
|
||||||
|
}
|
||||||
|
if (this.approvalId()) {
|
||||||
|
return `approval-${this.approvalId()}-baseline`;
|
||||||
|
}
|
||||||
|
if (this.environmentId()) {
|
||||||
|
return `${this.environmentId()}-baseline`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'global-release-baseline';
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly snapshotReference = computed(() => {
|
||||||
|
if (this.releaseId()) {
|
||||||
|
return `snapshot-release-${this.releaseId()}`;
|
||||||
|
}
|
||||||
|
if (this.approvalId()) {
|
||||||
|
return `snapshot-approval-${this.approvalId()}`;
|
||||||
|
}
|
||||||
|
return 'snapshot-2026-03-07T00:00:00Z';
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly selectedEvaluation = computed<GateEvaluation | null>(() => {
|
||||||
|
const gateId = this.selectedGateId();
|
||||||
|
if (!gateId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gate = this.gateResults().find((item) => item.id === gateId);
|
||||||
|
if (!gate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gateId,
|
||||||
|
gateName: gate.name,
|
||||||
|
description: gate.reason ?? 'Gate evaluation details',
|
||||||
|
result: gate.state === 'PASS'
|
||||||
|
? 'passed'
|
||||||
|
: gate.state === 'WARN'
|
||||||
|
? 'warning'
|
||||||
|
: 'failed',
|
||||||
|
evaluatedAt: '2026-03-07T14:00:00Z',
|
||||||
|
triggeredBy: this.scope() === 'global' ? 'catalog-preview' : 'release-context',
|
||||||
|
gateType: gate.gateType ?? 'standard',
|
||||||
|
policyVersion: this.policyReference(),
|
||||||
|
summary: {
|
||||||
|
totalRules: 3,
|
||||||
|
passed: gate.state === 'PASS' ? 3 : 2,
|
||||||
|
failed: gate.state === 'BLOCK' ? 1 : 0,
|
||||||
|
warnings: gate.state === 'WARN' ? 1 : 0,
|
||||||
|
},
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
ruleId: `${gateId}-1`,
|
||||||
|
ruleName: `${gate.name} baseline contract`,
|
||||||
|
description: 'Verifies the policy contract against the current promotion path.',
|
||||||
|
passed: gate.state !== 'BLOCK',
|
||||||
|
severity: gate.state === 'BLOCK' ? 'critical' : 'medium',
|
||||||
|
explanation: gate.reason ?? 'Evaluation completed successfully.',
|
||||||
|
evidenceRefs: [this.snapshotReference(), this.policyReference()],
|
||||||
|
remediation: gate.state === 'BLOCK'
|
||||||
|
? 'Trigger simulation or collect additional witness evidence before promoting.'
|
||||||
|
: undefined,
|
||||||
|
evaluationTimeMs: 18,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ruleId: `${gateId}-2`,
|
||||||
|
ruleName: `${gate.name} freshness`,
|
||||||
|
description: 'Checks that the supporting data snapshot is fresh enough for this path.',
|
||||||
|
passed: true,
|
||||||
|
severity: 'medium',
|
||||||
|
explanation: 'Supporting snapshot is present and attached to the decision.',
|
||||||
|
evidenceRefs: [this.snapshotReference()],
|
||||||
|
evaluationTimeMs: 12,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly blockingReasons = computed<readonly string[]>(() =>
|
||||||
|
this.gateResults()
|
||||||
|
.filter((gate) => gate.state === 'BLOCK' || gate.state === 'WARN')
|
||||||
|
.map((gate) => gate.reason ?? gate.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
readonly recommendedActions = computed<readonly string[]>(() => {
|
||||||
|
const actions = [
|
||||||
|
'Open promotion simulation to inspect how the active baseline would change after fixes.',
|
||||||
|
'Review VEX consensus and exceptions before deciding whether a warning should stay non-blocking.',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (this.scope() !== 'global') {
|
||||||
|
actions.unshift('Use the explain drawer to capture the exact rule and evidence chain that blocked this decision.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
});
|
||||||
|
|
||||||
|
contextQueryParams(): Record<string, string | null | undefined> {
|
||||||
|
return buildContextRouteParams({
|
||||||
|
releaseId: this.releaseId(),
|
||||||
|
approvalId: this.approvalId(),
|
||||||
|
environment: this.environmentId(),
|
||||||
|
artifact: this.artifactDigest(),
|
||||||
|
returnTo: this.returnTo(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openExplain(gateId: string): void {
|
||||||
|
this.selectedGateId.set(gateId);
|
||||||
|
this.drawerOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
openEvidence(): void {
|
||||||
|
void this.router.navigate(['/evidence/capsules'], {
|
||||||
|
queryParams: this.contextQueryParams(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
returnToSource(): void {
|
||||||
|
const returnTo = this.returnTo();
|
||||||
|
if (!returnTo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.router.navigateByUrl(returnTo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceString(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateDigest(value: string): string {
|
||||||
|
return value.length > 18 ? `${value.slice(0, 12)}...${value.slice(-4)}` : value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildContextRouteParams,
|
||||||
|
} from '../../shared/ui/context-route-state/context-route-state';
|
||||||
|
|
||||||
|
interface DecisioningOverviewCard {
|
||||||
|
readonly id: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly route: readonly unknown[];
|
||||||
|
readonly accent: 'ops' | 'pack' | 'vex' | 'gate';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-policy-decisioning-overview-page',
|
||||||
|
imports: [CommonModule, RouterLink],
|
||||||
|
template: `
|
||||||
|
<section class="policy-overview" data-testid="policy-decisioning-overview">
|
||||||
|
<div class="hero">
|
||||||
|
<div>
|
||||||
|
<p class="hero__eyebrow">Decisioning Map</p>
|
||||||
|
<h2>One operator shell for policy, VEX, and release gates</h2>
|
||||||
|
<p class="hero__copy">
|
||||||
|
Decisioning Studio now owns policy packs, governance, simulation, VEX
|
||||||
|
conflicts, exceptions, gate review, and audit. Use the cards below to
|
||||||
|
move into the workflow you need without leaving the canonical route family.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero__metrics">
|
||||||
|
<article>
|
||||||
|
<span class="metric-label">Canonical root</span>
|
||||||
|
<strong>/ops/policy</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span class="metric-label">Primary workflows</span>
|
||||||
|
<strong>7 tabs</strong>
|
||||||
|
</article>
|
||||||
|
<article>
|
||||||
|
<span class="metric-label">Context modes</span>
|
||||||
|
<strong>Global · Pack · Release</strong>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cards">
|
||||||
|
@for (card of cards(); track card.id) {
|
||||||
|
<a
|
||||||
|
class="card"
|
||||||
|
[class]="'card card--' + card.accent"
|
||||||
|
[routerLink]="card.route"
|
||||||
|
[queryParams]="contextQueryParams()"
|
||||||
|
[attr.data-testid]="'policy-overview-card-' + card.id"
|
||||||
|
>
|
||||||
|
<strong>{{ card.title }}</strong>
|
||||||
|
<p>{{ card.description }}</p>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.policy-overview {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
color-mix(in srgb, var(--color-brand-primary) 10%, transparent),
|
||||||
|
var(--color-surface-primary)
|
||||||
|
);
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__eyebrow {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: var(--color-status-info);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-heading);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__copy {
|
||||||
|
margin: 0.45rem 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__metrics {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero__metrics article {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.2rem;
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
color: inherit;
|
||||||
|
padding: 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card strong {
|
||||||
|
color: var(--color-text-heading);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card--ops {
|
||||||
|
border-top: 3px solid var(--color-status-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card--pack {
|
||||||
|
border-top: 3px solid var(--color-status-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card--vex {
|
||||||
|
border-top: 3px solid var(--color-status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card--gate {
|
||||||
|
border-top: 3px solid var(--color-status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class PolicyDecisioningOverviewPageComponent {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
|
||||||
|
readonly cards = computed<readonly DecisioningOverviewCard[]>(() => [
|
||||||
|
{
|
||||||
|
id: 'packs',
|
||||||
|
title: 'Packs Workspace',
|
||||||
|
description: 'Edit, approve, simulate, and explain policy packs from one routed workspace.',
|
||||||
|
route: ['/ops/policy/packs'],
|
||||||
|
accent: 'pack',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'governance',
|
||||||
|
title: 'Governance Controls',
|
||||||
|
description: 'Manage risk budgets, trust weighting, profiles, conflicts, and schema validation.',
|
||||||
|
route: ['/ops/policy/governance'],
|
||||||
|
accent: 'ops',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'simulation',
|
||||||
|
title: 'Simulation Lab',
|
||||||
|
description: 'Run shadow mode, lint, coverage, effective policy, promotion, and exception simulation.',
|
||||||
|
route: ['/ops/policy/simulation'],
|
||||||
|
accent: 'ops',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vex',
|
||||||
|
title: 'VEX & Exceptions',
|
||||||
|
description: 'Resolve VEX conflicts, search statements, manage consensus, and triage exceptions.',
|
||||||
|
route: ['/ops/policy/vex'],
|
||||||
|
accent: 'vex',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gates',
|
||||||
|
title: 'Release Gates',
|
||||||
|
description: 'Inspect gate posture, promotion decisions, and release-context blockers with explainability.',
|
||||||
|
route: ['/ops/policy/gates'],
|
||||||
|
accent: 'gate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit',
|
||||||
|
title: 'Policy Audit',
|
||||||
|
description: 'Review policy and VEX audit trails after the mutable flows have been consolidated.',
|
||||||
|
route: ['/ops/policy/audit'],
|
||||||
|
accent: 'ops',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
contextQueryParams(): Record<string, string | null | undefined> {
|
||||||
|
const queryParams = this.route.snapshot.root.queryParams ?? {};
|
||||||
|
|
||||||
|
return buildContextRouteParams({
|
||||||
|
releaseId: coerceString(queryParams['releaseId']),
|
||||||
|
approvalId: coerceString(queryParams['approvalId']),
|
||||||
|
environment: coerceString(queryParams['environment']),
|
||||||
|
artifact: coerceString(queryParams['artifact']),
|
||||||
|
returnTo: coerceString(queryParams['returnTo']),
|
||||||
|
workflowId: coerceString(queryParams['workflowId']),
|
||||||
|
evidenceId: coerceString(queryParams['evidenceId']),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceString(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import {
|
||||||
|
ActivatedRoute,
|
||||||
|
ActivatedRouteSnapshot,
|
||||||
|
NavigationEnd,
|
||||||
|
Router,
|
||||||
|
RouterLink,
|
||||||
|
RouterOutlet,
|
||||||
|
} from '@angular/router';
|
||||||
|
import { filter, startWith } from 'rxjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ContextHeaderComponent,
|
||||||
|
TabItem,
|
||||||
|
TabbedNavComponent,
|
||||||
|
} from '../../shared/ui';
|
||||||
|
import {
|
||||||
|
buildContextRouteParams,
|
||||||
|
} from '../../shared/ui/context-route-state/context-route-state';
|
||||||
|
|
||||||
|
type DecisioningPrimaryTab =
|
||||||
|
| 'overview'
|
||||||
|
| 'packs'
|
||||||
|
| 'governance'
|
||||||
|
| 'simulation'
|
||||||
|
| 'vex'
|
||||||
|
| 'gates'
|
||||||
|
| 'audit';
|
||||||
|
|
||||||
|
type DecisioningContextKind =
|
||||||
|
| 'global'
|
||||||
|
| 'pack'
|
||||||
|
| 'release'
|
||||||
|
| 'approval'
|
||||||
|
| 'workflow'
|
||||||
|
| 'evidence';
|
||||||
|
|
||||||
|
interface DecisioningShellState {
|
||||||
|
readonly activeTab: DecisioningPrimaryTab;
|
||||||
|
readonly kind: DecisioningContextKind;
|
||||||
|
readonly packId: string | null;
|
||||||
|
readonly releaseId: string | null;
|
||||||
|
readonly approvalId: string | null;
|
||||||
|
readonly environment: string | null;
|
||||||
|
readonly artifact: string | null;
|
||||||
|
readonly returnTo: string | null;
|
||||||
|
readonly workflowId: string | null;
|
||||||
|
readonly evidenceId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-policy-decisioning-shell',
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
RouterLink,
|
||||||
|
RouterOutlet,
|
||||||
|
ContextHeaderComponent,
|
||||||
|
TabbedNavComponent,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<section class="policy-decisioning-shell" data-testid="policy-decisioning-shell">
|
||||||
|
<app-context-header
|
||||||
|
eyebrow="Ops / Policy"
|
||||||
|
[title]="headerTitle()"
|
||||||
|
[subtitle]="headerSubtitle()"
|
||||||
|
[contextNote]="headerNote()"
|
||||||
|
[chips]="headerChips()"
|
||||||
|
[backLabel]="shellState().returnTo ? 'Return to source' : null"
|
||||||
|
(backClick)="returnToSource()"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
header-actions
|
||||||
|
class="shell-action"
|
||||||
|
[routerLink]="overviewRoute()"
|
||||||
|
[queryParams]="contextQueryParams()"
|
||||||
|
>
|
||||||
|
Reset view
|
||||||
|
</a>
|
||||||
|
</app-context-header>
|
||||||
|
|
||||||
|
<app-tabbed-nav
|
||||||
|
[tabs]="primaryTabs()"
|
||||||
|
[activeTab]="shellState().activeTab"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="policy-decisioning-shell__body">
|
||||||
|
<router-outlet />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-decisioning-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-decisioning-shell__body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--color-surface-secondary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class PolicyDecisioningShellComponent {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
readonly shellState = signal<DecisioningShellState>(this.readShellState());
|
||||||
|
|
||||||
|
readonly primaryTabs = computed<readonly TabItem[]>(() => {
|
||||||
|
const state = this.shellState();
|
||||||
|
const queryParams = this.contextQueryParams();
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'overview',
|
||||||
|
label: 'Overview',
|
||||||
|
route: this.overviewRoute(),
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-tab-overview',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'packs',
|
||||||
|
label: 'Packs',
|
||||||
|
route: state.packId
|
||||||
|
? ['/ops/policy/packs', state.packId]
|
||||||
|
: ['/ops/policy/packs'],
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-tab-packs',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'governance',
|
||||||
|
label: 'Governance',
|
||||||
|
route: ['/ops/policy/governance'],
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-tab-governance',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'simulation',
|
||||||
|
label: 'Simulation',
|
||||||
|
route: ['/ops/policy/simulation'],
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-tab-simulation',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vex',
|
||||||
|
label: 'VEX & Exceptions',
|
||||||
|
route: ['/ops/policy/vex'],
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-tab-vex',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'gates',
|
||||||
|
label: 'Release Gates',
|
||||||
|
route: this.gatesRoute(),
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-tab-gates',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit',
|
||||||
|
label: 'Audit',
|
||||||
|
route: ['/ops/policy/audit'],
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-tab-audit',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly headerTitle = computed(() => {
|
||||||
|
const state = this.shellState();
|
||||||
|
|
||||||
|
switch (state.kind) {
|
||||||
|
case 'pack':
|
||||||
|
return `Policy Pack ${state.packId}`;
|
||||||
|
case 'release':
|
||||||
|
return `Release ${state.releaseId} Decisioning`;
|
||||||
|
case 'approval':
|
||||||
|
return `Approval ${state.approvalId} Decisioning`;
|
||||||
|
case 'workflow':
|
||||||
|
return `Workflow ${state.workflowId} Decisioning`;
|
||||||
|
case 'evidence':
|
||||||
|
return `Evidence ${state.evidenceId} Decisioning`;
|
||||||
|
default:
|
||||||
|
return 'Policy Decisioning Studio';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly headerSubtitle = computed(() => {
|
||||||
|
const state = this.shellState();
|
||||||
|
|
||||||
|
switch (state.kind) {
|
||||||
|
case 'pack':
|
||||||
|
return 'Author, validate, approve, and explain a pack from the canonical policy shell.';
|
||||||
|
case 'release':
|
||||||
|
case 'approval':
|
||||||
|
return 'Review gates, simulation, VEX, and exceptions for a live release decision without leaving the shared shell.';
|
||||||
|
case 'workflow':
|
||||||
|
return 'Keep workflow gate logic and release policy inspection in one decisioning workspace.';
|
||||||
|
case 'evidence':
|
||||||
|
return 'Trace evidence, gate posture, and policy or VEX actions from a single canonical route.';
|
||||||
|
default:
|
||||||
|
return 'One canonical shell for policy packs, governance, simulation, VEX, exceptions, release gates, and audit.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly headerNote = computed(() => {
|
||||||
|
const state = this.shellState();
|
||||||
|
|
||||||
|
switch (state.kind) {
|
||||||
|
case 'release':
|
||||||
|
return 'Release-context mode keeps gate review and operator actions inside the shared policy shell.';
|
||||||
|
case 'approval':
|
||||||
|
return 'Approval-context mode preserves policy and VEX actions while allowing a direct return to the approval flow.';
|
||||||
|
case 'pack':
|
||||||
|
return 'Pack mode exposes authoring, YAML, rules, approvals, simulation, and explainability for the selected pack.';
|
||||||
|
case 'workflow':
|
||||||
|
return 'Workflow context is carried as a non-owning deep link for release pipeline editing.';
|
||||||
|
case 'evidence':
|
||||||
|
return 'Evidence context is non-owning: Decisioning Studio stays focused on gates, policy, and VEX actions.';
|
||||||
|
default:
|
||||||
|
return 'Use the primary tabs to move between policy packs, governance, simulation, release gates, VEX, and audit.';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly headerChips = computed(() => {
|
||||||
|
const state = this.shellState();
|
||||||
|
const chips: string[] = [];
|
||||||
|
|
||||||
|
if (state.packId) {
|
||||||
|
chips.push(`Pack ${state.packId}`);
|
||||||
|
}
|
||||||
|
if (state.releaseId) {
|
||||||
|
chips.push(`Release ${state.releaseId}`);
|
||||||
|
}
|
||||||
|
if (state.approvalId) {
|
||||||
|
chips.push(`Approval ${state.approvalId}`);
|
||||||
|
}
|
||||||
|
if (state.environment) {
|
||||||
|
chips.push(`Env ${state.environment}`);
|
||||||
|
}
|
||||||
|
if (state.artifact) {
|
||||||
|
chips.push(`Artifact ${truncateValue(state.artifact)}`);
|
||||||
|
}
|
||||||
|
if (state.workflowId) {
|
||||||
|
chips.push(`Workflow ${state.workflowId}`);
|
||||||
|
}
|
||||||
|
if (state.evidenceId) {
|
||||||
|
chips.push(`Evidence ${state.evidenceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chips;
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.router.events
|
||||||
|
.pipe(
|
||||||
|
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||||
|
startWith(null),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.shellState.set(this.readShellState());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
overviewRoute(): readonly unknown[] {
|
||||||
|
return ['/ops/policy/overview'];
|
||||||
|
}
|
||||||
|
|
||||||
|
gatesRoute(): readonly unknown[] {
|
||||||
|
const state = this.shellState();
|
||||||
|
|
||||||
|
if (state.approvalId) {
|
||||||
|
return ['/ops/policy/gates/approvals', state.approvalId];
|
||||||
|
}
|
||||||
|
if (state.releaseId) {
|
||||||
|
return ['/ops/policy/gates/releases', state.releaseId];
|
||||||
|
}
|
||||||
|
if (state.environment) {
|
||||||
|
return ['/ops/policy/gates/environments', state.environment];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['/ops/policy/gates'];
|
||||||
|
}
|
||||||
|
|
||||||
|
contextQueryParams(): Record<string, string | null | undefined> {
|
||||||
|
const state = this.shellState();
|
||||||
|
|
||||||
|
return buildContextRouteParams({
|
||||||
|
releaseId: state.releaseId,
|
||||||
|
approvalId: state.approvalId,
|
||||||
|
environment: state.environment,
|
||||||
|
artifact: state.artifact,
|
||||||
|
returnTo: state.returnTo,
|
||||||
|
workflowId: state.workflowId,
|
||||||
|
evidenceId: state.evidenceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
returnToSource(): void {
|
||||||
|
const returnTo = this.shellState().returnTo;
|
||||||
|
if (!returnTo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.router.navigateByUrl(returnTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readShellState(): DecisioningShellState {
|
||||||
|
const params = collectRouteParams(this.route.snapshot.root);
|
||||||
|
const queryParams = this.route.snapshot.root.queryParams ?? {};
|
||||||
|
const currentUrl = this.router.url.split('?')[0] ?? '';
|
||||||
|
const releaseId = coerceString(params['releaseId']) ?? coerceString(queryParams['releaseId']);
|
||||||
|
const approvalId = coerceString(params['approvalId']) ?? coerceString(queryParams['approvalId']);
|
||||||
|
const packId = coerceString(params['packId']) ?? coerceString(queryParams['packId']);
|
||||||
|
const environment = coerceString(params['environment']) ?? coerceString(queryParams['environment']);
|
||||||
|
const artifact =
|
||||||
|
coerceString(queryParams['artifact'])
|
||||||
|
?? coerceString(queryParams['artifactDigest'])
|
||||||
|
?? coerceString(queryParams['bundleDigest']);
|
||||||
|
const workflowId = coerceString(queryParams['workflowId']);
|
||||||
|
const evidenceId =
|
||||||
|
coerceString(queryParams['evidenceId'])
|
||||||
|
?? coerceString(queryParams['packetId']);
|
||||||
|
|
||||||
|
let kind: DecisioningContextKind = 'global';
|
||||||
|
|
||||||
|
if (approvalId) {
|
||||||
|
kind = 'approval';
|
||||||
|
} else if (releaseId) {
|
||||||
|
kind = 'release';
|
||||||
|
} else if (packId) {
|
||||||
|
kind = 'pack';
|
||||||
|
} else if (workflowId) {
|
||||||
|
kind = 'workflow';
|
||||||
|
} else if (evidenceId) {
|
||||||
|
kind = 'evidence';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTab: resolvePrimaryTab(currentUrl),
|
||||||
|
kind,
|
||||||
|
packId,
|
||||||
|
releaseId,
|
||||||
|
approvalId,
|
||||||
|
environment,
|
||||||
|
artifact,
|
||||||
|
returnTo: coerceString(queryParams['returnTo']),
|
||||||
|
workflowId,
|
||||||
|
evidenceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRouteParams(snapshot: ActivatedRouteSnapshot | null): Record<string, string> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack: ActivatedRouteSnapshot[] = [snapshot];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop()!;
|
||||||
|
for (const [key, value] of Object.entries(current.params ?? {})) {
|
||||||
|
if (typeof value === 'string' && value.length > 0) {
|
||||||
|
params[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stack.push(...current.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePrimaryTab(currentUrl: string): DecisioningPrimaryTab {
|
||||||
|
if (currentUrl.includes('/ops/policy/packs')) {
|
||||||
|
return 'packs';
|
||||||
|
}
|
||||||
|
if (currentUrl.includes('/ops/policy/governance')) {
|
||||||
|
return 'governance';
|
||||||
|
}
|
||||||
|
if (currentUrl.includes('/ops/policy/simulation')) {
|
||||||
|
return 'simulation';
|
||||||
|
}
|
||||||
|
if (currentUrl.includes('/ops/policy/vex')) {
|
||||||
|
return 'vex';
|
||||||
|
}
|
||||||
|
if (currentUrl.includes('/ops/policy/gates')) {
|
||||||
|
return 'gates';
|
||||||
|
}
|
||||||
|
if (currentUrl.includes('/ops/policy/audit')) {
|
||||||
|
return 'audit';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'overview';
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceString(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateValue(value: string): string {
|
||||||
|
return value.length > 20 ? `${value.slice(0, 12)}...${value.slice(-6)}` : value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import {
|
||||||
|
ActivatedRoute,
|
||||||
|
NavigationEnd,
|
||||||
|
Router,
|
||||||
|
RouterOutlet,
|
||||||
|
} from '@angular/router';
|
||||||
|
import { filter, startWith } from 'rxjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildContextRouteParams,
|
||||||
|
} from '../../shared/ui/context-route-state/context-route-state';
|
||||||
|
import {
|
||||||
|
TabItem,
|
||||||
|
TabbedNavComponent,
|
||||||
|
} from '../../shared/ui';
|
||||||
|
|
||||||
|
type VexSubview =
|
||||||
|
| 'dashboard'
|
||||||
|
| 'search'
|
||||||
|
| 'create'
|
||||||
|
| 'stats'
|
||||||
|
| 'consensus'
|
||||||
|
| 'explorer'
|
||||||
|
| 'conflicts'
|
||||||
|
| 'exceptions';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-policy-decisioning-vex-shell',
|
||||||
|
imports: [CommonModule, RouterOutlet, TabbedNavComponent],
|
||||||
|
template: `
|
||||||
|
<section class="policy-vex-shell" data-testid="policy-vex-shell">
|
||||||
|
<header class="section-header">
|
||||||
|
<div>
|
||||||
|
<p class="section-header__eyebrow">VEX & Exceptions</p>
|
||||||
|
<h2>Mutable VEX actions now live in Decisioning Studio</h2>
|
||||||
|
<p>
|
||||||
|
Search statements, resolve consensus, open exception queues, and keep release-context
|
||||||
|
deep links inside the same policy shell.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<app-tabbed-nav
|
||||||
|
[tabs]="tabItems()"
|
||||||
|
[activeTab]="activeSubview()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="policy-vex-shell__content">
|
||||||
|
<router-outlet />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.policy-vex-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header__eyebrow {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
color: var(--color-status-warning);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header p {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class PolicyDecisioningVexShellComponent {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
readonly activeSubview = signal<VexSubview>(this.readSubview());
|
||||||
|
|
||||||
|
readonly tabItems = computed<readonly TabItem[]>(() => {
|
||||||
|
const queryParams = buildContextRouteParams({
|
||||||
|
releaseId: coerceString(this.route.snapshot.root.queryParams['releaseId']),
|
||||||
|
approvalId: coerceString(this.route.snapshot.root.queryParams['approvalId']),
|
||||||
|
environment: coerceString(this.route.snapshot.root.queryParams['environment']),
|
||||||
|
artifact: coerceString(this.route.snapshot.root.queryParams['artifact']),
|
||||||
|
returnTo: coerceString(this.route.snapshot.root.queryParams['returnTo']),
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'dashboard',
|
||||||
|
label: 'Dashboard',
|
||||||
|
route: ['/ops/policy/vex'],
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-vex-tab-dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'search',
|
||||||
|
label: 'Search',
|
||||||
|
route: ['/ops/policy/vex/search'],
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-vex-tab-search',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'create',
|
||||||
|
label: 'Create',
|
||||||
|
route: ['/ops/policy/vex/create'],
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-vex-tab-create',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stats',
|
||||||
|
label: 'Stats',
|
||||||
|
route: ['/ops/policy/vex/stats'],
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-vex-tab-stats',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'consensus',
|
||||||
|
label: 'Consensus',
|
||||||
|
route: ['/ops/policy/vex/consensus'],
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-vex-tab-consensus',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'explorer',
|
||||||
|
label: 'Explorer',
|
||||||
|
route: ['/ops/policy/vex/explorer'],
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-vex-tab-explorer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'conflicts',
|
||||||
|
label: 'Conflicts',
|
||||||
|
route: ['/ops/policy/vex/conflicts'],
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-vex-tab-conflicts',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'exceptions',
|
||||||
|
label: 'Exceptions',
|
||||||
|
route: ['/ops/policy/vex/exceptions'],
|
||||||
|
queryParams,
|
||||||
|
testId: 'policy-vex-tab-exceptions',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.router.events
|
||||||
|
.pipe(
|
||||||
|
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||||
|
startWith(null),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.activeSubview.set(this.readSubview());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private readSubview(): VexSubview {
|
||||||
|
const url = this.router.url.split('?')[0] ?? '';
|
||||||
|
|
||||||
|
if (url.includes('/vex/search')) {
|
||||||
|
return 'search';
|
||||||
|
}
|
||||||
|
if (url.includes('/vex/create')) {
|
||||||
|
return 'create';
|
||||||
|
}
|
||||||
|
if (url.includes('/vex/stats')) {
|
||||||
|
return 'stats';
|
||||||
|
}
|
||||||
|
if (url.includes('/vex/consensus')) {
|
||||||
|
return 'consensus';
|
||||||
|
}
|
||||||
|
if (url.includes('/vex/explorer')) {
|
||||||
|
return 'explorer';
|
||||||
|
}
|
||||||
|
if (url.includes('/vex/conflicts')) {
|
||||||
|
return 'conflicts';
|
||||||
|
}
|
||||||
|
if (url.includes('/vex/exceptions')) {
|
||||||
|
return 'exceptions';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'dashboard';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function coerceString(value: unknown): string | null {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
|
||||||
|
export const policyDecisioningRoutes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
title: 'Policy Decisioning',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./policy-decisioning-shell.component').then(
|
||||||
|
(m) => m.PolicyDecisioningShellComponent,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
pathMatch: 'full',
|
||||||
|
redirectTo: 'overview',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'overview',
|
||||||
|
title: 'Policy Decisioning Overview',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./policy-decisioning-overview-page.component').then(
|
||||||
|
(m) => m.PolicyDecisioningOverviewPageComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'baselines',
|
||||||
|
pathMatch: 'full',
|
||||||
|
redirectTo: 'overview',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'waivers',
|
||||||
|
pathMatch: 'full',
|
||||||
|
redirectTo: 'vex/exceptions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'exceptions',
|
||||||
|
pathMatch: 'full',
|
||||||
|
redirectTo: 'vex/exceptions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'packs',
|
||||||
|
title: 'Policy Packs',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./policy-pack-shell.component').then(
|
||||||
|
(m) => m.PolicyPackShellComponent,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../policy-studio/workspace/policy-workspace.component').then(
|
||||||
|
(m) => m.PolicyWorkspaceComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':packId',
|
||||||
|
pathMatch: 'full',
|
||||||
|
redirectTo: ':packId/dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':packId/dashboard',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../policy-studio/dashboard/policy-dashboard.component').then(
|
||||||
|
(m) => m.PolicyDashboardComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':packId/edit',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../policy-studio/editor/policy-editor.component').then(
|
||||||
|
(m) => m.PolicyEditorComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':packId/editor',
|
||||||
|
pathMatch: 'full',
|
||||||
|
redirectTo: ':packId/edit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':packId/rules',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../policy-studio/rule-builder/policy-rule-builder.component').then(
|
||||||
|
(m) => m.PolicyRuleBuilderComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':packId/yaml',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../policy-studio/yaml/policy-yaml-editor.component').then(
|
||||||
|
(m) => m.PolicyYamlEditorComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':packId/approvals',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../policy-studio/approvals/policy-approvals.component').then(
|
||||||
|
(m) => m.PolicyApprovalsComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':packId/simulate',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../policy-studio/simulation/policy-simulation.component').then(
|
||||||
|
(m) => m.PolicySimulationComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ':packId/explain/:runId',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../policy-studio/explain/policy-explain.component').then(
|
||||||
|
(m) => m.PolicyExplainComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'governance',
|
||||||
|
title: 'Policy Governance',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('../policy-governance/policy-governance.routes').then(
|
||||||
|
(m) => m.policyGovernanceRoutes,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'simulation',
|
||||||
|
title: 'Policy Simulation',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('../policy-simulation/policy-simulation.routes').then(
|
||||||
|
(m) => m.policySimulationRoutes,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'vex',
|
||||||
|
title: 'VEX & Exceptions',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./policy-decisioning-vex-shell.component').then(
|
||||||
|
(m) => m.PolicyDecisioningVexShellComponent,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../vex-hub/vex-hub-dashboard.component').then(
|
||||||
|
(m) => m.VexHubDashboardComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'dashboard',
|
||||||
|
pathMatch: 'full',
|
||||||
|
redirectTo: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'search',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../vex-hub/vex-statement-search.component').then(
|
||||||
|
(m) => m.VexStatementSearchComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'search/detail/:id',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../vex-hub/vex-statement-detail.component').then(
|
||||||
|
(m) => m.VexStatementDetailComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'create',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../vex-hub/vex-create-workflow.component').then(
|
||||||
|
(m) => m.VexCreateWorkflowComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'stats',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../vex-hub/vex-hub-stats.component').then(
|
||||||
|
(m) => m.VexHubStatsComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'consensus',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../vex-hub/vex-consensus.component').then(
|
||||||
|
(m) => m.VexConsensusComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'explorer',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../vex-hub/vex-hub.component').then(
|
||||||
|
(m) => m.VexHubComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'conflicts',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../vex-hub/vex-conflict-resolution.component').then(
|
||||||
|
(m) => m.VexConflictResolutionComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'exceptions',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../exceptions/exception-dashboard.component').then(
|
||||||
|
(m) => m.ExceptionDashboardComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'exceptions/approvals',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../exceptions/exception-approval-queue.component').then(
|
||||||
|
(m) => m.ExceptionApprovalQueueComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'exceptions/:exceptionId',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../exceptions/exception-dashboard.component').then(
|
||||||
|
(m) => m.ExceptionDashboardComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'gates',
|
||||||
|
title: 'Release Gates',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./policy-decisioning-gates-page.component').then(
|
||||||
|
(m) => m.PolicyDecisioningGatesPageComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'gates/catalog',
|
||||||
|
title: 'Policy Gate Catalog',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../policy-gates/components/policy-preview-panel/policy-preview-panel.component').then(
|
||||||
|
(m) => m.PolicyPreviewPanelComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'gates/simulate/:promotionId',
|
||||||
|
title: 'Gate Simulation',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../policy-gates/components/bundle-simulator/bundle-simulator.component').then(
|
||||||
|
(m) => m.BundleSimulatorComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'gates/environments/:environment',
|
||||||
|
title: 'Environment Gate Review',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./policy-decisioning-gates-page.component').then(
|
||||||
|
(m) => m.PolicyDecisioningGatesPageComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'gates/releases/:releaseId',
|
||||||
|
title: 'Release Gate Review',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./policy-decisioning-gates-page.component').then(
|
||||||
|
(m) => m.PolicyDecisioningGatesPageComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'gates/approvals/:approvalId',
|
||||||
|
title: 'Approval Gate Review',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./policy-decisioning-gates-page.component').then(
|
||||||
|
(m) => m.PolicyDecisioningGatesPageComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'audit',
|
||||||
|
title: 'Policy Audit',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./policy-decisioning-audit-shell.component').then(
|
||||||
|
(m) => m.PolicyDecisioningAuditShellComponent,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
pathMatch: 'full',
|
||||||
|
redirectTo: 'policy',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../audit-log/audit-policy.component').then(
|
||||||
|
(m) => m.AuditPolicyComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'vex',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../audit-log/audit-vex.component').then(
|
||||||
|
(m) => m.AuditVexComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'log',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../audit-log/audit-log-dashboard.component').then(
|
||||||
|
(m) => m.AuditLogDashboardComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'log/events',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('../audit-log/audit-log-table.component').then(
|
||||||
|
(m) => m.AuditLogTableComponent,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import {
|
||||||
|
ActivatedRoute,
|
||||||
|
ActivatedRouteSnapshot,
|
||||||
|
NavigationEnd,
|
||||||
|
Router,
|
||||||
|
RouterOutlet,
|
||||||
|
} from '@angular/router';
|
||||||
|
import { filter, startWith } from 'rxjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
TabItem,
|
||||||
|
TabbedNavComponent,
|
||||||
|
} from '../../shared/ui';
|
||||||
|
|
||||||
|
type PackSubview =
|
||||||
|
| 'workspace'
|
||||||
|
| 'dashboard'
|
||||||
|
| 'edit'
|
||||||
|
| 'rules'
|
||||||
|
| 'yaml'
|
||||||
|
| 'approvals'
|
||||||
|
| 'simulate'
|
||||||
|
| 'explain';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-policy-pack-shell',
|
||||||
|
imports: [CommonModule, RouterOutlet, TabbedNavComponent],
|
||||||
|
template: `
|
||||||
|
<section class="policy-pack-shell" data-testid="policy-pack-shell">
|
||||||
|
<header class="section-header">
|
||||||
|
<div>
|
||||||
|
<p class="section-header__eyebrow">Packs</p>
|
||||||
|
<h2>{{ packId() ? 'Pack ' + packId() : 'Policy Pack Workspace' }}</h2>
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
packId()
|
||||||
|
? 'Edit rules, YAML, approvals, and simulations for the selected pack.'
|
||||||
|
: 'Browse deterministic pack inventory and open a pack into authoring mode.'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<app-tabbed-nav
|
||||||
|
[tabs]="tabItems()"
|
||||||
|
[activeTab]="activeSubview()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="policy-pack-shell__content">
|
||||||
|
<router-outlet />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
styles: [`
|
||||||
|
.policy-pack-shell {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--color-surface-primary);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header__eyebrow {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
color: var(--color-status-success);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header p {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
`],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class PolicyPackShellComponent {
|
||||||
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
|
||||||
|
readonly packId = signal<string | null>(this.readPackId());
|
||||||
|
readonly activeSubview = signal<PackSubview>(this.readSubview());
|
||||||
|
|
||||||
|
readonly tabItems = computed<readonly TabItem[]>(() => {
|
||||||
|
const packId = this.packId();
|
||||||
|
|
||||||
|
if (!packId) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'workspace',
|
||||||
|
label: 'Workspace',
|
||||||
|
route: ['/ops/policy/packs'],
|
||||||
|
testId: 'policy-pack-tab-workspace',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'dashboard',
|
||||||
|
label: 'Dashboard',
|
||||||
|
route: ['/ops/policy/packs', packId],
|
||||||
|
testId: 'policy-pack-tab-dashboard',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'edit',
|
||||||
|
label: 'Edit',
|
||||||
|
route: ['/ops/policy/packs', packId, 'edit'],
|
||||||
|
testId: 'policy-pack-tab-edit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rules',
|
||||||
|
label: 'Rules',
|
||||||
|
route: ['/ops/policy/packs', packId, 'rules'],
|
||||||
|
testId: 'policy-pack-tab-rules',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'yaml',
|
||||||
|
label: 'YAML',
|
||||||
|
route: ['/ops/policy/packs', packId, 'yaml'],
|
||||||
|
testId: 'policy-pack-tab-yaml',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'approvals',
|
||||||
|
label: 'Approvals',
|
||||||
|
route: ['/ops/policy/packs', packId, 'approvals'],
|
||||||
|
testId: 'policy-pack-tab-approvals',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'simulate',
|
||||||
|
label: 'Simulate',
|
||||||
|
route: ['/ops/policy/packs', packId, 'simulate'],
|
||||||
|
testId: 'policy-pack-tab-simulate',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.router.events
|
||||||
|
.pipe(
|
||||||
|
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||||
|
startWith(null),
|
||||||
|
takeUntilDestroyed(this.destroyRef),
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.packId.set(this.readPackId());
|
||||||
|
this.activeSubview.set(this.readSubview());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private readPackId(): string | null {
|
||||||
|
const params = collectParams(this.route.snapshot.root);
|
||||||
|
return typeof params['packId'] === 'string' && params['packId'].length > 0
|
||||||
|
? params['packId']
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readSubview(): PackSubview {
|
||||||
|
const url = this.router.url.split('?')[0] ?? '';
|
||||||
|
|
||||||
|
if (!this.readPackId()) {
|
||||||
|
return 'workspace';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.endsWith('/edit') || url.endsWith('/editor')) {
|
||||||
|
return 'edit';
|
||||||
|
}
|
||||||
|
if (url.endsWith('/rules')) {
|
||||||
|
return 'rules';
|
||||||
|
}
|
||||||
|
if (url.endsWith('/yaml')) {
|
||||||
|
return 'yaml';
|
||||||
|
}
|
||||||
|
if (url.endsWith('/approvals')) {
|
||||||
|
return 'approvals';
|
||||||
|
}
|
||||||
|
if (url.endsWith('/simulate')) {
|
||||||
|
return 'simulate';
|
||||||
|
}
|
||||||
|
if (url.includes('/explain/')) {
|
||||||
|
return 'explain';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'dashboard';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectParams(snapshot: ActivatedRouteSnapshot | null): Record<string, string> {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack: ActivatedRouteSnapshot[] = [snapshot];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop()!;
|
||||||
|
for (const [key, value] of Object.entries(current.params ?? {})) {
|
||||||
|
if (typeof value === 'string' && value.length > 0) {
|
||||||
|
params[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stack.push(...current.children);
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
}
|
||||||
@@ -588,7 +588,7 @@ export class ImpactPreviewComponent implements OnInit {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.applying.set(false);
|
this.applying.set(false);
|
||||||
// Navigate back to trust weights
|
// Navigate back to trust weights
|
||||||
window.location.href = '/policy/governance/trust-weights';
|
window.location.href = '/ops/policy/governance/trust-weights';
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -624,7 +624,7 @@ export class PolicyAuditLogComponent implements OnInit {
|
|||||||
|
|
||||||
viewDiff(entry: PolicyAuditEntry): void {
|
viewDiff(entry: PolicyAuditEntry): void {
|
||||||
if (entry.diffId && entry.policyVersion) {
|
if (entry.diffId && entry.policyVersion) {
|
||||||
this.router.navigate(['/policy/simulation/diff', entry.policyPackId], {
|
this.router.navigate(['/ops/policy/simulation/diff', entry.policyPackId], {
|
||||||
queryParams: {
|
queryParams: {
|
||||||
from: entry.policyVersion - 1,
|
from: entry.policyVersion - 1,
|
||||||
to: entry.policyVersion,
|
to: entry.policyVersion,
|
||||||
|
|||||||
@@ -686,7 +686,7 @@ export class PromotionGateComponent implements OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onPromote(): void {
|
onPromote(): void {
|
||||||
void this.router.navigate(['/policy/packs'], {
|
void this.router.navigate(['/ops/policy/packs'], {
|
||||||
queryParams: {
|
queryParams: {
|
||||||
promotedPack: this.policyPackId,
|
promotedPack: this.policyPackId,
|
||||||
promotedVersion: this.policyVersion,
|
promotedVersion: this.policyVersion,
|
||||||
|
|||||||
@@ -615,11 +615,11 @@ export class SimulationDashboardComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected navigateToHistory(): void {
|
protected navigateToHistory(): void {
|
||||||
this.router.navigate(['/policy/simulation/history']);
|
this.router.navigate(['/ops/policy/simulation/history']);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected navigateToPromotion(): void {
|
protected navigateToPromotion(): void {
|
||||||
this.router.navigate(['/policy/simulation/promotion']);
|
this.router.navigate(['/ops/policy/simulation/promotion']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1118,7 +1118,7 @@ export class SimulationHistoryComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewSimulation(simulationId: string): void {
|
viewSimulation(simulationId: string): void {
|
||||||
this.router.navigate(['/policy/simulation/console'], {
|
this.router.navigate(['/ops/policy/simulation/console'], {
|
||||||
queryParams: { simulationId },
|
queryParams: { simulationId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import { PolicyPackStore } from '../services/policy-pack.store';
|
|||||||
</ul>
|
</ul>
|
||||||
<div class="pack-card__actions">
|
<div class="pack-card__actions">
|
||||||
<a
|
<a
|
||||||
[routerLink]="['/policy-studio/packs', pack.id, 'editor']"
|
[routerLink]="['/ops/policy/packs', pack.id, 'edit']"
|
||||||
[class.action-disabled]="!canAuthor"
|
[class.action-disabled]="!canAuthor"
|
||||||
[attr.aria-disabled]="!canAuthor"
|
[attr.aria-disabled]="!canAuthor"
|
||||||
[title]="canAuthor ? '' : 'Requires policy:author scope'"
|
[title]="canAuthor ? '' : 'Requires policy:author scope'"
|
||||||
@@ -55,7 +55,7 @@ import { PolicyPackStore } from '../services/policy-pack.store';
|
|||||||
Edit
|
Edit
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
[routerLink]="['/policy-studio/packs', pack.id, 'simulate']"
|
[routerLink]="['/ops/policy/packs', pack.id, 'simulate']"
|
||||||
[class.action-disabled]="!canSimulate"
|
[class.action-disabled]="!canSimulate"
|
||||||
[attr.aria-disabled]="!canSimulate"
|
[attr.aria-disabled]="!canSimulate"
|
||||||
[title]="canSimulate ? '' : 'Requires policy:simulate scope'"
|
[title]="canSimulate ? '' : 'Requires policy:simulate scope'"
|
||||||
@@ -63,7 +63,7 @@ import { PolicyPackStore } from '../services/policy-pack.store';
|
|||||||
Simulate
|
Simulate
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
[routerLink]="['/policy-studio/packs', pack.id, 'approvals']"
|
[routerLink]="['/ops/policy/packs', pack.id, 'approvals']"
|
||||||
[class.action-disabled]="!canReviewOrApprove"
|
[class.action-disabled]="!canReviewOrApprove"
|
||||||
[attr.aria-disabled]="!canReviewOrApprove"
|
[attr.aria-disabled]="!canReviewOrApprove"
|
||||||
[title]="canReviewOrApprove ? '' : 'Requires policy:review or policy:approve scope'"
|
[title]="canReviewOrApprove ? '' : 'Requires policy:review or policy:approve scope'"
|
||||||
@@ -71,7 +71,7 @@ import { PolicyPackStore } from '../services/policy-pack.store';
|
|||||||
Approvals
|
Approvals
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
[routerLink]="['/policy-studio/packs', pack.id, 'dashboard']"
|
[routerLink]="['/ops/policy/packs', pack.id]"
|
||||||
[class.action-disabled]="!canView"
|
[class.action-disabled]="!canView"
|
||||||
[attr.aria-disabled]="!canView"
|
[attr.aria-disabled]="!canView"
|
||||||
[title]="canView ? '' : 'Requires policy:read scope'"
|
[title]="canView ? '' : 'Requires policy:read scope'"
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ type SortOrder = 'asc' | 'desc';
|
|||||||
<td>
|
<td>
|
||||||
<a
|
<a
|
||||||
class="policy-studio__link"
|
class="policy-studio__link"
|
||||||
[routerLink]="['/policy/governance/profiles', profile.profileId]"
|
[routerLink]="['/ops/policy/governance/profiles', profile.profileId]"
|
||||||
>
|
>
|
||||||
{{ profile.profileId }}
|
{{ profile.profileId }}
|
||||||
</a>
|
</a>
|
||||||
@@ -1088,7 +1088,7 @@ export class PolicyStudioComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewProfile(profile: RiskProfileSummary): void {
|
viewProfile(profile: RiskProfileSummary): void {
|
||||||
this.router.navigate(['/policy/governance/profiles', profile.profileId]);
|
this.router.navigate(['/ops/policy/governance/profiles', profile.profileId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
simulateWithProfile(profile: RiskProfileSummary): void {
|
simulateWithProfile(profile: RiskProfileSummary): void {
|
||||||
@@ -1097,7 +1097,7 @@ export class PolicyStudioComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
viewPack(pack: PolicyPackSummary): void {
|
viewPack(pack: PolicyPackSummary): void {
|
||||||
this.router.navigate(['/policy-studio/packs', pack.packId, 'dashboard']);
|
this.router.navigate(['/ops/policy/packs', pack.packId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
createRevision(pack: PolicyPackSummary): void {
|
createRevision(pack: PolicyPackSummary): void {
|
||||||
@@ -1110,11 +1110,11 @@ export class PolicyStudioComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openCreateProfile(): void {
|
openCreateProfile(): void {
|
||||||
this.router.navigate(['/policy/governance/profiles/new']);
|
this.router.navigate(['/ops/policy/governance/profiles/new']);
|
||||||
}
|
}
|
||||||
|
|
||||||
openCreatePack(): void {
|
openCreatePack(): void {
|
||||||
this.router.navigate(['/policy-studio/packs']);
|
this.router.navigate(['/ops/policy/packs']);
|
||||||
}
|
}
|
||||||
|
|
||||||
runSimulation(): void {
|
runSimulation(): void {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { ApprovalStore } from '../approval.store';
|
import { ApprovalStore } from '../approval.store';
|
||||||
import type { ApprovalUrgency, PromotionPreview, GateStatus } from '../../../../core/api/approval.models';
|
import type { ApprovalUrgency, PromotionPreview, GateStatus } from '../../../../core/api/approval.models';
|
||||||
import { getGateStatusColor, getUrgencyLabel } from '../../../../core/api/approval.models';
|
import { getGateStatusColor, getUrgencyLabel } from '../../../../core/api/approval.models';
|
||||||
|
import { buildContextReturnTo } from '../../../../shared/ui/context-route-state/context-route-state';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-promotion-request',
|
selector: 'app-promotion-request',
|
||||||
@@ -143,6 +144,12 @@ import { getGateStatusColor, getUrgencyLabel } from '../../../../core/api/approv
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="decisioning-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" (click)="openDecisioningPreview()">
|
||||||
|
Open Decisioning Studio
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -512,6 +519,21 @@ export class PromotionRequestComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openDecisioningPreview(): void {
|
||||||
|
const returnTo = buildContextReturnTo(
|
||||||
|
this.router,
|
||||||
|
['/releases', this.releaseId, 'request-promotion'],
|
||||||
|
);
|
||||||
|
|
||||||
|
void this.router.navigate(['/ops/policy/gates/releases', this.releaseId], {
|
||||||
|
queryParams: {
|
||||||
|
releaseId: this.releaseId,
|
||||||
|
environment: this.targetEnvironmentId || null,
|
||||||
|
returnTo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
isValid(): boolean {
|
isValid(): boolean {
|
||||||
return !!this.targetEnvironmentId &&
|
return !!this.targetEnvironmentId &&
|
||||||
!!this.justification &&
|
!!this.justification &&
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ interface AuditEventRow {
|
|||||||
<div class="footer-links">
|
<div class="footer-links">
|
||||||
<a routerLink="/platform-ops/data-integrity/scan-pipeline">Trigger SBOM scan/rescan</a>
|
<a routerLink="/platform-ops/data-integrity/scan-pipeline">Trigger SBOM scan/rescan</a>
|
||||||
<a routerLink="/security/findings">Open Findings</a>
|
<a routerLink="/security/findings">Open Findings</a>
|
||||||
<a routerLink="/security/vex">Open VEX/Exceptions</a>
|
<a routerLink="/ops/policy/vex">Open VEX/Exceptions</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import { Component, OnInit, OnDestroy, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
|
import { Component, OnInit, OnDestroy, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { EvidenceStore } from '../evidence.store';
|
import { EvidenceStore } from '../evidence.store';
|
||||||
import {
|
import {
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
getGateStatusClass,
|
getGateStatusClass,
|
||||||
type ExportFormat,
|
type ExportFormat,
|
||||||
} from '../../../../core/api/release-evidence.models';
|
} from '../../../../core/api/release-evidence.models';
|
||||||
|
import { buildContextReturnTo } from '../../../../shared/ui/context-route-state/context-route-state';
|
||||||
|
|
||||||
type TabType = 'overview' | 'content' | 'signature' | 'timeline';
|
type TabType = 'overview' | 'content' | 'signature' | 'timeline';
|
||||||
|
|
||||||
@@ -62,6 +63,13 @@ type TabType = 'overview' | 'content' | 'signature' | 'timeline';
|
|||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> Verify
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> Verify
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
type="button"
|
||||||
|
(click)="openDecisioningStudio()"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg> Policy Decisioning
|
||||||
|
</button>
|
||||||
<button class="btn btn-primary" (click)="showExportDialog.set(true)">
|
<button class="btn btn-primary" (click)="showExportDialog.set(true)">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Export
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> Export
|
||||||
</button>
|
</button>
|
||||||
@@ -1423,6 +1431,7 @@ type TabType = 'overview' | 'content' | 'signature' | 'timeline';
|
|||||||
})
|
})
|
||||||
export class EvidenceDetailComponent implements OnInit, OnDestroy {
|
export class EvidenceDetailComponent implements OnInit, OnDestroy {
|
||||||
private readonly route = inject(ActivatedRoute);
|
private readonly route = inject(ActivatedRoute);
|
||||||
|
private readonly router = inject(Router);
|
||||||
readonly store = inject(EvidenceStore);
|
readonly store = inject(EvidenceStore);
|
||||||
|
|
||||||
activeTab = signal<TabType>('overview');
|
activeTab = signal<TabType>('overview');
|
||||||
@@ -1492,6 +1501,26 @@ export class EvidenceDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openDecisioningStudio(): void {
|
||||||
|
const packet = this.packet();
|
||||||
|
if (!packet) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifact = packet.content.artifacts[0]?.digest ?? packet.contentHash;
|
||||||
|
const returnTo = buildContextReturnTo(this.router, ['/release-orchestrator/evidence', packet.id]);
|
||||||
|
|
||||||
|
void this.router.navigate(['/ops/policy/gates/releases', packet.releaseId], {
|
||||||
|
queryParams: {
|
||||||
|
releaseId: packet.releaseId,
|
||||||
|
environment: packet.environmentName,
|
||||||
|
artifact,
|
||||||
|
evidenceId: packet.id,
|
||||||
|
returnTo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
copyContent(): void {
|
copyContent(): void {
|
||||||
navigator.clipboard.writeText(this.rawJson());
|
navigator.clipboard.writeText(this.rawJson());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ReleaseManagementStore } from '../release.store';
|
|||||||
import { getEvidencePostureLabel, getGateStatusLabel, getRiskTierLabel } from '../../../../core/api/release-management.models';
|
import { getEvidencePostureLabel, getGateStatusLabel, getRiskTierLabel } from '../../../../core/api/release-management.models';
|
||||||
import type { ManagedRelease } from '../../../../core/api/release-management.models';
|
import type { ManagedRelease } from '../../../../core/api/release-management.models';
|
||||||
import { DegradedStateBannerComponent } from '../../../../shared/components/degraded-state-banner/degraded-state-banner.component';
|
import { DegradedStateBannerComponent } from '../../../../shared/components/degraded-state-banner/degraded-state-banner.component';
|
||||||
|
import { buildContextReturnTo } from '../../../../shared/ui/context-route-state/context-route-state';
|
||||||
|
|
||||||
interface PlatformListResponse<T> { items: T[]; total: number; limit: number; offset: number; }
|
interface PlatformListResponse<T> { items: T[]; total: number; limit: number; offset: number; }
|
||||||
interface PlatformItemResponse<T> { item: T; }
|
interface PlatformItemResponse<T> { item: T; }
|
||||||
@@ -152,6 +153,7 @@ interface ReloadOptions {
|
|||||||
<span>{{ getEvidencePostureLabel(release()!.evidencePosture) }}</span>
|
<span>{{ getEvidencePostureLabel(release()!.evidencePosture) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<button type="button" (click)="openDecisioningStudio()">Decisioning</button>
|
||||||
<button type="button" (click)="openTab('gate-decision')">Promote</button>
|
<button type="button" (click)="openTab('gate-decision')">Promote</button>
|
||||||
<button type="button" (click)="openTab('deployments')">Deploy</button>
|
<button type="button" (click)="openTab('deployments')">Deploy</button>
|
||||||
<button type="button" (click)="openTab('security-inputs')">Security</button>
|
<button type="button" (click)="openTab('security-inputs')">Security</button>
|
||||||
@@ -234,6 +236,7 @@ interface ReloadOptions {
|
|||||||
@for (check of preflightChecks(); track check.id) { <li>{{ check.label }}: <strong>{{ check.status }}</strong></li> }
|
@for (check of preflightChecks(); track check.id) { <li>{{ check.label }}: <strong>{{ check.status }}</strong></li> }
|
||||||
</ul>
|
</ul>
|
||||||
<button type="button" class="primary" [disabled]="!canPromote()">Promote Release</button>
|
<button type="button" class="primary" [disabled]="!canPromote()">Promote Release</button>
|
||||||
|
<button type="button" (click)="openDecisioningStudio()">Open Decisioning Studio</button>
|
||||||
<p><a [routerLink]="[detailBasePath(), releaseId(), 'security-inputs']">Open blockers</a></p>
|
<p><a [routerLink]="[detailBasePath(), releaseId(), 'security-inputs']">Open blockers</a></p>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
@@ -271,7 +274,7 @@ interface ReloadOptions {
|
|||||||
} @empty { <tr><td colspan="7">No findings.</td></tr> }
|
} @empty { <tr><td colspan="7">No findings.</td></tr> }
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p><button type="button" (click)="openGlobalFindings()">Open Findings</button> <button type="button" (click)="openReachabilityWorkspace()">Open Reachability</button> <button type="button" (click)="createException()">Create Exception</button> <button type="button" (click)="openTab('rollback')">Compare Baseline</button> <button type="button" class="primary" (click)="exportSecurityEvidence()">Export Security Evidence</button></p>
|
<p><button type="button" (click)="openGlobalFindings()">Open Findings</button> <button type="button" (click)="openReachabilityWorkspace()">Open Reachability</button> <button type="button" (click)="createException()">Create Exception</button> <button type="button" (click)="openDecisioningStudio()">Open Decisioning Studio</button> <button type="button" (click)="openTab('rollback')">Compare Baseline</button> <button type="button" class="primary" (click)="exportSecurityEvidence()">Export Security Evidence</button></p>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,6 +614,33 @@ export class ReleaseDetailComponent {
|
|||||||
|
|
||||||
canPromote(): boolean { return this.preflightChecks().every((c) => c.status !== 'fail'); }
|
canPromote(): boolean { return this.preflightChecks().every((c) => c.status !== 'fail'); }
|
||||||
|
|
||||||
|
openDecisioningStudio(): void {
|
||||||
|
const release = this.release();
|
||||||
|
const contextId = this.releaseContextId();
|
||||||
|
const environment =
|
||||||
|
this.runDetail()?.targetEnvironment
|
||||||
|
?? release?.currentEnvironment
|
||||||
|
?? release?.targetEnvironment
|
||||||
|
?? null;
|
||||||
|
const artifact =
|
||||||
|
this.runDetail()?.releaseVersionDigest
|
||||||
|
?? release?.digest
|
||||||
|
?? null;
|
||||||
|
const returnTo = buildContextReturnTo(
|
||||||
|
this.router,
|
||||||
|
[this.detailBasePath(), this.releaseId(), this.activeTab()],
|
||||||
|
);
|
||||||
|
|
||||||
|
void this.router.navigate(['/ops/policy/gates/releases', this.releaseId()], {
|
||||||
|
queryParams: {
|
||||||
|
releaseId: contextId,
|
||||||
|
environment,
|
||||||
|
artifact,
|
||||||
|
returnTo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
toggleTarget(targetId: string, event: Event): void {
|
toggleTarget(targetId: string, event: Event): void {
|
||||||
const checked = (event.target as HTMLInputElement).checked;
|
const checked = (event.target as HTMLInputElement).checked;
|
||||||
this.selectedTargets.update((cur) => {
|
this.selectedTargets.update((cur) => {
|
||||||
@@ -623,7 +653,30 @@ export class ReleaseDetailComponent {
|
|||||||
setBaseline(id: string): void { this.baselineId.set(id); this.loadDiff(); }
|
setBaseline(id: string): void { this.baselineId.set(id); this.loadDiff(); }
|
||||||
|
|
||||||
openFinding(): void { void this.router.navigate(['/security/triage'], { queryParams: { releaseId: this.releaseContextId() } }); }
|
openFinding(): void { void this.router.navigate(['/security/triage'], { queryParams: { releaseId: this.releaseContextId() } }); }
|
||||||
createException(): void { void this.router.navigate(['/security/disposition'], { queryParams: { releaseId: this.releaseContextId(), tab: 'exceptions' } }); }
|
createException(): void {
|
||||||
|
const release = this.release();
|
||||||
|
const returnTo = buildContextReturnTo(
|
||||||
|
this.router,
|
||||||
|
[this.detailBasePath(), this.releaseId(), this.activeTab()],
|
||||||
|
);
|
||||||
|
|
||||||
|
void this.router.navigate(['/ops/policy/vex/exceptions'], {
|
||||||
|
queryParams: {
|
||||||
|
releaseId: this.releaseContextId(),
|
||||||
|
environment:
|
||||||
|
this.runDetail()?.targetEnvironment
|
||||||
|
?? release?.currentEnvironment
|
||||||
|
?? release?.targetEnvironment
|
||||||
|
?? null,
|
||||||
|
artifact:
|
||||||
|
this.runDetail()?.releaseVersionDigest
|
||||||
|
?? release?.digest
|
||||||
|
?? null,
|
||||||
|
returnTo,
|
||||||
|
create: '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
openReachabilityWorkspace(): void {
|
openReachabilityWorkspace(): void {
|
||||||
const release = this.release();
|
const release = this.release();
|
||||||
const search = this.findings()[0]?.cveId || release?.name || this.releaseContextId();
|
const search = this.findings()[0]?.cveId || release?.name || this.releaseContextId();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type WorkflowStepType,
|
type WorkflowStepType,
|
||||||
type StepTypeDefinition,
|
type StepTypeDefinition,
|
||||||
} from '../../../../core/api/workflow.models';
|
} from '../../../../core/api/workflow.models';
|
||||||
|
import { buildContextReturnTo } from '../../../../shared/ui/context-route-state/context-route-state';
|
||||||
import { WorkflowVisualizerComponent } from '../../../workflow-visualization/components/workflow-visualizer/workflow-visualizer.component';
|
import { WorkflowVisualizerComponent } from '../../../workflow-visualization/components/workflow-visualizer/workflow-visualizer.component';
|
||||||
import type { WorkflowGraph } from '../../../workflow-visualization/services/workflow-visualization.service';
|
import type { WorkflowGraph } from '../../../workflow-visualization/services/workflow-visualization.service';
|
||||||
|
|
||||||
@@ -73,6 +74,9 @@ interface ConnectionState {
|
|||||||
@if (store.isDirty()) {
|
@if (store.isDirty()) {
|
||||||
<span class="unsaved-indicator">Unsaved changes</span>
|
<span class="unsaved-indicator">Unsaved changes</span>
|
||||||
}
|
}
|
||||||
|
<button class="btn btn-secondary" type="button" (click)="openDecisioningStudio()">
|
||||||
|
Decisioning
|
||||||
|
</button>
|
||||||
<button class="btn btn-secondary" (click)="showYamlView.set(!showYamlView())">
|
<button class="btn btn-secondary" (click)="showYamlView.set(!showYamlView())">
|
||||||
{{ showYamlView() ? 'Visual' : 'YAML' }}
|
{{ showYamlView() ? 'Visual' : 'YAML' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -1392,6 +1396,24 @@ export class WorkflowEditorComponent implements OnInit, OnDestroy, AfterViewInit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openDecisioningStudio(): void {
|
||||||
|
const workflowId =
|
||||||
|
this.store.selectedWorkflow()?.id
|
||||||
|
?? this.route.snapshot.paramMap.get('workflowId')
|
||||||
|
?? this.route.snapshot.paramMap.get('id');
|
||||||
|
|
||||||
|
const returnTo = workflowId
|
||||||
|
? buildContextReturnTo(this.router, ['/release-orchestrator/workflows', workflowId])
|
||||||
|
: buildContextReturnTo(this.router, ['/release-orchestrator/workflows']);
|
||||||
|
|
||||||
|
void this.router.navigate(['/ops/policy/gates'], {
|
||||||
|
queryParams: {
|
||||||
|
workflowId,
|
||||||
|
returnTo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Connection handling
|
// Connection handling
|
||||||
selectConnection(connection: { from: string; to: string }): void {
|
selectConnection(connection: { from: string; to: string }): void {
|
||||||
// Could implement connection selection for deletion
|
// Could implement connection selection for deletion
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
|
|
||||||
import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@angular/core';
|
import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@angular/core';
|
||||||
|
|
||||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
|
|
||||||
|
import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state';
|
||||||
|
|
||||||
type ReleaseDetailTabId =
|
type ReleaseDetailTabId =
|
||||||
| 'overview'
|
| 'overview'
|
||||||
@@ -44,6 +46,9 @@ type ReleaseDetailTabId =
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button type="button" class="btn btn--secondary" (click)="openDecisioning()">
|
||||||
|
Policy Decisioning
|
||||||
|
</button>
|
||||||
<button type="button" class="btn btn--secondary" (click)="openEvidence()">
|
<button type="button" class="btn btn--secondary" (click)="openEvidence()">
|
||||||
Open Evidence
|
Open Evidence
|
||||||
</button>
|
</button>
|
||||||
@@ -135,7 +140,7 @@ type ReleaseDetailTabId =
|
|||||||
<span>VEX Consensus</span>
|
<span>VEX Consensus</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn btn--sm btn--secondary" (click)="setTab('gates')">
|
<button type="button" class="btn btn--sm btn--secondary" (click)="openDecisioning()">
|
||||||
View Details
|
View Details
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,7 +212,7 @@ type ReleaseDetailTabId =
|
|||||||
<div class="gate-detail-header">
|
<div class="gate-detail-header">
|
||||||
<span class="gate-badge" [class]="'gate-badge--' + gate.status.toLowerCase()">{{ gate.status }}</span>
|
<span class="gate-badge" [class]="'gate-badge--' + gate.status.toLowerCase()">{{ gate.status }}</span>
|
||||||
<span class="gate-name">{{ gate.name }}</span>
|
<span class="gate-name">{{ gate.name }}</span>
|
||||||
<button type="button" class="btn btn--sm">Explain</button>
|
<button type="button" class="btn btn--sm" (click)="openDecisioning()">Explain</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="gate-reason">{{ gate.reason }}</p>
|
<p class="gate-reason">{{ gate.reason }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -571,6 +576,7 @@ type ReleaseDetailTabId =
|
|||||||
})
|
})
|
||||||
export class ReleaseDetailPageComponent implements OnInit {
|
export class ReleaseDetailPageComponent implements OnInit {
|
||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
releaseId = signal('');
|
releaseId = signal('');
|
||||||
activeTab = signal<ReleaseDetailTabId>('overview');
|
activeTab = signal<ReleaseDetailTabId>('overview');
|
||||||
@@ -646,4 +652,18 @@ export class ReleaseDetailPageComponent implements OnInit {
|
|||||||
requestPromotion(): void {
|
requestPromotion(): void {
|
||||||
console.log('Request promotion');
|
console.log('Request promotion');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openDecisioning(): void {
|
||||||
|
const release = this.release();
|
||||||
|
const returnTo = buildContextReturnTo(this.router, ['/releases', this.releaseId()]);
|
||||||
|
|
||||||
|
void this.router.navigate(['/ops/policy/gates/releases', this.releaseId()], {
|
||||||
|
queryParams: {
|
||||||
|
releaseId: this.releaseId(),
|
||||||
|
environment: release.currentEnv,
|
||||||
|
artifact: release.bundleDigest,
|
||||||
|
returnTo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { RouterLink } from '@angular/router';
|
|||||||
<h1 class="page-title">Exception Detail</h1>
|
<h1 class="page-title">Exception Detail</h1>
|
||||||
<p class="page-subtitle">Policy exception details and evidence.</p>
|
<p class="page-subtitle">Policy exception details and evidence.</p>
|
||||||
</div>
|
</div>
|
||||||
<a routerLink="/policy/exceptions" class="btn btn--secondary">Back to Exceptions</a>
|
<a routerLink="/ops/policy/vex/exceptions" class="btn btn--secondary">Back to Exceptions</a>
|
||||||
</header>
|
</header>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<p>Exception detail data will appear here once loaded.</p>
|
<p>Exception detail data will appear here once loaded.</p>
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export class ExceptionsPageComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
requestException(): void {
|
requestException(): void {
|
||||||
void this.router.navigate(['/policy/exceptions'], {
|
void this.router.navigate(['/ops/policy/vex/exceptions'], {
|
||||||
queryParams: { create: '1' },
|
queryParams: { create: '1' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h3>VEX Coverage</h3>
|
<h3>VEX Coverage</h3>
|
||||||
<a routerLink="/security/vex" class="panel-link">Manage VEX →</a>
|
<a routerLink="/ops/policy/vex" class="panel-link">Manage VEX →</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="vex-stats">
|
<div class="vex-stats">
|
||||||
<div class="vex-stat">
|
<div class="vex-stat">
|
||||||
@@ -126,7 +126,7 @@ import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h3>Active Exceptions</h3>
|
<h3>Active Exceptions</h3>
|
||||||
<a routerLink="/security/exceptions" class="panel-link">Manage →</a>
|
<a routerLink="/ops/policy/vex/exceptions" class="panel-link">Manage →</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="exceptions-list">
|
<div class="exceptions-list">
|
||||||
@for (exception of activeExceptions; track exception.id) {
|
@for (exception of activeExceptions; track exception.id) {
|
||||||
|
|||||||
@@ -363,8 +363,8 @@ export class VulnerabilityDetailPageComponent {
|
|||||||
|
|
||||||
openVex(): void {
|
openVex(): void {
|
||||||
const id = this.detail()?.cveId ?? this.vulnerabilityId();
|
const id = this.detail()?.cveId ?? this.vulnerabilityId();
|
||||||
void this.router.navigate(['/security/vex'], {
|
void this.router.navigate(['/ops/policy/vex/search'], {
|
||||||
queryParams: { cve: id },
|
queryParams: { cveId: id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,25 +20,25 @@ import { RouterLink } from '@angular/router';
|
|||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2>Policy Baselines</h2>
|
<h2>Policy Baselines</h2>
|
||||||
<p>Manage policy baselines for different environments.</p>
|
<p>Manage policy baselines for different environments.</p>
|
||||||
<a routerLink="/policy/packs" class="btn btn--primary">+ Create Baseline</a>
|
<a routerLink="/ops/policy/packs" class="btn btn--primary">+ Create Baseline</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2>Governance Rules</h2>
|
<h2>Governance Rules</h2>
|
||||||
<p>Define organizational governance rules for releases.</p>
|
<p>Define organizational governance rules for releases.</p>
|
||||||
<a routerLink="/policy/governance" class="btn btn--secondary">Edit Rules</a>
|
<a routerLink="/ops/policy/governance" class="btn btn--secondary">Edit Rules</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2>Policy Simulation</h2>
|
<h2>Policy Simulation</h2>
|
||||||
<p>Test policy changes before applying them.</p>
|
<p>Test policy changes before applying them.</p>
|
||||||
<a routerLink="/policy/simulation" class="btn btn--secondary">Run Simulation</a>
|
<a routerLink="/ops/policy/simulation" class="btn btn--secondary">Run Simulation</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h2>Exception Workflow</h2>
|
<h2>Exception Workflow</h2>
|
||||||
<p>Configure how policy exceptions are requested and approved.</p>
|
<p>Configure how policy exceptions are requested and approved.</p>
|
||||||
<a routerLink="/policy/exceptions" class="btn btn--secondary">Configure Workflow</a>
|
<a routerLink="/ops/policy/vex/exceptions" class="btn btn--secondary">Configure Workflow</a>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export class EvidenceLinksComponent {
|
|||||||
type: 'VEX',
|
type: 'VEX',
|
||||||
id: String(record['vexId'] ?? record['vex_id'] ?? record['vexDigest']),
|
id: String(record['vexId'] ?? record['vex_id'] ?? record['vexDigest']),
|
||||||
icon: 'verified_user',
|
icon: 'verified_user',
|
||||||
route: '/vex-hub',
|
route: '/ops/policy/vex/search/detail',
|
||||||
color: 'var(--color-status-success)',
|
color: 'var(--color-status-success)',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -87,7 +87,7 @@ export class EvidenceLinksComponent {
|
|||||||
type: 'Policy',
|
type: 'Policy',
|
||||||
id: String(record['policyId'] ?? record['policy_id'] ?? record['policyDigest']),
|
id: String(record['policyId'] ?? record['policy_id'] ?? record['policyDigest']),
|
||||||
icon: 'policy',
|
icon: 'policy',
|
||||||
route: '/policy',
|
route: '/ops/policy/packs',
|
||||||
color: 'var(--color-status-warning)',
|
color: 'var(--color-status-warning)',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -992,7 +992,8 @@ export class VexConflictResolutionComponent implements OnChanges {
|
|||||||
|
|
||||||
async resolve(): Promise<void> {
|
async resolve(): Promise<void> {
|
||||||
const selected = this.selectedStatement();
|
const selected = this.selectedStatement();
|
||||||
if (!selected || !this.resolutionType) return;
|
const selectedStatementId = selected?.statementId?.trim();
|
||||||
|
if (!selectedStatementId || !this.resolutionType) return;
|
||||||
|
|
||||||
this.resolving.set(true);
|
this.resolving.set(true);
|
||||||
|
|
||||||
@@ -1000,14 +1001,14 @@ export class VexConflictResolutionComponent implements OnChanges {
|
|||||||
await firstValueFrom(
|
await firstValueFrom(
|
||||||
this.vexHubApi.resolveConflict({
|
this.vexHubApi.resolveConflict({
|
||||||
cveId: this.cveId(),
|
cveId: this.cveId(),
|
||||||
selectedStatementId: selected.statementId,
|
selectedStatementId,
|
||||||
resolutionType: this.resolutionType,
|
resolutionType: this.resolutionType,
|
||||||
notes: this.resolutionNotes,
|
notes: this.resolutionNotes,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this.resolved.emit({
|
this.resolved.emit({
|
||||||
selectedStatementId: selected.statementId,
|
selectedStatementId,
|
||||||
resolutionType: this.resolutionType,
|
resolutionType: this.resolutionType,
|
||||||
notes: this.resolutionNotes,
|
notes: this.resolutionNotes,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -693,7 +693,14 @@ export class VexStatementSearchComponent implements OnInit {
|
|||||||
|
|
||||||
async ngOnInit(): Promise<void> {
|
async ngOnInit(): Promise<void> {
|
||||||
// Check for initial status from route or input
|
// Check for initial status from route or input
|
||||||
|
const cveIdParam = this.route.snapshot.queryParamMap.get('cveId');
|
||||||
|
const qParam = this.route.snapshot.queryParamMap.get('q');
|
||||||
const statusParam = this.route.snapshot.queryParamMap.get('status');
|
const statusParam = this.route.snapshot.queryParamMap.get('status');
|
||||||
|
if (cveIdParam) {
|
||||||
|
this.cveFilter = cveIdParam;
|
||||||
|
} else if (qParam) {
|
||||||
|
this.cveFilter = qParam;
|
||||||
|
}
|
||||||
if (statusParam) {
|
if (statusParam) {
|
||||||
this.statusFilter = statusParam;
|
this.statusFilter = statusParam;
|
||||||
} else if (this.initialStatus()) {
|
} else if (this.initialStatus()) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { PolicyPackStore } from '../../features/policy-studio/services/policy-pa
|
|||||||
template: `
|
template: `
|
||||||
<a
|
<a
|
||||||
class="chip"
|
class="chip"
|
||||||
routerLink="/administration/policy-governance"
|
routerLink="/ops/policy/packs"
|
||||||
[attr.title]="tooltip()"
|
[attr.title]="tooltip()"
|
||||||
>
|
>
|
||||||
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
|
||||||
@@ -91,4 +91,3 @@ export class PolicyBaselineChipComponent {
|
|||||||
return `Active policy baseline: ${activePack.name} ${activePack.version}. Click to manage policies.`;
|
return `Active policy baseline: ${activePack.name} ${activePack.version}. Click to manage policies.`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const SEARCH_ACTION_ROUTE_MATRIX: ReadonlyArray<SearchRouteMatrixEntry> =
|
|||||||
{
|
{
|
||||||
domain: 'vex',
|
domain: 'vex',
|
||||||
sourceRoute: '/vex-hub/CVE-2024-21626',
|
sourceRoute: '/vex-hub/CVE-2024-21626',
|
||||||
expectedRoute: '/security/advisories-vex?q=CVE-2024-21626',
|
expectedRoute: '/ops/policy/vex/search?cveId=CVE-2024-21626',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
domain: 'platform',
|
domain: 'platform',
|
||||||
@@ -65,8 +65,8 @@ export function normalizeSearchActionRoute(route: string): string {
|
|||||||
parsedUrl.pathname = `/security/findings/${pathname.substring('/triage/findings/'.length)}`;
|
parsedUrl.pathname = `/security/findings/${pathname.substring('/triage/findings/'.length)}`;
|
||||||
} else if (pathname.startsWith('/vex-hub/')) {
|
} else if (pathname.startsWith('/vex-hub/')) {
|
||||||
const lookup = decodeURIComponent(pathname.substring('/vex-hub/'.length));
|
const lookup = decodeURIComponent(pathname.substring('/vex-hub/'.length));
|
||||||
parsedUrl.pathname = '/security/advisories-vex';
|
parsedUrl.pathname = '/ops/policy/vex/search';
|
||||||
parsedUrl.search = lookup ? `?q=${encodeURIComponent(lookup)}` : '';
|
parsedUrl.search = lookup ? `?cveId=${encodeURIComponent(lookup)}` : '';
|
||||||
} else if (pathname.startsWith('/proof-chain/')) {
|
} else if (pathname.startsWith('/proof-chain/')) {
|
||||||
const digest = decodeURIComponent(pathname.substring('/proof-chain/'.length));
|
const digest = decodeURIComponent(pathname.substring('/proof-chain/'.length));
|
||||||
parsedUrl.pathname = '/evidence/proofs';
|
parsedUrl.pathname = '/evidence/proofs';
|
||||||
|
|||||||
@@ -17,7 +17,32 @@
|
|||||||
* until SPRINT_20260218_016 cutover; this file owns the /administration/* canonical paths.
|
* until SPRINT_20260218_016 cutover; this file owns the /administration/* canonical paths.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Routes } from '@angular/router';
|
import { inject } from '@angular/core';
|
||||||
|
import { Router, Routes } from '@angular/router';
|
||||||
|
|
||||||
|
function redirectToDecisioning(path: string) {
|
||||||
|
return ({
|
||||||
|
params,
|
||||||
|
queryParams,
|
||||||
|
fragment,
|
||||||
|
}: {
|
||||||
|
params: Record<string, string>;
|
||||||
|
queryParams: Record<string, string>;
|
||||||
|
fragment?: string | null;
|
||||||
|
}) => {
|
||||||
|
const router = inject(Router);
|
||||||
|
let targetPath = path;
|
||||||
|
|
||||||
|
for (const [name, value] of Object.entries(params ?? {})) {
|
||||||
|
targetPath = targetPath.replaceAll(`:${name}`, encodeURIComponent(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = router.parseUrl(targetPath);
|
||||||
|
target.queryParams = { ...queryParams };
|
||||||
|
target.fragment = fragment ?? null;
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const ADMINISTRATION_ROUTES: Routes = [
|
export const ADMINISTRATION_ROUTES: Routes = [
|
||||||
// A0 — Administration overview
|
// A0 — Administration overview
|
||||||
@@ -117,73 +142,106 @@ export const ADMINISTRATION_ROUTES: Routes = [
|
|||||||
path: 'policy-governance',
|
path: 'policy-governance',
|
||||||
title: 'Policy Governance',
|
title: 'Policy Governance',
|
||||||
data: { breadcrumb: 'Policy Governance' },
|
data: { breadcrumb: 'Policy Governance' },
|
||||||
loadChildren: () =>
|
redirectTo: redirectToDecisioning('/ops/policy/governance'),
|
||||||
import('../features/policy-governance/policy-governance.routes').then(
|
pathMatch: 'full',
|
||||||
(m) => m.policyGovernanceRoutes
|
},
|
||||||
),
|
{
|
||||||
|
path: 'policy-governance/exceptions',
|
||||||
|
title: 'Exceptions',
|
||||||
|
data: { breadcrumb: 'Exceptions' },
|
||||||
|
redirectTo: redirectToDecisioning('/ops/policy/vex/exceptions'),
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy-governance/exceptions/:id',
|
||||||
|
title: 'Exception Detail',
|
||||||
|
data: { breadcrumb: 'Exception Detail' },
|
||||||
|
redirectTo: redirectToDecisioning('/ops/policy/vex/exceptions/:id'),
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy-governance/:page',
|
||||||
|
title: 'Policy Governance',
|
||||||
|
data: { breadcrumb: 'Policy Governance' },
|
||||||
|
redirectTo: redirectToDecisioning('/ops/policy/governance/:page'),
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy-governance/:page/:child',
|
||||||
|
title: 'Policy Governance',
|
||||||
|
data: { breadcrumb: 'Policy Governance' },
|
||||||
|
redirectTo: redirectToDecisioning('/ops/policy/governance/:page/:child'),
|
||||||
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'policy',
|
path: 'policy',
|
||||||
title: 'Policy Governance',
|
title: 'Policy Governance',
|
||||||
data: { breadcrumb: 'Policy Governance' },
|
data: { breadcrumb: 'Policy Governance' },
|
||||||
loadComponent: () =>
|
redirectTo: redirectToDecisioning('/ops/policy/governance'),
|
||||||
import('../features/settings/policy/policy-governance-settings-page.component').then(
|
pathMatch: 'full',
|
||||||
(m) => m.PolicyGovernanceSettingsPageComponent
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'policy/packs',
|
path: 'policy/packs',
|
||||||
title: 'Policy Packs',
|
title: 'Policy Packs',
|
||||||
data: { breadcrumb: 'Policy Packs' },
|
data: { breadcrumb: 'Policy Packs' },
|
||||||
loadComponent: () =>
|
redirectTo: redirectToDecisioning('/ops/policy/packs'),
|
||||||
import('../features/policy-studio/workspace/policy-workspace.component').then(
|
pathMatch: 'full',
|
||||||
(m) => m.PolicyWorkspaceComponent
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'policy/exceptions',
|
path: 'policy/exceptions',
|
||||||
title: 'Exceptions',
|
title: 'Exceptions',
|
||||||
data: { breadcrumb: 'Exceptions' },
|
data: { breadcrumb: 'Exceptions' },
|
||||||
loadComponent: () =>
|
redirectTo: redirectToDecisioning('/ops/policy/vex/exceptions'),
|
||||||
import('../features/triage/triage-artifacts.component').then(
|
pathMatch: 'full',
|
||||||
(m) => m.TriageArtifactsComponent
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'policy/exceptions/:id',
|
path: 'policy/exceptions/:id',
|
||||||
title: 'Exception Detail',
|
title: 'Exception Detail',
|
||||||
data: { breadcrumb: 'Exception Detail' },
|
data: { breadcrumb: 'Exception Detail' },
|
||||||
loadComponent: () =>
|
redirectTo: redirectToDecisioning('/ops/policy/vex/exceptions/:id'),
|
||||||
import('../features/triage/triage-workspace.component').then(
|
pathMatch: 'full',
|
||||||
(m) => m.TriageWorkspaceComponent
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'policy/packs/:packId',
|
path: 'policy/packs/:packId',
|
||||||
title: 'Policy Pack',
|
title: 'Policy Pack',
|
||||||
data: { breadcrumb: 'Policy Pack' },
|
data: { breadcrumb: 'Policy Pack' },
|
||||||
loadComponent: () =>
|
redirectTo: redirectToDecisioning('/ops/policy/packs/:packId'),
|
||||||
import('../features/policy-studio/workspace/policy-workspace.component').then(
|
pathMatch: 'full',
|
||||||
(m) => m.PolicyWorkspaceComponent
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'policy/packs/:packId/:page',
|
path: 'policy/packs/:packId/:page',
|
||||||
title: 'Policy Pack',
|
title: 'Policy Pack',
|
||||||
data: { breadcrumb: 'Policy Pack' },
|
data: { breadcrumb: 'Policy Pack' },
|
||||||
loadComponent: () =>
|
redirectTo: redirectToDecisioning('/ops/policy/packs/:packId/:page'),
|
||||||
import('../features/policy-studio/workspace/policy-workspace.component').then(
|
pathMatch: 'full',
|
||||||
(m) => m.PolicyWorkspaceComponent
|
},
|
||||||
),
|
{
|
||||||
|
path: 'policy/packs/:packId/explain/:runId',
|
||||||
|
title: 'Policy Explain',
|
||||||
|
data: { breadcrumb: 'Policy Explain' },
|
||||||
|
redirectTo: redirectToDecisioning('/ops/policy/packs/:packId/explain/:runId'),
|
||||||
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'policy/governance',
|
path: 'policy/governance',
|
||||||
title: 'Policy Governance',
|
title: 'Policy Governance',
|
||||||
data: { breadcrumb: 'Policy Governance' },
|
data: { breadcrumb: 'Policy Governance' },
|
||||||
loadChildren: () =>
|
redirectTo: redirectToDecisioning('/ops/policy/governance'),
|
||||||
import('../features/policy-governance/policy-governance.routes').then(
|
pathMatch: 'full',
|
||||||
(m) => m.policyGovernanceRoutes
|
},
|
||||||
),
|
{
|
||||||
|
path: 'policy/governance/:page',
|
||||||
|
title: 'Policy Governance',
|
||||||
|
data: { breadcrumb: 'Policy Governance' },
|
||||||
|
redirectTo: redirectToDecisioning('/ops/policy/governance/:page'),
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy/governance/:page/:child',
|
||||||
|
title: 'Policy Governance',
|
||||||
|
data: { breadcrumb: 'Policy Governance' },
|
||||||
|
redirectTo: redirectToDecisioning('/ops/policy/governance/:page/:child'),
|
||||||
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
|
|
||||||
// A6 — Trust & Signing
|
// A6 — Trust & Signing
|
||||||
|
|||||||
@@ -8,6 +8,201 @@ export interface LegacyRedirectRouteTemplate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectRouteTemplate[] = [
|
export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectRouteTemplate[] = [
|
||||||
|
{
|
||||||
|
path: 'policy-studio',
|
||||||
|
redirectTo: '/ops/policy/packs',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy-studio/packs',
|
||||||
|
redirectTo: '/ops/policy/packs',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy-studio/packs/:packId',
|
||||||
|
redirectTo: '/ops/policy/packs/:packId',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy-studio/packs/:packId/dashboard',
|
||||||
|
redirectTo: '/ops/policy/packs/:packId',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy-studio/packs/:packId/editor',
|
||||||
|
redirectTo: '/ops/policy/packs/:packId/edit',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy-studio/packs/:packId/edit',
|
||||||
|
redirectTo: '/ops/policy/packs/:packId/edit',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy-studio/packs/:packId/rules',
|
||||||
|
redirectTo: '/ops/policy/packs/:packId/rules',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy-studio/packs/:packId/yaml',
|
||||||
|
redirectTo: '/ops/policy/packs/:packId/yaml',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy-studio/packs/:packId/approvals',
|
||||||
|
redirectTo: '/ops/policy/packs/:packId/approvals',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy-studio/packs/:packId/simulate',
|
||||||
|
redirectTo: '/ops/policy/packs/:packId/simulate',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy-studio/packs/:packId/explain/:runId',
|
||||||
|
redirectTo: '/ops/policy/packs/:packId/explain/:runId',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy-studio/dashboard',
|
||||||
|
redirectTo: '/ops/policy/overview',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy-studio/simulate',
|
||||||
|
redirectTo: '/ops/policy/simulation',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy-studio/approvals',
|
||||||
|
redirectTo: '/ops/policy/packs',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy',
|
||||||
|
redirectTo: '/ops/policy/governance',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy/packs',
|
||||||
|
redirectTo: '/ops/policy/packs',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy/packs/:packId',
|
||||||
|
redirectTo: '/ops/policy/packs/:packId',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy/governance',
|
||||||
|
redirectTo: '/ops/policy/governance',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy/governance/:page',
|
||||||
|
redirectTo: '/ops/policy/governance/:page',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy/governance/:page/:child',
|
||||||
|
redirectTo: '/ops/policy/governance/:page/:child',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy/baselines',
|
||||||
|
redirectTo: '/ops/policy/overview',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy/gates',
|
||||||
|
redirectTo: '/ops/policy/gates/catalog',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy/gates/simulate/:promotionId',
|
||||||
|
redirectTo: '/ops/policy/gates/simulate/:promotionId',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy/simulation',
|
||||||
|
redirectTo: '/ops/policy/simulation',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy/simulation/:page',
|
||||||
|
redirectTo: '/ops/policy/simulation/:page',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy/simulation/diff/:policyPackId',
|
||||||
|
redirectTo: '/ops/policy/simulation/diff/:policyPackId',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy/waivers',
|
||||||
|
redirectTo: '/ops/policy/vex/exceptions',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy/exceptions',
|
||||||
|
redirectTo: '/ops/policy/vex/exceptions',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'policy/exceptions/:id',
|
||||||
|
redirectTo: '/ops/policy/vex/exceptions/:id',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'vex-hub',
|
||||||
|
redirectTo: '/ops/policy/vex',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'vex-hub/:page',
|
||||||
|
redirectTo: '/ops/policy/vex/:page',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'vex-hub/search/detail/:id',
|
||||||
|
redirectTo: '/ops/policy/vex/search/detail/:id',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/vex-hub',
|
||||||
|
redirectTo: '/ops/policy/vex',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/vex-hub/:page',
|
||||||
|
redirectTo: '/ops/policy/vex/:page',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/policy/governance',
|
||||||
|
redirectTo: '/ops/policy/governance',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/policy/governance/:page',
|
||||||
|
redirectTo: '/ops/policy/governance/:page',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/policy/governance/:page/:child',
|
||||||
|
redirectTo: '/ops/policy/governance/:page/:child',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/policy/simulation',
|
||||||
|
redirectTo: '/ops/policy/simulation',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin/policy/simulation/:page',
|
||||||
|
redirectTo: '/ops/policy/simulation/:page',
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'ops/health',
|
path: 'ops/health',
|
||||||
redirectTo: '/ops/operations/health-slo',
|
redirectTo: '/ops/operations/health-slo',
|
||||||
|
|||||||
@@ -23,46 +23,13 @@ export const OPS_ROUTES: Routes = [
|
|||||||
import('../features/integration-hub/integration-hub.routes').then((m) => m.integrationHubRoutes),
|
import('../features/integration-hub/integration-hub.routes').then((m) => m.integrationHubRoutes),
|
||||||
},
|
},
|
||||||
// Standalone policy views (outside governance tabs, must be listed before 'policy' catch-all)
|
// Standalone policy views (outside governance tabs, must be listed before 'policy' catch-all)
|
||||||
{
|
|
||||||
path: 'policy/baselines',
|
|
||||||
title: 'Baselines',
|
|
||||||
data: { breadcrumb: 'Baselines' },
|
|
||||||
loadComponent: () =>
|
|
||||||
import('../features/policy/policy-studio.component').then((m) => m.PolicyStudioComponent),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'policy/gates',
|
|
||||||
title: 'Gate Catalog',
|
|
||||||
data: { breadcrumb: 'Gate Catalog' },
|
|
||||||
loadChildren: () =>
|
|
||||||
import('../features/policy-gates/policy-gates.routes').then((m) => m.POLICY_GATES_ROUTES),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'policy/simulation',
|
|
||||||
title: 'Simulation',
|
|
||||||
data: { breadcrumb: 'Simulation' },
|
|
||||||
loadChildren: () =>
|
|
||||||
import('../features/policy-simulation/policy-simulation.routes').then(
|
|
||||||
(m) => m.policySimulationRoutes,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'policy/waivers',
|
|
||||||
title: 'Waivers / Exceptions',
|
|
||||||
data: { breadcrumb: 'Waivers' },
|
|
||||||
loadComponent: () =>
|
|
||||||
import('../features/security/exceptions-page.component').then(
|
|
||||||
(m) => m.ExceptionsPageComponent,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
// Policy Governance tabbed layout (catches /ops/policy and /ops/policy/<tab>)
|
|
||||||
{
|
{
|
||||||
path: 'policy',
|
path: 'policy',
|
||||||
title: 'Policy',
|
title: 'Policy Decisioning',
|
||||||
data: { breadcrumb: 'Policy' },
|
data: { breadcrumb: 'Policy' },
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('../features/policy-governance/policy-governance.routes').then(
|
import('../features/policy-decisioning/policy-decisioning.routes').then(
|
||||||
(m) => m.policyGovernanceRoutes,
|
(m) => m.policyDecisioningRoutes,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,30 @@ function redirectToTriageWorkspace(path: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function redirectToDecisioning(path: string) {
|
||||||
|
return ({
|
||||||
|
params,
|
||||||
|
queryParams,
|
||||||
|
fragment,
|
||||||
|
}: {
|
||||||
|
params: Record<string, string>;
|
||||||
|
queryParams: Record<string, string>;
|
||||||
|
fragment?: string | null;
|
||||||
|
}) => {
|
||||||
|
const router = inject(Router);
|
||||||
|
let targetPath = path;
|
||||||
|
|
||||||
|
for (const [name, value] of Object.entries(params ?? {})) {
|
||||||
|
targetPath = targetPath.replaceAll(`:${name}`, encodeURIComponent(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = router.parseUrl(targetPath);
|
||||||
|
target.queryParams = { ...queryParams };
|
||||||
|
target.fragment = fragment ?? null;
|
||||||
|
return target;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const SECURITY_RISK_ROUTES: Routes = [
|
export const SECURITY_RISK_ROUTES: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
@@ -170,40 +194,47 @@ export const SECURITY_RISK_ROUTES: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('../features/analytics/sbom-lake-page.component').then((m) => m.SbomLakePageComponent),
|
import('../features/analytics/sbom-lake-page.component').then((m) => m.SbomLakePageComponent),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'vex/search/detail/:id',
|
||||||
|
title: 'VEX Hub',
|
||||||
|
data: { breadcrumb: 'VEX Hub' },
|
||||||
|
redirectTo: redirectToDecisioning('/ops/policy/vex/search/detail/:id'),
|
||||||
|
pathMatch: 'full',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'vex',
|
path: 'vex',
|
||||||
title: 'VEX Hub',
|
title: 'VEX Hub',
|
||||||
data: { breadcrumb: 'VEX Hub' },
|
data: { breadcrumb: 'VEX Hub' },
|
||||||
loadChildren: () => import('../features/vex-hub/vex-hub.routes').then((m) => m.vexHubRoutes),
|
redirectTo: redirectToDecisioning('/ops/policy/vex'),
|
||||||
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'vex/:page',
|
path: 'vex/:page',
|
||||||
title: 'VEX Hub',
|
title: 'VEX Hub',
|
||||||
data: { breadcrumb: 'VEX Hub' },
|
data: { breadcrumb: 'VEX Hub' },
|
||||||
loadChildren: () => import('../features/vex-hub/vex-hub.routes').then((m) => m.vexHubRoutes),
|
redirectTo: redirectToDecisioning('/ops/policy/vex/:page'),
|
||||||
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'exceptions',
|
path: 'exceptions',
|
||||||
title: 'Exceptions',
|
title: 'Exceptions',
|
||||||
data: { breadcrumb: 'Exceptions' },
|
data: { breadcrumb: 'Exceptions' },
|
||||||
loadComponent: () =>
|
redirectTo: redirectToDecisioning('/ops/policy/vex/exceptions'),
|
||||||
import('../features/exceptions/exception-dashboard.component').then((m) => m.ExceptionDashboardComponent),
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'exceptions/approvals',
|
path: 'exceptions/approvals',
|
||||||
title: 'Exception Approvals',
|
title: 'Exception Approvals',
|
||||||
data: { breadcrumb: 'Exception Approvals' },
|
data: { breadcrumb: 'Exception Approvals' },
|
||||||
loadComponent: () =>
|
redirectTo: redirectToDecisioning('/ops/policy/vex/exceptions/approvals'),
|
||||||
import('../features/exceptions/exception-approval-queue.component').then(
|
pathMatch: 'full',
|
||||||
(m) => m.ExceptionApprovalQueueComponent
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'exceptions/:exceptionId',
|
path: 'exceptions/:exceptionId',
|
||||||
title: 'Exception Detail',
|
title: 'Exception Detail',
|
||||||
data: { breadcrumb: 'Exception Detail' },
|
data: { breadcrumb: 'Exception Detail' },
|
||||||
loadComponent: () =>
|
redirectTo: redirectToDecisioning('/ops/policy/vex/exceptions/:exceptionId'),
|
||||||
import('../features/exceptions/exception-dashboard.component').then((m) => m.ExceptionDashboardComponent),
|
pathMatch: 'full',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'lineage',
|
path: 'lineage',
|
||||||
|
|||||||
@@ -54,10 +54,10 @@ describe('ADMINISTRATION_ROUTES (administration)', () => {
|
|||||||
expect(route?.data?.['breadcrumb']).toBe('Tenant & Branding');
|
expect(route?.data?.['breadcrumb']).toBe('Tenant & Branding');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('policy-governance route is under Administration (has loadChildren)', () => {
|
it('policy-governance route is preserved as an Administration alias into decisioning', () => {
|
||||||
const route = ADMINISTRATION_ROUTES.find((r) => r.path === 'policy-governance');
|
const route = ADMINISTRATION_ROUTES.find((r) => r.path === 'policy-governance');
|
||||||
expect(route).toBeDefined();
|
expect(route).toBeDefined();
|
||||||
expect(route?.loadChildren).toBeTruthy();
|
expect(typeof route?.redirectTo).toBe('function');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('policy-governance breadcrumb is canonical (no Release Control ownership)', () => {
|
it('policy-governance breadcrumb is canonical (no Release Control ownership)', () => {
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ describe('normalizeSearchActionRoute', () => {
|
|||||||
expect(normalizeSearchActionRoute('/triage/findings/abc-123')).toBe('/security/findings/abc-123');
|
expect(normalizeSearchActionRoute('/triage/findings/abc-123')).toBe('/security/findings/abc-123');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps vex hub routes into advisories page query', () => {
|
it('maps vex hub routes into decisioning search context', () => {
|
||||||
expect(normalizeSearchActionRoute('/vex-hub/CVE-2024-21626')).toBe('/security/advisories-vex?q=CVE-2024-21626');
|
expect(normalizeSearchActionRoute('/vex-hub/CVE-2024-21626')).toBe('/ops/policy/vex/search?cveId=CVE-2024-21626');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('maps proof-chain routes into evidence proofs query', () => {
|
it('maps proof-chain routes into evidence proofs query', () => {
|
||||||
|
|||||||
@@ -19,6 +19,18 @@ describe('Legacy redirect policy', () => {
|
|||||||
path: 'triage/findings/:findingId',
|
path: 'triage/findings/:findingId',
|
||||||
redirectTo: '/security/findings/:findingId',
|
redirectTo: '/security/findings/:findingId',
|
||||||
}),
|
}),
|
||||||
|
jasmine.objectContaining({
|
||||||
|
path: 'policy-studio/dashboard',
|
||||||
|
redirectTo: '/ops/policy/overview',
|
||||||
|
}),
|
||||||
|
jasmine.objectContaining({
|
||||||
|
path: 'policy/packs',
|
||||||
|
redirectTo: '/ops/policy/packs',
|
||||||
|
}),
|
||||||
|
jasmine.objectContaining({
|
||||||
|
path: 'admin/policy/governance',
|
||||||
|
redirectTo: '/ops/policy/governance',
|
||||||
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { policyDecisioningRoutes } from '../../app/features/policy-decisioning/policy-decisioning.routes';
|
||||||
|
|
||||||
|
describe('policyDecisioningRoutes', () => {
|
||||||
|
const root = policyDecisioningRoutes[0];
|
||||||
|
const children = root.children ?? [];
|
||||||
|
|
||||||
|
it('publishes the canonical primary tabs under /ops/policy', () => {
|
||||||
|
expect(root.path).toBe('');
|
||||||
|
expect(children.map((route) => route.path)).toEqual(
|
||||||
|
jasmine.arrayContaining([
|
||||||
|
'overview',
|
||||||
|
'packs',
|
||||||
|
'governance',
|
||||||
|
'simulation',
|
||||||
|
'vex',
|
||||||
|
'gates',
|
||||||
|
'audit',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps pack authoring subviews inside the packs shell', () => {
|
||||||
|
const packsRoute = children.find((route) => route.path === 'packs');
|
||||||
|
const packPaths = packsRoute?.children?.map((route) => route.path) ?? [];
|
||||||
|
|
||||||
|
expect(packPaths).toEqual(
|
||||||
|
jasmine.arrayContaining([
|
||||||
|
'',
|
||||||
|
':packId',
|
||||||
|
':packId/dashboard',
|
||||||
|
':packId/edit',
|
||||||
|
':packId/editor',
|
||||||
|
':packId/rules',
|
||||||
|
':packId/yaml',
|
||||||
|
':packId/approvals',
|
||||||
|
':packId/simulate',
|
||||||
|
':packId/explain/:runId',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps mutable VEX, exceptions, gates, and audit under the same tree', () => {
|
||||||
|
const vexRoute = children.find((route) => route.path === 'vex');
|
||||||
|
const auditRoute = children.find((route) => route.path === 'audit');
|
||||||
|
|
||||||
|
expect(vexRoute?.children?.map((route) => route.path)).toEqual(
|
||||||
|
jasmine.arrayContaining([
|
||||||
|
'',
|
||||||
|
'search',
|
||||||
|
'search/detail/:id',
|
||||||
|
'create',
|
||||||
|
'stats',
|
||||||
|
'consensus',
|
||||||
|
'explorer',
|
||||||
|
'conflicts',
|
||||||
|
'exceptions',
|
||||||
|
'exceptions/approvals',
|
||||||
|
'exceptions/:exceptionId',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(children.map((route) => route.path)).toEqual(
|
||||||
|
jasmine.arrayContaining([
|
||||||
|
'gates/catalog',
|
||||||
|
'gates/simulate/:promotionId',
|
||||||
|
'gates/environments/:environment',
|
||||||
|
'gates/releases/:releaseId',
|
||||||
|
'gates/approvals/:approvalId',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(auditRoute?.children?.map((route) => route.path)).toEqual(
|
||||||
|
jasmine.arrayContaining(['', 'policy', 'vex', 'log', 'log/events']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, provideRouter, Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { PolicyDecisioningShellComponent } from '../../app/features/policy-decisioning/policy-decisioning-shell.component';
|
||||||
|
|
||||||
|
describe('PolicyDecisioningShellComponent', () => {
|
||||||
|
let router: Router;
|
||||||
|
|
||||||
|
let currentUrl = '/ops/policy/overview';
|
||||||
|
let queryParams: Record<string, string> = {};
|
||||||
|
let childParams: Array<Record<string, string>> = [];
|
||||||
|
|
||||||
|
const routeStub = {
|
||||||
|
get snapshot() {
|
||||||
|
return {
|
||||||
|
root: buildSnapshot(queryParams, childParams),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [PolicyDecisioningShellComponent],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: routeStub,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
router = TestBed.inject(Router);
|
||||||
|
Object.defineProperty(router, 'url', {
|
||||||
|
configurable: true,
|
||||||
|
get: () => currentUrl,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createShell(
|
||||||
|
url: string,
|
||||||
|
nextQueryParams: Record<string, string> = {},
|
||||||
|
nextChildParams: Array<Record<string, string>> = [],
|
||||||
|
): ComponentFixture<PolicyDecisioningShellComponent> {
|
||||||
|
currentUrl = url;
|
||||||
|
queryParams = nextQueryParams;
|
||||||
|
childParams = nextChildParams;
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(PolicyDecisioningShellComponent);
|
||||||
|
fixture.detectChanges();
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('renders the canonical shell tabs in global mode', () => {
|
||||||
|
const fixture = createShell('/ops/policy/overview');
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
const text = fixture.nativeElement.textContent as string;
|
||||||
|
|
||||||
|
expect(component.headerTitle()).toBe('Policy Decisioning Studio');
|
||||||
|
expect(component.shellState().kind).toBe('global');
|
||||||
|
expect(component.shellState().activeTab).toBe('overview');
|
||||||
|
expect(component.primaryTabs().map((tab) => tab.id)).toEqual([
|
||||||
|
'overview',
|
||||||
|
'packs',
|
||||||
|
'governance',
|
||||||
|
'simulation',
|
||||||
|
'vex',
|
||||||
|
'gates',
|
||||||
|
'audit',
|
||||||
|
]);
|
||||||
|
expect(text).toContain('Policy Decisioning Studio');
|
||||||
|
expect(text).toContain('Overview');
|
||||||
|
expect(text).toContain('VEX & Exceptions');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps release context and return navigation inside the shared shell', () => {
|
||||||
|
const fixture = createShell(
|
||||||
|
'/ops/policy/gates/releases/rel-42',
|
||||||
|
{
|
||||||
|
releaseId: 'rel-42',
|
||||||
|
environment: 'prod-eu',
|
||||||
|
artifact: 'sha256:abc123',
|
||||||
|
returnTo: '/releases/rel-42',
|
||||||
|
},
|
||||||
|
[{ releaseId: 'rel-42' }],
|
||||||
|
);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
const navigateByUrlSpy = spyOn(router, 'navigateByUrl').and.returnValue(Promise.resolve(true));
|
||||||
|
|
||||||
|
expect(component.headerTitle()).toBe('Release rel-42 Decisioning');
|
||||||
|
expect(component.shellState().kind).toBe('release');
|
||||||
|
expect(component.shellState().activeTab).toBe('gates');
|
||||||
|
expect(component.headerChips()).toEqual([
|
||||||
|
'Release rel-42',
|
||||||
|
'Env prod-eu',
|
||||||
|
'Artifact sha256:abc123',
|
||||||
|
]);
|
||||||
|
|
||||||
|
component.returnToSource();
|
||||||
|
|
||||||
|
expect(navigateByUrlSpy).toHaveBeenCalledWith('/releases/rel-42');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildSnapshot(
|
||||||
|
rootQueryParams: Record<string, string>,
|
||||||
|
nestedParams: Array<Record<string, string>>,
|
||||||
|
): { params: Record<string, string>; queryParams: Record<string, string>; children: any[] } {
|
||||||
|
return {
|
||||||
|
params: {},
|
||||||
|
queryParams: rootQueryParams,
|
||||||
|
children: nestedParams.map((params) => ({
|
||||||
|
params,
|
||||||
|
children: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router';
|
||||||
|
|
||||||
|
import { EvidenceStore } from '../../app/features/release-orchestrator/evidence/evidence.store';
|
||||||
|
import { EvidenceDetailComponent } from '../../app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component';
|
||||||
|
|
||||||
|
describe('EvidenceDetailComponent (release orchestrator)', () => {
|
||||||
|
let fixture: ComponentFixture<EvidenceDetailComponent>;
|
||||||
|
let component: EvidenceDetailComponent;
|
||||||
|
let router: Router;
|
||||||
|
let loadPacketSpy: jasmine.Spy;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const packet = {
|
||||||
|
id: 'packet-42',
|
||||||
|
deploymentId: 'dep-42',
|
||||||
|
releaseId: 'rel-42',
|
||||||
|
releaseName: 'Checkout Hotfix',
|
||||||
|
releaseVersion: '2.1.0',
|
||||||
|
environmentId: 'prod-eu',
|
||||||
|
environmentName: 'prod-eu',
|
||||||
|
status: 'complete',
|
||||||
|
signatureStatus: 'valid',
|
||||||
|
contentHash: 'sha256:deadbeef',
|
||||||
|
signedAt: null,
|
||||||
|
signedBy: null,
|
||||||
|
createdAt: '2026-03-07T10:00:00Z',
|
||||||
|
size: 1024,
|
||||||
|
contentTypes: ['json'],
|
||||||
|
content: {
|
||||||
|
metadata: {
|
||||||
|
deploymentId: 'dep-42',
|
||||||
|
releaseId: 'rel-42',
|
||||||
|
environmentId: 'prod-eu',
|
||||||
|
startedAt: '2026-03-07T09:55:00Z',
|
||||||
|
completedAt: '2026-03-07T10:00:00Z',
|
||||||
|
initiatedBy: 'casey',
|
||||||
|
outcome: 'success',
|
||||||
|
},
|
||||||
|
release: {
|
||||||
|
name: 'Checkout Hotfix',
|
||||||
|
version: '2.1.0',
|
||||||
|
components: [],
|
||||||
|
},
|
||||||
|
workflow: {
|
||||||
|
id: 'wf-42',
|
||||||
|
name: 'Release Pipeline',
|
||||||
|
version: 4,
|
||||||
|
stepsExecuted: 7,
|
||||||
|
stepsFailed: 0,
|
||||||
|
},
|
||||||
|
targets: [],
|
||||||
|
approvals: [],
|
||||||
|
gateResults: [],
|
||||||
|
artifacts: [
|
||||||
|
{
|
||||||
|
name: 'bundle',
|
||||||
|
type: 'image',
|
||||||
|
digest: 'sha256:feedface',
|
||||||
|
size: 128,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
signature: null,
|
||||||
|
verificationResult: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPacketSpy = jasmine.createSpy('loadPacket');
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [EvidenceDetailComponent],
|
||||||
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: {
|
||||||
|
snapshot: {
|
||||||
|
paramMap: convertToParamMap({ id: 'packet-42' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: EvidenceStore,
|
||||||
|
useValue: {
|
||||||
|
loading: signal(false),
|
||||||
|
verifying: signal(false),
|
||||||
|
selectedPacket: signal(packet),
|
||||||
|
packetContent: signal(packet.content),
|
||||||
|
packetSignature: signal(packet.signature),
|
||||||
|
verificationResult: signal(packet.verificationResult),
|
||||||
|
loadPacket: loadPacketSpy,
|
||||||
|
clearSelection: jasmine.createSpy('clearSelection'),
|
||||||
|
verifyEvidence: jasmine.createSpy('verifyEvidence'),
|
||||||
|
exportEvidence: jasmine.createSpy('exportEvidence'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(EvidenceDetailComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
router = TestBed.inject(Router);
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads the requested packet and renders the decisioning action', () => {
|
||||||
|
const text = fixture.nativeElement.textContent as string;
|
||||||
|
|
||||||
|
expect(loadPacketSpy).toHaveBeenCalledWith('packet-42');
|
||||||
|
expect(text).toContain('Policy Decisioning');
|
||||||
|
expect(text).toContain('Checkout Hotfix 2.1.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deep-links release-context decisioning with evidence context preserved', () => {
|
||||||
|
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||||
|
|
||||||
|
component.openDecisioningStudio();
|
||||||
|
|
||||||
|
expect(navigateSpy).toHaveBeenCalledWith(
|
||||||
|
['/ops/policy/gates/releases', 'rel-42'],
|
||||||
|
jasmine.objectContaining({
|
||||||
|
queryParams: jasmine.objectContaining({
|
||||||
|
releaseId: 'rel-42',
|
||||||
|
environment: 'prod-eu',
|
||||||
|
artifact: 'sha256:feedface',
|
||||||
|
evidenceId: 'packet-42',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryParams = navigateSpy.calls.mostRecent().args[1]?.queryParams;
|
||||||
|
expect(queryParams.returnTo).toContain('/release-orchestrator/evidence/packet-42');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
import { ActivatedRoute, convertToParamMap, provideRouter } from '@angular/router';
|
import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router';
|
||||||
|
|
||||||
import { MockWorkflowClient, WORKFLOW_API } from '../../app/core/api/workflow.client';
|
import { MockWorkflowClient, WORKFLOW_API } from '../../app/core/api/workflow.client';
|
||||||
import { STEP_TYPES } from '../../app/core/api/workflow.models';
|
import { STEP_TYPES } from '../../app/core/api/workflow.models';
|
||||||
import { WorkflowEditorComponent } from '../../app/features/release-orchestrator/workflows/workflow-editor/workflow-editor.component';
|
import { WorkflowEditorComponent } from '../../app/features/release-orchestrator/workflows/workflow-editor/workflow-editor.component';
|
||||||
|
|
||||||
describe('visual-workflow-editor behavior', () => {
|
describe('visual-workflow-editor behavior', () => {
|
||||||
|
let router: Router;
|
||||||
const routeState = {
|
const routeState = {
|
||||||
workflowId: 'wf-001',
|
workflowId: 'wf-001',
|
||||||
query: {} as Record<string, string>,
|
query: {} as Record<string, string>,
|
||||||
@@ -38,6 +39,8 @@ describe('visual-workflow-editor behavior', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
|
|
||||||
|
router = TestBed.inject(Router);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createEditor(query: Record<string, string> = {}): Promise<ComponentFixture<WorkflowEditorComponent>> {
|
async function createEditor(query: Record<string, string> = {}): Promise<ComponentFixture<WorkflowEditorComponent>> {
|
||||||
@@ -143,4 +146,24 @@ describe('visual-workflow-editor behavior', () => {
|
|||||||
expect(updated?.dependencies).toContain('step-7');
|
expect(updated?.dependencies).toContain('step-7');
|
||||||
expect(component.store.validationErrors().join(' ')).not.toContain('Dependency validation');
|
expect(component.store.validationErrors().join(' ')).not.toContain('Dependency validation');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deep-links the shared decisioning shell from workflow context', async () => {
|
||||||
|
const fixture = await createEditor();
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
const navigateSpy = spyOn(router, 'navigate').and.returnValue(Promise.resolve(true));
|
||||||
|
|
||||||
|
component.openDecisioningStudio();
|
||||||
|
|
||||||
|
expect(navigateSpy).toHaveBeenCalledWith(
|
||||||
|
['/ops/policy/gates'],
|
||||||
|
jasmine.objectContaining({
|
||||||
|
queryParams: jasmine.objectContaining({
|
||||||
|
workflowId: 'wf-001',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const queryParams = navigateSpy.calls.mostRecent().args[1]?.queryParams;
|
||||||
|
expect(queryParams.returnTo).toContain('/release-orchestrator/workflows/wf-001');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ describe('Legacy Route Migration Framework (routes)', () => {
|
|||||||
const testRoutes: Routes = [
|
const testRoutes: Routes = [
|
||||||
...LEGACY_REDIRECT_ROUTES,
|
...LEGACY_REDIRECT_ROUTES,
|
||||||
{ path: 'platform/ops/health-slo', component: DummyRouteTargetComponent },
|
{ path: 'platform/ops/health-slo', component: DummyRouteTargetComponent },
|
||||||
|
{ path: 'ops/policy/overview', component: DummyRouteTargetComponent },
|
||||||
|
{ path: 'ops/policy/packs', component: DummyRouteTargetComponent },
|
||||||
|
{ path: 'ops/policy/packs/:packId', component: DummyRouteTargetComponent },
|
||||||
|
{ path: 'ops/policy/governance/:page', component: DummyRouteTargetComponent },
|
||||||
{ path: 'topology/regions', component: DummyRouteTargetComponent },
|
{ path: 'topology/regions', component: DummyRouteTargetComponent },
|
||||||
{ path: '**', component: DummyRouteTargetComponent },
|
{ path: '**', component: DummyRouteTargetComponent },
|
||||||
];
|
];
|
||||||
@@ -83,5 +87,20 @@ describe('Legacy Route Migration Framework (routes)', () => {
|
|||||||
await router.navigateByUrl('/release-orchestrator/environments');
|
await router.navigateByUrl('/release-orchestrator/environments');
|
||||||
expect(router.url).toBe('/topology/regions');
|
expect(router.url).toBe('/topology/regions');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('redirects legacy policy studio bookmarks into the decisioning shell', async () => {
|
||||||
|
await router.navigateByUrl('/policy-studio/dashboard');
|
||||||
|
expect(router.url).toBe('/ops/policy/overview');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects legacy policy pack bookmarks into the canonical packs shell', async () => {
|
||||||
|
await router.navigateByUrl('/policy/packs/pack-001');
|
||||||
|
expect(router.url).toBe('/ops/policy/packs/pack-001');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects admin policy governance aliases into the canonical governance view', async () => {
|
||||||
|
await router.navigateByUrl('/admin/policy/governance/profiles');
|
||||||
|
expect(router.url).toBe('/ops/policy/governance/profiles');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -196,19 +196,20 @@ describe('SECURITY_RISK_ROUTES', () => {
|
|||||||
expect(getRouteByPath('lineage')?.data?.['breadcrumb']).toBe('Lineage');
|
expect(getRouteByPath('lineage')?.data?.['breadcrumb']).toBe('Lineage');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exceptions route loads ExceptionDashboardComponent', async () => {
|
it('vex route is preserved as a redirect into decisioning', () => {
|
||||||
const component = await loadComponentByPath('exceptions');
|
expect(typeof getRouteByPath('vex')?.redirectTo).toBe('function');
|
||||||
expect((component as { name?: string }).name).toContain('ExceptionDashboardComponent');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exceptions detail route loads ExceptionDashboardComponent', async () => {
|
it('exceptions route is preserved as a redirect into decisioning', () => {
|
||||||
const component = await loadComponentByPath('exceptions/:exceptionId');
|
expect(typeof getRouteByPath('exceptions')?.redirectTo).toBe('function');
|
||||||
expect((component as { name?: string }).name).toContain('ExceptionDashboardComponent');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exception approvals route loads ExceptionApprovalQueueComponent', async () => {
|
it('exceptions detail route is preserved as a redirect into decisioning', () => {
|
||||||
const component = await loadComponentByPath('exceptions/approvals');
|
expect(typeof getRouteByPath('exceptions/:exceptionId')?.redirectTo).toBe('function');
|
||||||
expect((component as { name?: string }).name).toContain('ExceptionApprovalQueueComponent');
|
});
|
||||||
|
|
||||||
|
it('exception approvals route is preserved as a redirect into decisioning', () => {
|
||||||
|
expect(typeof getRouteByPath('exceptions/approvals')?.redirectTo).toBe('function');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reachability witness detail route loads WitnessPageComponent', async () => {
|
it('reachability witness detail route loads WitnessPageComponent', async () => {
|
||||||
|
|||||||
@@ -72,4 +72,13 @@ describe('security-overview-dashboard behavior', () => {
|
|||||||
|
|
||||||
expect(navigateSpy).toHaveBeenCalledWith('/ops/scanner');
|
expect(navigateSpy).toHaveBeenCalledWith('/ops/scanner');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('routes VEX and exceptions shortcuts into policy decisioning', () => {
|
||||||
|
const host = fixture.nativeElement as HTMLElement;
|
||||||
|
const vexLink = host.querySelector('a[href="/ops/policy/vex"]');
|
||||||
|
const exceptionsLink = host.querySelector('a[href="/ops/policy/vex/exceptions"]');
|
||||||
|
|
||||||
|
expect(vexLink?.textContent).toContain('Manage VEX');
|
||||||
|
expect(exceptionsLink?.textContent).toContain('Manage');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { expect, test, type Page, type Route } from '@playwright/test';
|
||||||
|
|
||||||
|
import type { StubAuthSession } from '../../src/app/testing/auth-fixtures';
|
||||||
|
|
||||||
|
const adminSession: StubAuthSession = {
|
||||||
|
subjectId: 'policy-e2e-user',
|
||||||
|
tenant: 'tenant-default',
|
||||||
|
scopes: [
|
||||||
|
'admin',
|
||||||
|
'ui.read',
|
||||||
|
'ui.admin',
|
||||||
|
'release:read',
|
||||||
|
'policy:read',
|
||||||
|
'policy:author',
|
||||||
|
'policy:review',
|
||||||
|
'policy:approve',
|
||||||
|
'policy:simulate',
|
||||||
|
'policy:audit',
|
||||||
|
'vex:read',
|
||||||
|
'vex:write',
|
||||||
|
'vex:export',
|
||||||
|
'exception:read',
|
||||||
|
'exception:approve',
|
||||||
|
'findings:read',
|
||||||
|
'vuln:view',
|
||||||
|
'orch:read',
|
||||||
|
'orch:operate',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
authority: {
|
||||||
|
issuer: '/authority',
|
||||||
|
clientId: 'stella-ops-ui',
|
||||||
|
authorizeEndpoint: '/authority/connect/authorize',
|
||||||
|
tokenEndpoint: '/authority/connect/token',
|
||||||
|
logoutEndpoint: '/authority/connect/logout',
|
||||||
|
redirectUri: 'https://127.0.0.1:4400/auth/callback',
|
||||||
|
postLogoutRedirectUri: 'https://127.0.0.1:4400/',
|
||||||
|
scope: 'openid profile email ui.read',
|
||||||
|
audience: '/gateway',
|
||||||
|
dpopAlgorithms: ['ES256'],
|
||||||
|
refreshLeewaySeconds: 60,
|
||||||
|
},
|
||||||
|
apiBaseUrls: {
|
||||||
|
authority: '/authority',
|
||||||
|
scanner: '/scanner',
|
||||||
|
policy: '/policy',
|
||||||
|
concelier: '/concelier',
|
||||||
|
attestor: '/attestor',
|
||||||
|
gateway: '/gateway',
|
||||||
|
},
|
||||||
|
quickstartMode: true,
|
||||||
|
setup: 'complete',
|
||||||
|
};
|
||||||
|
|
||||||
|
const policyPacks = [
|
||||||
|
{
|
||||||
|
id: 'pack-001',
|
||||||
|
name: 'Core Policy Pack',
|
||||||
|
description: 'Default pack for release gating',
|
||||||
|
version: '2026.03.07',
|
||||||
|
status: 'active',
|
||||||
|
createdAt: '2026-03-01T08:00:00Z',
|
||||||
|
modifiedAt: '2026-03-07T08:00:00Z',
|
||||||
|
createdBy: 'ops@example.com',
|
||||||
|
modifiedBy: 'ops@example.com',
|
||||||
|
tags: ['release', 'core'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const packDashboard = {
|
||||||
|
runs: [
|
||||||
|
{
|
||||||
|
runId: 'run-001',
|
||||||
|
policyVersion: '2026.03.07',
|
||||||
|
status: 'completed',
|
||||||
|
completedAt: '2026-03-07T09:00:00Z',
|
||||||
|
findingsCount: 5,
|
||||||
|
changedCount: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ruleHeatmap: [
|
||||||
|
{
|
||||||
|
ruleName: 'reachable-critical',
|
||||||
|
hitCount: 5,
|
||||||
|
averageLatencyMs: 14,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
vexWinsByDay: [{ date: '2026-03-07', value: 2 }],
|
||||||
|
suppressionsByDay: [{ date: '2026-03-07', value: 1 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fulfillJson(route: Route, body: unknown): Promise<void> {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigateClientSide(page: Page, target: string): Promise<void> {
|
||||||
|
await page.evaluate((url) => {
|
||||||
|
window.history.pushState({}, '', url);
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate', { state: window.history.state }));
|
||||||
|
}, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupHarness(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript((session) => {
|
||||||
|
(window as { __stellaopsTestSession?: unknown }).__stellaopsTestSession = session;
|
||||||
|
}, adminSession);
|
||||||
|
|
||||||
|
await page.route('**/platform/envsettings.json', (route) => fulfillJson(route, mockConfig));
|
||||||
|
await page.route('**/config.json', (route) => fulfillJson(route, mockConfig));
|
||||||
|
await page.route('**/.well-known/openid-configuration', (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
issuer: 'https://127.0.0.1:4400/authority',
|
||||||
|
authorization_endpoint: 'https://127.0.0.1:4400/authority/connect/authorize',
|
||||||
|
token_endpoint: 'https://127.0.0.1:4400/authority/connect/token',
|
||||||
|
jwks_uri: 'https://127.0.0.1:4400/authority/.well-known/jwks.json',
|
||||||
|
response_types_supported: ['code'],
|
||||||
|
subject_types_supported: ['public'],
|
||||||
|
id_token_signing_alg_values_supported: ['RS256'],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/authority/.well-known/jwks.json', (route) => fulfillJson(route, { keys: [] }));
|
||||||
|
await page.route('**/console/profile**', (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
subjectId: adminSession.subjectId,
|
||||||
|
username: 'policy-e2e',
|
||||||
|
displayName: 'Policy E2E',
|
||||||
|
tenant: adminSession.tenant,
|
||||||
|
roles: ['admin'],
|
||||||
|
scopes: adminSession.scopes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/console/token/introspect**', (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
active: true,
|
||||||
|
tenant: adminSession.tenant,
|
||||||
|
subject: adminSession.subjectId,
|
||||||
|
scopes: adminSession.scopes,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v2/context/regions', (route) =>
|
||||||
|
fulfillJson(route, [{ regionId: 'eu-west', displayName: 'EU West', sortOrder: 1, enabled: true }]),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v2/context/environments**', (route) =>
|
||||||
|
fulfillJson(route, [
|
||||||
|
{
|
||||||
|
environmentId: 'prod-eu',
|
||||||
|
regionId: 'eu-west',
|
||||||
|
environmentType: 'prod',
|
||||||
|
displayName: 'Prod EU',
|
||||||
|
sortOrder: 1,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
await page.route('**/api/v2/context/preferences', (route) =>
|
||||||
|
fulfillJson(route, {
|
||||||
|
tenantId: adminSession.tenant,
|
||||||
|
actorId: adminSession.subjectId,
|
||||||
|
regions: ['eu-west'],
|
||||||
|
environments: ['prod-eu'],
|
||||||
|
timeWindow: '24h',
|
||||||
|
stage: 'all',
|
||||||
|
updatedAt: '2026-03-07T12:00:00Z',
|
||||||
|
updatedBy: adminSession.subjectId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route('**/doctor/api/v1/doctor/trends**', (route) => fulfillJson(route, []));
|
||||||
|
await page.route('**/api/v1/approvals**', (route) => fulfillJson(route, []));
|
||||||
|
await page.route('**/api/policy/packs?**', (route) => fulfillJson(route, policyPacks));
|
||||||
|
await page.route('**/api/policy/packs', (route) => fulfillJson(route, policyPacks));
|
||||||
|
await page.route('**/api/policy/packs/pack-001/dashboard**', (route) =>
|
||||||
|
fulfillJson(route, packDashboard),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await setupHarness(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the canonical global shell under /ops/policy', async ({ page }) => {
|
||||||
|
await page.goto('/ops/policy/overview', { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
|
await expect(page.getByTestId('policy-decisioning-shell')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('policy-decisioning-overview')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('policy-tab-overview')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('policy-tab-vex')).toBeVisible();
|
||||||
|
await expect(page.getByText('Policy Decisioning Studio')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redirects legacy pack bookmarks into pack-mode decisioning', async ({ page }) => {
|
||||||
|
await page.goto('/ops/policy/overview', { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
|
await navigateClientSide(page, '/policy-studio/packs/pack-001/dashboard');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/ops\/policy\/packs\/pack-001(?:\/dashboard)?$/);
|
||||||
|
await expect(page.getByTestId('policy-pack-shell')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByTestId('policy-pack-shell').getByRole('heading', { name: 'Pack pack-001' }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText('Run dashboards')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps release-context gate review inside the shared shell', async ({ page }) => {
|
||||||
|
await page.goto(
|
||||||
|
'/ops/policy/gates/releases/rel-42?environment=prod-eu&artifact=sha256%3Afeedface&returnTo=%2Freleases%2Frel-42',
|
||||||
|
{ waitUntil: 'networkidle' },
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('policy-decisioning-shell')).toBeVisible();
|
||||||
|
await expect(page.getByTestId('policy-gates-page')).toBeVisible();
|
||||||
|
await expect(page.getByText('Release rel-42 Decisioning')).toBeVisible();
|
||||||
|
await expect(page.getByText('Env prod-eu')).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.locator('app-context-header').getByRole('button', { name: 'Return to source' }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('redirects security VEX aliases into the canonical decisioning shell', async ({ page }) => {
|
||||||
|
await page.goto('/ops/policy/overview', { waitUntil: 'networkidle' });
|
||||||
|
|
||||||
|
await navigateClientSide(page, '/security/vex?cveId=CVE-2024-21626');
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/ops\/policy\/vex\?cveId=CVE-2024-21626$/);
|
||||||
|
await expect(page.getByTestId('policy-vex-shell')).toBeVisible();
|
||||||
|
await expect(page.getByText('Mutable VEX actions now live in Decisioning Studio')).toBeVisible();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user