merge: harden derived shared ui components
57
docs/features/checked/web/settings-ia-rationalization-ui.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Settings IA Rationalization
|
||||
|
||||
## Summary
|
||||
The Settings shell has been rationalized from a mixed bucket of user preferences, admin consoles, setup pages, and redirect shims into a truthful personal-preferences surface. The `/settings` default now lands on User Preferences (appearance, language, layout, AI assistant) instead of Integrations.
|
||||
|
||||
## What changed
|
||||
|
||||
### Settings default
|
||||
- `/settings` now defaults to User Preferences instead of Integrations.
|
||||
|
||||
### Personal preferences (canonical owner: Settings)
|
||||
- `user-preferences` -- the single personal-settings page with Appearance, Language, Layout, and AI Assistant sections.
|
||||
|
||||
### Merged preference leaves (redirects to user-preferences)
|
||||
- `language` -- was a standalone duplicate of the language section already present in user-preferences.
|
||||
- `ai-preferences` -- already redirected to user-preferences (preserved).
|
||||
|
||||
### Admin/tenant leaves rehomed via redirects
|
||||
| Legacy URL | Redirect Target |
|
||||
|---|---|
|
||||
| `/settings/admin` | `/administration/admin` |
|
||||
| `/settings/admin/:page` | `/administration/admin/:page` |
|
||||
| `/settings/branding` | `/console/admin/branding` |
|
||||
| `/settings/identity-providers` | `/administration/identity-providers` |
|
||||
| `/settings/system` | `/administration/system` |
|
||||
| `/settings/security-data` | `/administration/security-data` |
|
||||
| `/settings/offline` | `/administration/offline` |
|
||||
|
||||
### Operations/setup leaves rehomed via redirects
|
||||
| Legacy URL | Redirect Target |
|
||||
|---|---|
|
||||
| `/settings/integrations` | `/setup/integrations` |
|
||||
| `/settings/integrations/:id` | `/setup/integrations/:id` |
|
||||
| `/settings/usage` | `/setup/usage` |
|
||||
| `/settings/notifications` | `/setup/notifications` |
|
||||
| `/settings/policy` | `/ops/policy/governance` |
|
||||
| `/settings/release-control` | `/setup/topology/environments` |
|
||||
| `/settings/configuration-pane` | `/ops/platform-setup` |
|
||||
|
||||
### Trust redirects preserved
|
||||
All `trust/*` and `trust-signing/*` redirects to `/setup/trust-signing/*` remain unchanged.
|
||||
|
||||
### Navigation config
|
||||
- `identity-providers` admin nav item now points to `/administration/identity-providers` instead of `/settings/identity-providers`.
|
||||
|
||||
### Administration routes
|
||||
- `/administration/identity-providers` now loads the IdentityProvidersSettingsPageComponent directly instead of redirecting to `/settings/identity-providers` (breaks the redirect loop created by the settings rehoming).
|
||||
|
||||
## Test evidence
|
||||
- 22 new tests in `settings-ia-rationalization.spec.ts` covering personal preference defaults, merged redirects, admin redirects, ops redirects, trust preservation, and route count validation.
|
||||
- 3 existing tests in `unified-settings-page.behavior.spec.ts` updated and passing.
|
||||
- 5 existing tests in `setup-topology-trust-cutover.spec.ts` verified passing (no regression).
|
||||
- All 30 settings tests pass, all 5 trust cutover tests pass.
|
||||
- Build clean (no TypeScript errors).
|
||||
|
||||
## Sprint
|
||||
`SPRINT_20260308_026_FE_settings_information_architecture_rationalization`
|
||||
@@ -0,0 +1,95 @@
|
||||
# Sprint 20260308_025 - FE Safe Cleanup And Generated Artifacts Prune
|
||||
|
||||
## Topic & Scope
|
||||
- Remove the approved generated and debug artifacts committed under the Web workspace so audits and review diffs stop mixing product code with disposable output.
|
||||
- Remove only the confirmed orphan route file and the legacy `release-control` leaves that are no longer mounted anywhere except redirect shims.
|
||||
- Keep live surfaces untouched, especially the mounted workflow replay, watchlist, witness, policy, triage, and hotfix flows.
|
||||
- Working directory: `src/Web/StellaOps.Web`.
|
||||
- Allowed coordination edits: `docs/implplan/SPRINT_20260308_025_FE_safe_cleanup_and_generated_artifacts_prune.md`, `docs/modules/ui/TASKS.md`, and `docs/modules/ui/implementation_plan.md`.
|
||||
- Expected evidence: clean git deletion set, successful Web build, successful Web test run, and execution-log updates.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on the preservation review already completed for the current route tree and shared-component inventory.
|
||||
- Must not overlap with unrelated user changes in the existing `main` worktree, so execution happens from an isolated cleanup branch/worktree.
|
||||
- Safe parallelism: documentation-only planning work for future settings or UX derivation sprints may proceed in parallel, but no other task should edit the same legacy `release-control` files during this sprint.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `AGENTS.md`
|
||||
- `docs/modules/ui/AGENTS.md`
|
||||
- `docs/modules/ui/architecture.md`
|
||||
- `src/Web/StellaOps.Web/src/app/app.routes.ts`
|
||||
- `src/Web/StellaOps.Web/src/app/routes/releases.routes.ts`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-CLN-001 - Freeze the deletion allowlist
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Developer (FE), Project Manager
|
||||
Task description:
|
||||
- Freeze the exact list of generated artifacts and orphan code files that are approved for deletion so the cleanup stays narrow and reviewable.
|
||||
- Reconfirm that `hotfixes-queue.component.ts` and other mounted flows remain outside the delete set even though they live near the legacy tree.
|
||||
|
||||
Completion criteria:
|
||||
- [x] The deletion list is explicitly recorded in the sprint execution log.
|
||||
- [x] Mounted or reused components are excluded from the cleanup.
|
||||
- [x] The cleanup scope stays inside `src/Web/StellaOps.Web`.
|
||||
|
||||
### FE-CLN-002 - Remove committed generated and debug artifacts
|
||||
Status: DONE
|
||||
Dependency: FE-CLN-001
|
||||
Owners: Developer (FE)
|
||||
Task description:
|
||||
- Delete the committed Storybook static bundle, Playwright HTML/debug outputs, manual screenshot folders, and ad hoc debug scripts/images that do not belong in source control.
|
||||
- Keep any active test or runtime sources intact; only disposable generated or debugging assets belong in this task.
|
||||
|
||||
Completion criteria:
|
||||
- [x] The approved generated/debug artifact paths are removed from source control.
|
||||
- [x] No product source file is changed as part of this deletion step.
|
||||
- [x] Git diff shows only the intended artifact removals.
|
||||
|
||||
### FE-CLN-003 - Remove the orphan route file and dead legacy release-control leaves
|
||||
Status: DONE
|
||||
Dependency: FE-CLN-001
|
||||
Owners: Developer (FE)
|
||||
Task description:
|
||||
- Delete the unused `workflow-visualization.routes.ts` file and the approved legacy `release-control` governance, regions, and setup leaves that are no longer mounted by the live route tree.
|
||||
- Preserve the still-mounted hotfix queue and any live route imports under Releases.
|
||||
|
||||
Completion criteria:
|
||||
- [x] The orphan workflow-visualization route file is removed.
|
||||
- [x] The approved legacy `release-control` governance, regions, and setup files are removed.
|
||||
- [x] `hotfixes-queue.component.ts` remains in place and the build graph stays valid.
|
||||
|
||||
### FE-CLN-004 - Rebuild, retest, and document the cleanup
|
||||
Status: BLOCKED
|
||||
Dependency: FE-CLN-002
|
||||
Owners: Developer (FE), Test Automation
|
||||
Task description:
|
||||
- Rebuild the Angular workspace and run the Web test suite after the deletions to prove the cleanup did not break route ownership or compile-time imports.
|
||||
- Record the commands and results in the execution log, then mark the sprint complete.
|
||||
|
||||
Completion criteria:
|
||||
- [x] `npm run build` succeeds in `src/Web/StellaOps.Web`.
|
||||
- [ ] `npm run test -- --watch=false` succeeds in `src/Web/StellaOps.Web`.
|
||||
- [x] Sprint execution log captures the verification commands and outcomes.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-08 | Sprint created to prune approved generated/debug artifacts plus confirmed orphan route and legacy release-control leaves from the Web workspace. | Codex |
|
||||
| 2026-03-08 | FE-CLN-001: deletion allowlist frozen. Generated/debug removals: `storybook-static/**`, `playwright-report/index.html`, tracked `output/playwright/*` repro files, `qa-sidebar-manual-screens/**`, `scheduler-debug.png`, and `tmp-debug-*.js`. Code removals: `workflow-visualization.routes.ts`, legacy `release-control/governance/*`, `release-control/regions/*`, `release-control/setup/*`. Explicitly preserved: mounted `hotfixes-queue.component.ts` and live workflow replay components. | Codex |
|
||||
| 2026-03-08 | FE-CLN-002 and FE-CLN-003 completed from isolated branch/worktree. One stale dead-code test file, `src/Web/StellaOps.Web/src/tests/release-control/release-control-setup.component.spec.ts`, was removed because it only imported the deleted legacy setup components. | Codex |
|
||||
| 2026-03-08 | FE-CLN-004: `npm ci --prefer-offline --no-audit --no-fund` succeeded. `npm run build` succeeded with existing bundle-budget warnings only. `npm run test -- --watch=false` did not complete cleanly: after the dead setup spec was removed, the suite still hit unrelated existing assertion failures across multiple areas and eventually exhausted Node heap. A bounded `ng test --watch=false --include src/tests/platform/platform-setup-routes.spec.ts --include src/tests/release-control/release-control-structure.component.spec.ts` run confirmed platform-setup route coverage passes, while `release-control-structure.component.spec.ts` still has one existing assertion mismatch in the create-promotion review copy. | Codex |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: use an isolated cleanup branch/worktree because the user-facing `main` checkout already contains unrelated modifications.
|
||||
- Risk: stale preservation-map output could tempt broader deletion than the route tree supports.
|
||||
- Mitigation: delete only files with explicit route/import confirmation, and keep mounted hotfix/workflow replay surfaces out of scope.
|
||||
- Verification blocker: the broader Web test suite is not clean after the cleanup-specific dead setup spec is removed; failures span unrelated areas such as navigation, triage, i18n, topbar locale behavior, audit-bundle auth expectations, and stale release-control copy assertions, then the run exhausts Node heap.
|
||||
- Mitigation: commit the scoped cleanup with truthful sprint status and leave the suite-wide failures to dedicated stabilization work instead of masking them as cleanup regressions.
|
||||
|
||||
## Next Checkpoints
|
||||
- Freeze the deletion allowlist and execute the cleanup.
|
||||
- Rebuild and retest the Web workspace.
|
||||
- Fast-forward `main` to the cleanup commit after verification succeeds.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Sprint 20260308_026 - FE Settings Information Architecture Rationalization
|
||||
|
||||
## Topic & Scope
|
||||
- Rationalize the current `/settings/*` tree so it becomes a truthful personal-settings surface instead of a mixed bucket of user preferences, admin consoles, setup pages, and redirect shims.
|
||||
- Preserve backward compatibility for existing links through explicit redirects where needed, but move ownership and discoverability back to the correct shells.
|
||||
- Treat this as a UX-first IA rewrite with detailed implementation sequencing, not as a shallow route rename.
|
||||
- Working directory: `src/Web/StellaOps.Web`.
|
||||
- Allowed coordination edits: `docs/modules/ui/TASKS.md`, `docs/modules/ui/implementation_plan.md`, `src/Web/StellaOps.Web/src/app/features/settings/**`, `src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts`, relevant canonical owner routes under `src/Web/StellaOps.Web/src/app/routes/**`, and checked-feature/docs output under `docs/features/checked/web/` plus `docs/modules/ui/**`.
|
||||
- Expected evidence: route inventory, IA contract, Angular route/nav tests, UX verification notes, and execution-log updates.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on the current route inventory and the review that classified settings leaves into personal, admin/setup, and alias buckets.
|
||||
- Should not run in parallel with other routing rewrites that touch `settings.routes.ts`, user-menu navigation, or canonical Setup/Admin ownership paths.
|
||||
- Safe parallelism: pure shared-component derivation sprints can proceed in parallel if they do not edit settings routes or settings host templates.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `AGENTS.md`
|
||||
- `docs/modules/ui/AGENTS.md`
|
||||
- `docs/modules/ui/architecture.md`
|
||||
- `src/Web/StellaOps.Web/AGENTS.md`
|
||||
- `src/Web/StellaOps.Web/src/app/features/settings/settings.routes.ts`
|
||||
- `src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-SETIA-001 - Audit and classify every settings route
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Product Manager, Developer (FE)
|
||||
Task description:
|
||||
- Produce the source-of-truth route inventory for every child of `/settings`, classifying each leaf as one of: personal preference, admin or tenant configuration, canonical-owner alias, or dead wrapper.
|
||||
- Capture whether each leaf is already visible somewhere else in the product, whether it overlaps an existing page, and whether its current label truthfully matches what the page actually does.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Every mounted `/settings/*` route is classified into a single ownership bucket.
|
||||
- [x] Existing visible entry points outside Settings are identified for admin/setup leaves.
|
||||
- [x] Duplicate or misleading leaves are called out explicitly before implementation begins.
|
||||
|
||||
### FE-SETIA-002 - Freeze the target IA and backward-compatibility contract
|
||||
Status: DONE
|
||||
Dependency: FE-SETIA-001
|
||||
Owners: Product Manager, UX
|
||||
Task description:
|
||||
- Define the target contract for Settings so the shell only owns true personal preferences, while admin, tenant, policy, trust, and operations configuration live under their canonical Setup, Ops, or Console Admin owners.
|
||||
- Decide which current URLs remain as redirects, which URLs are removed entirely, and which labels need to change for operator clarity.
|
||||
|
||||
Completion criteria:
|
||||
- [x] A final ownership decision exists for each current settings leaf.
|
||||
- [x] Redirect-vs-removal behavior is defined for every legacy or misleading route.
|
||||
- [x] The target IA is concise enough to explain in one operator-facing diagram or note.
|
||||
|
||||
### FE-SETIA-003 - Build the personal-settings shell and navigation model
|
||||
Status: DONE
|
||||
Dependency: FE-SETIA-002
|
||||
Owners: UX, Developer (FE)
|
||||
Task description:
|
||||
- Redesign the Settings shell around personal preferences only, with explicit sections such as Appearance, Language, Assistant, and Navigation/Layout.
|
||||
- Replace the current "global sidebar owns navigation" fiction with either an in-page settings nav or a sectioned preferences page that is visibly self-contained and understandable.
|
||||
|
||||
Completion criteria:
|
||||
- [x] The Settings shell has a truthful navigation model for personal preferences.
|
||||
- [x] The shell works on desktop and mobile without relying on hidden URL-only leaves.
|
||||
- [x] User-menu entry points land in a settings experience that is obviously personal, not administrative.
|
||||
|
||||
### FE-SETIA-004 - Merge overlapping personal preference leaves
|
||||
Status: DONE
|
||||
Dependency: FE-SETIA-003
|
||||
Owners: Developer (FE), UX
|
||||
Task description:
|
||||
- Consolidate `language` and any other overlapping preference leaves into the primary User Preferences experience so personal settings are not split across near-duplicate pages.
|
||||
- Preserve deep-link compatibility with redirects or anchored sections where helpful, but remove duplicate editing surfaces.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Language preferences are owned by the personal settings experience instead of a duplicate page.
|
||||
- [x] Duplicate personal-preference pages are removed or converted into thin redirects.
|
||||
- [x] Preference-saving behavior remains intact after the merge.
|
||||
|
||||
### FE-SETIA-005 - Rehome admin, tenant, and operations configuration leaves
|
||||
Status: DONE
|
||||
Dependency: FE-SETIA-002
|
||||
Owners: Developer (FE), Product Manager
|
||||
Task description:
|
||||
- Move or redirect `integrations`, `admin`, `branding`, `notifications`, `usage`, `system`, `security-data`, `identity-providers`, `policy`, `offline`, and related leaves to their correct canonical owners.
|
||||
- Ensure these pages are discoverable from the correct Setup/Ops/Admin entry points instead of surviving only as hidden Settings URLs.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Admin/setup leaves no longer present themselves as user settings.
|
||||
- [x] Canonical owner routes expose visible entry points for the rehomed capabilities.
|
||||
- [x] Legacy `/settings/*` bookmarks still resolve through controlled redirects where required.
|
||||
|
||||
### FE-SETIA-006 - Remove or collapse wrapper and alias-only settings pages
|
||||
Status: DONE
|
||||
Dependency: FE-SETIA-005
|
||||
Owners: Developer (FE)
|
||||
Task description:
|
||||
- Delete or collapse any settings pages that only exist as wrapper launchpads into other shells and do not provide independent value.
|
||||
- Keep the compatibility surface focused on redirects, not on maintaining duplicate shells with duplicated copy.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Alias-only settings pages are reduced to redirects or removed.
|
||||
- [x] No standalone wrapper remains if its only action is to link elsewhere.
|
||||
- [x] Route ownership becomes obvious from the code tree.
|
||||
|
||||
### FE-SETIA-007 - Add focused route, nav, and UX regression coverage
|
||||
Status: DONE
|
||||
Dependency: FE-SETIA-004
|
||||
Owners: Test Automation, Developer (FE)
|
||||
Task description:
|
||||
- Add regression coverage for the new Settings IA, including user-menu entry, redirected legacy URLs, and canonical owner entry points for rehomed admin/setup pages.
|
||||
- Include tests that prove hidden pages are now either visible from the right place or intentionally redirected.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Angular route/nav tests cover the new personal settings shell and key redirects.
|
||||
- [x] Regression coverage exists for at least the current user-menu entry plus representative admin/setup redirects.
|
||||
- [x] Known IA edge cases are documented in the sprint log or feature note.
|
||||
|
||||
### FE-SETIA-008 - Sync docs and ship the IA decision
|
||||
Status: DONE
|
||||
Dependency: FE-SETIA-007
|
||||
Owners: Documentation author, Project Manager
|
||||
Task description:
|
||||
- Record the final Settings IA contract in the UI docs, update the UI task board and implementation plan, and add a checked-feature note once the implementation ships.
|
||||
- Ensure future dead-code or preservation reviews have a truthful owner map for Settings.
|
||||
|
||||
Completion criteria:
|
||||
- [x] UI docs reflect the final Settings ownership model.
|
||||
- [x] UI task/plan docs reference the shipped IA.
|
||||
- [x] A checked-feature note exists for the implemented settings rationalization.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-08 | Sprint created to rationalize Settings into a truthful personal-preferences surface and rehome admin/setup leaves to their canonical owners. | Codex |
|
||||
| 2026-03-08 | All tasks DONE. Audited 20 settings child routes and classified into 3 personal-preference, 11 admin/tenant-config, and 6 ops/wrapper buckets. Settings default changed from Integrations to User Preferences. 14 admin/ops leaves converted to redirects pointing at their canonical owners (administration, setup, ops). Language merged into user-preferences via redirect. Identity-providers rehomed from settings to administration as canonical owner. Navigation config updated. 22 new route tests added. All 35 settings+trust tests pass. Build clean. | Developer (FE) |
|
||||
|
||||
## Decisions & Risks
|
||||
- Current risk: the existing Settings shell mixes user preferences with admin/setup pages, making most leaves either URL-only or misleadingly named.
|
||||
- UX principle: Settings must answer "what can I personalize for myself?" while Setup/Admin answer "what do I configure for the installation or tenant?"
|
||||
- Compatibility risk: old bookmarks may point to `/settings/*` admin leaves; mitigate with explicit redirects and route tests instead of duplicate shells.
|
||||
- Decision: `/administration/identity-providers` now loads the component directly instead of redirecting back to `/settings/identity-providers`, breaking the redirect loop.
|
||||
- Decision: Settings default route changed from Integrations to User Preferences, which is the correct personal-settings landing page.
|
||||
- Decision: `release-control` and `configuration-pane` wrapper pages converted to redirects to their canonical setup/ops owners since they only linked elsewhere.
|
||||
|
||||
## Next Checkpoints
|
||||
- Archived. All tasks shipped.
|
||||
@@ -0,0 +1,95 @@
|
||||
# Sprint 20260308_027 - FE Page Header To Context Header Derivation
|
||||
|
||||
## Topic & Scope
|
||||
- Replace the unused generic `PageHeaderComponent` with a stronger canonical header pattern derived from the already-mounted `ContextHeaderComponent`.
|
||||
- Improve operator UX by standardizing title, eyebrow, chips, return action, contextual note, and header actions across admin and setup surfaces.
|
||||
- Keep this sprint focused on header semantics, layout, and adoption, not on broader page redesign.
|
||||
- Working directory: `src/Web/StellaOps.Web`.
|
||||
- Allowed coordination edits: `docs/modules/ui/TASKS.md`, `docs/modules/ui/implementation_plan.md`, `src/Web/StellaOps.Web/src/app/shared/ui/**`, target mounted pages that adopt the header, and checked-feature/docs output under `docs/features/checked/web/` plus `docs/modules/ui/**`.
|
||||
- Expected evidence: shared-header contract, focused component tests, adopted target pages, and docs updates.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on the current shared-UI inventory and the existence of mounted `ContextHeaderComponent` usage in Watchlist, Reachability, Workflow Replay, and Policy shells.
|
||||
- Safe parallelism: may run in parallel with settings IA work if it avoids editing `settings.routes.ts`; coordinate carefully if Settings adopts the derived header.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/modules/ui/architecture.md`
|
||||
- `src/Web/StellaOps.Web/src/app/shared/ui/page-header/page-header.component.ts`
|
||||
- `src/Web/StellaOps.Web/src/app/shared/ui/context-header/context-header.component.ts`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-PHD-001 - Freeze the canonical header contract
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: UX, Developer (FE)
|
||||
Task description:
|
||||
- Compare `PageHeaderComponent` against the mounted `ContextHeaderComponent` and define the single canonical header contract the product should keep.
|
||||
- Document which capabilities remain mandatory: contextual eyebrow, chips, back action, action slot strategy, supportive note, and responsive stacking behavior.
|
||||
|
||||
Completion criteria:
|
||||
- [x] A single canonical header API is defined.
|
||||
- [x] Unused or redundant `PageHeaderComponent` behavior is either absorbed or rejected explicitly.
|
||||
- [x] Header semantics are described in UX terms, not only implementation terms.
|
||||
|
||||
### FE-PHD-002 - Derive the reusable header primitive
|
||||
Status: DONE
|
||||
Dependency: FE-PHD-001
|
||||
Owners: Developer (FE)
|
||||
Task description:
|
||||
- Extend or refine the canonical header primitive so it can serve the pages that previously would have used the generic page header without regressing the richer contextual flows.
|
||||
- Keep the API small and expressive; avoid two near-identical shared header components.
|
||||
|
||||
Completion criteria:
|
||||
- [x] The canonical header primitive supports the required title, metadata, and action variants.
|
||||
- [x] `PageHeaderComponent` is either removed or reduced to a compatibility wrapper with a clear migration path.
|
||||
- [x] Header behavior remains responsive and accessible.
|
||||
|
||||
### FE-PHD-003 - Adopt the derived header on target pages
|
||||
Status: DONE
|
||||
Dependency: FE-PHD-002
|
||||
Owners: Developer (FE), UX
|
||||
Task description:
|
||||
- Adopt the derived header on carefully chosen mounted surfaces that currently rely on ad hoc title/subtitle/action markup, prioritizing pages that need contextual clarity.
|
||||
- Use adoption to prove the pattern works for both dense operator surfaces and simpler settings/admin pages.
|
||||
|
||||
Completion criteria:
|
||||
- [x] At least one simple settings/admin page and one richer operational page adopt the derived header pattern.
|
||||
- [x] Repeated header markup is removed from adopted surfaces.
|
||||
- [x] The adopted pages gain clearer context and action placement.
|
||||
|
||||
### FE-PHD-004 - Verify, document, and retire the orphan path
|
||||
Status: DONE
|
||||
Dependency: FE-PHD-003
|
||||
Owners: Test Automation, Documentation author
|
||||
Task description:
|
||||
- Add focused tests for the canonical header behavior and record the derivation decision in UI docs so future reviews treat the old generic header as intentionally superseded.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Component or host tests cover the canonical header behavior.
|
||||
- [x] UI docs explain the header derivation and adoption targets.
|
||||
- [x] The old orphan path is no longer ambiguous in the shared inventory.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-08 | Sprint created to derive the unused generic page header into the mounted context-header pattern and adopt one canonical header primitive. | Codex |
|
||||
| 2026-03-08 | FE-PHD-001: Frozen the canonical header contract. `ContextHeaderComponent` is the single canonical header. `PageHeaderComponent` had only title, subtitle, and action slots; all useful bits absorbed. Canonical API: title (required), eyebrow (optional), subtitle (optional), contextNote (optional), chips (optional status indicators), backLabel+backClick (optional return action), headingLevel (1/2/3 for semantic HTML), testId (optional), header-actions content projection slot. | Developer (FE) |
|
||||
| 2026-03-08 | FE-PHD-002: Enhanced `ContextHeaderComponent` with configurable heading level (h1/h2/h3), testId, arrow in return button, ARIA labels on return button and chip list, JSDoc on all inputs. `PageHeaderComponent` reduced to deprecated compatibility wrapper delegating to `ContextHeaderComponent`. | Developer (FE) |
|
||||
| 2026-03-08 | FE-PHD-003: Adopted canonical header on 4 target pages: `RegistryAdminComponent` (admin/setup page), `PackRegistryBrowserComponent` (operational page), `DeadLetterDashboardComponent` (operational page), `OfflineKitComponent` (operational page). Removed repeated ad-hoc header markup from all 4. Each page now has eyebrow breadcrumb, consistent subtitle, and projected actions via the shared header. | Developer (FE) |
|
||||
| 2026-03-08 | FE-PHD-004: Added 15 focused component tests covering title rendering, eyebrow/subtitle display, chips with ARIA roles, back action behavior, action slot projection, heading level configurability (h1/h2/h3), testId attribute, and responsive layout structure. All 15 pass. Updated sprint and docs. Marked `PageHeaderComponent` as deprecated in the shared index. | Test Automation |
|
||||
| 2026-03-08 | Post-integration hardening: widened `ContextHeaderComponent` action-slot projection to accept legacy `primary-actions` and `secondary-actions` selectors, and added a dedicated `PageHeaderComponent` compatibility spec so wrapper behavior is now explicitly verified instead of assumed. | Developer (FE) |
|
||||
|
||||
## Decisions & Risks
|
||||
- **Decision: Single canonical header.** `ContextHeaderComponent` is the sole canonical header primitive. `PageHeaderComponent` is deprecated to a thin compatibility wrapper.
|
||||
- **Decision: Heading level configurability.** Added `headingLevel` input (1, 2, or 3) to support pages nested inside shells that already provide an h1. Default remains h1.
|
||||
- **Decision: Back button arrow.** Added a left arrow indicator to the return button for improved affordance and accessibility.
|
||||
- **Decision: testId support.** Added `testId` input that maps to `data-testid` on the header element for Playwright/test targeting.
|
||||
- **Decision: Adopted pages.** Registry Admin (admin/setup), Pack Registry Browser (operational), Dead-Letter Dashboard (operational), Offline Kit (operational). These four prove the pattern works across both simple admin and richer operational surfaces.
|
||||
- **Decision: Compatibility selectors remain supported.** `ContextHeaderComponent` now accepts `[header-actions]`, `[secondary-actions]`, and `[primary-actions]` in its projection slot so the deprecated wrapper continues to behave correctly during migration.
|
||||
- Risk: overfitting the header API to too many page variants could make the primitive hard to use.
|
||||
- Mitigation: validated the API on a bounded 4-page adoption set. Future rollout should proceed incrementally.
|
||||
|
||||
## Next Checkpoints
|
||||
- Broader rollout of canonical header to remaining pages with ad-hoc headers (not scoped to this sprint).
|
||||
- Eventual removal of `PageHeaderComponent` once no references remain.
|
||||
@@ -0,0 +1,118 @@
|
||||
# Sprint 20260308_028 - FE Metric Card Dashboard Derivation
|
||||
|
||||
## Topic & Scope
|
||||
- Derive the unused `MetricCardComponent` into a truthful canonical KPI card pattern for mounted ops, admin, quota, and system dashboards.
|
||||
- Improve UX by standardizing deltas, directional semantics, health coloring, and supporting context instead of leaving each dashboard to invent its own card shape.
|
||||
- Keep scope to KPI card behavior and adoption, not entire dashboard rewrites.
|
||||
- Working directory: `src/Web/StellaOps.Web`.
|
||||
- Allowed coordination edits: `docs/modules/ui/TASKS.md`, `docs/modules/ui/implementation_plan.md`, shared UI card primitives, selected dashboard hosts, and checked-feature/docs output under `docs/features/checked/web/` plus `docs/modules/ui/**`.
|
||||
- Expected evidence: canonical KPI card contract, bounded adoption set, focused tests, and docs updates.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on the mounted overview surfaces already present across Operations, Administration, Usage, System, and related overview pages.
|
||||
- Safe parallelism: may run alongside settings IA work if adoptions do not edit the same settings-owned templates; coordinate if Usage/System pages are part of both efforts.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/modules/ui/architecture.md`
|
||||
- `src/Web/StellaOps.Web/src/app/shared/ui/metric-card/metric-card.component.ts`
|
||||
- Mounted dashboard or overview pages chosen for adoption
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-MCD-001 - Freeze KPI semantics and visual rules
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: UX, Product Manager
|
||||
Task description:
|
||||
- Define what a canonical StellaOps KPI card must communicate: label, value, unit, trend/delta, severity or health state, supporting subtitle, and empty/loading/error behaviors.
|
||||
- Decide when positive deltas are good vs bad, so the shared component does not encode misleading green/red assumptions.
|
||||
|
||||
Completion criteria:
|
||||
- [x] KPI card semantic fields are explicitly defined.
|
||||
- [x] Delta direction rules are documented for operational contexts where "higher" can be either good or bad.
|
||||
- [x] The visual contract includes empty/loading/error states where needed.
|
||||
|
||||
**Frozen semantic model:**
|
||||
| Field | Type | Required | Description |
|
||||
|---|---|---|---|
|
||||
| `label` | `string` | yes | Metric name, displayed uppercase |
|
||||
| `value` | `string \| number` | yes | Current metric value |
|
||||
| `unit` | `string` | no | Display unit (ms, %, /hr, GB, etc.) |
|
||||
| `delta` | `number` | no | Percentage change; sign determines arrow |
|
||||
| `deltaDirection` | `'up-is-good' \| 'up-is-bad' \| 'neutral'` | no (default: `up-is-good`) | Controls green/red semantics |
|
||||
| `severity` | `'healthy' \| 'warning' \| 'critical' \| 'unknown'` | no | Left-border accent color |
|
||||
| `subtitle` | `string` | no | Supporting context line below value |
|
||||
| `loading` | `boolean` | no | Skeleton placeholder state |
|
||||
| `empty` | `boolean` | no | No-data state (shows `--`) |
|
||||
| `error` | `string` | no | Error message state (shows `--` + message) |
|
||||
|
||||
**Delta direction rules:**
|
||||
- `up-is-good`: uptime, throughput, scan completion, healthy service count, feedback score
|
||||
- `up-is-bad`: error rate, latency, vulnerability count, failure count, zero-result rate
|
||||
- `neutral`: informational metrics without value judgment (total count, signal volume)
|
||||
|
||||
### FE-MCD-002 - Derive the shared KPI card primitive
|
||||
Status: DONE
|
||||
Dependency: FE-MCD-001
|
||||
Owners: Developer (FE)
|
||||
Task description:
|
||||
- Rework the current `MetricCardComponent` into the canonical dashboard card pattern with the agreed semantics, layout, and accessibility behavior.
|
||||
- Keep the API reusable across quota, health, system, and admin overview surfaces without requiring ad hoc wrappers.
|
||||
|
||||
Completion criteria:
|
||||
- [x] The shared KPI card supports the agreed semantic model.
|
||||
- [x] Directional styling does not assume all positive movement is good.
|
||||
- [x] The component is accessible and responsive in dense dashboard grids.
|
||||
|
||||
### FE-MCD-003 - Adopt the derived KPI card on representative dashboards
|
||||
Status: DONE
|
||||
Dependency: FE-MCD-002
|
||||
Owners: Developer (FE), UX
|
||||
Task description:
|
||||
- Adopt the new KPI card on a representative mix of mounted dashboard pages so the shared primitive proves itself in real product surfaces.
|
||||
- Prioritize pages with repeated bespoke KPI tiles or weak visual consistency.
|
||||
|
||||
Completion criteria:
|
||||
- [x] A bounded set of mounted dashboard pages use the shared KPI card.
|
||||
- [x] Repeated bespoke KPI tile markup is reduced on adopted surfaces.
|
||||
- [x] The adopted dashboards present clearer health/trend information.
|
||||
|
||||
**Adopted surfaces (3):**
|
||||
1. `signals-runtime-dashboard.component.ts` - 3 bespoke metric articles replaced with `<app-metric-card>`
|
||||
2. `search-quality-dashboard.component.ts` - 4 bespoke metric divs replaced with `<app-metric-card>`
|
||||
3. `delivery-analytics.component.ts` - 5 of 6 bespoke metric divs replaced with `<app-metric-card>` (success-rate card kept bespoke due to specialized progress bar)
|
||||
|
||||
### FE-MCD-004 - Verify and document the derivation
|
||||
Status: DONE
|
||||
Dependency: FE-MCD-003
|
||||
Owners: Test Automation, Documentation author
|
||||
Task description:
|
||||
- Add focused component or host tests for semantic delta handling and document the shared KPI-card contract in the UI docs.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Tests cover the critical semantic cases for delta and state rendering.
|
||||
- [x] Docs record the adopted KPI-card contract and target surfaces.
|
||||
- [x] Future audits can classify the old unused component as intentionally derived, not forgotten.
|
||||
|
||||
**Test evidence:** 40 tests pass covering normal rendering, delta direction semantics (up-is-good, up-is-bad, neutral), loading/empty/error states, severity accents, and ARIA accessibility.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-08 | Sprint created to derive the orphan metric-card into a canonical KPI card pattern for mounted dashboards and overview surfaces. | Codex |
|
||||
| 2026-03-08 | FE-MCD-001: Froze KPI semantic model with 10 fields including deltaDirection and severity. Delta direction rules codified for up-is-good, up-is-bad, and neutral scenarios. | Developer (FE) |
|
||||
| 2026-03-08 | FE-MCD-002: Rewrote MetricCardComponent with full semantic model, ARIA labels, loading/empty/error states, severity accents, and responsive dense-grid support. Exported DeltaDirection and MetricSeverity types from shared/ui/index.ts. | Developer (FE) |
|
||||
| 2026-03-08 | FE-MCD-003: Adopted canonical card on 3 representative dashboards: signals-runtime (3 cards), search-quality (4 cards), delivery-analytics (5 cards). 12 bespoke inline tiles replaced total. | Developer (FE) |
|
||||
| 2026-03-08 | FE-MCD-004: Added 40 focused tests covering all semantic cases. Build verified clean. Docs updated. | Developer (FE) |
|
||||
| 2026-03-08 | Post-integration hardening: replaced non-reactive `computed()` state that was reading plain `@Input()` fields with synchronous helper methods, and added a regression spec that mutates inputs after first render to prove dashboard bindings stay current. | Developer (FE) |
|
||||
|
||||
## Decisions & Risks
|
||||
- Key risk: dashboard metrics have different "good/bad" semantics, so a naive green-for-up, red-for-down treatment would be wrong.
|
||||
- Mitigation: freeze semantic rules before component API design and test both positive-is-good and positive-is-bad cases.
|
||||
- Decision: `deltaDirection` defaults to `'up-is-good'` for backward compatibility with existing callers.
|
||||
- Decision: success-rate card in delivery-analytics kept bespoke because its progress bar visualization goes beyond the KPI card contract scope.
|
||||
- Decision: existing `StatsCardComponent` and `StatCardComponent` are not merged in this sprint; they serve different visual patterns (trend+sparkline vs. KPI). Consolidation is a separate future sprint.
|
||||
- Decision: input-derived presentation state is computed synchronously from current inputs rather than Angular signals. The card is input-driven, and helper methods keep it truthful when async dashboard data arrives after first render.
|
||||
|
||||
## Next Checkpoints
|
||||
- All tasks DONE. Sprint ready for archive after review.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Sprint 20260308_029 - FE Timeline List Audit Timeline Derivation
|
||||
|
||||
## Topic & Scope
|
||||
- Derive the unused `TimelineListComponent` into a canonical event-stream pattern for mounted audit, evidence, release investigation, and triage chronology surfaces.
|
||||
- Improve UX by standardizing chronology rendering, severity markers, timestamp treatment, and expandable contextual payloads.
|
||||
- Keep scope to the timeline primitive plus bounded adoptions, not a full redesign of every evidence or run-detail screen.
|
||||
- Working directory: `src/Web/StellaOps.Web`.
|
||||
- Allowed coordination edits: `docs/modules/ui/TASKS.md`, `docs/modules/ui/implementation_plan.md`, shared timeline primitives, selected mounted timeline hosts, and checked-feature/docs output under `docs/features/checked/web/` plus `docs/modules/ui/**`.
|
||||
- Expected evidence: canonical timeline contract, bounded adoption set, regression coverage, and docs updates.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on the mounted evidence, release, and triage chronology surfaces already present in the product.
|
||||
- Safe parallelism: may run with settings or header/card derivation work if it avoids editing the same host templates.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/modules/ui/architecture.md`
|
||||
- `src/Web/StellaOps.Web/src/app/shared/ui/timeline-list/timeline-list.component.ts`
|
||||
- Relevant mounted timeline/audit hosts chosen for adoption
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-TLD-001 - Freeze the canonical event model
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: UX, Product Manager
|
||||
Task description:
|
||||
- Define the canonical event model for StellaOps timelines, including timestamp precision, actor/source metadata, severity or event kind, optional evidence links, and empty/loading states.
|
||||
- Decide where relative time, absolute time, and grouping should appear so audit and ops surfaces remain truthful and scannable.
|
||||
|
||||
Completion criteria:
|
||||
- [x] A canonical event model exists for mounted timeline surfaces.
|
||||
- [x] Rules for relative vs absolute time display are documented.
|
||||
- [x] Grouping or expansion expectations are defined before implementation.
|
||||
|
||||
### FE-TLD-002 - Derive the shared timeline primitive
|
||||
Status: DONE
|
||||
Dependency: FE-TLD-001
|
||||
Owners: Developer (FE)
|
||||
Task description:
|
||||
- Rework `TimelineListComponent` so it can serve real audit/evidence use cases: richer markers, deterministic timestamp formatting, optional metadata slots, and expandable event detail.
|
||||
- Avoid keeping a toy timeline component that cannot carry actual operator evidence.
|
||||
|
||||
Completion criteria:
|
||||
- [x] The shared timeline primitive supports the agreed event model.
|
||||
- [x] Timestamp rendering is deterministic and appropriate for audit-grade surfaces.
|
||||
- [x] The component supports richer detail than the current orphan implementation.
|
||||
|
||||
### FE-TLD-003 - Adopt the derived timeline on mounted chronology surfaces
|
||||
Status: DONE
|
||||
Dependency: FE-TLD-002
|
||||
Owners: Developer (FE), UX
|
||||
Task description:
|
||||
- Adopt the derived timeline on a small set of mounted chronology surfaces where it improves consistency without flattening domain-specific meaning.
|
||||
- Use the adoption set to validate both compact event streams and denser evidence timelines.
|
||||
|
||||
Adoption surfaces:
|
||||
1. **Incident Timeline** (`features/platform-health/incident-timeline.component.ts`) - replaced bespoke inline timeline with canonical component, preserving domain-specific affected-services chips and correlated-events expandable.
|
||||
2. **Audit Timeline Search** (`features/audit-log/audit-timeline-search.component.ts`) - replaced bespoke inline timeline with canonical component, preserving module/action badge rendering via content projection.
|
||||
3. **Releases Activity** (`features/releases/releases-activity.component.ts`) - replaced the timeline view mode (which was rendering a table identical to the table view) with the canonical timeline, preserving lane/environment/outcome chips via content projection.
|
||||
|
||||
Completion criteria:
|
||||
- [x] A bounded set of mounted chronology surfaces adopt the shared timeline.
|
||||
- [x] Timeline UX improves on scanability and event meaning.
|
||||
- [x] Domain-specific context is preserved, not lost to over-generalization.
|
||||
|
||||
### FE-TLD-004 - Verify and document the derivation
|
||||
Status: DONE
|
||||
Dependency: FE-TLD-003
|
||||
Owners: Test Automation, Documentation author
|
||||
Task description:
|
||||
- Add focused regression coverage for timeline formatting and document the canonical timeline contract and adoption choices.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Tests cover core timeline rendering and timestamp behavior.
|
||||
- [x] Docs explain where the shared timeline is appropriate and where bespoke views still make sense.
|
||||
- [x] The old orphan classification becomes intentional and documented.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-08 | Sprint created to derive the unused timeline-list into a canonical event-stream pattern for mounted audit and evidence chronologies. | Codex |
|
||||
| 2026-03-08 | FE-TLD-001 DONE: Frozen canonical event model with TimelineEvent interface (id, timestamp, title, description, actor, eventKind, icon, evidenceLink, metadata, expandable). Time display rules: relative <24h, absolute UTC ISO-8601 >=24h, full ISO on tooltip. Date grouping supported. | Developer |
|
||||
| 2026-03-08 | FE-TLD-002 DONE: Derived TimelineListComponent with vertical timeline, colored severity markers (info/success/warning/error/critical/neutral), deterministic UTC timestamps, expandable detail sections, actor/source metadata, date grouping, loading skeleton, empty state, accessibility (role="feed", aria-labels), and content projection. | Developer |
|
||||
| 2026-03-08 | FE-TLD-003 DONE: Adopted on 3 surfaces: incident-timeline, audit-timeline-search, releases-activity (timeline view mode). Domain-specific context preserved via content projection. | Developer |
|
||||
| 2026-03-08 | FE-TLD-004 DONE: 32 focused tests covering event rendering, severity markers, timestamp formatting (relative vs absolute), expandable toggle, loading/empty states, date grouping, accessibility, and default fallbacks. Build passes. | Developer |
|
||||
| 2026-03-08 | Post-integration hardening: unified grouped and flat rendering behind a shared render-clock refresh path so relative timestamps stay truthful in flat mode too, and added a regression test that advances time between flat-mode renders. | Developer |
|
||||
|
||||
## Decisions & Risks
|
||||
- Risk: oversimplifying audit/evidence timelines could erase domain meaning or precision.
|
||||
- Mitigation: freeze the event model first and adopt only on bounded surfaces where the shared primitive fits cleanly.
|
||||
- Decision: Excluded witness/evidence hosts (sprint 031 territory), VEX timeline (domain-specific source-consensus visualization), pedigree timeline (horizontal ancestry lineage), observation timeline (SVG bar chart), and explainer timeline (process steps) from adoption because they are fundamentally different visualization patterns, not generic event streams.
|
||||
- Decision: Used content projection (ng-template #eventContent) to allow adopting surfaces to render domain-specific chips, badges, and links without modifying the shared component.
|
||||
- Decision: The `eventKind` field uses 'critical' as a distinct severity above 'error' (with visual emphasis via box-shadow ring).
|
||||
- Decision: both grouped and flat modes refresh the render clock from the same `renderedEvents` computed path so relative timestamps remain deterministic within a render cycle without drifting stale across input updates.
|
||||
|
||||
## Next Checkpoints
|
||||
- Freeze the event model and time-display rules. -- DONE
|
||||
- Build the richer shared timeline primitive. -- DONE
|
||||
- Adopt it on a bounded set of mounted chronology surfaces. -- DONE
|
||||
@@ -0,0 +1,92 @@
|
||||
# Sprint 20260308_030 - FE Split Pane And List Detail Shell Consolidation
|
||||
|
||||
## Topic & Scope
|
||||
- Consolidate the unused `SplitPaneComponent` into the mounted `ListDetailShellComponent` so the product has one truthful master-detail layout primitive instead of two overlapping abstractions.
|
||||
- Improve UX by defining a single responsive list-detail behavior for selection, secondary detail presentation, and mobile collapse behavior.
|
||||
- Keep scope to master-detail layout primitives and their bounded adoptions.
|
||||
- Working directory: `src/Web/StellaOps.Web`.
|
||||
- Allowed coordination edits: `docs/modules/ui/TASKS.md`, `docs/modules/ui/implementation_plan.md`, shared shell primitives, selected mounted list-detail hosts, and checked-feature/docs output under `docs/features/checked/web/` plus `docs/modules/ui/**`.
|
||||
- Expected evidence: consolidated shell contract, updated shared primitive, bounded host adoption, and regression coverage.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on the mounted `ListDetailShellComponent` usage already present in Watchlist and related contextual surfaces.
|
||||
- Safe parallelism: may run with other derivation sprints if it avoids editing the same host templates; coordinate closely with any watchlist or triage shell changes.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/modules/ui/architecture.md`
|
||||
- `src/Web/StellaOps.Web/src/app/shared/ui/split-pane/split-pane.component.ts`
|
||||
- `src/Web/StellaOps.Web/src/app/shared/ui/list-detail-shell/list-detail-shell.component.ts`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-SPL-001 - Freeze the single master-detail contract
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: UX, Developer (FE)
|
||||
Task description:
|
||||
- Compare the unused `SplitPaneComponent` against the mounted `ListDetailShellComponent` and freeze the single master-detail contract the UI should keep.
|
||||
- Decide which behaviors, if any, should migrate: collapsible secondary rail, width control, preserved selection context, and mobile stacking behavior.
|
||||
|
||||
Completion criteria:
|
||||
- [x] One canonical master-detail layout contract is defined.
|
||||
- [x] Useful `SplitPaneComponent` behavior is explicitly accepted or rejected.
|
||||
- [x] The contract describes both desktop and mobile behavior.
|
||||
|
||||
### FE-SPL-002 - Derive the canonical list-detail shell
|
||||
Status: DONE
|
||||
Dependency: FE-SPL-001
|
||||
Owners: Developer (FE)
|
||||
Task description:
|
||||
- Extend `ListDetailShellComponent` with the approved behavior from `SplitPaneComponent` if it materially improves operator UX.
|
||||
- Avoid porting gimmicks that add complexity without improving mounted surfaces.
|
||||
|
||||
Completion criteria:
|
||||
- [x] `ListDetailShellComponent` supports the agreed master-detail behavior.
|
||||
- [x] The API remains smaller and clearer than maintaining two primitives.
|
||||
- [x] Accessibility and responsive behavior are preserved.
|
||||
|
||||
### FE-SPL-003 - Adopt the consolidated shell on bounded mounted surfaces
|
||||
Status: DONE
|
||||
Dependency: FE-SPL-002
|
||||
Owners: Developer (FE), UX
|
||||
Task description:
|
||||
- Adopt the consolidated shell on a bounded set of mounted list-detail surfaces, validating both steady-state browsing and detail-open workflows.
|
||||
- Prefer surfaces where the detail panel and selection behavior are central to task completion.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Bounded mounted list-detail surfaces use the consolidated shell.
|
||||
- [x] Detail-open and mobile behaviors are tested on real host pages.
|
||||
- [x] `SplitPaneComponent` becomes removable or clearly deprecated.
|
||||
|
||||
### FE-SPL-004 - Verify and document the consolidation
|
||||
Status: DONE
|
||||
Dependency: FE-SPL-003
|
||||
Owners: Test Automation, Documentation author
|
||||
Task description:
|
||||
- Add focused tests for the consolidated shell behavior and document the single master-detail contract in the UI docs.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Regression coverage exists for the consolidated shell.
|
||||
- [x] Docs explain the one-shell rule for future UI work.
|
||||
- [x] The old unused split-pane path is no longer ambiguous.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-08 | Sprint created to consolidate the unused split-pane primitive into the mounted list-detail shell and establish one canonical master-detail layout. | Codex |
|
||||
| 2026-03-08 | FE-SPL-001: Compared SplitPaneComponent (flex, collapsible left rail, toggle button) vs ListDetailShellComponent (grid, conditional right detail, responsive breakpoint). Decision: keep ListDetailShellComponent as the canonical master-detail primitive. Accepted behaviors from SplitPane: collapsible toggle button (as `collapsible` input + `detailClosed` output), CSS transition animation for detail panel entry. Rejected: fixed-pixel left-width control (grid-based proportional sizing is superior), collapse-left-pane behavior (operators need the primary list visible). Mobile behavior: single-column stack below 1100px breakpoint (matches existing). | Developer (FE) |
|
||||
| 2026-03-08 | FE-SPL-002: Extended ListDetailShellComponent with `collapsible` input, `detailClosed` output, toggle button with SVG chevron icon, slide-in animation for detail pane, `role="complementary"` on detail container, `focus-visible` styles on toggle, `aria-label` and `aria-controls` on toggle button. API surface: 3 inputs (`detailVisible`, `detailWidth`, `collapsible`) + 1 output (`detailClosed`). | Developer (FE) |
|
||||
| 2026-03-08 | FE-SPL-003: Adopted consolidated shell on signing-key-dashboard (trust-admin). The key table now renders side-by-side with the key-detail-panel using the collapsible list-detail-shell. Watchlist (pre-existing adoption) continues to use the shell without collapsible toggle. SplitPaneComponent deprecated with JSDoc `@deprecated` annotation. | Developer (FE) |
|
||||
| 2026-03-08 | FE-SPL-004: Added 15 focused component tests covering: creation, primary pane rendering, detail visibility toggle, CSS class application, custom width, collapsible toggle button visibility, detailClosed emission, detail pane hiding after toggle, accessibility role, focus support, and default width. All 15 tests pass. Build passes. Sprint docs and TASKS.md updated. | Developer (FE) |
|
||||
|
||||
## Decisions & Risks
|
||||
- **Decision: ListDetailShellComponent is the canonical master-detail layout primitive.** SplitPaneComponent is deprecated.
|
||||
- **Accepted from SplitPane:** Collapsible toggle button (opt-in via `collapsible` input), detail panel slide-in animation.
|
||||
- **Rejected from SplitPane:** Fixed-pixel left-width control (grid proportional sizing is better for responsive layouts), collapse-left-pane behavior (operators need the primary list always visible in master-detail contexts).
|
||||
- **Contract:** Desktop shows 2-column grid (1.7fr primary + variable detail width). Mobile (<1100px) stacks to single column. Toggle button hidden on mobile. Detail pane has `role="complementary"` and slide-in animation.
|
||||
- Risk: adding too many optional behaviors could turn the canonical shell into a grab bag.
|
||||
- Mitigation: only `collapsible` was added; the API remains 3 inputs + 1 output.
|
||||
|
||||
## Next Checkpoints
|
||||
- Remove `SplitPaneComponent` entirely in a future cleanup sprint once confirmed no consumers remain.
|
||||
- Consider additional bounded adoptions on other list-detail surfaces as those features mature.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Sprint 20260308_031 - FE Witness Viewer Evidence Derivation
|
||||
|
||||
## Topic & Scope
|
||||
- Derive the orphan `WitnessViewerComponent` into reusable evidence and witness sub-surfaces inside the mounted Reachability and Evidence experiences instead of reviving a standalone full-page viewer.
|
||||
- Improve UX by surfacing verification summary, signatures, attestations, raw evidence actions, and supporting metadata where operators already investigate proofs.
|
||||
- Keep scope to witness/evidence presentation and derivation, not backend API redesign.
|
||||
- Working directory: `src/Web/StellaOps.Web`.
|
||||
- Allowed coordination edits: `docs/modules/ui/TASKS.md`, `docs/modules/ui/implementation_plan.md`, witness/evidence shared UI under `src/Web/StellaOps.Web/src/app/shared/ui/**`, mounted Reachability/Evidence hosts, and checked-feature/docs output under `docs/features/checked/web/` plus `docs/modules/ui/**`.
|
||||
- Expected evidence: derivation contract, extracted reusable sections, bounded host adoption, focused tests, and docs updates.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on the mounted reachability witness and evidence-detail flows already present in the route tree.
|
||||
- Should coordinate with any concurrent reachability or evidence route work because the adoption targets are live operator pages.
|
||||
- Safe parallelism: header/card/timeline derivation sprints may proceed separately if they do not edit the same witness/evidence hosts.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/modules/ui/architecture.md`
|
||||
- `src/Web/StellaOps.Web/src/app/shared/ui/witness-viewer/witness-viewer.component.ts`
|
||||
- Mounted reachability witness and evidence-detail hosts chosen for adoption
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-WVD-001 - Freeze the witness/evidence derivation contract
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Product Manager, UX
|
||||
Task description:
|
||||
- Audit which parts of `WitnessViewerComponent` still add value: verification summary, signature inspection, attestation details, raw payload access, and download/copy actions.
|
||||
- Decide which mounted surfaces should own those capabilities, and which full-page viewer behavior should be rejected as redundant.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Valuable witness/evidence capabilities are explicitly listed.
|
||||
- [x] Each capability is assigned to a mounted owner surface.
|
||||
- [x] Standalone full-page viewer behavior is either justified or rejected explicitly.
|
||||
|
||||
Derivation contract:
|
||||
1. **VerificationSummaryComponent** - pass/fail status, confidence tier, evidence type, creation date, source. Owner: Reachability WitnessPage + Evidence PacketPage.
|
||||
2. **SignatureInspectorComponent** - algorithm, key ID, verified/unverified badge, truncated signature with copy. Owner: Reachability WitnessPage.
|
||||
3. **AttestationDetailComponent** - predicate type, subject + digests, collapsible predicate JSON. Owner: any surface with in-toto attestation data.
|
||||
4. **EvidencePayloadComponent** - raw JSON viewer with copy/download, metadata display. Owner: Reachability WitnessPage + Evidence PacketPage.
|
||||
5. **Rejected**: standalone full-page `WitnessViewerComponent` behavior. The orphan viewer's HTTP loading, full-page header, and verify-via-API features are redundant because the mounted WitnessPage already has its own API integration and the Evidence surfaces have their own verify flows.
|
||||
|
||||
### FE-WVD-002 - Extract reusable witness/evidence sections
|
||||
Status: DONE
|
||||
Dependency: FE-WVD-001
|
||||
Owners: Developer (FE)
|
||||
Task description:
|
||||
- Extract the useful witness/evidence sections from the orphan component into reusable building blocks that can be embedded in mounted Reachability and Evidence views.
|
||||
- Keep the extracted units focused and composable instead of recreating the orphan full-page layout under a different name.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Reusable witness/evidence sections exist for the approved capabilities.
|
||||
- [x] The extracted units fit mounted pages without forcing a standalone-shell layout.
|
||||
- [x] The old full-page witness viewer is no longer the only place those behaviors exist.
|
||||
|
||||
Extracted sections (under `src/Web/StellaOps.Web/src/app/shared/ui/witness/`):
|
||||
- `verification-summary.component.ts` - VerificationSummaryComponent
|
||||
- `signature-inspector.component.ts` - SignatureInspectorComponent
|
||||
- `attestation-detail.component.ts` - AttestationDetailComponent
|
||||
- `evidence-payload.component.ts` - EvidencePayloadComponent
|
||||
- `witness.models.ts` - shared presentation-level models
|
||||
- `index.ts` - barrel export
|
||||
|
||||
### FE-WVD-003 - Adopt the extracted sections on mounted witness and evidence surfaces
|
||||
Status: DONE
|
||||
Dependency: FE-WVD-002
|
||||
Owners: Developer (FE), UX
|
||||
Task description:
|
||||
- Integrate the extracted sections into the mounted Reachability witness and Evidence proof/detail experiences so operators can verify and inspect proofs in context.
|
||||
- Use adoption to improve context continuity rather than adding one more isolated viewer entry point.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Mounted witness/evidence flows gain the approved proof-inspection capabilities.
|
||||
- [x] Context is preserved across reachability/evidence workflows.
|
||||
- [x] No duplicate standalone viewer surface is introduced.
|
||||
|
||||
Adopted surfaces:
|
||||
1. **Reachability WitnessPage** (`src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.*`) - Added VerificationSummary, SignatureInspector, and EvidencePayload sections below the existing Runtime Observation panel. Domain data mapped via computed signals.
|
||||
2. **Evidence PacketPage** (`src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts`) - Replaced the inline verify tab with composed VerificationSummary and EvidencePayload sections, improving the proof inspection flow.
|
||||
|
||||
### FE-WVD-004 - Verify and document the derivation
|
||||
Status: DONE
|
||||
Dependency: FE-WVD-003
|
||||
Owners: Test Automation, Documentation author
|
||||
Task description:
|
||||
- Add focused tests for the derived witness/evidence sections and document where proof verification details now live in the product.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Focused tests cover the derived witness/evidence sections.
|
||||
- [x] Docs explain the new owner surfaces for witness/proof inspection.
|
||||
- [x] The orphan witness-viewer path is intentionally retired or reduced.
|
||||
|
||||
Test results: 32/32 tests passing across 4 spec files:
|
||||
- `verification-summary.component.spec.ts` - 10 tests (status variants, confidence tiers, conditional fields)
|
||||
- `signature-inspector.component.spec.ts` - 8 tests (verified/unverified cards, truncation, copy button)
|
||||
- `attestation-detail.component.spec.ts` - 6 tests (empty state, predicate type, subject digests, toggle)
|
||||
- `evidence-payload.component.spec.ts` - 8 tests (show/hide raw, copy/download, metadata)
|
||||
|
||||
Build: Angular build succeeds with no new warnings.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-08 | Sprint created to derive the orphan witness-viewer into reusable proof-inspection sections for mounted Reachability and Evidence surfaces. | Codex |
|
||||
| 2026-03-08 | All 4 tasks completed. Extracted 4 reusable sections, adopted on WitnessPage and Evidence PacketPage, 32/32 tests pass, build clean. | Developer (FE) |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision target: embed proof inspection where operators already work, not as a separate full-page product island.
|
||||
- Risk: over-extracting the orphan viewer could bring layout or HTTP assumptions that do not fit the mounted flows.
|
||||
- Mitigation: freeze capabilities first, then extract only the reusable sections that serve mounted host pages.
|
||||
- Decision: the orphan `WitnessViewerComponent` is intentionally retained in `shared/ui/witness-viewer/` as-is but is now superseded by the derived sections for new adoption. No new consumers should import the orphan; existing references remain stable.
|
||||
- Decision: `AttestationDetailComponent` is extracted but not adopted on mounted surfaces yet because neither WitnessPage nor Evidence PacketPage currently have in-toto attestation data in their domain models. It is ready for adoption when attestation data flows arrive.
|
||||
|
||||
## Next Checkpoints
|
||||
- Freeze the witness/evidence capability map. DONE
|
||||
- Extract reusable proof-inspection sections. DONE
|
||||
- Adopt them into mounted Reachability and Evidence surfaces. DONE
|
||||
@@ -4,6 +4,9 @@
|
||||
- [DONE] `docs/implplan/SPRINT_20260308_014_FE_orphan_copy_inline_truncate_adoption.md` - CopyToClipboard, InlineCode, TruncatePipe adoption on console-admin, offline-kit, and triage replay-command surfaces.
|
||||
- [DONE] `docs/implplan/SPRINT_20260308_015_FE_orphan_filter_bar_unification.md` - Initial FilterBarComponent adoption batch; audit-log-table and trust-audit-log were later rolled back in sprint `024` to restore lost semantics.
|
||||
- [DONE] `docs-archived/implplan/SPRINT_20260308_024_FE_orphan_revival_regression_remediation.md` - Fixed reviewed orphan-revival regressions: build blockers cleared, canonical evidence-thread navigation restored, audit/trust filter capabilities restored, and fabricated finding evidence removed from mounted hosts.
|
||||
- [DOING] `docs/implplan/SPRINT_20260308_025_FE_safe_cleanup_and_generated_artifacts_prune.md` - Approved UI cleanup to prune committed generated/debug artifacts plus confirmed orphan route and legacy release-control leaves.
|
||||
- [DONE] `docs/implplan/SPRINT_20260308_026_FE_settings_information_architecture_rationalization.md` - Settings IA rationalized: personal-preferences shell with admin/ops rehoming via controlled redirects.
|
||||
- [DONE] `docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md` - Derived orphan WitnessViewerComponent into 4 reusable proof-inspection sections (VerificationSummary, SignatureInspector, AttestationDetail, EvidencePayload) adopted on Reachability WitnessPage and Evidence PacketPage.
|
||||
|
||||
## Queued Sprint Links
|
||||
- `docs/modules/ui/orphan-revival-batch/README.md` - review index for the orphan shared-component and disconnected-route revival batch.
|
||||
@@ -18,6 +21,12 @@
|
||||
- `docs/implplan/SPRINT_20260308_021_FE_unreachable_evidence_thread_and_persona_workspaces_routes.md`
|
||||
- `docs/implplan/SPRINT_20260308_022_FE_unreachable_release_investigation_routes.md`
|
||||
- `docs/implplan/SPRINT_20260308_023_FE_unreachable_registry_admin_route.md`
|
||||
- `docs/implplan/SPRINT_20260308_026_FE_settings_information_architecture_rationalization.md`
|
||||
- [DONE] `docs/implplan/SPRINT_20260308_027_FE_page_header_context_header_derivation.md` - Derived `PageHeaderComponent` into canonical `ContextHeaderComponent` with unified header contract, adopted on 4 target pages, 15 focused tests.
|
||||
- [DONE] `docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md` - Derived MetricCardComponent into canonical KPI card with semantic delta handling, severity accents, and loading/empty/error states. Adopted on 3 dashboards (12 bespoke tiles replaced). 40 tests pass.
|
||||
- [DONE] `docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md` - Derived canonical audit-grade timeline-list primitive. Adopted on incident-timeline, audit-timeline-search, and releases-activity.
|
||||
- [DONE] `docs/implplan/SPRINT_20260308_030_FE_split_pane_list_detail_shell_consolidation.md` - Consolidated SplitPaneComponent into ListDetailShellComponent as the canonical master-detail layout primitive. Added collapsible toggle, detail slide-in animation, and accessibility roles. Adopted on signing-key-dashboard. SplitPaneComponent deprecated.
|
||||
- `docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md`
|
||||
|
||||
## Delivery Tasks
|
||||
- [DONE] 041-T1 Root IA/nav rewrite (Mission Control + Ops + Setup)
|
||||
@@ -164,3 +173,15 @@
|
||||
- [DONE] FE-OFB-002 Migrate security and audit list pages to FilterBarComponent
|
||||
- [DONE] FE-OFB-003 Migrate release, evidence, and trust list pages to FilterBarComponent
|
||||
- [DONE] FE-OFB-004 Verify and document filter-bar revival
|
||||
- [DONE] FE-SPL-001 Freeze the single master-detail contract
|
||||
- [DONE] FE-SPL-002 Derive the canonical list-detail shell
|
||||
- [DONE] FE-SPL-003 Adopt the consolidated shell on bounded mounted surfaces
|
||||
- [DONE] FE-SPL-004 Verify and document the consolidation
|
||||
- [DONE] FE-SETIA-001 Audit and classify every settings route
|
||||
- [DONE] FE-SETIA-002 Freeze the target IA and backward-compatibility contract
|
||||
- [DONE] FE-SETIA-003 Build the personal-settings shell and navigation model
|
||||
- [DONE] FE-SETIA-004 Merge overlapping personal preference leaves
|
||||
- [DONE] FE-SETIA-005 Rehome admin, tenant, and operations configuration leaves
|
||||
- [DONE] FE-SETIA-006 Remove or collapse wrapper and alias-only settings pages
|
||||
- [DONE] FE-SETIA-007 Add focused route, nav, and UX regression coverage
|
||||
- [DONE] FE-SETIA-008 Sync docs and ship the IA decision
|
||||
|
||||
@@ -6,12 +6,19 @@ Provide a living plan for UI deliverables, dependencies, and evidence.
|
||||
## Active work
|
||||
- Track current sprints under `docs/implplan/SPRINT_*.md` for this module.
|
||||
- Update this file when new scoped work is approved.
|
||||
- No active UI remediation sprint is open right now.
|
||||
- Sprint `025` is active for safe cleanup of approved dead leaves and committed generated/debug artifacts in the Web workspace.
|
||||
- Sprint `026` shipped Settings IA rationalization: the Settings shell now owns only personal preferences (appearance, language, layout, AI assistant). All admin, tenant, and operations configuration leaves redirect to their canonical owners (Administration, Setup, Ops). See `docs/features/checked/web/settings-ia-rationalization-ui.md`.
|
||||
- Sprint `027` is DONE: derived `PageHeaderComponent` into canonical `ContextHeaderComponent` with unified header contract (configurable heading level, testId, ARIA), adopted on 4 target pages (RegistryAdmin, PackRegistryBrowser, DeadLetterDashboard, OfflineKit), 15 focused tests.
|
||||
- Sprint `031` (Witness Viewer Evidence Derivation) is DONE. Derived the orphan `WitnessViewerComponent` into 4 reusable proof-inspection sections (VerificationSummary, SignatureInspector, AttestationDetail, EvidencePayload) adopted on Reachability WitnessPage and Evidence PacketPage. See `docs/implplan/SPRINT_20260308_031_FE_witness_viewer_evidence_derivation.md`.
|
||||
|
||||
## Near-term deliverables
|
||||
- No active UI deliverables are currently staged in `docs/implplan`.
|
||||
- The next queued batch is `docs/modules/ui/orphan-revival-batch/README.md`, which stages independent review-ready sprints for orphan shared-component adoption and disconnected-route integration.
|
||||
- The queued orphan batch currently spans `SPRINT_20260308_013` through `SPRINT_20260308_023` and is intentionally not marked active until product review approves staffing.
|
||||
- Newly queued follow-on planning sprints cover Settings information architecture rationalization plus UX derivation tracks for the orphan `PageHeaderComponent`, `MetricCardComponent`, `TimelineListComponent`, `SplitPaneComponent`, and `WitnessViewerComponent` (`SPRINT_20260308_026` through `SPRINT_20260308_031`).
|
||||
- Sprint `028` (MetricCardComponent derivation into canonical KPI card) is DONE. The shared `MetricCardComponent` now supports semantic delta direction (`up-is-good` / `up-is-bad` / `neutral`), severity accents, loading/empty/error states, and ARIA accessibility. Adopted on signals-runtime, search-quality, and delivery-analytics dashboards. See `docs/implplan/SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation.md`.
|
||||
- Sprint `029` (TimelineListComponent derivation) is DONE. Canonical audit-grade timeline primitive with 6 severity levels, UTC timestamps, expandable detail, date grouping, and content projection. Adopted on incident-timeline, audit-timeline-search, and releases-activity. See `docs/implplan/SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation.md`.
|
||||
- Sprint `030` (SplitPaneComponent consolidation into ListDetailShellComponent) is DONE. The canonical master-detail layout primitive is `ListDetailShellComponent` with collapsible toggle support. `SplitPaneComponent` is deprecated. Adopted on signing-key-dashboard (trust-admin). See sprint file for contract details.
|
||||
- Sprint `014` (CopyToClipboard, InlineCode, TruncatePipe adoption) is DONE. See `docs/features/checked/web/orphan-copy-inline-truncate-adoption.md`.
|
||||
- Sprint `015` (FilterBarComponent adoption) shipped, then was partially rolled back on audit-family pages to restore lost filter semantics. See `docs/features/checked/web/filter-bar-unification.md` and `docs/features/checked/web/orphan-revival-regression-remediation-ui.md`.
|
||||
- Sprint `020` (FindingListComponent consolidation) shipped, then was rolled back on mounted findings and release-security hosts because the shared contract required fabricated data. See `docs/features/checked/web/orphan-finding-list-consolidation.md` and `docs/features/checked/web/orphan-revival-regression-remediation-ui.md`.
|
||||
|
||||
|
Before Width: | Height: | Size: 285 KiB |
@@ -1,101 +0,0 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const session = {
|
||||
subjectId: 'user-author',
|
||||
tenant: 'tenant-default',
|
||||
scopes: [
|
||||
'ui.read',
|
||||
'policy:read',
|
||||
'policy:author',
|
||||
'policy:simulate',
|
||||
'advisory-ai:view',
|
||||
'advisory-ai:operate',
|
||||
'findings:read',
|
||||
'vex:read',
|
||||
'admin',
|
||||
],
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--no-proxy-server'],
|
||||
});
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
const navHistory = [];
|
||||
const httpErrors = [];
|
||||
const failures = [];
|
||||
let currentUrl = '';
|
||||
|
||||
page.on('framenavigated', (frame) => {
|
||||
if (frame !== page.mainFrame()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = `${new Date().toISOString()} ${frame.url()}`;
|
||||
navHistory.push(entry);
|
||||
console.log('[nav]', entry);
|
||||
});
|
||||
|
||||
page.on('response', (response) => {
|
||||
if (response.status() < 400) {
|
||||
return;
|
||||
}
|
||||
|
||||
const request = response.request();
|
||||
const entry = `${response.status()} ${request.method()} ${response.url()}`;
|
||||
httpErrors.push(entry);
|
||||
console.log('[http-error]', entry);
|
||||
});
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
const entry = `${request.method()} ${request.url()} :: ${request.failure()?.errorText ?? 'failed'}`;
|
||||
failures.push(entry);
|
||||
console.log('[requestfailed]', entry);
|
||||
});
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
console.log('[console-error]', msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.addInitScript((stubSession) => {
|
||||
window.__stellaopsTestSession = stubSession;
|
||||
}, session);
|
||||
|
||||
const target = process.argv[2] ?? 'https://stella-ops.local/';
|
||||
console.log('[goto]', target);
|
||||
|
||||
try {
|
||||
await page.goto(target, { waitUntil: 'commit', timeout: 20000 });
|
||||
} catch (error) {
|
||||
console.log('[goto-error]', error.message);
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const url = page.url();
|
||||
if (url !== currentUrl) {
|
||||
currentUrl = url;
|
||||
console.log('[url-change]', url);
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
const searchInputCount = await page
|
||||
.evaluate(() => document.querySelectorAll('app-global-search input[type="text"]').length)
|
||||
.catch(() => -1);
|
||||
|
||||
console.log('[final-url]', page.url());
|
||||
console.log('[title]', await page.title().catch(() => '<title unavailable>'));
|
||||
console.log('[search-input-count]', searchInputCount);
|
||||
console.log('[nav-count]', navHistory.length);
|
||||
console.log('[http-error-count]', httpErrors.length);
|
||||
console.log('[failed-request-count]', failures.length);
|
||||
|
||||
await page.screenshot({ path: 'output/playwright/stella-ops-local-load-check-viewport.png' });
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -1,66 +0,0 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const session = {
|
||||
subjectId: 'user-author',
|
||||
tenant: 'tenant-default',
|
||||
scopes: ['ui.read', 'policy:read', 'policy:author', 'policy:simulate', 'advisory:search', 'advisory:read', 'search:read', 'findings:read', 'vex:read', 'admin'],
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes('/search')) {
|
||||
console.log('[requestfailed]', request.method(), url, request.failure()?.errorText);
|
||||
}
|
||||
});
|
||||
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
if (
|
||||
url.includes('/api/v1/search/query') ||
|
||||
url.includes('/api/v1/advisory-ai/search') ||
|
||||
url.includes('/api/v1/advisory-ai/search/analytics')
|
||||
) {
|
||||
const req = response.request();
|
||||
console.log('[response]', req.method(), response.status(), url);
|
||||
}
|
||||
});
|
||||
|
||||
await page.addInitScript((stubSession) => {
|
||||
window.__stellaopsTestSession = stubSession;
|
||||
}, session);
|
||||
|
||||
const url = process.argv[2] || 'https://stella-ops.local/';
|
||||
console.log('[goto]', url);
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const count = await page.evaluate(() => document.querySelectorAll('app-global-search input[type="text"]').length);
|
||||
console.log('[search-input-count]', count);
|
||||
|
||||
if (count === 0) {
|
||||
console.log('[page-url]', page.url());
|
||||
console.log('[title]', await page.title());
|
||||
await page.screenshot({ path: 'output/playwright/header-search-repro-no-input.png', fullPage: true });
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await page.click('app-global-search input[type="text"]', { timeout: 15000 });
|
||||
await page.fill('app-global-search input[type="text"]', 'critical findings', { timeout: 15000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const results = await page.evaluate(() => document.querySelectorAll('app-entity-card').length);
|
||||
const emptyText = await page.locator('.search__empty').allTextContents();
|
||||
const degradedVisible = await page.locator('.search__degraded-banner').isVisible().catch(() => false);
|
||||
console.log('[entity-cards]', results);
|
||||
console.log('[empty-text]', emptyText.join(' | '));
|
||||
console.log('[degraded-banner]', degradedVisible);
|
||||
|
||||
await page.screenshot({ path: 'output/playwright/header-search-repro-live.png', fullPage: true });
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -1,66 +0,0 @@
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const session = {
|
||||
subjectId: 'user-author',
|
||||
tenant: 'tenant-default',
|
||||
scopes: ['ui.read','policy:read','policy:author','policy:simulate','advisory:search','advisory:read','search:read','findings:read','vex:read','admin']
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
page.on('requestfailed', (request) => {
|
||||
const url = request.url();
|
||||
if (url.includes('/search')) {
|
||||
console.log('[requestfailed]', request.method(), url, request.failure()?.errorText);
|
||||
}
|
||||
});
|
||||
|
||||
page.on('response', (response) => {
|
||||
const url = response.url();
|
||||
if (
|
||||
url.includes('/api/v1/search/query') ||
|
||||
url.includes('/api/v1/advisory-ai/search') ||
|
||||
url.includes('/api/v1/advisory-ai/search/analytics')
|
||||
) {
|
||||
const req = response.request();
|
||||
console.log('[response]', req.method(), response.status(), url);
|
||||
}
|
||||
});
|
||||
|
||||
await page.addInitScript((stubSession) => {
|
||||
window.__stellaopsTestSession = stubSession;
|
||||
}, session);
|
||||
|
||||
const url = process.argv[2] || 'https://stella-ops.local:10000/';
|
||||
console.log('[goto]', url);
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const count = await page.evaluate(() => document.querySelectorAll('app-global-search input[type="text"]').length);
|
||||
console.log('[search-input-count]', count);
|
||||
|
||||
if (count === 0) {
|
||||
console.log('[page-url]', page.url());
|
||||
console.log('[title]', await page.title());
|
||||
await page.screenshot({ path: 'output/playwright/header-search-repro-no-input.png', fullPage: true });
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await page.click('app-global-search input[type="text"]', { timeout: 15000 });
|
||||
await page.fill('app-global-search input[type="text"]', 'critical findings', { timeout: 15000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
const results = await page.evaluate(() => document.querySelectorAll('app-entity-card').length);
|
||||
const emptyText = await page.locator('.search__empty').allTextContents();
|
||||
const degradedVisible = await page.locator('.search__degraded-banner').isVisible().catch(() => false);
|
||||
console.log('[entity-cards]', results);
|
||||
console.log('[empty-text]', emptyText.join(' | '));
|
||||
console.log('[degraded-banner]', degradedVisible);
|
||||
|
||||
await page.screenshot({ path: 'output/playwright/header-search-repro.png', fullPage: true });
|
||||
await browser.close();
|
||||
})();
|
||||
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 79 KiB |
@@ -637,7 +637,7 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
|
||||
{
|
||||
id: 'identity-providers',
|
||||
label: 'Identity Providers',
|
||||
route: '/settings/identity-providers',
|
||||
route: '/administration/identity-providers',
|
||||
icon: 'id-card',
|
||||
requiredScopes: ['ui.admin'],
|
||||
tooltip: 'Configure external identity providers (LDAP, SAML, OIDC)',
|
||||
|
||||
@@ -17,10 +17,11 @@ import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { NOTIFIER_API, NotifierApi } from '../../../core/api/notifier.client';
|
||||
import { NotifierDeliveryStats, NotifierDelivery } from '../../../core/api/notifier.models';
|
||||
import { MetricCardComponent } from '../../../shared/ui/metric-card/metric-card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-delivery-analytics',
|
||||
imports: [FormsModule],
|
||||
imports: [FormsModule, MetricCardComponent],
|
||||
template: `
|
||||
<div class="delivery-analytics">
|
||||
<header class="section-header">
|
||||
@@ -60,60 +61,41 @@ import { NotifierDeliveryStats, NotifierDelivery } from '../../../core/api/notif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="metric-header">
|
||||
<span class="metric-icon sent-icon">S</span>
|
||||
<span class="metric-label">Total Sent</span>
|
||||
</div>
|
||||
<div class="metric-value">{{ formatNumber(stats()!.totalSent) }}</div>
|
||||
<div class="metric-trend">
|
||||
<span class="trend-text">Delivered successfully</span>
|
||||
</div>
|
||||
</div>
|
||||
<app-metric-card
|
||||
label="Total Sent"
|
||||
[value]="formatNumber(stats()!.totalSent)"
|
||||
severity="healthy"
|
||||
subtitle="Delivered successfully"
|
||||
/>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="metric-header">
|
||||
<span class="metric-icon failed-icon">F</span>
|
||||
<span class="metric-label">Failed</span>
|
||||
</div>
|
||||
<div class="metric-value failed">{{ formatNumber(stats()!.totalFailed) }}</div>
|
||||
<div class="metric-trend">
|
||||
<span class="trend-text">Require attention</span>
|
||||
</div>
|
||||
</div>
|
||||
<app-metric-card
|
||||
label="Failed"
|
||||
[value]="formatNumber(stats()!.totalFailed)"
|
||||
[severity]="stats()!.totalFailed > 0 ? 'critical' : 'healthy'"
|
||||
subtitle="Require attention"
|
||||
/>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="metric-header">
|
||||
<span class="metric-icon pending-icon">P</span>
|
||||
<span class="metric-label">Pending</span>
|
||||
</div>
|
||||
<div class="metric-value pending">{{ formatNumber(stats()!.totalPending) }}</div>
|
||||
<div class="metric-trend">
|
||||
<span class="trend-text">In queue</span>
|
||||
</div>
|
||||
</div>
|
||||
<app-metric-card
|
||||
label="Pending"
|
||||
[value]="formatNumber(stats()!.totalPending)"
|
||||
[severity]="stats()!.totalPending > 50 ? 'warning' : 'healthy'"
|
||||
subtitle="In queue"
|
||||
/>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="metric-header">
|
||||
<span class="metric-icon throttled-icon">T</span>
|
||||
<span class="metric-label">Throttled</span>
|
||||
</div>
|
||||
<div class="metric-value throttled">{{ formatNumber(stats()!.totalThrottled) }}</div>
|
||||
<div class="metric-trend">
|
||||
<span class="trend-text">Rate limited</span>
|
||||
</div>
|
||||
</div>
|
||||
<app-metric-card
|
||||
label="Throttled"
|
||||
[value]="formatNumber(stats()!.totalThrottled)"
|
||||
[severity]="stats()!.totalThrottled > 0 ? 'warning' : 'healthy'"
|
||||
subtitle="Rate limited"
|
||||
/>
|
||||
|
||||
<div class="metric-card">
|
||||
<div class="metric-header">
|
||||
<span class="metric-icon latency-icon">L</span>
|
||||
<span class="metric-label">Avg Latency</span>
|
||||
</div>
|
||||
<div class="metric-value">{{ stats()!.avgDeliveryTimeMs }}ms</div>
|
||||
<div class="metric-trend">
|
||||
<span class="trend-text">Average delivery time</span>
|
||||
</div>
|
||||
</div>
|
||||
<app-metric-card
|
||||
label="Avg Latency"
|
||||
[value]="stats()!.avgDeliveryTimeMs"
|
||||
unit="ms"
|
||||
deltaDirection="up-is-bad"
|
||||
subtitle="Average delivery time"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Channel Breakdown -->
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
// Sprint: SPRINT_20251229_028_FE - Unified Audit Log Viewer
|
||||
import { Component, inject, signal, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
// Updated: SPRINT_20260308_029_FE - Adopt canonical timeline-list (FE-TLD-003)
|
||||
import { Component, inject, signal, computed, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuditLogClient } from '../../core/api/audit-log.client';
|
||||
import { AuditTimelineEntry } from '../../core/api/audit-log.models';
|
||||
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component';
|
||||
|
||||
function mapActionToKind(action: string): TimelineEventKind {
|
||||
const lower = action.toLowerCase();
|
||||
if (lower.includes('create') || lower.includes('approve') || lower.includes('success')) return 'success';
|
||||
if (lower.includes('delete') || lower.includes('revoke') || lower.includes('fail')) return 'error';
|
||||
if (lower.includes('update') || lower.includes('modify')) return 'warning';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-audit-timeline-search',
|
||||
imports: [RouterModule, FormsModule],
|
||||
imports: [RouterModule, FormsModule, TimelineListComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="timeline-page">
|
||||
@@ -32,36 +41,25 @@ import { AuditTimelineEntry } from '../../core/api/audit-log.models';
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (entries().length > 0) {
|
||||
<div class="timeline">
|
||||
@for (entry of entries(); track entry.timestamp) {
|
||||
<div class="timeline-entry">
|
||||
<div class="timeline-marker">
|
||||
<div class="marker-dot"></div>
|
||||
<div class="marker-line"></div>
|
||||
</div>
|
||||
<div class="entry-content">
|
||||
<div class="entry-time">{{ formatTime(entry.timestamp) }}</div>
|
||||
@if (entry.clusterSize && entry.clusterSize > 1) {
|
||||
<div class="cluster-badge">{{ entry.clusterSize }} events</div>
|
||||
}
|
||||
<div class="entry-events">
|
||||
@for (event of entry.events; track event.id) {
|
||||
<div class="event-item" [routerLink]="['/evidence/audit-log/events', event.id]">
|
||||
<span class="badge module" [class]="event.module">{{ event.module }}</span>
|
||||
<span class="badge action" [class]="event.action">{{ event.action }}</span>
|
||||
<span class="actor">{{ event.actor.name }}</span>
|
||||
<span class="desc">{{ event.description }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Canonical Timeline -->
|
||||
<app-timeline-list
|
||||
[events]="timelineEvents()"
|
||||
[loading]="searching()"
|
||||
[groupByDate]="true"
|
||||
[emptyMessage]="searched() && !searching() ? 'No events found matching your search.' : 'Enter a search query to find audit events.'"
|
||||
ariaLabel="Audit timeline search results"
|
||||
>
|
||||
<ng-template #eventContent let-event>
|
||||
@if (event.metadata && event.metadata['module']) {
|
||||
<div class="event-badges">
|
||||
<span class="badge badge--module" [attr.data-module]="event.metadata['module']">{{ event.metadata['module'] }}</span>
|
||||
@if (event.metadata['action']) {
|
||||
<span class="badge badge--action">{{ event.metadata['action'] }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else if (searched() && !searching()) {
|
||||
<div class="no-results">No events found matching your search.</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-timeline-list>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
@@ -69,36 +67,27 @@ import { AuditTimelineEntry } from '../../core/api/audit-log.models';
|
||||
.page-header { margin-bottom: 1.5rem; }
|
||||
.breadcrumb { font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
|
||||
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
|
||||
.breadcrumb a:hover { text-decoration: underline; }
|
||||
h1 { margin: 0 0 0.25rem; }
|
||||
.description { color: var(--color-text-secondary); margin: 0; }
|
||||
.search-bar { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1rem; margin-bottom: 2rem; }
|
||||
.search-bar { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1rem; margin-bottom: 1.5rem; }
|
||||
.search-bar input[type="text"] { flex: 1; min-width: 250px; padding: 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); font-size: 1rem; }
|
||||
.date-filters { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.date-filters input { padding: 0.5rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); }
|
||||
.date-filters span { color: var(--color-text-secondary); }
|
||||
.btn-primary { background: var(--color-brand-primary); color: var(--color-text-heading); border: none; padding: 0.75rem 1.5rem; border-radius: var(--radius-sm); cursor: pointer; }
|
||||
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.timeline { position: relative; }
|
||||
.timeline-entry { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.timeline-marker { display: flex; flex-direction: column; align-items: center; width: 20px; }
|
||||
.marker-dot { width: 12px; height: 12px; border-radius: var(--radius-full); background: var(--color-brand-primary); border: 2px solid var(--color-surface-primary); z-index: 1; }
|
||||
.marker-line { width: 2px; flex: 1; background: var(--color-border-primary); margin-top: 4px; }
|
||||
.timeline-entry:last-child .marker-line { display: none; }
|
||||
.entry-content { flex: 1; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1rem; }
|
||||
.entry-time { font-family: monospace; font-size: 0.85rem; color: var(--color-text-secondary); margin-bottom: 0.5rem; }
|
||||
.cluster-badge { display: inline-block; background: var(--color-surface-elevated); padding: 0.15rem 0.4rem; border-radius: var(--radius-sm); font-size: 0.75rem; margin-bottom: 0.5rem; }
|
||||
.entry-events { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||
.event-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; background: var(--color-surface-elevated); border-radius: var(--radius-sm); cursor: pointer; transition: background 0.2s; }
|
||||
.event-item:hover { background: var(--color-status-info-bg); }
|
||||
.badge { display: inline-block; padding: 0.1rem 0.35rem; border-radius: var(--radius-sm); font-size: 0.7rem; text-transform: uppercase; }
|
||||
.badge.module { background: var(--color-surface-primary); }
|
||||
.badge.module.policy { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
|
||||
.badge.module.authority { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
|
||||
.badge.module.vex { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.badge.action { background: var(--color-surface-primary); }
|
||||
.actor { font-size: 0.8rem; color: var(--color-text-secondary); }
|
||||
.desc { font-size: 0.85rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.no-results { text-align: center; padding: 3rem; color: var(--color-text-secondary); }
|
||||
|
||||
.event-badges { display: flex; gap: 0.25rem; margin-top: 0.25rem; flex-wrap: wrap; }
|
||||
.badge {
|
||||
display: inline-block; padding: 0.0625rem 0.35rem;
|
||||
border-radius: var(--radius-sm); font-size: 0.6875rem; text-transform: uppercase;
|
||||
}
|
||||
.badge--module { background: var(--color-surface-secondary); color: var(--color-text-secondary); }
|
||||
.badge--module[data-module="policy"] { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
|
||||
.badge--module[data-module="authority"] { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
|
||||
.badge--module[data-module="vex"] { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
|
||||
.badge--action { background: var(--color-surface-secondary); color: var(--color-text-secondary); }
|
||||
`]
|
||||
})
|
||||
export class AuditTimelineSearchComponent {
|
||||
@@ -112,6 +101,44 @@ export class AuditTimelineSearchComponent {
|
||||
startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
endDate = new Date().toISOString().split('T')[0];
|
||||
|
||||
/** Map audit timeline entries to canonical TimelineEvent[]. */
|
||||
readonly timelineEvents = computed<TimelineEvent[]>(() => {
|
||||
const result: TimelineEvent[] = [];
|
||||
for (const entry of this.entries()) {
|
||||
if (entry.clusterSize && entry.clusterSize > 1) {
|
||||
// Cluster: render as a single summary event
|
||||
const firstEvent = entry.events[0];
|
||||
result.push({
|
||||
id: entry.clusterId ?? entry.timestamp,
|
||||
timestamp: entry.timestamp,
|
||||
title: `${entry.clusterSize} events`,
|
||||
description: entry.events.map(e => `[${e.module}] ${e.description}`).join(' | '),
|
||||
actor: firstEvent?.actor?.name,
|
||||
eventKind: firstEvent ? mapActionToKind(firstEvent.action) : 'info',
|
||||
icon: 'summarize',
|
||||
metadata: firstEvent ? { module: firstEvent.module, action: firstEvent.action } : undefined,
|
||||
expandable: entry.events.length > 1 ? entry.events.map(e =>
|
||||
`${e.timestamp} [${e.module}/${e.action}] ${e.actor.name}: ${e.description}`
|
||||
).join('\n') : undefined,
|
||||
});
|
||||
} else {
|
||||
// Individual events
|
||||
for (const event of entry.events) {
|
||||
result.push({
|
||||
id: event.id,
|
||||
timestamp: event.timestamp ?? entry.timestamp,
|
||||
title: event.description,
|
||||
actor: event.actor?.name,
|
||||
eventKind: mapActionToKind(event.action),
|
||||
icon: 'event_note',
|
||||
metadata: { module: event.module, action: event.action },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
search(): void {
|
||||
if (!this.query.trim()) return;
|
||||
this.searching.set(true);
|
||||
@@ -124,8 +151,4 @@ export class AuditTimelineSearchComponent {
|
||||
error: () => this.searching.set(false),
|
||||
});
|
||||
}
|
||||
|
||||
formatTime(ts: string): string {
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Sprint: SPRINT_20251229_030_FE - Dead-Letter Management UI
|
||||
// Sprint 027: Adopted canonical ContextHeaderComponent
|
||||
import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular/core';
|
||||
|
||||
import { RouterModule } from '@angular/router';
|
||||
@@ -14,18 +15,20 @@ import {
|
||||
BatchReplayProgress,
|
||||
ERROR_CODE_REFERENCES,
|
||||
} from '../../core/api/deadletter.models';
|
||||
import { ContextHeaderComponent } from '../../shared/ui/context-header/context-header.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-deadletter-dashboard',
|
||||
imports: [RouterModule, FormsModule],
|
||||
imports: [RouterModule, FormsModule, ContextHeaderComponent],
|
||||
template: `
|
||||
<div class="deadletter-dashboard">
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<h1>Dead-Letter Queue Management</h1>
|
||||
<p class="subtitle">Failed job recovery and diagnostics</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<app-context-header
|
||||
eyebrow="Ops / Execution"
|
||||
title="Dead-Letter Queue Management"
|
||||
subtitle="Failed job recovery and diagnostics"
|
||||
testId="deadletter-dashboard-header"
|
||||
>
|
||||
<div header-actions class="header-actions">
|
||||
<button class="btn btn-secondary" (click)="exportData()">
|
||||
<span class="icon" aria-hidden="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"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/><path d="M12 15V3"/></svg></span>
|
||||
Export CSV
|
||||
@@ -42,7 +45,7 @@ import {
|
||||
<span class="icon" [class.spinning]="refreshing()" aria-hidden="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"><path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 3v6h-6"/></svg></span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
</app-context-header>
|
||||
|
||||
<!-- Batch Progress Banner -->
|
||||
@if (batchProgress()) {
|
||||
@@ -410,23 +413,6 @@ import {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
@@ -5,14 +5,23 @@
|
||||
* Detailed view of a single evidence packet with contents and verification.
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
|
||||
import {
|
||||
VerificationSummaryComponent,
|
||||
EvidencePayloadComponent,
|
||||
} from '../../shared/ui/witness/index';
|
||||
import type {
|
||||
VerificationSummaryData,
|
||||
EvidencePayloadData,
|
||||
} from '../../shared/ui/witness/index';
|
||||
|
||||
@Component({
|
||||
selector: 'app-evidence-packet-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
imports: [CommonModule, RouterLink, VerificationSummaryComponent, EvidencePayloadComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="evidence-packet">
|
||||
@@ -125,30 +134,16 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
</div>
|
||||
}
|
||||
@case ('verify') {
|
||||
<div class="panel">
|
||||
<h3>Verification</h3>
|
||||
<div class="verification-status">
|
||||
@if (packet().verified) {
|
||||
<div class="verification-result verification-result--success">
|
||||
<span class="verification-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg></span>
|
||||
<div>
|
||||
<strong>Signature Valid</strong>
|
||||
<p>Verified against trusted key: ops-signing-key-2026</p>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="verification-result verification-result--pending">
|
||||
<span class="verification-icon">?</span>
|
||||
<div>
|
||||
<strong>Not Yet Verified</strong>
|
||||
<p>Click verify to check signature</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="panel verify-panel" data-testid="verify-tab">
|
||||
<app-verification-summary [data]="verificationSummary()" />
|
||||
|
||||
<div class="verify-action-row">
|
||||
<button type="button" class="btn btn--primary" (click)="runVerification()">
|
||||
Run Verification
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn--primary" (click)="runVerification()">
|
||||
Run Verification
|
||||
</button>
|
||||
|
||||
<app-evidence-payload [data]="evidencePayload()" />
|
||||
</div>
|
||||
}
|
||||
@case ('proof-chain') {
|
||||
@@ -340,6 +335,9 @@ import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
.btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
|
||||
.btn--primary { background: var(--color-brand-primary); border: none; color: var(--color-text-heading); }
|
||||
.btn--secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); color: var(--color-text-primary); }
|
||||
|
||||
.verify-panel { display: grid; gap: 1rem; }
|
||||
.verify-action-row { display: flex; gap: 0.75rem; }
|
||||
`]
|
||||
})
|
||||
export class EvidencePacketPageComponent implements OnInit {
|
||||
@@ -381,6 +379,38 @@ export class EvidencePacketPageComponent implements OnInit {
|
||||
{ id: '5', type: 'Promotion', icon: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>', hash: 'sha256:m3n4o5...', time: '2h ago' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived proof-inspection section data (SPRINT-031 FE-WVD-003)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
readonly verificationSummary = computed((): VerificationSummaryData => {
|
||||
const p = this.packet();
|
||||
const pType: string = p.type;
|
||||
return {
|
||||
id: p.id,
|
||||
typeLabel: pType.charAt(0).toUpperCase() + pType.slice(1),
|
||||
typeBadge: pType === 'attestation' ? 'attestation' : pType === 'exception' ? 'bundle' : 'receipt',
|
||||
status: p.verified ? 'verified' : p.signed ? 'unverified' : 'pending',
|
||||
createdAt: p.createdAt,
|
||||
source: p.environment ?? undefined,
|
||||
};
|
||||
});
|
||||
|
||||
readonly evidencePayload = computed((): EvidencePayloadData => {
|
||||
const p = this.packet();
|
||||
return {
|
||||
evidenceId: p.id,
|
||||
rawContent: JSON.stringify(p, null, 2),
|
||||
metadata: {
|
||||
bundleDigest: p.bundleDigest,
|
||||
releaseVersion: p.releaseVersion,
|
||||
environment: p.environment,
|
||||
signed: p.signed,
|
||||
verified: p.verified,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.route.params.subscribe(params => {
|
||||
this.packetId.set(params['packetId'] || '');
|
||||
|
||||
@@ -1,35 +1,40 @@
|
||||
// Offline Kit Component
|
||||
// Sprint 026: Offline Kit Integration
|
||||
// Sprint 027: Adopted canonical ContextHeaderComponent
|
||||
|
||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { OfflineModeService } from '../../core/services/offline-mode.service';
|
||||
import { ContextHeaderComponent } from '../../shared/ui/context-header/context-header.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-offline-kit',
|
||||
imports: [RouterModule],
|
||||
imports: [RouterModule, ContextHeaderComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="offline-kit-layout">
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<h1>Offline Kit Management</h1>
|
||||
<p class="subtitle">Manage offline bundles, verify audit packages, and configure air-gap operation</p>
|
||||
<div class="page-shortcuts">
|
||||
<a routerLink="/ops/operations/feeds-airgap">Feeds & Airgap</a>
|
||||
<a routerLink="/evidence/exports">Evidence Exports</a>
|
||||
<a routerLink="/evidence/verify-replay">Verify & Replay</a>
|
||||
<a routerLink="/setup/trust-signing">Trust & Signing</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-status">
|
||||
<app-context-header
|
||||
eyebrow="Ops / Feeds & Airgap"
|
||||
title="Offline Kit Management"
|
||||
subtitle="Manage offline bundles, verify audit packages, and configure air-gap operation"
|
||||
[chips]="[isOffline() ? 'Offline' : 'Online']"
|
||||
testId="offline-kit-header"
|
||||
>
|
||||
<div header-actions class="header-status">
|
||||
<div class="connection-status" [class.offline]="isOffline()">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{ isOffline() ? 'Offline' : 'Online' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</app-context-header>
|
||||
|
||||
<div class="page-shortcuts">
|
||||
<a routerLink="/ops/operations/feeds-airgap">Feeds & Airgap</a>
|
||||
<a routerLink="/evidence/exports">Evidence Exports</a>
|
||||
<a routerLink="/evidence/verify-replay">Verify & Replay</a>
|
||||
<a routerLink="/setup/trust-signing">Trust & Signing</a>
|
||||
</div>
|
||||
|
||||
<nav class="tab-nav">
|
||||
<a routerLink="dashboard" routerLinkActive="active" class="tab-link">
|
||||
@@ -76,28 +81,8 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 1.5rem 2rem;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-heading);
|
||||
margin: 0 0 0.25rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-shortcuts {
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { UnifiedSearchClient } from '../../../core/api/unified-search.client';
|
||||
import { MetricCardComponent } from '../../../shared/ui/metric-card/metric-card.component';
|
||||
import type {
|
||||
SearchQualityAlert,
|
||||
SearchQualityTrendPoint,
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
@Component({
|
||||
selector: 'app-search-quality-dashboard',
|
||||
standalone: true,
|
||||
imports: [MetricCardComponent],
|
||||
template: `
|
||||
<div class="sqd">
|
||||
<div class="sqd__header">
|
||||
@@ -42,26 +44,30 @@ import type {
|
||||
|
||||
<!-- Summary metrics cards -->
|
||||
<div class="sqd__metrics">
|
||||
<div class="sqd__metric-card">
|
||||
<div class="sqd__metric-value">{{ metrics()?.totalSearches ?? 0 }}</div>
|
||||
<div class="sqd__metric-label">Total Searches</div>
|
||||
</div>
|
||||
<div class="sqd__metric-card">
|
||||
<div class="sqd__metric-value" [class.sqd__metric-value--warn]="(metrics()?.zeroResultRate ?? 0) > 10">
|
||||
{{ metrics()?.zeroResultRate ?? 0 }}%
|
||||
</div>
|
||||
<div class="sqd__metric-label">Zero-Result Rate</div>
|
||||
</div>
|
||||
<div class="sqd__metric-card">
|
||||
<div class="sqd__metric-value">{{ metrics()?.avgResultCount ?? 0 }}</div>
|
||||
<div class="sqd__metric-label">Avg Results / Query</div>
|
||||
</div>
|
||||
<div class="sqd__metric-card">
|
||||
<div class="sqd__metric-value" [class.sqd__metric-value--good]="(metrics()?.feedbackScore ?? 0) > 70">
|
||||
{{ metrics()?.feedbackScore ?? 0 }}%
|
||||
</div>
|
||||
<div class="sqd__metric-label">Feedback Score (Helpful)</div>
|
||||
</div>
|
||||
<app-metric-card
|
||||
label="Total Searches"
|
||||
[value]="metrics()?.totalSearches ?? 0"
|
||||
deltaDirection="up-is-good"
|
||||
/>
|
||||
<app-metric-card
|
||||
label="Zero-Result Rate"
|
||||
[value]="(metrics()?.zeroResultRate ?? 0)"
|
||||
unit="%"
|
||||
deltaDirection="up-is-bad"
|
||||
[severity]="(metrics()?.zeroResultRate ?? 0) > 10 ? 'warning' : undefined"
|
||||
/>
|
||||
<app-metric-card
|
||||
label="Avg Results / Query"
|
||||
[value]="metrics()?.avgResultCount ?? 0"
|
||||
deltaDirection="up-is-good"
|
||||
/>
|
||||
<app-metric-card
|
||||
label="Feedback Score (Helpful)"
|
||||
[value]="(metrics()?.feedbackScore ?? 0)"
|
||||
unit="%"
|
||||
deltaDirection="up-is-good"
|
||||
[severity]="(metrics()?.feedbackScore ?? 0) > 70 ? 'healthy' : undefined"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Zero-result alerts table -->
|
||||
@@ -291,34 +297,7 @@ import type {
|
||||
}
|
||||
}
|
||||
|
||||
.sqd__metric-card {
|
||||
padding: 1rem 1.25rem;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sqd__metric-value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.sqd__metric-value--warn {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.sqd__metric-value--good {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.sqd__metric-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
/* metric-card styling handled by the canonical MetricCardComponent */
|
||||
|
||||
.sqd__section {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
@@ -9,23 +9,25 @@ import {
|
||||
PackVersionRow,
|
||||
} from './models/pack-registry-browser.models';
|
||||
import { PackRegistryBrowserService } from './services/pack-registry-browser.service';
|
||||
import { ContextHeaderComponent } from '../../shared/ui/context-header/context-header.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-pack-registry-browser',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, ContextHeaderComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="pack-registry-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1>Pack Registry Browser</h1>
|
||||
<p>Browse TaskRunner packs, inspect DSSE signature state, and run compatibility-checked installs and upgrades.</p>
|
||||
</div>
|
||||
<button type="button" class="refresh-btn" (click)="refresh()" [disabled]="loading()">
|
||||
<app-context-header
|
||||
eyebrow="Ops / Execution"
|
||||
title="Pack Registry Browser"
|
||||
subtitle="Browse TaskRunner packs, inspect DSSE signature state, and run compatibility-checked installs and upgrades."
|
||||
testId="pack-registry-header"
|
||||
>
|
||||
<button header-actions type="button" class="refresh-btn" (click)="refresh()" [disabled]="loading()">
|
||||
{{ loading() ? 'Refreshing...' : 'Refresh' }}
|
||||
</button>
|
||||
</header>
|
||||
</app-context-header>
|
||||
|
||||
<section class="kpi-grid" aria-label="Pack registry summary">
|
||||
<article class="kpi-card">
|
||||
@@ -209,24 +211,6 @@ import { PackRegistryBrowserService } from './services/pack-registry-browser.ser
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin: 0.4rem 0 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
|
||||
@@ -1,56 +1,85 @@
|
||||
// Sprint: SPRINT_20251229_032_FE - Platform Health Dashboard
|
||||
// Updated: SPRINT_20260308_029_FE - Adopt canonical timeline-list (FE-TLD-003)
|
||||
import { Component, inject, signal, computed, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { PlatformHealthClient } from '../../core/api/platform-health.client';
|
||||
import {
|
||||
Incident,
|
||||
IncidentSeverity,
|
||||
INCIDENT_SEVERITY_COLORS,
|
||||
} from '../../core/api/platform-health.models';
|
||||
import { healthSloPath } from '../platform/ops/operations-paths';
|
||||
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component';
|
||||
|
||||
function mapSeverityToKind(severity: IncidentSeverity, state: string): TimelineEventKind {
|
||||
if (state === 'resolved') return 'success';
|
||||
switch (severity) {
|
||||
case 'critical': return 'critical';
|
||||
case 'warning': return 'warning';
|
||||
case 'info': return 'info';
|
||||
default: return 'neutral';
|
||||
}
|
||||
}
|
||||
|
||||
function mapSeverityToIcon(severity: IncidentSeverity, state: string): string {
|
||||
if (state === 'resolved') return 'check_circle';
|
||||
switch (severity) {
|
||||
case 'critical': return 'error';
|
||||
case 'warning': return 'warning';
|
||||
case 'info': return 'info';
|
||||
default: return 'radio_button_unchecked';
|
||||
}
|
||||
}
|
||||
|
||||
function buildExpandablePayload(incident: Incident): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (incident.rootCauseSuggestion) {
|
||||
parts.push(`Suggested Root Cause: ${incident.rootCauseSuggestion}`);
|
||||
}
|
||||
if (incident.correlatedEvents.length > 0) {
|
||||
parts.push(`Correlated Events (${incident.correlatedEvents.length}):`);
|
||||
for (const evt of incident.correlatedEvents) {
|
||||
parts.push(` ${evt.timestamp} [${evt.service}] ${evt.description}`);
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? parts.join('\n') : undefined;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-incident-timeline',
|
||||
imports: [CommonModule, RouterModule, FormsModule],
|
||||
imports: [RouterModule, FormsModule, TimelineListComponent],
|
||||
template: `
|
||||
<div class="incident-timeline p-6">
|
||||
<header class="mb-6">
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 mb-2">
|
||||
<a [routerLink]="healthOverviewPath" class="hover:text-blue-600">Platform Health</a>
|
||||
<div class="incident-timeline">
|
||||
<header class="page-header">
|
||||
<div class="breadcrumb">
|
||||
<a [routerLink]="healthOverviewPath">Platform Health</a>
|
||||
<span>/</span>
|
||||
<span>Incidents</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="header-row">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Incident Timeline</h1>
|
||||
<p class="text-gray-600 mt-1">Correlated incidents with root-cause analysis</p>
|
||||
<h1>Incident Timeline</h1>
|
||||
<p class="subtitle">Correlated incidents with root-cause analysis</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="header-actions">
|
||||
<select
|
||||
[(ngModel)]="hoursBack"
|
||||
(ngModelChange)="loadIncidents()"
|
||||
class="px-3 py-2 text-sm border rounded-md"
|
||||
>
|
||||
<option [value]="6">Last 6 hours</option>
|
||||
<option [value]="24">Last 24 hours</option>
|
||||
<option [value]="72">Last 3 days</option>
|
||||
<option [value]="168">Last 7 days</option>
|
||||
</select>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
[(ngModel)]="includeResolved"
|
||||
(ngModelChange)="loadIncidents()"
|
||||
class="rounded"
|
||||
/>
|
||||
Include resolved
|
||||
</label>
|
||||
<button
|
||||
(click)="exportReport()"
|
||||
class="px-3 py-2 text-sm border rounded-md hover:bg-gray-50"
|
||||
>
|
||||
<button type="button" class="btn-secondary" (click)="exportReport()">
|
||||
Export Report
|
||||
</button>
|
||||
</div>
|
||||
@@ -58,154 +87,160 @@ import { healthSloPath } from '../platform/ops/operations-paths';
|
||||
</header>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<section class="grid grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-lg border p-4">
|
||||
<span class="text-gray-600 text-sm">Total Incidents</span>
|
||||
<p class="text-2xl font-bold text-gray-900">{{ incidents().length }}</p>
|
||||
<section class="summary-cards">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">Total Incidents</span>
|
||||
<p class="summary-value">{{ incidents().length }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border p-4">
|
||||
<span class="text-gray-600 text-sm">Active</span>
|
||||
<p class="text-2xl font-bold text-red-600">{{ activeCount() }}</p>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">Active</span>
|
||||
<p class="summary-value summary-value--error">{{ activeCount() }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border p-4">
|
||||
<span class="text-gray-600 text-sm">Critical</span>
|
||||
<p class="text-2xl font-bold text-red-600">{{ criticalCount() }}</p>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">Critical</span>
|
||||
<p class="summary-value summary-value--error">{{ criticalCount() }}</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg border p-4">
|
||||
<span class="text-gray-600 text-sm">Resolved</span>
|
||||
<p class="text-2xl font-bold text-green-600">{{ resolvedCount() }}</p>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">Resolved</span>
|
||||
<p class="summary-value summary-value--success">{{ resolvedCount() }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Filters -->
|
||||
<section class="bg-white rounded-lg border p-4 mb-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<select
|
||||
[(ngModel)]="severityFilter"
|
||||
class="px-3 py-2 text-sm border rounded-md"
|
||||
>
|
||||
<option value="all">All Severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="info">Info</option>
|
||||
</select>
|
||||
<select
|
||||
[(ngModel)]="stateFilter"
|
||||
class="px-3 py-2 text-sm border rounded-md"
|
||||
>
|
||||
<option value="all">All States</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchQuery"
|
||||
placeholder="Search incidents..."
|
||||
class="px-3 py-2 text-sm border rounded-md flex-1"
|
||||
/>
|
||||
</div>
|
||||
<section class="filter-section">
|
||||
<select [(ngModel)]="severityFilter">
|
||||
<option value="all">All Severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="info">Info</option>
|
||||
</select>
|
||||
<select [(ngModel)]="stateFilter">
|
||||
<option value="all">All States</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
[(ngModel)]="searchQuery"
|
||||
placeholder="Search incidents..."
|
||||
class="search-input"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Timeline -->
|
||||
<section class="bg-white rounded-lg border">
|
||||
<div class="divide-y">
|
||||
@for (incident of filteredIncidents(); track incident.id) {
|
||||
<div class="p-4" [class.bg-red-50]="incident.state === 'active'">
|
||||
<div class="flex items-start gap-4">
|
||||
<!-- Timeline marker -->
|
||||
<div class="flex flex-col items-center">
|
||||
<span
|
||||
class="w-4 h-4 rounded-full"
|
||||
[class]="incident.state === 'active' ? 'bg-red-500' : 'bg-gray-400'"
|
||||
></span>
|
||||
<div class="w-0.5 h-full bg-gray-200 mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium rounded"
|
||||
[class]="INCIDENT_SEVERITY_COLORS[incident.severity]"
|
||||
>
|
||||
{{ incident.severity | uppercase }}
|
||||
</span>
|
||||
<span class="font-medium text-gray-900">{{ incident.title }}</span>
|
||||
@if (incident.state === 'resolved') {
|
||||
<span class="px-2 py-0.5 text-xs rounded bg-green-100 text-green-800">
|
||||
Resolved
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 mb-2">{{ incident.description }}</p>
|
||||
|
||||
<!-- Affected Services -->
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-sm text-gray-500">Affected:</span>
|
||||
@for (service of incident.affectedServices; track service) {
|
||||
<span class="px-2 py-0.5 text-xs bg-gray-100 rounded">{{ service }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Root Cause -->
|
||||
@if (incident.rootCauseSuggestion) {
|
||||
<div class="p-3 bg-blue-50 border border-blue-200 rounded mb-2">
|
||||
<p class="text-sm text-blue-800">
|
||||
<span class="font-medium">Suggested Root Cause:</span>
|
||||
{{ incident.rootCauseSuggestion }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Canonical Timeline -->
|
||||
<section class="timeline-section">
|
||||
<app-timeline-list
|
||||
[events]="timelineEvents()"
|
||||
[loading]="loading()"
|
||||
[groupByDate]="true"
|
||||
[emptyMessage]="loading() ? 'Loading incidents...' : 'No incidents found for the selected time range'"
|
||||
ariaLabel="Incident timeline"
|
||||
>
|
||||
<ng-template #eventContent let-event>
|
||||
@if (getIncidentForEvent(event.id); as incident) {
|
||||
@if (incident.affectedServices.length > 0) {
|
||||
<div class="affected-services">
|
||||
<span class="affected-label">Affected:</span>
|
||||
@for (service of incident.affectedServices; track service) {
|
||||
<span class="service-chip">{{ service }}</span>
|
||||
}
|
||||
|
||||
<!-- Correlated Events -->
|
||||
@if (incident.correlatedEvents.length > 0) {
|
||||
<details class="mt-2">
|
||||
<summary class="text-sm text-blue-600 cursor-pointer hover:underline">
|
||||
View {{ incident.correlatedEvents.length }} correlated events
|
||||
</summary>
|
||||
<div class="mt-2 pl-4 border-l-2 border-gray-200 space-y-2">
|
||||
@for (event of incident.correlatedEvents; track event.timestamp) {
|
||||
<div class="text-sm">
|
||||
<span class="text-gray-500">{{ event.timestamp | date:'shortTime' }}</span>
|
||||
<span class="text-gray-700 ml-2">{{ event.service }}:</span>
|
||||
<span class="text-gray-600">{{ event.description }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
|
||||
<!-- Timestamps -->
|
||||
<div class="mt-3 text-xs text-gray-500 flex items-center gap-4">
|
||||
<span>Started: {{ incident.startedAt | date:'medium' }}</span>
|
||||
@if (incident.resolvedAt) {
|
||||
<span>Resolved: {{ incident.resolvedAt | date:'medium' }}</span>
|
||||
}
|
||||
@if (incident.duration) {
|
||||
<span>Duration: {{ incident.duration }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
@if (loading()) {
|
||||
Loading incidents...
|
||||
} @else {
|
||||
No incidents found for the selected time range
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (incident.state === 'resolved') {
|
||||
<span class="resolved-badge">Resolved</span>
|
||||
}
|
||||
@if (incident.resolvedAt) {
|
||||
<span class="duration-info">Duration: {{ incident.duration ?? 'N/A' }}</span>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
</app-timeline-list>
|
||||
</section>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.incident-timeline {
|
||||
padding: 1.5rem;
|
||||
min-height: 100vh;
|
||||
background: var(--color-surface-primary);
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.page-header { display: grid; gap: 0.5rem; }
|
||||
.breadcrumb { font-size: 0.8125rem; color: var(--color-text-secondary); display: flex; gap: 0.35rem; align-items: center; }
|
||||
.breadcrumb a { color: var(--color-brand-primary); text-decoration: none; }
|
||||
.breadcrumb a:hover { text-decoration: underline; }
|
||||
h1 { margin: 0; font-size: 1.375rem; font-weight: var(--font-weight-semibold); color: var(--color-text-heading); }
|
||||
.subtitle { margin: 0.125rem 0 0; color: var(--color-text-secondary); font-size: 0.8125rem; }
|
||||
.header-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
|
||||
.header-actions { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; }
|
||||
.header-actions select, .btn-secondary {
|
||||
padding: 0.375rem 0.75rem; font-size: 0.8125rem;
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary); color: var(--color-text-primary); cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--color-surface-secondary); }
|
||||
.checkbox-label { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; color: var(--color-text-secondary); }
|
||||
|
||||
/* Summary cards */
|
||||
.summary-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; }
|
||||
.summary-card {
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary); padding: 0.75rem 1rem;
|
||||
}
|
||||
.summary-label { font-size: 0.75rem; color: var(--color-text-secondary); }
|
||||
.summary-value { margin: 0.25rem 0 0; font-size: 1.5rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); }
|
||||
.summary-value--error { color: var(--color-status-error-text); }
|
||||
.summary-value--success { color: var(--color-status-success-text); }
|
||||
|
||||
/* Filters */
|
||||
.filter-section {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary); padding: 0.75rem 1rem;
|
||||
}
|
||||
.filter-section select {
|
||||
padding: 0.375rem 0.75rem; font-size: 0.8125rem;
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
.search-input {
|
||||
flex: 1; padding: 0.375rem 0.75rem; font-size: 0.8125rem;
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
/* Timeline section */
|
||||
.timeline-section {
|
||||
border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-primary); padding: 1rem;
|
||||
}
|
||||
|
||||
/* Domain-specific chips inside content projection */
|
||||
.affected-services { display: flex; align-items: center; gap: 0.25rem; margin-top: 0.25rem; flex-wrap: wrap; }
|
||||
.affected-label { font-size: 0.6875rem; color: var(--color-text-secondary); }
|
||||
.service-chip {
|
||||
padding: 0.0625rem 0.375rem; font-size: 0.6875rem;
|
||||
background: var(--color-surface-secondary); border-radius: var(--radius-sm);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.resolved-badge {
|
||||
display: inline-block; margin-top: 0.25rem;
|
||||
padding: 0.0625rem 0.375rem; font-size: 0.6875rem;
|
||||
background: var(--color-status-success-bg); color: var(--color-status-success-text);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.duration-info {
|
||||
display: inline-block; margin-top: 0.25rem; margin-left: 0.5rem;
|
||||
font-size: 0.6875rem; color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.summary-cards { grid-template-columns: repeat(2, 1fr); }
|
||||
.header-row { flex-direction: column; }
|
||||
.filter-section { flex-wrap: wrap; }
|
||||
}
|
||||
`]
|
||||
})
|
||||
@@ -222,10 +257,7 @@ export class IncidentTimelineComponent implements OnInit {
|
||||
stateFilter = signal<'all' | 'active' | 'resolved'>('all');
|
||||
searchQuery = signal('');
|
||||
|
||||
// Expose constants
|
||||
readonly INCIDENT_SEVERITY_COLORS = INCIDENT_SEVERITY_COLORS;
|
||||
|
||||
// Computed
|
||||
// Computed counts
|
||||
activeCount = computed(() => this.incidents().filter((i) => i.state === 'active').length);
|
||||
resolvedCount = computed(() => this.incidents().filter((i) => i.state === 'resolved').length);
|
||||
criticalCount = computed(() => this.incidents().filter((i) => i.severity === 'critical').length);
|
||||
@@ -247,6 +279,26 @@ export class IncidentTimelineComponent implements OnInit {
|
||||
});
|
||||
});
|
||||
|
||||
/** Map filtered incidents to canonical TimelineEvent[]. */
|
||||
readonly timelineEvents = computed<TimelineEvent[]>(() => {
|
||||
return this.filteredIncidents().map((incident) => ({
|
||||
id: incident.id,
|
||||
timestamp: incident.startedAt,
|
||||
title: `[${incident.severity.toUpperCase()}] ${incident.title}`,
|
||||
description: incident.description,
|
||||
actor: undefined,
|
||||
eventKind: mapSeverityToKind(incident.severity, incident.state),
|
||||
icon: mapSeverityToIcon(incident.severity, incident.state),
|
||||
metadata: incident.duration ? { duration: incident.duration } : undefined,
|
||||
expandable: buildExpandablePayload(incident),
|
||||
}));
|
||||
});
|
||||
|
||||
/** Lookup incident by event ID for content projection. */
|
||||
getIncidentForEvent(eventId: string): Incident | undefined {
|
||||
return this.filteredIncidents().find((i) => i.id === eventId);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadIncidents();
|
||||
}
|
||||
|
||||
@@ -269,6 +269,19 @@
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<!-- Derived proof-inspection sections (SPRINT-031 FE-WVD-003) -->
|
||||
<section class="proof-inspection-grid" data-testid="proof-inspection-sections">
|
||||
@if (verificationSummary(); as summary) {
|
||||
<app-verification-summary [data]="summary" />
|
||||
}
|
||||
|
||||
<app-signature-inspector [signatures]="signatureDataList()" />
|
||||
|
||||
@if (evidencePayload(); as payload) {
|
||||
<app-evidence-payload [data]="payload" />
|
||||
}
|
||||
</section>
|
||||
|
||||
<app-poe-drawer
|
||||
[open]="showPoe() && !!proofArtifact()"
|
||||
[poeArtifact]="proofArtifact()"
|
||||
|
||||
@@ -292,6 +292,11 @@ h2 {
|
||||
}
|
||||
}
|
||||
|
||||
.proof-inspection-grid {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.path-row {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -27,6 +27,17 @@ import {
|
||||
findWitnessFixture,
|
||||
} from './reachability-fixtures';
|
||||
|
||||
import {
|
||||
VerificationSummaryComponent,
|
||||
SignatureInspectorComponent,
|
||||
EvidencePayloadComponent,
|
||||
} from '../../shared/ui/witness/index';
|
||||
import type {
|
||||
VerificationSummaryData,
|
||||
SignatureData,
|
||||
EvidencePayloadData,
|
||||
} from '../../shared/ui/witness/index';
|
||||
|
||||
interface WitnessPathRow {
|
||||
readonly id: string;
|
||||
readonly symbol: string;
|
||||
@@ -40,7 +51,13 @@ type MessageType = 'success' | 'error';
|
||||
@Component({
|
||||
selector: 'app-witness-page',
|
||||
standalone: true,
|
||||
imports: [CommonModule, PoEDrawerComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
PoEDrawerComponent,
|
||||
VerificationSummaryComponent,
|
||||
SignatureInspectorComponent,
|
||||
EvidencePayloadComponent,
|
||||
],
|
||||
templateUrl: './witness-page.component.html',
|
||||
styleUrls: ['./witness-page.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -156,6 +173,58 @@ export class WitnessPageComponent {
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived proof-inspection section data (SPRINT-031 FE-WVD-003)
|
||||
// Maps Reachability domain data to the shared witness section inputs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
readonly verificationSummary = computed((): VerificationSummaryData | null => {
|
||||
const w = this.witness();
|
||||
if (!w) return null;
|
||||
return {
|
||||
id: w.witnessId,
|
||||
typeLabel: 'Witness',
|
||||
typeBadge: 'witness',
|
||||
status: w.signature?.verified ? 'verified'
|
||||
: w.signature ? 'unverified'
|
||||
: 'pending',
|
||||
confidenceTier: w.confidenceTier,
|
||||
confidenceScore: w.confidenceScore,
|
||||
createdAt: w.observedAt,
|
||||
source: w.evidence.analysisMethod,
|
||||
};
|
||||
});
|
||||
|
||||
readonly signatureDataList = computed((): readonly SignatureData[] => {
|
||||
const w = this.witness();
|
||||
if (!w?.signature) return [];
|
||||
return [{
|
||||
id: w.signature.keyId,
|
||||
algorithm: w.signature.algorithm,
|
||||
keyId: w.signature.keyId,
|
||||
value: w.signature.signature,
|
||||
timestamp: w.signature.verifiedAt,
|
||||
verified: w.signature.verified ?? false,
|
||||
}];
|
||||
});
|
||||
|
||||
readonly evidencePayload = computed((): EvidencePayloadData | null => {
|
||||
const w = this.witness();
|
||||
if (!w) return null;
|
||||
return {
|
||||
evidenceId: w.witnessId,
|
||||
rawContent: JSON.stringify(w, null, 2),
|
||||
metadata: {
|
||||
scanId: w.scanId,
|
||||
vulnId: w.vulnId,
|
||||
confidenceTier: w.confidenceTier,
|
||||
isReachable: w.isReachable,
|
||||
pathHash: w.pathHash ?? 'n/a',
|
||||
analysisMethod: w.evidence.analysisMethod,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
constructor() {
|
||||
combineLatest([this.route.paramMap, this.route.queryParamMap])
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Registry Admin Component
|
||||
// Sprint 023: Registry Admin UI
|
||||
// Sprint 027: Adopted canonical ContextHeaderComponent
|
||||
|
||||
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core';
|
||||
|
||||
@@ -11,38 +12,37 @@ import {
|
||||
RegistryAdminHttpService,
|
||||
} from '../../core/api/registry-admin.client';
|
||||
import { PlanRuleDto } from '../../core/api/registry-admin.models';
|
||||
import { ContextHeaderComponent } from '../../shared/ui/context-header/context-header.component';
|
||||
|
||||
type TabType = 'plans' | 'audit';
|
||||
|
||||
@Component({
|
||||
selector: 'app-registry-admin',
|
||||
imports: [RouterModule],
|
||||
imports: [RouterModule, ContextHeaderComponent],
|
||||
providers: [
|
||||
{ provide: REGISTRY_ADMIN_API, useClass: RegistryAdminHttpService },
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="registry-admin">
|
||||
<header class="registry-admin__header">
|
||||
<div class="registry-admin__title-row">
|
||||
<div>
|
||||
<h1 class="registry-admin__title">Registry Token Service</h1>
|
||||
<p class="registry-admin__subtitle">
|
||||
Manage access plans, repository scopes, and allowlists
|
||||
</p>
|
||||
<app-context-header
|
||||
eyebrow="Setup / Integrations"
|
||||
title="Registry Token Service"
|
||||
subtitle="Manage access plans, repository scopes, and allowlists"
|
||||
[chips]="headerChips()"
|
||||
testId="registry-admin-header"
|
||||
>
|
||||
<div header-actions class="registry-admin__stats">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{{ totalPlans() }}</span>
|
||||
<span class="stat-label">Plans</span>
|
||||
</div>
|
||||
<div class="registry-admin__stats">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{{ totalPlans() }}</span>
|
||||
<span class="stat-label">Plans</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{{ enabledPlans() }}</span>
|
||||
<span class="stat-label">Enabled</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{{ enabledPlans() }}</span>
|
||||
<span class="stat-label">Enabled</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</app-context-header>
|
||||
|
||||
<nav class="registry-admin__tabs" role="tablist">
|
||||
<a
|
||||
@@ -90,30 +90,6 @@ type TabType = 'plans' | 'audit';
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.registry-admin__header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.registry-admin__title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.registry-admin__title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin: 0 0 0.25rem;
|
||||
color: var(--color-surface-secondary);
|
||||
}
|
||||
|
||||
.registry-admin__subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.registry-admin__stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -199,6 +175,14 @@ export class RegistryAdminComponent implements OnInit {
|
||||
|
||||
readonly totalPlans = computed(() => this.plans().length);
|
||||
readonly enabledPlans = computed(() => this.plans().filter((p) => p.enabled).length);
|
||||
readonly headerChips = computed(() => {
|
||||
const total = this.totalPlans();
|
||||
const enabled = this.enabledPlans();
|
||||
if (!total) {
|
||||
return [];
|
||||
}
|
||||
return [`${enabled}/${total} enabled`];
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadDashboard();
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-release-control-governance-hub',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="governance-hub">
|
||||
<header class="header">
|
||||
<h1>Governance</h1>
|
||||
<p>Policy and exception controls anchored under Release Control.</p>
|
||||
</header>
|
||||
|
||||
<div class="cards">
|
||||
<a routerLink="/ops/policy/baselines" class="card">
|
||||
<h2>Policy Baselines</h2>
|
||||
<p>Environment-scoped baseline definitions and lock rules.</p>
|
||||
</a>
|
||||
<a routerLink="/ops/policy/gates" class="card">
|
||||
<h2>Governance Rules</h2>
|
||||
<p>Rule catalog for release control gate enforcement.</p>
|
||||
</a>
|
||||
<a routerLink="/ops/policy/simulation" class="card">
|
||||
<h2>Policy Simulation</h2>
|
||||
<p>Dry-run policy evaluations before production rollout.</p>
|
||||
</a>
|
||||
<a routerLink="/ops/policy/waivers" class="card">
|
||||
<h2>Exception Workflow</h2>
|
||||
<p>Exception requests, approvals, and expiry management.</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.governance-hub {
|
||||
display: grid;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.72rem 0.8rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class ReleaseControlGovernanceHubComponent {}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-release-control-governance-section',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="governance-section">
|
||||
<header>
|
||||
<h1>{{ sectionTitle() }}</h1>
|
||||
<p>This governance area is scaffolded and ready for backend contract binding.</p>
|
||||
</header>
|
||||
|
||||
<p class="note">Canonical location: Release Control > Governance.</p>
|
||||
<a routerLink="/ops/policy">Back to Governance Hub</a>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.governance-section {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.note {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class ReleaseControlGovernanceSectionComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
readonly sectionTitle = signal(
|
||||
(this.route.snapshot.data['sectionTitle'] as string | undefined) ?? 'Governance'
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const RELEASE_CONTROL_GOVERNANCE_ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
title: 'Governance',
|
||||
data: { breadcrumb: 'Governance' },
|
||||
loadComponent: () =>
|
||||
import('./release-control-governance-hub.component').then(
|
||||
(m) => m.ReleaseControlGovernanceHubComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'baselines',
|
||||
title: 'Policy Baselines',
|
||||
data: { breadcrumb: 'Policy Baselines', sectionTitle: 'Policy Baselines' },
|
||||
loadComponent: () =>
|
||||
import('./release-control-governance-section.component').then(
|
||||
(m) => m.ReleaseControlGovernanceSectionComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'rules',
|
||||
title: 'Governance Rules',
|
||||
data: { breadcrumb: 'Governance Rules', sectionTitle: 'Governance Rules' },
|
||||
loadComponent: () =>
|
||||
import('./release-control-governance-section.component').then(
|
||||
(m) => m.ReleaseControlGovernanceSectionComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'simulation',
|
||||
title: 'Policy Simulation',
|
||||
data: { breadcrumb: 'Policy Simulation', sectionTitle: 'Policy Simulation' },
|
||||
loadComponent: () =>
|
||||
import('./release-control-governance-section.component').then(
|
||||
(m) => m.ReleaseControlGovernanceSectionComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'exceptions',
|
||||
title: 'Exception Workflow',
|
||||
data: { breadcrumb: 'Exception Workflow', sectionTitle: 'Exception Workflow' },
|
||||
loadComponent: () =>
|
||||
import('./release-control-governance-section.component').then(
|
||||
(m) => m.ReleaseControlGovernanceSectionComponent
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -1,129 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
|
||||
interface EnvironmentNode {
|
||||
id: string;
|
||||
stage: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-region-detail',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="region-detail">
|
||||
<header class="header">
|
||||
<h1>{{ regionLabel() }} Region</h1>
|
||||
<p>Pipeline posture by environment with promotion flow context.</p>
|
||||
</header>
|
||||
|
||||
<section class="summary">
|
||||
<article>
|
||||
<span>Total environments</span>
|
||||
<strong>{{ environments.length }}</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>Overall health</span>
|
||||
<strong>DEGRADED</strong>
|
||||
</article>
|
||||
<article>
|
||||
<span>SBOM posture</span>
|
||||
<strong>WARN</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="pipeline">
|
||||
@for (env of environments; track env.id) {
|
||||
<a class="pipeline-node" [routerLink]="['/releases/environments', regionLabel(), 'environments', env.id]">
|
||||
<h2>{{ env.id }}</h2>
|
||||
<p>{{ env.stage }}</p>
|
||||
<p>Status: {{ env.status }}</p>
|
||||
</a>
|
||||
}
|
||||
</section>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.region-detail {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 1.42rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.summary {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
}
|
||||
|
||||
.summary article {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.6rem 0.7rem;
|
||||
}
|
||||
|
||||
.summary span {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.summary strong {
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
|
||||
.pipeline {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||
}
|
||||
|
||||
.pipeline-node {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.65rem 0.75rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.pipeline-node h2 {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.pipeline-node p {
|
||||
margin: 0.14rem 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class RegionDetailComponent {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
readonly regionLabel = signal(this.route.snapshot.paramMap.get('region') ?? 'global');
|
||||
|
||||
readonly environments: EnvironmentNode[] = [
|
||||
{ id: 'dev', stage: 'Development', status: 'HEALTHY' },
|
||||
{ id: 'stage', stage: 'Staging', status: 'HEALTHY' },
|
||||
{ id: 'prod', stage: 'Production', status: 'DEGRADED' },
|
||||
];
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
interface RegionCard {
|
||||
id: string;
|
||||
name: string;
|
||||
envCount: number;
|
||||
health: string;
|
||||
sbomPosture: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-regions-overview',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="regions-overview">
|
||||
<header class="header">
|
||||
<h1>Regions & Environments</h1>
|
||||
<p>Region-first release control posture with environment health and SBOM coverage context.</p>
|
||||
</header>
|
||||
|
||||
<div class="cards">
|
||||
@for (region of regions; track region.id) {
|
||||
<a class="card" [routerLink]="['/releases/environments', region.id]">
|
||||
<h2>{{ region.name }}</h2>
|
||||
<p>Environments: {{ region.envCount }}</p>
|
||||
<p>Health: {{ region.health }}</p>
|
||||
<p>SBOM posture: {{ region.sbomPosture }}</p>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.regions-overview {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0 0 0.2rem;
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
|
||||
.card {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.75rem 0.85rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0.15rem 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class RegionsOverviewComponent {
|
||||
readonly regions: RegionCard[] = [
|
||||
{
|
||||
id: 'global',
|
||||
name: 'Global',
|
||||
envCount: 4,
|
||||
health: 'DEGRADED',
|
||||
sbomPosture: 'WARN',
|
||||
},
|
||||
{
|
||||
id: 'eu-west',
|
||||
name: 'EU West',
|
||||
envCount: 3,
|
||||
health: 'HEALTHY',
|
||||
sbomPosture: 'OK',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
interface SetupArea {
|
||||
title: string;
|
||||
description: string;
|
||||
route: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-release-control-setup-home',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="setup-home">
|
||||
<header class="header">
|
||||
<h1>Release Control Setup</h1>
|
||||
<p>
|
||||
Canonical setup hub for environments, promotion paths, targets, agents, workflows, and
|
||||
bundle templates.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p class="state-banner">
|
||||
Read-only structural mode: setup contracts are shown with deterministic placeholders until
|
||||
backend setup APIs are wired.
|
||||
</p>
|
||||
|
||||
<section class="areas" aria-label="Setup areas">
|
||||
@for (area of areas; track area.route) {
|
||||
<a class="card" [routerLink]="area.route">
|
||||
<h2>{{ area.title }}</h2>
|
||||
<p>{{ area.description }}</p>
|
||||
</a>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="legacy-map" aria-label="Legacy setup aliases">
|
||||
<h2>Legacy Setup Aliases</h2>
|
||||
<ul>
|
||||
<li><code>/settings/release-control</code> redirects to <code>/release-control/setup</code></li>
|
||||
<li>
|
||||
<code>/settings/release-control/environments</code> redirects to
|
||||
<code>/release-control/setup/environments-paths</code>
|
||||
</li>
|
||||
<li>
|
||||
<code>/settings/release-control/targets</code> and <code>/settings/release-control/agents</code>
|
||||
redirect to <code>/release-control/setup/targets-agents</code>
|
||||
</li>
|
||||
<li>
|
||||
<code>/settings/release-control/workflows</code> redirects to
|
||||
<code>/release-control/setup/workflows</code>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.setup-home {
|
||||
padding: 1.5rem;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.state-banner {
|
||||
margin: 0;
|
||||
border: 1px solid var(--color-status-warning-border, #facc15);
|
||||
background: var(--color-status-warning-bg, #fffbeb);
|
||||
color: var(--color-status-warning-text, #854d0e);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 0.7rem 0.85rem;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.areas {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: block;
|
||||
border: 1px solid var(--color-border-primary, #e4e7ec);
|
||||
background: var(--color-surface-primary, #fff);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 0.9rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--color-brand-primary, #2563eb);
|
||||
box-shadow: 0 3px 10px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.legacy-map {
|
||||
border: 1px solid var(--color-border-primary, #e4e7ec);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 0.9rem;
|
||||
background: var(--color-surface-primary, #fff);
|
||||
}
|
||||
|
||||
.legacy-map h2 {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.legacy-map ul {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
font-size: 0.83rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class ReleaseControlSetupHomeComponent {
|
||||
readonly areas: SetupArea[] = [
|
||||
{
|
||||
title: 'Environments and Promotion Paths',
|
||||
description: 'Define environment hierarchy and promotion routes (Dev -> Stage -> Prod).',
|
||||
route: '/release-control/setup/environments-paths',
|
||||
},
|
||||
{
|
||||
title: 'Targets and Agents',
|
||||
description: 'Track runtime targets and execution agents used by release deployments.',
|
||||
route: '/release-control/setup/targets-agents',
|
||||
},
|
||||
{
|
||||
title: 'Workflows',
|
||||
description: 'Review workflow templates and promotion execution steps before activation.',
|
||||
route: '/release-control/setup/workflows',
|
||||
},
|
||||
{
|
||||
title: 'Bundle Templates',
|
||||
description: 'Manage default bundle composition templates and validation requirements.',
|
||||
route: '/release-control/setup/bundle-templates',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-setup-bundle-templates',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page">
|
||||
<header class="header">
|
||||
<a routerLink="/ops/platform-setup" class="back-link">Back to Setup</a>
|
||||
<h1>Bundle Templates</h1>
|
||||
<p>Template presets for bundle composition, validation gates, and release metadata policy.</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Template Catalog</h2>
|
||||
<table aria-label="Bundle templates">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Template</th>
|
||||
<th>Required Sections</th>
|
||||
<th>Validation Profile</th>
|
||||
<th>Default Use</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>service-platform</td><td>digest, config, changelog, evidence</td><td>strict</td><td>platform releases</td></tr>
|
||||
<tr><td>edge-hotfix</td><td>digest, changelog, evidence</td><td>fast-track</td><td>hotfix bundle</td></tr>
|
||||
<tr><td>regional-rollout</td><td>digest, config, promotion path, evidence</td><td>risk-aware</td><td>multi-region rollout</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Template Rules</h2>
|
||||
<ul>
|
||||
<li>Template controls required builder sections before bundle version materialization.</li>
|
||||
<li>Validation profile maps to policy and advisory confidence requirements.</li>
|
||||
<li>Template changes apply only to newly created bundle versions (immutability preserved).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel links">
|
||||
<h2>Related Surfaces</h2>
|
||||
<a routerLink="/releases/bundles/create">Open Bundle Builder</a>
|
||||
<a routerLink="/releases/bundles">Open Bundle Catalog</a>
|
||||
</section>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.page {
|
||||
padding: 1.5rem;
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 0.84rem;
|
||||
color: var(--color-brand-primary, #2563eb);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0.25rem 0 0.2rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--color-border-primary, #e4e7ec);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 0.85rem;
|
||||
background: var(--color-surface-primary, #fff);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.96rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.35rem;
|
||||
border-top: 1px solid var(--color-border-primary, #e4e7ec);
|
||||
}
|
||||
|
||||
th {
|
||||
border-top: 0;
|
||||
font-size: 0.73rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--color-brand-primary, #2563eb);
|
||||
text-decoration: none;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class SetupBundleTemplatesComponent {}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-setup-environments-paths',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page">
|
||||
<header class="header">
|
||||
<a routerLink="/ops/platform-setup" class="back-link">Back to Setup</a>
|
||||
<h1>Environments and Promotion Paths</h1>
|
||||
<p>Release Control-owned environment graph and allowed promotion flows.</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Environment Inventory</h2>
|
||||
<table aria-label="Environment inventory">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Environment</th>
|
||||
<th>Region</th>
|
||||
<th>Risk Tier</th>
|
||||
<th>Promotion Entry</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>dev-us-east</td><td>us-east</td><td>low</td><td>yes</td></tr>
|
||||
<tr><td>stage-eu-west</td><td>eu-west</td><td>medium</td><td>yes</td></tr>
|
||||
<tr><td>prod-eu-west</td><td>eu-west</td><td>high</td><td>yes</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Promotion Path Rules</h2>
|
||||
<ul>
|
||||
<li><code>dev-*</code> can promote to <code>stage-*</code> with approval gates.</li>
|
||||
<li><code>stage-*</code> can promote to <code>prod-*</code> only with policy + ops gate pass.</li>
|
||||
<li>Cross-region promotion requires an explicit path definition and target parity checks.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel links">
|
||||
<h2>Related Surfaces</h2>
|
||||
<a routerLink="/releases/environments">Open Regions and Environments</a>
|
||||
<a routerLink="/releases/approvals">Open Promotions</a>
|
||||
</section>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.page {
|
||||
padding: 1.5rem;
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 0.84rem;
|
||||
color: var(--color-brand-primary, #2563eb);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0.25rem 0 0.2rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--color-border-primary, #e4e7ec);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 0.85rem;
|
||||
background: var(--color-surface-primary, #fff);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.96rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.35rem;
|
||||
border-top: 1px solid var(--color-border-primary, #e4e7ec);
|
||||
}
|
||||
|
||||
th {
|
||||
border-top: 0;
|
||||
font-size: 0.73rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--color-brand-primary, #2563eb);
|
||||
text-decoration: none;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class SetupEnvironmentsPathsComponent {}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-setup-targets-agents',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page">
|
||||
<header class="header">
|
||||
<a routerLink="/ops/platform-setup" class="back-link">Back to Setup</a>
|
||||
<h1>Targets and Agents</h1>
|
||||
<p>Release Control deployment execution topology with ownership split to Integrations.</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Deployment Targets</h2>
|
||||
<table aria-label="Deployment targets">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Target</th>
|
||||
<th>Runtime</th>
|
||||
<th>Region</th>
|
||||
<th>Agent Group</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>edge-gateway-prod</td><td>vm</td><td>eu-west</td><td>agent-eu</td><td>ready</td></tr>
|
||||
<tr><td>payments-core-stage</td><td>nomad</td><td>us-east</td><td>agent-us</td><td>ready</td></tr>
|
||||
<tr><td>billing-svc-prod</td><td>ecs</td><td>eu-west</td><td>agent-eu</td><td>degraded</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Agent Coverage</h2>
|
||||
<ul>
|
||||
<li><strong>agent-eu</strong>: 42 targets, heartbeat every 20s, upgrade window Fri 23:00 UTC.</li>
|
||||
<li><strong>agent-us</strong>: 35 targets, heartbeat every 20s, upgrade window Sat 01:00 UTC.</li>
|
||||
<li><strong>agent-apac</strong>: 18 targets, on-call watch enabled, runtime drift checks active.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel links">
|
||||
<h2>Ownership Links</h2>
|
||||
<a routerLink="/integrations/hosts">
|
||||
Connector connectivity and credentials are managed in Integrations > Targets / Runtimes
|
||||
</a>
|
||||
<a routerLink="/platform-ops/agents">Operational status and diagnostics are managed in Platform Ops > Agents</a>
|
||||
</section>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.page {
|
||||
padding: 1.5rem;
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 0.84rem;
|
||||
color: var(--color-brand-primary, #2563eb);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0.25rem 0 0.2rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--color-border-primary, #e4e7ec);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 0.85rem;
|
||||
background: var(--color-surface-primary, #fff);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.96rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.35rem;
|
||||
border-top: 1px solid var(--color-border-primary, #e4e7ec);
|
||||
}
|
||||
|
||||
th {
|
||||
border-top: 0;
|
||||
font-size: 0.73rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--color-brand-primary, #2563eb);
|
||||
text-decoration: none;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class SetupTargetsAgentsComponent {}
|
||||
@@ -1,133 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-setup-workflows',
|
||||
standalone: true,
|
||||
imports: [RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="page">
|
||||
<header class="header">
|
||||
<a routerLink="/ops/platform-setup" class="back-link">Back to Setup</a>
|
||||
<h1>Workflows</h1>
|
||||
<p>Release Control workflow definitions for promotion orchestration and approval sequencing.</p>
|
||||
</header>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Workflow Catalog</h2>
|
||||
<table aria-label="Workflow catalog">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Workflow</th>
|
||||
<th>Path</th>
|
||||
<th>Gate Profile</th>
|
||||
<th>Rollback</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>standard-blue-green</td><td>dev -> stage -> prod</td><td>strict-prod</td><td>auto</td></tr>
|
||||
<tr><td>canary-regional</td><td>stage -> prod-canary -> prod</td><td>risk-aware</td><td>manual</td></tr>
|
||||
<tr><td>hotfix-fast-track</td><td>stage -> prod</td><td>expedited</td><td>manual</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Execution Constraints</h2>
|
||||
<ul>
|
||||
<li>All workflows require a bundle version digest and resolved inputs before promotion launch.</li>
|
||||
<li>Approval checkpoints inherit policy gates from Administration policy governance baseline.</li>
|
||||
<li>Run timeline evidence checkpoints are mandatory for promotion completion.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="panel links">
|
||||
<h2>Related Surfaces</h2>
|
||||
<a routerLink="/administration/workflows">Open legacy workflow editor surface</a>
|
||||
<a routerLink="/releases/runs">Open Run Timeline</a>
|
||||
</section>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.page {
|
||||
padding: 1.5rem;
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 0.84rem;
|
||||
color: var(--color-brand-primary, #2563eb);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
margin: 0.25rem 0 0.2rem;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--color-border-primary, #e4e7ec);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
padding: 0.85rem;
|
||||
background: var(--color-surface-primary, #fff);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
margin: 0 0 0.6rem;
|
||||
font-size: 0.96rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 0.45rem 0.35rem;
|
||||
border-top: 1px solid var(--color-border-primary, #e4e7ec);
|
||||
}
|
||||
|
||||
th {
|
||||
border-top: 0;
|
||||
font-size: 0.73rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
color: var(--color-text-secondary, #667085);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.links {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.links a {
|
||||
color: var(--color-brand-primary, #2563eb);
|
||||
text-decoration: none;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class SetupWorkflowsComponent {}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { take } from 'rxjs';
|
||||
|
||||
import { PlatformContextStore } from '../../core/context/platform-context.store';
|
||||
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component';
|
||||
|
||||
interface ReleaseActivityProjection {
|
||||
activityId: string;
|
||||
@@ -24,10 +25,28 @@ interface PlatformListResponse<T> {
|
||||
count: number;
|
||||
}
|
||||
|
||||
function deriveOutcomeKind(status: string): TimelineEventKind {
|
||||
const lower = status.toLowerCase();
|
||||
if (lower.includes('published') || lower.includes('approved') || lower.includes('deployed')) return 'success';
|
||||
if (lower.includes('blocked') || lower.includes('rejected') || lower.includes('failed')) return 'error';
|
||||
if (lower.includes('pending_approval')) return 'warning';
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function deriveOutcomeIcon(status: string): string {
|
||||
const lower = status.toLowerCase();
|
||||
if (lower.includes('published') || lower.includes('deployed')) return 'rocket_launch';
|
||||
if (lower.includes('approved')) return 'check_circle';
|
||||
if (lower.includes('blocked') || lower.includes('rejected')) return 'block';
|
||||
if (lower.includes('failed')) return 'error';
|
||||
if (lower.includes('pending_approval')) return 'pending';
|
||||
return 'play_circle';
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-releases-activity',
|
||||
standalone: true,
|
||||
imports: [RouterLink, FormsModule],
|
||||
imports: [RouterLink, FormsModule, TimelineListComponent],
|
||||
template: `
|
||||
<section class="activity">
|
||||
<header>
|
||||
@@ -97,49 +116,83 @@ interface PlatformListResponse<T> {
|
||||
@if (loading()) {
|
||||
<div class="banner">Loading release runs...</div>
|
||||
} @else {
|
||||
@if (viewMode() === 'correlations') {
|
||||
<div class="clusters">
|
||||
@for (cluster of correlationClusters(); track cluster.key) {
|
||||
<article>
|
||||
<h3>{{ cluster.key }}</h3>
|
||||
<p>{{ cluster.count }} events · {{ cluster.releases }} release version(s)</p>
|
||||
<p>{{ cluster.environments }}</p>
|
||||
</article>
|
||||
} @empty {
|
||||
<div class="banner">No run correlations match the current filters.</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Release Version</th>
|
||||
<th>Lane</th>
|
||||
<th>Outcome</th>
|
||||
<th>Environment</th>
|
||||
<th>Needs Approval</th>
|
||||
<th>Data Integrity</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows(); track row.activityId) {
|
||||
<tr>
|
||||
<td><a [routerLink]="['/releases/runs', row.releaseId, 'summary']">{{ row.activityId }}</a></td>
|
||||
<td>{{ row.releaseName }}</td>
|
||||
<td>{{ deriveLane(row) }}</td>
|
||||
<td>{{ deriveOutcome(row) }}</td>
|
||||
<td>{{ row.targetRegion || '-' }}/{{ row.targetEnvironment || '-' }}</td>
|
||||
<td>{{ deriveNeedsApproval(row) ? 'yes' : 'no' }}</td>
|
||||
<td>{{ deriveDataIntegrity(row) }}</td>
|
||||
<td>{{ formatDate(row.occurredAt) }}</td>
|
||||
</tr>
|
||||
@switch (viewMode()) {
|
||||
@case ('timeline') {
|
||||
<!-- Canonical timeline rendering -->
|
||||
<div class="timeline-container">
|
||||
<app-timeline-list
|
||||
[events]="timelineEvents()"
|
||||
[loading]="loading()"
|
||||
[groupByDate]="true"
|
||||
emptyMessage="No runs match the active filters."
|
||||
ariaLabel="Release activity timeline"
|
||||
>
|
||||
<ng-template #eventContent let-event>
|
||||
@if (event.metadata) {
|
||||
<div class="run-meta">
|
||||
@if (event.metadata['lane']) {
|
||||
<span class="run-chip">{{ event.metadata['lane'] }}</span>
|
||||
}
|
||||
@if (event.metadata['environment']) {
|
||||
<span class="run-chip">{{ event.metadata['environment'] }}</span>
|
||||
}
|
||||
@if (event.metadata['outcome']) {
|
||||
<span class="run-chip run-chip--outcome" [attr.data-outcome]="event.metadata['outcome']">{{ event.metadata['outcome'] }}</span>
|
||||
}
|
||||
@if (event.evidenceLink) {
|
||||
<a class="run-link" [routerLink]="event.evidenceLink">View run</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</ng-template>
|
||||
</app-timeline-list>
|
||||
</div>
|
||||
}
|
||||
@case ('correlations') {
|
||||
<div class="clusters">
|
||||
@for (cluster of correlationClusters(); track cluster.key) {
|
||||
<article>
|
||||
<h3>{{ cluster.key }}</h3>
|
||||
<p>{{ cluster.count }} events · {{ cluster.releases }} release version(s)</p>
|
||||
<p>{{ cluster.environments }}</p>
|
||||
</article>
|
||||
} @empty {
|
||||
<tr><td colspan="8">No runs match the active filters.</td></tr>
|
||||
<div class="banner">No run correlations match the current filters.</div>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@default {
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Release Version</th>
|
||||
<th>Lane</th>
|
||||
<th>Outcome</th>
|
||||
<th>Environment</th>
|
||||
<th>Needs Approval</th>
|
||||
<th>Data Integrity</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows(); track row.activityId) {
|
||||
<tr>
|
||||
<td><a [routerLink]="['/releases/runs', row.releaseId, 'summary']">{{ row.activityId }}</a></td>
|
||||
<td>{{ row.releaseName }}</td>
|
||||
<td>{{ deriveLane(row) }}</td>
|
||||
<td>{{ deriveOutcome(row) }}</td>
|
||||
<td>{{ row.targetRegion || '-' }}/{{ row.targetEnvironment || '-' }}</td>
|
||||
<td>{{ deriveNeedsApproval(row) ? 'yes' : 'no' }}</td>
|
||||
<td>{{ deriveDataIntegrity(row) }}</td>
|
||||
<td>{{ formatDate(row.occurredAt) }}</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr><td colspan="8">No runs match the active filters.</td></tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
@@ -155,6 +208,18 @@ interface PlatformListResponse<T> {
|
||||
.banner{padding:.7rem;font-size:.8rem;color:var(--color-text-secondary)} .banner.error{color:var(--color-status-error-text)}
|
||||
table{width:100%;border-collapse:collapse}th,td{border-bottom:1px solid var(--color-border-primary);padding:.4rem .5rem;font-size:.72rem;text-align:left;vertical-align:top}th{font-size:.66rem;color:var(--color-text-secondary);text-transform:uppercase}
|
||||
tr:last-child td{border-bottom:none}.clusters{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.45rem}.clusters article{padding:.55rem}.clusters h3{margin:0;font-size:.82rem}.clusters p{margin:.2rem 0;color:var(--color-text-secondary);font-size:.74rem}
|
||||
|
||||
/* Timeline container */
|
||||
.timeline-container{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary);padding:.75rem}
|
||||
|
||||
/* Domain-specific run metadata */
|
||||
.run-meta{display:flex;gap:.25rem;margin-top:.25rem;align-items:center;flex-wrap:wrap}
|
||||
.run-chip{padding:.0625rem .35rem;font-size:.66rem;border-radius:var(--radius-sm);background:var(--color-surface-secondary);color:var(--color-text-secondary)}
|
||||
.run-chip--outcome[data-outcome="success"]{background:var(--color-status-success-bg);color:var(--color-status-success-text)}
|
||||
.run-chip--outcome[data-outcome="failed"]{background:var(--color-status-error-bg);color:var(--color-status-error-text)}
|
||||
.run-chip--outcome[data-outcome="in_progress"]{background:var(--color-status-info-bg);color:var(--color-status-info-text)}
|
||||
.run-link{font-size:.7rem;color:var(--color-brand-primary);text-decoration:none;margin-left:.25rem}
|
||||
.run-link:hover{text-decoration:underline}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
@@ -202,6 +267,25 @@ export class ReleasesActivityComponent {
|
||||
return rows;
|
||||
});
|
||||
|
||||
/** Map filtered rows to canonical TimelineEvent[] for the timeline view mode. */
|
||||
readonly timelineEvents = computed<TimelineEvent[]>(() => {
|
||||
return this.filteredRows().map((row) => ({
|
||||
id: row.activityId,
|
||||
timestamp: row.occurredAt,
|
||||
title: `${row.releaseName} - ${row.eventType}`,
|
||||
description: `${row.targetRegion ?? '-'}/${row.targetEnvironment ?? '-'} · ${row.status}`,
|
||||
actor: row.actorId || undefined,
|
||||
eventKind: deriveOutcomeKind(row.status),
|
||||
icon: deriveOutcomeIcon(row.status),
|
||||
evidenceLink: `/releases/runs/${row.releaseId}/summary`,
|
||||
metadata: {
|
||||
lane: this.deriveLane(row),
|
||||
environment: `${row.targetRegion ?? '-'}/${row.targetEnvironment ?? '-'}`,
|
||||
outcome: this.deriveOutcome(row),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
readonly correlationClusters = computed(() => {
|
||||
const map = new Map<string, { key: string; count: number; releaseSet: Set<string>; envSet: Set<string> }>();
|
||||
for (const row of this.filteredRows()) {
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
/**
|
||||
* Settings Page Component (Shell)
|
||||
* Sprint: SPRINT_20260118_002_FE_settings_consolidation (SETTINGS-001)
|
||||
* Settings Page Component (Personal Preferences Shell)
|
||||
* Sprint: SPRINT_20260308_026_FE_settings_information_architecture_rationalization
|
||||
*
|
||||
* Shell page with sidebar navigation for all settings sections.
|
||||
* The Settings shell now owns only personal user preferences.
|
||||
* Admin, tenant, and operations configuration have been rehomed
|
||||
* to their canonical owners (Setup, Administration, Ops).
|
||||
*/
|
||||
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
/**
|
||||
* Settings Page Component (Shell)
|
||||
*
|
||||
* Navigation is handled by the global sidebar.
|
||||
* This shell provides the content area for settings sub-routes.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-settings-page',
|
||||
imports: [RouterOutlet],
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
/**
|
||||
* Settings Routes
|
||||
* Sprint: SPRINT_20260118_002_FE_settings_consolidation
|
||||
* Settings Routes — Personal Preferences Shell
|
||||
* Sprint: SPRINT_20260308_026_FE_settings_information_architecture_rationalization
|
||||
*
|
||||
* Settings now owns only personal user preferences (appearance, language,
|
||||
* layout, AI assistant). All admin, tenant, and operations configuration
|
||||
* leaves have been rehomed to their canonical owners with backward-compatible
|
||||
* redirects preserved for legacy bookmarks.
|
||||
*/
|
||||
|
||||
import { inject } from '@angular/core';
|
||||
import { Router, Routes } from '@angular/router';
|
||||
|
||||
function redirectToCanonicalSetup(path: string) {
|
||||
function redirectToCanonical(path: string) {
|
||||
return ({
|
||||
params,
|
||||
queryParams,
|
||||
@@ -38,125 +43,15 @@ export const SETTINGS_ROUTES: Routes = [
|
||||
import('./settings-page.component').then(m => m.SettingsPageComponent),
|
||||
data: {},
|
||||
children: [
|
||||
// -----------------------------------------------------------------------
|
||||
// Personal preferences (canonical owner: Settings)
|
||||
// -----------------------------------------------------------------------
|
||||
{
|
||||
path: '',
|
||||
title: 'Integrations',
|
||||
title: 'User Preferences',
|
||||
loadComponent: () =>
|
||||
import('./integrations/integrations-settings-page.component').then(m => m.IntegrationsSettingsPageComponent),
|
||||
data: { breadcrumb: 'Integrations' },
|
||||
},
|
||||
{
|
||||
path: 'integrations',
|
||||
title: 'Integrations',
|
||||
loadComponent: () =>
|
||||
import('./integrations/integrations-settings-page.component').then(m => m.IntegrationsSettingsPageComponent),
|
||||
data: { breadcrumb: 'Integrations' },
|
||||
},
|
||||
{
|
||||
path: 'integrations/:id',
|
||||
title: 'Integration Detail',
|
||||
loadComponent: () =>
|
||||
import('./integrations/integration-detail-page.component').then(m => m.IntegrationDetailPageComponent),
|
||||
data: { breadcrumb: 'Integration Detail' },
|
||||
},
|
||||
{
|
||||
path: 'configuration-pane',
|
||||
title: 'Configuration Pane',
|
||||
loadComponent: () =>
|
||||
import('../configuration-pane/components/configuration-pane.component').then(m => m.ConfigurationPaneComponent),
|
||||
data: { breadcrumb: 'Configuration Pane' },
|
||||
},
|
||||
{
|
||||
path: 'release-control',
|
||||
title: 'Release Control',
|
||||
loadComponent: () =>
|
||||
import('./release-control/release-control-settings-page.component').then(m => m.ReleaseControlSettingsPageComponent),
|
||||
data: { breadcrumb: 'Release Control' },
|
||||
},
|
||||
{
|
||||
path: 'trust',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonicalSetup('/setup/trust-signing'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust/issuers',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/issuers'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust/:page/:child',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/:page/:child'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust/:page',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/:page'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust-signing',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonicalSetup('/setup/trust-signing'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust-signing/:page',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/:page'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust-signing/:page/:child',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonicalSetup('/setup/trust-signing/:page/:child'),
|
||||
data: { breadcrumb: 'Trust & Signing' },
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'security-data',
|
||||
title: 'Security Data',
|
||||
loadComponent: () =>
|
||||
import('./security-data/security-data-settings-page.component').then(m => m.SecurityDataSettingsPageComponent),
|
||||
data: { breadcrumb: 'Security Data' },
|
||||
},
|
||||
{
|
||||
path: 'admin',
|
||||
title: 'Identity & Access',
|
||||
loadComponent: () =>
|
||||
import('./admin/admin-settings-page.component').then(m => m.AdminSettingsPageComponent),
|
||||
data: { breadcrumb: 'Identity & Access' },
|
||||
},
|
||||
{
|
||||
path: 'admin/:page',
|
||||
title: 'Identity & Access',
|
||||
loadComponent: () =>
|
||||
import('./admin/admin-settings-page.component').then(m => m.AdminSettingsPageComponent),
|
||||
data: { breadcrumb: 'Identity & Access' },
|
||||
},
|
||||
{
|
||||
path: 'branding',
|
||||
title: 'Tenant & Branding',
|
||||
loadComponent: () =>
|
||||
import('./branding/branding-settings-page.component').then(m => m.BrandingSettingsPageComponent),
|
||||
data: { breadcrumb: 'Tenant & Branding' },
|
||||
},
|
||||
{
|
||||
path: 'usage',
|
||||
title: 'Usage & Limits',
|
||||
loadComponent: () =>
|
||||
import('./usage/usage-settings-page.component').then(m => m.UsageSettingsPageComponent),
|
||||
data: { breadcrumb: 'Usage & Limits' },
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
title: 'Notifications',
|
||||
loadComponent: () =>
|
||||
import('./notifications/notifications-settings-page.component').then(m => m.NotificationsSettingsPageComponent),
|
||||
data: { breadcrumb: 'Notifications' },
|
||||
import('./user-preferences/user-preferences-page.component').then(m => m.UserPreferencesPageComponent),
|
||||
data: { breadcrumb: 'User Preferences' },
|
||||
},
|
||||
{
|
||||
path: 'user-preferences',
|
||||
@@ -165,12 +60,15 @@ export const SETTINGS_ROUTES: Routes = [
|
||||
import('./user-preferences/user-preferences-page.component').then(m => m.UserPreferencesPageComponent),
|
||||
data: { breadcrumb: 'User Preferences' },
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Merged personal preference leaves (redirects to user-preferences)
|
||||
// -----------------------------------------------------------------------
|
||||
{
|
||||
path: 'language',
|
||||
title: 'Language',
|
||||
loadComponent: () =>
|
||||
import('./language/language-settings-page.component').then(m => m.LanguageSettingsPageComponent),
|
||||
data: { breadcrumb: 'Language' },
|
||||
redirectTo: 'user-preferences',
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'ai-preferences',
|
||||
@@ -178,35 +76,143 @@ export const SETTINGS_ROUTES: Routes = [
|
||||
redirectTo: 'user-preferences',
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Admin/tenant config redirects -> canonical administration owner
|
||||
// -----------------------------------------------------------------------
|
||||
{
|
||||
path: 'policy',
|
||||
title: 'Policy Governance',
|
||||
loadComponent: () =>
|
||||
import('./policy/policy-governance-settings-page.component').then(m => m.PolicyGovernanceSettingsPageComponent),
|
||||
data: { breadcrumb: 'Policy Governance' },
|
||||
path: 'admin',
|
||||
title: 'Identity & Access',
|
||||
redirectTo: redirectToCanonical('/administration/admin'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'offline',
|
||||
title: 'Offline Settings',
|
||||
loadComponent: () =>
|
||||
import('../offline-kit/components/offline-dashboard.component').then(m => m.OfflineDashboardComponent),
|
||||
data: { breadcrumb: 'Offline Settings' },
|
||||
path: 'admin/:page',
|
||||
title: 'Identity & Access',
|
||||
redirectTo: redirectToCanonical('/administration/admin/:page'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'branding',
|
||||
title: 'Tenant & Branding',
|
||||
redirectTo: redirectToCanonical('/console/admin/branding'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'usage',
|
||||
title: 'Usage & Limits',
|
||||
redirectTo: redirectToCanonical('/setup/usage'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
title: 'Notifications',
|
||||
redirectTo: redirectToCanonical('/setup/notifications'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'identity-providers',
|
||||
title: 'Identity Providers',
|
||||
loadComponent: () =>
|
||||
import('./identity-providers/identity-providers-settings-page.component').then(
|
||||
(m) => m.IdentityProvidersSettingsPageComponent,
|
||||
),
|
||||
data: { breadcrumb: 'Identity Providers' },
|
||||
redirectTo: redirectToCanonical('/administration/identity-providers'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
title: 'System',
|
||||
loadComponent: () =>
|
||||
import('./system/system-settings-page.component').then(m => m.SystemSettingsPageComponent),
|
||||
data: { breadcrumb: 'System' },
|
||||
redirectTo: redirectToCanonical('/administration/system'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'security-data',
|
||||
title: 'Security Data',
|
||||
redirectTo: redirectToCanonical('/administration/security-data'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Operations config redirects -> canonical ops/setup owners
|
||||
// -----------------------------------------------------------------------
|
||||
{
|
||||
path: 'integrations',
|
||||
title: 'Integrations',
|
||||
redirectTo: redirectToCanonical('/setup/integrations'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'integrations/:id',
|
||||
title: 'Integration Detail',
|
||||
redirectTo: redirectToCanonical('/setup/integrations/:id'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'policy',
|
||||
title: 'Policy Governance',
|
||||
redirectTo: redirectToCanonical('/ops/policy/governance'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'offline',
|
||||
title: 'Offline Settings',
|
||||
redirectTo: redirectToCanonical('/administration/offline'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'release-control',
|
||||
title: 'Release Control',
|
||||
redirectTo: redirectToCanonical('/setup/topology/environments'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'configuration-pane',
|
||||
title: 'Configuration Pane',
|
||||
redirectTo: redirectToCanonical('/ops/platform-setup'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Trust redirects (already existed, preserved)
|
||||
// -----------------------------------------------------------------------
|
||||
{
|
||||
path: 'trust',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonical('/setup/trust-signing'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust/issuers',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonical('/setup/trust-signing/issuers'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust/:page/:child',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonical('/setup/trust-signing/:page/:child'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust/:page',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonical('/setup/trust-signing/:page'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust-signing',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonical('/setup/trust-signing'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust-signing/:page',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonical('/setup/trust-signing/:page'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
{
|
||||
path: 'trust-signing/:page/:child',
|
||||
title: 'Trust & Signing',
|
||||
redirectTo: redirectToCanonical('/setup/trust-signing/:page/:child'),
|
||||
pathMatch: 'full' as const,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -3,11 +3,12 @@ import { CommonModule } from '@angular/common';
|
||||
|
||||
import { HostProbeHealth, ProbeHealthState, SignalsRuntimeDashboardViewModel } from './models/signals-runtime-dashboard.models';
|
||||
import { SignalsRuntimeDashboardService } from './services/signals-runtime-dashboard.service';
|
||||
import { MetricCardComponent } from '../../shared/ui/metric-card/metric-card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-signals-runtime-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, MetricCardComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="signals-page">
|
||||
@@ -27,21 +28,26 @@ import { SignalsRuntimeDashboardService } from './services/signals-runtime-dashb
|
||||
|
||||
@if (vm(); as dashboard) {
|
||||
<section class="metrics-grid" aria-label="Signal runtime metrics">
|
||||
<article class="metric-card">
|
||||
<h2>Signals / sec</h2>
|
||||
<p>{{ dashboard.metrics.signalsPerSecond | number:'1.0-2' }}</p>
|
||||
<small>Last hour events: {{ dashboard.metrics.lastHourCount }}</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Error rate</h2>
|
||||
<p>{{ dashboard.metrics.errorRatePercent | number:'1.0-2' }}%</p>
|
||||
<small>Total signals: {{ dashboard.metrics.totalSignals }}</small>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<h2>Avg latency</h2>
|
||||
<p>{{ dashboard.metrics.averageLatencyMs | number:'1.0-0' }} ms</p>
|
||||
<small>Gateway-backed when available</small>
|
||||
</article>
|
||||
<app-metric-card
|
||||
label="Signals / sec"
|
||||
[value]="(dashboard.metrics.signalsPerSecond | number:'1.0-2') ?? '--'"
|
||||
deltaDirection="up-is-good"
|
||||
[subtitle]="'Last hour events: ' + dashboard.metrics.lastHourCount"
|
||||
/>
|
||||
<app-metric-card
|
||||
label="Error rate"
|
||||
[value]="(dashboard.metrics.errorRatePercent | number:'1.0-2') ?? '--'"
|
||||
unit="%"
|
||||
deltaDirection="up-is-bad"
|
||||
[subtitle]="'Total signals: ' + dashboard.metrics.totalSignals"
|
||||
/>
|
||||
<app-metric-card
|
||||
label="Avg latency"
|
||||
[value]="(dashboard.metrics.averageLatencyMs | number:'1.0-0') ?? '--'"
|
||||
unit="ms"
|
||||
deltaDirection="up-is-bad"
|
||||
subtitle="Gateway-backed when available"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="summary-grid">
|
||||
@@ -174,31 +180,7 @@ import { SignalsRuntimeDashboardService } from './services/signals-runtime-dashb
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--color-surface-secondary);
|
||||
background: var(--color-surface-primary);
|
||||
padding: 0.9rem;
|
||||
}
|
||||
|
||||
.metric-card h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.metric-card p {
|
||||
margin: 0.4rem 0 0.2rem;
|
||||
font-size: 1.8rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
.metric-card small {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
/* metric-card styling handled by the canonical MetricCardComponent */
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -19,10 +19,11 @@ import {
|
||||
import { KeyDetailPanelComponent } from './key-detail-panel.component';
|
||||
import { KeyExpiryWarningComponent } from './key-expiry-warning.component';
|
||||
import { KeyRotationWizardComponent } from './key-rotation-wizard.component';
|
||||
import { ListDetailShellComponent } from '../../shared/ui/list-detail-shell/list-detail-shell.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-signing-key-dashboard',
|
||||
imports: [CommonModule, FormsModule, KeyDetailPanelComponent, KeyExpiryWarningComponent, KeyRotationWizardComponent],
|
||||
imports: [CommonModule, FormsModule, KeyDetailPanelComponent, KeyExpiryWarningComponent, KeyRotationWizardComponent, ListDetailShellComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="key-dashboard">
|
||||
@@ -86,8 +87,14 @@ import { KeyRotationWizardComponent } from './key-rotation-wizard.component';
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Keys Table -->
|
||||
<div class="key-dashboard__table-container">
|
||||
<!-- Keys Table + Detail Shell -->
|
||||
<app-list-detail-shell
|
||||
[detailVisible]="!!selectedKey()"
|
||||
[collapsible]="true"
|
||||
detailWidth="28rem"
|
||||
(detailClosed)="selectedKey.set(null)"
|
||||
>
|
||||
<div shell-primary class="key-dashboard__table-container">
|
||||
@if (loading()) {
|
||||
<div class="key-dashboard__loading">Loading signing keys...</div>
|
||||
} @else if (error()) {
|
||||
@@ -242,17 +249,20 @@ import { KeyRotationWizardComponent } from './key-rotation-wizard.component';
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Detail Panel -->
|
||||
@if (selectedKey()) {
|
||||
<app-key-detail-panel
|
||||
[key]="selectedKey()!"
|
||||
(close)="selectedKey.set(null)"
|
||||
(rotateKey)="openRotationWizard($event)"
|
||||
(revokeKey)="onRevokeKeyById($event)"
|
||||
></app-key-detail-panel>
|
||||
}
|
||||
<!-- Detail Panel (projected into shell-detail slot) -->
|
||||
@if (selectedKey()) {
|
||||
<aside shell-detail>
|
||||
<app-key-detail-panel
|
||||
[key]="selectedKey()!"
|
||||
(close)="selectedKey.set(null)"
|
||||
(rotateKey)="openRotationWizard($event)"
|
||||
(revokeKey)="onRevokeKeyById($event)"
|
||||
></app-key-detail-panel>
|
||||
</aside>
|
||||
}
|
||||
</app-list-detail-shell>
|
||||
|
||||
<!-- Rotation Wizard -->
|
||||
<!-- Rotation Wizard (outside shell — overlays both panes) -->
|
||||
@if (rotatingKey()) {
|
||||
<app-key-rotation-wizard
|
||||
[key]="rotatingKey()!"
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// Copyright (c) Stella Ops. All rights reserved. SPDX-License-Identifier: BUSL-1.1
|
||||
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const workflowVisualizationRoutes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadComponent: () =>
|
||||
import('./components/workflow-visualizer/workflow-visualizer.component').then(
|
||||
(m) => m.WorkflowVisualizerComponent
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -351,11 +351,15 @@ export const ADMINISTRATION_ROUTES: Routes = [
|
||||
pathMatch: 'full',
|
||||
},
|
||||
|
||||
// Legacy alias: /administration/identity-providers → /settings/identity-providers
|
||||
// Identity Providers — canonical owner (rehomed from /settings/identity-providers)
|
||||
{
|
||||
path: 'identity-providers',
|
||||
redirectTo: '/settings/identity-providers',
|
||||
pathMatch: 'full',
|
||||
title: 'Identity Providers',
|
||||
data: { breadcrumb: 'Identity Providers' },
|
||||
loadComponent: () =>
|
||||
import('../features/settings/identity-providers/identity-providers-settings-page.component').then(
|
||||
(m) => m.IdentityProvidersSettingsPageComponent
|
||||
),
|
||||
},
|
||||
|
||||
// A7 — System
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Context Header Component Tests
|
||||
* Sprint 027: Canonical header contract verification
|
||||
*/
|
||||
import { Component } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ContextHeaderComponent, HeadingLevel } from './context-header.component';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Test host: exercises all inputs, output, and content projection */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [ContextHeaderComponent],
|
||||
template: `
|
||||
<app-context-header
|
||||
[eyebrow]="eyebrow"
|
||||
[title]="title"
|
||||
[subtitle]="subtitle"
|
||||
[contextNote]="contextNote"
|
||||
[chips]="chips"
|
||||
[backLabel]="backLabel"
|
||||
[headingLevel]="headingLevel"
|
||||
[testId]="testId"
|
||||
(backClick)="backClicked = true"
|
||||
>
|
||||
<button header-actions data-testid="projected-action">Do Something</button>
|
||||
</app-context-header>
|
||||
`,
|
||||
})
|
||||
class ContextHeaderTestHostComponent {
|
||||
eyebrow = '';
|
||||
title = 'Test Page';
|
||||
subtitle = '';
|
||||
contextNote = '';
|
||||
chips: readonly string[] = [];
|
||||
backLabel: string | null = null;
|
||||
headingLevel: HeadingLevel = 1;
|
||||
testId: string | null = null;
|
||||
backClicked = false;
|
||||
}
|
||||
|
||||
describe('ContextHeaderComponent', () => {
|
||||
let fixture: ComponentFixture<ContextHeaderTestHostComponent>;
|
||||
let host: ContextHeaderTestHostComponent;
|
||||
let el: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ContextHeaderTestHostComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ContextHeaderTestHostComponent);
|
||||
host = fixture.componentInstance;
|
||||
el = fixture.nativeElement;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
/* ---- Title rendering ---- */
|
||||
|
||||
it('renders the title as an h1 by default', () => {
|
||||
const h1 = el.querySelector('h1');
|
||||
expect(h1).toBeTruthy();
|
||||
expect(h1!.textContent!.trim()).toBe('Test Page');
|
||||
});
|
||||
|
||||
it('does not render eyebrow, subtitle, or note when empty', () => {
|
||||
expect(el.querySelector('.context-header__eyebrow')).toBeFalsy();
|
||||
expect(el.querySelector('.context-header__subtitle')).toBeFalsy();
|
||||
expect(el.querySelector('.context-header__note')).toBeFalsy();
|
||||
});
|
||||
|
||||
/* ---- Eyebrow/subtitle display ---- */
|
||||
|
||||
it('renders eyebrow text when provided', () => {
|
||||
host.eyebrow = 'Ops / Policy';
|
||||
fixture.detectChanges();
|
||||
|
||||
const eyebrow = el.querySelector('.context-header__eyebrow');
|
||||
expect(eyebrow).toBeTruthy();
|
||||
expect(eyebrow!.textContent!.trim()).toBe('Ops / Policy');
|
||||
});
|
||||
|
||||
it('renders subtitle and context note when provided', () => {
|
||||
host.subtitle = 'A brief description';
|
||||
host.contextNote = 'Additional operational context';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.context-header__subtitle')!.textContent!.trim()).toBe('A brief description');
|
||||
expect(el.querySelector('.context-header__note')!.textContent!.trim()).toBe('Additional operational context');
|
||||
});
|
||||
|
||||
/* ---- Chips ---- */
|
||||
|
||||
it('renders chips when provided', () => {
|
||||
host.chips = ['running', 'prod', 'v2.1'];
|
||||
fixture.detectChanges();
|
||||
|
||||
const chips = el.querySelectorAll('.context-header__chip');
|
||||
expect(chips.length).toBe(3);
|
||||
expect(chips[0].textContent!.trim()).toBe('running');
|
||||
expect(chips[1].textContent!.trim()).toBe('prod');
|
||||
expect(chips[2].textContent!.trim()).toBe('v2.1');
|
||||
});
|
||||
|
||||
it('does not render chips container when empty', () => {
|
||||
host.chips = [];
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.context-header__chips')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('marks chip container with role=list for accessibility', () => {
|
||||
host.chips = ['status'];
|
||||
fixture.detectChanges();
|
||||
|
||||
const container = el.querySelector('.context-header__chips');
|
||||
expect(container!.getAttribute('role')).toBe('list');
|
||||
expect(container!.getAttribute('aria-label')).toBe('Context chips');
|
||||
|
||||
const chip = el.querySelector('.context-header__chip');
|
||||
expect(chip!.getAttribute('role')).toBe('listitem');
|
||||
});
|
||||
|
||||
/* ---- Back action behavior ---- */
|
||||
|
||||
it('hides back button when backLabel is null', () => {
|
||||
host.backLabel = null;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.context-header__return')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('renders back button and emits backClick when clicked', () => {
|
||||
host.backLabel = 'Return to Findings';
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = el.querySelector('.context-header__return') as HTMLButtonElement;
|
||||
expect(button).toBeTruthy();
|
||||
expect(button.textContent).toContain('Return to Findings');
|
||||
expect(button.getAttribute('aria-label')).toBe('Navigate back: Return to Findings');
|
||||
|
||||
button.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(host.backClicked).toBeTrue();
|
||||
});
|
||||
|
||||
/* ---- Action slot projection ---- */
|
||||
|
||||
it('projects content into the header-actions slot', () => {
|
||||
const projected = el.querySelector('[data-testid="projected-action"]');
|
||||
expect(projected).toBeTruthy();
|
||||
expect(projected!.textContent!.trim()).toBe('Do Something');
|
||||
});
|
||||
|
||||
/* ---- Heading levels (accessibility) ---- */
|
||||
|
||||
it('renders h2 when headingLevel is 2', () => {
|
||||
host.headingLevel = 2;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('h1')).toBeFalsy();
|
||||
expect(el.querySelector('h2')).toBeTruthy();
|
||||
expect(el.querySelector('h2')!.textContent!.trim()).toBe('Test Page');
|
||||
});
|
||||
|
||||
it('renders h3 when headingLevel is 3', () => {
|
||||
host.headingLevel = 3;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('h1')).toBeFalsy();
|
||||
expect(el.querySelector('h3')).toBeTruthy();
|
||||
expect(el.querySelector('h3')!.textContent!.trim()).toBe('Test Page');
|
||||
});
|
||||
|
||||
/* ---- Test ID ---- */
|
||||
|
||||
it('sets data-testid on the header element when provided', () => {
|
||||
host.testId = 'my-page-header';
|
||||
fixture.detectChanges();
|
||||
|
||||
const header = el.querySelector('header');
|
||||
expect(header!.getAttribute('data-testid')).toBe('my-page-header');
|
||||
});
|
||||
|
||||
it('does not set data-testid when testId is null', () => {
|
||||
host.testId = null;
|
||||
fixture.detectChanges();
|
||||
|
||||
const header = el.querySelector('header');
|
||||
expect(header!.getAttribute('data-testid')).toBeNull();
|
||||
});
|
||||
|
||||
/* ---- Responsive behavior (structural check) ---- */
|
||||
|
||||
it('renders the header with flex layout between copy and actions', () => {
|
||||
const header = el.querySelector('.context-header') as HTMLElement;
|
||||
expect(header).toBeTruthy();
|
||||
|
||||
const copy = el.querySelector('.context-header__copy');
|
||||
const actions = el.querySelector('.context-header__actions');
|
||||
expect(copy).toBeTruthy();
|
||||
expect(actions).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,47 @@
|
||||
/**
|
||||
* Context Header Component
|
||||
*
|
||||
* Canonical page header primitive for Stella Ops. Serves both simple
|
||||
* admin/settings pages (title + subtitle + actions) and richer operational
|
||||
* pages (eyebrow, chips, back action, context note).
|
||||
*
|
||||
* Replaces the deprecated PageHeaderComponent (SPRINT-027).
|
||||
*/
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
|
||||
/** Allowed heading element levels for the title. */
|
||||
export type HeadingLevel = 1 | 2 | 3;
|
||||
|
||||
@Component({
|
||||
selector: 'app-context-header',
|
||||
standalone: true,
|
||||
template: `
|
||||
<header class="context-header">
|
||||
<header
|
||||
class="context-header"
|
||||
[attr.data-testid]="testId"
|
||||
>
|
||||
<div class="context-header__copy">
|
||||
@if (eyebrow) {
|
||||
<p class="context-header__eyebrow">{{ eyebrow }}</p>
|
||||
}
|
||||
|
||||
<div class="context-header__title-row">
|
||||
<h1 class="context-header__title">{{ title }}</h1>
|
||||
@switch (headingLevel) {
|
||||
@case (2) {
|
||||
<h2 class="context-header__title">{{ title }}</h2>
|
||||
}
|
||||
@case (3) {
|
||||
<h3 class="context-header__title">{{ title }}</h3>
|
||||
}
|
||||
@default {
|
||||
<h1 class="context-header__title">{{ title }}</h1>
|
||||
}
|
||||
}
|
||||
|
||||
@if (chips.length) {
|
||||
<div class="context-header__chips" aria-label="Context chips">
|
||||
<div class="context-header__chips" role="list" aria-label="Context chips">
|
||||
@for (chip of chips; track chip) {
|
||||
<span class="context-header__chip">{{ chip }}</span>
|
||||
<span class="context-header__chip" role="listitem">{{ chip }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -36,13 +61,15 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
|
||||
<button
|
||||
type="button"
|
||||
class="context-header__return"
|
||||
[attr.aria-label]="'Navigate back: ' + backLabel"
|
||||
(click)="backClick.emit()"
|
||||
>
|
||||
<span class="context-header__return-arrow" aria-hidden="true">←</span>
|
||||
{{ backLabel }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<ng-content select="[header-actions]"></ng-content>
|
||||
<ng-content select="[header-actions],[secondary-actions],[primary-actions]"></ng-content>
|
||||
</div>
|
||||
</header>
|
||||
`,
|
||||
@@ -80,6 +107,7 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
|
||||
margin: 0;
|
||||
color: var(--color-text-heading, var(--color-text-primary));
|
||||
font-size: 1.6rem;
|
||||
font-weight: var(--font-weight-semibold, 600);
|
||||
}
|
||||
|
||||
.context-header__subtitle,
|
||||
@@ -89,6 +117,10 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.context-header__subtitle {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.context-header__note {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
@@ -120,6 +152,9 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
|
||||
}
|
||||
|
||||
.context-header__return {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--color-surface-secondary, var(--color-surface-primary));
|
||||
@@ -129,6 +164,15 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
|
||||
padding: 0.6rem 0.9rem;
|
||||
}
|
||||
|
||||
.context-header__return:hover {
|
||||
background: var(--color-surface-tertiary, var(--color-surface-secondary));
|
||||
}
|
||||
|
||||
.context-header__return-arrow {
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.context-header {
|
||||
display: grid;
|
||||
@@ -142,12 +186,36 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ContextHeaderComponent {
|
||||
/** Contextual eyebrow label shown above the title (e.g. breadcrumb path). */
|
||||
@Input() eyebrow = '';
|
||||
|
||||
/** Primary heading text (required for meaningful display). */
|
||||
@Input() title = '';
|
||||
|
||||
/** Short description displayed below the title. */
|
||||
@Input() subtitle = '';
|
||||
|
||||
/** Extended contextual note displayed below the subtitle. */
|
||||
@Input() contextNote = '';
|
||||
|
||||
/** Status or context chips displayed inline with the title. */
|
||||
@Input() chips: readonly string[] = [];
|
||||
|
||||
/**
|
||||
* Label for the back/return button. When null or empty, the button is hidden.
|
||||
* Use for contextual navigation (e.g. "Return to Findings").
|
||||
*/
|
||||
@Input() backLabel: string | null = null;
|
||||
|
||||
/**
|
||||
* Semantic heading level (1, 2, or 3). Defaults to 1 (h1).
|
||||
* Use 2 for pages nested inside a shell that already provides an h1.
|
||||
*/
|
||||
@Input() headingLevel: HeadingLevel = 1;
|
||||
|
||||
/** Optional test identifier for the header element. */
|
||||
@Input() testId: string | null = null;
|
||||
|
||||
/** Emitted when the user clicks the back/return button. */
|
||||
@Output() readonly backClick = new EventEmitter<void>();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// Layout primitives
|
||||
export * from './page-header/page-header.component';
|
||||
export * from './context-header/context-header.component';
|
||||
/** @deprecated Use ContextHeaderComponent instead */
|
||||
export * from './context-drawer-host/context-drawer-host.component';
|
||||
export * from './filter-bar/filter-bar.component';
|
||||
export * from './list-detail-shell/list-detail-shell.component';
|
||||
@@ -18,9 +19,12 @@ export * from './context-route-state/context-route-state';
|
||||
|
||||
// Data display
|
||||
export * from './status-badge/status-badge.component';
|
||||
export * from './metric-card/metric-card.component';
|
||||
export { MetricCardComponent, DeltaDirection, MetricSeverity } from './metric-card/metric-card.component';
|
||||
export * from './timeline-list/timeline-list.component';
|
||||
|
||||
// Witness/evidence proof-inspection sections
|
||||
export * from './witness/index';
|
||||
|
||||
// Utility
|
||||
export * from './empty-state/empty-state.component';
|
||||
export * from './inline-code/inline-code.component';
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @file list-detail-shell.component.spec.ts
|
||||
* @sprint SPRINT_20260308_030_FE_split_pane_list_detail_shell_consolidation (FE-SPL-004)
|
||||
* @description Unit tests for the canonical ListDetailShellComponent
|
||||
*/
|
||||
|
||||
import { Component } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ListDetailShellComponent } from './list-detail-shell.component';
|
||||
|
||||
/**
|
||||
* Test host that wraps ListDetailShellComponent with projected content.
|
||||
*/
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [ListDetailShellComponent],
|
||||
template: `
|
||||
<app-list-detail-shell
|
||||
[detailVisible]="detailVisible"
|
||||
[detailWidth]="detailWidth"
|
||||
[collapsible]="collapsible"
|
||||
(detailClosed)="onDetailClosed()"
|
||||
>
|
||||
<div shell-primary data-testid="primary">Primary content</div>
|
||||
<div shell-detail data-testid="detail">Detail content</div>
|
||||
</app-list-detail-shell>
|
||||
`,
|
||||
})
|
||||
class TestHostComponent {
|
||||
detailVisible = false;
|
||||
detailWidth = '24rem';
|
||||
collapsible = false;
|
||||
detailClosedCount = 0;
|
||||
|
||||
onDetailClosed(): void {
|
||||
this.detailClosedCount++;
|
||||
this.detailVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
describe('ListDetailShellComponent', () => {
|
||||
let fixture: ComponentFixture<TestHostComponent>;
|
||||
let host: TestHostComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TestHostComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TestHostComponent);
|
||||
host = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
fixture.detectChanges();
|
||||
expect(host).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render the primary pane', () => {
|
||||
fixture.detectChanges();
|
||||
const primary = fixture.nativeElement.querySelector('[data-testid="primary"]');
|
||||
expect(primary).toBeTruthy();
|
||||
expect(primary.textContent).toContain('Primary content');
|
||||
});
|
||||
|
||||
it('should hide detail pane when detailVisible is false', () => {
|
||||
host.detailVisible = false;
|
||||
fixture.detectChanges();
|
||||
const detail = fixture.nativeElement.querySelector('[data-testid="detail"]');
|
||||
expect(detail).toBeNull();
|
||||
});
|
||||
|
||||
it('should show detail pane when detailVisible is true', () => {
|
||||
host.detailVisible = true;
|
||||
fixture.detectChanges();
|
||||
const detail = fixture.nativeElement.querySelector('[data-testid="detail"]');
|
||||
expect(detail).toBeTruthy();
|
||||
expect(detail.textContent).toContain('Detail content');
|
||||
});
|
||||
|
||||
it('should apply the --with-detail CSS class when detail is visible', () => {
|
||||
host.detailVisible = true;
|
||||
fixture.detectChanges();
|
||||
const shell = fixture.nativeElement.querySelector('.list-detail-shell');
|
||||
expect(shell.classList).toContain('list-detail-shell--with-detail');
|
||||
});
|
||||
|
||||
it('should not apply the --with-detail CSS class when detail is hidden', () => {
|
||||
host.detailVisible = false;
|
||||
fixture.detectChanges();
|
||||
const shell = fixture.nativeElement.querySelector('.list-detail-shell');
|
||||
expect(shell.classList).not.toContain('list-detail-shell--with-detail');
|
||||
});
|
||||
|
||||
it('should set custom detail width via CSS variable', () => {
|
||||
host.detailVisible = true;
|
||||
host.detailWidth = '32rem';
|
||||
fixture.detectChanges();
|
||||
const shell = fixture.nativeElement.querySelector('.list-detail-shell') as HTMLElement;
|
||||
expect(shell.style.getPropertyValue('--list-detail-shell-detail-width')).toBe('32rem');
|
||||
});
|
||||
|
||||
it('should not show toggle button when collapsible is false', () => {
|
||||
host.detailVisible = true;
|
||||
host.collapsible = false;
|
||||
fixture.detectChanges();
|
||||
const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle');
|
||||
expect(toggle).toBeNull();
|
||||
});
|
||||
|
||||
it('should show toggle button when collapsible is true and detail is visible', () => {
|
||||
host.detailVisible = true;
|
||||
host.collapsible = true;
|
||||
fixture.detectChanges();
|
||||
const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle');
|
||||
expect(toggle).toBeTruthy();
|
||||
expect(toggle.getAttribute('aria-label')).toBe('Close detail panel');
|
||||
});
|
||||
|
||||
it('should not show toggle button when collapsible is true but detail is hidden', () => {
|
||||
host.detailVisible = false;
|
||||
host.collapsible = true;
|
||||
fixture.detectChanges();
|
||||
const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle');
|
||||
expect(toggle).toBeNull();
|
||||
});
|
||||
|
||||
it('should emit detailClosed when toggle button is clicked', () => {
|
||||
host.detailVisible = true;
|
||||
host.collapsible = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle') as HTMLButtonElement;
|
||||
toggle.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(host.detailClosedCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should hide detail pane after toggle is clicked and host reacts', () => {
|
||||
host.detailVisible = true;
|
||||
host.collapsible = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle') as HTMLButtonElement;
|
||||
toggle.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const detail = fixture.nativeElement.querySelector('[data-testid="detail"]');
|
||||
expect(detail).toBeNull();
|
||||
});
|
||||
|
||||
it('should render detail pane with complementary role for accessibility', () => {
|
||||
host.detailVisible = true;
|
||||
fixture.detectChanges();
|
||||
const detailContainer = fixture.nativeElement.querySelector('.list-detail-shell__detail');
|
||||
expect(detailContainer.getAttribute('role')).toBe('complementary');
|
||||
});
|
||||
|
||||
it('should have focus-visible style support on toggle button', () => {
|
||||
host.detailVisible = true;
|
||||
host.collapsible = true;
|
||||
fixture.detectChanges();
|
||||
const toggle = fixture.nativeElement.querySelector('.list-detail-shell__toggle') as HTMLButtonElement;
|
||||
// Verify button is focusable (type="button" element)
|
||||
expect(toggle.tagName.toLowerCase()).toBe('button');
|
||||
expect(toggle.getAttribute('type')).toBe('button');
|
||||
});
|
||||
|
||||
it('should use the default detail width of 24rem', () => {
|
||||
host.detailVisible = true;
|
||||
fixture.detectChanges();
|
||||
const shell = fixture.nativeElement.querySelector('.list-detail-shell') as HTMLElement;
|
||||
expect(shell.style.getPropertyValue('--list-detail-shell-detail-width')).toBe('24rem');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,30 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
/**
|
||||
* ListDetailShellComponent — canonical master-detail layout primitive.
|
||||
*
|
||||
* Consolidates the former SplitPaneComponent behavior into a single shell
|
||||
* so the codebase has one truthful master-detail layout.
|
||||
*
|
||||
* Content projection slots:
|
||||
* [shell-primary] — the list / primary pane (always rendered)
|
||||
* [shell-detail] — the detail / secondary pane (conditionally rendered)
|
||||
*
|
||||
* Inputs:
|
||||
* detailVisible — whether the detail pane is shown
|
||||
* detailWidth — CSS value for the detail pane width (default 24rem)
|
||||
* collapsible — show a toggle button between panes (default false)
|
||||
*
|
||||
* Outputs:
|
||||
* detailClosed — emits when the user clicks the collapse toggle to hide detail
|
||||
*
|
||||
* Sprint: SPRINT_20260308_030_FE_split_pane_list_detail_shell_consolidation
|
||||
*/
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
} from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-list-detail-shell',
|
||||
@@ -13,8 +39,38 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
<ng-content select="[shell-primary]"></ng-content>
|
||||
</div>
|
||||
|
||||
@if (detailVisible && collapsible) {
|
||||
<button
|
||||
type="button"
|
||||
class="list-detail-shell__toggle"
|
||||
(click)="onToggleDetail()"
|
||||
[attr.aria-label]="'Close detail panel'"
|
||||
aria-controls="list-detail-shell-detail"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (detailVisible) {
|
||||
<div class="list-detail-shell__detail">
|
||||
<div
|
||||
class="list-detail-shell__detail"
|
||||
id="list-detail-shell-detail"
|
||||
role="complementary"
|
||||
[attr.aria-label]="'Detail panel'"
|
||||
>
|
||||
<ng-content select="[shell-detail]"></ng-content>
|
||||
</div>
|
||||
}
|
||||
@@ -28,13 +84,64 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
}
|
||||
|
||||
.list-detail-shell--with-detail {
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(20rem, var(--list-detail-shell-detail-width, 24rem));
|
||||
grid-template-columns: minmax(0, 1.7fr) auto minmax(20rem, var(--list-detail-shell-detail-width, 24rem));
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.list-detail-shell__primary,
|
||||
.list-detail-shell__detail {
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.list-detail-shell__detail {
|
||||
animation: list-detail-shell-slide-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes list-detail-shell-slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(1rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.list-detail-shell__toggle {
|
||||
align-self: start;
|
||||
position: sticky;
|
||||
top: 0.5rem;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 2rem;
|
||||
margin: 0 -0.25rem;
|
||||
padding: 0;
|
||||
background: var(--color-surface-primary, #fff);
|
||||
border: 1px solid var(--color-border-primary, #e0e0e0);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
color: var(--color-text-secondary, #666);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.list-detail-shell__toggle:hover {
|
||||
background: var(--color-nav-hover, #f5f5f5);
|
||||
color: var(--color-text-primary, #333);
|
||||
}
|
||||
|
||||
.list-detail-shell__toggle:focus-visible {
|
||||
outline: 2px solid var(--color-status-info, #2196f3);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* When collapsible is false (no toggle button), keep the two-column grid */
|
||||
.list-detail-shell--with-detail:not(:has(.list-detail-shell__toggle)) {
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(20rem, var(--list-detail-shell-detail-width, 24rem));
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
@@ -42,6 +149,15 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
.list-detail-shell--with-detail {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.list-detail-shell__toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.list-detail-shell__detail {
|
||||
border-top: 1px solid var(--color-border-primary, #e0e0e0);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
}
|
||||
`],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -49,4 +165,11 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
export class ListDetailShellComponent {
|
||||
@Input() detailVisible = false;
|
||||
@Input() detailWidth = '24rem';
|
||||
@Input() collapsible = false;
|
||||
|
||||
@Output() readonly detailClosed = new EventEmitter<void>();
|
||||
|
||||
onToggleDetail(): void {
|
||||
this.detailClosed.emit();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* Metric Card Component Tests
|
||||
* Sprint: SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation (FE-MCD-004)
|
||||
*
|
||||
* Covers:
|
||||
* - Normal rendering with all inputs
|
||||
* - Delta direction semantics (up-is-good vs up-is-bad vs neutral)
|
||||
* - Loading / empty / error states
|
||||
* - Severity accent rendering
|
||||
* - Accessibility (ARIA labels)
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MetricCardComponent, DeltaDirection, MetricSeverity } from './metric-card.component';
|
||||
|
||||
describe('MetricCardComponent', () => {
|
||||
let fixture: ComponentFixture<MetricCardComponent>;
|
||||
let component: MetricCardComponent;
|
||||
let el: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MetricCardComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MetricCardComponent);
|
||||
component = fixture.componentInstance;
|
||||
el = fixture.nativeElement;
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Normal rendering
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('normal rendering', () => {
|
||||
it('should render label and value', () => {
|
||||
component.label = 'Total Scans';
|
||||
component.value = 1234;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__label')?.textContent?.trim()).toBe('Total Scans');
|
||||
expect(el.querySelector('.metric-card__value')?.textContent?.trim()).toBe('1,234');
|
||||
});
|
||||
|
||||
it('should render string values as-is', () => {
|
||||
component.label = 'Status';
|
||||
component.value = 'Healthy';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__value')?.textContent?.trim()).toBe('Healthy');
|
||||
});
|
||||
|
||||
it('should render unit when provided', () => {
|
||||
component.label = 'Latency';
|
||||
component.value = 42;
|
||||
component.unit = 'ms';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__unit')?.textContent?.trim()).toBe('ms');
|
||||
});
|
||||
|
||||
it('should not render unit when not provided', () => {
|
||||
component.label = 'Count';
|
||||
component.value = 10;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__unit')).toBeNull();
|
||||
});
|
||||
|
||||
it('should render subtitle when provided', () => {
|
||||
component.label = 'Error Rate';
|
||||
component.value = '0.5%';
|
||||
component.subtitle = 'Platform-wide';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__subtitle')?.textContent?.trim()).toBe('Platform-wide');
|
||||
});
|
||||
|
||||
it('should not render subtitle when not provided', () => {
|
||||
component.label = 'Count';
|
||||
component.value = 5;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__subtitle')).toBeNull();
|
||||
});
|
||||
|
||||
it('should recompute derived output when inputs change after first render', () => {
|
||||
fixture.componentRef.setInput('label', 'Latency');
|
||||
fixture.componentRef.setInput('value', 150);
|
||||
fixture.componentRef.setInput('delta', 5);
|
||||
fixture.componentRef.setInput('deltaDirection', 'up-is-bad');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__value')?.textContent?.trim()).toBe('150');
|
||||
expect(el.querySelector('.metric-card__delta')?.textContent?.trim()).toContain('+5%');
|
||||
expect(el.querySelector('.metric-card__delta')?.classList.contains('metric-card__delta--bad')).toBe(true);
|
||||
|
||||
fixture.componentRef.setInput('value', 95);
|
||||
fixture.componentRef.setInput('delta', -3);
|
||||
fixture.componentRef.setInput('deltaDirection', 'up-is-good');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__value')?.textContent?.trim()).toBe('95');
|
||||
expect(el.querySelector('.metric-card__delta')?.textContent?.trim()).toContain('-3%');
|
||||
expect(el.querySelector('.metric-card__delta')?.classList.contains('metric-card__delta--bad')).toBe(true);
|
||||
expect(el.querySelector('.metric-card')?.getAttribute('aria-label')).toContain('Latency: 95, -3%');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Delta display
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('delta display', () => {
|
||||
it('should show positive delta with + sign and up arrow', () => {
|
||||
component.label = 'Throughput';
|
||||
component.value = 200;
|
||||
component.delta = 12.5;
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.textContent?.trim()).toContain('+12.5%');
|
||||
|
||||
// Should have an up arrow SVG
|
||||
const svg = deltaEl?.querySelector('svg');
|
||||
expect(svg).toBeTruthy();
|
||||
expect(deltaEl?.querySelector('polyline')?.getAttribute('points')).toContain('17,11');
|
||||
});
|
||||
|
||||
it('should show negative delta with - sign and down arrow', () => {
|
||||
component.label = 'Errors';
|
||||
component.value = 3;
|
||||
component.delta = -8;
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.textContent?.trim()).toContain('-8%');
|
||||
|
||||
// Should have a down arrow SVG
|
||||
expect(deltaEl?.querySelector('polyline')?.getAttribute('points')).toContain('7,13');
|
||||
});
|
||||
|
||||
it('should show zero delta without arrow', () => {
|
||||
component.label = 'Stable';
|
||||
component.value = 100;
|
||||
component.delta = 0;
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.textContent?.trim()).toContain('0%');
|
||||
expect(deltaEl?.querySelector('svg')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not render delta when undefined', () => {
|
||||
component.label = 'Simple';
|
||||
component.value = 42;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__delta')).toBeNull();
|
||||
});
|
||||
|
||||
it('should format integer delta without decimal', () => {
|
||||
component.label = 'Test';
|
||||
component.value = 10;
|
||||
component.delta = 5;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__delta')?.textContent?.trim()).toContain('+5%');
|
||||
});
|
||||
|
||||
it('should format fractional delta with one decimal', () => {
|
||||
component.label = 'Test';
|
||||
component.value = 10;
|
||||
component.delta = 5.7;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__delta')?.textContent?.trim()).toContain('+5.7%');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Delta direction semantics
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('delta direction semantics', () => {
|
||||
it('up-is-good: positive delta should be green (--good class)', () => {
|
||||
component.label = 'Uptime';
|
||||
component.value = '99.9%';
|
||||
component.delta = 2;
|
||||
component.deltaDirection = 'up-is-good';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(true);
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--bad')).toBe(false);
|
||||
});
|
||||
|
||||
it('up-is-good: negative delta should be red (--bad class)', () => {
|
||||
component.label = 'Uptime';
|
||||
component.value = '97%';
|
||||
component.delta = -3;
|
||||
component.deltaDirection = 'up-is-good';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--bad')).toBe(true);
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(false);
|
||||
});
|
||||
|
||||
it('up-is-bad: positive delta should be red (--bad class)', () => {
|
||||
component.label = 'Error Rate';
|
||||
component.value = '5.2%';
|
||||
component.delta = 1.5;
|
||||
component.deltaDirection = 'up-is-bad';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--bad')).toBe(true);
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(false);
|
||||
});
|
||||
|
||||
it('up-is-bad: negative delta should be green (--good class)', () => {
|
||||
component.label = 'Vulnerabilities';
|
||||
component.value = 12;
|
||||
component.delta = -20;
|
||||
component.deltaDirection = 'up-is-bad';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(true);
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--bad')).toBe(false);
|
||||
});
|
||||
|
||||
it('neutral: positive delta should be gray (--neutral class)', () => {
|
||||
component.label = 'Signals';
|
||||
component.value = 450;
|
||||
component.delta = 10;
|
||||
component.deltaDirection = 'neutral';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--neutral')).toBe(true);
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(false);
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--bad')).toBe(false);
|
||||
});
|
||||
|
||||
it('neutral: negative delta should be gray (--neutral class)', () => {
|
||||
component.label = 'Signals';
|
||||
component.value = 400;
|
||||
component.delta = -5;
|
||||
component.deltaDirection = 'neutral';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--neutral')).toBe(true);
|
||||
});
|
||||
|
||||
it('zero delta should always be neutral', () => {
|
||||
component.label = 'Count';
|
||||
component.value = 10;
|
||||
component.delta = 0;
|
||||
component.deltaDirection = 'up-is-good';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--neutral')).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults to up-is-good when deltaDirection is not set', () => {
|
||||
component.label = 'Default';
|
||||
component.value = 50;
|
||||
component.delta = 5;
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaEl = el.querySelector('.metric-card__delta');
|
||||
expect(deltaEl?.classList.contains('metric-card__delta--good')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Severity state
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('severity state', () => {
|
||||
const severities: MetricSeverity[] = ['healthy', 'warning', 'critical', 'unknown'];
|
||||
|
||||
for (const sev of severities) {
|
||||
it(`should apply --${sev} class when severity is '${sev}'`, () => {
|
||||
component.label = 'Health';
|
||||
component.value = sev;
|
||||
component.severity = sev;
|
||||
fixture.detectChanges();
|
||||
|
||||
const card = el.querySelector('.metric-card');
|
||||
expect(card?.classList.contains(`metric-card--${sev}`)).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
it('should not apply severity class when severity is undefined', () => {
|
||||
component.label = 'No Sev';
|
||||
component.value = 42;
|
||||
fixture.detectChanges();
|
||||
|
||||
const card = el.querySelector('.metric-card');
|
||||
expect(card?.classList.contains('metric-card--healthy')).toBe(false);
|
||||
expect(card?.classList.contains('metric-card--warning')).toBe(false);
|
||||
expect(card?.classList.contains('metric-card--critical')).toBe(false);
|
||||
expect(card?.classList.contains('metric-card--unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Loading state
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('loading state', () => {
|
||||
it('should render skeleton placeholders when loading', () => {
|
||||
component.label = 'Loading';
|
||||
component.value = 0;
|
||||
component.loading = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const card = el.querySelector('.metric-card');
|
||||
expect(card?.classList.contains('metric-card--loading')).toBe(true);
|
||||
|
||||
const skeletons = el.querySelectorAll('.metric-card__skeleton');
|
||||
expect(skeletons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not render actual value when loading', () => {
|
||||
component.label = 'Loading';
|
||||
component.value = 999;
|
||||
component.loading = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
// Value should not appear in the rendered output
|
||||
expect(el.textContent).not.toContain('999');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Empty state
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('empty state', () => {
|
||||
it('should render -- value when empty', () => {
|
||||
component.label = 'Empty Metric';
|
||||
component.value = 0;
|
||||
component.empty = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card--empty')).toBeTruthy();
|
||||
expect(el.querySelector('.metric-card__value--empty')?.textContent?.trim()).toBe('--');
|
||||
});
|
||||
|
||||
it('should still render subtitle when empty with subtitle', () => {
|
||||
component.label = 'Empty';
|
||||
component.value = 0;
|
||||
component.empty = true;
|
||||
component.subtitle = 'No data available';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__subtitle')?.textContent?.trim()).toBe('No data available');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Error state
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('error state', () => {
|
||||
it('should render error state with message', () => {
|
||||
component.label = 'Broken Metric';
|
||||
component.value = 0;
|
||||
component.error = 'Service unavailable';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card--error')).toBeTruthy();
|
||||
expect(el.querySelector('.metric-card__value--error')?.textContent?.trim()).toBe('--');
|
||||
expect(el.querySelector('.metric-card__subtitle--error')?.textContent?.trim()).toBe('Service unavailable');
|
||||
});
|
||||
|
||||
it('should show label even in error state', () => {
|
||||
component.label = 'Error Label';
|
||||
component.value = 0;
|
||||
component.error = 'Timeout';
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card__label')?.textContent?.trim()).toBe('Error Label');
|
||||
});
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Accessibility
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
describe('accessibility', () => {
|
||||
it('should have role="group" on the card container', () => {
|
||||
component.label = 'Test';
|
||||
component.value = 42;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card')?.getAttribute('role')).toBe('group');
|
||||
});
|
||||
|
||||
it('should have a composite aria-label with value and unit', () => {
|
||||
component.label = 'Latency';
|
||||
component.value = 150;
|
||||
component.unit = 'ms';
|
||||
fixture.detectChanges();
|
||||
|
||||
const ariaLabel = el.querySelector('.metric-card')?.getAttribute('aria-label');
|
||||
expect(ariaLabel).toContain('Latency');
|
||||
expect(ariaLabel).toContain('150');
|
||||
expect(ariaLabel).toContain('ms');
|
||||
});
|
||||
|
||||
it('should include delta in aria-label when present', () => {
|
||||
component.label = 'Rate';
|
||||
component.value = 10;
|
||||
component.delta = 5;
|
||||
fixture.detectChanges();
|
||||
|
||||
const ariaLabel = el.querySelector('.metric-card')?.getAttribute('aria-label');
|
||||
expect(ariaLabel).toContain('+5%');
|
||||
});
|
||||
|
||||
it('should include severity in aria-label when present', () => {
|
||||
component.label = 'Health';
|
||||
component.value = 'Good';
|
||||
component.severity = 'healthy';
|
||||
fixture.detectChanges();
|
||||
|
||||
const ariaLabel = el.querySelector('.metric-card')?.getAttribute('aria-label');
|
||||
expect(ariaLabel).toContain('healthy');
|
||||
});
|
||||
|
||||
it('should indicate loading in aria-label', () => {
|
||||
component.label = 'Loading';
|
||||
component.value = 0;
|
||||
component.loading = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card')?.getAttribute('aria-label')).toContain('loading');
|
||||
});
|
||||
|
||||
it('should indicate error in aria-label', () => {
|
||||
component.label = 'Broken';
|
||||
component.value = 0;
|
||||
component.error = 'Connection lost';
|
||||
fixture.detectChanges();
|
||||
|
||||
const ariaLabel = el.querySelector('.metric-card')?.getAttribute('aria-label');
|
||||
expect(ariaLabel).toContain('error');
|
||||
expect(ariaLabel).toContain('Connection lost');
|
||||
});
|
||||
|
||||
it('should indicate no data in aria-label when empty', () => {
|
||||
component.label = 'Empty';
|
||||
component.value = 0;
|
||||
component.empty = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.metric-card')?.getAttribute('aria-label')).toContain('no data');
|
||||
});
|
||||
|
||||
it('should have aria-label on delta badge with favorable/unfavorable', () => {
|
||||
component.label = 'Good';
|
||||
component.value = 99;
|
||||
component.delta = 5;
|
||||
component.deltaDirection = 'up-is-good';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaAriaLabel = el.querySelector('.metric-card__delta')?.getAttribute('aria-label');
|
||||
expect(deltaAriaLabel).toContain('favorable');
|
||||
});
|
||||
|
||||
it('should label unfavorable delta in up-is-bad mode', () => {
|
||||
component.label = 'Bad';
|
||||
component.value = 50;
|
||||
component.delta = 10;
|
||||
component.deltaDirection = 'up-is-bad';
|
||||
fixture.detectChanges();
|
||||
|
||||
const deltaAriaLabel = el.querySelector('.metric-card__delta')?.getAttribute('aria-label');
|
||||
expect(deltaAriaLabel).toContain('unfavorable');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,31 @@
|
||||
/**
|
||||
* Metric Card Component
|
||||
* Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (SHARED-010)
|
||||
* Metric Card Component - Canonical KPI Card
|
||||
* Sprint: SPRINT_20260308_028_FE_metric_card_dashboard_card_derivation (FE-MCD-002)
|
||||
*
|
||||
* Displays a metric with label, value, and optional delta.
|
||||
* Reusable KPI card for dashboard grids. Supports:
|
||||
* - Semantic delta with configurable direction (up-is-good / up-is-bad / neutral)
|
||||
* - Severity/health state coloring (healthy / warning / critical / unknown)
|
||||
* - Optional unit display
|
||||
* - Loading, empty, and error states
|
||||
* - Dense dashboard grid responsiveness
|
||||
* - ARIA accessibility
|
||||
*/
|
||||
|
||||
import { Component, Input, ChangeDetectionStrategy, computed } from '@angular/core';
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Controls color semantics for delta values.
|
||||
* - 'up-is-good': positive delta = green, negative = red (e.g., uptime, throughput)
|
||||
* - 'up-is-bad': positive delta = red, negative = green (e.g., error rate, latency, vulnerabilities)
|
||||
* - 'neutral': delta is always neutral gray, no good/bad implication
|
||||
*/
|
||||
export type DeltaDirection = 'up-is-good' | 'up-is-bad' | 'neutral';
|
||||
|
||||
/**
|
||||
* Health/severity state for the card.
|
||||
* Applied as a left-border accent and optional value color.
|
||||
*/
|
||||
export type MetricSeverity = 'healthy' | 'warning' | 'critical' | 'unknown';
|
||||
|
||||
@Component({
|
||||
selector: 'app-metric-card',
|
||||
@@ -14,38 +33,116 @@ import { Component, Input, ChangeDetectionStrategy, computed } from '@angular/co
|
||||
imports: [],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="metric-card">
|
||||
<div class="metric-card__header">
|
||||
<span class="metric-card__label">{{ label }}</span>
|
||||
@if (delta !== undefined && delta !== null) {
|
||||
<span
|
||||
class="metric-card__delta"
|
||||
[class.metric-card__delta--positive]="delta > 0"
|
||||
[class.metric-card__delta--negative]="delta < 0"
|
||||
>
|
||||
{{ deltaDisplay() }}
|
||||
</span>
|
||||
<div
|
||||
class="metric-card"
|
||||
[class.metric-card--loading]="loading"
|
||||
[class.metric-card--empty]="empty"
|
||||
[class.metric-card--error]="error"
|
||||
[class.metric-card--healthy]="severity === 'healthy'"
|
||||
[class.metric-card--warning]="severity === 'warning'"
|
||||
[class.metric-card--critical]="severity === 'critical'"
|
||||
[class.metric-card--unknown]="severity === 'unknown'"
|
||||
[attr.role]="'group'"
|
||||
[attr.aria-label]="ariaLabel()"
|
||||
>
|
||||
@if (loading) {
|
||||
<div class="metric-card__header">
|
||||
<span class="metric-card__label metric-card__skeleton"> </span>
|
||||
</div>
|
||||
<div class="metric-card__value metric-card__skeleton"> </div>
|
||||
<div class="metric-card__subtitle metric-card__skeleton"> </div>
|
||||
} @else if (error) {
|
||||
<div class="metric-card__header">
|
||||
<span class="metric-card__label">{{ label }}</span>
|
||||
</div>
|
||||
<div class="metric-card__value metric-card__value--error">--</div>
|
||||
<div class="metric-card__subtitle metric-card__subtitle--error">{{ error }}</div>
|
||||
} @else if (empty) {
|
||||
<div class="metric-card__header">
|
||||
<span class="metric-card__label">{{ label }}</span>
|
||||
</div>
|
||||
<div class="metric-card__value metric-card__value--empty">--</div>
|
||||
@if (subtitle) {
|
||||
<div class="metric-card__subtitle">{{ subtitle }}</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="metric-card__header">
|
||||
<span class="metric-card__label">{{ label }}</span>
|
||||
@if (delta !== undefined && delta !== null) {
|
||||
<span
|
||||
class="metric-card__delta"
|
||||
[class]="deltaColorClass()"
|
||||
[attr.aria-label]="deltaAriaLabel()"
|
||||
>
|
||||
@if (deltaIcon() === 'up') {
|
||||
<svg class="metric-card__delta-icon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<polyline points="17,11 12,6 7,11"></polyline>
|
||||
<line x1="12" y1="6" x2="12" y2="18"></line>
|
||||
</svg>
|
||||
} @else if (deltaIcon() === 'down') {
|
||||
<svg class="metric-card__delta-icon" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<polyline points="7,13 12,18 17,13"></polyline>
|
||||
<line x1="12" y1="18" x2="12" y2="6"></line>
|
||||
</svg>
|
||||
}
|
||||
{{ deltaDisplay() }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="metric-card__value-row">
|
||||
<span class="metric-card__value">{{ formattedValue() }}</span>
|
||||
@if (unit) {
|
||||
<span class="metric-card__unit">{{ unit }}</span>
|
||||
}
|
||||
</div>
|
||||
@if (subtitle) {
|
||||
<div class="metric-card__subtitle">{{ subtitle }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="metric-card__value">{{ value }}</div>
|
||||
@if (subtitle) {
|
||||
<div class="metric-card__subtitle">{{ subtitle }}</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
padding: 1rem;
|
||||
background: var(--color-surface-primary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: border-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
border-color: var(--color-border-secondary);
|
||||
}
|
||||
|
||||
/* Severity accents */
|
||||
.metric-card--healthy {
|
||||
border-left: 3px solid var(--color-status-success);
|
||||
}
|
||||
|
||||
.metric-card--warning {
|
||||
border-left: 3px solid var(--color-status-warning);
|
||||
}
|
||||
|
||||
.metric-card--critical {
|
||||
border-left: 3px solid var(--color-status-error);
|
||||
}
|
||||
|
||||
.metric-card--unknown {
|
||||
border-left: 3px solid var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.metric-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
@@ -54,23 +151,51 @@ import { Component, Input, ChangeDetectionStrategy, computed } from '@angular/co
|
||||
color: var(--color-text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
line-height: 1.4;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Delta badge */
|
||||
.metric-card__delta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: var(--radius-sm);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metric-card__delta--positive {
|
||||
background: var(--color-severity-low-bg);
|
||||
color: var(--color-status-success-text);
|
||||
.metric-card__delta-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metric-card__delta--negative {
|
||||
background: var(--color-severity-critical-bg);
|
||||
color: var(--color-status-error-text);
|
||||
.metric-card__delta--good {
|
||||
background: var(--color-status-success-bg, rgba(34, 197, 94, 0.1));
|
||||
color: var(--color-status-success-text, #16a34a);
|
||||
}
|
||||
|
||||
.metric-card__delta--bad {
|
||||
background: var(--color-status-error-bg, rgba(239, 68, 68, 0.1));
|
||||
color: var(--color-status-error-text, #dc2626);
|
||||
}
|
||||
|
||||
.metric-card__delta--neutral {
|
||||
background: var(--color-surface-tertiary, rgba(107, 114, 128, 0.1));
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
/* Value row */
|
||||
.metric-card__value-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
@@ -78,24 +203,182 @@ import { Component, Input, ChangeDetectionStrategy, computed } from '@angular/co
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.metric-card__value--empty {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.metric-card__value--error {
|
||||
color: var(--color-status-error);
|
||||
}
|
||||
|
||||
.metric-card__unit {
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Subtitle */
|
||||
.metric-card__subtitle {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.metric-card__subtitle--error {
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
/* Loading skeleton */
|
||||
.metric-card--loading .metric-card__skeleton {
|
||||
background: var(--color-skeleton-base, rgba(107, 114, 128, 0.15));
|
||||
border-radius: var(--radius-sm);
|
||||
animation: metric-card-pulse 1.5s ease-in-out infinite;
|
||||
color: transparent;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.metric-card--loading .metric-card__label.metric-card__skeleton {
|
||||
width: 60%;
|
||||
min-height: 0.875rem;
|
||||
}
|
||||
|
||||
.metric-card--loading .metric-card__value.metric-card__skeleton {
|
||||
width: 40%;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.metric-card--loading .metric-card__subtitle.metric-card__skeleton {
|
||||
width: 80%;
|
||||
min-height: 0.875rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@keyframes metric-card-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Responsive dense grids */
|
||||
@media (max-width: 640px) {
|
||||
.metric-card__value {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class MetricCardComponent {
|
||||
@Input() label!: string;
|
||||
@Input() value!: string | number;
|
||||
/** Metric label / name */
|
||||
@Input({ required: true }) label!: string;
|
||||
|
||||
/** Current metric value */
|
||||
@Input({ required: true }) value!: string | number;
|
||||
|
||||
/** Optional display unit (e.g., 'ms', '%', '/hr', 'GB') */
|
||||
@Input() unit?: string;
|
||||
|
||||
/** Percentage delta change. Sign determines arrow direction. */
|
||||
@Input() delta?: number;
|
||||
|
||||
/**
|
||||
* Controls color semantics for delta values.
|
||||
* - 'up-is-good': positive delta = green (e.g., uptime, throughput, healthy count)
|
||||
* - 'up-is-bad': positive delta = red (e.g., error rate, latency, vulnerability count)
|
||||
* - 'neutral': delta always shown in gray
|
||||
*
|
||||
* Default: 'up-is-good'
|
||||
*/
|
||||
@Input() deltaDirection: DeltaDirection = 'up-is-good';
|
||||
|
||||
/** Optional health/severity state. Renders a colored left-border accent. */
|
||||
@Input() severity?: MetricSeverity;
|
||||
|
||||
/** Supporting context line below the value */
|
||||
@Input() subtitle?: string;
|
||||
|
||||
deltaDisplay = computed(() => {
|
||||
/** Show loading skeleton */
|
||||
@Input() loading = false;
|
||||
|
||||
/** Show empty/no-data state */
|
||||
@Input() empty = false;
|
||||
|
||||
/** Error message string. When truthy, renders error state. */
|
||||
@Input() error?: string;
|
||||
|
||||
/** Formatted display value with locale-aware number formatting */
|
||||
formattedValue(): string {
|
||||
const val = this.value;
|
||||
if (typeof val === 'string') return val;
|
||||
if (typeof val === 'number') {
|
||||
if (Number.isFinite(val)) {
|
||||
return val.toLocaleString();
|
||||
}
|
||||
return '--';
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
/** Delta display text: "+12.3%" or "-5.1%" */
|
||||
deltaDisplay(): string {
|
||||
if (this.delta === undefined || this.delta === null) return '';
|
||||
const sign = this.delta > 0 ? '+' : '';
|
||||
return `${sign}${this.delta}%`;
|
||||
});
|
||||
const abs = Math.abs(this.delta);
|
||||
const formatted = abs % 1 === 0 ? abs.toString() : abs.toFixed(1);
|
||||
const sign = this.delta > 0 ? '+' : this.delta < 0 ? '-' : '';
|
||||
return `${sign}${formatted}%`;
|
||||
}
|
||||
|
||||
/** Arrow direction based on delta sign */
|
||||
deltaIcon(): 'up' | 'down' | null {
|
||||
if (this.delta === undefined || this.delta === null || this.delta === 0) return null;
|
||||
return this.delta > 0 ? 'up' : 'down';
|
||||
}
|
||||
|
||||
/** CSS class for delta badge based on direction semantics */
|
||||
deltaColorClass(): string {
|
||||
const base = 'metric-card__delta';
|
||||
if (this.delta === undefined || this.delta === null || this.delta === 0) {
|
||||
return `${base} ${base}--neutral`;
|
||||
}
|
||||
|
||||
if (this.deltaDirection === 'neutral') {
|
||||
return `${base} ${base}--neutral`;
|
||||
}
|
||||
|
||||
const isPositive = this.delta > 0;
|
||||
const isGood =
|
||||
(this.deltaDirection === 'up-is-good' && isPositive) ||
|
||||
(this.deltaDirection === 'up-is-bad' && !isPositive);
|
||||
|
||||
return `${base} ${isGood ? `${base}--good` : `${base}--bad`}`;
|
||||
}
|
||||
|
||||
/** Composite ARIA label for the entire card */
|
||||
ariaLabel(): string {
|
||||
if (this.loading) return `${this.label}: loading`;
|
||||
if (this.error) return `${this.label}: error, ${this.error}`;
|
||||
if (this.empty) return `${this.label}: no data`;
|
||||
|
||||
const val = this.formattedValue();
|
||||
const unitStr = this.unit ? ` ${this.unit}` : '';
|
||||
const deltaStr = this.delta != null ? `, ${this.deltaDisplay()}` : '';
|
||||
const severityStr = this.severity ? `, ${this.severity}` : '';
|
||||
return `${this.label}: ${val}${unitStr}${deltaStr}${severityStr}`;
|
||||
}
|
||||
|
||||
/** ARIA label specifically for the delta badge */
|
||||
deltaAriaLabel(): string {
|
||||
if (this.delta === undefined || this.delta === null) return '';
|
||||
const display = this.deltaDisplay();
|
||||
if (this.deltaDirection === 'neutral') return `Change: ${display}`;
|
||||
|
||||
const isPositive = this.delta > 0;
|
||||
const isGood =
|
||||
(this.deltaDirection === 'up-is-good' && isPositive) ||
|
||||
(this.deltaDirection === 'up-is-bad' && !isPositive);
|
||||
|
||||
return `Change: ${display}, ${isGood ? 'favorable' : 'unfavorable'}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { PageHeaderComponent } from './page-header.component';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [PageHeaderComponent],
|
||||
template: `
|
||||
<app-page-header title="Compatibility Title" subtitle="Compatibility Subtitle">
|
||||
<button secondary-actions data-testid="secondary-action">Secondary</button>
|
||||
<button primary-actions data-testid="primary-action">Primary</button>
|
||||
</app-page-header>
|
||||
`,
|
||||
})
|
||||
class PageHeaderHostComponent {}
|
||||
|
||||
describe('PageHeaderComponent', () => {
|
||||
let fixture: ComponentFixture<PageHeaderHostComponent>;
|
||||
let el: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PageHeaderHostComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PageHeaderHostComponent);
|
||||
el = fixture.nativeElement;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('forwards legacy action slots into the context-header action area', () => {
|
||||
const actions = el.querySelector('.context-header__actions');
|
||||
expect(actions?.querySelector('[data-testid="secondary-action"]')?.textContent?.trim()).toBe('Secondary');
|
||||
expect(actions?.querySelector('[data-testid="primary-action"]')?.textContent?.trim()).toBe('Primary');
|
||||
});
|
||||
|
||||
it('forwards title and subtitle to the canonical context header', () => {
|
||||
expect(el.querySelector('.context-header__title')?.textContent?.trim()).toBe('Compatibility Title');
|
||||
expect(el.querySelector('.context-header__subtitle')?.textContent?.trim()).toBe('Compatibility Subtitle');
|
||||
});
|
||||
});
|
||||
@@ -1,76 +1,39 @@
|
||||
/**
|
||||
* Page Header Component
|
||||
* Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (SHARED-010)
|
||||
*
|
||||
* Consistent page header with title, subtitle, and actions.
|
||||
* @deprecated Use ContextHeaderComponent instead. This component is a
|
||||
* compatibility wrapper retained for any remaining references. It will
|
||||
* be removed in a future cleanup sprint.
|
||||
*
|
||||
* Migration guide:
|
||||
* <app-page-header title="T" subtitle="S">
|
||||
* <button primary-actions>Action</button>
|
||||
* </app-page-header>
|
||||
*
|
||||
* becomes:
|
||||
*
|
||||
* <app-context-header title="T" subtitle="S">
|
||||
* <button header-actions>Action</button>
|
||||
* </app-context-header>
|
||||
*/
|
||||
|
||||
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
import { ContextHeaderComponent } from '../context-header/context-header.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-page-header',
|
||||
standalone: true,
|
||||
imports: [],
|
||||
imports: [ContextHeaderComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<header class="page-header">
|
||||
<div class="page-header__content">
|
||||
<h1 class="page-header__title">{{ title }}</h1>
|
||||
@if (subtitle) {
|
||||
<p class="page-header__subtitle">{{ subtitle }}</p>
|
||||
}
|
||||
</div>
|
||||
<div class="page-header__actions">
|
||||
<ng-content select="[secondary-actions]"></ng-content>
|
||||
<ng-content select="[primary-actions]"></ng-content>
|
||||
</div>
|
||||
</header>
|
||||
<app-context-header
|
||||
[title]="title"
|
||||
[subtitle]="subtitle"
|
||||
testId="page-header-compat"
|
||||
>
|
||||
<ng-content select="[secondary-actions]" header-actions></ng-content>
|
||||
<ng-content select="[primary-actions]" header-actions></ng-content>
|
||||
</app-context-header>
|
||||
`,
|
||||
styles: [`
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header__content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.page-header__title {
|
||||
margin: 0 0 0.375rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.page-header__subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.page-header__actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-header__actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class PageHeaderComponent {
|
||||
@Input() title!: string;
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
* Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (SHARED-010)
|
||||
*
|
||||
* Left list + right details layout.
|
||||
*
|
||||
* @deprecated Use `ListDetailShellComponent` instead. The list-detail shell is the
|
||||
* canonical master-detail layout primitive as of SPRINT_20260308_030. This component
|
||||
* is retained only for backward compatibility and will be removed in a future sprint.
|
||||
*/
|
||||
|
||||
import { Component, Input, ChangeDetectionStrategy, signal } from '@angular/core';
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Timeline List Component Tests
|
||||
* Sprint: SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation (FE-TLD-004)
|
||||
*/
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Component } from '@angular/core';
|
||||
import { TimelineListComponent, TimelineEvent, TimelineEventKind } from './timeline-list.component';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const NOW = new Date('2026-03-08T12:00:00.000Z');
|
||||
|
||||
function minutesAgo(n: number): string {
|
||||
return new Date(NOW.getTime() - n * 60_000).toISOString();
|
||||
}
|
||||
function hoursAgo(n: number): string {
|
||||
return new Date(NOW.getTime() - n * 3_600_000).toISOString();
|
||||
}
|
||||
function daysAgo(n: number): string {
|
||||
return new Date(NOW.getTime() - n * 86_400_000).toISOString();
|
||||
}
|
||||
|
||||
const SAMPLE_EVENTS: TimelineEvent[] = [
|
||||
{
|
||||
id: 'evt-1',
|
||||
timestamp: minutesAgo(5),
|
||||
title: 'Scan completed',
|
||||
description: 'Container image scan finished',
|
||||
actor: 'scanner-worker-01',
|
||||
eventKind: 'success',
|
||||
icon: 'check_circle',
|
||||
metadata: { imageDigest: 'sha256:abc123' },
|
||||
},
|
||||
{
|
||||
id: 'evt-2',
|
||||
timestamp: hoursAgo(3),
|
||||
title: 'Policy evaluated',
|
||||
eventKind: 'info',
|
||||
icon: 'policy',
|
||||
},
|
||||
{
|
||||
id: 'evt-3',
|
||||
timestamp: daysAgo(2),
|
||||
title: 'Finding created',
|
||||
description: 'CVE-2024-12345 detected',
|
||||
eventKind: 'error',
|
||||
icon: 'error',
|
||||
evidenceLink: '/findings/CVE-2024-12345',
|
||||
expandable: '{"cve":"CVE-2024-12345","cvss":9.8}',
|
||||
},
|
||||
{
|
||||
id: 'evt-4',
|
||||
timestamp: daysAgo(2),
|
||||
title: 'Attestation created',
|
||||
eventKind: 'warning',
|
||||
icon: 'verified',
|
||||
metadata: { sigAlgo: 'ECDSA-P256' },
|
||||
},
|
||||
{
|
||||
id: 'evt-5',
|
||||
timestamp: daysAgo(5),
|
||||
title: 'Cache hit',
|
||||
eventKind: 'neutral',
|
||||
},
|
||||
];
|
||||
|
||||
// Test host component to set inputs
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [TimelineListComponent],
|
||||
template: `
|
||||
<app-timeline-list
|
||||
[events]="events"
|
||||
[loading]="loading"
|
||||
[groupByDate]="groupByDate"
|
||||
[emptyMessage]="emptyMessage"
|
||||
[ariaLabel]="ariaLabel"
|
||||
/>
|
||||
`,
|
||||
})
|
||||
class TestHostComponent {
|
||||
events: TimelineEvent[] = [];
|
||||
loading = false;
|
||||
groupByDate = false;
|
||||
emptyMessage = 'No events to display';
|
||||
ariaLabel = 'Test timeline';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TimelineListComponent', () => {
|
||||
let fixture: ComponentFixture<TestHostComponent>;
|
||||
let host: TestHostComponent;
|
||||
let el: HTMLElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Patch Date.now for deterministic relative-time output
|
||||
vi.useFakeTimers({ now: NOW });
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TestHostComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TestHostComponent);
|
||||
host = fixture.componentInstance;
|
||||
el = fixture.nativeElement;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rendering basics
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should create', () => {
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render empty state when no events', () => {
|
||||
fixture.detectChanges();
|
||||
const emptyEl = el.querySelector('.timeline__empty');
|
||||
expect(emptyEl).toBeTruthy();
|
||||
expect(emptyEl!.textContent).toContain('No events to display');
|
||||
});
|
||||
|
||||
it('should render custom empty message', () => {
|
||||
host.emptyMessage = 'Nothing here.';
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline__empty-text')!.textContent).toContain('Nothing here.');
|
||||
});
|
||||
|
||||
it('should render events with titles', () => {
|
||||
host.events = SAMPLE_EVENTS;
|
||||
fixture.detectChanges();
|
||||
const titles = el.querySelectorAll('.timeline__title');
|
||||
expect(titles.length).toBe(5);
|
||||
expect(titles[0].textContent).toContain('Scan completed');
|
||||
expect(titles[2].textContent).toContain('Finding created');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Severity marker colors
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should apply correct severity marker classes', () => {
|
||||
host.events = SAMPLE_EVENTS;
|
||||
fixture.detectChanges();
|
||||
const markers = el.querySelectorAll('.timeline__marker');
|
||||
expect(markers[0].classList.contains('timeline__marker--success')).toBe(true);
|
||||
expect(markers[1].classList.contains('timeline__marker--info')).toBe(true);
|
||||
expect(markers[2].classList.contains('timeline__marker--error')).toBe(true);
|
||||
expect(markers[3].classList.contains('timeline__marker--warning')).toBe(true);
|
||||
expect(markers[4].classList.contains('timeline__marker--neutral')).toBe(true);
|
||||
});
|
||||
|
||||
it('should render critical marker with distinct styling', () => {
|
||||
host.events = [{
|
||||
id: 'crit-1',
|
||||
timestamp: minutesAgo(1),
|
||||
title: 'Critical breach',
|
||||
eventKind: 'critical',
|
||||
}];
|
||||
fixture.detectChanges();
|
||||
const marker = el.querySelector('.timeline__marker');
|
||||
expect(marker!.classList.contains('timeline__marker--critical')).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Timestamp formatting
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should display relative time for events less than 24h old', () => {
|
||||
host.events = [
|
||||
{ id: 't-1', timestamp: minutesAgo(5), title: '5m event' },
|
||||
{ id: 't-2', timestamp: hoursAgo(3), title: '3h event' },
|
||||
];
|
||||
fixture.detectChanges();
|
||||
const times = el.querySelectorAll('.timeline__time');
|
||||
expect(times[0].textContent).toContain('5m ago');
|
||||
expect(times[1].textContent).toContain('3h ago');
|
||||
});
|
||||
|
||||
it('should display "Just now" for very recent events', () => {
|
||||
host.events = [{ id: 't-now', timestamp: NOW.toISOString(), title: 'Now event' }];
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline__time')!.textContent).toContain('Just now');
|
||||
});
|
||||
|
||||
it('should display absolute UTC time for events older than 24h', () => {
|
||||
host.events = [{ id: 't-old', timestamp: daysAgo(2), title: 'Old event' }];
|
||||
fixture.detectChanges();
|
||||
const timeText = el.querySelector('.timeline__time')!.textContent!.trim();
|
||||
expect(timeText).toContain('UTC');
|
||||
expect(timeText).toMatch(/\d{4}-\d{2}-\d{2}/);
|
||||
});
|
||||
|
||||
it('should refresh relative time when flat-mode events change', () => {
|
||||
host.groupByDate = false;
|
||||
host.events = [{ id: 't-flat', timestamp: minutesAgo(5), title: 'Flat event' }];
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline__time')!.textContent).toContain('5m ago');
|
||||
|
||||
vi.setSystemTime(new Date(NOW.getTime() + 10 * 60_000));
|
||||
host.events = [{ ...host.events[0] }];
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.timeline__time')!.textContent).toContain('15m ago');
|
||||
});
|
||||
|
||||
it('should show full ISO timestamp in title attribute (tooltip)', () => {
|
||||
host.events = [{ id: 't-tip', timestamp: minutesAgo(10), title: 'Tooltip event' }];
|
||||
fixture.detectChanges();
|
||||
const timeEl = el.querySelector('.timeline__time') as HTMLElement;
|
||||
expect(timeEl.getAttribute('title')).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Expandable detail sections
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should show expand button for events with expandable content', () => {
|
||||
host.events = [SAMPLE_EVENTS[2]]; // evt-3 has expandable
|
||||
fixture.detectChanges();
|
||||
const btn = el.querySelector('.timeline__expand-btn');
|
||||
expect(btn).toBeTruthy();
|
||||
expect(btn!.textContent).toContain('Show details');
|
||||
});
|
||||
|
||||
it('should toggle expandable section on click', () => {
|
||||
host.events = [SAMPLE_EVENTS[2]];
|
||||
fixture.detectChanges();
|
||||
|
||||
const btn = el.querySelector('.timeline__expand-btn') as HTMLButtonElement;
|
||||
expect(el.querySelector('.timeline__expandable')).toBeNull();
|
||||
|
||||
btn.click();
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline__expandable')).toBeTruthy();
|
||||
expect(el.querySelector('.timeline__expandable-content')!.textContent).toContain('CVE-2024-12345');
|
||||
expect(btn.textContent).toContain('Hide details');
|
||||
|
||||
btn.click();
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline__expandable')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not show expand button for events without expandable content', () => {
|
||||
host.events = [SAMPLE_EVENTS[0]]; // evt-1 has no expandable
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline__expand-btn')).toBeNull();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Optional fields
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should render actor when provided', () => {
|
||||
host.events = [SAMPLE_EVENTS[0]]; // has actor
|
||||
fixture.detectChanges();
|
||||
const actor = el.querySelector('.timeline__actor');
|
||||
expect(actor).toBeTruthy();
|
||||
expect(actor!.textContent).toContain('scanner-worker-01');
|
||||
});
|
||||
|
||||
it('should not render actor when not provided', () => {
|
||||
host.events = [SAMPLE_EVENTS[1]]; // no actor
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline__actor')).toBeNull();
|
||||
});
|
||||
|
||||
it('should render description when provided', () => {
|
||||
host.events = [SAMPLE_EVENTS[0]];
|
||||
fixture.detectChanges();
|
||||
const desc = el.querySelector('.timeline__description');
|
||||
expect(desc).toBeTruthy();
|
||||
expect(desc!.textContent).toContain('Container image scan finished');
|
||||
});
|
||||
|
||||
it('should render evidence link when provided', () => {
|
||||
host.events = [SAMPLE_EVENTS[2]]; // has evidenceLink
|
||||
fixture.detectChanges();
|
||||
const link = el.querySelector('.timeline__evidence-link') as HTMLAnchorElement;
|
||||
expect(link).toBeTruthy();
|
||||
expect(link.getAttribute('href')).toBe('/findings/CVE-2024-12345');
|
||||
});
|
||||
|
||||
it('should render metadata chips when provided', () => {
|
||||
host.events = [SAMPLE_EVENTS[0]]; // has metadata
|
||||
fixture.detectChanges();
|
||||
const chips = el.querySelectorAll('.timeline__meta-chip');
|
||||
expect(chips.length).toBe(1);
|
||||
expect(chips[0].querySelector('.timeline__meta-key')!.textContent).toContain('imageDigest');
|
||||
expect(chips[0].querySelector('.timeline__meta-value')!.textContent).toContain('sha256:abc123');
|
||||
});
|
||||
|
||||
it('should render material icon when provided', () => {
|
||||
host.events = [SAMPLE_EVENTS[0]]; // has icon
|
||||
fixture.detectChanges();
|
||||
const icon = el.querySelector('.timeline__icon');
|
||||
expect(icon).toBeTruthy();
|
||||
expect(icon!.textContent).toContain('check_circle');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Date grouping
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should group events by date when groupByDate is true', () => {
|
||||
host.events = SAMPLE_EVENTS;
|
||||
host.groupByDate = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const dateHeaders = el.querySelectorAll('.timeline__date-label');
|
||||
// Events span 3 different days: today (~5m ago, ~3h ago), 2 days ago (2 events), 5 days ago
|
||||
expect(dateHeaders.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should not group events when groupByDate is false', () => {
|
||||
host.events = SAMPLE_EVENTS;
|
||||
host.groupByDate = false;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(el.querySelector('.timeline__date-group')).toBeNull();
|
||||
const items = el.querySelectorAll('.timeline__item');
|
||||
expect(items.length).toBe(5);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Loading state
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should show loading skeleton when loading is true', () => {
|
||||
host.loading = true;
|
||||
fixture.detectChanges();
|
||||
const skeleton = el.querySelector('.timeline--loading');
|
||||
expect(skeleton).toBeTruthy();
|
||||
expect(el.querySelectorAll('.timeline__skeleton-item').length).toBe(5);
|
||||
});
|
||||
|
||||
it('should show sr-only loading text for screen readers', () => {
|
||||
host.loading = true;
|
||||
fixture.detectChanges();
|
||||
const srOnly = el.querySelector('.sr-only');
|
||||
expect(srOnly).toBeTruthy();
|
||||
expect(srOnly!.textContent).toContain('Loading timeline');
|
||||
});
|
||||
|
||||
it('should not show loading skeleton when loading is false', () => {
|
||||
host.loading = false;
|
||||
fixture.detectChanges();
|
||||
expect(el.querySelector('.timeline--loading')).toBeNull();
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Accessibility
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should have role="feed" on the timeline container', () => {
|
||||
host.events = SAMPLE_EVENTS;
|
||||
fixture.detectChanges();
|
||||
const timeline = el.querySelector('[role="feed"]');
|
||||
expect(timeline).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should apply custom ariaLabel', () => {
|
||||
host.ariaLabel = 'Incident feed';
|
||||
host.events = SAMPLE_EVENTS;
|
||||
fixture.detectChanges();
|
||||
const timeline = el.querySelector('[role="feed"]');
|
||||
expect(timeline!.getAttribute('aria-label')).toBe('Incident feed');
|
||||
});
|
||||
|
||||
it('should have role="article" on each event item', () => {
|
||||
host.events = SAMPLE_EVENTS;
|
||||
fixture.detectChanges();
|
||||
const articles = el.querySelectorAll('[role="article"]');
|
||||
expect(articles.length).toBe(5);
|
||||
});
|
||||
|
||||
it('should have aria-label on each event item matching title', () => {
|
||||
host.events = [SAMPLE_EVENTS[0]];
|
||||
fixture.detectChanges();
|
||||
const article = el.querySelector('[role="article"]');
|
||||
expect(article!.getAttribute('aria-label')).toBe('Scan completed');
|
||||
});
|
||||
|
||||
it('should have aria-expanded on expand button', () => {
|
||||
host.events = [SAMPLE_EVENTS[2]]; // has expandable
|
||||
fixture.detectChanges();
|
||||
const btn = el.querySelector('.timeline__expand-btn');
|
||||
expect(btn!.getAttribute('aria-expanded')).toBe('false');
|
||||
|
||||
(btn as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
expect(btn!.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
it('should have datetime attribute on time elements', () => {
|
||||
host.events = [SAMPLE_EVENTS[0]];
|
||||
fixture.detectChanges();
|
||||
const timeEl = el.querySelector('.timeline__time');
|
||||
expect(timeEl!.getAttribute('datetime')).toMatch(/\d{4}-\d{2}-\d{2}T/);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Vertical connector line
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should not render connector line on the last item', () => {
|
||||
host.events = SAMPLE_EVENTS.slice(0, 2);
|
||||
fixture.detectChanges();
|
||||
const items = el.querySelectorAll('.timeline__item');
|
||||
expect(items[1].classList.contains('timeline__item--last')).toBe(true);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Default eventKind fallback
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
it('should default to neutral marker when eventKind is not provided', () => {
|
||||
host.events = [{ id: 'no-kind', timestamp: minutesAgo(1), title: 'No kind' }];
|
||||
fixture.detectChanges();
|
||||
const marker = el.querySelector('.timeline__marker');
|
||||
expect(marker!.classList.contains('timeline__marker--neutral')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,71 +1,349 @@
|
||||
/**
|
||||
* Timeline List Component
|
||||
* Sprint: SPRINT_20260118_009_FE_route_migration_shared_components (SHARED-010)
|
||||
* Sprint: SPRINT_20260308_029_FE_timeline_list_audit_timeline_derivation (FE-TLD-001..002)
|
||||
*
|
||||
* Chronological event timeline display.
|
||||
* Canonical audit-grade event-stream timeline.
|
||||
*
|
||||
* Features:
|
||||
* - Vertical timeline with colored severity markers
|
||||
* - Deterministic UTC ISO-8601 timestamp formatting
|
||||
* - Relative time for <24h, absolute for older, full ISO on hover
|
||||
* - Expandable detail sections for event payloads
|
||||
* - Optional actor/source metadata
|
||||
* - Date grouping when events span multiple days
|
||||
* - Loading skeleton and empty states
|
||||
* - Accessibility (role="feed", aria-labels)
|
||||
* - Content projection via ng-template for domain-specific rendering
|
||||
*/
|
||||
|
||||
import { Component, Input, ChangeDetectionStrategy, ContentChild, TemplateRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
ContentChild,
|
||||
TemplateRef,
|
||||
computed,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Canonical Event Model (FE-TLD-001)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Severity / event kind for timeline events.
|
||||
* Determines marker color and visual weight.
|
||||
*/
|
||||
export type TimelineEventKind = 'info' | 'success' | 'warning' | 'error' | 'critical' | 'neutral';
|
||||
|
||||
/**
|
||||
* Canonical timeline event model for all audit/evidence/release chronology surfaces.
|
||||
*
|
||||
* Time display rules:
|
||||
* - Relative time for events < 24h old (e.g. "5m ago", "3h ago")
|
||||
* - Absolute UTC ISO-8601 for events >= 24h old (e.g. "2026-03-08 14:23 UTC")
|
||||
* - Full ISO-8601 timestamp always available via tooltip on hover
|
||||
* - Events are grouped by date when spanning multiple days
|
||||
*/
|
||||
export interface TimelineEvent {
|
||||
id: string;
|
||||
timestamp: Date | string;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
type?: 'success' | 'warning' | 'error' | 'info' | 'neutral';
|
||||
/** Unique event identifier. */
|
||||
readonly id: string;
|
||||
/** ISO-8601 UTC timestamp. */
|
||||
readonly timestamp: string;
|
||||
/** Event summary (single line). */
|
||||
readonly title: string;
|
||||
/** Optional detail text. */
|
||||
readonly description?: string;
|
||||
/** Who or what caused this event (user, service, system). */
|
||||
readonly actor?: string;
|
||||
/** Event severity / kind. Defaults to 'neutral'. */
|
||||
readonly eventKind?: TimelineEventKind;
|
||||
/** Material icon name (optional). */
|
||||
readonly icon?: string;
|
||||
/** Link to related evidence (optional URL). */
|
||||
readonly evidenceLink?: string;
|
||||
/** Arbitrary key-value metadata. */
|
||||
readonly metadata?: Record<string, string>;
|
||||
/** Expandable detail payload (rendered in collapsible section). */
|
||||
readonly expandable?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DateGroup {
|
||||
dateLabel: string;
|
||||
events: TimelineEvent[];
|
||||
}
|
||||
|
||||
const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Format a timestamp for display.
|
||||
* - < 1 minute: "Just now"
|
||||
* - < 60 minutes: "Xm ago"
|
||||
* - < 24 hours: "Xh ago"
|
||||
* - >= 24 hours: "YYYY-MM-DD HH:mm UTC"
|
||||
*/
|
||||
function formatDisplayTime(iso: string, now: Date): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return iso;
|
||||
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
if (diffMs < 0) {
|
||||
// Future event: show absolute
|
||||
return formatAbsoluteUtc(date);
|
||||
}
|
||||
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
|
||||
return formatAbsoluteUtc(date);
|
||||
}
|
||||
|
||||
function formatAbsoluteUtc(date: Date): string {
|
||||
const y = date.getUTCFullYear();
|
||||
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getUTCDate()).padStart(2, '0');
|
||||
const hh = String(date.getUTCHours()).padStart(2, '0');
|
||||
const mm = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
return `${y}-${m}-${d} ${hh}:${mm} UTC`;
|
||||
}
|
||||
|
||||
function toIsoFull(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return iso;
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function toDateKey(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return 'Unknown';
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@Component({
|
||||
selector: 'app-timeline-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [NgTemplateOutlet],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="timeline">
|
||||
@for (event of events; track event.id; let last = $last) {
|
||||
<div class="timeline__item" [class.timeline__item--last]="last">
|
||||
<div class="timeline__marker" [class]="'timeline__marker--' + (event.type || 'neutral')">
|
||||
@if (event.icon) {
|
||||
<span>{{ event.icon }}</span>
|
||||
}
|
||||
</div>
|
||||
<div class="timeline__content">
|
||||
<div class="timeline__header">
|
||||
<span class="timeline__title">{{ event.title }}</span>
|
||||
<time class="timeline__time">{{ formatTime(event.timestamp) }}</time>
|
||||
<!-- Loading skeleton -->
|
||||
@if (loading()) {
|
||||
<div class="timeline timeline--loading" role="status" aria-label="Loading timeline events">
|
||||
@for (i of skeletonRows; track i) {
|
||||
<div class="timeline__skeleton-item">
|
||||
<div class="timeline__skeleton-marker"></div>
|
||||
<div class="timeline__skeleton-content">
|
||||
<div class="timeline__skeleton-title"></div>
|
||||
<div class="timeline__skeleton-desc"></div>
|
||||
</div>
|
||||
@if (event.description) {
|
||||
<p class="timeline__description">{{ event.description }}</p>
|
||||
}
|
||||
@if (eventTemplate) {
|
||||
<ng-container *ngTemplateOutlet="eventTemplate; context: { $implicit: event }"></ng-container>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<span class="sr-only">Loading timeline...</span>
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
class="timeline"
|
||||
role="feed"
|
||||
[attr.aria-label]="ariaLabel()"
|
||||
[attr.aria-busy]="false"
|
||||
>
|
||||
@if (groupByDate()) {
|
||||
<!-- Grouped by date -->
|
||||
@for (group of dateGroups(); track group.dateLabel) {
|
||||
<div class="timeline__date-group">
|
||||
<div class="timeline__date-header" role="separator">
|
||||
<span class="timeline__date-label">{{ group.dateLabel }}</span>
|
||||
</div>
|
||||
@for (event of group.events; track event.id; let last = $last; let idx = $index) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="eventRow; context: { $implicit: event, last: last && $last }"
|
||||
></ng-container>
|
||||
}
|
||||
</div>
|
||||
} @empty {
|
||||
<ng-container *ngTemplateOutlet="emptyState"></ng-container>
|
||||
}
|
||||
} @else {
|
||||
<!-- Flat list -->
|
||||
@for (event of renderedEvents(); track event.id; let last = $last) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="eventRow; context: { $implicit: event, last: last }"
|
||||
></ng-container>
|
||||
} @empty {
|
||||
<ng-container *ngTemplateOutlet="emptyState"></ng-container>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Event row template -->
|
||||
<ng-template #eventRow let-event let-last="last">
|
||||
<article
|
||||
class="timeline__item"
|
||||
[class.timeline__item--last]="last"
|
||||
role="article"
|
||||
[attr.aria-label]="event.title"
|
||||
>
|
||||
<div
|
||||
class="timeline__marker"
|
||||
[class]="'timeline__marker--' + (event.eventKind || 'neutral')"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@if (event.icon) {
|
||||
<span class="timeline__icon material-symbols-outlined">{{ event.icon }}</span>
|
||||
}
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="timeline__empty">No events to display</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="timeline__content">
|
||||
<div class="timeline__header">
|
||||
<span class="timeline__title">{{ event.title }}</span>
|
||||
<time
|
||||
class="timeline__time"
|
||||
[attr.datetime]="toIsoFull(event.timestamp)"
|
||||
[title]="toIsoFull(event.timestamp)"
|
||||
>{{ formatTime(event.timestamp) }}</time>
|
||||
</div>
|
||||
|
||||
@if (event.actor) {
|
||||
<span class="timeline__actor">{{ event.actor }}</span>
|
||||
}
|
||||
|
||||
@if (event.description) {
|
||||
<p class="timeline__description">{{ event.description }}</p>
|
||||
}
|
||||
|
||||
@if (event.evidenceLink) {
|
||||
<a
|
||||
class="timeline__evidence-link"
|
||||
[href]="event.evidenceLink"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>View evidence</a>
|
||||
}
|
||||
|
||||
@if (event.metadata && hasKeys(event.metadata)) {
|
||||
<div class="timeline__metadata">
|
||||
@for (entry of objectEntries(event.metadata); track entry[0]) {
|
||||
<span class="timeline__meta-chip">
|
||||
<span class="timeline__meta-key">{{ entry[0] }}</span>
|
||||
<span class="timeline__meta-value">{{ entry[1] }}</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (event.expandable) {
|
||||
<button
|
||||
type="button"
|
||||
class="timeline__expand-btn"
|
||||
[attr.aria-expanded]="isExpanded(event.id)"
|
||||
(click)="toggleExpand(event.id)"
|
||||
>
|
||||
{{ isExpanded(event.id) ? 'Hide details' : 'Show details' }}
|
||||
</button>
|
||||
@if (isExpanded(event.id)) {
|
||||
<div class="timeline__expandable" role="region" aria-label="Event details">
|
||||
<pre class="timeline__expandable-content">{{ event.expandable }}</pre>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (eventTemplate) {
|
||||
<ng-container
|
||||
*ngTemplateOutlet="eventTemplate; context: { $implicit: event }"
|
||||
></ng-container>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
</ng-template>
|
||||
|
||||
<!-- Empty state template -->
|
||||
<ng-template #emptyState>
|
||||
<div class="timeline__empty" role="status">
|
||||
<span class="timeline__empty-icon material-symbols-outlined" aria-hidden="true">event_busy</span>
|
||||
<p class="timeline__empty-text">{{ emptyMessage() }}</p>
|
||||
</div>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: [`
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared timeline primitive */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ---- Date group header ---- */
|
||||
.timeline__date-group {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.timeline__date-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0 0.5rem 0.25rem;
|
||||
}
|
||||
|
||||
.timeline__date-header::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--color-border-primary);
|
||||
}
|
||||
|
||||
.timeline__date-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- Event item ---- */
|
||||
.timeline__item {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
gap: 0.75rem;
|
||||
position: relative;
|
||||
padding-bottom: 1.5rem;
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.timeline__item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
top: 1.25rem;
|
||||
left: 0.5625rem;
|
||||
top: 1.375rem;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--color-border-primary);
|
||||
@@ -75,6 +353,7 @@ export interface TimelineEvent {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- Marker ---- */
|
||||
.timeline__marker {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
@@ -87,6 +366,11 @@ export interface TimelineEvent {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline__icon {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.timeline__marker--success {
|
||||
background: var(--color-severity-low-bg);
|
||||
color: var(--color-status-success-text);
|
||||
@@ -102,6 +386,12 @@ export interface TimelineEvent {
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.timeline__marker--critical {
|
||||
background: var(--color-severity-critical-bg);
|
||||
color: var(--color-status-error-text);
|
||||
box-shadow: 0 0 0 2px var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.timeline__marker--info {
|
||||
background: var(--color-severity-info-bg);
|
||||
color: var(--color-status-info-text);
|
||||
@@ -112,6 +402,7 @@ export interface TimelineEvent {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ---- Content ---- */
|
||||
.timeline__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -131,40 +422,265 @@ export interface TimelineEvent {
|
||||
}
|
||||
|
||||
.timeline__time {
|
||||
font-size: 0.75rem;
|
||||
font-size: 0.6875rem;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
color: var(--color-text-secondary);
|
||||
white-space: nowrap;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.timeline__actor {
|
||||
display: inline-block;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-secondary);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.timeline__actor::before {
|
||||
content: 'by ';
|
||||
}
|
||||
|
||||
.timeline__description {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.timeline__evidence-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.timeline__evidence-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---- Metadata chips ---- */
|
||||
.timeline__metadata {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.375rem;
|
||||
}
|
||||
|
||||
.timeline__meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.timeline__meta-key {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.timeline__meta-value {
|
||||
color: var(--color-text-primary);
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
/* ---- Expandable ---- */
|
||||
.timeline__expand-btn {
|
||||
display: inline-block;
|
||||
margin-top: 0.375rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-brand-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline__expand-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.timeline__expand-btn:focus-visible {
|
||||
outline: 2px solid var(--color-brand-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.timeline__expandable {
|
||||
margin-top: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.timeline__expandable-content {
|
||||
margin: 0;
|
||||
font-family: 'JetBrains Mono', 'SF Mono', monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ---- Empty ---- */
|
||||
.timeline__empty {
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2.5rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.timeline__empty-icon {
|
||||
font-size: 2rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.timeline__empty-text {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* ---- Loading skeleton ---- */
|
||||
.timeline--loading .timeline__skeleton-item {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.timeline__skeleton-marker {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface-secondary);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.timeline__skeleton-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.timeline__skeleton-title {
|
||||
width: 60%;
|
||||
height: 0.875rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-secondary);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.timeline__skeleton-desc {
|
||||
width: 40%;
|
||||
height: 0.625rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-secondary);
|
||||
animation: skeleton-pulse 1.5s ease-in-out infinite;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
@keyframes skeleton-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class TimelineListComponent {
|
||||
@Input() events: TimelineEvent[] = [];
|
||||
/** Events to display. */
|
||||
readonly events = input<TimelineEvent[]>([]);
|
||||
|
||||
/** Whether to show loading skeleton. */
|
||||
readonly loading = input<boolean>(false);
|
||||
|
||||
/** Whether to group events by date (when events span multiple days). */
|
||||
readonly groupByDate = input<boolean>(false);
|
||||
|
||||
/** Empty state message. */
|
||||
readonly emptyMessage = input<string>('No events to display');
|
||||
|
||||
/** Accessible label for the feed container. */
|
||||
readonly ariaLabel = input<string>('Event timeline');
|
||||
|
||||
/** Optional content projection template for domain-specific rendering. */
|
||||
@ContentChild('eventContent') eventTemplate?: TemplateRef<unknown>;
|
||||
|
||||
formatTime(timestamp: Date | string): string {
|
||||
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
/** Expanded event IDs. */
|
||||
private readonly expandedIds = signal<Set<string>>(new Set());
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
return date.toLocaleDateString();
|
||||
/** Skeleton row count for loading state. */
|
||||
readonly skeletonRows = [0, 1, 2, 3, 4];
|
||||
|
||||
/** Cached "now" for consistent relative time within a render cycle. */
|
||||
private renderNow = new Date();
|
||||
|
||||
/** Flat and grouped modes share the same render clock refresh path. */
|
||||
readonly renderedEvents = computed<TimelineEvent[]>(() => {
|
||||
const evts = this.events();
|
||||
this.renderNow = new Date();
|
||||
return evts;
|
||||
});
|
||||
|
||||
/** Computed date groups for grouped display. */
|
||||
readonly dateGroups = computed<DateGroup[]>(() => {
|
||||
const evts = this.renderedEvents();
|
||||
if (!evts.length) return [];
|
||||
|
||||
const groups = new Map<string, TimelineEvent[]>();
|
||||
for (const evt of evts) {
|
||||
const key = toDateKey(evt.timestamp);
|
||||
const arr = groups.get(key) ?? [];
|
||||
arr.push(evt);
|
||||
groups.set(key, arr);
|
||||
}
|
||||
|
||||
return Array.from(groups.entries()).map(([dateLabel, events]) => ({
|
||||
dateLabel,
|
||||
events,
|
||||
}));
|
||||
});
|
||||
|
||||
// Template helper: format display time
|
||||
formatTime(iso: string): string {
|
||||
return formatDisplayTime(iso, this.renderNow);
|
||||
}
|
||||
|
||||
// Template helper: full ISO timestamp for tooltip
|
||||
toIsoFull(iso: string): string {
|
||||
return toIsoFull(iso);
|
||||
}
|
||||
|
||||
// Template helper: check if metadata has keys
|
||||
hasKeys(obj: Record<string, string>): boolean {
|
||||
return Object.keys(obj).length > 0;
|
||||
}
|
||||
|
||||
// Template helper: Object.entries for template iteration
|
||||
objectEntries(obj: Record<string, string>): [string, string][] {
|
||||
return Object.entries(obj);
|
||||
}
|
||||
|
||||
// Expand/collapse
|
||||
isExpanded(id: string): boolean {
|
||||
return this.expandedIds().has(id);
|
||||
}
|
||||
|
||||
toggleExpand(id: string): void {
|
||||
this.expandedIds.update(ids => {
|
||||
const next = new Set(ids);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Attestation Detail Component Tests
|
||||
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-004)
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Component, signal } from '@angular/core';
|
||||
|
||||
import { AttestationDetailComponent } from './attestation-detail.component';
|
||||
import type { AttestationData } from './witness.models';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [AttestationDetailComponent],
|
||||
template: `<app-attestation-detail [data]="data()" />`,
|
||||
})
|
||||
class TestHostComponent {
|
||||
data = signal<AttestationData | null>(null);
|
||||
}
|
||||
|
||||
describe('AttestationDetailComponent', () => {
|
||||
let fixture: ComponentFixture<TestHostComponent>;
|
||||
let host: TestHostComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TestHostComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TestHostComponent);
|
||||
host = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should render the attestation detail section', () => {
|
||||
const section = fixture.nativeElement.querySelector('[data-testid="attestation-detail"]');
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show empty state when no data', () => {
|
||||
const empty = fixture.nativeElement.querySelector('[data-testid="attestation-empty"]');
|
||||
expect(empty).toBeTruthy();
|
||||
expect(empty.textContent).toContain('No attestation data available');
|
||||
});
|
||||
|
||||
it('should display predicate type', () => {
|
||||
host.data.set({
|
||||
predicateType: 'https://in-toto.io/attestation/vulns/v0.1',
|
||||
subjectName: 'registry.example.com/app:v1.2',
|
||||
subjectDigests: [
|
||||
{ algorithm: 'sha256', hash: 'abc123def456' },
|
||||
],
|
||||
predicate: { scanner: 'grype', version: '0.72' },
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const predicateType = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-type"]');
|
||||
expect(predicateType).toBeTruthy();
|
||||
expect(predicateType.textContent).toContain('https://in-toto.io/attestation/vulns/v0.1');
|
||||
});
|
||||
|
||||
it('should display subject name and digests', () => {
|
||||
host.data.set({
|
||||
predicateType: 'https://in-toto.io/attestation/sbom/v0.1',
|
||||
subjectName: 'registry.example.com/app:v1.2',
|
||||
subjectDigests: [
|
||||
{ algorithm: 'sha256', hash: 'abc123' },
|
||||
{ algorithm: 'sha512', hash: 'def456' },
|
||||
],
|
||||
predicate: {},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const subject = fixture.nativeElement.querySelector('[data-testid="attestation-subject"]');
|
||||
expect(subject).toBeTruthy();
|
||||
expect(subject.textContent).toContain('registry.example.com/app:v1.2');
|
||||
|
||||
const digestRows = fixture.nativeElement.querySelectorAll('.digest-row');
|
||||
expect(digestRows.length).toBe(2);
|
||||
expect(digestRows[0].textContent).toContain('sha256');
|
||||
expect(digestRows[0].textContent).toContain('abc123');
|
||||
expect(digestRows[1].textContent).toContain('sha512');
|
||||
});
|
||||
|
||||
it('should toggle predicate JSON on click', () => {
|
||||
host.data.set({
|
||||
predicateType: 'https://in-toto.io/attestation/sbom/v0.1',
|
||||
subjectName: 'app:v1',
|
||||
subjectDigests: [],
|
||||
predicate: { scanner: 'grype', version: '0.72' },
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
// Initially hidden
|
||||
let predicateJson = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-json"]');
|
||||
expect(predicateJson).toBeNull();
|
||||
|
||||
// Click toggle
|
||||
const toggle = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-toggle"]');
|
||||
expect(toggle).toBeTruthy();
|
||||
toggle.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
// Now visible
|
||||
predicateJson = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-json"]');
|
||||
expect(predicateJson).toBeTruthy();
|
||||
expect(predicateJson.textContent).toContain('grype');
|
||||
expect(predicateJson.textContent).toContain('0.72');
|
||||
});
|
||||
|
||||
it('should hide predicate JSON when toggled off', () => {
|
||||
host.data.set({
|
||||
predicateType: 'type',
|
||||
subjectName: 'subj',
|
||||
subjectDigests: [],
|
||||
predicate: { key: 'value' },
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const toggle = fixture.nativeElement.querySelector('[data-testid="attestation-predicate-toggle"]');
|
||||
toggle.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
// Verify visible
|
||||
expect(fixture.nativeElement.querySelector('[data-testid="attestation-predicate-json"]')).toBeTruthy();
|
||||
|
||||
// Toggle off
|
||||
toggle.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('[data-testid="attestation-predicate-json"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Attestation Detail Component
|
||||
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-002)
|
||||
*
|
||||
* Composable section displaying attestation statement type, subject digests,
|
||||
* and predicate payload. Embeddable in Reachability witness and Evidence
|
||||
* proof surfaces.
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
import type { AttestationData } from './witness.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-attestation-detail',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="attestation-detail" data-testid="attestation-detail">
|
||||
<h3 class="section-title">Attestation</h3>
|
||||
|
||||
@if (!data()) {
|
||||
<div class="empty-state" data-testid="attestation-empty">
|
||||
No attestation data available.
|
||||
</div>
|
||||
} @else {
|
||||
<dl class="attestation-grid">
|
||||
<dt>Predicate Type</dt>
|
||||
<dd><code data-testid="attestation-predicate-type">{{ data()!.predicateType }}</code></dd>
|
||||
|
||||
<dt>Subject</dt>
|
||||
<dd>
|
||||
<div class="subject-info">
|
||||
<span class="subject-name" data-testid="attestation-subject">{{ data()!.subjectName }}</span>
|
||||
<div class="subject-digests">
|
||||
@for (digest of data()!.subjectDigests; track digest.algorithm) {
|
||||
<div class="digest-row">
|
||||
<span class="digest-algorithm">{{ digest.algorithm }}:</span>
|
||||
<code class="digest-value">{{ digest.hash }}</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<!-- Predicate JSON (collapsible) -->
|
||||
<div class="predicate-section">
|
||||
<button
|
||||
type="button"
|
||||
class="predicate-toggle"
|
||||
(click)="showPredicate.set(!showPredicate())"
|
||||
[attr.aria-expanded]="showPredicate()"
|
||||
data-testid="attestation-predicate-toggle"
|
||||
>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true"
|
||||
[class.rotated]="showPredicate()"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6"/>
|
||||
</svg>
|
||||
Predicate Data
|
||||
</button>
|
||||
|
||||
@if (showPredicate()) {
|
||||
<pre class="predicate-json" data-testid="attestation-predicate-json">{{ predicateJson() }}</pre>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.attestation-detail {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.attestation-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.attestation-grid dt {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.attestation-grid dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.8125rem;
|
||||
background: var(--color-severity-none-bg);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.subject-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.subject-name {
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.subject-digests {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.digest-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.digest-algorithm {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
min-width: 55px;
|
||||
}
|
||||
|
||||
.digest-value {
|
||||
font-size: 0.72rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.predicate-section {
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
padding-top: 0.65rem;
|
||||
}
|
||||
|
||||
.predicate-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-brand-primary);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.predicate-toggle svg {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.predicate-toggle svg.rotated {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.predicate-json {
|
||||
background: var(--color-text-heading);
|
||||
color: var(--color-severity-none-bg);
|
||||
padding: 0.85rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.78rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class AttestationDetailComponent {
|
||||
/** Attestation data to display (null when not available). */
|
||||
readonly data = input<AttestationData | null>(null);
|
||||
|
||||
/** Whether the predicate JSON is expanded. */
|
||||
readonly showPredicate = signal(false);
|
||||
|
||||
/** Formatted predicate JSON. */
|
||||
readonly predicateJson = () => {
|
||||
const d = this.data();
|
||||
if (!d) return '';
|
||||
try {
|
||||
return JSON.stringify(d.predicate, null, 2);
|
||||
} catch {
|
||||
return String(d.predicate);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Evidence Payload Component Tests
|
||||
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-004)
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Component, signal } from '@angular/core';
|
||||
|
||||
import { EvidencePayloadComponent } from './evidence-payload.component';
|
||||
import type { EvidencePayloadData } from './witness.models';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [EvidencePayloadComponent],
|
||||
template: `<app-evidence-payload [data]="data()" />`,
|
||||
})
|
||||
class TestHostComponent {
|
||||
data = signal<EvidencePayloadData>({
|
||||
evidenceId: 'ev-001',
|
||||
rawContent: '{"type":"attestation","verified":true}',
|
||||
metadata: {
|
||||
source: 'scanner',
|
||||
version: '0.72',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('EvidencePayloadComponent', () => {
|
||||
let fixture: ComponentFixture<TestHostComponent>;
|
||||
let host: TestHostComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TestHostComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TestHostComponent);
|
||||
host = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should render the evidence payload section', () => {
|
||||
const section = fixture.nativeElement.querySelector('[data-testid="evidence-payload"]');
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show the "Show Raw Content" button initially', () => {
|
||||
const showBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-show"]');
|
||||
expect(showBtn).toBeTruthy();
|
||||
expect(showBtn.textContent).toContain('Show Raw Content');
|
||||
});
|
||||
|
||||
it('should show raw content when toggled', () => {
|
||||
const showBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-show"]');
|
||||
showBtn.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const raw = fixture.nativeElement.querySelector('[data-testid="evidence-payload-raw"]');
|
||||
expect(raw).toBeTruthy();
|
||||
expect(raw.textContent).toContain('attestation');
|
||||
expect(raw.textContent).toContain('true');
|
||||
});
|
||||
|
||||
it('should have copy and download action buttons', () => {
|
||||
const copyBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-copy"]');
|
||||
const downloadBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-download"]');
|
||||
expect(copyBtn).toBeTruthy();
|
||||
expect(downloadBtn).toBeTruthy();
|
||||
expect(copyBtn.textContent).toContain('Copy');
|
||||
expect(downloadBtn.textContent).toContain('Download');
|
||||
});
|
||||
|
||||
it('should display metadata section when metadata is provided', () => {
|
||||
const metadata = fixture.nativeElement.querySelector('[data-testid="evidence-payload-metadata"]');
|
||||
expect(metadata).toBeTruthy();
|
||||
expect(metadata.textContent).toContain('scanner');
|
||||
expect(metadata.textContent).toContain('0.72');
|
||||
});
|
||||
|
||||
it('should not display metadata when empty', () => {
|
||||
host.data.set({
|
||||
evidenceId: 'ev-002',
|
||||
rawContent: '{}',
|
||||
metadata: {},
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const metadata = fixture.nativeElement.querySelector('[data-testid="evidence-payload-metadata"]');
|
||||
expect(metadata).toBeNull();
|
||||
});
|
||||
|
||||
it('should not display metadata when undefined', () => {
|
||||
host.data.set({
|
||||
evidenceId: 'ev-003',
|
||||
rawContent: '{}',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const metadata = fixture.nativeElement.querySelector('[data-testid="evidence-payload-metadata"]');
|
||||
expect(metadata).toBeNull();
|
||||
});
|
||||
|
||||
it('should hide raw content when toggled off', () => {
|
||||
// Show
|
||||
const showBtn = fixture.nativeElement.querySelector('[data-testid="evidence-payload-show"]');
|
||||
showBtn.click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.querySelector('[data-testid="evidence-payload-raw"]')).toBeTruthy();
|
||||
|
||||
// Hide via the "Hide raw content" link
|
||||
const hideBtn = fixture.nativeElement.querySelector('.btn-link');
|
||||
hideBtn.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.querySelector('[data-testid="evidence-payload-raw"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Evidence Payload Component
|
||||
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-002)
|
||||
*
|
||||
* Composable section for viewing, copying, and downloading raw evidence
|
||||
* JSON payloads and metadata. Embeddable in Reachability witness and
|
||||
* Evidence proof views.
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
signal,
|
||||
computed,
|
||||
} from '@angular/core';
|
||||
|
||||
import type { EvidencePayloadData } from './witness.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-evidence-payload',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="evidence-payload" data-testid="evidence-payload">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">Raw Evidence</h3>
|
||||
<div class="section-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action"
|
||||
(click)="copyPayload()"
|
||||
data-testid="evidence-payload-copy"
|
||||
>
|
||||
@if (copied()) {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true"
|
||||
style="display:inline;vertical-align:middle">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Copied
|
||||
} @else {
|
||||
Copy
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-action"
|
||||
(click)="downloadPayload()"
|
||||
data-testid="evidence-payload-download"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw Content (collapsible) -->
|
||||
@if (showRaw()) {
|
||||
<div class="raw-wrapper">
|
||||
<pre class="raw-content" data-testid="evidence-payload-raw">{{ data().rawContent }}</pre>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
(click)="showRaw.set(false)"
|
||||
>
|
||||
Hide raw content
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="show-raw-btn"
|
||||
(click)="showRaw.set(true)"
|
||||
data-testid="evidence-payload-show"
|
||||
>
|
||||
Show Raw Content
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Metadata (when available) -->
|
||||
@if (hasMetadata()) {
|
||||
<div class="metadata-section">
|
||||
<h4 class="metadata-title">Metadata</h4>
|
||||
<pre class="metadata-json" data-testid="evidence-payload-metadata">{{ metadataJson() }}</pre>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.evidence-payload {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
padding: 0.35rem 0.65rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
background: var(--color-severity-none-bg);
|
||||
}
|
||||
|
||||
.show-raw-btn {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-primary);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.show-raw-btn:hover {
|
||||
background: var(--color-severity-none-bg);
|
||||
}
|
||||
|
||||
.raw-wrapper {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.raw-content,
|
||||
.metadata-json {
|
||||
background: var(--color-text-heading);
|
||||
color: var(--color-severity-none-bg);
|
||||
padding: 0.85rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.78rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-brand-primary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 0.75rem;
|
||||
text-decoration: underline;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.metadata-section {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.metadata-title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.78rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class EvidencePayloadComponent {
|
||||
/** Payload data to display. */
|
||||
readonly data = input.required<EvidencePayloadData>();
|
||||
|
||||
/** Whether the raw content is expanded. */
|
||||
readonly showRaw = signal(false);
|
||||
|
||||
/** Whether copy feedback is active. */
|
||||
readonly copied = signal(false);
|
||||
|
||||
/** Whether metadata is non-empty. */
|
||||
readonly hasMetadata = computed(() => {
|
||||
const meta = this.data().metadata;
|
||||
return meta != null && Object.keys(meta).length > 0;
|
||||
});
|
||||
|
||||
/** Formatted metadata JSON. */
|
||||
readonly metadataJson = computed(() => {
|
||||
const meta = this.data().metadata;
|
||||
if (!meta) return '';
|
||||
try {
|
||||
return JSON.stringify(meta, null, 2);
|
||||
} catch {
|
||||
return String(meta);
|
||||
}
|
||||
});
|
||||
|
||||
/** Copy raw content to clipboard. */
|
||||
copyPayload(): void {
|
||||
const content = this.data().rawContent;
|
||||
navigator.clipboard.writeText(content).then(() => {
|
||||
this.copied.set(true);
|
||||
setTimeout(() => this.copied.set(false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
/** Download raw content as JSON file. */
|
||||
downloadPayload(): void {
|
||||
const content = this.data().rawContent;
|
||||
const id = this.data().evidenceId;
|
||||
const blob = new Blob([content], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `evidence-${id}.json`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
21
src/Web/StellaOps.Web/src/app/shared/ui/witness/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Shared Witness/Evidence Proof-Inspection Sections
|
||||
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation
|
||||
*
|
||||
* Reusable composable sections derived from the orphan WitnessViewerComponent.
|
||||
* Designed for embedding in mounted Reachability and Evidence surfaces.
|
||||
*/
|
||||
|
||||
export { VerificationSummaryComponent } from './verification-summary.component';
|
||||
export { SignatureInspectorComponent } from './signature-inspector.component';
|
||||
export { AttestationDetailComponent } from './attestation-detail.component';
|
||||
export { EvidencePayloadComponent } from './evidence-payload.component';
|
||||
|
||||
export type {
|
||||
VerificationSummaryData,
|
||||
SignatureData,
|
||||
AttestationData,
|
||||
EvidencePayloadData,
|
||||
VerificationStatus,
|
||||
ConfidenceTier,
|
||||
} from './witness.models';
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Signature Inspector Component Tests
|
||||
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-004)
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Component, signal } from '@angular/core';
|
||||
|
||||
import { SignatureInspectorComponent } from './signature-inspector.component';
|
||||
import type { SignatureData } from './witness.models';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [SignatureInspectorComponent],
|
||||
template: `<app-signature-inspector [signatures]="signatures()" />`,
|
||||
})
|
||||
class TestHostComponent {
|
||||
signatures = signal<readonly SignatureData[]>([]);
|
||||
}
|
||||
|
||||
describe('SignatureInspectorComponent', () => {
|
||||
let fixture: ComponentFixture<TestHostComponent>;
|
||||
let host: TestHostComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TestHostComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TestHostComponent);
|
||||
host = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should render the signature inspector section', () => {
|
||||
const section = fixture.nativeElement.querySelector('[data-testid="signature-inspector"]');
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should show empty state when no signatures', () => {
|
||||
const empty = fixture.nativeElement.querySelector('[data-testid="signature-empty"]');
|
||||
expect(empty).toBeTruthy();
|
||||
expect(empty.textContent).toContain('No signatures available');
|
||||
});
|
||||
|
||||
it('should render a verified signature card', () => {
|
||||
host.signatures.set([{
|
||||
id: 'sig-001',
|
||||
algorithm: 'ECDSA-P256',
|
||||
keyId: 'key-abc-123',
|
||||
value: 'MEUCIQD+base64signaturevaluehere==',
|
||||
timestamp: '2026-03-08T10:30:00Z',
|
||||
verified: true,
|
||||
issuer: 'Stella Ops CA',
|
||||
}]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const card = fixture.nativeElement.querySelector('[data-testid="signature-card-sig-001"]');
|
||||
expect(card).toBeTruthy();
|
||||
expect(card.classList.contains('signature-card--verified')).toBe(true);
|
||||
expect(card.textContent).toContain('Verified');
|
||||
expect(card.textContent).toContain('ECDSA-P256');
|
||||
expect(card.textContent).toContain('key-abc-123');
|
||||
expect(card.textContent).toContain('Stella Ops CA');
|
||||
});
|
||||
|
||||
it('should render an unverified signature card', () => {
|
||||
host.signatures.set([{
|
||||
id: 'sig-002',
|
||||
algorithm: 'Ed25519',
|
||||
keyId: 'key-def-456',
|
||||
value: 'shortval',
|
||||
verified: false,
|
||||
}]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const card = fixture.nativeElement.querySelector('[data-testid="signature-card-sig-002"]');
|
||||
expect(card).toBeTruthy();
|
||||
expect(card.classList.contains('signature-card--verified')).toBe(false);
|
||||
expect(card.textContent).toContain('Unverified');
|
||||
expect(card.textContent).toContain('Ed25519');
|
||||
});
|
||||
|
||||
it('should render multiple signature cards', () => {
|
||||
host.signatures.set([
|
||||
{
|
||||
id: 'sig-a',
|
||||
algorithm: 'ECDSA-P256',
|
||||
keyId: 'key-1',
|
||||
value: 'sig-value-a',
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: 'sig-b',
|
||||
algorithm: 'RSA-PSS',
|
||||
keyId: 'key-2',
|
||||
value: 'sig-value-b',
|
||||
verified: false,
|
||||
},
|
||||
]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const cards = fixture.nativeElement.querySelectorAll('.signature-card');
|
||||
expect(cards.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should truncate long signature values', () => {
|
||||
const longSig = 'A'.repeat(100);
|
||||
host.signatures.set([{
|
||||
id: 'sig-long',
|
||||
algorithm: 'ECDSA-P256',
|
||||
keyId: 'key-long',
|
||||
value: longSig,
|
||||
verified: true,
|
||||
}]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const sigValue = fixture.nativeElement.querySelector('.signature-value');
|
||||
expect(sigValue).toBeTruthy();
|
||||
// Truncated: 16 chars + '...' + 16 chars = 35 chars, not 100
|
||||
expect(sigValue.textContent!.length).toBeLessThan(100);
|
||||
expect(sigValue.textContent).toContain('...');
|
||||
});
|
||||
|
||||
it('should show copy button for long signatures', () => {
|
||||
host.signatures.set([{
|
||||
id: 'sig-copy',
|
||||
algorithm: 'ECDSA-P256',
|
||||
keyId: 'key-copy',
|
||||
value: 'A'.repeat(100),
|
||||
verified: true,
|
||||
}]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const copyBtn = fixture.nativeElement.querySelector('[data-testid="copy-sig-sig-copy"]');
|
||||
expect(copyBtn).toBeTruthy();
|
||||
expect(copyBtn.textContent).toContain('Copy full');
|
||||
});
|
||||
|
||||
it('should not show copy button for short signatures', () => {
|
||||
host.signatures.set([{
|
||||
id: 'sig-short',
|
||||
algorithm: 'ECDSA-P256',
|
||||
keyId: 'key-short',
|
||||
value: 'short',
|
||||
verified: true,
|
||||
}]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const copyBtn = fixture.nativeElement.querySelector('[data-testid="copy-sig-sig-short"]');
|
||||
expect(copyBtn).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Signature Inspector Component
|
||||
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-002)
|
||||
*
|
||||
* Composable section showing signature details: algorithm, key ID, verification
|
||||
* result, and truncated/expandable signature value. Embeddable in mounted
|
||||
* Reachability witness detail and Evidence packet views.
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
import type { SignatureData } from './witness.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-signature-inspector',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="signature-inspector" data-testid="signature-inspector">
|
||||
<h3 class="section-title">
|
||||
Signatures
|
||||
@if (signatures().length) {
|
||||
<span class="section-count">({{ signatures().length }})</span>
|
||||
}
|
||||
</h3>
|
||||
|
||||
@if (signatures().length === 0) {
|
||||
<div class="empty-state" data-testid="signature-empty">
|
||||
No signatures available for this evidence.
|
||||
</div>
|
||||
} @else {
|
||||
<div class="signatures-list">
|
||||
@for (sig of signatures(); track sig.id) {
|
||||
<div
|
||||
class="signature-card"
|
||||
[class.signature-card--verified]="sig.verified"
|
||||
[attr.data-testid]="'signature-card-' + sig.id"
|
||||
>
|
||||
<div class="signature-card__header">
|
||||
<span class="signature-card__status">
|
||||
@if (sig.verified) {
|
||||
<span class="verified-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</span>
|
||||
Verified
|
||||
} @else {
|
||||
<span class="unverified-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
</svg>
|
||||
</span>
|
||||
Unverified
|
||||
}
|
||||
</span>
|
||||
<span class="signature-card__algorithm">{{ sig.algorithm }}</span>
|
||||
</div>
|
||||
|
||||
<dl class="signature-card__details">
|
||||
<dt>Key ID</dt>
|
||||
<dd><code>{{ sig.keyId }}</code></dd>
|
||||
|
||||
@if (sig.issuer) {
|
||||
<dt>Issuer</dt>
|
||||
<dd>{{ sig.issuer }}</dd>
|
||||
}
|
||||
|
||||
@if (sig.timestamp) {
|
||||
<dt>Timestamp</dt>
|
||||
<dd>{{ formatDate(sig.timestamp) }}</dd>
|
||||
}
|
||||
|
||||
<dt>Signature</dt>
|
||||
<dd class="signature-value-cell">
|
||||
<code class="signature-value">{{ truncateSignature(sig.value) }}</code>
|
||||
@if (sig.value.length > 40) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link btn-small"
|
||||
(click)="copySignature(sig.value)"
|
||||
[attr.data-testid]="'copy-sig-' + sig.id"
|
||||
>
|
||||
{{ copiedId() === sig.id ? 'Copied' : 'Copy full' }}
|
||||
</button>
|
||||
}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.signature-inspector {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.section-count {
|
||||
font-weight: var(--font-weight-regular, 400);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.signatures-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.signature-card {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.85rem;
|
||||
background: var(--color-surface-primary);
|
||||
}
|
||||
|
||||
.signature-card--verified {
|
||||
border-color: var(--color-severity-low-border);
|
||||
background: var(--color-severity-low-bg);
|
||||
}
|
||||
|
||||
.signature-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.signature-card__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.verified-icon {
|
||||
color: var(--color-status-success-text);
|
||||
}
|
||||
|
||||
.unverified-icon {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.signature-card__algorithm {
|
||||
background: var(--color-border-primary);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.signature-card__details {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 0.35rem 0.85rem;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.signature-card__details dt {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.signature-card__details dd {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.signature-value {
|
||||
max-width: 260px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.signature-value-cell {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-brand-primary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 0.75rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class SignatureInspectorComponent {
|
||||
/** List of signatures to display. */
|
||||
readonly signatures = input.required<readonly SignatureData[]>();
|
||||
|
||||
/** Track which signature was just copied. */
|
||||
readonly copiedId = signal<string | null>(null);
|
||||
|
||||
truncateSignature(value: string): string {
|
||||
if (value.length <= 40) return value;
|
||||
return `${value.slice(0, 16)}...${value.slice(-16)}`;
|
||||
}
|
||||
|
||||
copySignature(value: string): void {
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
// Find the matching signature for feedback
|
||||
const sig = this.signatures().find(s => s.value === value);
|
||||
if (sig) {
|
||||
this.copiedId.set(sig.id);
|
||||
setTimeout(() => this.copiedId.set(null), 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatDate(isoDate: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
}).format(new Date(isoDate));
|
||||
} catch {
|
||||
return isoDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Verification Summary Component Tests
|
||||
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-004)
|
||||
*/
|
||||
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { Component, signal } from '@angular/core';
|
||||
|
||||
import { VerificationSummaryComponent } from './verification-summary.component';
|
||||
import type { VerificationSummaryData } from './witness.models';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [VerificationSummaryComponent],
|
||||
template: `<app-verification-summary [data]="data()" />`,
|
||||
})
|
||||
class TestHostComponent {
|
||||
data = signal<VerificationSummaryData>({
|
||||
id: 'witness-001',
|
||||
typeLabel: 'Witness',
|
||||
typeBadge: 'witness',
|
||||
status: 'verified',
|
||||
confidenceTier: 'confirmed',
|
||||
confidenceScore: 0.95,
|
||||
createdAt: '2026-03-08T10:00:00Z',
|
||||
source: 'static',
|
||||
});
|
||||
}
|
||||
|
||||
describe('VerificationSummaryComponent', () => {
|
||||
let fixture: ComponentFixture<TestHostComponent>;
|
||||
let host: TestHostComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TestHostComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TestHostComponent);
|
||||
host = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should render the verification summary section', () => {
|
||||
const section = fixture.nativeElement.querySelector('[data-testid="verification-summary"]');
|
||||
expect(section).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should display verified status badge', () => {
|
||||
const badge = fixture.nativeElement.querySelector('[data-testid="verification-status-verified"]');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.textContent).toContain('Verified');
|
||||
});
|
||||
|
||||
it('should display the evidence ID', () => {
|
||||
const mono = fixture.nativeElement.querySelector('.summary-mono');
|
||||
expect(mono).toBeTruthy();
|
||||
expect(mono.textContent).toContain('witness-001');
|
||||
});
|
||||
|
||||
it('should display confidence tier badge', () => {
|
||||
const badge = fixture.nativeElement.querySelector('.confidence-badge--confirmed');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.textContent).toContain('Confirmed Reachable');
|
||||
expect(badge.textContent).toContain('95%');
|
||||
});
|
||||
|
||||
it('should display type badge', () => {
|
||||
const badge = fixture.nativeElement.querySelector('.type-badge--witness');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.textContent).toContain('Witness');
|
||||
});
|
||||
|
||||
it('should display failed status correctly', () => {
|
||||
host.data.set({
|
||||
id: 'test-002',
|
||||
typeLabel: 'Attestation',
|
||||
typeBadge: 'attestation',
|
||||
status: 'failed',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('[data-testid="verification-status-failed"]');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.textContent).toContain('Failed');
|
||||
});
|
||||
|
||||
it('should display pending status correctly', () => {
|
||||
host.data.set({
|
||||
id: 'test-003',
|
||||
typeLabel: 'Bundle',
|
||||
typeBadge: 'bundle',
|
||||
status: 'pending',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('[data-testid="verification-status-pending"]');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.textContent).toContain('Pending');
|
||||
});
|
||||
|
||||
it('should display unverified status correctly', () => {
|
||||
host.data.set({
|
||||
id: 'test-004',
|
||||
typeLabel: 'Signature',
|
||||
typeBadge: 'signature',
|
||||
status: 'unverified',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const badge = fixture.nativeElement.querySelector('[data-testid="verification-status-unverified"]');
|
||||
expect(badge).toBeTruthy();
|
||||
expect(badge.textContent).toContain('Unverified');
|
||||
});
|
||||
|
||||
it('should not display confidence section when tier is undefined', () => {
|
||||
host.data.set({
|
||||
id: 'test-005',
|
||||
typeLabel: 'Receipt',
|
||||
typeBadge: 'receipt',
|
||||
status: 'verified',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const confidenceBadge = fixture.nativeElement.querySelector('.confidence-badge');
|
||||
expect(confidenceBadge).toBeNull();
|
||||
});
|
||||
|
||||
it('should not display source when not provided', () => {
|
||||
host.data.set({
|
||||
id: 'test-006',
|
||||
typeLabel: 'Witness',
|
||||
typeBadge: 'witness',
|
||||
status: 'verified',
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const items = fixture.nativeElement.querySelectorAll('.summary-item');
|
||||
const labels = Array.from(items).map((el: any) =>
|
||||
el.querySelector('.summary-label')?.textContent?.trim()
|
||||
);
|
||||
expect(labels).not.toContain('Source');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* Verification Summary Component
|
||||
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation (FE-WVD-002)
|
||||
*
|
||||
* Composable section showing pass/fail verification status, confidence tier badge,
|
||||
* evidence type, and creation metadata. Designed for embedding in mounted
|
||||
* Reachability and Evidence surfaces -- not as a standalone page.
|
||||
*/
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
input,
|
||||
computed,
|
||||
} from '@angular/core';
|
||||
|
||||
import type {
|
||||
VerificationSummaryData,
|
||||
VerificationStatus,
|
||||
ConfidenceTier,
|
||||
} from './witness.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-verification-summary',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="verification-summary" data-testid="verification-summary">
|
||||
<h3 class="section-title">Verification Summary</h3>
|
||||
<div class="summary-grid">
|
||||
<!-- Status -->
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Status</span>
|
||||
<span
|
||||
class="status-badge"
|
||||
[class]="'status-badge--' + data().status"
|
||||
[attr.data-testid]="'verification-status-' + data().status"
|
||||
>
|
||||
@if (data().status === 'verified') {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true"
|
||||
style="display:inline;vertical-align:middle">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
} @else if (data().status === 'failed') {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true"
|
||||
style="display:inline;vertical-align:middle">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
} @else if (data().status === 'pending') {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true"
|
||||
style="display:inline;vertical-align:middle">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
} @else {
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" aria-hidden="true"
|
||||
style="display:inline;vertical-align:middle">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
</svg>
|
||||
}
|
||||
{{ statusLabel() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Evidence Type -->
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Type</span>
|
||||
<span class="type-badge" [class]="'type-badge--' + data().typeBadge">
|
||||
{{ data().typeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Confidence Tier (when present) -->
|
||||
@if (data().confidenceTier) {
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Confidence</span>
|
||||
<span class="confidence-badge" [class]="'confidence-badge--' + data().confidenceTier">
|
||||
{{ confidenceLabel() }}
|
||||
@if (data().confidenceScore != null) {
|
||||
<span class="confidence-score">({{ confidencePercent() }})</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Created -->
|
||||
@if (data().createdAt) {
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Created</span>
|
||||
<span class="summary-value">{{ formatDate(data().createdAt!) }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Source -->
|
||||
@if (data().source) {
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Source</span>
|
||||
<span class="summary-value">{{ data().source }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ID -->
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Evidence ID</span>
|
||||
<code class="summary-mono">{{ data().id }}</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [`
|
||||
.verification-summary {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface-secondary);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.summary-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-size: 0.8125rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.status-badge--verified {
|
||||
background: var(--color-severity-low-bg);
|
||||
color: var(--color-status-success-text);
|
||||
}
|
||||
|
||||
.status-badge--unverified {
|
||||
background: var(--color-severity-none-bg);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status-badge--failed {
|
||||
background: var(--color-severity-critical-bg);
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.status-badge--pending {
|
||||
background: var(--color-severity-medium-bg);
|
||||
color: var(--color-status-warning-text);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: uppercase;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.type-badge--attestation {
|
||||
background: var(--color-severity-info-bg);
|
||||
color: var(--color-status-info-text);
|
||||
}
|
||||
|
||||
.type-badge--signature {
|
||||
background: var(--color-status-excepted-bg);
|
||||
color: var(--color-status-excepted);
|
||||
}
|
||||
|
||||
.type-badge--receipt {
|
||||
background: var(--color-severity-low-bg);
|
||||
color: var(--color-status-success-text);
|
||||
}
|
||||
|
||||
.type-badge--bundle {
|
||||
background: var(--color-severity-high-bg);
|
||||
color: var(--color-severity-high);
|
||||
}
|
||||
|
||||
.type-badge--witness {
|
||||
background: var(--color-severity-info-bg);
|
||||
color: var(--color-status-info-text);
|
||||
}
|
||||
|
||||
.confidence-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: var(--font-weight-medium);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.confidence-badge--confirmed {
|
||||
background: var(--color-severity-critical-bg);
|
||||
color: var(--color-status-error-text);
|
||||
}
|
||||
|
||||
.confidence-badge--likely {
|
||||
background: var(--color-severity-high-bg);
|
||||
color: var(--color-severity-high);
|
||||
}
|
||||
|
||||
.confidence-badge--present {
|
||||
background: var(--color-severity-none-bg);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.confidence-badge--unreachable {
|
||||
background: var(--color-severity-low-bg);
|
||||
color: var(--color-status-success-text);
|
||||
}
|
||||
|
||||
.confidence-badge--unknown {
|
||||
background: var(--color-severity-info-bg);
|
||||
color: var(--color-status-info-text);
|
||||
}
|
||||
|
||||
.confidence-score {
|
||||
opacity: 0.8;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
`],
|
||||
})
|
||||
export class VerificationSummaryComponent {
|
||||
/** Verification summary data to display. */
|
||||
readonly data = input.required<VerificationSummaryData>();
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
const labels: Record<VerificationStatus, string> = {
|
||||
verified: 'Verified',
|
||||
unverified: 'Unverified',
|
||||
failed: 'Failed',
|
||||
pending: 'Pending',
|
||||
};
|
||||
return labels[this.data().status] ?? this.data().status;
|
||||
});
|
||||
|
||||
readonly confidenceLabel = computed(() => {
|
||||
const labels: Record<ConfidenceTier, string> = {
|
||||
confirmed: 'Confirmed Reachable',
|
||||
likely: 'Likely Reachable',
|
||||
present: 'Present',
|
||||
unreachable: 'Unreachable',
|
||||
unknown: 'Unknown',
|
||||
};
|
||||
return labels[this.data().confidenceTier!] ?? this.data().confidenceTier;
|
||||
});
|
||||
|
||||
readonly confidencePercent = computed(() => {
|
||||
const score = this.data().confidenceScore;
|
||||
if (score == null) return '';
|
||||
return `${Math.round(score * 100)}%`;
|
||||
});
|
||||
|
||||
formatDate(isoDate: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
year: 'numeric',
|
||||
}).format(new Date(isoDate));
|
||||
} catch {
|
||||
return isoDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Shared witness/evidence proof-inspection models.
|
||||
* Sprint: SPRINT_20260308_031_FE_witness_viewer_evidence_derivation
|
||||
*
|
||||
* Domain types used by the derived proof-inspection sections.
|
||||
* These are intentionally presentation-level models that both the
|
||||
* Reachability and Evidence features can map their domain data into.
|
||||
*/
|
||||
|
||||
/** Verification status for an evidence artifact. */
|
||||
export type VerificationStatus = 'verified' | 'unverified' | 'failed' | 'pending';
|
||||
|
||||
/** Confidence tier for reachability assessment (mirrors witness.models). */
|
||||
export type ConfidenceTier = 'confirmed' | 'likely' | 'present' | 'unreachable' | 'unknown';
|
||||
|
||||
/**
|
||||
* Input data for the verification summary section.
|
||||
*/
|
||||
export interface VerificationSummaryData {
|
||||
/** Unique identifier for the evidence or witness. */
|
||||
readonly id: string;
|
||||
/** Human-readable label for the evidence type. */
|
||||
readonly typeLabel: string;
|
||||
/** CSS class suffix for the type badge (e.g., 'attestation', 'signature'). */
|
||||
readonly typeBadge: string;
|
||||
/** Current verification status. */
|
||||
readonly status: VerificationStatus;
|
||||
/** Confidence tier (if available). */
|
||||
readonly confidenceTier?: ConfidenceTier;
|
||||
/** Confidence score 0.0-1.0 (if available). */
|
||||
readonly confidenceScore?: number;
|
||||
/** When the evidence was created or observed. */
|
||||
readonly createdAt?: string;
|
||||
/** Evidence source identifier. */
|
||||
readonly source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input data for the signature inspector section.
|
||||
*/
|
||||
export interface SignatureData {
|
||||
/** Signature identifier. */
|
||||
readonly id: string;
|
||||
/** Cryptographic algorithm (e.g., ECDSA-P256, Ed25519). */
|
||||
readonly algorithm: string;
|
||||
/** Key identifier. */
|
||||
readonly keyId: string;
|
||||
/** Truncated or full signature value. */
|
||||
readonly value: string;
|
||||
/** Timestamp of signature. */
|
||||
readonly timestamp?: string;
|
||||
/** Whether the signature has been verified. */
|
||||
readonly verified: boolean;
|
||||
/** Issuer of the signing key (optional). */
|
||||
readonly issuer?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input data for the attestation detail section.
|
||||
*/
|
||||
export interface AttestationData {
|
||||
/** In-toto predicate type URI. */
|
||||
readonly predicateType: string;
|
||||
/** Subject name. */
|
||||
readonly subjectName: string;
|
||||
/** Subject digests (algorithm -> hash). */
|
||||
readonly subjectDigests: ReadonlyArray<{ readonly algorithm: string; readonly hash: string }>;
|
||||
/** Predicate payload (arbitrary JSON). */
|
||||
readonly predicate: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input data for the evidence payload section.
|
||||
*/
|
||||
export interface EvidencePayloadData {
|
||||
/** Evidence identifier for naming downloads. */
|
||||
readonly evidenceId: string;
|
||||
/** Raw content to display (JSON string or raw text). */
|
||||
readonly rawContent: string;
|
||||
/** Metadata key-value pairs to display. */
|
||||
readonly metadata?: Record<string, unknown>;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
import { ReleaseControlSetupHomeComponent } from '../../app/features/release-control/setup/release-control-setup-home.component';
|
||||
import { SetupBundleTemplatesComponent } from '../../app/features/release-control/setup/setup-bundle-templates.component';
|
||||
import { SetupEnvironmentsPathsComponent } from '../../app/features/release-control/setup/setup-environments-paths.component';
|
||||
import { SetupTargetsAgentsComponent } from '../../app/features/release-control/setup/setup-targets-agents.component';
|
||||
import { SetupWorkflowsComponent } from '../../app/features/release-control/setup/setup-workflows.component';
|
||||
|
||||
describe('Release Control setup components (release-control)', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ReleaseControlSetupHomeComponent,
|
||||
SetupEnvironmentsPathsComponent,
|
||||
SetupTargetsAgentsComponent,
|
||||
SetupWorkflowsComponent,
|
||||
SetupBundleTemplatesComponent,
|
||||
],
|
||||
providers: [provideRouter([])],
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('setup home renders required setup areas', () => {
|
||||
const fixture = TestBed.createComponent(ReleaseControlSetupHomeComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text).toContain('Release Control Setup');
|
||||
expect(text).toContain('Environments and Promotion Paths');
|
||||
expect(text).toContain('Targets and Agents');
|
||||
expect(text).toContain('Workflows');
|
||||
expect(text).toContain('Bundle Templates');
|
||||
});
|
||||
|
||||
it('environments and paths page renders inventory and path rules', () => {
|
||||
const fixture = TestBed.createComponent(SetupEnvironmentsPathsComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text).toContain('Environment Inventory');
|
||||
expect(text).toContain('Promotion Path Rules');
|
||||
});
|
||||
|
||||
it('targets and agents page renders ownership links', () => {
|
||||
const fixture = TestBed.createComponent(SetupTargetsAgentsComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text).toContain('Targets and Agents');
|
||||
expect(text).toContain('Integrations > Targets / Runtimes');
|
||||
expect(text).toContain('Platform Ops > Agents');
|
||||
});
|
||||
|
||||
it('workflows page renders workflow catalog and run timeline link', () => {
|
||||
const fixture = TestBed.createComponent(SetupWorkflowsComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text).toContain('Workflow Catalog');
|
||||
expect(text).toContain('Open Run Timeline');
|
||||
});
|
||||
|
||||
it('bundle templates page renders template catalog and builder link', () => {
|
||||
const fixture = TestBed.createComponent(SetupBundleTemplatesComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const text = fixture.nativeElement.textContent as string;
|
||||
expect(text).toContain('Template Catalog');
|
||||
expect(text).toContain('Open Bundle Builder');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Settings IA Rationalization Tests
|
||||
* Sprint: SPRINT_20260308_026_FE_settings_information_architecture_rationalization
|
||||
*
|
||||
* Verifies that the Settings shell now owns only personal preferences,
|
||||
* admin/tenant/ops leaves redirect to canonical owners, and legacy
|
||||
* bookmarks resolve through controlled redirects.
|
||||
*/
|
||||
|
||||
import { SETTINGS_ROUTES } from '../../app/features/settings/settings.routes';
|
||||
|
||||
describe('Settings IA rationalization', () => {
|
||||
const root = SETTINGS_ROUTES[0];
|
||||
const children = root?.children ?? [];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Personal preferences are the canonical default
|
||||
// ---------------------------------------------------------------------------
|
||||
it('defaults to user-preferences as the settings landing page', () => {
|
||||
const defaultRoute = children.find((r) => r.path === '');
|
||||
expect(defaultRoute).toBeDefined();
|
||||
expect(defaultRoute?.title).toBe('User Preferences');
|
||||
expect(typeof defaultRoute?.loadComponent).toBe('function');
|
||||
});
|
||||
|
||||
it('mounts user-preferences as a named route', () => {
|
||||
const route = children.find((r) => r.path === 'user-preferences');
|
||||
expect(route).toBeDefined();
|
||||
expect(route?.title).toBe('User Preferences');
|
||||
expect(typeof route?.loadComponent).toBe('function');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Merged personal preference leaves redirect to user-preferences
|
||||
// ---------------------------------------------------------------------------
|
||||
it('redirects /settings/language to user-preferences', () => {
|
||||
const route = children.find((r) => r.path === 'language');
|
||||
expect(route).toBeDefined();
|
||||
expect(route?.redirectTo).toBe('user-preferences');
|
||||
});
|
||||
|
||||
it('redirects /settings/ai-preferences to user-preferences', () => {
|
||||
const route = children.find((r) => r.path === 'ai-preferences');
|
||||
expect(route).toBeDefined();
|
||||
expect(route?.redirectTo).toBe('user-preferences');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin/tenant config leaves redirect to canonical administration
|
||||
// ---------------------------------------------------------------------------
|
||||
it('redirects /settings/admin to canonical administration', () => {
|
||||
const route = children.find((r) => r.path === 'admin');
|
||||
expect(route).toBeDefined();
|
||||
expect(typeof route?.redirectTo).toBe('function');
|
||||
expect(route?.pathMatch).toBe('full');
|
||||
});
|
||||
|
||||
it('redirects /settings/admin/:page to canonical administration', () => {
|
||||
const route = children.find((r) => r.path === 'admin/:page');
|
||||
expect(route).toBeDefined();
|
||||
expect(typeof route?.redirectTo).toBe('function');
|
||||
});
|
||||
|
||||
it('redirects /settings/branding to canonical console admin branding', () => {
|
||||
const route = children.find((r) => r.path === 'branding');
|
||||
expect(route).toBeDefined();
|
||||
expect(typeof route?.redirectTo).toBe('function');
|
||||
});
|
||||
|
||||
it('redirects /settings/identity-providers to canonical administration', () => {
|
||||
const route = children.find((r) => r.path === 'identity-providers');
|
||||
expect(route).toBeDefined();
|
||||
expect(typeof route?.redirectTo).toBe('function');
|
||||
});
|
||||
|
||||
it('redirects /settings/system to canonical administration', () => {
|
||||
const route = children.find((r) => r.path === 'system');
|
||||
expect(route).toBeDefined();
|
||||
expect(typeof route?.redirectTo).toBe('function');
|
||||
});
|
||||
|
||||
it('redirects /settings/security-data to canonical administration', () => {
|
||||
const route = children.find((r) => r.path === 'security-data');
|
||||
expect(route).toBeDefined();
|
||||
expect(typeof route?.redirectTo).toBe('function');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operations config leaves redirect to canonical setup/ops
|
||||
// ---------------------------------------------------------------------------
|
||||
it('redirects /settings/integrations to canonical setup', () => {
|
||||
const route = children.find((r) => r.path === 'integrations');
|
||||
expect(route).toBeDefined();
|
||||
expect(typeof route?.redirectTo).toBe('function');
|
||||
});
|
||||
|
||||
it('redirects /settings/integrations/:id to canonical setup', () => {
|
||||
const route = children.find((r) => r.path === 'integrations/:id');
|
||||
expect(route).toBeDefined();
|
||||
expect(typeof route?.redirectTo).toBe('function');
|
||||
});
|
||||
|
||||
it('redirects /settings/usage to canonical setup', () => {
|
||||
const route = children.find((r) => r.path === 'usage');
|
||||
expect(route).toBeDefined();
|
||||
expect(typeof route?.redirectTo).toBe('function');
|
||||
});
|
||||
|
||||
it('redirects /settings/notifications to canonical setup', () => {
|
||||
const route = children.find((r) => r.path === 'notifications');
|
||||
expect(route).toBeDefined();
|
||||
expect(typeof route?.redirectTo).toBe('function');
|
||||
});
|
||||
|
||||
it('redirects /settings/policy to canonical ops policy governance', () => {
|
||||
const route = children.find((r) => r.path === 'policy');
|
||||
expect(route).toBeDefined();
|
||||
expect(typeof route?.redirectTo).toBe('function');
|
||||
});
|
||||
|
||||
it('redirects /settings/offline to canonical administration', () => {
|
||||
const route = children.find((r) => r.path === 'offline');
|
||||
expect(route).toBeDefined();
|
||||
expect(typeof route?.redirectTo).toBe('function');
|
||||
});
|
||||
|
||||
it('redirects /settings/release-control to canonical setup topology', () => {
|
||||
const route = children.find((r) => r.path === 'release-control');
|
||||
expect(route).toBeDefined();
|
||||
expect(typeof route?.redirectTo).toBe('function');
|
||||
});
|
||||
|
||||
it('redirects /settings/configuration-pane to canonical ops platform-setup', () => {
|
||||
const route = children.find((r) => r.path === 'configuration-pane');
|
||||
expect(route).toBeDefined();
|
||||
expect(typeof route?.redirectTo).toBe('function');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trust redirects preserved from previous sprint
|
||||
// ---------------------------------------------------------------------------
|
||||
it('preserves trust/* redirects to /setup/trust-signing', () => {
|
||||
const trustRoot = children.find((r) => r.path === 'trust');
|
||||
expect(trustRoot).toBeDefined();
|
||||
expect(typeof trustRoot?.redirectTo).toBe('function');
|
||||
|
||||
const trustIssuers = children.find((r) => r.path === 'trust/issuers');
|
||||
expect(trustIssuers).toBeDefined();
|
||||
|
||||
const trustPage = children.find((r) => r.path === 'trust/:page');
|
||||
expect(trustPage).toBeDefined();
|
||||
|
||||
const trustPageChild = children.find((r) => r.path === 'trust/:page/:child');
|
||||
expect(trustPageChild).toBeDefined();
|
||||
});
|
||||
|
||||
it('preserves trust-signing/* redirects to /setup/trust-signing', () => {
|
||||
const ts = children.find((r) => r.path === 'trust-signing');
|
||||
expect(ts).toBeDefined();
|
||||
expect(typeof ts?.redirectTo).toBe('function');
|
||||
|
||||
const tsPage = children.find((r) => r.path === 'trust-signing/:page');
|
||||
expect(tsPage).toBeDefined();
|
||||
|
||||
const tsPageChild = children.find((r) => r.path === 'trust-signing/:page/:child');
|
||||
expect(tsPageChild).toBeDefined();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// No loadComponent routes remain for admin/ops pages
|
||||
// ---------------------------------------------------------------------------
|
||||
it('contains no loadComponent routes for admin/ops leaves', () => {
|
||||
const adminOpsLeaves = [
|
||||
'integrations', 'integrations/:id', 'admin', 'admin/:page',
|
||||
'branding', 'usage', 'notifications', 'security-data', 'policy',
|
||||
'offline', 'system', 'identity-providers', 'release-control',
|
||||
'configuration-pane',
|
||||
];
|
||||
|
||||
for (const path of adminOpsLeaves) {
|
||||
const route = children.find((r) => r.path === path);
|
||||
if (route) {
|
||||
expect(route.loadComponent).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route count validation
|
||||
// ---------------------------------------------------------------------------
|
||||
it('has the expected number of child routes', () => {
|
||||
// 2 personal preference routes + 2 merged redirects + 8 admin redirects
|
||||
// + 6 ops redirects + 7 trust redirects = 25
|
||||
expect(children.length).toBe(25);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { routes } from '../../app/app.routes';
|
||||
import { ADVISORY_AI_API, type AdvisoryAiApi } from '../../app/core/api/advisory-ai.client';
|
||||
import type { RemediationPrSettings } from '../../app/core/api/advisory-ai.models';
|
||||
import { RemediationPrSettingsComponent } from '../../app/features/settings/remediation-pr-settings.component';
|
||||
@@ -23,46 +22,33 @@ describe('unified-settings-page behavior', () => {
|
||||
localStorage.removeItem('stellaops.remediation-pr.preferences');
|
||||
});
|
||||
|
||||
it('declares canonical /administration route and keeps /settings redirect alias', () => {
|
||||
const settingsAlias = routes.find((route) => route.path === 'settings');
|
||||
expect(settingsAlias).toBeDefined();
|
||||
expect(settingsAlias?.redirectTo).toBe('/administration');
|
||||
|
||||
const administrationRoute = routes.find((route) => route.path === 'administration');
|
||||
expect(administrationRoute).toBeDefined();
|
||||
expect(typeof administrationRoute?.loadChildren).toBe('function');
|
||||
|
||||
it('mounts personal preferences as the settings default and redirects admin leaves', () => {
|
||||
const root = SETTINGS_ROUTES.find((route) => route.path === '');
|
||||
expect(root).toBeDefined();
|
||||
const childPaths = (root?.children ?? []).map((child) => child.path);
|
||||
|
||||
expect(childPaths).toEqual([
|
||||
'',
|
||||
'integrations',
|
||||
'integrations/:id',
|
||||
'configuration-pane',
|
||||
'release-control',
|
||||
'trust',
|
||||
'trust/:page',
|
||||
'security-data',
|
||||
'admin',
|
||||
'admin/:page',
|
||||
'branding',
|
||||
'usage',
|
||||
'notifications',
|
||||
'ai-preferences',
|
||||
'policy',
|
||||
'offline',
|
||||
'system',
|
||||
]);
|
||||
// The default route is now user-preferences, not integrations
|
||||
const defaultChild = (root?.children ?? []).find((child) => child.path === '');
|
||||
expect(defaultChild?.title).toBe('User Preferences');
|
||||
expect(typeof defaultChild?.loadComponent).toBe('function');
|
||||
|
||||
const brandingRoute = (root?.children ?? []).find((child) => child.path === 'branding');
|
||||
expect(brandingRoute?.title).toBe('Tenant & Branding');
|
||||
expect(brandingRoute?.data?.['breadcrumb']).toBe('Tenant & Branding');
|
||||
// user-preferences is also available as a named route
|
||||
expect(childPaths).toContain('user-preferences');
|
||||
|
||||
const offlineRoute = (root?.children ?? []).find((child) => child.path === 'offline');
|
||||
expect(offlineRoute?.title).toBe('Offline Settings');
|
||||
expect(offlineRoute?.data?.['breadcrumb']).toBe('Offline Settings');
|
||||
// Admin/ops leaves are redirects, not loadComponent pages
|
||||
const adminRedirects = ['admin', 'branding', 'integrations', 'notifications', 'usage', 'system', 'offline', 'policy', 'security-data', 'identity-providers'];
|
||||
for (const path of adminRedirects) {
|
||||
const route = (root?.children ?? []).find((child) => child.path === path);
|
||||
expect(route).toBeDefined();
|
||||
expect(route?.loadComponent).toBeUndefined();
|
||||
}
|
||||
|
||||
// Language and ai-preferences redirect to user-preferences
|
||||
const langRoute = (root?.children ?? []).find((child) => child.path === 'language');
|
||||
expect(langRoute?.redirectTo).toBe('user-preferences');
|
||||
|
||||
const aiRoute = (root?.children ?? []).find((child) => child.path === 'ai-preferences');
|
||||
expect(aiRoute?.redirectTo).toBe('user-preferences');
|
||||
});
|
||||
|
||||
it('renders settings shell container', async () => {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svgjs="http://svgjs.com/svgjs" xmlns:xlink="http://www.w3.org/1999/xlink" width="164" height="164" version="1.1"><svg xmlns="http://www.w3.org/2000/svg" width="164" height="164" fill="none" viewBox="0 0 164 164"><path fill="#FF4785" d="M22.467 147.762 17.5 15.402a8.062 8.062 0 0 1 7.553-8.35L137.637.016a8.061 8.061 0 0 1 8.565 8.047v144.23a8.063 8.063 0 0 1-8.424 8.054l-107.615-4.833a8.062 8.062 0 0 1-7.695-7.752Z"/><path fill="#fff" fill-rule="evenodd" d="m128.785.57-15.495.968-.755 18.172a1.203 1.203 0 0 0 1.928 1.008l7.06-5.354 5.962 4.697a1.202 1.202 0 0 0 1.946-.987L128.785.569Zm-12.059 60.856c-2.836 2.203-23.965 3.707-23.965.57.447-11.969-4.912-12.494-7.889-12.494-2.828 0-7.59.855-7.59 7.267 0 6.534 6.96 10.223 15.13 14.553 11.607 6.15 25.654 13.594 25.654 32.326 0 17.953-14.588 27.871-33.194 27.871-19.201 0-35.981-7.769-34.086-34.702.744-3.163 25.156-2.411 25.156 0-.298 11.114 2.232 14.383 8.633 14.383 4.912 0 7.144-2.708 7.144-7.267 0-6.9-7.252-10.973-15.595-15.657C64.827 81.933 51.53 74.468 51.53 57.34c0-17.098 11.76-28.497 32.747-28.497 20.988 0 32.449 11.224 32.449 32.584Z" clip-rule="evenodd"/></svg><style>@media (prefers-color-scheme:light){:root{filter:none}}</style></svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,177 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<title>@storybook/angular - Storybook</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
|
||||
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Nunito Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./sb-common-assets/nunito-sans-regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Nunito Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./sb-common-assets/nunito-sans-italic.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Nunito Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./sb-common-assets/nunito-sans-bold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Nunito Sans';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./sb-common-assets/nunito-sans-bold-italic.woff2') format('woff2');
|
||||
}
|
||||
</style>
|
||||
|
||||
<link href="./sb-manager/runtime.js" rel="modulepreload" />
|
||||
|
||||
|
||||
<link href="./sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/essentials-controls-1/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/essentials-actions-2/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/essentials-docs-3/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/essentials-backgrounds-4/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/essentials-viewport-5/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/essentials-toolbars-6/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/essentials-measure-7/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/essentials-outline-8/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/a11y-9/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
<link href="./sb-addons/interactions-10/manager-bundle.js" rel="modulepreload" />
|
||||
|
||||
|
||||
<style>
|
||||
#storybook-root[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
|
||||
<script>
|
||||
|
||||
|
||||
window['FEATURES'] = {
|
||||
"argTypeTargetsV7": true,
|
||||
"legacyDecoratorFileOrder": false,
|
||||
"disallowImplicitActionsInRenderV8": true
|
||||
};
|
||||
|
||||
|
||||
|
||||
window['REFS'] = {};
|
||||
|
||||
|
||||
|
||||
window['LOGLEVEL'] = "info";
|
||||
|
||||
|
||||
|
||||
window['DOCS_OPTIONS'] = {
|
||||
"defaultName": "Docs",
|
||||
"autodocs": "tag"
|
||||
};
|
||||
|
||||
|
||||
|
||||
window['CONFIG_TYPE'] = "PRODUCTION";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
window['TAGS_OPTIONS'] = {
|
||||
"dev-only": {
|
||||
"excludeFromDocsStories": true
|
||||
},
|
||||
"docs-only": {
|
||||
"excludeFromSidebar": true
|
||||
},
|
||||
"test-only": {
|
||||
"excludeFromSidebar": true,
|
||||
"excludeFromDocsStories": true
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
window['STORYBOOK_RENDERER'] = "angular";
|
||||
|
||||
|
||||
|
||||
window['STORYBOOK_BUILDER'] = "@storybook/builder-webpack5";
|
||||
|
||||
|
||||
|
||||
window['STORYBOOK_FRAMEWORK'] = "@storybook/angular";
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
<script type="module">
|
||||
import './sb-manager/globals-runtime.js';
|
||||
|
||||
|
||||
import './sb-addons/storybook-core-core-server-presets-0/common-manager-bundle.js';
|
||||
|
||||
import './sb-addons/essentials-controls-1/manager-bundle.js';
|
||||
|
||||
import './sb-addons/essentials-actions-2/manager-bundle.js';
|
||||
|
||||
import './sb-addons/essentials-docs-3/manager-bundle.js';
|
||||
|
||||
import './sb-addons/essentials-backgrounds-4/manager-bundle.js';
|
||||
|
||||
import './sb-addons/essentials-viewport-5/manager-bundle.js';
|
||||
|
||||
import './sb-addons/essentials-toolbars-6/manager-bundle.js';
|
||||
|
||||
import './sb-addons/essentials-measure-7/manager-bundle.js';
|
||||
|
||||
import './sb-addons/essentials-outline-8/manager-bundle.js';
|
||||
|
||||
import './sb-addons/a11y-9/manager-bundle.js';
|
||||
|
||||
import './sb-addons/interactions-10/manager-bundle.js';
|
||||
|
||||
|
||||
import './sb-manager/runtime.js';
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
{"generatedAt":1770045794032,"userSince":1770045051724,"hasCustomBabel":false,"hasCustomWebpack":false,"hasStaticDirs":false,"hasStorybookEslint":false,"refCount":0,"testPackages":{"@axe-core/playwright":"4.8.4","@playwright/test":"1.56.1","@types/jasmine":"5.1.12","jasmine-core":"5.1.2","karma":"6.4.4","karma-chrome-launcher":"3.2.0","karma-coverage":"2.2.1","karma-jasmine":"5.1.0","karma-jasmine-html-reporter":"2.1.0"},"hasRouterPackage":true,"packageManager":{"type":"npm","agent":"npm"},"preview":{"usesGlobals":true},"framework":{"name":"@storybook/angular","options":{}},"builder":"@storybook/builder-webpack5","renderer":"@storybook/angular","portableStoriesFileCount":0,"applicationFileCount":58,"storybookVersion":"8.6.14","storybookVersionSpecifier":"^8.6.14","language":"typescript","storybookPackages":{"@chromatic-com/storybook":{"version":"5.0.0"},"@storybook/angular":{"version":"8.6.14"},"@storybook/test":{"version":"8.6.14"},"storybook":{"version":"8.6.14"}},"addons":{"@storybook/addon-essentials":{"version":"8.6.14"},"@storybook/addon-a11y":{"version":"8.6.14"},"@storybook/addon-interactions":{"version":"8.6.14"}}}
|
||||
@@ -1,3 +0,0 @@
|
||||
try{
|
||||
(()=>{var T=__STORYBOOK_API__,{ActiveTabs:h,Consumer:g,ManagerContext:f,Provider:v,RequestResponseError:A,addons:n,combineParameters:x,controlOrMetaKey:P,controlOrMetaSymbol:k,eventMatchesShortcut:M,eventToShortcut:R,experimental_MockUniversalStore:C,experimental_UniversalStore:U,experimental_requestResponse:w,experimental_useUniversalStore:B,isMacLike:E,isShortcutTaken:I,keyToSymbol:K,merge:N,mockChannel:G,optionOrAltSymbol:L,shortcutMatchesShortcut:Y,shortcutToHumanString:q,types:D,useAddonState:F,useArgTypes:H,useArgs:j,useChannel:V,useGlobalTypes:z,useGlobals:J,useParameter:Q,useSharedState:W,useStoryPrepared:X,useStorybookApi:Z,useStorybookState:$}=__STORYBOOK_API__;var S=(()=>{let e;return typeof window<"u"?e=window:typeof globalThis<"u"?e=globalThis:typeof window<"u"?e=window:typeof self<"u"?e=self:e={},e})(),c="tag-filters",p="static-filter";n.register(c,e=>{let u=Object.entries(S.TAGS_OPTIONS??{}).reduce((t,r)=>{let[o,i]=r;return i.excludeFromSidebar&&(t[o]=!0),t},{});e.experimental_setFilter(p,t=>{let r=t.tags??[];return(r.includes("dev")||t.type==="docs")&&r.filter(o=>u[o]).length===0})});})();
|
||||
}catch(e){ console.error("[Storybook] One of your manager-entries failed: " + import.meta.url, e); }
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svgjs="http://svgjs.com/svgjs" xmlns:xlink="http://www.w3.org/1999/xlink" width="164" height="164" version="1.1"><svg xmlns="http://www.w3.org/2000/svg" width="164" height="164" fill="none" viewBox="0 0 164 164"><path fill="#FF4785" d="M22.467 147.762 17.5 15.402a8.062 8.062 0 0 1 7.553-8.35L137.637.016a8.061 8.061 0 0 1 8.565 8.047v144.23a8.063 8.063 0 0 1-8.424 8.054l-107.615-4.833a8.062 8.062 0 0 1-7.695-7.752Z"/><path fill="#fff" fill-rule="evenodd" d="m128.785.57-15.495.968-.755 18.172a1.203 1.203 0 0 0 1.928 1.008l7.06-5.354 5.962 4.697a1.202 1.202 0 0 0 1.946-.987L128.785.569Zm-12.059 60.856c-2.836 2.203-23.965 3.707-23.965.57.447-11.969-4.912-12.494-7.889-12.494-2.828 0-7.59.855-7.59 7.267 0 6.534 6.96 10.223 15.13 14.553 11.607 6.15 25.654 13.594 25.654 32.326 0 17.953-14.588 27.871-33.194 27.871-19.201 0-35.981-7.769-34.086-34.702.744-3.163 25.156-2.411 25.156 0-.298 11.114 2.232 14.383 8.633 14.383 4.912 0 7.144-2.708 7.144-7.267 0-6.9-7.252-10.973-15.595-15.657C64.827 81.933 51.53 74.468 51.53 57.34c0-17.098 11.76-28.497 32.747-28.497 20.988 0 32.449 11.224 32.449 32.584Z" clip-rule="evenodd"/></svg><style>@media (prefers-color-scheme:light){:root{filter:none}}</style></svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |