From 8f2e15d992c9aaaa0a9a99e16ca93ff53e125cd9 Mon Sep 17 00:00:00 2001 From: master <> Date: Wed, 18 Mar 2026 22:44:59 +0200 Subject: [PATCH] UI fixes --- .claude/settings.local.json | 10 +- src/Web/StellaOps.Web/AGENTS.md | 92 + src/Web/StellaOps.Web/src/app/app.config.ts | 2249 ++++++++-------- src/Web/StellaOps.Web/src/app/app.routes.ts | 17 +- .../core/branding/branding.service.spec.ts | 2 +- .../src/app/core/branding/branding.service.ts | 2 +- .../src/app/core/i18n/date-format.service.ts | 111 + .../StellaOps.Web/src/app/core/i18n/index.ts | 1 + .../app/core/services/offline-mode.service.ts | 4 +- .../admin-notifications.component.ts | 76 +- .../channel-management.component.ts | 12 +- .../delivery-analytics.component.ts | 2 +- .../components/delivery-history.component.ts | 22 +- .../components/escalation-config.component.ts | 4 +- .../notification-dashboard.component.ts | 786 +++--- .../notification-rule-editor.component.ts | 2 +- .../notification-rule-list.component.ts | 18 +- .../operator-override-management.component.ts | 4 +- .../components/operator-override.component.ts | 9 +- .../quiet-hours-config.component.ts | 9 +- .../components/rule-simulator.component.ts | 4 +- .../components/template-editor.component.ts | 4 +- .../components/throttle-config.component.ts | 4 +- .../administration-overview.component.ts | 43 +- .../evidence-drilldown.component.ts | 2 +- .../explanation-panel.component.ts | 2 +- .../advisory-ai/pr-tracker.component.ts | 8 +- .../remediation-plan-preview.component.ts | 2 +- .../agents/agent-detail-page.component.ts | 89 +- .../agents/agent-fleet-dashboard.component.ts | 10 +- .../agents/agent-onboard-wizard.component.ts | 10 +- .../agent-action-modal.component.ts | 2 +- .../agent-health-tab.component.ts | 2 +- .../agent-tasks-tab.component.ts | 20 +- .../fleet-comparison.component.ts | 2 +- .../ai-runs/ai-run-viewer.component.ts | 8 +- .../analytics/sbom-lake-page.component.ts | 9 +- .../aoc-compliance-dashboard.component.ts | 31 +- .../compliance-report.component.ts | 2 +- .../guard-violations-list.component.ts | 7 +- .../ingestion-flow.component.ts | 4 +- .../provenance-validator.component.ts | 2 +- .../features/aoc/verify-action.component.html | 346 +-- .../features/aoc/verify-action.component.ts | 4 +- .../aoc/violation-drilldown.component.html | 542 ++-- .../aoc/violation-drilldown.component.scss | 4 +- .../approval-detail-page.component.ts | 1526 ++++++++--- .../approvals/approval-detail.component.ts | 2 +- .../approvals-inbox-page.component.ts | 16 +- .../approvals/approvals-inbox.component.ts | 802 ++++-- .../request-exception-modal.component.ts | 10 +- .../audit-log/audit-anomalies.component.ts | 4 +- .../audit-log/audit-authority.component.ts | 27 +- .../audit-log/audit-correlations.component.ts | 4 +- .../audit-log/audit-event-detail.component.ts | 4 +- .../audit-log/audit-export.component.ts | 2 +- .../audit-log/audit-integrations.component.ts | 2 +- .../audit-log-dashboard.component.ts | 62 +- .../audit-log/audit-log-table.component.ts | 4 +- .../audit-log/audit-policy.component.ts | 2 +- .../audit-timeline-search.component.ts | 2 +- .../features/audit-log/audit-vex.component.ts | 2 +- .../binary-index-ops.component.ts | 120 +- .../bundles/bundle-builder.component.ts | 7 +- .../bundles/bundle-catalog.component.ts | 7 +- .../bundles/bundle-detail.component.ts | 82 +- .../bundle-version-detail.component.ts | 63 +- .../change-trace-viewer.component.ts | 2 +- .../byte-diff-viewer.component.scss | 6 +- .../delta-list/delta-list.component.scss | 6 +- .../proof-panel/proof-panel.component.scss | 2 +- .../summary-header.component.scss | 2 +- .../summary-header.component.ts | 8 +- .../actionables-panel.component.scss | 6 +- .../components/categories-pane.component.ts | 2 +- .../components/compare-view.component.ts | 9 +- .../vex-merge-explanation.component.scss | 6 +- .../witness-path/witness-path.component.scss | 4 +- .../configuration-pane.component.ts | 4 +- .../integration-detail.component.ts | 36 +- .../integration-section.component.ts | 6 +- .../branding/branding-editor.component.ts | 2048 ++++++++++----- .../clients/clients-list.component.ts | 9 +- .../console-admin-layout.component.ts | 72 +- .../roles/roles-list.component.ts | 9 +- .../users/users-list.component.ts | 9 +- .../console/console-profile.component.html | 4 +- .../console/console-profile.component.scss | 4 +- .../console/console-profile.component.ts | 3 +- .../control-plane-dashboard.component.ts | 6 +- .../features/cvss/cvss-receipt.component.scss | 2 +- .../dashboard-v3/dashboard-v3.component.ts | 841 +++--- .../dashboard/ai-risk-drivers.component.ts | 4 +- .../sources-dashboard.component.html | 293 ++- .../sources-dashboard.component.scss | 2 +- .../dashboard/sources-dashboard.component.ts | 3 +- .../deadletter-dashboard.component.ts | 2 +- .../deadletter-entry-detail.component.ts | 4 +- .../deadletter/deadletter-queue.component.ts | 2 +- .../component-diff-row.component.ts | 2 +- .../deploy-diff-panel.component.ts | 2 +- .../policy-hit-annotation.component.ts | 2 +- .../deploy-diff/pages/deploy-diff.page.ts | 6 +- .../deployment-detail-page.component.ts | 93 +- .../deployments-list-page.component.ts | 28 +- .../features/docs/docs-viewer.component.ts | 6 +- .../check-result/check-result.component.scss | 2 +- .../registry-check-details.component.ts | 2 +- .../remediation-panel.component.ts | 4 +- .../doctor/doctor-dashboard.component.html | 262 +- .../doctor/doctor-dashboard.component.scss | 658 +++-- .../doctor/doctor-dashboard.component.ts | 93 +- .../environment-detail-page.component.ts | 56 +- .../evidence-audit-overview.component.spec.ts | 5 +- .../evidence-audit-overview.component.ts | 138 +- .../evidence-bundles.component.spec.ts | 15 +- .../evidence-bundles.component.ts | 10 +- .../export-center.component.spec.ts | 15 +- .../export-center.component.ts | 71 +- ...provenance-visualization.component.spec.ts | 15 +- .../provenance-visualization.component.ts | 16 +- .../replay-controls.component.ts | 5 +- .../stella-bundle-export-button.component.ts | 6 +- .../evidence-pack-list.component.ts | 2 +- .../evidence-pack-viewer.component.ts | 14 +- .../evidence-export-dialog.component.scss | 6 +- .../evidence-node-card.component.scss | 4 +- .../evidence-node-card.component.ts | 5 +- .../evidence-thread-list.component.html | 5 +- .../evidence-thread-list.component.ts | 7 +- .../evidence-thread-view.component.html | 5 +- .../evidence-thread-view.component.ts | 7 +- .../evidence-timeline-panel.component.scss | 4 +- .../evidence-timeline-panel.component.ts | 7 +- .../evidence-transcript-panel.component.scss | 6 +- .../evidence-transcript-panel.component.ts | 5 +- .../evidence-center-page.component.ts | 4 +- .../evidence-packet-page.component.ts | 65 +- .../evidence/evidence-page.component.ts | 6 +- .../evidence/evidence-panel.component.html | 2283 +++++++++-------- .../evidence/evidence-panel.component.scss | 44 +- .../evidence/evidence-panel.component.ts | 4 +- .../exception-approval-queue.component.html | 2 +- .../exception-approval-queue.component.scss | 20 +- .../exception-approval-queue.component.ts | 3 +- .../exception-center.component.html | 588 ++--- .../exception-center.component.scss | 16 +- .../exception-dashboard.component.html | 2 +- .../exception-dashboard.component.scss | 15 +- .../exception-dashboard.component.ts | 2 + .../exception-detail.component.scss | 23 +- .../exceptions/exception-detail.component.ts | 6 +- .../exception-draft-inline.component.scss | 8 +- .../exception-wizard.component.html | 1286 +++++----- .../exception-wizard.component.scss | 42 +- .../exceptions/exception-wizard.component.ts | 6 +- .../feed-mirror/airgap-export.component.ts | 5 +- .../feed-mirror-dashboard.component.ts | 123 +- .../feed-mirror/feed-mirror.component.html | 54 +- .../feed-mirror/feed-mirror.component.scss | 77 +- .../feed-mirror/feed-mirror.component.ts | 8 +- .../findings/bulk-triage-view.component.scss | 2 +- .../detail/evidence-panel.component.ts | 8 +- .../findings/findings-list.component.html | 2 +- .../findings/findings-list.component.scss | 20 +- .../findings/findings-list.component.ts | 4 +- .../function-map-detail.component.ts | 4 +- .../function-map-generator.component.ts | 4 +- .../features/graph/graph-canvas.component.ts | 4 +- .../graph/graph-explorer.component.html | 5 +- .../graph/graph-explorer.component.ts | 3 +- .../features/graph/graph-filters.component.ts | 16 +- .../graph/graph-overlays.component.ts | 2 +- .../graph/graph-side-panels.component.ts | 116 +- .../home/home-dashboard.component.scss | 71 +- .../features/home/home-dashboard.component.ts | 45 +- .../integration-activity.component.ts | 9 +- .../integration-detail.component.ts | 51 +- .../integration-hub.component.ts | 4 +- .../integration-list.component.ts | 4 +- .../integration-shell.component.ts | 87 +- .../advisory-source-catalog.component.ts | 8 +- .../mirror-client-setup.component.ts | 8 +- .../mirror-domain-builder.component.ts | 6 +- .../integration-wizard.component.scss | 14 +- .../integrations-hub.component.ts | 24 +- .../integrations/models/integration.models.ts | 333 +++ .../components/issuer-detail.component.ts | 5 +- .../components/issuer-list.component.ts | 8 +- .../issuer-trust/issuer-trust.component.ts | 59 +- .../jobengine-job-detail.component.ts | 5 +- .../jobengine/jobengine-jobs.component.ts | 5 +- .../attestation-links.component.ts | 8 +- .../audit-pack-export.component.scss | 21 +- .../cgs-badge/cgs-badge.component.ts | 4 +- .../compare-panel/compare-panel.component.ts | 12 +- .../diff-table/diff-table.component.html | 5 +- .../diff-table/diff-table.component.scss | 2 +- .../diff-table/diff-table.component.ts | 3 +- .../explainer-timeline.component.html | 5 +- .../explainer-timeline.component.scss | 6 +- .../explainer-timeline.component.ts | 3 +- .../lineage-compare-panel.component.ts | 7 +- .../lineage-compare.component.ts | 7 +- .../lineage-graph-container.component.ts | 7 +- .../lineage-mobile-compare.component.ts | 143 +- .../lineage-timeline-slider.component.ts | 6 +- .../lineage-vex-diff.component.ts | 48 +- .../node-diff-table/diff-table.component.html | 5 +- .../node-diff-table/diff-table.component.scss | 4 +- .../node-diff-table/diff-table.component.ts | 3 +- .../call-path-mini.component.scss | 2 +- .../reachability-diff-view.component.html | 5 +- .../reachability-diff-view.component.ts | 4 +- .../timeline-slider.component.ts | 8 +- .../why-safe-panel.component.ts | 6 +- .../mission-activity-page.component.ts | 2 +- .../mission-alerts-page.component.ts | 2 +- .../components/bundle-management.component.ts | 5 +- .../components/jwks-management.component.ts | 8 +- .../verification-center.component.ts | 71 +- .../offline-kit/offline-kit.component.ts | 168 +- .../evidence-card/evidence-card.component.ts | 4 +- .../incident-timeline.component.ts | 4 +- .../data-integrity-overview.component.ts | 2 +- .../data-quality-slos-page.component.ts | 2 +- .../dlq-replays-page.component.ts | 2 +- .../feeds-freshness-page.component.ts | 2 +- ...integration-connectivity-page.component.ts | 2 +- .../job-run-detail-page.component.ts | 2 +- .../nightly-ops-report-page.component.ts | 2 +- ...achability-ingest-health-page.component.ts | 2 +- .../scan-pipeline-health-page.component.ts | 2 +- .../consent-management.component.ts | 4 +- .../ops/event-stream-page.component.ts | 4 +- .../ops/feeds-offline-shell.component.ts | 70 + .../platform-feeds-airgap-page.component.ts | 154 +- .../platform-jobs-queues-page.component.ts | 50 +- .../platform-ops-overview-page.component.html | 158 +- .../platform-ops-overview-page.component.scss | 121 +- .../platform-ops-overview-page.component.ts | 88 +- .../platform/platform-home-page.component.ts | 4 +- ...etup-defaults-guardrails-page.component.ts | 24 +- ...atform-setup-feed-policy-page.component.ts | 25 +- ...form-setup-gate-profiles-page.component.ts | 24 +- .../setup/platform-setup-home.component.ts | 80 +- ...rm-setup-promotion-paths-page.component.ts | 24 +- ...tup-regions-environments-page.component.ts | 26 +- ...-setup-release-templates-page.component.ts | 24 +- ...rm-setup-workflows-gates-page.component.ts | 24 +- .../topology-wizard.component.ts | 24 +- ...olicy-decisioning-audit-shell.component.ts | 75 +- ...policy-decisioning-gates-page.component.ts | 4 +- .../policy-decisioning-shell.component.ts | 127 +- .../policy-decisioning-vex-shell.component.ts | 120 +- .../policy-pack-shell.component.ts | 103 +- .../conflict-resolution-wizard.component.ts | 6 +- .../governance-audit.component.ts | 4 +- .../impact-preview.component.ts | 12 +- .../policy-conflict-dashboard.component.ts | 12 +- .../policy-governance.component.ts | 167 +- .../policy-validator.component.ts | 4 +- .../risk-profile-list.component.ts | 4 +- .../schema-docs.component.ts | 8 +- .../sealed-mode-control.component.ts | 10 +- .../sealed-mode-overrides.component.ts | 2 +- .../staleness-config.component.ts | 12 +- .../trust-weighting.component.ts | 10 +- .../policy-simulation.component.ts | 194 +- .../simulation-dashboard.component.ts | 248 +- .../ai/version-history.component.ts | 8 +- .../nl-input/policy-nl-input.component.ts | 2 +- .../workspace/policy-workspace.component.ts | 6 +- .../verdict-proof-panel.component.ts | 8 +- .../policy/policy-studio.component.ts | 131 +- .../promotions/create-promotion.component.ts | 2 +- .../promotions/promotion-detail.component.ts | 84 +- .../promotions/promotions-list.component.ts | 59 +- .../proof-detail-panel.component.html | 5 +- .../proof-detail-panel.component.scss | 16 +- .../proof-detail-panel.component.ts | 3 +- .../proof-chain/proof-chain.component.html | 44 +- .../proof-chain/proof-chain.component.scss | 16 +- .../proof-chain/proof-chain.component.ts | 5 +- .../proof-studio-container.component.html | 5 +- .../proof-studio-container.component.scss | 4 +- .../proof-studio-container.component.ts | 4 +- .../proof/proof-ledger-view.component.scss | 2 +- .../proof/proof-ledger-view.component.ts | 5 +- .../proof/proof-replay-dashboard.component.ts | 7 +- .../proof/score-comparison-view.component.ts | 71 +- .../proof-replay-dashboard.component.ts | 7 +- .../quota-alert-config.component.ts | 2 +- .../quota-forecast.component.ts | 2 +- .../quota-report-export.component.ts | 2 +- .../tenant-quota-detail.component.ts | 2 +- .../path-viewer/path-viewer.component.scss | 6 +- .../risk-drift-card.component.scss | 2 +- .../reachability/poe-drawer.component.ts | 10 +- .../reachability-center.component.html | 56 +- .../reachability-center.component.scss | 28 +- .../reachability-center.component.ts | 11 +- .../reachability-explain-widget.component.ts | 2 +- .../reachability-explain.component.scss | 4 +- .../reachability/witness-page.component.html | 56 +- .../reachability/witness-page.component.scss | 16 +- .../reachability/witness-page.component.ts | 11 +- .../components/plan-audit.component.ts | 11 +- .../components/plan-editor.component.ts | 8 +- .../components/plan-list.component.ts | 11 +- .../registry-admin.component.ts | 82 +- .../hotfixes/hotfixes-queue.component.ts | 806 +++++- .../approval-detail.component.ts | 17 +- .../approval-queue.component.ts | 148 +- .../pipeline-overview.component.scss | 20 +- .../recent-releases.component.scss | 4 +- .../dashboard/dashboard.component.scss | 4 +- .../deployment-list.component.ts | 11 +- .../deployment-monitor.component.ts | 11 +- .../freeze-window-editor.component.ts | 2 +- .../environment-detail.component.ts | 37 +- .../environment-list.component.ts | 2 +- .../evidence-detail.component.ts | 43 +- .../evidence-list/evidence-list.component.ts | 6 +- .../create-release.component.ts | 946 +++++-- .../release-detail.component.ts | 45 +- .../release-list/release-list.component.ts | 903 +++++-- .../workflow-list/workflow-list.component.ts | 11 +- .../releases/hotfix-create-page.component.ts | 232 +- .../releases/hotfix-detail-page.component.ts | 444 +++- .../releases/release-detail-page.component.ts | 86 +- .../releases/release-flow.component.html | 659 +++-- .../releases/release-flow.component.ts | 3 +- .../release-ops-overview-page.component.ts | 2 +- .../releases/releases-activity.component.ts | 29 +- .../releases/releases-list-page.component.ts | 4 +- .../budget-burnup-chart.component.ts | 10 +- .../create-exception-modal.component.ts | 4 +- .../components/exception-ledger.component.ts | 14 +- .../reachability-slice.component.ts | 2 +- .../components/sbom-diff-panel.component.ts | 2 +- .../components/side-by-side-diff.component.ts | 8 +- .../verdict-why-summary.component.ts | 6 +- .../components/vex-sources-panel.component.ts | 4 +- .../risk/risk-dashboard.component.html | 4 +- .../features/risk/risk-dashboard.component.ts | 2 + .../sbom-diff-view.component.ts | 4 +- .../source-detail/source-detail.component.ts | 5 +- .../source-wizard/source-wizard.component.ts | 6 +- .../sources-list/sources-list.component.html | 5 +- .../sources-list/sources-list.component.scss | 2 +- .../sources-list/sources-list.component.ts | 3 +- .../cdx-evidence-panel.component.ts | 8 +- .../commit-info/commit-info.component.ts | 12 +- .../diff-viewer/diff-viewer.component.ts | 80 +- .../evidence-detail-drawer.component.ts | 4 +- .../patch-list/patch-list.component.ts | 4 +- .../component-detail/component-detail.page.ts | 2 +- .../components/analyzer-health.component.ts | 8 +- .../components/baseline-list.component.ts | 8 +- .../components/offline-kit-list.component.ts | 8 +- .../scanner-ops/scanner-ops.component.ts | 140 +- .../features/scanner/scan-submit.component.ts | 4 +- .../features/scans/entropy-panel.component.ts | 97 +- .../scan-attestation-panel.component.scss | 2 +- .../schedule-management.component.ts | 7 +- .../scheduler-run-stream.component.ts | 5 +- .../scheduler-ops/scheduler-runs.component.ts | 7 +- .../scheduler-ops/worker-fleet.component.ts | 12 +- .../scores/score-comparison.component.ts | 7 +- .../exception-manager.component.ts | 8 +- .../finding-detail-drawer.component.ts | 8 +- .../secret-detection-settings.component.ts | 121 +- .../secret-findings-list.component.ts | 5 +- .../finding-detail-page.component.ts | 41 +- .../remediation-browse.component.ts | 2 +- .../remediation-fix-detail.component.ts | 2 +- .../remediation-submit.component.ts | 6 +- .../security-risk-overview.component.ts | 257 +- .../security/exceptions-page.component.ts | 2 +- .../security/lineage-page.component.ts | 55 +- .../security/patch-map-page.component.ts | 54 +- .../security/reachability-page.component.ts | 55 +- .../features/security/risk-page.component.ts | 53 +- .../security/sbom-graph-page.component.ts | 6 +- .../security-disposition-page.component.ts | 143 +- .../security-findings-page.component.ts | 252 +- .../security-overview-page.component.ts | 81 +- .../security-reports-page.component.ts | 379 ++- .../security-sbom-explorer-page.component.ts | 151 +- .../security/unknowns-page.component.ts | 55 +- .../security/vex-hub-page.component.ts | 18 +- .../vulnerability-detail-page.component.ts | 2 +- .../admin/admin-settings-page.component.ts | 303 ++- .../settings/ai-preferences.component.ts | 6 +- .../add-provider-wizard.component.ts | 6 +- ...ntity-providers-settings-page.component.ts | 342 +-- .../integration-detail-page.component.ts | 59 +- .../integrations-settings-page.component.ts | 2 +- .../notifications-settings-page.component.ts | 2 +- .../system/system-settings-page.component.ts | 124 +- .../usage/usage-settings-page.component.ts | 6 +- .../user-preferences-page.component.ts | 1228 ++++++--- .../components/config-missing.component.ts | 4 +- .../components/setup-wizard.component.ts | 4 +- .../signals-runtime-dashboard.component.ts | 22 +- .../merge-preview.component.scss | 4 +- .../snapshot-panel.component.scss | 2 +- .../sources/aoc-dashboard.component.html | 585 +++-- .../sources/aoc-dashboard.component.scss | 4 +- .../sources/aoc-dashboard.component.ts | 3 +- .../system-health-page.component.ts | 75 +- .../timeline-page.component.html | 5 +- .../timeline-page.component.scss | 2 +- .../timeline-page/timeline-page.component.ts | 4 +- .../environment-posture-page.component.ts | 90 +- .../pending-deletions-panel.component.ts | 5 +- .../topology/readiness-dashboard.component.ts | 5 +- .../topology-agents-page.component.ts | 34 +- ...ology-environment-detail-page.component.ts | 125 +- .../topology/topology-hosts-page.component.ts | 32 +- .../topology-inventory-page.component.ts | 34 +- .../topology/topology-map-page.component.ts | 7 +- .../topology-overview-page.component.ts | 8 +- ...topology-promotion-paths-page.component.ts | 34 +- ...ogy-regions-environments-page.component.ts | 44 +- .../topology/topology-shell.component.ts | 134 +- .../topology-targets-page.component.ts | 32 +- .../triage-inbox/triage-inbox.component.html | 2 +- .../triage-inbox/triage-inbox.component.ts | 3 +- .../ai-recommendation-panel.component.ts | 18 +- .../attestation-viewer.component.ts | 2 +- .../bulk-action-modal.component.ts | 8 +- .../case-header/case-header.component.scss | 2 +- .../decision-drawer-enhanced.component.ts | 8 +- .../decision-drawer.component.ts | 2 +- .../binary-diff-tab.component.ts | 4 +- .../evidence-panel/diff-tab.component.ts | 6 +- .../evidence-uri-link.component.scss | 2 +- .../function-trace.component.ts | 4 +- .../evidence-panel/runtime-tab.component.ts | 4 +- .../symbol-path-viewer.component.ts | 2 +- .../tabbed-evidence-panel.component.ts | 156 +- .../evidence-pills.component.ts | 10 +- .../export-evidence-button.component.ts | 4 +- .../findings-detail-page.component.ts | 2 +- .../gated-buckets/gated-buckets.component.ts | 4 +- .../gating-explainer.component.ts | 8 +- .../noise-gating-delta-report.component.ts | 2 +- .../playbook-suggestion.component.ts | 6 +- .../provenance-breadcrumb.component.ts | 2 +- .../quiet-lane/parked-item-card.component.ts | 12 +- .../quiet-lane-container.component.ts | 8 +- .../ttl-countdown-chip.component.ts | 6 +- .../reachability-context.component.ts | 14 +- .../replay-command.component.ts | 6 +- .../risk-line/risk-line.component.ts | 2 +- .../triage-canvas/triage-canvas.component.ts | 61 +- .../triage-lane-toggle.component.ts | 2 +- .../triage-list/triage-list.component.ts | 24 +- .../triage-queue/triage-queue.component.ts | 20 +- .../unknowns-list.component.html | 5 +- .../unknowns-list.component.scss | 2 +- .../unknowns-list/unknowns-list.component.ts | 3 +- .../verdict-ladder.component.scss | 4 +- .../vex-history/vex-history.component.ts | 22 +- .../vex-trust-display.component.ts | 2 +- .../triage/triage-artifacts.component.html | 134 +- .../triage/triage-artifacts.component.scss | 122 +- .../triage/triage-artifacts.component.ts | 3 +- .../triage-audit-bundle-new.component.scss | 10 +- .../triage-audit-bundles.component.html | 2 +- .../triage-audit-bundles.component.scss | 6 +- .../triage/triage-audit-bundles.component.ts | 3 +- .../triage/triage-workspace.component.html | 12 +- .../triage/triage-workspace.component.scss | 14 +- .../triage/triage-workspace.component.ts | 2 + .../triage/vex-decision-modal.component.scss | 12 +- .../trivy-db-settings-page.component.scss | 2 +- .../certificate-inventory.component.ts | 24 +- .../issuer-trust-list.component.ts | 24 +- .../signing-key-dashboard.component.ts | 24 +- .../trust-admin/trust-admin.component.ts | 250 +- .../trust-admin/trust-admin.routes.ts | 15 +- .../trust-admin/trust-overview.component.ts | 6 +- .../unknowns-dashboard.component.ts | 132 +- .../grey-queue-panel.component.spec.ts | 31 +- .../unknowns/grey-queue-panel.component.ts | 372 ++- .../unknowns-budget-widget.component.ts | 2 +- .../unknowns/unknowns-queue.component.ts | 54 +- .../verdict-actions.component.scss | 2 +- .../verdict-detail-panel.component.scss | 2 +- .../vex-hub/ai-explain-panel.component.ts | 6 +- .../vex-hub/ai-remediate-panel.component.ts | 20 +- .../vex-conflict-resolution.component.ts | 6 +- .../vex-hub/vex-consensus.component.ts | 26 +- .../vex-hub/vex-hub-dashboard.component.ts | 111 +- .../app/features/vex-hub/vex-hub.component.ts | 66 +- .../vex-statement-detail-panel.component.ts | 10 +- .../vex-hub/vex-statement-detail.component.ts | 14 +- .../vex-hub/vex-statement-search.component.ts | 22 +- .../vex-merge-panel.component.ts | 15 +- .../vex-conflict-studio.component.scss | 4 +- .../vex-timeline/vex-timeline.component.ts | 10 +- .../citation-link/citation-link.component.ts | 8 +- .../evidence-subgraph.component.ts | 11 +- .../filter-preset-pills.component.ts | 8 +- .../verdict-explanation.component.ts | 6 +- .../vuln-triage-dashboard.component.ts | 7 +- .../vulnerability-explorer.component.html | 857 +++---- .../vulnerability-explorer.component.scss | 10 +- .../vulnerability-explorer.component.ts | 17 +- .../watchlist/watchlist-page.component.html | 52 +- .../watchlist/watchlist-page.component.scss | 25 +- .../watchlist/watchlist-page.component.ts | 11 +- .../workflow-visualizer.component.ts | 8 +- .../run-graph-replay-page.component.html | 59 +- .../run-graph-replay-page.component.scss | 9 +- .../run-graph-replay-page.component.ts | 6 + .../auditor-workspace.component.ts | 12 +- .../developer-workspace.component.ts | 4 +- .../workspace-nav-dropdown.component.ts | 4 +- .../layout/app-shell/app-shell.component.ts | 6 +- .../app-sidebar/app-sidebar.component.ts | 301 ++- .../app-sidebar/sidebar-nav-item.component.ts | 55 + .../layout/app-topbar/app-topbar.component.ts | 106 +- .../layout/breadcrumb/breadcrumb.component.ts | 4 +- .../context-chips/context-chips.component.ts | 533 ++-- .../global-search/global-search.component.ts | 8 + .../overlay-host/overlay-host.component.ts | 6 +- .../src/app/routes/releases.routes.ts | 16 +- .../src/app/routes/setup.routes.ts | 13 +- .../src/app/routes/topology.routes.ts | 27 +- .../accordion/accordion.component.ts | 4 +- .../action-waterfall.component.ts | 2 +- .../ai/ai-assist-panel.component.ts | 2 +- .../shared/components/ai/ai-chip.component.ts | 2 +- .../components/ai/ai-summary.component.ts | 4 +- .../ai/ask-stella-button.component.ts | 2 +- .../ai/ask-stella-panel.component.ts | 10 +- .../ai/llm-unavailable.component.ts | 10 +- .../components/approval-button.component.ts | 2 +- .../components/attestation-node.component.ts | 10 +- .../attestation-viewer.component.ts | 2 +- .../components/badge/badge.component.ts | 4 +- .../binary-diff-panel.component.ts | 4 +- .../breadcrumb/breadcrumb.component.ts | 2 +- .../bundle-freshness-widget.component.ts | 10 +- .../components/button/button.component.ts | 52 +- .../data-table/data-table.component.ts | 6 +- .../degraded-state-banner.component.ts | 8 +- .../determinism-badge.component.html | 260 +- .../components/dropdown/dropdown.component.ts | 6 +- .../dsse-envelope-viewer.component.ts | 2 +- .../empty-state/empty-state.component.ts | 6 +- .../components/entropy-panel.component.html | 320 +-- .../entropy-policy-banner.component.html | 400 +-- .../entropy-policy-banner.component.scss | 12 +- .../error-state/error-state.component.ts | 6 +- .../components/evidence-drawer.component.ts | 4 +- .../evidence-drawer.component.ts | 4 +- .../components/exception-badge.component.ts | 5 +- .../components/exception-explain.component.ts | 8 +- .../export-center/sarif-download.component.ts | 2 +- .../feature-card/feature-card.component.ts | 45 +- .../filters/filter-strip.component.ts | 6 +- .../components/finding-detail.component.ts | 8 +- .../function-diff/function-diff.component.ts | 2 +- .../graph-diff/graph-diff.component.ts | 10 +- .../lattice-diagram.component.ts | 2 +- .../components/metrics-dashboard.component.ts | 8 +- .../offline-verification.component.ts | 8 +- .../plain-language-toggle.component.ts | 4 +- .../policy-gate-indicator.component.html | 554 ++-- .../policy-gate-indicator.component.scss | 6 +- .../policy/policy-export-dialog.component.ts | 4 +- .../policy/policy-import-dialog.component.ts | 4 +- .../policy/policy-pack-editor.component.ts | 2 +- .../segment-detail-modal.component.ts | 4 +- .../shared/components/proof-tree.component.ts | 4 +- .../quick-verify-drawer.component.ts | 8 +- .../shared/components/rekor-link.component.ts | 2 +- .../score/signed-score-ribbon.component.ts | 10 +- .../search-input/search-input.component.ts | 2 +- .../stat-card/stat-card.component.ts | 2 +- .../stella-filter-chip.component.ts | 231 ++ .../stella-filter-multi.component.ts | 267 ++ .../stella-metric-card.component.ts | 206 ++ .../stella-metric-grid.component.ts | 31 + .../stella-page-tabs.component.ts | 290 +++ .../stella-quick-links.component.ts | 163 ++ .../shared/components/tabs/tabs.component.ts | 14 +- .../theme-toggle/theme-toggle.component.ts | 2 +- .../components/timeline-event.component.ts | 10 +- .../toast/toast-container.component.ts | 2 +- .../triage/triage-card.component.ts | 2 +- .../unwitnessed-advisory.component.ts | 6 +- .../user-menu/user-menu.component.scss | 11 - .../user-menu/user-menu.component.ts | 20 +- .../vex-trust-popover.component.ts | 6 +- .../view-mode-switcher.component.ts | 115 + .../view-mode-toggle.component.html | 11 +- .../view-mode-toggle.component.scss | 22 +- .../view-mode-toggle.component.ts | 31 +- .../witness-comparison.component.ts | 4 +- .../evidence-link/evidence-link.component.ts | 2 +- .../gate-summary-panel.component.ts | 10 +- .../witness-path-preview.component.ts | 4 +- .../evidence-packet-drawer.component.ts | 20 +- .../finding-detail-drawer.component.ts | 22 +- .../witness-drawer.component.ts | 6 +- .../src/app/shared/pipes/format.pipes.ts | 18 +- .../ui/empty-state/empty-state.component.ts | 6 +- .../ui/filter-bar/filter-bar.component.ts | 165 +- .../overview-card-groups.component.ts | 2 +- .../ui/tabbed-nav/tabbed-nav.component.ts | 20 +- .../timeline-list/timeline-list.component.ts | 14 +- .../witness-viewer.component.ts | 20 +- .../witness/attestation-detail.component.ts | 2 +- .../ui/witness/evidence-payload.component.ts | 2 +- .../witness/signature-inspector.component.ts | 10 +- .../witness/verification-summary.component.ts | 8 +- src/Web/StellaOps.Web/src/styles.scss | 41 + src/Web/StellaOps.Web/src/styles/_forms.scss | 17 +- .../src/styles/_interactions.scss | 2 +- src/Web/StellaOps.Web/src/styles/_mixins.scss | 20 +- .../src/styles/_quick-links.scss | 81 + src/Web/StellaOps.Web/src/styles/_tables.scss | 87 + .../src/styles/tokens/_colors.scss | 229 +- 629 files changed, 22826 insertions(+), 17058 deletions(-) create mode 100644 src/Web/StellaOps.Web/src/app/core/i18n/date-format.service.ts create mode 100644 src/Web/StellaOps.Web/src/app/features/platform/ops/feeds-offline-shell.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/components/stella-filter-chip/stella-filter-chip.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/components/stella-filter-multi/stella-filter-multi.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/components/stella-metric-card/stella-metric-card.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/components/stella-metric-card/stella-metric-grid.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/components/stella-page-tabs/stella-page-tabs.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/components/stella-quick-links/stella-quick-links.component.ts create mode 100644 src/Web/StellaOps.Web/src/app/shared/components/view-mode-switcher/view-mode-switcher.component.ts create mode 100644 src/Web/StellaOps.Web/src/styles/_quick-links.scss create mode 100644 src/Web/StellaOps.Web/src/styles/_tables.scss diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 6a284f2c0..7b178328c 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -28,7 +28,15 @@ "Bash(if not exist \"E:\\dev\\git.stella-ops.org\\src\\Scanner\\__Tests\\StellaOps.Scanner.Analyzers.Lang.Node.Tests\\Internal\" mkdir \"E:\\dev\\git.stella-ops.org\\src\\Scanner\\__Tests\\StellaOps.Scanner.Analyzers.Lang.Node.Tests\\Internal\")", "Bash(rm:*)", "Bash(if not exist \"C:\\dev\\New folder\\git.stella-ops.org\\docs\\implplan\\archived\" mkdir \"C:\\dev\\New folder\\git.stella-ops.org\\docs\\implplan\\archived\")", - "Bash(del \"C:\\dev\\New folder\\git.stella-ops.org\\docs\\implplan\\SPRINT_0510_0001_0001_airgap.md\")" + "Bash(del \"C:\\dev\\New folder\\git.stella-ops.org\\docs\\implplan\\SPRINT_0510_0001_0001_airgap.md\")", + "Bash(docker build:*)", + "Bash(docker compose:*)", + "Bash(docker wait:*)", + "Bash(npx ng:*)", + "mcp__plugin_playwright_playwright__browser_snapshot", + "mcp__plugin_playwright_playwright__browser_click", + "mcp__plugin_playwright_playwright__browser_navigate", + "mcp__plugin_playwright_playwright__browser_take_screenshot" ], "deny": [], "ask": [] diff --git a/src/Web/StellaOps.Web/AGENTS.md b/src/Web/StellaOps.Web/AGENTS.md index 50769112e..3ba74ccc3 100644 --- a/src/Web/StellaOps.Web/AGENTS.md +++ b/src/Web/StellaOps.Web/AGENTS.md @@ -87,3 +87,95 @@ Design and build the StellaOps web user experience that surfaces backend capabil - 4. Coordinate doc updates, tests, and cross-guild communication whenever contracts or workflows change. - 5. Revert to `TODO` if you pause the task without shipping changes; leave notes in commit/PR descriptions for context. +## Metric / KPI Cards Convention (MANDATORY) + +All metric badges, stat cards, KPI tiles, and summary indicators **must** use ``. +Do NOT create custom `.stat-card`, `.summary-card`, `.kpi-card`, or `.posture-card` elements. + +**Components:** +- `shared/components/stella-metric-card/stella-metric-card.component.ts` — individual card +- `shared/components/stella-metric-card/stella-metric-grid.component.ts` — responsive grid wrapper + +**Usage:** +```html + + + + +``` + +**Design rules:** +- Cards are **uncolored** — no severity/status color backgrounds +- Icon is mandatory (SVG path d, multi-path via `|||`) +- Subtitle is 5-10 words explaining the metric +- If `route` is set: card is clickable with hover lift + arrow +- If no `route`: static display, no hover effect + +## Tab Navigation Convention (MANDATORY) + +All page-level tab navigation **must** use ``. +Do NOT create custom `.tabs`, `.tab-navigation`, `.tab-button`, or `nav[role="tablist"]` elements. +Do NOT use the `app-tabs` / `TabsComponent` for page tabs (that component is for inline content tabs only). + +**Component:** `shared/components/stella-page-tabs/stella-page-tabs.component.ts` + +**Usage (signal-based, no router):** +```html + + @switch (activeTab()) { + @case ('first') { } + @case ('second') { } + } + +``` + +**Usage (router-based):** +```html + + + +``` + +**Tab definition:** +```typescript +const MY_TABS: readonly StellaPageTab[] = [ + { id: 'overview', label: 'Overview', icon: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z' }, + { id: 'details', label: 'Details', icon: 'M14 2H6a2...', status: 'warn', statusHint: '3 issues' }, +]; +``` + +**Design rules:** +- Every tab MUST have an SVG icon (`icon` field — SVG path `d` attribute, multi-path via `|||`) +- Labels should be short (1-2 words) +- Use `status` for warn/error indicators, `badge` for counts +- Do NOT duplicate the tab bar styling — the component owns all tab CSS +- The component provides: keyboard navigation, ARIA roles, active background, bottom border, icon opacity transitions, panel border-radius, enter animation + +## Table Styling Convention +All HTML tables must use the `stella-table` CSS class for consistent styling. +Never define custom table padding, borders, or header styles inline. +Use the shared data-table component when possible, or the stella-table class for simple static tables. + +## Filter Convention (MANDATORY) + +Three filter component types: +1. `stella-filter-chip` — Single-select dropdown (Region, Env, Stage, Type, Gate, Risk) +2. `stella-filter-multi` — Multi-select with checkboxes + All/None (Severity, Status) +3. `stella-view-mode-switcher` — Binary toggle (Operator/Auditor, view modes) + +Global filters (Region, Env, Window, Stage, Operator/Auditor) live in the header bar only. +Pages must NOT duplicate global filters. Read from PlatformContextStore. + +Design: Compact inline chips, 28px height, no border default, dropdown on click. + diff --git a/src/Web/StellaOps.Web/src/app/app.config.ts b/src/Web/StellaOps.Web/src/app/app.config.ts index 8c3c2b6e4..3e1879639 100644 --- a/src/Web/StellaOps.Web/src/app/app.config.ts +++ b/src/Web/StellaOps.Web/src/app/app.config.ts @@ -1,1121 +1,1128 @@ -import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; -import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core'; -import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; -import { provideRouter, TitleStrategy, withComponentInputBinding } from '@angular/router'; -import { provideMarkdown } from 'ngx-markdown'; -import { PageTitleStrategy } from './core/navigation/page-title.strategy'; - -import { routes } from './app.routes'; -import { CONCELIER_EXPORTER_API_BASE_URL } from './core/api/concelier-exporter.client'; -import { - AUTHORITY_CONSOLE_API, - AUTHORITY_CONSOLE_API_BASE_URL, - AuthorityConsoleApiHttpClient, -} from './core/api/authority-console.client'; -import { - CONSOLE_API_BASE_URL, - DEFAULT_EVENT_SOURCE_FACTORY, - EVENT_SOURCE_FACTORY, -} from './core/api/console-status.client'; -import { - NOTIFY_API, - NOTIFY_API_BASE_URL, - NotifyApiHttpClient, - MockNotifyClient, -} from './core/api/notify.client'; -import { - EXCEPTION_API, - EXCEPTION_API_BASE_URL, - ExceptionApiHttpClient, - MockExceptionApiService, -} from './core/api/exception.client'; -import { VULNERABILITY_API, MockVulnerabilityApiService } from './core/api/vulnerability.client'; -import { - VULNERABILITY_API_BASE_URL, - VULNERABILITY_QUERY_API_BASE_URL, - VulnerabilityHttpClient, -} from './core/api/vulnerability-http.client'; -import { RISK_API, MockRiskApi } from './core/api/risk.client'; -import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client'; -import { AppConfigService } from './core/config/app-config.service'; -import { I18nService } from './core/i18n'; -import { DoctorTrendService } from './core/doctor/doctor-trend.service'; -import { DoctorNotificationService } from './core/doctor/doctor-notification.service'; -import { BackendProbeService } from './core/config/backend-probe.service'; -import { OpenApiContextParamMap } from './core/context/openapi-context-param-map.service'; -import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor'; -import { AuthSessionStore } from './core/auth/auth-session.store'; -import { TenantActivationService } from './core/auth/tenant-activation.service'; -import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor'; -import { TenantHttpInterceptor } from './core/auth/tenant-http.interceptor'; -import { GlobalContextHttpInterceptor } from './core/context/global-context-http.interceptor'; -import { seedAuthSession, type StubAuthSession } from './testing'; -import { CVSS_API_BASE_URL } from './core/api/cvss.client'; -import { AUTH_SERVICE } from './core/auth'; -import { AuthorityAuthService } from './core/auth/authority-auth.service'; -import { AuthorityAuthAdapterService } from './core/auth/authority-auth-adapter.service'; -import { - ADVISORY_AI_API, - ADVISORY_AI_API_BASE_URL, - AdvisoryAiApiHttpClient, - MockAdvisoryAiClient, -} from './core/api/advisory-ai.client'; -import { - ADVISORY_API, - ADVISORY_API_BASE_URL, - AdvisoryApiHttpClient, - MockAdvisoryApiService, -} from './core/api/advisories.client'; -import { - VEX_EVIDENCE_API, - VEX_EVIDENCE_API_BASE_URL, - VexEvidenceHttpClient, - MockVexEvidenceClient, -} from './core/api/vex-evidence.client'; -import { - VEX_DECISIONS_API, - VEX_DECISIONS_API_BASE_URL, - VexDecisionsHttpClient, - MockVexDecisionsClient, -} from './core/api/vex-decisions.client'; -import { VEX_HUB_API, VEX_HUB_API_BASE_URL, VEX_LENS_API_BASE_URL, VexHubApiHttpClient, MockVexHubClient } from './core/api/vex-hub.client'; -import { - AUDIT_BUNDLES_API, - AUDIT_BUNDLES_API_BASE_URL, - AuditBundlesHttpClient, - MockAuditBundlesClient, -} from './core/api/audit-bundles.client'; -import { - POLICY_EXCEPTIONS_API, - POLICY_EXCEPTIONS_API_BASE_URL, - PolicyExceptionsHttpClient, - MockPolicyExceptionsApiService, -} from './core/api/policy-exceptions.client'; -import { - POLICY_EVIDENCE_API, - PolicyEvidenceCompositeClient, -} from './core/api/policy-evidence.client'; -import { - ORCHESTRATOR_API, - JOBENGINE_API_BASE_URL, - OrchestratorHttpClient, - MockJobEngineClient, -} from './core/api/jobengine.client'; -import { - ORCHESTRATOR_CONTROL_API, - JobEngineControlHttpClient, - MockJobEngineControlClient, -} from './core/api/jobengine-control.client'; -import { - FIRST_SIGNAL_API, - FirstSignalHttpClient, - MockFirstSignalClient, -} from './core/api/first-signal.client'; -import { - EXCEPTION_EVENTS_API, - EXCEPTION_EVENTS_API_BASE_URL, - ExceptionEventsHttpClient, - MockExceptionEventsApiService, -} from './core/api/exception-events.client'; -import { - EVIDENCE_PACK_API, - EVIDENCE_PACK_API_BASE_URL, - EvidencePackHttpClient, - MockEvidencePackClient, -} from './core/api/evidence-pack.client'; -import { - AI_RUNS_API, - AI_RUNS_API_BASE_URL, - AiRunsHttpClient, - MockAiRunsClient, -} from './core/api/ai-runs.client'; -import { - RELEASE_DASHBOARD_API, - RELEASE_DASHBOARD_API_BASE_URL, - ReleaseDashboardHttpClient, -} from './core/api/release-dashboard.client'; -import { - RELEASE_ENVIRONMENT_API, - RELEASE_ENVIRONMENT_API_BASE_URL, - ReleaseEnvironmentHttpClient, -} from './core/api/release-environment.client'; -import { - RELEASE_MANAGEMENT_API, - ReleaseManagementHttpClient, -} from './core/api/release-management.client'; -import { - WORKFLOW_API, - WorkflowHttpClient, -} from './core/api/workflow.client'; -import { - APPROVAL_API, - ApprovalHttpClient, -} from './core/api/approval.client'; -import { - DEPLOYMENT_API, - DeploymentHttpClient, -} from './core/api/deployment.client'; -import { - RELEASE_EVIDENCE_API, - ReleaseEvidenceHttpClient, -} from './core/api/release-evidence.client'; -import { - DOCTOR_API, - HttpDoctorClient, -} from './features/doctor/services/doctor.client'; -import { - WITNESS_API, - WitnessHttpClient, -} from './core/api/witness.client'; -import { - NOTIFIER_API, - NOTIFIER_API_BASE_URL, - NotifierApiHttpClient, - MockNotifierClient, -} from './core/api/notifier.client'; -import { - POLICY_ENGINE_API, - PolicyEngineHttpClient, - MockPolicyEngineApi, -} from './core/api/policy-engine.client'; -import { - TRUST_API, - TrustHttpService, - MockTrustApiService, -} from './core/api/trust.client'; -import { - VULN_ANNOTATION_API, - HttpVulnAnnotationClient, -} from './core/api/vuln-annotation.client'; -import { - AUTHORITY_ADMIN_API, - AUTHORITY_ADMIN_API_BASE_URL, - AuthorityAdminHttpClient, - MockAuthorityAdminClient, -} from './core/api/authority-admin.client'; -import { - SECURITY_FINDINGS_API, - SECURITY_FINDINGS_API_BASE_URL, - SecurityFindingsHttpClient, - MockSecurityFindingsClient, -} from './core/api/security-findings.client'; -import { - SECURITY_OVERVIEW_API, - SecurityOverviewHttpClient, - MockSecurityOverviewClient, -} from './core/api/security-overview.client'; -import { - CONSOLE_VULN_API, - ConsoleVulnHttpClient, - MockConsoleVulnClient, -} from './core/api/console-vuln.client'; -import { - REACHABILITY_API, - ReachabilityClient, - MockReachabilityApi, -} from './core/api/reachability.client'; -import { - SCHEDULER_API, - SCHEDULER_API_BASE_URL, - SchedulerHttpClient, - MockSchedulerClient, -} from './core/api/scheduler.client'; -import { AnalyticsHttpClient, MockAnalyticsClient } from './core/api/analytics.client'; -import { FEED_MIRROR_API, FEED_MIRROR_API_BASE_URL, FeedMirrorHttpClient } from './core/api/feed-mirror.client'; -import { ATTESTATION_CHAIN_API, AttestationChainHttpClient } from './core/api/attestation-chain.client'; -import { CONSOLE_SEARCH_API, ConsoleSearchHttpClient } from './core/api/console-search.client'; -import { POLICY_GOVERNANCE_API, HttpPolicyGovernanceApi } from './core/api/policy-governance.client'; -import { - POLICY_SIMULATION_API, - POLICY_SIMULATION_API_BASE_URL, - PolicySimulationHttpClient, -} from './core/api/policy-simulation.client'; -import { - GRAPH_API_BASE_URL, - GRAPH_PLATFORM_API, - GraphPlatformHttpClient, -} from './core/api/graph-platform.client'; -import { POLICY_GATES_API, POLICY_GATES_API_BASE_URL, PolicyGatesHttpClient } from './core/api/policy-gates.client'; -import { RELEASE_API, ReleaseHttpClient } from './core/api/release.client'; -import { TRIAGE_EVIDENCE_API, TriageEvidenceHttpClient } from './core/api/triage-evidence.client'; -import { VERDICT_API, HttpVerdictClient } from './core/api/verdict.client'; -import { WATCHLIST_API, WatchlistHttpClient } from './core/api/watchlist.client'; -import { EVIDENCE_API, EVIDENCE_API_BASE_URL, EvidenceHttpClient } from './core/api/evidence.client'; -import { - MANIFEST_API, - PROOF_BUNDLE_API, - SCORE_REPLAY_API, - ManifestClient, - ProofBundleClient, - ScoreReplayClient, -} from './core/api/proof.client'; -import { SBOM_EVIDENCE_API, SbomEvidenceService } from './features/sbom/services/sbom-evidence.service'; -import { HttpReplayClient } from './core/api/replay.client'; -import { REPLAY_API } from './features/proofs/proof-replay-dashboard.component'; -import { HttpScoreClient } from './core/api/score.client'; -import { SCORE_API } from './features/scores/score-comparison.component'; -import { AOC_API, AOC_API_BASE_URL, AOC_SOURCES_API_BASE_URL, AocHttpClient } from './core/api/aoc.client'; -import { DELTA_VERDICT_API, HttpDeltaVerdictApi } from './core/services/delta-verdict.service'; -import { RISK_BUDGET_API, HttpRiskBudgetApi } from './core/services/risk-budget.service'; -import { FIX_VERIFICATION_API, FixVerificationApiClient } from './core/services/fix-verification.service'; -import { SCORING_API, HttpScoringApi } from './core/services/scoring.service'; -import { ABAC_OVERLAY_API, AbacOverlayHttpClient } from './core/api/abac-overlay.client'; -import { - IDENTITY_PROVIDER_API, - IDENTITY_PROVIDER_API_BASE_URL, - IdentityProviderApiHttpClient, - MockIdentityProviderClient, -} from './core/api/identity-provider.client'; - -function resolveApiBaseUrl(baseUrl: string | undefined, path: string): string { - const normalizedBase = (baseUrl ?? '').trim(); - - if (!normalizedBase) { - return path; - } - - try { - return new URL(path, normalizedBase).toString(); - } catch { - if (path.startsWith('/')) { - return path; - } - - const baseWithoutTrailingSlash = normalizedBase.endsWith('/') - ? normalizedBase.slice(0, -1) - : normalizedBase; - - return `${baseWithoutTrailingSlash}/${path.replace(/^\/+/, '')}`; - } -} - -export function resolveApiRootUrl(baseUrl: string | undefined): string { - const normalizedBase = (baseUrl ?? '').trim(); - if (!normalizedBase) { - return ''; - } - - return normalizedBase.endsWith('/') - ? normalizedBase.slice(0, -1) - : normalizedBase; -} - -export const appConfig: ApplicationConfig = { - providers: [ - provideRouter(routes, withComponentInputBinding()), - provideAnimationsAsync(), - { provide: TitleStrategy, useClass: PageTitleStrategy }, - provideHttpClient(withInterceptorsFromDi()), - provideMarkdown(), - provideAppInitializer(() => { - const initializerFn = ((configService: AppConfigService, probeService: BackendProbeService, i18nService: I18nService, openApiParamMap: OpenApiContextParamMap) => async () => { - await configService.load(); - await i18nService.loadTranslations(); - await openApiParamMap.initialize(); - if (configService.isConfigured()) { - probeService.probe(); - } - })(inject(AppConfigService), inject(BackendProbeService), inject(I18nService), inject(OpenApiContextParamMap)); - return initializerFn(); - }), - { - provide: HTTP_INTERCEPTORS, - useClass: AuthHttpInterceptor, - multi: true, - }, - { - provide: HTTP_INTERCEPTORS, - useClass: OperatorMetadataInterceptor, - multi: true, - }, - { - provide: HTTP_INTERCEPTORS, - useClass: TenantHttpInterceptor, - multi: true, - }, - { - provide: HTTP_INTERCEPTORS, - useClass: GlobalContextHttpInterceptor, - multi: true, - }, - { - provide: CONCELIER_EXPORTER_API_BASE_URL, - useValue: '/api/v1/concelier/exporters/trivy-db', - }, - { - provide: AUTHORITY_CONSOLE_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return resolveApiBaseUrl(gatewayBase, '/console'); - }, - }, - AuthorityConsoleApiHttpClient, - { - provide: AUTHORITY_CONSOLE_API, - useExisting: AuthorityConsoleApiHttpClient, - }, - provideAppInitializer(() => { - const initializerFn = ((store: AuthSessionStore, tenantActivation: TenantActivationService) => () => { - if (typeof window === 'undefined') return; - const stub = (window as any).__stellaopsTestSession as StubAuthSession | undefined; - if (!stub) return; - try { - seedAuthSession(store, stub); - tenantActivation.activateTenant(stub.tenant); - } catch (err) { - console.warn('Failed to seed test session', err); - } - })(inject(AuthSessionStore), inject(TenantActivationService)); - return initializerFn(); - }), - { - provide: RISK_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/risk', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') - ? gatewayBase.slice(0, -1) - : gatewayBase; - return `${normalized}/api/risk`; - } - }, - }, - { - provide: AUTH_SERVICE, - useExisting: AuthorityAuthAdapterService, - }, - { - provide: CVSS_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const policyBase = config.config.apiBaseUrls.policy; - try { - return new URL('/api/cvss', policyBase).toString(); - } catch { - const normalized = policyBase.endsWith('/') ? policyBase.slice(0, -1) : policyBase; - return `${normalized}/api/cvss`; - } - }, - }, - RiskHttpClient, - MockRiskApi, - { - provide: RISK_API, - useExisting: RiskHttpClient, - }, - { - provide: VULNERABILITY_QUERY_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway - ?? config.config.apiBaseUrls.scanner - ?? config.config.apiBaseUrls.authority; - return resolveApiBaseUrl(gatewayBase, '/api/v1/vulnerabilities'); - }, - }, - { - provide: VULNERABILITY_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - return resolveApiRootUrl(config.config.apiBaseUrls.authority); - }, - }, - VulnerabilityHttpClient, - MockVulnerabilityApiService, - { - provide: VULNERABILITY_API, - useExisting: VulnerabilityHttpClient, - }, - { - provide: NOTIFY_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/notify', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/notify`; - } - }, - }, - { - provide: ADVISORY_AI_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/v1/advisory-ai', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/v1/advisory-ai`; - } - }, - }, - AdvisoryAiApiHttpClient, - MockAdvisoryAiClient, - { - provide: ADVISORY_AI_API, - useExisting: AdvisoryAiApiHttpClient, - }, - { - provide: ADVISORY_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - }, - }, - AdvisoryApiHttpClient, - MockAdvisoryApiService, - { - provide: ADVISORY_API, - useExisting: AdvisoryApiHttpClient, - }, - { - provide: VEX_EVIDENCE_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - }, - }, - { - provide: VEX_HUB_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/vex', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/vex`; - } - }, - }, - { - provide: VEX_LENS_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/vexlens', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/vexlens`; - } - }, - }, - VexHubApiHttpClient, - MockVexHubClient, - { - provide: VEX_HUB_API, - useExisting: VexHubApiHttpClient, - }, - VexEvidenceHttpClient, - MockVexEvidenceClient, - { - provide: VEX_EVIDENCE_API, - useExisting: VexEvidenceHttpClient, - }, - { - provide: VEX_DECISIONS_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - }, - }, - VexDecisionsHttpClient, - MockVexDecisionsClient, - { - provide: VEX_DECISIONS_API, - useExisting: VexDecisionsHttpClient, - }, - { - provide: AUDIT_BUNDLES_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - }, - }, - AuditBundlesHttpClient, - MockAuditBundlesClient, - { - provide: AUDIT_BUNDLES_API, - useExisting: AuditBundlesHttpClient, - }, - { - provide: POLICY_EXCEPTIONS_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - }, - }, - PolicyExceptionsHttpClient, - MockPolicyExceptionsApiService, - { - provide: POLICY_EXCEPTIONS_API, - useExisting: PolicyExceptionsHttpClient, - }, - PolicyEvidenceCompositeClient, - { - provide: POLICY_EVIDENCE_API, - useExisting: PolicyEvidenceCompositeClient, - }, - { - provide: JOBENGINE_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return resolveApiBaseUrl(gatewayBase, '/api/v1'); - }, - }, - OrchestratorHttpClient, - MockJobEngineClient, - { - provide: ORCHESTRATOR_API, - useExisting: OrchestratorHttpClient, - }, - JobEngineControlHttpClient, - MockJobEngineControlClient, - { - provide: ORCHESTRATOR_CONTROL_API, - useExisting: JobEngineControlHttpClient, - }, - FirstSignalHttpClient, - MockFirstSignalClient, - { - provide: FIRST_SIGNAL_API, - useExisting: FirstSignalHttpClient, - }, - { - provide: EXCEPTION_EVENTS_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - }, - }, - ExceptionEventsHttpClient, - MockExceptionEventsApiService, - { - provide: EXCEPTION_EVENTS_API, - useExisting: ExceptionEventsHttpClient, - }, - { - provide: EXCEPTION_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/policy', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/policy`; - } - }, - }, - ExceptionApiHttpClient, - MockExceptionApiService, - { - provide: EXCEPTION_API, - useExisting: ExceptionApiHttpClient, - }, - { - provide: EVIDENCE_PACK_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/v1/evidence-packs', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/v1/evidence-packs`; - } - }, - }, - EvidencePackHttpClient, - MockEvidencePackClient, - { - provide: EVIDENCE_PACK_API, - useExisting: EvidencePackHttpClient, - }, - { - provide: AI_RUNS_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return resolveApiBaseUrl(gatewayBase, '/api/v1/advisory-ai/runs'); - }, - }, - AiRunsHttpClient, - MockAiRunsClient, - { - provide: AI_RUNS_API, - useExisting: AiRunsHttpClient, - }, - { - provide: CONSOLE_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return resolveApiBaseUrl(gatewayBase, '/api/console'); - }, - }, - { - provide: EVENT_SOURCE_FACTORY, - useValue: DEFAULT_EVENT_SOURCE_FACTORY, - }, - NotifyApiHttpClient, - MockNotifyClient, - { - provide: NOTIFY_API, - useExisting: NotifyApiHttpClient, - }, - // Release Dashboard API (runtime HTTP client) - { - provide: RELEASE_DASHBOARD_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/release-jobengine', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/release-orchestrator`; - } - }, - }, - ReleaseDashboardHttpClient, - { - provide: RELEASE_DASHBOARD_API, - useExisting: ReleaseDashboardHttpClient, - }, - // Release Environment API (Sprint 111_002) - { - provide: RELEASE_ENVIRONMENT_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/release-jobengine', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/release-orchestrator`; - } - }, - }, - ReleaseEnvironmentHttpClient, - { - provide: RELEASE_ENVIRONMENT_API, - useExisting: ReleaseEnvironmentHttpClient, - }, - // Release Management API (runtime HTTP client) - ReleaseManagementHttpClient, - { - provide: RELEASE_MANAGEMENT_API, - useExisting: ReleaseManagementHttpClient, - }, - // Workflow API (runtime HTTP client) - WorkflowHttpClient, - { - provide: WORKFLOW_API, - useExisting: WorkflowHttpClient, - }, - // Approval API (runtime HTTP client) - ApprovalHttpClient, - { - provide: APPROVAL_API, - useExisting: ApprovalHttpClient, - }, - // Deployment API (runtime HTTP client) - DeploymentHttpClient, - { - provide: DEPLOYMENT_API, - useExisting: DeploymentHttpClient, - }, - // Release Evidence API (runtime HTTP client) - ReleaseEvidenceHttpClient, - { - provide: RELEASE_EVIDENCE_API, - useExisting: ReleaseEvidenceHttpClient, - }, - // Doctor API (runtime HTTP client) - HttpDoctorClient, - { - provide: DOCTOR_API, - useExisting: HttpDoctorClient, - }, - // Witness API (Sprint 20260112_013_FE_witness_ui_wiring) - WitnessHttpClient, - { - provide: WITNESS_API, - useExisting: WitnessHttpClient, - }, - // Notifier API (Bug fix: missing DI providers caused NG0201) - { - provide: NOTIFIER_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/notifier', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/notifier`; - } - }, - }, - NotifierApiHttpClient, - MockNotifierClient, - { - provide: NOTIFIER_API, - useExisting: NotifierApiHttpClient, - }, - // Policy Engine API - PolicyEngineHttpClient, - MockPolicyEngineApi, - { - provide: POLICY_ENGINE_API, - useExisting: PolicyEngineHttpClient, - }, - // Trust API - TrustHttpService, - MockTrustApiService, - { - provide: TRUST_API, - useExisting: TrustHttpService, - }, - // ABAC overlay API - AbacOverlayHttpClient, - { - provide: ABAC_OVERLAY_API, - useExisting: AbacOverlayHttpClient, - }, - // Vuln Annotation API (runtime HTTP client) - HttpVulnAnnotationClient, - { - provide: VULN_ANNOTATION_API, - useExisting: HttpVulnAnnotationClient, - }, - // Authority Admin API (admin CRUD for users/roles/clients/tokens/tenants) - { - provide: AUTHORITY_ADMIN_API_BASE_URL, - useValue: '/console/admin', - }, - AuthorityAdminHttpClient, - MockAuthorityAdminClient, - { - provide: AUTHORITY_ADMIN_API, - useExisting: AuthorityAdminHttpClient, - }, - // Security Findings API (findings ledger via gateway) - { - provide: SECURITY_FINDINGS_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - }, - }, - SecurityFindingsHttpClient, - MockSecurityFindingsClient, - { - provide: SECURITY_FINDINGS_API, - useExisting: SecurityFindingsHttpClient, - }, - // Security Overview API (aggregated security dashboard data) - SecurityOverviewHttpClient, - MockSecurityOverviewClient, - { - provide: SECURITY_OVERVIEW_API, - useExisting: SecurityOverviewHttpClient, - }, - // Scheduler API (schedule CRUD) - { - provide: SCHEDULER_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/scheduler/api/v1/scheduler', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/scheduler/api/v1/scheduler`; - } - }, - }, - SchedulerHttpClient, - MockSchedulerClient, - { - provide: SCHEDULER_API, - useExisting: SchedulerHttpClient, - }, - // Console Vuln API - ConsoleVulnHttpClient, - MockConsoleVulnClient, - { - provide: CONSOLE_VULN_API, - useExisting: ConsoleVulnHttpClient, - }, - // Reachability API - ReachabilityClient, - MockReachabilityApi, - { - provide: REACHABILITY_API, - useExisting: ReachabilityClient, - }, - // Analytics API - AnalyticsHttpClient, - // Feed Mirror API (Concelier backend via gateway) - { - provide: FEED_MIRROR_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/concelier', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/concelier`; - } - }, - }, - FeedMirrorHttpClient, - { - provide: FEED_MIRROR_API, - useExisting: FeedMirrorHttpClient, - }, - // Attestation Chain API - AttestationChainHttpClient, - { - provide: ATTESTATION_CHAIN_API, - useExisting: AttestationChainHttpClient, - }, - // Console Search API - ConsoleSearchHttpClient, - { - provide: CONSOLE_SEARCH_API, - useExisting: ConsoleSearchHttpClient, - }, - // Policy Governance API - HttpPolicyGovernanceApi, - { - provide: POLICY_GOVERNANCE_API, - useExisting: HttpPolicyGovernanceApi, - }, - // Policy Simulation API - { - provide: POLICY_SIMULATION_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const policyBase = config.config.apiBaseUrls.policy; - return policyBase.endsWith('/') ? policyBase.slice(0, -1) : policyBase; - }, - }, - PolicySimulationHttpClient, - { - provide: POLICY_SIMULATION_API, - useExisting: PolicySimulationHttpClient, - }, - // Graph Platform API - { - provide: GRAPH_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/graph', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/graph`; - } - }, - }, - GraphPlatformHttpClient, - { - provide: GRAPH_PLATFORM_API, - useExisting: GraphPlatformHttpClient, - }, - // Policy Gates API (Policy Gateway backend) - { - provide: POLICY_GATES_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/policy', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/policy`; - } - }, - }, - PolicyGatesHttpClient, - { - provide: POLICY_GATES_API, - useExisting: PolicyGatesHttpClient, - }, - // Release API (Release JobEngine backend) - ReleaseHttpClient, - { - provide: RELEASE_API, - useExisting: ReleaseHttpClient, - }, - // Triage Evidence API - TriageEvidenceHttpClient, - { - provide: TRIAGE_EVIDENCE_API, - useExisting: TriageEvidenceHttpClient, - }, - // Verdict API - HttpVerdictClient, - { - provide: VERDICT_API, - useExisting: HttpVerdictClient, - }, - // Watchlist API - WatchlistHttpClient, - { - provide: WATCHLIST_API, - useExisting: WatchlistHttpClient, - }, - // Evidence API (Evidence Locker backend via gateway) - { - provide: EVIDENCE_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/evidence', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/evidence`; - } - }, - }, - EvidenceHttpClient, - { - provide: EVIDENCE_API, - useExisting: EvidenceHttpClient, - }, - // Proof APIs (Manifest, Bundle, Score Replay) - ManifestClient, - ProofBundleClient, - ScoreReplayClient, - { - provide: MANIFEST_API, - useExisting: ManifestClient, - }, - { - provide: PROOF_BUNDLE_API, - useExisting: ProofBundleClient, - }, - { - provide: SCORE_REPLAY_API, - useExisting: ScoreReplayClient, - }, - // SBOM Evidence API - SbomEvidenceService, - { - provide: SBOM_EVIDENCE_API, - useExisting: SbomEvidenceService, - }, - // Replay API - HttpReplayClient, - { - provide: REPLAY_API, - useExisting: HttpReplayClient, - }, - // Score API - HttpScoreClient, - { - provide: SCORE_API, - useExisting: HttpScoreClient, - }, - // Evidence-weighted scoring API - HttpScoringApi, - { - provide: SCORING_API, - useExisting: HttpScoringApi, - }, - // Risk dashboard and fix verification stores - HttpDeltaVerdictApi, - { - provide: DELTA_VERDICT_API, - useExisting: HttpDeltaVerdictApi, - }, - HttpRiskBudgetApi, - { - provide: RISK_BUDGET_API, - useExisting: HttpRiskBudgetApi, - }, - FixVerificationApiClient, - { - provide: FIX_VERIFICATION_API, - useExisting: FixVerificationApiClient, - }, - // AOC API (Attestor + Sources backend via gateway) - { - provide: AOC_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/attestor', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/attestor`; - } - }, - }, - { - provide: AOC_SOURCES_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/sources', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/sources`; - } - }, - }, - AocHttpClient, - { provide: AOC_API, useExisting: AocHttpClient }, - - // Identity Provider API (Platform backend via gateway) - { - provide: IDENTITY_PROVIDER_API_BASE_URL, - deps: [AppConfigService], - useFactory: (config: AppConfigService) => { - const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; - try { - return new URL('/api/v1/platform/identity-providers', gatewayBase).toString(); - } catch { - const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; - return `${normalized}/api/v1/platform/identity-providers`; - } - }, - }, - IdentityProviderApiHttpClient, - MockIdentityProviderClient, - { - provide: IDENTITY_PROVIDER_API, - useExisting: IdentityProviderApiHttpClient, - }, - - // Doctor background services — started from AppComponent to avoid - // NG0200 circular DI during APP_INITIALIZER (Router not yet ready). - DoctorTrendService, - DoctorNotificationService, - ], -}; +import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; +import { ApplicationConfig, inject, LOCALE_ID, provideAppInitializer } from '@angular/core'; +import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; +import { provideRouter, TitleStrategy, withComponentInputBinding } from '@angular/router'; +import { provideMarkdown } from 'ngx-markdown'; +import { PageTitleStrategy } from './core/navigation/page-title.strategy'; + +import { routes } from './app.routes'; +import { CONCELIER_EXPORTER_API_BASE_URL } from './core/api/concelier-exporter.client'; +import { + AUTHORITY_CONSOLE_API, + AUTHORITY_CONSOLE_API_BASE_URL, + AuthorityConsoleApiHttpClient, +} from './core/api/authority-console.client'; +import { + CONSOLE_API_BASE_URL, + DEFAULT_EVENT_SOURCE_FACTORY, + EVENT_SOURCE_FACTORY, +} from './core/api/console-status.client'; +import { + NOTIFY_API, + NOTIFY_API_BASE_URL, + NotifyApiHttpClient, + MockNotifyClient, +} from './core/api/notify.client'; +import { + EXCEPTION_API, + EXCEPTION_API_BASE_URL, + ExceptionApiHttpClient, + MockExceptionApiService, +} from './core/api/exception.client'; +import { VULNERABILITY_API, MockVulnerabilityApiService } from './core/api/vulnerability.client'; +import { + VULNERABILITY_API_BASE_URL, + VULNERABILITY_QUERY_API_BASE_URL, + VulnerabilityHttpClient, +} from './core/api/vulnerability-http.client'; +import { RISK_API, MockRiskApi } from './core/api/risk.client'; +import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client'; +import { AppConfigService } from './core/config/app-config.service'; +import { I18nService } from './core/i18n'; +import { DoctorTrendService } from './core/doctor/doctor-trend.service'; +import { DoctorNotificationService } from './core/doctor/doctor-notification.service'; +import { BackendProbeService } from './core/config/backend-probe.service'; +import { OpenApiContextParamMap } from './core/context/openapi-context-param-map.service'; +import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor'; +import { AuthSessionStore } from './core/auth/auth-session.store'; +import { TenantActivationService } from './core/auth/tenant-activation.service'; +import { OperatorMetadataInterceptor } from './core/orchestrator/operator-metadata.interceptor'; +import { TenantHttpInterceptor } from './core/auth/tenant-http.interceptor'; +import { GlobalContextHttpInterceptor } from './core/context/global-context-http.interceptor'; +import { seedAuthSession, type StubAuthSession } from './testing'; +import { CVSS_API_BASE_URL } from './core/api/cvss.client'; +import { AUTH_SERVICE } from './core/auth'; +import { AuthorityAuthService } from './core/auth/authority-auth.service'; +import { AuthorityAuthAdapterService } from './core/auth/authority-auth-adapter.service'; +import { + ADVISORY_AI_API, + ADVISORY_AI_API_BASE_URL, + AdvisoryAiApiHttpClient, + MockAdvisoryAiClient, +} from './core/api/advisory-ai.client'; +import { + ADVISORY_API, + ADVISORY_API_BASE_URL, + AdvisoryApiHttpClient, + MockAdvisoryApiService, +} from './core/api/advisories.client'; +import { + VEX_EVIDENCE_API, + VEX_EVIDENCE_API_BASE_URL, + VexEvidenceHttpClient, + MockVexEvidenceClient, +} from './core/api/vex-evidence.client'; +import { + VEX_DECISIONS_API, + VEX_DECISIONS_API_BASE_URL, + VexDecisionsHttpClient, + MockVexDecisionsClient, +} from './core/api/vex-decisions.client'; +import { VEX_HUB_API, VEX_HUB_API_BASE_URL, VEX_LENS_API_BASE_URL, VexHubApiHttpClient, MockVexHubClient } from './core/api/vex-hub.client'; +import { + AUDIT_BUNDLES_API, + AUDIT_BUNDLES_API_BASE_URL, + AuditBundlesHttpClient, + MockAuditBundlesClient, +} from './core/api/audit-bundles.client'; +import { + POLICY_EXCEPTIONS_API, + POLICY_EXCEPTIONS_API_BASE_URL, + PolicyExceptionsHttpClient, + MockPolicyExceptionsApiService, +} from './core/api/policy-exceptions.client'; +import { + POLICY_EVIDENCE_API, + PolicyEvidenceCompositeClient, +} from './core/api/policy-evidence.client'; +import { + ORCHESTRATOR_API, + JOBENGINE_API_BASE_URL, + OrchestratorHttpClient, + MockJobEngineClient, +} from './core/api/jobengine.client'; +import { + ORCHESTRATOR_CONTROL_API, + JobEngineControlHttpClient, + MockJobEngineControlClient, +} from './core/api/jobengine-control.client'; +import { + FIRST_SIGNAL_API, + FirstSignalHttpClient, + MockFirstSignalClient, +} from './core/api/first-signal.client'; +import { + EXCEPTION_EVENTS_API, + EXCEPTION_EVENTS_API_BASE_URL, + ExceptionEventsHttpClient, + MockExceptionEventsApiService, +} from './core/api/exception-events.client'; +import { + EVIDENCE_PACK_API, + EVIDENCE_PACK_API_BASE_URL, + EvidencePackHttpClient, + MockEvidencePackClient, +} from './core/api/evidence-pack.client'; +import { + AI_RUNS_API, + AI_RUNS_API_BASE_URL, + AiRunsHttpClient, + MockAiRunsClient, +} from './core/api/ai-runs.client'; +import { + RELEASE_DASHBOARD_API, + RELEASE_DASHBOARD_API_BASE_URL, + ReleaseDashboardHttpClient, +} from './core/api/release-dashboard.client'; +import { + RELEASE_ENVIRONMENT_API, + RELEASE_ENVIRONMENT_API_BASE_URL, + ReleaseEnvironmentHttpClient, +} from './core/api/release-environment.client'; +import { + RELEASE_MANAGEMENT_API, + ReleaseManagementHttpClient, +} from './core/api/release-management.client'; +import { + WORKFLOW_API, + WorkflowHttpClient, +} from './core/api/workflow.client'; +import { + APPROVAL_API, + ApprovalHttpClient, +} from './core/api/approval.client'; +import { + DEPLOYMENT_API, + DeploymentHttpClient, +} from './core/api/deployment.client'; +import { + RELEASE_EVIDENCE_API, + ReleaseEvidenceHttpClient, +} from './core/api/release-evidence.client'; +import { + DOCTOR_API, + HttpDoctorClient, +} from './features/doctor/services/doctor.client'; +import { + WITNESS_API, + WitnessHttpClient, +} from './core/api/witness.client'; +import { + NOTIFIER_API, + NOTIFIER_API_BASE_URL, + NotifierApiHttpClient, + MockNotifierClient, +} from './core/api/notifier.client'; +import { + POLICY_ENGINE_API, + PolicyEngineHttpClient, + MockPolicyEngineApi, +} from './core/api/policy-engine.client'; +import { + TRUST_API, + TrustHttpService, + MockTrustApiService, +} from './core/api/trust.client'; +import { + VULN_ANNOTATION_API, + HttpVulnAnnotationClient, +} from './core/api/vuln-annotation.client'; +import { + AUTHORITY_ADMIN_API, + AUTHORITY_ADMIN_API_BASE_URL, + AuthorityAdminHttpClient, + MockAuthorityAdminClient, +} from './core/api/authority-admin.client'; +import { + SECURITY_FINDINGS_API, + SECURITY_FINDINGS_API_BASE_URL, + SecurityFindingsHttpClient, + MockSecurityFindingsClient, +} from './core/api/security-findings.client'; +import { + SECURITY_OVERVIEW_API, + SecurityOverviewHttpClient, + MockSecurityOverviewClient, +} from './core/api/security-overview.client'; +import { + CONSOLE_VULN_API, + ConsoleVulnHttpClient, + MockConsoleVulnClient, +} from './core/api/console-vuln.client'; +import { + REACHABILITY_API, + ReachabilityClient, + MockReachabilityApi, +} from './core/api/reachability.client'; +import { + SCHEDULER_API, + SCHEDULER_API_BASE_URL, + SchedulerHttpClient, + MockSchedulerClient, +} from './core/api/scheduler.client'; +import { AnalyticsHttpClient, MockAnalyticsClient } from './core/api/analytics.client'; +import { FEED_MIRROR_API, FEED_MIRROR_API_BASE_URL, FeedMirrorHttpClient } from './core/api/feed-mirror.client'; +import { ATTESTATION_CHAIN_API, AttestationChainHttpClient } from './core/api/attestation-chain.client'; +import { CONSOLE_SEARCH_API, ConsoleSearchHttpClient } from './core/api/console-search.client'; +import { POLICY_GOVERNANCE_API, HttpPolicyGovernanceApi } from './core/api/policy-governance.client'; +import { + POLICY_SIMULATION_API, + POLICY_SIMULATION_API_BASE_URL, + PolicySimulationHttpClient, +} from './core/api/policy-simulation.client'; +import { + GRAPH_API_BASE_URL, + GRAPH_PLATFORM_API, + GraphPlatformHttpClient, +} from './core/api/graph-platform.client'; +import { POLICY_GATES_API, POLICY_GATES_API_BASE_URL, PolicyGatesHttpClient } from './core/api/policy-gates.client'; +import { RELEASE_API, ReleaseHttpClient } from './core/api/release.client'; +import { TRIAGE_EVIDENCE_API, TriageEvidenceHttpClient } from './core/api/triage-evidence.client'; +import { VERDICT_API, HttpVerdictClient } from './core/api/verdict.client'; +import { WATCHLIST_API, WatchlistHttpClient } from './core/api/watchlist.client'; +import { EVIDENCE_API, EVIDENCE_API_BASE_URL, EvidenceHttpClient } from './core/api/evidence.client'; +import { + MANIFEST_API, + PROOF_BUNDLE_API, + SCORE_REPLAY_API, + ManifestClient, + ProofBundleClient, + ScoreReplayClient, +} from './core/api/proof.client'; +import { SBOM_EVIDENCE_API, SbomEvidenceService } from './features/sbom/services/sbom-evidence.service'; +import { HttpReplayClient } from './core/api/replay.client'; +import { REPLAY_API } from './features/proofs/proof-replay-dashboard.component'; +import { HttpScoreClient } from './core/api/score.client'; +import { SCORE_API } from './features/scores/score-comparison.component'; +import { AOC_API, AOC_API_BASE_URL, AOC_SOURCES_API_BASE_URL, AocHttpClient } from './core/api/aoc.client'; +import { DELTA_VERDICT_API, HttpDeltaVerdictApi } from './core/services/delta-verdict.service'; +import { RISK_BUDGET_API, HttpRiskBudgetApi } from './core/services/risk-budget.service'; +import { FIX_VERIFICATION_API, FixVerificationApiClient } from './core/services/fix-verification.service'; +import { SCORING_API, HttpScoringApi } from './core/services/scoring.service'; +import { ABAC_OVERLAY_API, AbacOverlayHttpClient } from './core/api/abac-overlay.client'; +import { + IDENTITY_PROVIDER_API, + IDENTITY_PROVIDER_API_BASE_URL, + IdentityProviderApiHttpClient, + MockIdentityProviderClient, +} from './core/api/identity-provider.client'; + +function resolveApiBaseUrl(baseUrl: string | undefined, path: string): string { + const normalizedBase = (baseUrl ?? '').trim(); + + if (!normalizedBase) { + return path; + } + + try { + return new URL(path, normalizedBase).toString(); + } catch { + if (path.startsWith('/')) { + return path; + } + + const baseWithoutTrailingSlash = normalizedBase.endsWith('/') + ? normalizedBase.slice(0, -1) + : normalizedBase; + + return `${baseWithoutTrailingSlash}/${path.replace(/^\/+/, '')}`; + } +} + +export function resolveApiRootUrl(baseUrl: string | undefined): string { + const normalizedBase = (baseUrl ?? '').trim(); + if (!normalizedBase) { + return ''; + } + + return normalizedBase.endsWith('/') + ? normalizedBase.slice(0, -1) + : normalizedBase; +} + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes, withComponentInputBinding()), + provideAnimationsAsync(), + { provide: TitleStrategy, useClass: PageTitleStrategy }, + provideHttpClient(withInterceptorsFromDi()), + provideMarkdown(), + // Wire Angular's LOCALE_ID to the user's chosen locale so that built-in + // pipes (DatePipe, DecimalPipe, CurrencyPipe, etc.) respect regional settings. + { + provide: LOCALE_ID, + deps: [I18nService], + useFactory: (i18n: I18nService) => i18n.locale(), + }, + provideAppInitializer(() => { + const initializerFn = ((configService: AppConfigService, probeService: BackendProbeService, i18nService: I18nService, openApiParamMap: OpenApiContextParamMap) => async () => { + await configService.load(); + await i18nService.loadTranslations(); + await openApiParamMap.initialize(); + if (configService.isConfigured()) { + probeService.probe(); + } + })(inject(AppConfigService), inject(BackendProbeService), inject(I18nService), inject(OpenApiContextParamMap)); + return initializerFn(); + }), + { + provide: HTTP_INTERCEPTORS, + useClass: AuthHttpInterceptor, + multi: true, + }, + { + provide: HTTP_INTERCEPTORS, + useClass: OperatorMetadataInterceptor, + multi: true, + }, + { + provide: HTTP_INTERCEPTORS, + useClass: TenantHttpInterceptor, + multi: true, + }, + { + provide: HTTP_INTERCEPTORS, + useClass: GlobalContextHttpInterceptor, + multi: true, + }, + { + provide: CONCELIER_EXPORTER_API_BASE_URL, + useValue: '/api/v1/concelier/exporters/trivy-db', + }, + { + provide: AUTHORITY_CONSOLE_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return resolveApiBaseUrl(gatewayBase, '/console'); + }, + }, + AuthorityConsoleApiHttpClient, + { + provide: AUTHORITY_CONSOLE_API, + useExisting: AuthorityConsoleApiHttpClient, + }, + provideAppInitializer(() => { + const initializerFn = ((store: AuthSessionStore, tenantActivation: TenantActivationService) => () => { + if (typeof window === 'undefined') return; + const stub = (window as any).__stellaopsTestSession as StubAuthSession | undefined; + if (!stub) return; + try { + seedAuthSession(store, stub); + tenantActivation.activateTenant(stub.tenant); + } catch (err) { + console.warn('Failed to seed test session', err); + } + })(inject(AuthSessionStore), inject(TenantActivationService)); + return initializerFn(); + }), + { + provide: RISK_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/risk', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') + ? gatewayBase.slice(0, -1) + : gatewayBase; + return `${normalized}/api/risk`; + } + }, + }, + { + provide: AUTH_SERVICE, + useExisting: AuthorityAuthAdapterService, + }, + { + provide: CVSS_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const policyBase = config.config.apiBaseUrls.policy; + try { + return new URL('/api/cvss', policyBase).toString(); + } catch { + const normalized = policyBase.endsWith('/') ? policyBase.slice(0, -1) : policyBase; + return `${normalized}/api/cvss`; + } + }, + }, + RiskHttpClient, + MockRiskApi, + { + provide: RISK_API, + useExisting: RiskHttpClient, + }, + { + provide: VULNERABILITY_QUERY_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway + ?? config.config.apiBaseUrls.scanner + ?? config.config.apiBaseUrls.authority; + return resolveApiBaseUrl(gatewayBase, '/api/v1/vulnerabilities'); + }, + }, + { + provide: VULNERABILITY_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + return resolveApiRootUrl(config.config.apiBaseUrls.authority); + }, + }, + VulnerabilityHttpClient, + MockVulnerabilityApiService, + { + provide: VULNERABILITY_API, + useExisting: VulnerabilityHttpClient, + }, + { + provide: NOTIFY_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/notify', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/notify`; + } + }, + }, + { + provide: ADVISORY_AI_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/v1/advisory-ai', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/v1/advisory-ai`; + } + }, + }, + AdvisoryAiApiHttpClient, + MockAdvisoryAiClient, + { + provide: ADVISORY_AI_API, + useExisting: AdvisoryAiApiHttpClient, + }, + { + provide: ADVISORY_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + }, + }, + AdvisoryApiHttpClient, + MockAdvisoryApiService, + { + provide: ADVISORY_API, + useExisting: AdvisoryApiHttpClient, + }, + { + provide: VEX_EVIDENCE_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + }, + }, + { + provide: VEX_HUB_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/vex', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/vex`; + } + }, + }, + { + provide: VEX_LENS_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/vexlens', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/vexlens`; + } + }, + }, + VexHubApiHttpClient, + MockVexHubClient, + { + provide: VEX_HUB_API, + useExisting: VexHubApiHttpClient, + }, + VexEvidenceHttpClient, + MockVexEvidenceClient, + { + provide: VEX_EVIDENCE_API, + useExisting: VexEvidenceHttpClient, + }, + { + provide: VEX_DECISIONS_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + }, + }, + VexDecisionsHttpClient, + MockVexDecisionsClient, + { + provide: VEX_DECISIONS_API, + useExisting: VexDecisionsHttpClient, + }, + { + provide: AUDIT_BUNDLES_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + }, + }, + AuditBundlesHttpClient, + MockAuditBundlesClient, + { + provide: AUDIT_BUNDLES_API, + useExisting: AuditBundlesHttpClient, + }, + { + provide: POLICY_EXCEPTIONS_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + }, + }, + PolicyExceptionsHttpClient, + MockPolicyExceptionsApiService, + { + provide: POLICY_EXCEPTIONS_API, + useExisting: PolicyExceptionsHttpClient, + }, + PolicyEvidenceCompositeClient, + { + provide: POLICY_EVIDENCE_API, + useExisting: PolicyEvidenceCompositeClient, + }, + { + provide: JOBENGINE_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return resolveApiBaseUrl(gatewayBase, '/api/v1'); + }, + }, + OrchestratorHttpClient, + MockJobEngineClient, + { + provide: ORCHESTRATOR_API, + useExisting: OrchestratorHttpClient, + }, + JobEngineControlHttpClient, + MockJobEngineControlClient, + { + provide: ORCHESTRATOR_CONTROL_API, + useExisting: JobEngineControlHttpClient, + }, + FirstSignalHttpClient, + MockFirstSignalClient, + { + provide: FIRST_SIGNAL_API, + useExisting: FirstSignalHttpClient, + }, + { + provide: EXCEPTION_EVENTS_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + }, + }, + ExceptionEventsHttpClient, + MockExceptionEventsApiService, + { + provide: EXCEPTION_EVENTS_API, + useExisting: ExceptionEventsHttpClient, + }, + { + provide: EXCEPTION_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/policy', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/policy`; + } + }, + }, + ExceptionApiHttpClient, + MockExceptionApiService, + { + provide: EXCEPTION_API, + useExisting: ExceptionApiHttpClient, + }, + { + provide: EVIDENCE_PACK_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/v1/evidence-packs', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/v1/evidence-packs`; + } + }, + }, + EvidencePackHttpClient, + MockEvidencePackClient, + { + provide: EVIDENCE_PACK_API, + useExisting: EvidencePackHttpClient, + }, + { + provide: AI_RUNS_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return resolveApiBaseUrl(gatewayBase, '/api/v1/advisory-ai/runs'); + }, + }, + AiRunsHttpClient, + MockAiRunsClient, + { + provide: AI_RUNS_API, + useExisting: AiRunsHttpClient, + }, + { + provide: CONSOLE_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return resolveApiBaseUrl(gatewayBase, '/api/console'); + }, + }, + { + provide: EVENT_SOURCE_FACTORY, + useValue: DEFAULT_EVENT_SOURCE_FACTORY, + }, + NotifyApiHttpClient, + MockNotifyClient, + { + provide: NOTIFY_API, + useExisting: NotifyApiHttpClient, + }, + // Release Dashboard API (runtime HTTP client) + { + provide: RELEASE_DASHBOARD_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/release-jobengine', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/release-orchestrator`; + } + }, + }, + ReleaseDashboardHttpClient, + { + provide: RELEASE_DASHBOARD_API, + useExisting: ReleaseDashboardHttpClient, + }, + // Release Environment API (Sprint 111_002) + { + provide: RELEASE_ENVIRONMENT_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/release-jobengine', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/release-orchestrator`; + } + }, + }, + ReleaseEnvironmentHttpClient, + { + provide: RELEASE_ENVIRONMENT_API, + useExisting: ReleaseEnvironmentHttpClient, + }, + // Release Management API (runtime HTTP client) + ReleaseManagementHttpClient, + { + provide: RELEASE_MANAGEMENT_API, + useExisting: ReleaseManagementHttpClient, + }, + // Workflow API (runtime HTTP client) + WorkflowHttpClient, + { + provide: WORKFLOW_API, + useExisting: WorkflowHttpClient, + }, + // Approval API (runtime HTTP client) + ApprovalHttpClient, + { + provide: APPROVAL_API, + useExisting: ApprovalHttpClient, + }, + // Deployment API (runtime HTTP client) + DeploymentHttpClient, + { + provide: DEPLOYMENT_API, + useExisting: DeploymentHttpClient, + }, + // Release Evidence API (runtime HTTP client) + ReleaseEvidenceHttpClient, + { + provide: RELEASE_EVIDENCE_API, + useExisting: ReleaseEvidenceHttpClient, + }, + // Doctor API (runtime HTTP client) + HttpDoctorClient, + { + provide: DOCTOR_API, + useExisting: HttpDoctorClient, + }, + // Witness API (Sprint 20260112_013_FE_witness_ui_wiring) + WitnessHttpClient, + { + provide: WITNESS_API, + useExisting: WitnessHttpClient, + }, + // Notifier API (Bug fix: missing DI providers caused NG0201) + { + provide: NOTIFIER_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/notifier', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/notifier`; + } + }, + }, + NotifierApiHttpClient, + MockNotifierClient, + { + provide: NOTIFIER_API, + useExisting: NotifierApiHttpClient, + }, + // Policy Engine API + PolicyEngineHttpClient, + MockPolicyEngineApi, + { + provide: POLICY_ENGINE_API, + useExisting: PolicyEngineHttpClient, + }, + // Trust API + TrustHttpService, + MockTrustApiService, + { + provide: TRUST_API, + useExisting: TrustHttpService, + }, + // ABAC overlay API + AbacOverlayHttpClient, + { + provide: ABAC_OVERLAY_API, + useExisting: AbacOverlayHttpClient, + }, + // Vuln Annotation API (runtime HTTP client) + HttpVulnAnnotationClient, + { + provide: VULN_ANNOTATION_API, + useExisting: HttpVulnAnnotationClient, + }, + // Authority Admin API (admin CRUD for users/roles/clients/tokens/tenants) + { + provide: AUTHORITY_ADMIN_API_BASE_URL, + useValue: '/console/admin', + }, + AuthorityAdminHttpClient, + MockAuthorityAdminClient, + { + provide: AUTHORITY_ADMIN_API, + useExisting: AuthorityAdminHttpClient, + }, + // Security Findings API (findings ledger via gateway) + { + provide: SECURITY_FINDINGS_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + return gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + }, + }, + SecurityFindingsHttpClient, + MockSecurityFindingsClient, + { + provide: SECURITY_FINDINGS_API, + useExisting: SecurityFindingsHttpClient, + }, + // Security Overview API (aggregated security dashboard data) + SecurityOverviewHttpClient, + MockSecurityOverviewClient, + { + provide: SECURITY_OVERVIEW_API, + useExisting: SecurityOverviewHttpClient, + }, + // Scheduler API (schedule CRUD) + { + provide: SCHEDULER_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/scheduler/api/v1/scheduler', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/scheduler/api/v1/scheduler`; + } + }, + }, + SchedulerHttpClient, + MockSchedulerClient, + { + provide: SCHEDULER_API, + useExisting: SchedulerHttpClient, + }, + // Console Vuln API + ConsoleVulnHttpClient, + MockConsoleVulnClient, + { + provide: CONSOLE_VULN_API, + useExisting: ConsoleVulnHttpClient, + }, + // Reachability API + ReachabilityClient, + MockReachabilityApi, + { + provide: REACHABILITY_API, + useExisting: ReachabilityClient, + }, + // Analytics API + AnalyticsHttpClient, + // Feed Mirror API (Concelier backend via gateway) + { + provide: FEED_MIRROR_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/concelier', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/concelier`; + } + }, + }, + FeedMirrorHttpClient, + { + provide: FEED_MIRROR_API, + useExisting: FeedMirrorHttpClient, + }, + // Attestation Chain API + AttestationChainHttpClient, + { + provide: ATTESTATION_CHAIN_API, + useExisting: AttestationChainHttpClient, + }, + // Console Search API + ConsoleSearchHttpClient, + { + provide: CONSOLE_SEARCH_API, + useExisting: ConsoleSearchHttpClient, + }, + // Policy Governance API + HttpPolicyGovernanceApi, + { + provide: POLICY_GOVERNANCE_API, + useExisting: HttpPolicyGovernanceApi, + }, + // Policy Simulation API + { + provide: POLICY_SIMULATION_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const policyBase = config.config.apiBaseUrls.policy; + return policyBase.endsWith('/') ? policyBase.slice(0, -1) : policyBase; + }, + }, + PolicySimulationHttpClient, + { + provide: POLICY_SIMULATION_API, + useExisting: PolicySimulationHttpClient, + }, + // Graph Platform API + { + provide: GRAPH_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/graph', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/graph`; + } + }, + }, + GraphPlatformHttpClient, + { + provide: GRAPH_PLATFORM_API, + useExisting: GraphPlatformHttpClient, + }, + // Policy Gates API (Policy Gateway backend) + { + provide: POLICY_GATES_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/policy', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/policy`; + } + }, + }, + PolicyGatesHttpClient, + { + provide: POLICY_GATES_API, + useExisting: PolicyGatesHttpClient, + }, + // Release API (Release JobEngine backend) + ReleaseHttpClient, + { + provide: RELEASE_API, + useExisting: ReleaseHttpClient, + }, + // Triage Evidence API + TriageEvidenceHttpClient, + { + provide: TRIAGE_EVIDENCE_API, + useExisting: TriageEvidenceHttpClient, + }, + // Verdict API + HttpVerdictClient, + { + provide: VERDICT_API, + useExisting: HttpVerdictClient, + }, + // Watchlist API + WatchlistHttpClient, + { + provide: WATCHLIST_API, + useExisting: WatchlistHttpClient, + }, + // Evidence API (Evidence Locker backend via gateway) + { + provide: EVIDENCE_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/evidence', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/evidence`; + } + }, + }, + EvidenceHttpClient, + { + provide: EVIDENCE_API, + useExisting: EvidenceHttpClient, + }, + // Proof APIs (Manifest, Bundle, Score Replay) + ManifestClient, + ProofBundleClient, + ScoreReplayClient, + { + provide: MANIFEST_API, + useExisting: ManifestClient, + }, + { + provide: PROOF_BUNDLE_API, + useExisting: ProofBundleClient, + }, + { + provide: SCORE_REPLAY_API, + useExisting: ScoreReplayClient, + }, + // SBOM Evidence API + SbomEvidenceService, + { + provide: SBOM_EVIDENCE_API, + useExisting: SbomEvidenceService, + }, + // Replay API + HttpReplayClient, + { + provide: REPLAY_API, + useExisting: HttpReplayClient, + }, + // Score API + HttpScoreClient, + { + provide: SCORE_API, + useExisting: HttpScoreClient, + }, + // Evidence-weighted scoring API + HttpScoringApi, + { + provide: SCORING_API, + useExisting: HttpScoringApi, + }, + // Risk dashboard and fix verification stores + HttpDeltaVerdictApi, + { + provide: DELTA_VERDICT_API, + useExisting: HttpDeltaVerdictApi, + }, + HttpRiskBudgetApi, + { + provide: RISK_BUDGET_API, + useExisting: HttpRiskBudgetApi, + }, + FixVerificationApiClient, + { + provide: FIX_VERIFICATION_API, + useExisting: FixVerificationApiClient, + }, + // AOC API (Attestor + Sources backend via gateway) + { + provide: AOC_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/attestor', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/attestor`; + } + }, + }, + { + provide: AOC_SOURCES_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/sources', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/sources`; + } + }, + }, + AocHttpClient, + { provide: AOC_API, useExisting: AocHttpClient }, + + // Identity Provider API (Platform backend via gateway) + { + provide: IDENTITY_PROVIDER_API_BASE_URL, + deps: [AppConfigService], + useFactory: (config: AppConfigService) => { + const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority; + try { + return new URL('/api/v1/platform/identity-providers', gatewayBase).toString(); + } catch { + const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase; + return `${normalized}/api/v1/platform/identity-providers`; + } + }, + }, + IdentityProviderApiHttpClient, + MockIdentityProviderClient, + { + provide: IDENTITY_PROVIDER_API, + useExisting: IdentityProviderApiHttpClient, + }, + + // Doctor background services — started from AppComponent to avoid + // NG0200 circular DI during APP_INITIALIZER (Router not yet ready). + DoctorTrendService, + DoctorNotificationService, + ], +}; diff --git a/src/Web/StellaOps.Web/src/app/app.routes.ts b/src/Web/StellaOps.Web/src/app/app.routes.ts index 52e66ad62..18fa268cc 100644 --- a/src/Web/StellaOps.Web/src/app/app.routes.ts +++ b/src/Web/StellaOps.Web/src/app/app.routes.ts @@ -122,6 +122,13 @@ export const routes: Routes = [ data: { breadcrumb: 'Mission Control' }, loadChildren: () => import('./routes/mission-control.routes').then((m) => m.MISSION_CONTROL_ROUTES), }, + { + path: 'environments', + title: 'Environments', + canMatch: [requireConfigGuard, requireBackendsReachableGuard, requireAuthGuard, requireOpsGuard], + data: { breadcrumb: 'Environments' }, + loadChildren: () => import('./routes/topology.routes').then((m) => m.TOPOLOGY_ROUTES), + }, { path: 'releases', title: 'Releases', @@ -329,7 +336,7 @@ export const routes: Routes = [ { path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' }, { path: 'setup/regions-environments', - redirectTo: preserveAppRedirect('/setup/topology/regions'), + redirectTo: preserveAppRedirect('/environments/regions'), pathMatch: 'full', }, { @@ -382,11 +389,11 @@ export const routes: Routes = [ redirectTo: preserveAppRedirect('/releases/promotions/:promotionId'), pathMatch: 'full', }, - { path: 'environments', redirectTo: preserveAppRedirect('/releases/environments'), pathMatch: 'full' }, - { path: 'regions', redirectTo: preserveAppRedirect('/releases/environments'), pathMatch: 'full' }, + { path: 'environments', redirectTo: preserveAppRedirect('/environments/regions'), pathMatch: 'full' }, + { path: 'regions', redirectTo: preserveAppRedirect('/environments/regions'), pathMatch: 'full' }, { path: 'setup', redirectTo: '/ops/platform-setup', pathMatch: 'full' }, - { path: 'setup/environments-paths', redirectTo: '/setup/topology/environments', pathMatch: 'full' }, - { path: 'setup/targets-agents', redirectTo: '/setup/topology/targets', pathMatch: 'full' }, + { path: 'setup/environments-paths', redirectTo: '/environments/regions', pathMatch: 'full' }, + { path: 'setup/targets-agents', redirectTo: '/environments/targets', pathMatch: 'full' }, { path: 'setup/workflows', redirectTo: '/ops/platform-setup', pathMatch: 'full' }, { path: 'setup/bundle-templates', redirectTo: '/releases/bundles', pathMatch: 'full' }, { path: 'governance', redirectTo: '/ops/policy', pathMatch: 'full' }, diff --git a/src/Web/StellaOps.Web/src/app/core/branding/branding.service.spec.ts b/src/Web/StellaOps.Web/src/app/core/branding/branding.service.spec.ts index eb50490ed..38b3cdd16 100644 --- a/src/Web/StellaOps.Web/src/app/core/branding/branding.service.spec.ts +++ b/src/Web/StellaOps.Web/src/app/core/branding/branding.service.spec.ts @@ -102,7 +102,7 @@ describe('BrandingService', () => { service.fetchBranding().subscribe((response) => { expect(response.branding.tenantId).toBe('default'); - expect(response.branding.title).toBe('Stella Ops Dashboard'); + expect(response.branding.title).toBe('Stella Ops'); expect(response.branding.themeTokens).toEqual({}); }); diff --git a/src/Web/StellaOps.Web/src/app/core/branding/branding.service.ts b/src/Web/StellaOps.Web/src/app/core/branding/branding.service.ts index 428a8af4e..bbae034d5 100644 --- a/src/Web/StellaOps.Web/src/app/core/branding/branding.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/branding/branding.service.ts @@ -79,7 +79,7 @@ export class BrandingService { // Default branding configuration private readonly defaultBranding: BrandingConfiguration = { tenantId: 'default', - title: 'Stella Ops Dashboard', + title: 'Stella Ops', themeTokens: {} }; diff --git a/src/Web/StellaOps.Web/src/app/core/i18n/date-format.service.ts b/src/Web/StellaOps.Web/src/app/core/i18n/date-format.service.ts new file mode 100644 index 000000000..261840f92 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/core/i18n/date-format.service.ts @@ -0,0 +1,111 @@ +/** + * Locale-aware date formatting service. + * + * Centralises all date/time formatting behind the user's chosen locale + * (stored in I18nService). Components should inject this service instead of + * hardcoding 'en-US' in Intl.DateTimeFormat / toLocaleString calls. + */ + +import { Injectable, computed, inject } from '@angular/core'; +import { I18nService } from './i18n.service'; + +@Injectable({ providedIn: 'root' }) +export class DateFormatService { + private readonly i18n = inject(I18nService); + + /** The current locale string (e.g. 'de-DE', 'en-US'). */ + readonly locale = computed(() => this.i18n.locale()); + + // ----------------------------------------------------------------------- + // Pre-built formatters (re-created when locale changes via computed()) + // ----------------------------------------------------------------------- + + /** Short date+time: "Jan 15, 2026, 3:45 PM" */ + readonly mediumFormatter = computed( + () => new Intl.DateTimeFormat(this.locale(), { + year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }), + ); + + /** Short date only: "Jan 15, 2026" */ + readonly shortDateFormatter = computed( + () => new Intl.DateTimeFormat(this.locale(), { + year: 'numeric', month: 'short', day: 'numeric', + }), + ); + + /** Month+day only: "Jan 15" */ + readonly monthDayFormatter = computed( + () => new Intl.DateTimeFormat(this.locale(), { + month: 'short', day: 'numeric', + }), + ); + + /** Time only: "3:45 PM" */ + readonly timeFormatter = computed( + () => new Intl.DateTimeFormat(this.locale(), { + hour: '2-digit', minute: '2-digit', + }), + ); + + // ----------------------------------------------------------------------- + // Convenience methods + // ----------------------------------------------------------------------- + + /** + * Format a date/ISO-string with arbitrary Intl.DateTimeFormat options, + * automatically using the user's locale. + */ + format(value: string | Date | number, options?: Intl.DateTimeFormatOptions): string { + try { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return new Intl.DateTimeFormat(this.locale(), options).format(date); + } catch { + return String(value); + } + } + + /** Locale-aware toLocaleString(). */ + toLocaleString(value: string | Date | number, options?: Intl.DateTimeFormatOptions): string { + try { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString(this.locale(), options); + } catch { + return String(value); + } + } + + /** Locale-aware toLocaleDateString(). */ + toLocaleDateString(value: string | Date | number, options?: Intl.DateTimeFormatOptions): string { + try { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleDateString(this.locale(), options); + } catch { + return String(value); + } + } + + /** Locale-aware toLocaleTimeString(). */ + toLocaleTimeString(value: string | Date | number, options?: Intl.DateTimeFormatOptions): string { + try { + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleTimeString(this.locale(), options); + } catch { + return String(value); + } + } + + /** Locale-aware number formatting. */ + formatNumber(value: number, options?: Intl.NumberFormatOptions): string { + try { + return new Intl.NumberFormat(this.locale(), options).format(value); + } catch { + return String(value); + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/core/i18n/index.ts b/src/Web/StellaOps.Web/src/app/core/i18n/index.ts index f9e8e9b73..f0097c2c7 100644 --- a/src/Web/StellaOps.Web/src/app/core/i18n/index.ts +++ b/src/Web/StellaOps.Web/src/app/core/i18n/index.ts @@ -14,3 +14,4 @@ export { export { TranslatePipe } from './translate.pipe'; export { LocaleCatalogService } from './locale-catalog.service'; export { UserLocalePreferenceService } from './user-locale-preference.service'; +export { DateFormatService } from './date-format.service'; diff --git a/src/Web/StellaOps.Web/src/app/core/services/offline-mode.service.ts b/src/Web/StellaOps.Web/src/app/core/services/offline-mode.service.ts index d0582c09f..26bfed939 100644 --- a/src/Web/StellaOps.Web/src/app/core/services/offline-mode.service.ts +++ b/src/Web/StellaOps.Web/src/app/core/services/offline-mode.service.ts @@ -9,6 +9,7 @@ import { BundleFreshnessInfo, OfflineManifest } from '../api/offline-kit.models'; +import { DateFormatService } from '../i18n/date-format.service'; const HEALTH_CHECK_INTERVAL_MS = 30000; // 30 seconds const HEALTH_CHECK_TIMEOUT_MS = 3000; // 3 seconds @@ -19,6 +20,7 @@ const MANIFEST_CACHE_KEY = 'stellaops_offline_manifest'; @Injectable({ providedIn: 'root' }) export class OfflineModeService implements OnDestroy { private readonly http = inject(HttpClient); + private readonly dateFmt = inject(DateFormatService); private healthCheckInterval: ReturnType | null = null; // Signals for reactive state management @@ -43,7 +45,7 @@ export class OfflineModeService implements OnDestroy { const freshness = this.bundleFreshness(); const dateStr = state.bundleCreatedAt - ? new Date(state.bundleCreatedAt).toLocaleDateString('en-US', { + ? this.dateFmt.toLocaleDateString(state.bundleCreatedAt, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : 'unknown'; diff --git a/src/Web/StellaOps.Web/src/app/features/admin-notifications/admin-notifications.component.ts b/src/Web/StellaOps.Web/src/app/features/admin-notifications/admin-notifications.component.ts index 63a132f30..6219e3a56 100644 --- a/src/Web/StellaOps.Web/src/app/features/admin-notifications/admin-notifications.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/admin-notifications/admin-notifications.component.ts @@ -30,13 +30,23 @@ import { LocalizationConfig, NotifyIncident, } from '../../core/api/notify.models'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type NotifyAdminTab = 'channels' | 'rules' | 'templates' | 'deliveries' | 'incidents' | 'config'; +const NOTIFY_ADMIN_TABS: StellaPageTab[] = [ + { id: 'channels', label: 'Channels', icon: 'M22 2L11 13|||M22 2l-7 20-4-9-9-4 20-7z' }, + { id: 'rules', label: 'Rules', icon: 'M22 3H2l8 9.46V19l4 2v-8.54L22 3z' }, + { id: 'templates', label: 'Templates', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'deliveries', label: 'Deliveries', icon: 'M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9|||M13.73 21a2 2 0 0 1-3.46 0' }, + { id: 'incidents', label: 'Incidents', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01' }, + { id: 'config', label: 'Config', icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' }, +]; + @Component({ selector: 'app-admin-notifications', standalone: true, - imports: [CommonModule, RouterModule], + imports: [CommonModule, RouterModule, StellaPageTabsComponent], template: `
- - -
- -
- @for (example of cliGuidance.examples; track example) { -
- {{ example }} - -
- } -
-
- - -
- [i] - Install CLI: npm install -g @stellaops/cli -
- - } - + + +
    + @for (v of result()!.violations.slice(0, 3); track v.documentId + v.violationCode) { +
  • + +
  • + } + @if (result()!.violations.length > 3) { +
  • + + {{ result()!.violations.length - 3 }} more violations +
  • + } +
+ + } @else { +
+ [+] + No violations found in the last {{ windowHours() }} hours +
+ } + + +
+ ID: {{ result()!.verificationId | slice:0:12 }} + Completed: {{ result()!.completedAt | date:'medium' }} +
+ + } + + + @if (showCliGuidance()) { +
+
CLI Parity
+

{{ cliGuidance.description }}

+ + +
+ +
+ {{ getCliCommand() }} + +
+
+ + +
+ + + + @for (flag of cliGuidance.flags; track flag.flag) { + + + + + } + +
{{ flag.flag }}{{ flag.description }}
+
+ + +
+ +
+ @for (example of cliGuidance.examples; track example) { +
+ {{ example }} + +
+ } +
+
+ + +
+ [i] + Install CLI: npm install -g @stellaops/cli +
+
+ } + diff --git a/src/Web/StellaOps.Web/src/app/features/aoc/verify-action.component.ts b/src/Web/StellaOps.Web/src/app/features/aoc/verify-action.component.ts index c5e917703..dd313fe7c 100644 --- a/src/Web/StellaOps.Web/src/app/features/aoc/verify-action.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/aoc/verify-action.component.ts @@ -9,6 +9,8 @@ import { signal, } from '@angular/core'; import { AocClient } from '../../core/api/aoc.client'; +import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component'; +import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component'; import { AocVerificationRequest, AocVerificationResult, @@ -27,7 +29,7 @@ export interface CliParityGuidance { @Component({ selector: 'app-verify-action', standalone: true, - imports: [CommonModule], + imports: [CommonModule, StellaMetricCardComponent, StellaMetricGridComponent], templateUrl: './verify-action.component.html', styleUrls: ['./verify-action.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.html b/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.html index fda5ffab4..a22ca99e7 100644 --- a/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.html +++ b/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.html @@ -1,139 +1,139 @@ -
- -
-
-
- {{ totalViolations() }} - Violations -
-
- {{ totalDocuments() }} - Documents -
-
- @if (severityCounts().critical > 0) { - {{ severityCounts().critical }} critical - } - @if (severityCounts().high > 0) { - {{ severityCounts().high }} high - } - @if (severityCounts().medium > 0) { - {{ severityCounts().medium }} medium - } - @if (severityCounts().low > 0) { - {{ severityCounts().low }} low - } -
-
- -
-
- - -
- -
-
- - - @if (viewMode() === 'by-violation') { -
- @for (group of filteredGroups(); track group.code) { -
- - - @if (expandedCode() === group.code) { -
- @if (group.remediation) { -
- Remediation: {{ group.remediation }} -
- } - - - - - - - - - - - - - - @for (v of group.violations; track v.documentId + v.field) { - - - - - - +
DocumentFieldExpectedActualProvenance
- - - @if (v.field) { - {{ v.field }} - } @else { - - - } - - @if (v.expected) { - {{ v.expected }} - } @else { - - - } - - @if (v.actual) { - {{ v.actual }} - } @else { - - - } - - @if (v.provenance) { -
- {{ getSourceTypeIcon(v.provenance.sourceType) }} - - {{ v.provenance.sourceId | slice:0:15 }} - - - {{ formatDigest(v.provenance.digest) }} - -
- } @else { - No provenance - } +
+ +
+
+
+ {{ totalViolations() }} + Violations +
+
+ {{ totalDocuments() }} + Documents +
+
+ @if (severityCounts().critical > 0) { + {{ severityCounts().critical }} critical + } + @if (severityCounts().high > 0) { + {{ severityCounts().high }} high + } + @if (severityCounts().medium > 0) { + {{ severityCounts().medium }} medium + } + @if (severityCounts().low > 0) { + {{ severityCounts().low }} low + } +
+
+ +
+
+ + +
+ +
+
+ + + @if (viewMode() === 'by-violation') { +
+ @for (group of filteredGroups(); track group.code) { +
+ + + @if (expandedCode() === group.code) { +
+ @if (group.remediation) { +
+ Remediation: {{ group.remediation }} +
+ } + + + + + + + + + + + + + + @for (v of group.violations; track v.documentId + v.field) { + + + + + + } - -
DocumentFieldExpectedActualProvenance
+ + + @if (v.field) { + {{ v.field }} + } @else { + - + } + + @if (v.expected) { + {{ v.expected }} + } @else { + - + } + + @if (v.actual) { + {{ v.actual }} + } @else { + - + } + + @if (v.provenance) { +
+ {{ getSourceTypeIcon(v.provenance.sourceType) }} + + {{ v.provenance.sourceId | slice:0:15 }} + + + {{ formatDigest(v.provenance.digest) }} + +
+ } @else { + No provenance + }
-
- } -
- } - - @if (filteredGroups().length === 0) { -
- @if (searchFilter()) { -

No violations match "{{ searchFilter() }}"

- } @else { -

No violations to display

- } -
- } -
- } - - - @if (viewMode() === 'by-document') { -
- @for (doc of filteredDocuments(); track doc.documentId) { -
- - - @if (expandedDocId() === doc.documentId) { -
- -
-

Provenance

-
-
-
Source
-
- {{ getSourceTypeIcon(doc.provenance.sourceType) }} - {{ doc.provenance.sourceId }} -
-
-
-
Digest
-
{{ doc.provenance.digest }}
-
-
-
Ingested
-
{{ formatDate(doc.provenance.ingestedAt) }}
-
- @if (doc.provenance.submitter) { -
-
Submitter
-
{{ doc.provenance.submitter }}
-
- } - @if (doc.provenance.sourceUrl) { -
-
Source URL
-
{{ doc.provenance.sourceUrl }}
-
- } -
-
- - -
-

Violations

-
    - @for (v of doc.violations; track v.violationCode + v.field) { -
  • -
    - {{ v.violationCode }} - @if (v.field) { - at - {{ v.field }} - } -
    - @if (v.expected || v.actual) { -
    -
    - Expected: - {{ v.expected || 'N/A' }} -
    -
    - Actual: - {{ v.actual || 'N/A' }} -
    -
    - } -
  • - } -
-
- - - @if (doc.rawContent) { -
-

- Document Fields - -

-
- @for (field of doc.highlightedFields; track field) { -
- {{ field }} - {{ getFieldValue(doc.rawContent, field) }} -
- } -
-
- } -
- } -
- } - - @if (filteredDocuments().length === 0) { -
- @if (searchFilter()) { -

No documents match "{{ searchFilter() }}"

- } @else { -

No documents to display

- } -
- } -
- } -
+
+
+ } +
+ } + + @if (filteredGroups().length === 0) { +
+ @if (searchFilter()) { +

No violations match "{{ searchFilter() }}"

+ } @else { +

No violations to display

+ } +
+ } +
+ } + + + @if (viewMode() === 'by-document') { +
+ @for (doc of filteredDocuments(); track doc.documentId) { +
+ + + @if (expandedDocId() === doc.documentId) { +
+ +
+

Provenance

+
+
+
Source
+
+ {{ getSourceTypeIcon(doc.provenance.sourceType) }} + {{ doc.provenance.sourceId }} +
+
+
+
Digest
+
{{ doc.provenance.digest }}
+
+
+
Ingested
+
{{ formatDate(doc.provenance.ingestedAt) }}
+
+ @if (doc.provenance.submitter) { +
+
Submitter
+
{{ doc.provenance.submitter }}
+
+ } + @if (doc.provenance.sourceUrl) { +
+
Source URL
+
{{ doc.provenance.sourceUrl }}
+
+ } +
+
+ + +
+

Violations

+
    + @for (v of doc.violations; track v.violationCode + v.field) { +
  • +
    + {{ v.violationCode }} + @if (v.field) { + at + {{ v.field }} + } +
    + @if (v.expected || v.actual) { +
    +
    + Expected: + {{ v.expected || 'N/A' }} +
    +
    + Actual: + {{ v.actual || 'N/A' }} +
    +
    + } +
  • + } +
+
+ + + @if (doc.rawContent) { +
+

+ Document Fields + +

+
+ @for (field of doc.highlightedFields; track field) { +
+ {{ field }} + {{ getFieldValue(doc.rawContent, field) }} +
+ } +
+
+ } +
+ } +
+ } + + @if (filteredDocuments().length === 0) { +
+ @if (searchFilter()) { +

No documents match "{{ searchFilter() }}"

+ } @else { +

No documents to display

+ } +
+ } +
+ } +
diff --git a/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.scss b/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.scss index b7267b6b4..c77fd0443 100644 --- a/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/aoc/violation-drilldown.component.scss @@ -267,7 +267,7 @@ .doc-link { background: none; border: none; - color: var(--color-brand-primary); + color: var(--color-text-link); font-family: var(--font-family-mono); font-size: var(--font-size-xs); cursor: pointer; @@ -431,7 +431,7 @@ .btn-link { background: none; border: none; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: var(--font-size-xs); cursor: pointer; padding: 0; diff --git a/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts index 69cca50c2..267258c57 100644 --- a/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail-page.component.ts @@ -5,6 +5,7 @@ import { ActivatedRoute, ParamMap, Router, RouterLink } from '@angular/router'; import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state'; import { OPERATIONS_PATHS, dataIntegrityPath } from '../platform/ops/operations-paths'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type GateResult = 'PASS' | 'WARN' | 'BLOCK'; type HealthStatus = 'OK' | 'WARN' | 'FAIL'; @@ -19,6 +20,17 @@ type ApprovalTabId = | 'replay' | 'history'; +const APPROVAL_DETAIL_TABS: StellaPageTab[] = [ + { id: 'overview', label: 'Overview', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' }, + { id: 'gates', label: 'Gates', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'security', label: 'Security', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01' }, + { id: 'reachability', label: 'Reachability', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, + { id: 'ops-data', label: 'Ops Data', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01' }, + { id: 'evidence', label: 'Evidence', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'replay', label: 'Replay', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' }, + { id: 'history', label: 'History', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' }, +]; + interface ApprovalDetailState { id: string; bundleVersion: string; @@ -92,215 +104,299 @@ interface HistoryEvent { @Component({ selector: 'app-approval-detail-page', standalone: true, - imports: [CommonModule, RouterLink, FormsModule], + imports: [CommonModule, RouterLink, FormsModule, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
+
+ +
+ + + Back to Queue + + + {{ approval().status | uppercase }} + +
+ +
- Back to Approvals - -
-

Approval Detail

- - {{ approval().status | uppercase }} - -
- -
+
- Bundle Version - {{ approval().bundleVersion }} -
-
- Manifest Digest - {{ approval().bundleDigest }} -
-
- Promotion Edge - {{ approval().sourceEnvironment }} -> {{ approval().targetEnvironment }} -
-
- Workflow - {{ approval().workflow }} -
-
- Requested By - {{ approval().requestedBy }} ({{ approval().requestedAt }}) +

{{ approval().bundleVersion }}

+

+ Approval {{ approval().id }} + | + {{ approval().workflow }} + | + Requested by {{ approval().requestedBy }} {{ approval().requestedAt }} +

-
- Gates PASS/BLOCK: {{ gatePassCount() }}/{{ gateBlockCount() }} - Approvals: {{ approval().approvals }} - CritR: {{ approval().critR }} - SBOM: {{ approval().sbomFreshness }} - Hybrid B/I/R: {{ approval().birCoverage }} - - Data Integrity {{ approval().dataIntegrity }} - + +
+
+ Manifest Digest + {{ approval().bundleDigest }} +
+
+ Promotion + + + {{ approval().sourceEnvironment }} + + {{ approval().targetEnvironment }} + + +
+ +
+
+ Gates + + {{ gatePassCount() }} pass + @if (gateBlockCount() > 0) { + / + {{ gateBlockCount() }} block + } + +
+
+
+ Approvals + {{ approval().approvals }} +
+
+
+ CritR + {{ approval().critR }} +
+
+
+ SBOM + {{ approval().sbomFreshness }} +
+
+
+ B/I/R + {{ approval().birCoverage }} +
+
+
+ Data Integrity + + {{ approval().dataIntegrity }} + +
+
+ + @if (approval().status === 'pending') { -
- - -

{{ decisionReasonLength() }}/{{ minDecisionReasonLength }} characters

+
+
+ + + + {{ decisionReasonLength() }}/{{ minDecisionReasonLength }} + +
+ +
+
+ + +
+
+ + + +
+
} - -
- - - - - -
- + + +
@if (activeTab() === 'overview') {

Overview

-

- Decision case-file for release approval {{ approval().id }} with governance, risk, - data confidence, and evidence context in one place. +

+ Decision case-file for release approval {{ approval().id }} with governance, risk, + data confidence, and evidence context.

-
    -
  • Blocking gates: {{ gateBlockCount() }}
  • -
  • Requested path: {{ approval().sourceEnvironment }} -> {{ approval().targetEnvironment }}
  • -
  • Exception requested: {{ requestException ? 'Yes' : 'No' }}
  • -
+
+
+ Blocking gates + {{ gateBlockCount() }} +
+
+ Promotion path + {{ approval().sourceEnvironment }} -> {{ approval().targetEnvironment }} +
+
+ Exception requested + {{ requestException ? 'Yes' : 'No' }} +
+
} @if (activeTab() === 'gates') {

Gates

-

Data snapshot: OSV 35m, NVD 3h 12m, Nightly SBOM rescan WARN

-

Decision digest: {{ decisionDigest }}

+
+ Data snapshot: OSV 35m, NVD 3h 12m, Nightly SBOM rescan WARN + Decision digest: {{ decisionDigest }} +
- - - - - - - - - - - @for (row of gateTraceRows; track row.id) { +
+
GateResultWhyActions
+ - - - - + + + + - @if (expandedGateId() === row.id) { - - + + @for (row of gateTraceRows; track row.id) { + + + + + + @if (expandedGateId() === row.id) { + + + + } } - } - -
{{ row.gate }} - - {{ row.result }} - - {{ row.why }} - - @if (row.result === 'BLOCK') { - - } - GateResultWhyActions
-

Inputs: {{ row.inputs.join(', ') }}

-

Timestamp: {{ row.timestamp }}

-

Evidence age: {{ row.evidenceAge }}

+
+ {{ row.gate }} + + + {{ row.result }} + + {{ row.why }} + + @if (row.result === 'BLOCK') { + + }
+
+
Inputs {{ row.inputs.join(', ') }}
+
Timestamp {{ row.timestamp }}
+
Evidence age {{ row.evidenceAge }}
+
+
+ + +
} @if (activeTab() === 'security') {

Security

-

- CritR {{ approval().critR }} | VEX coverage 83% | SBOM freshness {{ approval().sbomFreshness }} -

- -
-
-

By Environment

-
    -
  • Staging CritR: 1
  • -
  • Production CritR: 3
  • -
+
+
+ CritR + {{ approval().critR }}
-
-

Delta vs Deployed

-

+2 critical reachable introduced / -1 resolved.

+
+ VEX Coverage + 83% +
+
+ SBOM Freshness + {{ approval().sbomFreshness }}
- - - - - - - - - - - - @for (item of securityFindings; track item.cve) { +
+
+

By Environment

+
    +
  • Staging CritR: 1
  • +
  • Production CritR: 3
  • +
+
+
+

Delta vs Deployed

+

+2 critical reachable introduced / -1 resolved

+
+
+ +
+
CVEPackageComponentReachabilityVEX
+ - - - - - + + + + + - } - -
{{ item.cve }}{{ item.packageName }}{{ item.component }}{{ item.reachability }}{{ item.vex }}CVEPackageComponentReachabilityVEX
+ + + @for (item of securityFindings; track item.cve) { + + {{ item.cve }} + {{ item.packageName }} + {{ item.component }} + + + {{ item.reachability }} + + + {{ item.vex }} + + } + + +
} @@ -308,39 +404,62 @@ interface HistoryEvent { @if (activeTab() === 'reachability') {

Reachability

-

Coverage: Build 84% | Image 92% | Runtime 61%

-

Evidence age: Build 42m | Image 38m | Runtime 2h 11m

-

- Policy interpretation: Runtime coverage below 70% downgrades confidence and can +

+
+ Build + 84% + 42m ago +
+
+ Image + 92% + 38m ago +
+
+ Runtime + 61% + 2h 11m ago +
+
+

+ Runtime coverage below 70% downgrades confidence and can block strict production promotions.

- - - - - - - - - - - - @for (item of reachabilityRows; track item.component) { +
+
ComponentDigestBuildImageRuntime
+ - - - - - + + + + + - } - -
{{ item.component }}{{ item.digest }}{{ item.build ? 'Y' : 'N' }}{{ item.image ? 'Y' : 'N' }}{{ item.runtime ? 'Y' : 'N' }}ComponentDigestBuildImageRuntime
+ + + @for (item of reachabilityRows; track item.component) { + + {{ item.component }} + {{ item.digest }} + + {{ item.build ? 'Y' : 'N' }} + + + {{ item.image ? 'Y' : 'N' }} + + + {{ item.runtime ? 'Y' : 'N' }} + + + } + + +
} @@ -351,57 +470,61 @@ interface HistoryEvent {

Live data pending dedicated aggregation contract; using validated stub lens.

-
+

Feeds

@for (row of feedsRows; track row.name) { -

+

{{ row.status }} - {{ row.name }} - {{ row.detail }} -

+ {{ row.name }} + {{ row.detail }} +
}
-
+

Nightly Jobs

@for (row of jobsRows; track row.name) { -

+

{{ row.status }} - {{ row.name }} - {{ row.detail }} -

+ {{ row.name }} + {{ row.detail }} +
}
-
+

Integrations

@for (row of integrationRows; track row.name) { -

+

{{ row.status }} - {{ row.name }} - {{ row.detail }} -

+ {{ row.name }} + {{ row.detail }} +
}
-
+

DLQ

@for (row of dlqRows; track row.name) { -

+

{{ row.status }} - {{ row.name }} - {{ row.detail }} -

+ {{ row.name }} + {{ row.detail }} +
}
} @@ -409,19 +532,28 @@ interface HistoryEvent { @if (activeTab() === 'evidence') {

Evidence

-
    +
    @for (artifact of evidenceArtifacts; track artifact.name) { -
  • - {{ artifact.name }} - - {{ artifact.status === 'ready' ? 'ready' : 'pending seal' }} -
  • +
    + +
    + {{ artifact.name }} + {{ artifact.status === 'ready' ? 'Sealed' : 'Pending seal' }} +
    +
    } -
-

Signature status: DSSE signed, transparency log anchored, replay metadata present.

+ +

Signature status: DSSE signed, transparency log anchored, replay metadata present.

} @@ -430,24 +562,39 @@ interface HistoryEvent {

Replay/Verify

- - - - +
+ Verdict ID + +
+
+ Bundle Manifest + +
+
+ Baseline + +
+
+ Data Snapshot + +
- +

Recent Replays

-
    +
    @for (event of replayEvents; track event.id) { -
  • {{ event.requestedAt }} - {{ event.status }}
  • +
    + {{ event.requestedAt }} + {{ event.status }} +
    } @empty { -
  • No replay requests yet.
  • +

    No replay requests yet.

    } -
+
} @@ -455,16 +602,22 @@ interface HistoryEvent { @if (activeTab() === 'history') {

History

-
    +
    @for (item of historyEvents; track item.id) { -
  • - {{ item.when }} - {{ item.event }} -

    {{ item.detail }}

    -
  • +
    +
    +
    +
    + {{ item.event }} + +
    +

    {{ item.detail }}

    +
    +
    } @empty { -
  • No history events available.
  • +

    No history events available.

    } -
+
} @@ -472,46 +625,43 @@ interface HistoryEvent { `, styles: [ ` - .approval-detail-v2 { + :host { display: block; } + + .approval-detail { display: grid; - gap: 1rem; - max-width: 1280px; + gap: var(--space-4); + max-width: var(--container-page-xl, 1200px); margin: 0 auto; } - .decision-header, - .tab-panel { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - background: var(--color-surface-primary); - padding: 1rem; + /* ── Top bar ── */ + .top-bar { + display: flex; + align-items: center; + justify-content: space-between; } .back-link { - color: var(--color-brand-primary); - text-decoration: none; - font-size: 0.85rem; - } - - .decision-header__title-row { - display: flex; - justify-content: space-between; + display: inline-flex; align-items: center; - gap: 1rem; - margin-top: 0.5rem; + gap: var(--space-1); + color: var(--color-text-link); + text-decoration: none; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + transition: opacity var(--motion-duration-sm) var(--motion-ease-standard); } - h1, - h2, - h3 { - margin: 0; - } + .back-link:hover { opacity: 0.8; } + .back-link svg { width: 14px; height: 14px; } .approval-status { border-radius: var(--radius-full); - padding: 0.15rem 0.6rem; - font-size: 0.7rem; - font-weight: var(--font-weight-semibold); + padding: 2px var(--space-3); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + text-transform: uppercase; + letter-spacing: var(--letter-spacing-wide); } .approval-status--pending { @@ -529,42 +679,148 @@ interface HistoryEvent { color: var(--color-status-error-text); } - .decision-header__identity-grid { - margin-top: 0.75rem; + /* ── Decision header card ── */ + .decision-header { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + padding: var(--space-5); display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 0.75rem; + gap: var(--space-4); } - .decision-header__identity-grid .label { - display: block; - font-size: 0.7rem; + h1, h2, h3 { margin: 0; } + + h1 { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-heading); + } + + .decision-header__subtitle { + margin: var(--space-1) 0 0; + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } + + .separator { + display: inline-block; + margin: 0 var(--space-1); + color: var(--color-border-primary); + } + + /* Identity grid */ + .identity-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-3); + } + + .identity-cell { + display: flex; + flex-direction: column; + gap: var(--space-0-5); + } + + .identity-label { + font-size: var(--font-size-xs); text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: var(--letter-spacing-wider); + color: var(--color-text-muted); + font-weight: var(--font-weight-medium); + } + + .identity-value { + font-size: var(--font-size-base); + color: var(--color-text-primary); + font-weight: var(--font-weight-medium); + } + + .identity-value--mono { + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-normal); + } + + .flow-badge { + display: inline-flex; + align-items: center; + gap: var(--space-2); + } + + .flow-env { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); color: var(--color-text-secondary); } - .decision-header__identity-grid code { - font-size: 0.75rem; - } - - .readiness-bar { - margin-top: 0.75rem; - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - font-size: 0.82rem; - } - - .status-pill { - border-radius: var(--radius-full); - padding: 0.1rem 0.45rem; - font-size: 0.66rem; + .flow-env--target { + color: var(--color-text-primary); font-weight: var(--font-weight-semibold); } - .status-pill--pass, - .status-pill--ok { + .flow-arrow { + width: 20px; + height: 8px; + color: var(--color-brand-primary); + flex-shrink: 0; + } + + /* ── Readiness strip ── */ + .readiness-strip { + display: flex; + align-items: center; + gap: var(--space-3); + flex-wrap: wrap; + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-secondary); + } + + .metric { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; + } + + .metric__label { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: var(--letter-spacing-wide); + font-weight: var(--font-weight-medium); + } + + .metric__value { + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + } + + .metric--warn .metric__value { color: var(--color-status-warning-text); } + + .metric__pass { color: var(--color-status-success-text); } + .metric__sep { color: var(--color-text-muted); margin: 0 2px; } + .metric__block { color: var(--color-status-error-text); } + + .metric-divider { + width: 1px; + height: 28px; + background: var(--color-border-primary); + flex-shrink: 0; + } + + .status-pill { + display: inline-block; + border-radius: var(--radius-full); + padding: 1px var(--space-2); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + } + + .status-pill--pass, .status-pill--ok { background: var(--color-status-success-bg); color: var(--color-status-success-text); } @@ -574,214 +830,702 @@ interface HistoryEvent { color: var(--color-status-warning-text); } - .status-pill--block, - .status-pill--fail { + .status-pill--block, .status-pill--fail { background: var(--color-status-error-bg); color: var(--color-status-error-text); } - .decision-form { - margin-top: 0.8rem; + /* ── Decision area ── */ + .decision-area { display: grid; - gap: 0.35rem; + gap: var(--space-3); + padding-top: var(--space-3); + border-top: 1px solid var(--color-border-primary); } - .decision-form label { - font-size: 0.8rem; + .decision-form { + display: grid; + gap: var(--space-1); + } + + .decision-form__label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); } .decision-form textarea { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - background: var(--color-surface-primary); + background: var(--color-surface-secondary); color: var(--color-text-primary); - padding: 0.5rem; + padding: var(--space-2) var(--space-3); + font-family: inherit; + font-size: var(--font-size-base); + resize: vertical; + transition: border-color var(--motion-duration-sm) var(--motion-ease-standard), + box-shadow var(--motion-duration-sm) var(--motion-ease-standard); } - .decision-form p { - margin: 0; - font-size: 0.72rem; - color: var(--color-text-secondary); + .decision-form textarea:focus { + outline: none; + border-color: var(--color-brand-primary); + box-shadow: 0 0 0 2px var(--color-focus-ring, rgba(245, 166, 35, 0.3)); + } + + .decision-form__counter { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + text-align: right; + } + + .decision-form__counter--met { + color: var(--color-status-success-text); } .decision-actions { - margin-top: 0.8rem; display: flex; + align-items: center; + justify-content: space-between; flex-wrap: wrap; - gap: 0.5rem; + gap: var(--space-3); } - .decision-actions button, - .tab-panel button { - border: 1px solid var(--color-border-primary); + .decision-actions__primary { + display: flex; + gap: var(--space-2); + } + + .decision-actions__secondary { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; + } + + /* ── Buttons ── */ + .btn { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-2) var(--space-3); border-radius: var(--radius-md); - background: var(--color-surface-secondary); - color: var(--color-text-primary); - padding: 0.4rem 0.65rem; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + font-family: inherit; cursor: pointer; + transition: background var(--motion-duration-sm) var(--motion-ease-standard), + border-color var(--motion-duration-sm) var(--motion-ease-standard), + opacity var(--motion-duration-sm) var(--motion-ease-standard); } - .decision-actions button:disabled { - opacity: 0.55; + .btn svg { width: 14px; height: 14px; } + + .btn:disabled { + opacity: 0.45; cursor: not-allowed; } + .btn--approve { + background: var(--color-status-success-bg); + color: var(--color-status-success-text); + border: 1px solid var(--color-status-success-border); + } + + .btn--approve:not(:disabled):hover { + background: var(--color-status-success-text); + color: var(--color-surface-primary); + } + + .btn--reject { + background: var(--color-status-error-bg); + color: var(--color-status-error-text); + border: 1px solid var(--color-status-error-border, var(--color-status-error)); + } + + .btn--reject:not(:disabled):hover { + background: var(--color-status-error-text); + color: var(--color-surface-primary); + } + + .btn--primary { + background: var(--color-btn-primary-bg, var(--color-brand-primary)); + color: var(--color-btn-primary-text, var(--color-text-heading)); + border: 1px solid var(--color-btn-primary-border, var(--color-brand-primary)); + } + + .btn--primary:hover { + background: var(--color-btn-primary-hover-bg, var(--color-brand-primary-hover)); + } + + .btn--ghost { + background: transparent; + color: var(--color-text-secondary); + border: 1px solid var(--color-border-primary); + } + + .btn--ghost:hover { + background: var(--color-surface-secondary); + color: var(--color-text-primary); + } + + /* ── Tab navigation ── */ .tab-nav { display: flex; - flex-wrap: wrap; - gap: 0.45rem; + gap: 0; + border-bottom: 1px solid var(--color-border-primary); + overflow-x: auto; } .tab-nav__item { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - color: var(--color-text-secondary); - padding: 0.45rem 0.7rem; - font-size: 0.8rem; + padding: var(--space-2) var(--space-3); + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + font-family: inherit; cursor: pointer; + white-space: nowrap; + transition: color var(--motion-duration-sm) var(--motion-ease-standard), + border-color var(--motion-duration-sm) var(--motion-ease-standard); + } + + .tab-nav__item:hover { + color: var(--color-text-primary); } .tab-nav__item--active { - color: var(--color-brand-primary); - border-color: var(--color-brand-primary); - background: var(--color-brand-soft); + color: var(--color-text-link, var(--color-brand-primary)); + border-bottom-color: var(--color-brand-primary); + font-weight: var(--font-weight-semibold); } + /* ── Tab panel ── */ .tab-panel { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + padding: var(--space-5); display: grid; - gap: 0.75rem; + gap: var(--space-4); } - .tab-panel p, - .tab-panel li { - font-size: 0.84rem; + .tab-panel h2 { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + color: var(--color-text-heading); } - .muted { + .tab-panel h3 { + font-size: var(--font-size-base); + font-weight: var(--font-weight-semibold); + color: var(--color-text-heading); + } + + .tab-panel p, .tab-panel li { + font-size: var(--font-size-base); color: var(--color-text-secondary); + line-height: var(--line-height-base); } - table { - width: 100%; - border-collapse: collapse; + .panel-desc { margin: 0; } + + .muted { color: var(--color-text-muted); } + + /* ── Overview cards ── */ + .overview-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-3); } - th, - td { - text-align: left; - border-bottom: 1px solid var(--color-border-primary); - padding: 0.45rem; - vertical-align: top; - font-size: 0.8rem; - } - - th { - text-transform: uppercase; - letter-spacing: 0.04em; - font-size: 0.68rem; - color: var(--color-text-secondary); - } - - .trace-detail { + .overview-card { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); background: var(--color-surface-secondary); } - .fix-links { - margin-top: 0.35rem; - display: flex; - flex-wrap: wrap; - gap: 0.45rem; + .overview-card--warn { + border-color: var(--color-status-error-border, var(--color-status-error)); + background: var(--color-status-error-bg); } - .fix-links a, - .footer-links a, - .link-btn { - color: var(--color-brand-primary); - text-decoration: none; + .overview-card__label { + font-size: var(--font-size-xs); + text-transform: uppercase; + letter-spacing: var(--letter-spacing-wide); + color: var(--color-text-muted); + font-weight: var(--font-weight-medium); + } + + .overview-card__value { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-heading); + } + + .overview-card--warn .overview-card__value { + color: var(--color-status-error-text); + } + + .overview-card__value--sm { + font-size: var(--font-size-base); + } + + /* ── Table wrappers ── */ + .table-wrapper { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + overflow: hidden; + } + + .table-wrapper table { margin: 0; } + + .row--block { + background: var(--color-status-error-bg); + } + + .cell-gate-name { white-space: nowrap; } + .cell-why { color: var(--color-text-secondary); } + + .trace-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-1); background: none; border: none; padding: 0; + color: var(--color-text-link); + font-size: var(--font-size-sm); + font-family: inherit; cursor: pointer; } - .footer-links { - margin-top: 0.8rem; + .trace-toggle svg { + width: 12px; + height: 12px; + transition: transform var(--motion-duration-sm) var(--motion-ease-standard); + } + + .trace-toggle svg.trace-toggle--open { + transform: rotate(90deg); + } + + .trace-row td { padding: 0 !important; } + + .trace-detail { + background: var(--color-surface-secondary); + padding: var(--space-3) var(--space-4) !important; + } + + .trace-detail__grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-2); + font-size: var(--font-size-sm); + } + + .trace-label { + display: block; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: var(--letter-spacing-wide); + margin-bottom: 2px; + } + + .fix-links { + margin-top: var(--space-2); display: flex; flex-wrap: wrap; - gap: 0.8rem; + gap: var(--space-2); + } + + .fix-link { + color: var(--color-text-link); + text-decoration: none; + font-size: var(--font-size-sm); + } + + .fix-link:hover { text-decoration: underline; } + + /* ── Footer links ── */ + .footer-links { + display: flex; + flex-wrap: wrap; + gap: var(--space-3); + padding-top: var(--space-3); + border-top: 1px solid var(--color-border-primary); + } + + .footer-link { + display: inline-flex; + align-items: center; + gap: var(--space-1); + color: var(--color-text-link); + text-decoration: none; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + } + + .footer-link:hover { text-decoration: underline; } + + /* ── Security tab ── */ + .security-summary { + display: flex; + gap: var(--space-4); + } + + .security-metric { + display: flex; + flex-direction: column; + gap: 2px; + } + + .security-metric__label { + font-size: var(--font-size-xs); + text-transform: uppercase; + letter-spacing: var(--letter-spacing-wide); + color: var(--color-text-muted); + font-weight: var(--font-weight-medium); + } + + .security-metric__value { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-bold); + color: var(--color-text-heading); + } + + .security-metric__value--critical { + color: var(--color-severity-critical-text, #9A2818); } .two-col { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1rem; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-3); } + .info-card { + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-secondary); + } + + .info-card ul { + margin: var(--space-2) 0 0; + padding-left: var(--space-4); + } + + .info-card p { margin: var(--space-2) 0 0; } + + .delta--add { color: var(--color-severity-critical-text, #9A2818); font-weight: var(--font-weight-semibold); } + .delta--resolve { color: var(--color-status-success-text); font-weight: var(--font-weight-semibold); } + + .reach-badge { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + } + + .reach-badge--critical { + color: var(--color-severity-critical-text, #9A2818); + font-weight: var(--font-weight-semibold); + } + + /* ── Reachability coverage strip ── */ + .reach-coverage-strip { + display: flex; + gap: var(--space-3); + } + + .reach-cov-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: var(--space-3); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-secondary); + } + + .reach-cov-item--warn { + border-color: var(--color-status-warning-border, var(--color-status-warning)); + background: var(--color-status-warning-bg); + } + + .reach-cov-label { + font-size: var(--font-size-xs); + text-transform: uppercase; + letter-spacing: var(--letter-spacing-wide); + color: var(--color-text-muted); + font-weight: var(--font-weight-medium); + } + + .reach-cov-value { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-heading); + } + + .reach-cov-item--warn .reach-cov-value { + color: var(--color-status-warning-text); + } + + .reach-cov-age { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + } + + .policy-note { + padding: var(--space-2) var(--space-3); + border-left: 3px solid var(--color-status-warning-border, var(--color-status-warning)); + background: var(--color-status-warning-bg); + border-radius: 0 var(--radius-sm) var(--radius-sm) 0; + font-size: var(--font-size-sm); + color: var(--color-status-warning-text); + } + + .bool-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--radius-full); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + background: var(--color-status-error-bg); + color: var(--color-status-error-text); + } + + .bool-badge--yes { + background: var(--color-status-success-bg); + color: var(--color-status-success-text); + } + + /* ── Ops grid ── */ .ops-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.8rem; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-3); } - .ops-grid article { + .ops-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-secondary); - padding: 0.7rem; + padding: var(--space-3) var(--space-4); + display: grid; + gap: var(--space-2); } - .ops-grid p { - margin: 0.4rem 0 0; + .ops-row { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--font-size-sm); } + .ops-row__name { + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); + } + + .ops-row__detail { + color: var(--color-text-muted); + margin-left: auto; + } + + /* ── Evidence grid ── */ + .evidence-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-2); + } + + .evidence-item { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-secondary); + } + + .evidence-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + } + + .evidence-item--ready .evidence-icon { color: var(--color-status-success-text); } + .evidence-item--pending .evidence-icon { color: var(--color-status-warning-text); } + + .evidence-name { + display: block; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + font-family: var(--font-family-mono); + color: var(--color-text-primary); + } + + .evidence-status { + display: block; + font-size: var(--font-size-xs); + color: var(--color-text-muted); + } + + /* ── Replay ── */ .replay-form { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.7rem; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-3); } - .replay-form label { - display: grid; - gap: 0.25rem; - font-size: 0.75rem; - color: var(--color-text-secondary); + .replay-field { + display: flex; + flex-direction: column; + gap: var(--space-1); } - .replay-form input { + .replay-field__label { + font-size: var(--font-size-xs); + text-transform: uppercase; + letter-spacing: var(--letter-spacing-wide); + color: var(--color-text-muted); + font-weight: var(--font-weight-medium); + } + + .replay-field input { border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); - background: var(--color-surface-primary); + background: var(--color-surface-secondary); color: var(--color-text-primary); - padding: 0.35rem 0.45rem; + padding: var(--space-2); + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); } - .history-list { - list-style: none; - margin: 0; - padding: 0; + .replay-list { display: grid; - gap: 0.6rem; + gap: var(--space-2); } - .history-list li { + .replay-event { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-3); border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - padding: 0.55rem; background: var(--color-surface-secondary); } - .history-list p { - margin: 0.2rem 0 0; - color: var(--color-text-secondary); + .replay-event__time { + font-family: var(--font-family-mono); + font-size: var(--font-size-sm); + color: var(--color-text-muted); } + /* ── History timeline ── */ + .history-timeline { + display: grid; + gap: 0; + padding-left: var(--space-4); + border-left: 2px solid var(--color-border-primary); + } + + .history-event { + display: flex; + gap: var(--space-3); + padding: var(--space-3) 0; + position: relative; + } + + .history-event__dot { + position: absolute; + left: calc(-1 * var(--space-4) - 5px); + top: var(--space-4); + width: 8px; + height: 8px; + border-radius: var(--radius-full); + background: var(--color-brand-primary); + border: 2px solid var(--color-surface-primary); + } + + .history-event__content { + flex: 1; + } + + .history-event__header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--space-2); + } + + .history-event__time { + font-family: var(--font-family-mono); + font-size: var(--font-size-xs); + color: var(--color-text-muted); + white-space: nowrap; + } + + .history-event__content p { + margin: var(--space-1) 0 0; + } + + /* ── Gate metadata ── */ + .gate-meta { + display: flex; + flex-direction: column; + gap: var(--space-1); + font-size: var(--font-size-sm); + color: var(--color-text-muted); + } + + .gate-meta code { + font-size: var(--font-size-xs); + } + + /* ── Responsive ── */ @media (max-width: 900px) { + .identity-grid, .two-col, .ops-grid, - .replay-form { + .replay-form, + .evidence-grid, + .overview-cards { grid-template-columns: 1fr; } + + .readiness-strip { + gap: var(--space-2); + } + + .metric-divider { display: none; } + + .trace-detail__grid { + grid-template-columns: 1fr; + } + + .decision-actions { + flex-direction: column; + align-items: flex-start; + } + + .reach-coverage-strip { + flex-direction: column; + } } `, ], @@ -789,11 +1533,13 @@ interface HistoryEvent { export class ApprovalDetailPageComponent implements OnInit { protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS; protected readonly dataIntegrityPath = dataIntegrityPath; + readonly approvalDetailTabs = APPROVAL_DETAIL_TABS; private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); protected readonly integrationsRoute = '/setup/integrations'; protected readonly topologyEnvironmentsRoute = '/setup/topology/environments'; + readonly APPROVAL_DETAIL_TABS = APPROVAL_DETAIL_TABS; readonly minDecisionReasonLength = 10; readonly activeTab = signal('overview'); readonly expandedGateId = signal(null); diff --git a/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail.component.ts index 6307fc7e9..e8649d546 100644 --- a/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/approvals/approval-detail.component.ts @@ -34,7 +34,7 @@ import { ActivatedRoute, RouterLink } from '@angular/router'; .back-link { display: inline-block; margin-bottom: 1rem; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; &:hover { diff --git a/src/Web/StellaOps.Web/src/app/features/approvals/approvals-inbox-page.component.ts b/src/Web/StellaOps.Web/src/app/features/approvals/approvals-inbox-page.component.ts index 3a26406ec..593bc9933 100644 --- a/src/Web/StellaOps.Web/src/app/features/approvals/approvals-inbox-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/approvals/approvals-inbox-page.component.ts @@ -98,22 +98,22 @@ interface ApprovalRequest { styles: [` .approvals-page { max-width: 1000px; margin: 0 auto; } - .page-header { margin-bottom: 1.5rem; } + .page-header { margin-bottom: 0.75rem; } .page-title { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: var(--font-weight-semibold); } .page-subtitle { margin: 0; color: var(--color-text-secondary); } .filter-row { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; align-items: center; - gap: 0.75rem; - margin-bottom: 1rem; - padding: 0.5rem 0; + gap: 0.5rem; + margin-bottom: 0.75rem; + padding: 0; } .status-chips { - display: flex; + display: inline-flex; gap: 0.375rem; - flex-wrap: wrap; + flex-wrap: nowrap; } .chip { display: inline-flex; @@ -129,7 +129,7 @@ interface ApprovalRequest { } .chip:hover { background: var(--color-nav-hover); } .chip--active { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border-color: var(--color-brand-primary); color: #fff; } diff --git a/src/Web/StellaOps.Web/src/app/features/approvals/approvals-inbox.component.ts b/src/Web/StellaOps.Web/src/app/features/approvals/approvals-inbox.component.ts index fa308ffbf..f9908d36e 100644 --- a/src/Web/StellaOps.Web/src/app/features/approvals/approvals-inbox.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/approvals/approvals-inbox.component.ts @@ -1,246 +1,541 @@ -import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; -import { FormsModule } from '@angular/forms'; -import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { catchError, of } from 'rxjs'; import { APPROVAL_API } from '../../core/api/approval.client'; import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.models'; +import { FilterBarComponent, type FilterOption, type ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type QueueTab = 'pending' | 'approved' | 'rejected' | 'expiring' | 'my-team'; +const QUEUE_TABS: StellaPageTab[] = [ + { id: 'pending', label: 'Pending', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' }, + { id: 'approved', label: 'Approved', icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M22 4L12 14.01l-3-3' }, + { id: 'rejected', label: 'Rejected', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M15 9l-6 6|||M9 9l6 6' }, + { id: 'expiring', label: 'Expiring', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01' }, + { id: 'my-team', label: 'My Team', icon: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|||M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|||M20 8v6|||M23 11h-6' }, +]; + @Component({ selector: 'app-approvals-inbox', standalone: true, - imports: [RouterLink, FormsModule], + imports: [RouterModule, FilterBarComponent, StellaPageTabsComponent], template: ` -
-
-

Release Run Approvals Queue

-

Run-centric approval queue with gate/env/hotfix/risk filtering.

+
+
+
+

Approvals Queue

+

Review and act on release promotion requests across environments

+
- + + -
- + - - - - - -
- - @if (loading()) { } - @if (error()) { } - - @if (!loading()) { - - - - - - - - - - - - - - - @for (approval of filtered(); track approval.id) { - - - - - - - - - - - } @empty { - - } - -
ReleaseFlowGate TypeRiskStatusRequesterExpiresActions
{{ approval.releaseName }} {{ approval.releaseVersion }}{{ approval.sourceEnvironment }} ? {{ approval.targetEnvironment }}{{ deriveGateType(approval) }}{{ approval.urgency }}{{ approval.status }}{{ approval.requestedBy }}{{ timeRemaining(approval.expiresAt) }}Open
No approvals match the active queue filters.
+ @if (error()) { +
+ + {{ error() }} + +
} -
+ + @if (loading() && filtered().length === 0) { +
+
+ Loading approvals... +
+ } @else if (filtered().length === 0 && !error()) { +
+
+ + + + +
+

No approvals found

+

+ @if (hasActiveFilters()) { + No approvals match the current filters. Try broadening your search or clearing filters. + } @else { + There are no {{ activeTab() }} approval requests at this time. + } +

+ @if (hasActiveFilters()) { +
+ +
+ } +
+ } @else { +
+ + + + + + + + + + + + + + + @for (approval of filtered(); track approval.id) { + + + + + + + + + + + } + +
ReleasePromotionGateRiskStatusRequesterExpires
+ + {{ approval.releaseName }} + +
{{ approval.releaseVersion }}
+
+
+ {{ approval.sourceEnvironment }} + + {{ approval.targetEnvironment }} +
+
+ + {{ deriveGateType(approval) }} + + + + {{ approval.urgency }} + + + + {{ approval.status }} + + +
{{ approval.requestedBy }}
+
+ {{ timeRemaining(approval.expiresAt) }} + + + + +
+
+ } + `, styles: [` - .approvals { + /* ─── Page layout ─── */ + .approval-list { display: grid; - gap: 1rem; - max-width: 1400px; + gap: 0.5rem; + max-width: 1600px; + margin: 0 auto; } - .approvals header h1 { - margin: 0; - font-size: 1.5rem; - font-weight: var(--font-weight-semibold, 600); - } - - .approvals header p { - margin: 0.25rem 0 0; - color: var(--color-text-secondary); - font-size: 0.875rem; - } - - /* Tabs */ - .tabs { + /* ─── Header ─── */ + .list-header { display: flex; - gap: 0.25rem; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; flex-wrap: wrap; - border-bottom: 1px solid var(--color-border-primary); - padding-bottom: 0; } - .tabs a { - padding: 0.5rem 1rem; - font-size: 0.8125rem; - color: var(--color-tab-inactive-text, var(--color-text-secondary)); - text-decoration: none; - border: none; - border-bottom: 2px solid transparent; - border-radius: 0; - transition: color 150ms ease, border-color 150ms ease; - font-weight: 500; + .list-header h1 { + margin: 0; + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-tight, 1.25); } - .tabs a:hover { - color: var(--color-text-primary); + .subtitle { + margin: 0.2rem 0 0; + color: var(--color-text-secondary); + font-size: var(--font-size-sm, 0.75rem); } - .tabs a.active { - color: var(--color-tab-active-text, var(--color-text-primary)); - border-bottom: 2px solid var(--color-tab-active-border, var(--color-brand-primary)); - font-weight: 600; - } - - /* Filters */ - .filters { + /* ─── Status / Error banner ─── */ + .status-banner { + border: 1px solid var(--color-status-error-border); + border-radius: var(--radius-md); + background: var(--color-status-error-bg); + padding: 0.6rem 0.8rem; + font-size: var(--font-size-sm, 0.75rem); display: flex; gap: 0.5rem; - flex-wrap: wrap; align-items: center; } - .filters select { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - color: var(--color-text-primary); - padding: 0.375rem 0.75rem; - font-size: 0.8125rem; - transition: border-color 150ms ease, box-shadow 150ms ease; + .status-banner.error { + color: var(--color-status-error-text); } - .filters select:focus { - outline: none; - border-color: var(--color-brand-primary); - box-shadow: 0 0 0 2px var(--color-focus-ring); + .status-banner__icon { + flex-shrink: 0; } - /* Banners */ - .banner { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: linear-gradient(90deg, var(--color-surface-secondary) 25%, var(--color-surface-primary) 50%, var(--color-surface-secondary) 75%); - background-size: 200% 100%; - animation: shimmer 1.5s infinite; - padding: 0.75rem 1rem; - font-size: 0.8125rem; + .status-banner span { + flex: 1; + } + + .status-banner__retry { + flex-shrink: 0; + padding: 0.25rem 0.6rem; + border: 1px solid var(--color-status-error-border); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-status-error-text); + font-size: var(--font-size-xs, 0.6875rem); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: background var(--motion-duration-sm, 140ms) ease; + } + + .status-banner__retry:hover { + background: color-mix(in srgb, var(--color-status-error-text) 10%, transparent); + } + + /* ─── Loading state ─── */ + .loading-state { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + padding: 3rem 1rem; + font-size: var(--font-size-sm, 0.75rem); color: var(--color-text-secondary); } - @keyframes shimmer { - 0% { background-position: 200% 0; } - 100% { background-position: -200% 0; } + .loading-state__spinner { + width: 18px; + height: 18px; + border: 2px solid var(--color-border-primary); + border-top-color: var(--color-brand-primary); + border-radius: 50%; + animation: spin 0.7s linear infinite; } - .banner.error { - color: var(--color-status-error-text); - background: var(--color-surface-primary); - animation: none; - border-color: var(--color-status-error); + @keyframes spin { + to { transform: rotate(360deg); } } - /* Table */ - table { - width: 100%; - border-collapse: collapse; + /* ─── Empty state ─── */ + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 3.5rem 1.5rem 4rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); background: var(--color-surface-primary); - overflow: hidden; } - th, td { - border-bottom: 1px solid var(--color-border-primary); - padding: 0.5rem 0.75rem; - font-size: 0.8125rem; - text-align: left; - vertical-align: top; + .empty-state__icon { + display: flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + border-radius: var(--radius-xl); + background: var(--color-brand-primary-10, var(--color-surface-secondary)); + color: var(--color-brand-primary); + margin-bottom: 1.25rem; } - th { - font-size: 0.6875rem; - font-weight: var(--font-weight-semibold, 600); + .empty-state__title { + margin: 0 0 0.4rem; + font-size: var(--font-size-md, 1rem); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + } + + .empty-state__desc { + margin: 0 0 1.5rem; + max-width: 420px; + font-size: var(--font-size-sm, 0.75rem); color: var(--color-text-secondary); + line-height: var(--line-height-relaxed, 1.625); + } + + .empty-state__actions { + display: flex; + gap: 0.5rem; + align-items: center; + } + + .btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + border-radius: var(--radius-md); + font-size: var(--font-size-sm, 0.75rem); + font-weight: var(--font-weight-medium); + cursor: pointer; + padding: 0.4rem 0.75rem; + white-space: nowrap; + transition: background var(--motion-duration-sm, 140ms) ease, + border-color var(--motion-duration-sm, 140ms) ease; + border: 1px solid var(--color-btn-secondary-border); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); + } + + .btn-secondary:hover { + background: var(--color-btn-secondary-hover-bg); + border-color: var(--color-btn-secondary-hover-border); + } + + /* ─── Table ─── */ + .table-container { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--color-surface-primary); + } + + .approval-table { + width: 100%; + border-collapse: collapse; + } + + .approval-table th, + .approval-table td { + text-align: left; + padding: 0.5rem 0.6rem; + vertical-align: top; + font-size: var(--font-size-sm, 0.75rem); + } + + .approval-table thead { + border-bottom: 1px solid var(--color-border-primary); + } + + .approval-table th { + font-size: var(--font-size-xs, 0.6875rem); + font-weight: var(--font-weight-medium); text-transform: uppercase; letter-spacing: 0.04em; + color: var(--color-text-secondary); background: var(--color-surface-secondary); - position: sticky; - top: 0; - z-index: 1; + padding: 0.45rem 0.6rem; + white-space: nowrap; } - tbody tr:nth-child(even) { - background: var(--color-surface-secondary); + .approval-table tbody tr { + border-bottom: 1px solid var(--color-border-primary); + transition: background var(--motion-duration-sm, 140ms) ease; } - tbody tr:hover { - background: var(--color-nav-hover); - } - - tr:last-child td { + .approval-table tbody tr:last-child { border-bottom: none; } - td a { - color: var(--color-brand-primary); - text-decoration: none; - font-weight: var(--font-weight-medium, 500); + .approval-table tbody tr:hover { + background: var(--color-surface-secondary); } - td a:hover { + .approval-table tbody tr.expiring-soon { + background: var(--color-status-warning-bg); + } + + .approval-table tbody tr.expiring-soon:hover { + background: color-mix(in srgb, var(--color-status-warning-bg) 80%, var(--color-surface-secondary)); + } + + .approval-table tbody tr.expired { + background: var(--color-status-error-bg); + opacity: 0.7; + } + + /* Column sizing */ + .col-identity { min-width: 160px; } + .col-flow { min-width: 140px; } + .col-gate { min-width: 80px; } + .col-risk { min-width: 80px; } + .col-status { min-width: 80px; } + .col-actor { min-width: 100px; } + .col-expires { min-width: 80px; } + .col-actions { width: 40px; text-align: center; vertical-align: middle; } + + .identity-link { + color: var(--color-text-link); + text-decoration: none; + font-size: var(--font-size-sm, 0.75rem); + line-height: 1.3; + } + + .identity-link:hover { text-decoration: underline; } + + .meta { + margin-top: 0.15rem; + color: var(--color-text-muted); + font-size: var(--font-size-xs, 0.6875rem); + font-family: var(--font-family-mono, ui-monospace, monospace); + } + + /* Flow badge */ + .flow-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + } + + .flow-env { + font-size: var(--font-size-xs, 0.6875rem); + color: var(--color-text-secondary); + } + + .flow-env--target { + color: var(--color-text-primary); + font-weight: var(--font-weight-medium); + } + + .flow-arrow { + color: var(--color-brand-primary); + flex-shrink: 0; + } + + /* Gate chip (matches versions page pattern) */ + .gate-chip { + display: inline-flex; + align-items: center; + border-radius: var(--radius-full); + padding: 0.06rem 0.45rem; + font-size: var(--font-size-xs, 0.6875rem); + font-weight: var(--font-weight-semibold); + text-transform: capitalize; + letter-spacing: 0.04em; + line-height: 1.4; + border: 1px solid var(--color-border-primary); + } + + .gate-chip--policy { + color: var(--color-status-info-text); + border-color: var(--color-status-info-border, var(--color-status-info-text)); + background: var(--color-status-info-bg); + } + + .gate-chip--security, + .gate-chip--critical { + color: var(--color-status-error-text); + border-color: var(--color-status-error-border); + background: var(--color-status-error-bg); + } + + .gate-chip--ops, + .gate-chip--warn, + .gate-chip--pending, + .gate-chip--high { + color: var(--color-status-warning-text); + border-color: var(--color-status-warning-border); + background: var(--color-status-warning-bg); + } + + .gate-chip--pass, + .gate-chip--approved, + .gate-chip--low, + .gate-chip--normal { + color: var(--color-status-success-text); + border-color: var(--color-status-success-border); + background: var(--color-status-success-bg); + } + + .gate-chip--rejected, + .gate-chip--block { + color: var(--color-status-error-text); + border-color: var(--color-status-error-border); + background: var(--color-status-error-bg); + } + + .gate-chip--expired { + color: var(--color-text-muted); + border-color: var(--color-border-primary); + background: var(--color-surface-secondary); + } + + /* Expires column */ + .col-expires { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: var(--font-size-xs, 0.6875rem); + } + + .col-expires--urgent { + color: var(--color-severity-high-text, #A04808); + font-weight: var(--font-weight-semibold); + } + + .col-expires--expired { + color: var(--color-status-error-text); + font-weight: var(--font-weight-semibold); + } + + /* Row action (chevron - matches versions page) */ + .row-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + text-decoration: none; + transition: color var(--motion-duration-sm, 140ms) ease, + background var(--motion-duration-sm, 140ms) ease; + } + + .row-action:hover { + color: var(--color-text-link); + background: var(--color-surface-tertiary); + } + + /* ─── Responsive ─── */ + @media (max-width: 920px) { + .table-container { + overflow-x: auto; + } + + .list-header { + flex-direction: column; + gap: 0.75rem; + } + + .queue-tabs { + overflow-x: auto; + flex-wrap: nowrap; + } + } `], changeDetection: ChangeDetectionStrategy.OnPush, }) @@ -255,23 +550,31 @@ export class ApprovalsInboxComponent { readonly filtered = signal([]); readonly activeTab = signal('pending'); - readonly tabs: Array<{ id: QueueTab; label: string }> = [ - { id: 'pending', label: 'Pending' }, - { id: 'approved', label: 'Approved' }, - { id: 'rejected', label: 'Rejected' }, - { id: 'expiring', label: 'Expiring' }, - { id: 'my-team', label: 'My Team' }, + + readonly pageTabs = computed(() => + QUEUE_TABS.map(tab => ({ ...tab, badge: this.tabCount(tab.id as QueueTab) })) + ); + + // Shared filter bar integration + readonly filterOptions: FilterOption[] = [ + { key: 'gateType', label: 'Gate Type', options: [{ value: 'policy', label: 'Policy' }, { value: 'ops', label: 'Ops' }, { value: 'security', label: 'Security' }] }, + { key: 'environment', label: 'Environment', options: [{ value: 'dev', label: 'Dev' }, { value: 'qa', label: 'QA' }, { value: 'staging', label: 'Staging' }, { value: 'prod', label: 'Prod' }] }, + { key: 'hotfix', label: 'Hotfix', options: [{ value: 'true', label: 'Hotfix Only' }, { value: 'false', label: 'Non-hotfix' }] }, + { key: 'risk', label: 'Risk', options: [{ value: 'critical', label: 'Critical' }, { value: 'high', label: 'High' }, { value: 'normal', label: 'Normal/Low' }] }, ]; + readonly activeFilterPills = signal([]); + gateTypeFilter = 'all'; envFilter = 'all'; hotfixFilter = 'all'; riskFilter = 'all'; + searchTerm = ''; constructor() { this.route.queryParamMap.subscribe((params) => { const tab = (params.get('tab') ?? 'pending') as QueueTab; - if (this.tabs.some((item) => item.id === tab)) { + if (QUEUE_TABS.some((item) => item.id === tab)) { this.activeTab.set(tab); } else { this.activeTab.set('pending'); @@ -281,6 +584,89 @@ export class ApprovalsInboxComponent { }); } + tabCount(tabId: QueueTab): number { + const all = this.approvals(); + const now = Date.now(); + switch (tabId) { + case 'pending': + return all.filter((a) => a.status === 'pending').length; + case 'approved': + return all.filter((a) => a.status === 'approved').length; + case 'rejected': + return all.filter((a) => a.status === 'rejected').length; + case 'expiring': + return all.filter((a) => a.status === 'pending' && (new Date(a.expiresAt).getTime() - now) <= 24 * 60 * 60 * 1000).length; + case 'my-team': + return all.filter((a) => a.status === 'pending' && a.requestedBy.toLowerCase().includes('team')).length; + default: + return 0; + } + } + + switchTab(tabId: QueueTab): void { + void this.router.navigate([], { queryParams: { tab: tabId }, queryParamsHandling: 'merge' }); + } + + onSearch(value: string): void { + this.searchTerm = value; + this.applyFilters(); + } + + onFilterChanged(filter: ActiveFilter): void { + const map: Record = { + gateType: 'gateTypeFilter', + environment: 'envFilter', + hotfix: 'hotfixFilter', + risk: 'riskFilter', + }; + const prop = map[filter.key]; + if (prop) { + (this as any)[prop] = filter.value; + } + this.applyFilters(); + } + + onFilterRemoved(filter: ActiveFilter): void { + const map: Record = { + gateType: 'gateTypeFilter', + environment: 'envFilter', + hotfix: 'hotfixFilter', + risk: 'riskFilter', + }; + const prop = map[filter.key]; + if (prop) { + (this as any)[prop] = 'all'; + } + this.applyFilters(); + } + + clearAllFilters(): void { + this.gateTypeFilter = 'all'; + this.envFilter = 'all'; + this.hotfixFilter = 'all'; + this.riskFilter = 'all'; + this.searchTerm = ''; + this.activeFilterPills.set([]); + this.applyFilters(); + } + + hasActiveFilters(): boolean { + return this.activeFilterPills().length > 0 || this.searchTerm.trim().length > 0; + } + + reload(): void { + this.load(); + } + + isExpiringSoon(expiresAt: string): boolean { + const ms = new Date(expiresAt).getTime() - Date.now(); + return ms > 0 && ms <= 4 * 60 * 60 * 1000; + } + + isExpired(expiresAt: string): boolean { + return new Date(expiresAt).getTime() - Date.now() <= 0; + } + deriveGateType(approval: ApprovalRequest): 'policy' | 'ops' | 'security' { const releaseName = approval.releaseName.toLowerCase(); if (!approval.gatesPassed || releaseName.includes('policy')) { @@ -292,6 +678,26 @@ export class ApprovalsInboxComponent { return 'ops'; } + urgencyToGate(urgency: string): string { + switch (urgency) { + case 'critical': return 'critical'; + case 'high': return 'high'; + case 'normal': return 'normal'; + case 'low': return 'low'; + default: return 'normal'; + } + } + + statusToGate(status: string): string { + switch (status) { + case 'pending': return 'pending'; + case 'approved': return 'approved'; + case 'rejected': return 'rejected'; + case 'expired': return 'expired'; + default: return 'pending'; + } + } + applyFilters(): void { const tab = this.activeTab(); const now = Date.now(); @@ -305,6 +711,16 @@ export class ApprovalsInboxComponent { rows = rows.filter((item) => item.status === tab); } + if (this.searchTerm.trim()) { + const q = this.searchTerm.trim().toLowerCase(); + rows = rows.filter((item) => + item.releaseName.toLowerCase().includes(q) || + item.requestedBy.toLowerCase().includes(q) || + item.targetEnvironment.toLowerCase().includes(q) || + item.sourceEnvironment.toLowerCase().includes(q) + ); + } + if (this.gateTypeFilter !== 'all') { rows = rows.filter((item) => this.deriveGateType(item) === this.gateTypeFilter); } @@ -324,6 +740,7 @@ export class ApprovalsInboxComponent { } this.filtered.set(rows.sort((a, b) => new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime())); + this.rebuildFilterPills(); } timeRemaining(expiresAt: string): string { @@ -337,6 +754,24 @@ export class ApprovalsInboxComponent { return `${hours}h ${minutes}m`; } + private rebuildFilterPills(): void { + const pills: ActiveFilter[] = []; + const defs: { key: string; prop: string; label: string }[] = [ + { key: 'gateType', prop: 'gateTypeFilter', label: 'Gate' }, + { key: 'environment', prop: 'envFilter', label: 'Env' }, + { key: 'hotfix', prop: 'hotfixFilter', label: 'Hotfix' }, + { key: 'risk', prop: 'riskFilter', label: 'Risk' }, + ]; + for (const def of defs) { + const val = (this as any)[def.prop] as string; + if (val !== 'all') { + const opt = this.filterOptions.find(f => f.key === def.key)?.options.find(o => o.value === val); + pills.push({ key: def.key, value: val, label: def.label + ': ' + (opt?.label || val) }); + } + } + this.activeFilterPills.set(pills); + } + private load(): void { this.loading.set(true); this.error.set(null); @@ -359,4 +794,3 @@ export class ApprovalsInboxComponent { }); } } - diff --git a/src/Web/StellaOps.Web/src/app/features/approvals/modals/request-exception-modal.component.ts b/src/Web/StellaOps.Web/src/app/features/approvals/modals/request-exception-modal.component.ts index ab33da393..51143b5ad 100644 --- a/src/Web/StellaOps.Web/src/app/features/approvals/modals/request-exception-modal.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/approvals/modals/request-exception-modal.component.ts @@ -639,18 +639,18 @@ export interface GateContext { } .btn--primary { - background: var(--color-brand-600); + background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } .btn--primary:hover:not(:disabled) { - background: var(--color-brand-700); + background: var(--color-btn-primary-bg-hover); } .btn--secondary { - background: var(--color-surface-secondary); - color: var(--color-text-primary); - border: 1px solid var(--color-border-primary); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); + border: 1px solid var(--color-btn-secondary-border); } .btn--secondary:hover:not(:disabled) { diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-anomalies.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-anomalies.component.ts index ce92d991b..a54124420 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-anomalies.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-anomalies.component.ts @@ -90,7 +90,7 @@ import { AuditAnomalyAlert } from '../../core/api/audit-log.models'; .anomalies-page { padding: 1.5rem; max-width: 1200px; margin: 0 auto; } .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 { color: var(--color-text-link); text-decoration: none; } h1 { margin: 0 0 0.25rem; } .description { color: var(--color-text-secondary); margin: 0; } .filter-bar { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; } @@ -113,7 +113,7 @@ import { AuditAnomalyAlert } from '../../core/api/audit-log.models'; .ack-info { font-size: 0.8rem; color: var(--color-text-secondary); font-style: italic; } .alert-actions { display: flex; gap: 0.75rem; } .btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border: none; padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; } - .btn-secondary { background: var(--color-surface-elevated); border: 1px solid var(--color-border-primary); padding: 0.5rem 1rem; border-radius: var(--radius-sm); text-decoration: none; color: inherit; } + .btn-secondary { background: var(--color-btn-secondary-bg); border: 1px solid var(--color-btn-secondary-border); padding: 0.5rem 1rem; border-radius: var(--radius-sm); text-decoration: none; color: inherit; } .no-alerts { text-align: center; padding: 3rem; color: var(--color-text-secondary); background: var(--color-surface-primary); border-radius: var(--radius-lg); } .anomaly-types h2 { margin: 0 0 1rem; font-size: 1.1rem; } .types-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-authority.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-authority.component.ts index d0cd8d4b3..b154437b0 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-authority.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-authority.component.ts @@ -4,10 +4,17 @@ import { Component, OnInit, inject, signal, ChangeDetectionStrategy } from '@ang import { RouterModule } from '@angular/router'; import { AuditLogClient } from '../../core/api/audit-log.client'; import { AuditEvent } from '../../core/api/audit-log.models'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +const AUTHORITY_TABS: StellaPageTab[] = [ + { id: 'tokens', label: 'Token Lifecycle', icon: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' }, + { id: 'airgap', label: 'Air-Gap Events', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'incidents', label: 'Incidents', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01' }, +]; @Component({ selector: 'app-audit-authority', - imports: [RouterModule], + imports: [RouterModule, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -19,11 +26,12 @@ import { AuditEvent } from '../../core/api/audit-log.models';

Token lifecycle, revocations, air-gap events, and incidents

-
- - - -
+ @@ -80,12 +88,10 @@ import { AuditEvent } from '../../core/api/audit-log.models'; .authority-audit-page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; } .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 { color: var(--color-text-link); text-decoration: none; } h1 { margin: 0 0 0.25rem; } .description { color: var(--color-text-secondary); margin: 0; } - .tabs { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; } - .tabs button { padding: 0.5rem 1rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); cursor: pointer; } - .tabs button.active { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border-color: var(--color-btn-primary-bg); } + .events-table { width: 100%; border-collapse: collapse; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); } .events-table th, .events-table td { padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); } .events-table th { background: var(--color-surface-elevated); font-weight: var(--font-weight-semibold); font-size: 0.85rem; } @@ -113,6 +119,7 @@ import { AuditEvent } from '../../core/api/audit-log.models'; export class AuditAuthorityComponent implements OnInit { private readonly auditClient = inject(AuditLogClient); + readonly authorityTabs = AUTHORITY_TABS; readonly events = signal([]); readonly hasMore = signal(false); private cursor: string | null = null; diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-correlations.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-correlations.component.ts index 4221ba282..71081fd86 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-correlations.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-correlations.component.ts @@ -78,7 +78,7 @@ import { AuditCorrelationCluster } from '../../core/api/audit-log.models'; .correlations-page { padding: 1.5rem; max-width: 1200px; margin: 0 auto; } .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 { color: var(--color-text-link); text-decoration: none; } h1 { margin: 0 0 0.25rem; } .description { color: var(--color-text-secondary); margin: 0; } .clusters-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1rem; } @@ -99,7 +99,7 @@ import { AuditCorrelationCluster } from '../../core/api/audit-log.models'; .cluster-detail { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1.5rem; } .detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } .detail-header h2 { margin: 0; font-size: 1.1rem; } - .btn-secondary { padding: 0.5rem 1rem; background: var(--color-surface-elevated); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); cursor: pointer; } + .btn-secondary { padding: 0.5rem 1rem; background: var(--color-btn-secondary-bg); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); cursor: pointer; } .cluster-meta { display: flex; gap: 1.5rem; margin-bottom: 1.5rem; font-size: 0.85rem; color: var(--color-text-secondary); } .root-event, .related-events { margin-bottom: 1.5rem; } .root-event h3, .related-events h3 { margin: 0 0 0.75rem; font-size: 0.95rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-detail.component.ts index 6138f1e74..98b533df0 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-event-detail.component.ts @@ -167,7 +167,7 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo .event-detail-page { padding: 1.5rem; max-width: 1200px; margin: 0 auto; } .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 { color: var(--color-text-link); text-decoration: none; } h1 { margin: 0; } .event-card { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; } .event-header { display: flex; align-items: center; gap: 0.75rem; padding: 1rem; background: var(--color-surface-elevated); border-bottom: 1px solid var(--color-border-primary); } @@ -178,7 +178,7 @@ import { AuditEvent, AuditCorrelationCluster } from '../../core/api/audit-log.mo .label { font-size: 0.75rem; color: var(--color-text-secondary); text-transform: uppercase; } .value { font-size: 0.9rem; } .mono { font-family: monospace; font-size: 0.85rem; } - .link { color: var(--color-brand-primary); text-decoration: none; } + .link { color: var(--color-text-link); text-decoration: none; } .description-section, .tags-section, .details-section, .diff-section { margin-bottom: 1.5rem; } .description-section h3, .tags-section h3, .details-section h3, .diff-section h3 { margin: 0 0 0.75rem; font-size: 1rem; } .description-section p { margin: 0; } diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-export.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-export.component.ts index 16441c872..961e524fe 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-export.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-export.component.ts @@ -167,7 +167,7 @@ import { AuditExportRequest, AuditExportResponse, AuditLogFilters, AuditModule, .export-page { padding: 1.5rem; max-width: 900px; margin: 0 auto; } .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 { color: var(--color-text-link); text-decoration: none; } h1 { margin: 0 0 0.25rem; } .description { color: var(--color-text-secondary); margin: 0; } .export-config { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 1.5rem; margin-bottom: 2rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-integrations.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-integrations.component.ts index 961b72a45..493acb3b8 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-integrations.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-integrations.component.ts @@ -104,7 +104,7 @@ import { AuditEvent } from '../../core/api/audit-log.models'; .integrations-audit-page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; } .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 { color: var(--color-text-link); text-decoration: none; } h1 { margin: 0 0 0.25rem; } .description { color: var(--color-text-secondary); margin: 0; } .events-table { width: 100%; border-collapse: collapse; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); } diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts index cad85e993..fd122f8b5 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-dashboard.component.ts @@ -4,10 +4,12 @@ import { CommonModule } from '@angular/common'; import { RouterModule } from '@angular/router'; import { AuditLogClient } from '../../core/api/audit-log.client'; import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '../../core/api/audit-log.models'; +import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component'; +import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component'; @Component({ selector: 'app-audit-log-dashboard', - imports: [CommonModule, RouterModule], + imports: [CommonModule, RouterModule, StellaMetricCardComponent, StellaMetricGridComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -21,18 +23,20 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '. @if (stats()) { -
-
- {{ stats()?.totalEvents | number }} - Total Events (7d) -
+ + @for (entry of moduleStats(); track entry.module) { -
- {{ entry.count | number }} - {{ formatModule(entry.module) }} -
+ } -
+ } @if (allCountsZero()) { @@ -159,20 +163,7 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '. transition: border-color 150ms ease; } .btn-secondary:hover { border-color: var(--color-brand-primary); } - .stats-strip { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 2rem; } - .stat-card { - background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); padding: 0.85rem 1.25rem; min-width: 120px; text-align: center; - transition: transform 150ms ease, box-shadow 150ms ease; - } - .stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); } - .stat-value { display: block; font-size: 1.5rem; font-weight: var(--font-weight-bold); line-height: 1.2; } - .stat-label { font-size: 0.75rem; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.03em; } - .stat-card.policy { border-left: 4px solid var(--color-status-info); } - .stat-card.authority { border-left: 4px solid var(--color-status-excepted); } - .stat-card.vex { border-left: 4px solid var(--color-status-success); } - .stat-card.integrations { border-left: 4px solid var(--color-status-warning); } - .stat-card.jobengine { border-left: 4px solid var(--color-brand-secondary); } + stella-metric-grid { margin-bottom: 2rem; } .anomaly-alerts { margin-bottom: 2rem; } .anomaly-alerts h2 { margin: 0 0 1rem; font-size: 1.1rem; } .alert-list { display: flex; gap: 1rem; flex-wrap: wrap; } @@ -211,13 +202,13 @@ import { AuditStatsSummary, AuditEvent, AuditAnomalyAlert, AuditModule } from '. transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); } - .access-icon { font-size: 1.25rem; display: block; margin-bottom: 0.5rem; } - .access-label { font-weight: var(--font-weight-semibold); display: block; margin-bottom: 0.25rem; } + .access-icon { font-size: 1.25rem; display: block; margin-bottom: 0.5rem; color: var(--color-text-secondary); } + .access-label { font-weight: var(--font-weight-semibold); display: block; margin-bottom: 0.25rem; color: var(--color-text-heading); } .access-desc { font-size: 0.8rem; color: var(--color-text-secondary); } .recent-events { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); overflow: hidden; } .section-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; border-bottom: 1px solid var(--color-border-primary); } .section-header h2 { margin: 0; font-size: 1rem; } - .link { font-size: 0.85rem; color: var(--color-brand-primary); text-decoration: none; } + .link { font-size: 0.85rem; color: var(--color-text-link); text-decoration: none; } .link:hover { text-decoration: underline; } .events-table { width: 100%; border-collapse: collapse; } .events-table th, .events-table td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); font-size: 0.84rem; } @@ -322,6 +313,21 @@ export class AuditLogDashboardComponent implements OnInit { return labels[module] || module; } + getModuleIcon(module: AuditModule): string { + const icons: Record = { + policy: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', + authority: 'M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2|||M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|||M23 21v-2a4 4 0 0 0-3-3.87|||M16 3.13a4 4 0 0 1 0 7.75', + vex: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M9 11l3 3L22 4', + integrations: 'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6|||M15 3h6v6|||M10 14L21 3', + jobengine: 'M12 2v4|||M12 18v4|||M4.93 4.93l2.83 2.83|||M16.24 16.24l2.83 2.83|||M2 12h4|||M18 12h4|||M4.93 19.07l2.83-2.83|||M16.24 7.76l2.83-2.83', + scanner: 'M11 1a10 10 0 1 0 0 20 10 10 0 0 0 0-20z|||M21 21l-4.35-4.35', + attestor: 'M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11', + sbom: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6', + scheduler: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20z|||M12 6v6l4 2', + }; + return icons[module] || 'M12 20V10|||M18 20V4|||M6 20v-4'; + } + formatAnomalyType(type: string): string { return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); } diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts index cc3151f48..8d52257ed 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-log-table.component.ts @@ -242,7 +242,7 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } .page-header { margin-bottom: 1.5rem; } .page-header h1 { margin: 0; font-size: 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 { color: var(--color-text-link); text-decoration: none; } .breadcrumb a:hover { text-decoration: underline; } .filters-bar { background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); padding: 0.85rem 1rem; margin-bottom: 1.5rem; } .filter-row { display: flex; gap: 0.75rem; flex-wrap: wrap; margin-bottom: 0.6rem; align-items: flex-end; } @@ -326,7 +326,7 @@ import { AuditEvent, AuditLogFilters, AuditModule, AuditAction, AuditSeverity } .badge.severity.critical { background: var(--color-status-error-text); color: white; } .actor-type { font-size: 0.7rem; color: var(--color-text-muted); } .resource, .description { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .link { color: var(--color-brand-primary); text-decoration: none; font-size: 0.8rem; } + .link { color: var(--color-text-link); text-decoration: none; font-size: 0.8rem; } .link:hover { text-decoration: underline; } .btn-xs { padding: 0.15rem 0.4rem; font-size: 0.7rem; cursor: pointer; margin-left: 0.5rem; diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-policy.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-policy.component.ts index 650a10b60..33ccab27e 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-policy.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-policy.component.ts @@ -80,7 +80,7 @@ import { AuditEvent } from '../../core/api/audit-log.models'; .policy-audit-page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; } .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 { color: var(--color-text-link); text-decoration: none; } h1 { margin: 0 0 0.25rem; } .description { color: var(--color-text-secondary); margin: 0; } .event-categories { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-timeline-search.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-timeline-search.component.ts index b52aec992..cdfc1a1a5 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-timeline-search.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-timeline-search.component.ts @@ -66,7 +66,7 @@ function mapActionToKind(action: string): TimelineEventKind { .timeline-page { padding: 1.5rem; max-width: 1000px; margin: 0 auto; } .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 { color: var(--color-text-link); text-decoration: none; } .breadcrumb a:hover { text-decoration: underline; } h1 { margin: 0 0 0.25rem; } .description { color: var(--color-text-secondary); margin: 0; } diff --git a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-vex.component.ts b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-vex.component.ts index 415a4f06b..b10b705df 100644 --- a/src/Web/StellaOps.Web/src/app/features/audit-log/audit-vex.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/audit-log/audit-vex.component.ts @@ -106,7 +106,7 @@ import { AuditEvent } from '../../core/api/audit-log.models'; .vex-audit-page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; } .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 { color: var(--color-text-link); text-decoration: none; } h1 { margin: 0 0 0.25rem; } .description { color: var(--color-text-secondary); margin: 0; } .events-table { width: 100%; border-collapse: collapse; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); } diff --git a/src/Web/StellaOps.Web/src/app/features/binary-index/binary-index-ops.component.ts b/src/Web/StellaOps.Web/src/app/features/binary-index/binary-index-ops.component.ts index 6ce89dda8..1a0d825a9 100644 --- a/src/Web/StellaOps.Web/src/app/features/binary-index/binary-index-ops.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/binary-index/binary-index-ops.component.ts @@ -25,14 +25,43 @@ import { BinaryFingerprintExport, FingerprintExportEntry, } from '../../core/api/binary-index-ops.client'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; // Sprint: SPRINT_20260117_007_CLI_binary_analysis (BAN-004) type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint'; +const BINARY_INDEX_TABS: readonly StellaPageTab[] = [ + { + id: 'health', + label: 'Health', + icon: 'M22 12h-4l-3 9L9 3l-3 9H2', + }, + { + id: 'bench', + label: 'Benchmark', + icon: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z', + }, + { + id: 'cache', + label: 'Cache', + icon: 'M12 2C6.48 2 2 4.02 2 6.5v11c0 2.49 4.48 4.5 10 4.5s10-2.01 10-4.5v-11C22 4.02 17.52 2 12 2z|||M2 6.5c0 2.49 4.48 4.5 10 4.5s10-2.01 10-4.5|||M2 12c0 2.49 4.48 4.5 10 4.5s10-2.01 10-4.5', + }, + { + id: 'config', + label: 'Configuration', + icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0', + }, + { + id: 'fingerprint', + label: 'Fingerprint Export', + icon: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4|||M7 10l5 5 5-5|||M12 15V3', + }, +]; + @Component({ selector: 'app-binary-index-ops', standalone: true, - imports: [CommonModule], + imports: [CommonModule, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -57,54 +86,12 @@ type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint';
- - +
@if (loading()) {
Loading BinaryIndex status...
@@ -616,6 +603,7 @@ type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint'; } }
+
`, styles: [` @@ -662,9 +650,9 @@ type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint'; text-transform: uppercase; } - .status-badge--healthy { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .status-badge--degraded { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } - .status-badge--unhealthy { background: var(--color-status-error-text); color: var(--color-status-error-border); } + .status-badge--healthy { background: var(--color-status-success-text); color: #fff; } + .status-badge--degraded { background: var(--color-status-warning-text); color: #fff; } + .status-badge--unhealthy { background: var(--color-status-error-text); color: #fff; } .status-badge--unknown { background: var(--color-text-primary); color: var(--color-text-muted); } .status-timestamp { @@ -672,33 +660,6 @@ type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint'; color: var(--color-text-secondary); } - .binidx-ops__tabs { - display: flex; - gap: 0; - border-bottom: 1px solid var(--color-text-primary); - margin-bottom: 1.5rem; - } - - .binidx-ops__tab { - padding: 0.75rem 1.25rem; - background: transparent; - border: none; - border-bottom: 2px solid transparent; - color: var(--color-text-muted); - cursor: pointer; - font-size: 0.875rem; - transition: all 0.15s ease; - } - - .binidx-ops__tab:hover { - color: rgba(212, 201, 168, 0.3); - } - - .binidx-ops__tab--active { - color: var(--color-status-info); - border-bottom-color: var(--color-status-info); - } - .binidx-ops__content { min-height: 400px; } @@ -1183,6 +1144,7 @@ type Tab = 'health' | 'bench' | 'cache' | 'config' | 'fingerprint'; `], }) export class BinaryIndexOpsComponent implements OnInit, OnDestroy { + readonly BINARY_INDEX_TABS = BINARY_INDEX_TABS; private readonly client = inject(BinaryIndexOpsClient); private refreshInterval: ReturnType | null = null; diff --git a/src/Web/StellaOps.Web/src/app/features/bundles/bundle-builder.component.ts b/src/Web/StellaOps.Web/src/app/features/bundles/bundle-builder.component.ts index d803bf052..a4a929ada 100644 --- a/src/Web/StellaOps.Web/src/app/features/bundles/bundle-builder.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/bundles/bundle-builder.component.ts @@ -92,7 +92,7 @@ interface ComponentDraft {

Select Components

Add artifact versions to include in this bundle.

-
+
@@ -272,7 +272,7 @@ interface ComponentDraft { } .step-item--active .step-item__num { - background: var(--color-brand-primary, #4f46e5); + background: var(--color-btn-primary-bg); color: #fff; } @@ -329,7 +329,6 @@ interface ComponentDraft { .bundle-builder__table { width: 100%; - border-collapse: collapse; font-size: 0.84rem; margin-bottom: 0.85rem; } @@ -378,7 +377,7 @@ interface ComponentDraft { } .btn-primary { - background: var(--color-brand-primary, #4f46e5); + background: var(--color-btn-primary-bg); color: #fff; } diff --git a/src/Web/StellaOps.Web/src/app/features/bundles/bundle-catalog.component.ts b/src/Web/StellaOps.Web/src/app/features/bundles/bundle-catalog.component.ts index 44a8ad0df..8e1cfec7c 100644 --- a/src/Web/StellaOps.Web/src/app/features/bundles/bundle-catalog.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/bundles/bundle-catalog.component.ts @@ -91,7 +91,7 @@ interface BundleRow { {{ errorMessage }}

} @else { -
Component
+
@@ -198,14 +198,13 @@ interface BundleRow { } .filter-chip--active { - background: var(--color-brand-primary, #4f46e5); + background: var(--color-btn-primary-bg); color: #fff; border-color: var(--color-brand-primary, #4f46e5); } .bundle-catalog__table { width: 100%; - border-collapse: collapse; font-size: 0.875rem; } @@ -305,7 +304,7 @@ interface BundleRow { .btn-primary { padding: 0.5rem 1rem; - background: var(--color-brand-primary, #4f46e5); + background: var(--color-btn-primary-bg); color: #fff; border-radius: var(--radius-sm, 4px); text-decoration: none; diff --git a/src/Web/StellaOps.Web/src/app/features/bundles/bundle-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/bundles/bundle-detail.component.ts index b1aa032fa..97b62787a 100644 --- a/src/Web/StellaOps.Web/src/app/features/bundles/bundle-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/bundles/bundle-detail.component.ts @@ -6,11 +6,30 @@ import { ReleaseControlBundleDetailDto, ReleaseControlBundleVersionSummaryDto, } from './bundle-organizer.api'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +const BUNDLE_DETAIL_TABS: readonly StellaPageTab[] = [ + { + id: 'versions', + label: 'Versions', + icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2', + }, + { + id: 'config', + label: 'Config Contract', + icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0', + }, + { + id: 'changelog', + label: 'Changelog', + icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8', + }, +]; @Component({ selector: 'app-bundle-detail', standalone: true, - imports: [RouterLink], + imports: [RouterLink, StellaPageTabsComponent], template: `
Bundle
+
@@ -147,6 +148,7 @@ import {

Per-repository changelog exports are attached to evidence packs.

} + } `, @@ -197,30 +199,6 @@ import { color: var(--color-text-secondary, #666); } - .bundle-detail__tabs { - display: flex; - gap: 0; - border-bottom: 2px solid var(--color-border, #e5e7eb); - margin-bottom: 1.5rem; - } - - .bundle-detail__tabs button { - padding: 0.5rem 1rem; - border: none; - background: transparent; - font-size: 0.875rem; - cursor: pointer; - color: var(--color-text-secondary, #666); - border-bottom: 2px solid transparent; - margin-bottom: -2px; - } - - .tab--active { - color: var(--color-brand-primary, #4f46e5) !important; - border-bottom-color: var(--color-brand-primary, #4f46e5) !important; - font-weight: 600; - } - .bundle-detail__section { padding: 1rem 0; } @@ -286,7 +264,6 @@ import { .bundle-detail__table { width: 100%; - border-collapse: collapse; font-size: 0.83rem; } @@ -318,7 +295,7 @@ import { .btn-primary { padding: 0.5rem 1rem; - background: var(--color-brand-primary, #4f46e5); + background: var(--color-btn-primary-bg); color: #fff; border-radius: var(--radius-sm, 4px); text-decoration: none; @@ -338,6 +315,7 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, }) export class BundleDetailComponent implements OnInit { + readonly BUNDLE_DETAIL_TABS = BUNDLE_DETAIL_TABS; private readonly route = inject(ActivatedRoute); private readonly bundleApi = inject(BundleOrganizerApi); diff --git a/src/Web/StellaOps.Web/src/app/features/bundles/bundle-version-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/bundles/bundle-version-detail.component.ts index 75a6beea4..6626098e4 100644 --- a/src/Web/StellaOps.Web/src/app/features/bundles/bundle-version-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/bundles/bundle-version-detail.component.ts @@ -4,11 +4,30 @@ import { BundleOrganizerApi, ReleaseControlBundleVersionDetailDto, } from './bundle-organizer.api'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +const BUNDLE_VERSION_TABS: readonly StellaPageTab[] = [ + { + id: 'components', + label: 'Components', + icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12', + }, + { + id: 'validation', + label: 'Validation', + icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M22 4L12 14.01l-3-3', + }, + { + id: 'releases', + label: 'Promotions', + icon: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z', + }, +]; @Component({ selector: 'app-bundle-version-detail', standalone: true, - imports: [RouterLink], + imports: [RouterLink, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -68,19 +87,19 @@ import { } -
- - - -
- + @if (activeTab() === 'components') {

Manifest components (digest-first)

@if (versionDetailModel.components.length === 0) {

No components listed for this version.

} @else { -
Version
+
@@ -143,6 +162,7 @@ import { Create promotion from this version } + } `, @@ -271,29 +291,6 @@ import { border-color: var(--color-brand-primary, #6366f1); } - .bvd__tabs { - display: flex; - border-bottom: 2px solid var(--color-border, #e5e7eb); - margin-bottom: 1rem; - } - - .bvd__tabs button { - padding: 0.5rem 1rem; - border: none; - background: transparent; - font-size: 0.875rem; - cursor: pointer; - color: var(--color-text-secondary, #666); - border-bottom: 2px solid transparent; - margin-bottom: -2px; - } - - .tab--active { - color: var(--color-brand-primary, #4f46e5) !important; - border-bottom-color: var(--color-brand-primary, #4f46e5) !important; - font-weight: 600; - } - section h2 { margin: 0 0 0.35rem; font-size: 0.9rem; @@ -318,7 +315,6 @@ import { .bvd__table { width: 100%; - border-collapse: collapse; font-size: 0.83rem; } @@ -360,7 +356,7 @@ import { width: fit-content; border: 0; border-radius: 4px; - background: var(--color-brand-primary, #4f46e5); + background: var(--color-btn-primary-bg); color: #fff; padding: 0.4rem 0.7rem; font-size: 0.78rem; @@ -423,6 +419,7 @@ import { `], }) export class BundleVersionDetailComponent implements OnInit { + readonly BUNDLE_VERSION_TABS = BUNDLE_VERSION_TABS; private readonly route = inject(ActivatedRoute); private readonly bundleApi = inject(BundleOrganizerApi); diff --git a/src/Web/StellaOps.Web/src/app/features/change-trace/change-trace-viewer.component.ts b/src/Web/StellaOps.Web/src/app/features/change-trace/change-trace-viewer.component.ts index e93d198c6..14adadb16 100644 --- a/src/Web/StellaOps.Web/src/app/features/change-trace/change-trace-viewer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/change-trace/change-trace-viewer.component.ts @@ -172,7 +172,7 @@ import { readReleaseInvestigationQueryState } from '../release-investigation/rel } .btn-primary { - background: var(--color-primary); + background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); &:hover:not(:disabled) { diff --git a/src/Web/StellaOps.Web/src/app/features/change-trace/components/byte-diff-viewer/byte-diff-viewer.component.scss b/src/Web/StellaOps.Web/src/app/features/change-trace/components/byte-diff-viewer/byte-diff-viewer.component.scss index 682735312..0111284a4 100644 --- a/src/Web/StellaOps.Web/src/app/features/change-trace/components/byte-diff-viewer/byte-diff-viewer.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/change-trace/components/byte-diff-viewer/byte-diff-viewer.component.scss @@ -26,7 +26,7 @@ .icon { font-family: var(--font-family-mono); - color: var(--color-brand-primary); + color: var(--color-text-link); } } } @@ -91,7 +91,7 @@ .offset { font-family: var(--font-family-mono); - color: var(--color-brand-primary); + color: var(--color-text-link); } .size { @@ -150,7 +150,7 @@ font-family: var(--font-family-mono); font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); - color: var(--color-brand-primary); + color: var(--color-text-link); } .section { diff --git a/src/Web/StellaOps.Web/src/app/features/change-trace/components/delta-list/delta-list.component.scss b/src/Web/StellaOps.Web/src/app/features/change-trace/components/delta-list/delta-list.component.scss index 0ab42e68f..7797ffca3 100644 --- a/src/Web/StellaOps.Web/src/app/features/change-trace/components/delta-list/delta-list.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/change-trace/components/delta-list/delta-list.component.scss @@ -174,7 +174,7 @@ &.change-patched { background: var(--color-brand-light); - color: var(--color-brand-primary); + color: var(--color-text-link); } &.change-rebuilt { @@ -242,7 +242,7 @@ &.change-patched { background: var(--color-brand-light); - color: var(--color-brand-primary); + color: var(--color-text-link); } &.change-rebuilt { @@ -335,7 +335,7 @@ &.symbol-patched { background: var(--color-brand-light); - color: var(--color-brand-primary); + color: var(--color-text-link); } &.symbol-unchanged { diff --git a/src/Web/StellaOps.Web/src/app/features/change-trace/components/proof-panel/proof-panel.component.scss b/src/Web/StellaOps.Web/src/app/features/change-trace/components/proof-panel/proof-panel.component.scss index 7ef144508..44873ccff 100644 --- a/src/Web/StellaOps.Web/src/app/features/change-trace/components/proof-panel/proof-panel.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/change-trace/components/proof-panel/proof-panel.component.scss @@ -30,7 +30,7 @@ .icon { font-family: var(--font-family-mono); font-size: var(--font-size-sm); - color: var(--color-brand-primary); + color: var(--color-text-link); } .title { diff --git a/src/Web/StellaOps.Web/src/app/features/change-trace/components/summary-header/summary-header.component.scss b/src/Web/StellaOps.Web/src/app/features/change-trace/components/summary-header/summary-header.component.scss index f83e11772..4a6a08f23 100644 --- a/src/Web/StellaOps.Web/src/app/features/change-trace/components/summary-header/summary-header.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/change-trace/components/summary-header/summary-header.component.scss @@ -126,7 +126,7 @@ .method-chip { padding: var(--space-1) var(--space-3); background: var(--color-brand-light); - color: var(--color-brand-primary); + color: var(--color-text-link); border-radius: var(--radius-full); font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); diff --git a/src/Web/StellaOps.Web/src/app/features/change-trace/components/summary-header/summary-header.component.ts b/src/Web/StellaOps.Web/src/app/features/change-trace/components/summary-header/summary-header.component.ts index 077dd5aae..bc2c8be1d 100644 --- a/src/Web/StellaOps.Web/src/app/features/change-trace/components/summary-header/summary-header.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/change-trace/components/summary-header/summary-header.component.ts @@ -5,9 +5,11 @@ // ----------------------------------------------------------------------------- import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, Input, + inject,} from '@angular/core'; import { ChangeTrace, ChangeTraceVerdict } from '../../models/change-trace.models'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; @Component({ selector: 'stella-summary-header', imports: [CommonModule], @@ -16,6 +18,8 @@ import { ChangeTrace, ChangeTraceVerdict } from '../../models/change-trace.model changeDetection: ChangeDetectionStrategy.OnPush }) export class SummaryHeaderComponent { + private readonly dateFmt = inject(DateFormatService); + @Input({ required: true }) trace!: ChangeTrace; get verdictClass(): string { @@ -64,7 +68,7 @@ export class SummaryHeaderComponent { } formatNumber(value: number): string { - return new Intl.NumberFormat('en-US').format(value); + return new Intl.NumberFormat(this.dateFmt.locale()).format(value); } formatDate(isoDate: string): string { diff --git a/src/Web/StellaOps.Web/src/app/features/compare/components/actionables-panel/actionables-panel.component.scss b/src/Web/StellaOps.Web/src/app/features/compare/components/actionables-panel/actionables-panel.component.scss index fbc15712f..29243dd9b 100644 --- a/src/Web/StellaOps.Web/src/app/features/compare/components/actionables-panel/actionables-panel.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/compare/components/actionables-panel/actionables-panel.component.scss @@ -14,7 +14,7 @@ font-weight: var(--font-weight-semibold); mat-icon { - color: var(--color-brand-primary); + color: var(--color-text-link); } } @@ -28,8 +28,8 @@ } .action-upgrade mat-icon { color: var(--color-status-info); } - .action-patch mat-icon { color: var(--color-brand-secondary); } - .action-vex mat-icon { color: var(--color-brand-primary); } + .action-patch mat-icon { color: var(--color-text-link); } + .action-vex mat-icon { color: var(--color-text-link); } .action-config mat-icon { color: var(--color-status-warning); } .action-investigate mat-icon { color: var(--color-status-error); } diff --git a/src/Web/StellaOps.Web/src/app/features/compare/components/categories-pane.component.ts b/src/Web/StellaOps.Web/src/app/features/compare/components/categories-pane.component.ts index db33090a1..042d0c459 100644 --- a/src/Web/StellaOps.Web/src/app/features/compare/components/categories-pane.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/compare/components/categories-pane.component.ts @@ -67,7 +67,7 @@ interface CategoryInfo { } .categories-pane__clear { font-size: 0.75rem; - color: var(--color-brand-primary); + color: var(--color-text-link); background: none; border: none; cursor: pointer; diff --git a/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view.component.ts b/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view.component.ts index 0991d3f23..5323d29e1 100644 --- a/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/compare/components/compare-view.component.ts @@ -15,6 +15,7 @@ import { TrustIndicatorsComponent } from './trust-indicators.component'; import { DeltaSummaryStripComponent } from './delta-summary-strip.component'; import { ThreePaneLayoutComponent } from './three-pane-layout.component'; import { ExportActionsComponent } from './export-actions.component'; +import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component'; export type UserRole = 'developer' | 'security' | 'audit'; @@ -28,8 +29,7 @@ export type UserRole = 'developer' | 'security' | 'audit'; TrustIndicatorsComponent, DeltaSummaryStripComponent, ThreePaneLayoutComponent, - ExportActionsComponent, - ], + ExportActionsComponent, LoadingStateComponent], template: `
@@ -62,8 +62,7 @@ export type UserRole = 'developer' | 'security' | 'audit'; @if (loading()) {
-
- Loading comparison... +
} @@ -161,7 +160,7 @@ export type UserRole = 'developer' | 'security' | 'audit'; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; padding: 3rem; color: var(--color-text-muted); } - .compare-view__empty a { color: var(--color-brand-primary); text-decoration: none; } + .compare-view__empty a { color: var(--color-text-link); text-decoration: none; } .compare-view__empty a:hover { text-decoration: underline; } .compare-view__explain-toggle { position: fixed; bottom: 1.5rem; right: 1.5rem; diff --git a/src/Web/StellaOps.Web/src/app/features/compare/components/vex-merge-explanation/vex-merge-explanation.component.scss b/src/Web/StellaOps.Web/src/app/features/compare/components/vex-merge-explanation/vex-merge-explanation.component.scss index 682024c45..52735007d 100644 --- a/src/Web/StellaOps.Web/src/app/features/compare/components/vex-merge-explanation/vex-merge-explanation.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/compare/components/vex-merge-explanation/vex-merge-explanation.component.scss @@ -17,7 +17,7 @@ font-size: var(--font-size-sm); strong { - color: var(--color-brand-primary); + color: var(--color-text-link); } .conflict { @@ -68,7 +68,7 @@ .source-status { background: var(--color-brand-light); - color: var(--color-brand-primary); + color: var(--color-text-link); } .source-priority { @@ -79,7 +79,7 @@ .winner-badge { margin-left: auto; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 24px; width: 24px; height: 24px; diff --git a/src/Web/StellaOps.Web/src/app/features/compare/components/witness-path/witness-path.component.scss b/src/Web/StellaOps.Web/src/app/features/compare/components/witness-path/witness-path.component.scss index 8acf721c2..60f1de5b1 100644 --- a/src/Web/StellaOps.Web/src/app/features/compare/components/witness-path/witness-path.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/compare/components/witness-path/witness-path.component.scss @@ -56,7 +56,7 @@ border-left: 3px solid var(--color-brand-primary); .node-icon mat-icon { - color: var(--color-brand-primary); + color: var(--color-text-link); } } @@ -163,7 +163,7 @@ mat-chip { background: var(--color-brand-light); - color: var(--color-brand-primary); + color: var(--color-text-link); } } } diff --git a/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/configuration-pane.component.ts b/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/configuration-pane.component.ts index 31194f9f0..beb1f495c 100644 --- a/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/configuration-pane.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/configuration-pane.component.ts @@ -364,12 +364,12 @@ import { } .btn-primary { - background: var(--theme-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } .btn-primary:hover:not(:disabled) { - background: var(--theme-brand-hover); + background: var(--color-btn-primary-bg-hover); } .btn-secondary { diff --git a/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/integration-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/integration-detail.component.ts index 69217803f..fa274080f 100644 --- a/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/integration-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/integration-detail.component.ts @@ -13,10 +13,16 @@ import { VAULT_PROVIDER_DEFINITIONS, SETTINGS_STORE_PROVIDER_DEFINITIONS, } from '../models/configuration-pane.models'; +import { StellaPageTabsComponent, StellaPageTab } from '../../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +const CONFIG_DETAIL_TABS: StellaPageTab[] = [ + { id: 'config', label: 'Configuration', icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' }, + { id: 'health', label: 'Health Checks', icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M22 4L12 14.01l-3-3' }, +]; @Component({ selector: 'app-integration-detail', - imports: [FormsModule], + imports: [FormsModule, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -52,23 +58,12 @@ import {
-
- - -
+ @if (activeTab === 'config') { @@ -603,7 +598,7 @@ import { } .btn-primary { - background: var(--theme-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } @@ -623,7 +618,7 @@ import { } .btn-primary:hover:not(:disabled) { - background: var(--theme-brand-hover); + background: var(--color-btn-primary-bg-hover); } .btn-secondary:hover:not(:disabled) { @@ -659,6 +654,7 @@ export class IntegrationDetailComponent { readonly runChecks = output(); readonly removeIntegration = output(); + readonly configDetailTabs = CONFIG_DETAIL_TABS; activeTab: 'config' | 'health' = 'config'; getStatusLabel(status: string): string { diff --git a/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/integration-section.component.ts b/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/integration-section.component.ts index b999f4216..a4a94bf2a 100644 --- a/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/integration-section.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/configuration-pane/components/integration-section.component.ts @@ -164,7 +164,7 @@ import { ConfigurationSection, ConfiguredIntegration, ConnectionStatus } from '. .btn-add { padding: 6px 14px; - background: var(--theme-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); border: none; border-radius: var(--radius-sm); @@ -174,7 +174,7 @@ import { ConfigurationSection, ConfiguredIntegration, ConnectionStatus } from '. } .btn-add:hover { - background: var(--theme-brand-hover); + background: var(--color-btn-primary-bg-hover); } .integrations-list { @@ -284,7 +284,7 @@ import { ConfigurationSection, ConfiguredIntegration, ConnectionStatus } from '. font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); padding: 1px 5px; - background: var(--theme-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); border-radius: var(--radius-sm); text-transform: uppercase; diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/branding/branding-editor.component.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/branding/branding-editor.component.ts index 5774a25d4..9711428d3 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/branding/branding-editor.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/branding/branding-editor.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, inject, signal } from '@angular/core'; +import { Component, OnInit, inject, signal, computed } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { BrandingService } from '../../../core/branding/branding.service'; @@ -12,596 +12,714 @@ interface ThemeToken { category: string; } +interface ColorPickerDef { + token: string; + label: string; + fallback: string; + description?: string; +} + +/** + * Default light-theme values sourced from _colors.scss (:root block). + * These are the canonical fallback values for the branding editor. + */ +const LIGHT_DEFAULTS = { + '--color-brand-primary': '#F5A623', + '--color-brand-secondary': '#D4920A', + '--color-surface-primary': '#FFFCF5', + '--color-surface-secondary': '#FFF9ED', + '--color-surface-tertiary': '#FEF3E2', + '--color-text-primary': '#2A1E00', + '--color-text-secondary': '#453518', + '--color-text-muted': '#5C4E32', + '--color-text-heading': '#1A0F00', + '--color-border-primary': '#B0A076', + '--color-status-success': '#357A28', + '--color-status-warning': '#8A5A08', + '--color-status-error': '#B53525', + '--color-status-info': '#3A6A92', + '--color-sidebar-bg': '#12151F', + '--color-sidebar-active-text': '#FFD580', +} as const; + +const DARK_DEFAULTS = { + '--color-surface-primary': '#0C1220', + '--color-surface-secondary': '#141C2E', + '--color-text-primary': '#F5F0E6', + '--color-brand-primary': '#F5B84A', +} as const; + @Component({ selector: 'app-branding-editor', imports: [FormsModule], template: ` -
-
-

Branding Configuration

-
- - -
-
+
+ +
+ - @if (!canWrite) { -
- Branding is read-only for this session. Changes require branding write permission. -
- } - - @if (error()) { -
{{ error() }}
- } - - @if (success()) { -
{{ success() }}
- } - - @if (isLoading()) { -
Loading branding configuration...
- } @else { -
- -
-

General Settings

-
- - - Displayed in browser tab and header -
-
- - -
-

Logo & Favicon

- -
- -
- @if (formData.logoUrl) { -
- Logo preview - -
- } @else { -
- - - PNG, JPEG, or SVG (max 256KB) -
- } + @if (!canWrite) { +
+ + Branding is read-only. Changes require authority:branding:write scope. +
+ } + + @if (error()) { +
+ + {{ error() }} +
+ } + + @if (success()) { +
+ + {{ success() }} +
+ } + + @if (isLoading()) { +
+
+
+
+
+ } @else { + +
+
+
+ +
+

Identity

+

Application title, logo, and favicon

+
+
- -
- -
- @if (formData.faviconUrl) { -
- Favicon preview - + @if (openSections().has('identity')) { +
+
+ + + Appears in the browser tab and sidebar header. +
+ +
+
+ +
+ @if (formData.logoUrl) { + Logo preview + + } @else { + +
+ + Upload logo + PNG, JPEG, SVG · max 256 KB +
+ } +
- } @else { -
- - - ICO or PNG (max 256KB) + +
+ +
+ @if (formData.faviconUrl) { + Favicon preview + + } @else { + +
+ + Upload favicon + ICO or PNG · max 256 KB +
+ } +
- } +
-
+ }
- - -
-

Theme Tokens

- - - @for (category of themeCategories; track category) { -
-

{{ category.label }}

-
- @for (token of getTokensByCategory(category.prefix); track token.key) { -
- -
- - @if (isColorToken(token.key)) { - + + +
+
+
+ +
+

Brand Colors

+

Primary accent and identity colors

+
+
+ +
+ @if (openSections().has('brand')) { +
+
+ @for (picker of brandColorPickers; track picker.token) { +
+
+ {{ picker.label }} + @if (picker.description) { + {{ picker.description }} }
+
+
+ +
+
+ +
+ {{ picker.token }}
}
} - -
-

Custom Token

-
- - - -
-
- - -
-

Preview

-
-
- @if (formData.logoUrl) { - + + +
+
+
+ +
+

Surface Colors

+

Background and container colors

+
+
+ +
+ @if (openSections().has('surface')) { +
+
+ @for (picker of surfaceColorPickers; track picker.token) { +
+
+ {{ picker.label }} + @if (picker.description) { + {{ picker.description }} + } +
+
+
+ +
+
+ +
+ {{ picker.token }} +
+ } +
+
+ } +
+ + +
+
+
+ +
+

Text Colors

+

Typography hierarchy and readability

+
+
+ +
+ @if (openSections().has('text')) { +
+
+ @for (picker of textColorPickers; track picker.token) { +
+
+ {{ picker.label }} + @if (picker.description) { + {{ picker.description }} + } +
+
+
+ +
+
+ +
+ {{ picker.token }} +
+ } +
+
+ } +
+ + +
+
+
+ +
+

Status Colors

+

Success, warning, error, and info indicators

+
+
+ +
+ @if (openSections().has('status')) { +
+
+ @for (picker of statusColorPickers; track picker.token) { +
+
+ {{ picker.label }} +
+
+
+ +
+
+ +
+ {{ picker.token }} +
+ } +
+
+ } +
+ + +
+
+
+ +
+

Dark Theme Overrides

+

Override colors when dark / night mode is active

+
+
+ +
+ @if (openSections().has('night')) { +
+
+ @for (picker of nightColorPickers; track picker.token) { +
+
+ {{ picker.label }} + @if (picker.description) { + {{ picker.description }} + } +
+
+
+ +
+
+ +
+ {{ picker.token }} +
+ } +
+
+ } +
+ + +
+
+
+ +
+

Advanced Tokens

+

Raw CSS custom properties for fine-grained control

+
+
+ +
+ @if (openSections().has('advanced')) { +
+ @for (category of themeCategories; track category.prefix) { + @if (getTokensByCategory(category.prefix).length) { +
+

{{ category.label }}

+
+ @for (token of getTokensByCategory(category.prefix); track token.key) { +
+ {{ token.key }} +
+ + @if (isColorToken(token.key)) { +
+ +
+
+ } +
+
+ } +
+
+ } } -
- {{ formData.title || 'Stella Ops Dashboard' }} + +
+

Add Custom Token

+
+ + + +
-
-
-

Sample Card

-

- This is a preview of how your branding will appear. -

- + } +
+ } +
+ + +
+ + +
+ +
+ + {{ formData.title || 'Stella Ops' }} + +
+ v3.2 +
+
+ + +
+
+ Releases + 42 +
+
+
+
+
+ Findings + 7 +
+
+
+
+
+ + +
+ + Passed + + + Warning + + + Failed + + + Info + +
+ + + +
+
- } +
`, styles: [` - .admin-panel { - max-width: 1000px; + /* ================================================================= + Page Layout + ================================================================= */ + .branding-page { + display: grid; + grid-template-columns: 1fr 320px; + gap: var(--space-6, 1.5rem); + align-items: start; + min-height: 0; } - .admin-header { + @media (max-width: 1100px) { + .branding-page { + grid-template-columns: 1fr; + } + .branding-preview { + display: none; + } + } + + /* ================================================================= + Page Header + ================================================================= */ + .page-header { display: flex; justify-content: space-between; - align-items: center; - margin-bottom: var(--space-6, 1.5rem); + align-items: flex-start; + gap: 1rem; + margin-bottom: var(--space-5, 1.25rem); + flex-wrap: wrap; } - .admin-header h1 { + .page-header h1 { margin: 0; - font-size: var(--font-size-2xl); - font-weight: var(--font-weight-semibold); + font-size: var(--font-size-2xl, 1.5rem); + font-weight: var(--font-weight-bold, 700); color: var(--color-text-heading); + letter-spacing: -0.02em; + } + + .page-subtitle { + margin: 0.25rem 0 0; + color: var(--color-text-secondary); + font-size: var(--font-size-sm, 0.75rem); } .header-actions { - display: flex; - gap: 0.625rem; - } - - .branding-sections { - display: flex; - flex-direction: column; - gap: var(--space-5, 1.25rem); - } - - .branding-section { - background: var(--color-surface-primary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - padding: var(--space-5, 1.25rem); - transition: border-color 150ms ease; - } - - .branding-section:hover { - border-color: var(--color-border-secondary); - } - - .branding-section h2 { - margin: 0 0 var(--space-4, 1rem) 0; - font-size: var(--font-size-md); - font-weight: var(--font-weight-semibold); - color: var(--color-text-heading); - padding-bottom: 0.625rem; - border-bottom: 1px solid var(--color-border-primary); - } - - .branding-section h3 { - margin: var(--space-4, 1rem) 0 0.625rem 0; - font-size: var(--font-size-base); - font-weight: var(--font-weight-semibold); - color: var(--color-text-primary); - } - - .section-info { - margin-bottom: var(--space-4, 1rem); - color: var(--color-text-secondary); - font-size: var(--font-size-sm, 0.75rem); - line-height: var(--line-height-base, 1.5); - } - - .form-group { - margin-bottom: var(--space-4, 1rem); - } - - .form-group label { - display: block; - margin-bottom: 0.375rem; - font-size: var(--font-size-sm, 0.75rem); - font-weight: var(--font-weight-medium); - color: var(--color-text-primary); - } - - .form-group input { - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - font-size: var(--font-size-base); - background: var(--color-surface-primary); - color: var(--color-text-primary); - outline: none; - transition: - border-color 150ms ease, - box-shadow 150ms ease; - } - - .form-group input:hover:not(:disabled):not(:focus) { - border-color: var(--color-border-secondary); - } - - .form-group input:focus { - border-color: var(--color-brand-primary); - box-shadow: 0 0 0 2px var(--color-focus-ring); - } - - .form-hint { - display: block; - margin-top: 0.25rem; - font-size: var(--font-size-xs, 0.6875rem); - color: var(--color-text-muted); - } - - .asset-upload { - margin-bottom: var(--space-5, 1.25rem); - } - - .asset-upload > label { - display: block; - margin-bottom: 0.375rem; - font-size: var(--font-size-sm, 0.75rem); - font-weight: var(--font-weight-medium); - color: var(--color-text-primary); - } - - .upload-area { - border: 2px dashed var(--color-border-primary); - border-radius: var(--radius-lg); - padding: var(--space-5, 1.25rem); - text-align: center; - background: var(--color-surface-secondary); - transition: - border-color 200ms ease, - background-color 200ms ease; - } - - .upload-area:hover { - border-color: var(--color-brand-primary); - background: var(--color-brand-primary-10, rgba(245, 166, 35, 0.1)); - } - - .upload-placeholder input[type="file"] { - display: none; - } - - .upload-placeholder small { - display: block; - margin-top: 0.5rem; - color: var(--color-text-muted); - font-size: var(--font-size-xs, 0.6875rem); - } - - .asset-preview { - display: flex; - align-items: center; - justify-content: center; - gap: 1rem; - } - - .logo-preview { - max-width: 200px; - max-height: 60px; - border-radius: var(--radius-sm); - } - - .favicon-preview { - width: 32px; - height: 32px; - border-radius: var(--radius-sm); - } - - .theme-category { - margin-bottom: var(--space-5, 1.25rem); - } - - .token-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 0.875rem; - } - - .token-item { - padding: 0.625rem; - background: var(--color-surface-secondary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - transition: border-color 150ms ease; - } - - .token-item:hover { - border-color: var(--color-border-secondary); - } - - .token-item label { - display: block; - margin-bottom: 0.375rem; - font-size: var(--font-size-xs, 0.6875rem); - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - text-transform: capitalize; - } - - .token-input-group { - display: flex; - gap: 0.375rem; - } - - .token-input { - flex: 1; - padding: 0.375rem 0.625rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - font-size: var(--font-size-sm, 0.75rem); - font-family: var(--font-family-mono, monospace); - background: var(--color-surface-primary); - color: var(--color-text-primary); - outline: none; - transition: - border-color 150ms ease, - box-shadow 150ms ease; - } - - .token-input:focus { - border-color: var(--color-brand-primary); - box-shadow: 0 0 0 2px var(--color-focus-ring); - } - - .color-picker { - width: 2.25rem; - height: 2rem; - padding: 2px; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - cursor: pointer; - background: var(--color-surface-primary); - transition: border-color 150ms ease; - } - - .color-picker:hover { - border-color: var(--color-brand-primary); - } - - .add-token-form { display: flex; gap: 0.5rem; + flex-shrink: 0; + } + + /* ================================================================= + Buttons + ================================================================= */ + .btn { + display: inline-flex; align-items: center; - padding: 0.75rem; - background: var(--color-surface-secondary); - border: 1px dashed var(--color-border-primary); - border-radius: var(--radius-md); - } - - .token-key-input, - .token-value-input { - flex: 1; - padding: 0.375rem 0.625rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - font-size: var(--font-size-sm, 0.75rem); - background: var(--color-surface-primary); - color: var(--color-text-primary); - outline: none; - transition: - border-color 150ms ease, - box-shadow 150ms ease; - } - - .token-key-input:focus, - .token-value-input:focus { - border-color: var(--color-brand-primary); - box-shadow: 0 0 0 2px var(--color-focus-ring); - } - - .preview-panel { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - overflow: hidden; - box-shadow: var(--shadow-sm); - } - - .preview-header { - padding: 0.875rem 1.25rem; - display: flex; - align-items: center; - gap: 0.75rem; - } - - .preview-logo img { - max-height: 36px; - } - - .preview-title { - font-size: var(--font-size-md); - font-weight: var(--font-weight-semibold); - } - - .preview-content { - padding: 1.25rem; - } - - .preview-card { - padding: 1rem; - border-radius: var(--radius-lg); - box-shadow: var(--shadow-sm); - } - - .preview-card h4 { - margin: 0 0 0.625rem 0; - font-size: var(--font-size-base); - font-weight: var(--font-weight-semibold); - } - - .preview-card p { - margin: 0 0 0.875rem 0; - font-size: var(--font-size-sm, 0.75rem); - line-height: var(--line-height-base, 1.5); - } - - .preview-button { + gap: 0.375rem; padding: 0.4375rem 0.875rem; border: none; - border-radius: var(--radius-sm); + border-radius: var(--radius-md, 6px); font-size: var(--font-size-sm, 0.75rem); - font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium, 500); + font-family: inherit; cursor: pointer; - transition: opacity 150ms ease; + transition: all 150ms ease; + white-space: nowrap; } - .preview-button:hover { - opacity: 0.9; - } - - .btn-primary, - .btn-secondary, - .btn-sm { - padding: 0.4375rem 0.875rem; - border: none; - border-radius: var(--radius-sm); - font-size: var(--font-size-sm, 0.75rem); - font-weight: var(--font-weight-medium); - cursor: pointer; - transition: - background-color 150ms ease, - color 150ms ease, - opacity 150ms ease; + .btn:disabled { + opacity: 0.45; + cursor: not-allowed; } .btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); + border: 1px solid var(--color-btn-primary-border); } .btn-primary:hover:not(:disabled) { - background: var(--color-brand-primary-hover); - } - - .btn-primary:active:not(:disabled) { - transform: translateY(1px); - } - - .btn-primary:disabled { - background: var(--color-surface-tertiary); - color: var(--color-text-muted); - cursor: not-allowed; + background: var(--color-btn-primary-bg-hover); } .btn-secondary { @@ -615,56 +733,688 @@ interface ThemeToken { border-color: var(--color-border-secondary); } - .btn-sm { - padding: 0.25rem 0.625rem; - font-size: var(--font-size-xs, 0.6875rem); - background: var(--color-surface-tertiary); - color: var(--color-text-primary); - border: 1px solid var(--color-border-primary); + .btn-ghost { + background: transparent; + color: var(--color-text-secondary); + border: 1px solid transparent; } - .btn-sm.btn-danger { + .btn-ghost:hover:not(:disabled) { + background: var(--color-surface-tertiary); + color: var(--color-text-primary); + } + + .btn-sm { + padding: 0.3125rem 0.625rem; + font-size: var(--font-size-xs, 0.6875rem); + } + + .btn-xs { + padding: 0.1875rem 0.5rem; + font-size: var(--font-size-xs, 0.6875rem); + } + + .btn-remove { + color: var(--color-status-error); + } + + .btn-remove:hover:not(:disabled) { + background: var(--color-status-error-bg); + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + .spin { + animation: spin 0.8s linear infinite; + } + + /* ================================================================= + Notices + ================================================================= */ + .notice { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.875rem; + border-radius: var(--radius-md, 6px); + margin-bottom: var(--space-4, 1rem); + font-size: var(--font-size-sm, 0.75rem); + line-height: 1.5; + border: 1px solid; + } + + .notice code { + font-family: var(--font-family-mono); + font-size: 0.9em; + padding: 0.125em 0.375em; + background: rgba(0,0,0,0.06); + border-radius: 3px; + } + + .notice svg { + flex-shrink: 0; + } + + .notice-info { + background: var(--color-status-info-bg); + color: var(--color-status-info-text); + border-color: var(--color-status-info-border); + } + + .notice-error { background: var(--color-status-error-bg); color: var(--color-status-error-text); border-color: var(--color-status-error-border); } - .btn-sm.btn-danger:hover { - background: var(--color-severity-critical); - color: white; - } - - .alert { - padding: 0.625rem 0.875rem; - border-radius: var(--radius-md); - margin-bottom: var(--space-4, 1rem); - font-size: var(--font-size-sm, 0.75rem); - line-height: var(--line-height-base, 1.5); - } - - .alert-info { - background: var(--color-status-info-bg); - color: var(--color-status-info-text); - border: 1px solid var(--color-status-info-border); - } - - .alert-error { - background: var(--color-status-error-bg); - color: var(--color-status-error-text); - border: 1px solid var(--color-status-error-border); - } - - .alert-success { + .notice-success { background: var(--color-status-success-bg); color: var(--color-status-success-text); - border: 1px solid var(--color-status-success-border); + border-color: var(--color-status-success-border); } - .loading { - text-align: center; - padding: var(--space-12, 3rem); + /* ================================================================= + Loading State + ================================================================= */ + .loading-state { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: var(--space-6, 1.5rem) 0; + } + + .loading-pulse { + height: 120px; + border-radius: var(--radius-lg, 8px); + background: linear-gradient(90deg, + var(--color-skeleton-base, #FFF9ED) 25%, + var(--color-skeleton-highlight, rgba(212, 201, 168, 0.3)) 50%, + var(--color-skeleton-base, #FFF9ED) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + } + + @keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } + + /* ================================================================= + Settings Cards (collapsible sections) + ================================================================= */ + .settings-card { + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg, 8px); + margin-bottom: var(--space-3, 0.75rem); + overflow: hidden; + transition: border-color 150ms ease; + } + + .settings-card:hover { + border-color: var(--color-border-secondary); + } + + .card-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.875rem 1rem; + cursor: pointer; + user-select: none; + transition: background 150ms ease; + } + + .card-header:hover { + background: var(--color-surface-secondary); + } + + .card-header-left { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .card-header-left > svg { color: var(--color-text-muted); + flex-shrink: 0; + } + + .card-header h2 { + margin: 0; + font-size: var(--font-size-base, 0.8125rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--color-text-heading); + } + + .card-header p { + margin: 0.125rem 0 0; + font-size: var(--font-size-xs, 0.6875rem); + color: var(--color-text-muted); + } + + .chevron { + color: var(--color-text-muted); + transition: transform 200ms ease; + flex-shrink: 0; + } + + .chevron.open { + transform: rotate(180deg); + } + + .card-body { + padding: 0 1rem 1rem; + border-top: 1px solid var(--color-border-primary); + } + + /* ================================================================= + Form Fields + ================================================================= */ + .form-field { + margin-top: 1rem; + } + + .form-field label { + display: block; + margin-bottom: 0.375rem; font-size: var(--font-size-sm, 0.75rem); + font-weight: var(--font-weight-medium, 500); + color: var(--color-text-primary); + } + + .input { + width: 100%; + padding: 0.4375rem 0.75rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm, 4px); + font-size: var(--font-size-base, 0.8125rem); + font-family: inherit; + background: var(--color-surface-primary); + color: var(--color-text-primary); + outline: none; + transition: border-color 150ms ease, box-shadow 150ms ease; + } + + .input:hover:not(:disabled):not(:focus):not([readonly]) { + border-color: var(--color-border-secondary); + } + + .input:focus { + border-color: var(--color-brand-primary); + box-shadow: 0 0 0 2px var(--color-focus-ring); + } + + .input-sm { + padding: 0.3125rem 0.625rem; + font-size: var(--font-size-sm, 0.75rem); + } + + .input-mono { + font-family: var(--font-family-mono, monospace); + } + + .field-hint { + display: block; + margin-top: 0.25rem; + font-size: var(--font-size-xs, 0.6875rem); + color: var(--color-text-muted); + } + + /* ================================================================= + Upload Zones + ================================================================= */ + .upload-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-top: 1rem; + } + + @media (max-width: 640px) { + .upload-row { + grid-template-columns: 1fr; + } + } + + .upload-field > label { + display: block; + margin-bottom: 0.375rem; + font-size: var(--font-size-sm, 0.75rem); + font-weight: var(--font-weight-medium, 500); + color: var(--color-text-primary); + } + + .upload-zone { + position: relative; + border: 2px dashed var(--color-border-primary); + border-radius: var(--radius-lg, 8px); + padding: 1.25rem; + text-align: center; + background: var(--color-surface-secondary); + transition: border-color 200ms ease, background 200ms ease; + min-height: 100px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + } + + .upload-zone:hover { + border-color: var(--color-brand-primary); + background: var(--color-brand-primary-10, rgba(245, 166, 35, 0.1)); + } + + .upload-zone.has-preview { + border-style: solid; + border-color: var(--color-border-secondary); + } + + .upload-zone input[type="file"] { + display: none; + } + + .upload-empty { + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.375rem; + color: var(--color-text-muted); + } + + .upload-empty svg { + opacity: 0.5; + } + + .upload-empty span { + font-size: var(--font-size-sm, 0.75rem); + font-weight: var(--font-weight-medium, 500); + } + + .upload-hint { + font-size: var(--font-size-xs, 0.6875rem) !important; + font-weight: var(--font-weight-normal, 400) !important; + opacity: 0.7; + } + + .upload-preview { + border-radius: var(--radius-sm, 4px); + } + + .logo-img { + max-width: 160px; + max-height: 48px; + } + + .favicon-img { + width: 32px; + height: 32px; + } + + .btn-remove { + margin-top: 0.5rem; + } + + /* ================================================================= + Color Grid & Swatches + ================================================================= */ + .color-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 0.875rem; + padding-top: 1rem; + } + + .color-field { + padding: 0.75rem; + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md, 6px); + transition: border-color 150ms ease; + } + + .color-field:hover { + border-color: var(--color-border-secondary); + } + + .color-field-header { + margin-bottom: 0.5rem; + } + + .color-field-label { + display: block; + font-size: var(--font-size-sm, 0.75rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--color-text-primary); + } + + .color-field-desc { + display: block; + font-size: var(--font-size-xs, 0.6875rem); + color: var(--color-text-muted); + margin-top: 0.125rem; + } + + .color-input-row { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .color-swatch-wrap { + position: relative; + width: 2rem; + height: 2rem; + flex-shrink: 0; + } + + .color-swatch-wrap-sm { + width: 1.5rem; + height: 1.5rem; + } + + .color-swatch-input { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; + } + + .color-swatch-input:disabled { + cursor: not-allowed; + } + + .color-swatch { + width: 100%; + height: 100%; + border-radius: var(--radius-sm, 4px); + border: 1px solid var(--color-border-primary); + pointer-events: none; + transition: border-color 150ms ease; + } + + .color-swatch-sm { + border-radius: 3px; + } + + .color-swatch-wrap:hover .color-swatch { + border-color: var(--color-brand-primary); + } + + .color-token-name { + display: block; + margin-top: 0.375rem; + font-size: var(--font-size-xs, 0.6875rem); + color: var(--color-text-muted); + font-family: var(--font-family-mono, monospace); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .color-input-row .input { + flex: 1; + min-width: 0; + } + + /* ================================================================= + Advanced Tokens Section + ================================================================= */ + .token-category { + margin-top: 1rem; + } + + .token-category-title { + margin: 0 0 0.5rem; + font-size: var(--font-size-xs, 0.6875rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .token-list { + display: flex; + flex-direction: column; + gap: 0.375rem; + } + + .token-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.625rem; + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm, 4px); + } + + .token-key { + font-family: var(--font-family-mono, monospace); + font-size: var(--font-size-xs, 0.6875rem); + color: var(--color-text-secondary); + white-space: nowrap; + min-width: 140px; + } + + .token-input-wrap { + display: flex; + align-items: center; + gap: 0.375rem; + flex: 1; + min-width: 0; + } + + .token-input-wrap .input { + flex: 1; + min-width: 0; + } + + .add-token-section { + margin-top: 1.25rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border-primary); + } + + .add-token-row { + display: flex; + gap: 0.5rem; + align-items: center; + } + + .add-token-row .input { + flex: 1; + } + + /* ================================================================= + Live Preview Sidebar + ================================================================= */ + .branding-preview { + min-width: 0; + } + + .preview-sticky { + position: sticky; + top: var(--space-4, 1rem); + } + + .preview-heading { + margin: 0 0 0.75rem; + font-size: var(--font-size-sm, 0.75rem); + font-weight: var(--font-weight-semibold, 600); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .preview-shell { + display: flex; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg, 8px); + overflow: hidden; + height: 440px; + box-shadow: var(--shadow-md); + } + + /* Sidebar mock */ + .preview-sidebar { + width: 64px; + display: flex; + flex-direction: column; + align-items: center; + padding: 0.75rem 0.375rem; + gap: 0.75rem; + flex-shrink: 0; + } + + .preview-sidebar-logo { + width: 28px; + height: 28px; + border-radius: var(--radius-sm, 4px); + object-fit: contain; + } + + .preview-sidebar-brand { + font-size: 7px; + font-weight: 700; + text-align: center; + line-height: 1.2; + word-break: break-all; + max-width: 52px; + } + + .preview-sidebar-nav { + display: flex; + flex-direction: column; + gap: 0.25rem; + width: 100%; + } + + .preview-nav-item { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.375rem; + border-left: 2px solid transparent; + border-radius: 0 3px 3px 0; + transition: background 150ms ease; + } + + .preview-nav-item.active { + background: rgba(245, 184, 74, 0.12); + } + + .preview-nav-item span { + font-size: 6.5px; + color: rgba(255,255,255,0.5); + } + + .preview-nav-item.active span { + font-weight: 600; + } + + .preview-nav-dot { + width: 4px; + height: 4px; + border-radius: 50%; + background: rgba(255,255,255,0.2); + flex-shrink: 0; + } + + /* Main mock */ + .preview-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + } + + .preview-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.625rem; + border-bottom: 1px solid; + } + + .preview-topbar-title { + font-size: 8px; + font-weight: 600; + } + + .preview-topbar-pill { + font-size: 6px; + font-weight: 600; + padding: 0.125rem 0.375rem; + border-radius: 99px; + } + + .preview-cards { + display: flex; + gap: 0.375rem; + padding: 0.5rem 0.625rem; + } + + .preview-stat-card { + flex: 1; + padding: 0.375rem 0.5rem; + border-radius: 4px; + border: 1px solid; + } + + .preview-stat-label { + display: block; + font-size: 6px; + } + + .preview-stat-value { + display: block; + font-size: 13px; + font-weight: 700; + margin: 0.125rem 0; + } + + .preview-stat-bar { + height: 3px; + background: rgba(128,128,128,0.15); + border-radius: 2px; + overflow: hidden; + } + + .preview-stat-fill { + height: 100%; + width: 80%; + border-radius: 2px; + } + + .preview-chips { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + padding: 0.25rem 0.625rem; + } + + .preview-chip { + font-size: 6px; + font-weight: 600; + padding: 0.125rem 0.375rem; + border-radius: 99px; + border: 1px solid; + } + + .preview-action-btn { + margin: auto 0.625rem 0.625rem; + padding: 0.3125rem 0.5rem; + border: none; + border-radius: 4px; + font-size: 7px; + font-weight: 600; + cursor: default; + text-align: center; } `] }) @@ -679,6 +1429,9 @@ export class BrandingEditorComponent implements OnInit { readonly success = signal(null); readonly hasChanges = signal(false); + private readonly _openSections = signal(new Set(['identity', 'brand'])); + readonly openSections = this._openSections.asReadonly(); + formData = { title: '', logoUrl: '', @@ -689,14 +1442,95 @@ export class BrandingEditorComponent implements OnInit { themeTokens: ThemeToken[] = []; newToken = { key: '', value: '' }; + // ----------------------------------------------------------------- + // Color picker definitions with CORRECT defaults from _colors.scss + // ----------------------------------------------------------------- + + readonly brandColorPickers: ColorPickerDef[] = [ + { token: '--theme-brand-primary', label: 'Primary Brand', fallback: LIGHT_DEFAULTS['--color-brand-primary'], description: 'Buttons, accents, active states' }, + { token: '--theme-brand-secondary', label: 'Secondary Brand', fallback: LIGHT_DEFAULTS['--color-brand-secondary'], description: 'Hover and complementary accents' }, + ]; + + readonly surfaceColorPickers: ColorPickerDef[] = [ + { token: '--theme-bg-primary', label: 'Page Background', fallback: LIGHT_DEFAULTS['--color-surface-primary'], description: 'Main canvas' }, + { token: '--theme-bg-secondary', label: 'Card Background', fallback: LIGHT_DEFAULTS['--color-surface-secondary'], description: 'Cards, inputs, subtle panels' }, + { token: '--theme-bg-tertiary', label: 'Elevated Surface', fallback: LIGHT_DEFAULTS['--color-surface-tertiary'], description: 'Hover backgrounds, tertiary' }, + { token: '--theme-bg-sidebar', label: 'Sidebar Background', fallback: LIGHT_DEFAULTS['--color-sidebar-bg'], description: 'Permanent dark sidebar rail' }, + ]; + + readonly textColorPickers: ColorPickerDef[] = [ + { token: '--theme-text-primary', label: 'Primary Text', fallback: LIGHT_DEFAULTS['--color-text-primary'], description: 'Headings and body text' }, + { token: '--theme-text-secondary', label: 'Secondary Text', fallback: LIGHT_DEFAULTS['--color-text-secondary'], description: 'Descriptions and labels' }, + { token: '--theme-text-muted', label: 'Muted Text', fallback: LIGHT_DEFAULTS['--color-text-muted'], description: 'Hints, captions, timestamps' }, + ]; + + readonly statusColorPickers: ColorPickerDef[] = [ + { token: '--theme-status-success', label: 'Success', fallback: LIGHT_DEFAULTS['--color-status-success'] }, + { token: '--theme-status-warning', label: 'Warning', fallback: LIGHT_DEFAULTS['--color-status-warning'] }, + { token: '--theme-status-error', label: 'Error', fallback: LIGHT_DEFAULTS['--color-status-error'] }, + { token: '--theme-status-info', label: 'Info', fallback: LIGHT_DEFAULTS['--color-status-info'] }, + ]; + + readonly nightColorPickers: ColorPickerDef[] = [ + { token: '--theme-bg-night', label: 'Night Background', fallback: DARK_DEFAULTS['--color-surface-primary'], description: 'Page background in dark mode' }, + { token: '--theme-bg-night-secondary', label: 'Night Card', fallback: DARK_DEFAULTS['--color-surface-secondary'], description: 'Card background in dark mode' }, + { token: '--theme-text-night', label: 'Night Text', fallback: DARK_DEFAULTS['--color-text-primary'], description: 'Primary text in dark mode' }, + { token: '--theme-brand-night', label: 'Night Brand', fallback: DARK_DEFAULTS['--color-brand-primary'], description: 'Brand accent in dark mode' }, + ]; + readonly themeCategories = [ { prefix: '--theme-bg-', label: 'Background Colors' }, { prefix: '--theme-text-', label: 'Text Colors' }, { prefix: '--theme-border-', label: 'Border Colors' }, { prefix: '--theme-brand-', label: 'Brand Colors' }, - { prefix: '--theme-status-', label: 'Status Colors' } + { prefix: '--theme-status-', label: 'Status Colors' }, + { prefix: '--theme-focus-', label: 'Focus Colors' }, ]; + // ----------------------------------------------------------------- + // Resolved preview values (check token first, then fallback) + // ----------------------------------------------------------------- + + resolvedBrandColor = computed(() => + this.getTokenValue('--theme-brand-primary') || LIGHT_DEFAULTS['--color-brand-primary'] + ); + + resolvedSurfacePrimary = computed(() => + this.getTokenValue('--theme-bg-primary') || LIGHT_DEFAULTS['--color-surface-primary'] + ); + + resolvedSurfaceSecondary = computed(() => + this.getTokenValue('--theme-bg-secondary') || LIGHT_DEFAULTS['--color-surface-secondary'] + ); + + resolvedTextPrimary = computed(() => + this.getTokenValue('--theme-text-primary') || LIGHT_DEFAULTS['--color-text-primary'] + ); + + resolvedTextMuted = computed(() => + this.getTokenValue('--theme-text-muted') || LIGHT_DEFAULTS['--color-text-muted'] + ); + + resolvedBorderColor = computed(() => + this.getTokenValue('--theme-border-primary') || LIGHT_DEFAULTS['--color-border-primary'] + ); + + resolvedStatusSuccess = computed(() => + this.getTokenValue('--theme-status-success') || LIGHT_DEFAULTS['--color-status-success'] + ); + + resolvedStatusWarning = computed(() => + this.getTokenValue('--theme-status-warning') || LIGHT_DEFAULTS['--color-status-warning'] + ); + + resolvedStatusError = computed(() => + this.getTokenValue('--theme-status-error') || LIGHT_DEFAULTS['--color-status-error'] + ); + + resolvedStatusInfo = computed(() => + this.getTokenValue('--theme-status-info') || LIGHT_DEFAULTS['--color-status-info'] + ); + get canWrite(): boolean { return this.auth.hasScope(StellaOpsScopes.AUTHORITY_BRANDING_WRITE); } @@ -705,6 +1539,16 @@ export class BrandingEditorComponent implements OnInit { this.loadCurrentBranding(); } + toggleSection(key: string): void { + const current = new Set(this._openSections()); + if (current.has(key)) { + current.delete(key); + } else { + current.add(key); + } + this._openSections.set(current); + } + loadCurrentBranding(): void { this.isLoading.set(true); this.error.set(null); @@ -746,10 +1590,6 @@ export class BrandingEditorComponent implements OnInit { return this.themeTokens.filter(t => t.key.startsWith(prefix)); } - formatTokenLabel(key: string): string { - return key.replace(/^--theme-/, '').replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); - } - isColorToken(key: string): boolean { return key.includes('color') || key.includes('bg') || key.includes('text') || key.includes('border') || key.includes('brand') || key.includes('status'); @@ -760,19 +1600,42 @@ export class BrandingEditorComponent implements OnInit { return token?.value || ''; } + setTokenValue(key: string, value: string): void { + const existing = this.themeTokens.find(t => t.key === key); + if (existing) { + existing.value = value; + } else { + this.themeTokens.push({ + key, + value, + category: this.getCategoryForToken(key) + }); + } + this.markAsChanged(); + } + markAsChanged(): void { if (!this.canWrite) { return; } - this.hasChanges.set(true); this.success.set(null); } + resetAllToDefaults(): void { + if (!this.canWrite) return; + + // Clear all color tokens back to empty (fallbacks will show) + this.themeTokens = []; + this.formData.themeTokens = {}; + this.formData.title = ''; + this.formData.logoUrl = ''; + this.formData.faviconUrl = ''; + this.markAsChanged(); + } + async onLogoSelected(event: Event): Promise { - if (!this.canWrite) { - return; - } + if (!this.canWrite) return; const input = event.target as HTMLInputElement; if (!input.files || input.files.length === 0) return; @@ -789,15 +1652,13 @@ export class BrandingEditorComponent implements OnInit { this.formData.logoUrl = dataUri; this.markAsChanged(); this.error.set(null); - } catch (err) { + } catch { this.error.set('Failed to process logo file'); } } async onFaviconSelected(event: Event): Promise { - if (!this.canWrite) { - return; - } + if (!this.canWrite) return; const input = event.target as HTMLInputElement; if (!input.files || input.files.length === 0) return; @@ -814,37 +1675,27 @@ export class BrandingEditorComponent implements OnInit { this.formData.faviconUrl = dataUri; this.markAsChanged(); this.error.set(null); - } catch (err) { + } catch { this.error.set('Failed to process favicon file'); } } removeLogo(): void { - if (!this.canWrite) { - return; - } - + if (!this.canWrite) return; this.formData.logoUrl = ''; this.markAsChanged(); } removeFavicon(): void { - if (!this.canWrite) { - return; - } - + if (!this.canWrite) return; this.formData.faviconUrl = ''; this.markAsChanged(); } addCustomToken(): void { - if (!this.canWrite) { - return; - } - + if (!this.canWrite) return; if (!this.newToken.key || !this.newToken.value) return; - // Ensure key starts with --theme- let key = this.newToken.key.trim(); if (!key.startsWith('--theme-')) { key = '--theme-' + key.replace(/^--/, ''); @@ -868,7 +1719,6 @@ export class BrandingEditorComponent implements OnInit { this.error.set(null); this.success.set(null); - // Build theme tokens object from themeTokens array const themeTokens: Record = {}; this.themeTokens.forEach(token => { themeTokens[token.key] = token.value; diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/clients/clients-list.component.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/clients/clients-list.component.ts index e94ecc38b..4f214f183 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/clients/clients-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/clients/clients-list.component.ts @@ -117,7 +117,7 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code. } @else if (clients.length === 0 && !isCreating) {
No OAuth2 clients configured
} @else { -
Component
+
@@ -293,7 +293,6 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code. .admin-table { width: 100%; - border-collapse: collapse; background: var(--theme-bg-secondary); border-radius: var(--radius-lg); overflow: hidden; @@ -333,7 +332,7 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code. .badge { display: inline-block; padding: 2px 8px; - background: var(--theme-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); border-radius: var(--radius-sm); font-size: var(--font-size-sm); @@ -378,12 +377,12 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code. } .btn-primary { - background: var(--theme-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } .btn-primary:hover:not(:disabled) { - background: var(--theme-brand-hover); + background: var(--color-btn-primary-bg-hover); } .btn-primary:disabled { diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/console-admin-layout.component.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/console-admin-layout.component.ts index 1ca8889fe..dd5098408 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/console-admin-layout.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/console-admin-layout.component.ts @@ -1,15 +1,73 @@ -import { Component, ChangeDetectionStrategy } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { Component, ChangeDetectionStrategy, DestroyRef, inject, OnInit, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router'; +import { filter } from 'rxjs'; + +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +type TabType = 'tenants' | 'users' | 'roles' | 'clients' | 'tokens' | 'audit' | 'branding'; + +const KNOWN_TAB_IDS: readonly string[] = ['tenants', 'users', 'roles', 'clients', 'tokens', 'audit', 'branding']; + +const PAGE_TABS: readonly StellaPageTab[] = [ + { id: 'tenants', label: 'Tenants', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M2 12h20|||M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z' }, + { id: 'users', label: 'Users', icon: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|||M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|||M20 8v6|||M23 11h-6' }, + { id: 'roles', label: 'Roles', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'clients', label: 'Clients', icon: 'M18 12h2|||M4 12h2|||M12 4v2|||M12 18v2|||M9 9h6v6H9z' }, + { id: 'tokens', label: 'Tokens', icon: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' }, + { id: 'audit', label: 'Audit', icon: 'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2|||M8 2h8v4H8z' }, + { id: 'branding', label: 'Branding', icon: 'M12 2L2 7l10 5 10-5-10-5z|||M2 17l10 5 10-5|||M2 12l10 5 10-5' }, +]; /** - * Simple layout wrapper for Console Admin child routes. - * Provides the needed for nested route rendering. + * Layout wrapper for Console Admin child routes. + * Provides canonical stella-page-tabs navigation and for nested route rendering. */ @Component({ selector: 'app-console-admin-layout', standalone: true, - imports: [RouterOutlet], - template: ``, + imports: [RouterOutlet, StellaPageTabsComponent], + template: ` + + + + `, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class ConsoleAdminLayoutComponent {} +export class ConsoleAdminLayoutComponent implements OnInit { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); + + readonly pageTabs = PAGE_TABS; + readonly activeTab = signal('tenants'); + + ngOnInit(): void { + this.setActiveTabFromUrl(this.router.url); + this.router.events.pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef), + ).subscribe(e => this.setActiveTabFromUrl(e.urlAfterRedirects)); + } + + onTabChange(tabId: string): void { + this.activeTab.set(tabId as TabType); + this.router.navigate([tabId], { relativeTo: this.route, queryParamsHandling: 'merge' }); + } + + private setActiveTabFromUrl(url: string): void { + const segments = url.split('?')[0].split('/').filter(Boolean); + const lastSegment = segments.at(-1) ?? ''; + if (KNOWN_TAB_IDS.includes(lastSegment as TabType)) { + this.activeTab.set(lastSegment as TabType); + } else { + // Default to 'tenants' when at the admin root + this.activeTab.set('tenants'); + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/roles/roles-list.component.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/roles/roles-list.component.ts index 1bac09c1d..a0e664a3f 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/roles/roles-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/roles/roles-list.component.ts @@ -152,7 +152,7 @@ interface RoleBundle { } @else if (customRoles.length === 0 && !isCreating) {
No custom roles defined
} @else { -
Client ID
+
@@ -436,7 +436,6 @@ interface RoleBundle { .admin-table { width: 100%; - border-collapse: collapse; background: var(--theme-bg-secondary); border-radius: var(--radius-lg); overflow: hidden; @@ -471,7 +470,7 @@ interface RoleBundle { .badge { display: inline-block; padding: 2px 8px; - background: var(--theme-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); border-radius: var(--radius-sm); font-size: var(--font-size-sm); @@ -494,12 +493,12 @@ interface RoleBundle { } .btn-primary { - background: var(--theme-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } .btn-primary:hover:not(:disabled) { - background: var(--theme-brand-hover); + background: var(--color-btn-primary-bg-hover); } .btn-primary:disabled { diff --git a/src/Web/StellaOps.Web/src/app/features/console-admin/users/users-list.component.ts b/src/Web/StellaOps.Web/src/app/features/console-admin/users/users-list.component.ts index 8d8132896..680f5ed6c 100644 --- a/src/Web/StellaOps.Web/src/app/features/console-admin/users/users-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console-admin/users/users-list.component.ts @@ -84,7 +84,7 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code. } @else if (users.length === 0 && !isCreating) {
No users found
} @else { -
Role Name
+
@@ -210,7 +210,6 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code. .admin-table { width: 100%; - border-collapse: collapse; background: var(--theme-bg-secondary); border-radius: var(--radius-lg); overflow: hidden; @@ -249,7 +248,7 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code. .badge { display: inline-block; padding: 2px 8px; - background: var(--theme-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); border-radius: var(--radius-sm); font-size: var(--font-size-sm); @@ -290,12 +289,12 @@ import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code. } .btn-primary { - background: var(--theme-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } .btn-primary:hover:not(:disabled) { - background: var(--theme-brand-hover); + background: var(--color-btn-primary-bg-hover); } .btn-primary:disabled { diff --git a/src/Web/StellaOps.Web/src/app/features/console/console-profile.component.html b/src/Web/StellaOps.Web/src/app/features/console/console-profile.component.html index 07f527bda..cc3ed1d96 100644 --- a/src/Web/StellaOps.Web/src/app/features/console/console-profile.component.html +++ b/src/Web/StellaOps.Web/src/app/features/console/console-profile.component.html @@ -23,9 +23,7 @@ } @if (loading()) { -
- Loading profile context... -
+ } @if (!loading()) { diff --git a/src/Web/StellaOps.Web/src/app/features/console/console-profile.component.scss b/src/Web/StellaOps.Web/src/app/features/console/console-profile.component.scss index 3a2cd4162..945aa6651 100644 --- a/src/Web/StellaOps.Web/src/app/features/console/console-profile.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/console/console-profile.component.scss @@ -57,7 +57,7 @@ padding: var(--space-3) var(--space-4); border-radius: var(--radius-lg); background: var(--color-brand-light); - color: var(--color-brand-primary); + color: var(--color-text-link); font-weight: var(--font-weight-medium); } @@ -140,7 +140,7 @@ .tenant-chip { background-color: var(--color-brand-light); - color: var(--color-brand-primary); + color: var(--color-text-link); } .tenant-count { diff --git a/src/Web/StellaOps.Web/src/app/features/console/console-profile.component.ts b/src/Web/StellaOps.Web/src/app/features/console/console-profile.component.ts index 6b889b651..0d406074d 100644 --- a/src/Web/StellaOps.Web/src/app/features/console/console-profile.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/console/console-profile.component.ts @@ -7,12 +7,13 @@ import { inject, } from '@angular/core'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; import { ConsoleSessionService } from '../../core/console/console-session.service'; import { ConsoleSessionStore } from '../../core/console/console-session.store'; @Component({ selector: 'app-console-profile', - imports: [CommonModule], + imports: [CommonModule, LoadingStateComponent], templateUrl: './console-profile.component.html', styleUrls: ['./console-profile.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/Web/StellaOps.Web/src/app/features/control-plane/control-plane-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/control-plane/control-plane-dashboard.component.ts index 0b25442cd..d75355ba6 100644 --- a/src/Web/StellaOps.Web/src/app/features/control-plane/control-plane-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/control-plane/control-plane-dashboard.component.ts @@ -863,12 +863,12 @@ import { LoadingStateComponent } from '../../shared/components/loading-state/loa } .btn--primary { - background: var(--so-brand); - color: var(--color-surface-inverse); + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); } .btn--primary:hover { - background: var(--so-brand-hover); + background: var(--color-btn-primary-bg-hover); transform: translateY(-1px); } diff --git a/src/Web/StellaOps.Web/src/app/features/cvss/cvss-receipt.component.scss b/src/Web/StellaOps.Web/src/app/features/cvss/cvss-receipt.component.scss index 6b009efd9..74acebfce 100644 --- a/src/Web/StellaOps.Web/src/app/features/cvss/cvss-receipt.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/cvss/cvss-receipt.component.scss @@ -77,7 +77,7 @@ .cvss-tabs button.active { border-bottom: 2px solid var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); } .cvss-panel { diff --git a/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts b/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts index d512ec82a..b56ade6b1 100644 --- a/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/dashboard-v3/dashboard-v3.component.ts @@ -13,8 +13,16 @@ import { inject, signal, OnInit, + AfterViewInit, + ViewChild, + ElementRef, + NgZone, } from '@angular/core'; import { RouterLink } from '@angular/router'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; +import { StellaQuickLinksComponent, StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.component'; +import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component'; +import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component'; import { catchError, take } from 'rxjs/operators'; import { of } from 'rxjs'; @@ -62,7 +70,7 @@ interface AdvisoryFeedSummary { @Component({ selector: 'app-dashboard-v3', standalone: true, - imports: [RouterLink], + imports: [RouterLink, LoadingStateComponent, StellaQuickLinksComponent, StellaMetricCardComponent, StellaMetricGridComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -131,7 +139,7 @@ interface AdvisoryFeedSummary {
@if (vulnStatsLoading()) { -

Loading...

+ } @else if (vulnStats(); as stats) {
@@ -173,22 +181,26 @@ interface AdvisoryFeedSummary {

SBOM Health

-
-
- - {{ sbomStats().criticalEnvCount }} - - Critical Envs -
-
- {{ sbomStats().totalCritR }} - Critical Reachable -
-
- {{ sbomStats().noIssueCount }} - Clean Envs -
-
+ + + + + @if (sbomStats().totalCritR === 0 && sbomStats().criticalEnvCount === 0) {

No critical reachable issues in current scope.

} @@ -205,7 +217,7 @@ interface AdvisoryFeedSummary {
@if (feedStatusLoading()) { -

Loading...

+ } @else if (feedSummary().loaded && feedSummary().totalSources > 0) {
@@ -245,27 +257,36 @@ interface AdvisoryFeedSummary {
-
-
-
{{ filteredEnvironments().length }}
-
Environments
-
-
-
{{ blockedCount() }}
-
Blocked
-
-
-
{{ degradedCount() }}
-
Degraded
-
-
-
- - {{ healthyCount() }} -
-
Healthy
-
-
+ + + + + +
@@ -274,78 +295,104 @@ interface AdvisoryFeedSummary { All environments
-
- @for (env of filteredEnvironments(); track env.id) { -
-
-
- {{ env.name }} - {{ env.region }} -
- - {{ env.deployStatus }} - -
- -
-
- SBOM - - {{ env.sbomFreshness }} - -
-
- CritR - - {{ env.critRCount }} - -
-
- HighR - - {{ env.highRCount }} - -
-
- B/I/R - {{ env.birCoverage }} -
-
- Pending - - {{ env.pendingApprovals }} - -
-
- - -
+
+ @if (showPipelineLeftArrow()) { + } +
+ @for (env of filteredEnvironments(); track env.id) { +
+
+
+ {{ env.name }} + {{ env.region }} +
+ + {{ env.deployStatus }} + +
- @if (filteredEnvironments().length === 0) { -
-

No environments match the current filter.

-
+
+
+ SBOM + + {{ env.sbomFreshness }} + +
+
+ CritR + + {{ env.critRCount }} + +
+
+ HighR + + {{ env.highRCount }} + +
+
+ B/I/R + {{ env.birCoverage }} +
+
+ Pending + + {{ env.pendingApprovals }} + +
+
+ + +
+ } + + @if (filteredEnvironments().length === 0) { +
+

No environments match the current filter.

+
+ } +
+ @if (showPipelineRightArrow()) { + }
@@ -357,86 +404,98 @@ interface AdvisoryFeedSummary {

Environments at Risk

Open environments
-
-
Email
- - - - - - - - - - - - @for (env of riskEnvironments(); track env.id) { +
+ @if (riskTableHasOverflow()) { + + } +
+
Region/EnvHealthSBOMCritRB/I/RAction
+ - - - - - - + + + + + + - } - -
{{ env.region }} / {{ env.name }}{{ env.deployStatus }}{{ env.sbomFreshness }}{{ env.critRCount }}{{ env.birCoverage }} - - Open - - Region/EnvHealthSBOMCritRB/I/RAction
+ + + @for (env of riskEnvironments(); track env.id) { + + {{ env.region }} / {{ env.name }} + {{ env.deployStatus }} + {{ env.sbomFreshness }} + {{ env.critRCount }} + {{ env.birCoverage }} + + + Open + + + + } + + +
} - - +
+ + Feeds +
+
+ + Security +
+
+ + Evidence +
+
+ + DLQ +
+ + + Diagnostics + + + + + } - - -
-
- - Services -
-
- - Feeds -
-
- - Security -
-
- - Evidence -
-
- - DLQ -
- - - Diagnostics - -
`, styles: [` @@ -446,11 +505,12 @@ interface AdvisoryFeedSummary { .mission-board { padding: 1.5rem; - max-width: 1600px; + max-width: 100%; margin: 0 auto; display: grid; grid-template-columns: 1fr; - grid-template-rows: auto 1fr auto; + grid-template-rows: auto 1fr; + overflow: hidden; gap: 1.5rem; min-height: calc(100vh - 120px); } @@ -480,16 +540,16 @@ interface AdvisoryFeedSummary { padding: 0.4rem 1rem; font-size: 0.8rem; font-weight: var(--font-weight-semibold); - border: none; + border: 1px solid var(--color-btn-primary-border); border-radius: var(--radius-md); background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); cursor: pointer; - transition: opacity 150ms ease, transform 150ms ease; + transition: opacity 150ms ease, transform 150ms ease, background 150ms ease; } .refresh-btn:hover:not(:disabled) { - opacity: 0.9; + background: var(--color-btn-primary-bg-hover); transform: translateY(-1px); } @@ -554,7 +614,7 @@ interface AdvisoryFeedSummary { width: 28px; height: 28px; border-radius: var(--radius-full); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); font-size: 0.85rem; font-weight: var(--font-weight-bold); @@ -580,9 +640,17 @@ interface AdvisoryFeedSummary { /* -- Board Body: 2-column layout ----------------------------------------- */ .board-body { display: grid; - grid-template-columns: 1fr 2fr; + grid-template-columns: minmax(220px, 1fr) minmax(0, 4fr); gap: 1.5rem; align-items: start; + max-width: 100%; + overflow: hidden; + } + + @media (max-width: 900px) { + .board-body { + grid-template-columns: 1fr; + } } /* -- Left Column: Security Posture --------------------------------------- */ @@ -590,6 +658,7 @@ interface AdvisoryFeedSummary { display: flex; flex-direction: column; gap: 1rem; + min-width: 220px; } .posture-card { @@ -658,11 +727,12 @@ interface AdvisoryFeedSummary { .posture-link { display: inline-block; font-size: 0.8rem; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } .posture-link:hover { + color: var(--color-text-link-hover); text-decoration: underline; } @@ -724,36 +794,7 @@ interface AdvisoryFeedSummary { color: var(--color-text-muted); } - /* SBOM stats */ - .sbom-stats { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 0.5rem; - text-align: center; - } - - .sbom-stat { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.1rem; - } - - .sbom-stat-value { - font-size: 1.25rem; - font-weight: var(--font-weight-bold); - } - - .sbom-stat-value.danger { - color: var(--color-status-error); - } - - .sbom-stat-label { - font-size: 0.68rem; - color: var(--color-text-secondary); - text-transform: uppercase; - letter-spacing: 0.03em; - } + /* SBOM stats — now uses stella-metric-grid/card */ /* Feed stats */ .feed-stats { @@ -796,49 +837,11 @@ interface AdvisoryFeedSummary { display: flex; flex-direction: column; gap: 1.25rem; + min-width: 0; + overflow: hidden; } - /* Mission Summary Strip */ - .mission-summary { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 0.75rem; - } - - .summary-card { - background: var(--color-surface-primary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - padding: 0.75rem 1rem; - display: flex; - flex-direction: column; - gap: 0.15rem; - transition: transform 150ms ease, box-shadow 150ms ease; - } - - .summary-card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); - } - - .summary-card.warning { - border-left: 4px solid var(--color-status-warning); - } - - .summary-card.critical { - border-left: 4px solid var(--color-status-error); - } - - .summary-value { - font-size: 1.5rem; - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); - } - - .summary-label { - font-size: 0.75rem; - color: var(--color-text-secondary); - } + /* Mission Summary Strip — now uses stella-metric-grid/card */ /* Pipeline Board */ .pipeline-board { @@ -863,18 +866,27 @@ interface AdvisoryFeedSummary { .section-link { font-size: 0.85rem; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } .env-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); - gap: 1rem; + display: flex; + overflow-x: auto; + flex-wrap: nowrap; + gap: 0.75rem; + max-height: 220px; + padding-bottom: 0.25rem; + scrollbar-width: none; + scroll-behavior: smooth; + } + + .env-grid::-webkit-scrollbar { + display: none; } .env-grid-empty { - grid-column: 1 / -1; + width: 100%; padding: 2rem; text-align: center; color: var(--color-text-secondary); @@ -886,6 +898,8 @@ interface AdvisoryFeedSummary { overflow: hidden; background: var(--color-surface-elevated); transition: transform 150ms ease, box-shadow 150ms ease; + flex-shrink: 0; + width: 280px; } .env-card:hover { @@ -990,7 +1004,7 @@ interface AdvisoryFeedSummary { .env-link { font-size: 0.75rem; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } @@ -1004,11 +1018,17 @@ interface AdvisoryFeedSummary { .risk-table__container { overflow-x: auto; + max-height: 300px; + overflow-y: auto; + scrollbar-width: none; + } + + .risk-table__container::-webkit-scrollbar { + display: none; } .risk-table__table { width: 100%; - border-collapse: collapse; font-size: 0.82rem; } @@ -1031,7 +1051,7 @@ interface AdvisoryFeedSummary { } .risk-table__table td a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } @@ -1040,50 +1060,6 @@ interface AdvisoryFeedSummary { font-weight: var(--font-weight-semibold); } - /* Quick Links */ - .quick-links { - background: var(--color-surface-primary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - padding: 1rem 1.25rem; - } - - .quick-links-title { - margin: 0 0 0.75rem; - font-size: 0.85rem; - font-weight: var(--font-weight-semibold); - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--color-text-secondary); - } - - .quick-links-grid { - display: flex; - gap: 0.6rem; - flex-wrap: wrap; - } - - .domain-nav-item { - display: inline-flex; - align-items: center; - gap: 0.4rem; - padding: 0.4rem 0.9rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-full); - font-size: 0.82rem; - color: var(--color-text-primary); - text-decoration: none; - background: var(--color-surface-elevated); - transition: background 150ms ease, border-color 150ms ease, transform 150ms ease, box-shadow 150ms ease; - } - - .domain-nav-item:hover { - background: var(--color-surface-primary); - border-color: var(--color-brand-primary); - transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); - } - /* -- Footer: Platform Health Bar ----------------------------------------- */ .platform-health-bar { display: flex; @@ -1091,7 +1067,7 @@ interface AdvisoryFeedSummary { gap: 1rem; flex-wrap: wrap; padding: 0.65rem 1rem; - background: var(--color-surface-primary); + background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); font-size: 0.78rem; @@ -1122,7 +1098,7 @@ interface AdvisoryFeedSummary { .health-link { font-size: 0.78rem; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } @@ -1144,6 +1120,140 @@ interface AdvisoryFeedSummary { .status-dot.degraded { background: var(--color-status-warning); } .status-dot.error { background: var(--color-status-error); } + /* -- Pipeline Scroll Arrows & Gradients ----------------------------------- */ + .env-grid-wrapper { + position: relative; + max-width: 100%; + } + + /* Left gradient fade */ + .env-grid-wrapper.can-scroll-left::before { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 56px; + background: linear-gradient(to right, var(--color-surface-secondary) 0%, transparent 100%); + pointer-events: none; + z-index: 1; + } + + /* Right gradient fade */ + .env-grid-wrapper.can-scroll-right::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 56px; + background: linear-gradient(to left, var(--color-surface-secondary) 0%, transparent 100%); + pointer-events: none; + z-index: 1; + } + + .scroll-arrow { + position: absolute; + top: 0.5rem; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-full); + border: 1px solid var(--color-border-primary); + background: var(--color-surface-primary); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + color: var(--color-text-primary); + cursor: pointer; + padding: 0; + transition: background 150ms ease, box-shadow 150ms ease, transform 150ms ease; + } + + .scroll-arrow:hover { + background: var(--color-surface-elevated); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + transform: scale(1.1); + } + + .scroll-arrow:active { + transform: scale(0.95); + } + + .scroll-arrow--left { + left: 0.5rem; + } + + .scroll-arrow--right { + right: 0.5rem; + } + + /* -- Risk Table Scroll Fade ---------------------------------------------- */ + .risk-table__scroll-wrapper { + position: relative; + } + + /* Top gradient fade (when scrolled down) */ + .risk-table__scroll-wrapper.has-overflow.scrolled-from-top::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 48px; + background: linear-gradient(var(--color-surface-primary) 0%, transparent 100%); + pointer-events: none; + z-index: 1; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + } + + /* Bottom gradient fade (when not scrolled to bottom) */ + .risk-table__scroll-wrapper.has-overflow:not(.scrolled-to-bottom)::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 48px; + background: linear-gradient(transparent 0%, var(--color-surface-primary) 100%); + pointer-events: none; + z-index: 1; + border-radius: 0 0 var(--radius-lg) var(--radius-lg); + } + + /* Single scroll button — bottom-right by default, top-right when at bottom */ + .risk-table__scroll-btn { + position: absolute; + bottom: 0.5rem; + right: 0.5rem; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border-radius: var(--radius-full); + border: 1px solid var(--color-border-primary); + background: var(--color-surface-elevated); + color: var(--color-text-secondary); + cursor: pointer; + padding: 0; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12); + transition: all 180ms ease; + } + + .risk-table__scroll-btn:hover { + background: var(--color-surface-primary); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18); + transform: scale(1.1); + } + + .risk-table__scroll-btn--at-bottom { + bottom: auto; + top: 0.5rem; + } + /* -- Responsive: collapse to single column on mobile --------------------- */ @media (max-width: 768px) { .board-body { @@ -1161,11 +1271,31 @@ interface AdvisoryFeedSummary { } `], }) -export class DashboardV3Component implements OnInit { +export class DashboardV3Component implements OnInit, AfterViewInit { private readonly context = inject(PlatformContextStore); private readonly vulnApi = inject(VULNERABILITY_API); private readonly sourceApi = inject(SourceManagementApi); private readonly authService = inject(AUTH_SERVICE) as AuthService; + private readonly ngZone = inject(NgZone); + + // -- Scroll refs and signals ------------------------------------------------ + @ViewChild('pipelineScroll') pipelineScrollRef?: ElementRef; + @ViewChild('riskTableScroll') riskTableScrollRef?: ElementRef; + + readonly showPipelineLeftArrow = signal(false); + readonly showPipelineRightArrow = signal(false); + readonly riskTableHasOverflow = signal(false); + readonly riskTableAtBottom = signal(false); + readonly riskTableAtTop = signal(true); + + readonly dashboardQuickLinks: StellaQuickLink[] = [ + { label: 'Release Runs', route: '/releases/runs' }, + { label: 'Security & Risk', route: '/security' }, + { label: 'Operations', route: '/ops/operations' }, + { label: 'Evidence', route: '/evidence' }, + { label: 'Platform Setup', route: '/ops/platform-setup' }, + { label: 'Diagnostics', route: '/ops/operations/doctor' }, + ]; // -- Loading states ------------------------------------------------------- readonly vulnStatsLoading = signal(false); @@ -1220,6 +1350,82 @@ export class DashboardV3Component implements OnInit { this.loadFeedStatus(); } + ngAfterViewInit(): void { + // Check scroll arrows at multiple intervals to catch async data rendering + const checkScroll = () => { + this.ngZone.run(() => { + this.updatePipelineArrows(); + this.updateRiskTableOverflow(); + }); + }; + this.ngZone.runOutsideAngular(() => { + setTimeout(checkScroll, 200); + setTimeout(checkScroll, 600); + setTimeout(checkScroll, 1500); + }); + } + + onPipelineScroll(): void { + this.updatePipelineArrows(); + } + + scrollPipeline(direction: 'left' | 'right'): void { + const el = this.pipelineScrollRef?.nativeElement; + if (!el) return; + const scrollAmount = 300; + el.scrollBy({ + left: direction === 'left' ? -scrollAmount : scrollAmount, + behavior: 'smooth', + }); + } + + onRiskTableScroll(): void { + this.updateRiskTableOverflow(); + } + + scrollRiskTable(direction: 'up' | 'down'): void { + const el = this.riskTableScrollRef?.nativeElement; + if (!el) return; + const scrollAmount = 150; + el.scrollBy({ + top: direction === 'up' ? -scrollAmount : scrollAmount, + behavior: 'smooth', + }); + } + + private updatePipelineArrows(): void { + const el = this.pipelineScrollRef?.nativeElement; + if (!el) { + this.showPipelineLeftArrow.set(false); + this.showPipelineRightArrow.set(false); + return; + } + // scrollLeft > 1 accounts for sub-pixel rounding + this.showPipelineLeftArrow.set(el.scrollLeft > 1); + // scrollWidth - scrollLeft - clientWidth > 1 means more content to the right + this.showPipelineRightArrow.set(el.scrollWidth - el.scrollLeft - el.clientWidth > 1); + } + + private updateRiskTableOverflow(): void { + const el = this.riskTableScrollRef?.nativeElement; + if (!el) { + this.riskTableHasOverflow.set(false); + this.riskTableAtBottom.set(false); + this.riskTableAtTop.set(true); + return; + } + const hasOverflow = el.scrollHeight > el.clientHeight + 1; + this.riskTableHasOverflow.set(hasOverflow); + if (hasOverflow) { + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 2; + this.riskTableAtBottom.set(atBottom); + this.riskTableAtTop.set(el.scrollTop < 2); + } else { + this.riskTableAtBottom.set(false); + this.riskTableAtTop.set(true); + } + } + readonly allEnvironments = computed(() => { const environments = this.context.environments(); if (environments.length === 0) { @@ -1267,7 +1473,12 @@ export class DashboardV3Component implements OnInit { this.loadVulnerabilityStats(); this.loadFeedStatus(); // Reset refreshing after a short delay to provide feedback - setTimeout(() => this.refreshing.set(false), 1500); + setTimeout(() => { + this.refreshing.set(false); + // Re-check scroll states after data refresh + this.updatePipelineArrows(); + this.updateRiskTableOverflow(); + }, 1500); } environmentPostureRoute(env: EnvironmentCard): string[] { diff --git a/src/Web/StellaOps.Web/src/app/features/dashboard/ai-risk-drivers.component.ts b/src/Web/StellaOps.Web/src/app/features/dashboard/ai-risk-drivers.component.ts index 5d38f6d47..d6de96dde 100644 --- a/src/Web/StellaOps.Web/src/app/features/dashboard/ai-risk-drivers.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/dashboard/ai-risk-drivers.component.ts @@ -256,7 +256,7 @@ export interface DashboardAiData { justify-content: center; width: 1.5rem; height: 1.5rem; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border-radius: var(--radius-full); font-size: 0.75rem; font-weight: var(--font-weight-semibold); @@ -293,7 +293,7 @@ export interface DashboardAiData { border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); font-size: 0.75rem; - color: var(--color-brand-primary); + color: var(--color-text-link); cursor: pointer; transition: all 0.15s ease; white-space: nowrap; diff --git a/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.html b/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.html index b49ad1e2b..a5f0708fb 100644 --- a/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.html +++ b/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.html @@ -1,148 +1,145 @@ -
-
-

{{ 'ui.sources_dashboard.title' | translate }}

-
- - -
-
- - @if (loading()) { -
-
-

{{ 'ui.sources_dashboard.loading_aoc' | translate }}

-
- } - - @if (error()) { -
-

{{ error() }}

- -
- } - - @if (metrics(); as m) { -
- -
-

{{ 'ui.sources_dashboard.pass_fail_title' | translate }}

-
-
- {{ passRate() }}% - {{ 'ui.sources_dashboard.pass_rate' | translate }} -
-
-
- {{ m.passCount | number }} - {{ 'ui.sources_dashboard.passed' | translate }} -
-
- {{ m.failCount | number }} - {{ 'ui.sources_dashboard.failed' | translate }} -
-
- {{ m.totalCount | number }} - {{ 'ui.labels.total' | translate }} -
-
-
-
- - -
-

{{ 'ui.sources_dashboard.recent_violations' | translate }}

-
- @if (m.recentViolations.length === 0) { -

{{ 'ui.sources_dashboard.no_violations' | translate }}

- } @else { -
    - @for (v of m.recentViolations; track v.code) { -
  • -
    - {{ v.code }} - {{ v.count }}x -
    -

    {{ v.description }}

    - {{ formatRelativeTime(v.lastSeen) }} -
  • - } -
- } -
-
- - -
-

{{ 'ui.sources_dashboard.throughput_title' | translate }}

-
-
-
- {{ m.ingestThroughput.docsPerMinute | number:'1.1-1' }} - {{ 'ui.sources_dashboard.docs_per_min' | translate }} -
-
- {{ m.ingestThroughput.avgLatencyMs }} - {{ 'ui.sources_dashboard.avg_ms' | translate }} -
-
- {{ m.ingestThroughput.p95LatencyMs }} - {{ 'ui.sources_dashboard.p95_ms' | translate }} -
-
- {{ m.ingestThroughput.queueDepth }} - {{ 'ui.sources_dashboard.queue' | translate }} -
-
- {{ m.ingestThroughput.errorRate | number:'1.2-2' }}% - {{ 'ui.sources_dashboard.errors' | translate }} -
-
-
-
-
- - - @if (verificationResult(); as result) { -
-

{{ 'ui.sources_dashboard.verification_complete' | translate }}

-
- {{ result.status | titlecase }} - {{ 'ui.sources_dashboard.checked' | translate }} {{ result.checkedCount | number }} - {{ 'ui.sources_dashboard.passed' | translate }}: {{ result.passedCount | number }} - {{ 'ui.sources_dashboard.failed' | translate }}: {{ result.failedCount | number }} -
- @if (result.violations.length > 0) { -
- {{ 'ui.actions.view' | translate }} {{ result.violations.length }} {{ 'ui.sources_dashboard.violations' | translate }} -
    - @for (v of result.violations; track v.documentId) { -
  • - {{ v.violationCode }} in {{ v.documentId }} - @if (v.field) { -
    {{ 'ui.sources_dashboard.field' | translate }} {{ v.field }} ({{ 'ui.sources_dashboard.expected' | translate }} {{ v.expected }}, {{ 'ui.sources_dashboard.actual' | translate }} {{ v.actual }}) - } -
  • - } -
-
- } -

- {{ 'ui.sources_dashboard.cli_equivalent' | translate }} stella aoc verify --since=24h --tenant=default -

-
- } - -

- {{ 'ui.sources_dashboard.data_from' | translate }} {{ m.timeWindow.start | date:'short' }} {{ 'ui.sources_dashboard.to' | translate }} {{ m.timeWindow.end | date:'short' }} - ({{ m.timeWindow.durationMinutes / 60 | number:'1.0-0' }}{{ 'ui.sources_dashboard.hour_window' | translate }}) -

- } -
+
+
+

{{ 'ui.sources_dashboard.title' | translate }}

+
+ + +
+
+ + @if (loading()) { + + } + + @if (error()) { +
+

{{ error() }}

+ +
+ } + + @if (metrics(); as m) { +
+ +
+

{{ 'ui.sources_dashboard.pass_fail_title' | translate }}

+
+
+ {{ passRate() }}% + {{ 'ui.sources_dashboard.pass_rate' | translate }} +
+
+
+ {{ m.passCount | number }} + {{ 'ui.sources_dashboard.passed' | translate }} +
+
+ {{ m.failCount | number }} + {{ 'ui.sources_dashboard.failed' | translate }} +
+
+ {{ m.totalCount | number }} + {{ 'ui.labels.total' | translate }} +
+
+
+
+ + +
+

{{ 'ui.sources_dashboard.recent_violations' | translate }}

+
+ @if (m.recentViolations.length === 0) { +

{{ 'ui.sources_dashboard.no_violations' | translate }}

+ } @else { +
    + @for (v of m.recentViolations; track v.code) { +
  • +
    + {{ v.code }} + {{ v.count }}x +
    +

    {{ v.description }}

    + {{ formatRelativeTime(v.lastSeen) }} +
  • + } +
+ } +
+
+ + +
+

{{ 'ui.sources_dashboard.throughput_title' | translate }}

+
+
+
+ {{ m.ingestThroughput.docsPerMinute | number:'1.1-1' }} + {{ 'ui.sources_dashboard.docs_per_min' | translate }} +
+
+ {{ m.ingestThroughput.avgLatencyMs }} + {{ 'ui.sources_dashboard.avg_ms' | translate }} +
+
+ {{ m.ingestThroughput.p95LatencyMs }} + {{ 'ui.sources_dashboard.p95_ms' | translate }} +
+
+ {{ m.ingestThroughput.queueDepth }} + {{ 'ui.sources_dashboard.queue' | translate }} +
+
+ {{ m.ingestThroughput.errorRate | number:'1.2-2' }}% + {{ 'ui.sources_dashboard.errors' | translate }} +
+
+
+
+
+ + + @if (verificationResult(); as result) { +
+

{{ 'ui.sources_dashboard.verification_complete' | translate }}

+
+ {{ result.status | titlecase }} + {{ 'ui.sources_dashboard.checked' | translate }} {{ result.checkedCount | number }} + {{ 'ui.sources_dashboard.passed' | translate }}: {{ result.passedCount | number }} + {{ 'ui.sources_dashboard.failed' | translate }}: {{ result.failedCount | number }} +
+ @if (result.violations.length > 0) { +
+ {{ 'ui.actions.view' | translate }} {{ result.violations.length }} {{ 'ui.sources_dashboard.violations' | translate }} +
    + @for (v of result.violations; track v.documentId) { +
  • + {{ v.violationCode }} in {{ v.documentId }} + @if (v.field) { +
    {{ 'ui.sources_dashboard.field' | translate }} {{ v.field }} ({{ 'ui.sources_dashboard.expected' | translate }} {{ v.expected }}, {{ 'ui.sources_dashboard.actual' | translate }} {{ v.actual }}) + } +
  • + } +
+
+ } +

+ {{ 'ui.sources_dashboard.cli_equivalent' | translate }} stella aoc verify --since=24h --tenant=default +

+
+ } + +

+ {{ 'ui.sources_dashboard.data_from' | translate }} {{ m.timeWindow.start | date:'short' }} {{ 'ui.sources_dashboard.to' | translate }} {{ m.timeWindow.end | date:'short' }} + ({{ m.timeWindow.durationMinutes / 60 | number:'1.0-0' }}{{ 'ui.sources_dashboard.hour_window' | translate }}) +

+ } +
diff --git a/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.scss b/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.scss index af3c15aac..9f5116f65 100644 --- a/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.scss @@ -296,7 +296,7 @@ summary { cursor: pointer; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: var(--font-size-base); } diff --git a/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.ts index 4da231712..f4a3a0772 100644 --- a/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/dashboard/sources-dashboard.component.ts @@ -14,10 +14,11 @@ import { AocViolationSummary, AocVerificationResult, } from '../../core/api/aoc.models'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; @Component({ selector: 'app-sources-dashboard', - imports: [CommonModule, TranslatePipe], + imports: [CommonModule, TranslatePipe, LoadingStateComponent], templateUrl: './sources-dashboard.component.html', styleUrls: ['./sources-dashboard.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-dashboard.component.ts index 2d522a85d..83d10661d 100644 --- a/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-dashboard.component.ts @@ -432,7 +432,7 @@ import { ContextHeaderComponent } from '../../shared/ui/context-header/context-h .btn:hover:not(:disabled) { background: var(--color-surface-tertiary); } .btn:disabled { opacity: 0.5; cursor: not-allowed; } - .btn-primary { background: var(--color-primary); color: var(--color-btn-primary-text); border-color: var(--color-primary); } + .btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border-color: var(--color-btn-primary-border, transparent); } .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; } .btn-icon { padding: 0.5rem; } .spinning { animation: spin 1s linear infinite; } diff --git a/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-entry-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-entry-detail.component.ts index 3232a27a6..605b037d1 100644 --- a/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-entry-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-entry-detail.component.ts @@ -399,8 +399,8 @@ import { deadLetterQueuePath, jobEngineJobPath } from '../platform/ops/operation .btn:hover:not(:disabled) { background: var(--color-surface-tertiary); } .btn:disabled { opacity: 0.5; cursor: not-allowed; } - .btn-primary { background: var(--color-primary); color: var(--color-btn-primary-text); border-color: var(--color-primary); } - .btn-secondary { background: var(--color-surface-secondary); } + .btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border-color: var(--color-btn-primary-border, transparent); } + .btn-secondary { background: var(--color-btn-secondary-bg); } .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; } .btn-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; } diff --git a/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-queue.component.ts b/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-queue.component.ts index 790529e2c..2bbd55c13 100644 --- a/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-queue.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deadletter/deadletter-queue.component.ts @@ -311,7 +311,7 @@ import { OPERATIONS_PATHS, deadLetterEntryPath } from '../platform/ops/operation .btn:hover:not(:disabled) { background: var(--color-surface-tertiary); } .btn:disabled { opacity: 0.5; cursor: not-allowed; } - .btn-secondary { background: var(--color-surface-secondary); } + .btn-secondary { background: var(--color-btn-secondary-bg); } .btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; } .btn-icon { padding: 0.5rem; } .spinning { animation: spin 1s linear infinite; } diff --git a/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/component-diff-row/component-diff-row.component.ts b/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/component-diff-row/component-diff-row.component.ts index 22426ba8f..883e039f9 100644 --- a/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/component-diff-row/component-diff-row.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/component-diff-row/component-diff-row.component.ts @@ -515,7 +515,7 @@ import { .vuln-link { font-family: 'SF Mono', 'Consolas', monospace; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; &:hover { diff --git a/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.ts index 1993bb7f2..af3242bd4 100644 --- a/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/deploy-diff-panel/deploy-diff-panel.component.ts @@ -428,7 +428,7 @@ import { OverrideDialogComponent } from '../override-dialog/override-dialog.comp padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: var(--font-weight-medium); - color: var(--color-brand-primary); + color: var(--color-text-link); background: transparent; border: 1px solid var(--color-primary); border-radius: var(--radius-md); diff --git a/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/policy-hit-annotation/policy-hit-annotation.component.ts b/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/policy-hit-annotation/policy-hit-annotation.component.ts index 0d23c2ba2..a51cfa1ec 100644 --- a/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/policy-hit-annotation/policy-hit-annotation.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deploy-diff/components/policy-hit-annotation/policy-hit-annotation.component.ts @@ -266,7 +266,7 @@ import { .more-hits-btn { padding: 0.125rem 0.5rem; font-size: 0.6875rem; - color: var(--color-brand-primary); + color: var(--color-text-link); background: transparent; border: none; cursor: pointer; diff --git a/src/Web/StellaOps.Web/src/app/features/deploy-diff/pages/deploy-diff.page.ts b/src/Web/StellaOps.Web/src/app/features/deploy-diff/pages/deploy-diff.page.ts index 35046113a..53e34db36 100644 --- a/src/Web/StellaOps.Web/src/app/features/deploy-diff/pages/deploy-diff.page.ts +++ b/src/Web/StellaOps.Web/src/app/features/deploy-diff/pages/deploy-diff.page.ts @@ -134,7 +134,7 @@ import { text-decoration: none; &:hover { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: underline; } } @@ -208,7 +208,7 @@ import { padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: var(--font-weight-medium); - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; border: 1px solid var(--color-primary); border-radius: var(--radius-md); @@ -233,7 +233,7 @@ import { text-decoration: none; &:hover { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: underline; } } diff --git a/src/Web/StellaOps.Web/src/app/features/deployments/deployment-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/deployments/deployment-detail-page.component.ts index 2462d384b..fecde884b 100644 --- a/src/Web/StellaOps.Web/src/app/features/deployments/deployment-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deployments/deployment-detail-page.component.ts @@ -10,6 +10,7 @@ import { CommonModule } from '@angular/common'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; interface WorkflowStep { id: string; @@ -32,7 +33,7 @@ interface DeploymentArtifact { @Component({ selector: 'app-deployment-detail-page', standalone: true, - imports: [CommonModule, RouterLink, FormsModule], + imports: [CommonModule, RouterLink, FormsModule, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -53,11 +54,11 @@ interface DeploymentArtifact {
-
@if (actionMessage()) { @@ -66,20 +67,11 @@ interface DeploymentArtifact { - - -
+ @switch (activeTab()) { @case ('workflow') { @@ -153,7 +145,7 @@ interface DeploymentArtifact {

Immutable Artifacts

{{ artifacts().length }} artifacts
- +
@@ -256,7 +248,7 @@ interface DeploymentArtifact { @case ('targets') {

Deployment Targets

-
Name
+
@@ -326,7 +318,7 @@ interface DeploymentArtifact { } } - + `, styles: [` @@ -334,7 +326,7 @@ interface DeploymentArtifact { /* Header */ .page-header { margin-bottom: 1.5rem; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 1rem; } - .back-link { display: block; margin-bottom: 0.5rem; font-size: 0.875rem; color: var(--color-brand-primary); text-decoration: none; width: 100%; } + .back-link { display: block; margin-bottom: 0.5rem; font-size: 0.875rem; color: var(--color-text-link); text-decoration: none; width: 100%; } .header-main { flex: 1; } .header-title-row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; } .page-title { margin: 0; font-size: 1.5rem; font-weight: var(--font-weight-semibold); font-family: ui-monospace, SFMono-Regular, monospace; } @@ -350,12 +342,6 @@ interface DeploymentArtifact { .status-badge--failed { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); } .status-badge--cancelled { background: var(--color-severity-none-bg); color: var(--color-text-secondary); } - /* Tabs */ - .tabs { display: flex; gap: 0.25rem; border-bottom: 1px solid var(--color-border-primary); margin-bottom: 1.5rem; overflow-x: auto; } - .tab { padding: 0.75rem 1rem; background: transparent; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; font-size: 0.875rem; font-weight: var(--font-weight-medium); color: var(--color-text-secondary); cursor: pointer; white-space: nowrap; } - .tab:hover { color: var(--color-text-primary); } - .tab--active { color: var(--color-tab-active-text, var(--color-text-primary)); border-bottom-color: var(--color-tab-active-border, var(--color-brand-primary)); font-weight: 600; } - /* Panel */ .panel { padding: 1.25rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); } .panel h3 { margin: 0; font-size: 1rem; font-weight: var(--font-weight-semibold); } @@ -365,7 +351,8 @@ interface DeploymentArtifact { .workflow-panel { } .workflow-summary { font-size: 0.75rem; color: var(--color-text-secondary); } .workflow-dag { display: flex; flex-direction: column; align-items: center; gap: 0; padding: 1rem 0; } - .dag-node { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1.5rem; background: var(--color-surface-secondary); border: 2px solid var(--color-border-primary); border-radius: var(--radius-lg); cursor: pointer; transition: all 0.15s; min-width: 250px; position: relative; } + .dag-node { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1.5rem; margin-bottom: 2.75rem; background: var(--color-surface-secondary); border: 2px solid var(--color-border-primary); border-radius: var(--radius-lg); cursor: pointer; transition: all 0.15s; min-width: 250px; position: relative; } + .dag-node:last-child { margin-bottom: 0; } .dag-node:hover { border-color: var(--color-brand-primary); } .dag-node--selected { border-color: var(--color-brand-primary); box-shadow: 0 0 0 3px var(--color-focus-ring); } .dag-node--complete { border-color: var(--color-severity-low-border); background: var(--color-severity-low-bg); } @@ -381,7 +368,7 @@ interface DeploymentArtifact { .dag-node__content { flex: 1; } .dag-node__name { display: block; font-weight: var(--font-weight-semibold); font-size: 0.875rem; } .dag-node__duration { display: block; font-size: 0.75rem; color: var(--color-text-secondary); } - .dag-connector { position: absolute; bottom: -40px; left: 50%; transform: translateX(-50%); color: var(--color-border-primary); z-index: 1; } + .dag-connector { position: absolute; bottom: -2.75rem; left: 50%; transform: translateX(-50%); color: var(--color-border-primary); z-index: 1; pointer-events: none; } .icon-spin { animation: spin 1s linear infinite; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.7; } } @@ -394,18 +381,22 @@ interface DeploymentArtifact { .step-detail__status--running { background: var(--color-severity-info-bg); color: var(--color-status-info-text); } .step-detail__status--failed { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); } .step-detail__meta { font-size: 0.75rem; color: var(--color-text-secondary); display: flex; gap: 1rem; margin-bottom: 0.75rem; } - .step-detail__logs { background: var(--color-text-heading); padding: 0.75rem; border-radius: var(--radius-md); max-height: 200px; overflow: auto; } - .step-detail__logs pre { margin: 0; font-size: 0.625rem; color: var(--color-severity-none-bg); font-family: ui-monospace, monospace; } + .step-detail__logs { background: var(--color-terminal-bg); padding: 0.75rem; border-radius: var(--radius-md); max-height: 200px; overflow: auto; } + .step-detail__logs pre { margin: 0; font-size: 0.625rem; color: var(--color-terminal-text); font-family: ui-monospace, monospace; } /* Artifacts Table (DEP-004) */ .artifacts-count { font-size: 0.75rem; color: var(--color-text-secondary); } - .artifacts-table { } + .artifacts-table { border-collapse: collapse; } + .artifacts-table th, + .artifacts-table td { border-bottom: 1px solid var(--color-border-primary); } + .artifacts-table tbody tr:last-child td { border-bottom: none; } .artifact-name { display: flex; align-items: center; gap: 0.5rem; } - .artifact-icon { font-size: 1rem; } - .artifact-hash { font-family: ui-monospace, monospace; } + .artifact-icon { display: inline-flex; align-items: center; flex-shrink: 0; font-size: 1rem; } + .artifact-hash { font-family: ui-monospace, monospace; white-space: nowrap; } .artifact-hash code { font-size: 0.625rem; } - .copy-btn { background: none; border: none; cursor: pointer; padding: 0.25rem; font-size: 0.75rem; } - .artifact-actions { display: flex; gap: 0.5rem; } + .copy-btn { background: none; border: none; cursor: pointer; padding: 0.25rem; font-size: 0.75rem; color: var(--color-text-secondary); } + .copy-btn:hover { color: var(--color-text-primary); } + .artifact-actions { display: flex; gap: 0.5rem; white-space: nowrap; } .type-badge { font-size: 0.625rem; padding: 0.125rem 0.5rem; border-radius: var(--radius-sm); font-weight: var(--font-weight-medium); text-transform: uppercase; } .type-badge--lock { background: var(--color-severity-info-bg); color: var(--color-status-info-text); } .type-badge--script { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); } @@ -418,8 +409,8 @@ interface DeploymentArtifact { .logs-controls { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; } .logs-step-select { padding: 0.375rem 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); font-size: 0.75rem; } .logs-search { padding: 0.375rem 0.75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); font-size: 0.75rem; width: 200px; } - .log-viewer { background: var(--color-text-heading); border-radius: var(--radius-lg); padding: 1rem; max-height: 500px; overflow: auto; flex: 1; } - .log-content { margin: 0; font-size: 0.75rem; color: var(--color-severity-none-bg); font-family: ui-monospace, monospace; white-space: pre-wrap; } + .log-viewer { background: var(--color-terminal-bg); border-radius: var(--radius-lg); padding: 1rem; max-height: 500px; overflow: auto; flex: 1; } + .log-content { margin: 0; font-size: 0.75rem; color: var(--color-terminal-text); font-family: ui-monospace, monospace; white-space: pre-wrap; } .logs-footer { display: flex; justify-content: space-between; padding-top: 0.5rem; font-size: 0.75rem; color: var(--color-text-secondary); } .logs-match-count { color: var(--color-status-warning-text); } @@ -431,24 +422,22 @@ interface DeploymentArtifact { /* Evidence */ .evidence-info { margin: 0 0 1rem; font-size: 0.875rem; } - .evidence-info a { color: var(--color-brand-primary); } + .evidence-info a { color: var(--color-text-link); } .evidence-summary { display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem; } .evidence-item { display: flex; flex-direction: column; gap: 0.25rem; } .evidence-label { font-size: 0.625rem; text-transform: uppercase; color: var(--color-text-secondary); } .evidence-item code { font-size: 0.625rem; } .evidence-badge { font-size: 0.625rem; padding: 0.125rem 0.5rem; border-radius: var(--radius-sm); font-weight: var(--font-weight-semibold); width: fit-content; } .evidence-badge--success { background: var(--color-severity-low-bg); color: var(--color-status-success-text); } - .rekor-link { font-size: 0.875rem; color: var(--color-brand-primary); } + .rekor-link { font-size: 0.875rem; color: var(--color-text-link); } - /* Data Table */ - .data-table { width: 100%; border-collapse: collapse; } - .data-table th, .data-table td { padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); } - .data-table th { font-size: 0.75rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; } - .data-table code { font-size: 0.625rem; } + /* Table styling provided by global .stella-table class */ /* Buttons */ - .btn { padding: 0.5rem 1rem; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: var(--font-weight-medium); cursor: pointer; text-decoration: none; } + .btn { padding: 0.5rem 1rem; border-radius: var(--radius-md); font-size: 0.875rem; font-weight: var(--font-weight-medium); cursor: pointer; text-decoration: none; transition: background 150ms ease, border-color 150ms ease, transform 150ms ease; } .btn--sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; } + .btn--primary { background: var(--color-btn-primary-bg); border: 1px solid var(--color-btn-primary-border); color: var(--color-btn-primary-text); } + .btn--primary:hover { background: var(--color-btn-primary-bg-hover); transform: translateY(-1px); } .btn--secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); color: var(--color-text-primary); } .btn--secondary:hover { background: var(--color-nav-hover); } .btn--secondary.active { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border-color: var(--color-btn-primary-bg); } @@ -474,12 +463,12 @@ export class DeploymentDetailPageComponent implements OnInit, AfterViewChecked { autoScroll = signal(false); actionMessage = signal(null); - tabs = [ - { id: 'workflow', label: 'Workflow' }, - { id: 'targets', label: 'Targets' }, - { id: 'artifacts', label: 'Artifacts' }, - { id: 'logs', label: 'Logs' }, - { id: 'evidence', label: 'Evidence' }, + pageTabs: readonly StellaPageTab[] = [ + { id: 'workflow', label: 'Workflow', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, + { id: 'targets', label: 'Targets', icon: 'M22 12H2|||M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z|||M6 16h.01|||M10 16h.01' }, + { id: 'artifacts', label: 'Artifacts', icon: 'M21 8v13H3V8|||M1 3h22v5H1z|||M10 12h4' }, + { id: 'logs', label: 'Logs', icon: 'M4 17l6-6-6-6|||M12 19h8' }, + { id: 'evidence', label: 'Evidence', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, ]; deployment = signal({ diff --git a/src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts b/src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts index 9125ddd1b..7012c357a 100644 --- a/src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/deployments/deployments-list-page.component.ts @@ -32,7 +32,7 @@ interface Deployment {
-
Target
+
@@ -138,30 +138,16 @@ interface Deployment { overflow-x: auto; overflow-y: hidden; } - .data-table { width: 100%; min-width: 840px; border-collapse: collapse; } - .data-table th, .data-table td { - padding: 0.5rem 0.75rem; - text-align: left; - border-bottom: 1px solid var(--color-border-primary); - } - .data-table th { - background: var(--color-surface-secondary); - font-size: 0.6875rem; - font-weight: var(--font-weight-semibold); - color: var(--color-text-secondary); - text-transform: uppercase; - letter-spacing: 0.04em; + /* Table styling provided by global .stella-table class */ + .stella-table { min-width: 840px; } + .stella-table th { position: sticky; top: 0; z-index: 1; } - .data-table td { font-size: 0.8125rem; } - .data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); } - .data-table tbody tr:hover { background: var(--color-nav-hover); } - .data-table a { color: var(--color-brand-primary); text-decoration: none; } - .data-table a:hover { text-decoration: underline; } - .deployment-link { font-family: ui-monospace, SFMono-Regular, monospace; font-weight: var(--font-weight-medium); } + .deployment-link { font-family: ui-monospace, SFMono-Regular, monospace; font-weight: var(--font-weight-medium); color: var(--color-text-link); text-decoration: none; } + .deployment-link:hover { color: var(--color-text-link-hover); text-decoration: underline; } .release-version { font-family: ui-monospace, SFMono-Regular, monospace; color: var(--color-text-primary); font-size: 0.8125rem; } .status-badge { @@ -276,7 +262,7 @@ interface Deployment { .deployments-page { max-width: none; } .page-title { font-size: 1.75rem; line-height: 1.1; } .page-subtitle { font-size: 0.875rem; line-height: 1.35; } - .data-table th, .data-table td { padding: 0.5rem 0.75rem; } + .stella-table th, .stella-table td { padding: 0.5rem 0.75rem; } .table-container--desktop { display: none; } .deployment-cards { display: grid; } .deployment-card__meta { grid-template-columns: 1fr; } diff --git a/src/Web/StellaOps.Web/src/app/features/docs/docs-viewer.component.ts b/src/Web/StellaOps.Web/src/app/features/docs/docs-viewer.component.ts index 1de7a2069..b8a790b8f 100644 --- a/src/Web/StellaOps.Web/src/app/features/docs/docs-viewer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/docs/docs-viewer.component.ts @@ -126,7 +126,7 @@ interface TocHeading { border: 1px solid var(--color-border-primary); border-radius: var(--radius-full); background: var(--color-surface-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; padding: 0.3rem 0.65rem; font-size: 0.78rem; @@ -194,7 +194,7 @@ interface TocHeading { padding: 0.1rem 0; } - .docs-viewer__toc a:hover { color: var(--color-brand-primary); } + .docs-viewer__toc a:hover { color: var(--color-text-link); } .docs-viewer__toc-level-1 { font-weight: var(--font-weight-semibold); } .docs-viewer__toc-level-2 { padding-left: 0; } @@ -239,7 +239,7 @@ interface TocHeading { .docs-viewer__content ul, .docs-viewer__content ol { padding-left: 1.25rem; } - .docs-viewer__content a { color: var(--color-brand-primary); } + .docs-viewer__content a { color: var(--color-text-link); } .docs-viewer__content code { font-family: var(--font-mono, monospace); diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/components/check-result/check-result.component.scss b/src/Web/StellaOps.Web/src/app/features/doctor/components/check-result/check-result.component.scss index a5a792259..cc438177a 100644 --- a/src/Web/StellaOps.Web/src/app/features/doctor/components/check-result/check-result.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/doctor/components/check-result/check-result.component.scss @@ -193,7 +193,7 @@ &:hover { background: var(--color-surface-secondary); - color: var(--color-brand-primary); + color: var(--color-text-link); } &:focus-visible { diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/components/registry/registry-check-details.component.ts b/src/Web/StellaOps.Web/src/app/features/doctor/components/registry/registry-check-details.component.ts index bab08f8d8..66018e3bf 100644 --- a/src/Web/StellaOps.Web/src/app/features/doctor/components/registry/registry-check-details.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/doctor/components/registry/registry-check-details.component.ts @@ -230,7 +230,7 @@ import { } &.active { - color: var(--color-brand-primary); + color: var(--color-text-link); border-bottom-color: var(--color-brand-primary); } } diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/components/remediation-panel/remediation-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/doctor/components/remediation-panel/remediation-panel.component.ts index ab79fc08b..57cce6065 100644 --- a/src/Web/StellaOps.Web/src/app/features/doctor/components/remediation-panel/remediation-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/doctor/components/remediation-panel/remediation-panel.component.ts @@ -115,7 +115,7 @@ import { Remediation, RemediationStep } from '../../models/doctor.models'; } .run-fix-btn { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border-color: var(--color-brand-primary); color: white; @@ -181,7 +181,7 @@ import { Remediation, RemediationStep } from '../../models/doctor.models'; .step-number { font-weight: var(--font-weight-semibold); - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.875rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.html b/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.html index 64fb3a1da..387966bc4 100644 --- a/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.html +++ b/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.html @@ -1,36 +1,44 @@
-
-

Doctor Diagnostics

-

Run diagnostic checks on your Stella Ops deployment

+
+
+ +
+
+

Diagnostics

+

Run diagnostic checks on your Stella Ops deployment

+
@@ -61,148 +69,194 @@ @if (store.error()) {
- + {{ store.error() }}
} - - @if (store.summary(); as summary) { - + + @if (store.hasReport()) { +
+
+ @if (overallHealthy()) { + + All systems operational + } @else { + + {{ issueCount() }} issue{{ issueCount() === 1 ? '' : 's' }} detected + } +
+ +
} + @if (store.packGroups().length) {
-
+

Doctor Packs

-

Discovered integrations and checks available to run.

+

Discovered integrations and available checks

-
+ + + +
+ @for (pack of store.packGroups(); track pack.category) { + @if (activePackTab() === pack.category) {
@for (plugin of pack.plugins; track plugin.pluginId) { -
-
-
-
{{ plugin.displayName }}
-
{{ plugin.pluginId }}
+
+
+
+ {{ plugin.displayName }} + {{ plugin.pluginId }}
-
- {{ plugin.checks.length > 0 ? plugin.checks.length : plugin.checkCount }} checks +
+ {{ plugin.checks.length > 0 ? plugin.checks.length : plugin.checkCount }} checks @if (plugin.version && plugin.version !== 'unknown') { - v{{ plugin.version }} + v{{ plugin.version }} }
@if (plugin.checks.length > 0) { -
    +
    @for (check of plugin.checks; track check.checkId) { -
  • {{ check.checkId }}
  • + {{ check.checkId }} } -
+
} @else { -
Checks not discovered yet.
+
Checks not discovered yet.
} -
+ }
- + } }
} - -
-
- - -
- -
- -
- @for (sev of severities; track sev.value) { - - } -
-
- -
- + +
+ - - +
+ @for (sev of severities; track sev.value) { + + } +
+ @if (store.searchQuery() || store.severityFilter().length || store.categoryFilter()) { + + }
- +
@if (store.state() === 'idle' && !store.hasReport()) {
-
+
+ +

No Diagnostics Run Yet

-

Click "Quick Check" to run a fast diagnostic, or "Full Check" for comprehensive analysis.

+

Click "Quick" to run a fast diagnostic, or "Full" for comprehensive analysis.

} @if (store.hasReport()) { -
- - Showing {{ store.filteredResults().length }} of {{ store.report()?.results?.length || 0 }} checks - -
+ +
+ + {{ getResultsForTab().length }} of {{ store.report()?.results?.length || 0 }} checks + +
-
- @for (result of store.filteredResults(); track trackResult($index, result)) { - - } +
+ @for (result of getResultsForTab(); track trackResult($index, result)) { +
+ {{ result.checkId }} + + @switch (result.severity) { + @case ('pass') { OK } + @case ('fail') { FAIL } + @case ('warn') { WARN } + @case ('info') { INFO } + @case ('skip') { SKIP } + } + +
+ @if (isResultSelected(result)) { +
+ +
+ } + } - @if (store.filteredResults().length === 0) { -
-

No checks match your current filters.

- -
- } -
+ @if (getResultsForTab().length === 0) { +
+

No checks match your current filters.

+ +
+ } +
+
}
diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.scss b/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.scss index a46d3a6dd..d09af860f 100644 --- a/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.scss @@ -1,66 +1,87 @@ @use 'tokens/breakpoints' as *; .doctor-dashboard { - padding: var(--space-6); max-width: 1400px; - margin: 0 auto; } -// Header +// ── Header ── .dashboard-header { display: flex; justify-content: space-between; - align-items: flex-start; - margin-bottom: var(--space-6); + align-items: center; + margin-bottom: var(--space-5); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--color-border-primary); flex-wrap: wrap; gap: var(--space-4); +} - .header-content { - h1 { - margin: 0 0 var(--space-1) 0; - font-size: var(--font-size-2xl); - font-weight: var(--font-weight-semibold); - color: var(--color-text-primary); - } +.header-left { + display: flex; + align-items: center; + gap: var(--space-3); +} - .subtitle { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--font-size-base); - } +.header-icon { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-brand-primary) 10%, var(--color-surface-primary)); + border: 1px solid color-mix(in srgb, var(--color-brand-primary) 25%, var(--color-border-primary)); + color: var(--color-brand-primary); + flex-shrink: 0; +} + +.header-content { + h1 { + margin: 0 0 0.1rem 0; + font-size: 1.25rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + letter-spacing: -0.01em; } - .header-actions { - display: flex; - gap: var(--space-2); - flex-wrap: wrap; + .subtitle { + margin: 0; + color: var(--color-text-secondary); + font-size: 0.8125rem; } } -// Buttons +.header-actions { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} + +// ── Buttons ── .btn { display: inline-flex; align-items: center; - gap: var(--space-1-5); - padding: var(--space-2) var(--space-4); + gap: 0.375rem; + padding: 0.4rem 0.75rem; border-radius: var(--radius-md); - font-size: var(--font-size-base); + font-size: 0.8125rem; font-weight: var(--font-weight-medium); cursor: pointer; - transition: all 150ms ease; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; border: 1px solid transparent; + white-space: nowrap; &:disabled { - opacity: 0.5; + opacity: 0.45; cursor: not-allowed; } - &:active:not(:disabled) { - transform: translateY(1px); + &:focus-visible { + outline: 2px solid var(--color-brand-primary); + outline-offset: 2px; } .btn-icon { - font-size: var(--font-size-md); display: inline-flex; align-items: center; } @@ -69,32 +90,32 @@ .btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); - border-color: var(--color-btn-primary-bg); &:hover:not(:disabled) { - filter: brightness(1.1); + background: var(--color-btn-primary-bg-hover); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12); } } .btn-secondary { - background: var(--color-surface-secondary); - color: var(--color-text-primary); - border-color: var(--color-border-primary); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); + border-color: var(--color-btn-secondary-border); &:hover:not(:disabled) { - border-color: var(--color-brand-primary); - background: var(--color-surface-primary); + background: var(--color-btn-secondary-hover-bg); + border-color: var(--color-btn-secondary-hover-border); } } .btn-outline { - background: transparent; - color: var(--color-text-primary); - border-color: var(--color-border-primary); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); + border-color: var(--color-btn-secondary-border); &:hover:not(:disabled) { - background: var(--color-surface-secondary); - border-color: var(--color-brand-primary); + background: var(--color-btn-secondary-hover-bg); + border-color: var(--color-btn-secondary-hover-border); } } @@ -104,7 +125,7 @@ border: none; &:hover:not(:disabled) { - background: var(--color-surface-secondary); + background: var(--color-surface-tertiary); color: var(--color-text-primary); } } @@ -112,7 +133,7 @@ .btn-link { background: none; border: none; - color: var(--color-brand-primary); + color: var(--color-text-link); padding: 0; cursor: pointer; @@ -121,18 +142,19 @@ } } -// Progress +// ── Progress ── .progress-container { - margin-bottom: var(--space-6); - padding: var(--space-4); + margin-bottom: var(--space-5); + padding: var(--space-3) var(--space-4); background: var(--color-surface-secondary); border-radius: var(--radius-lg); + border: 1px solid var(--color-border-primary); } .progress-bar { - height: 8px; + height: 6px; background: var(--color-surface-tertiary); - border-radius: var(--radius-sm); + border-radius: 3px; overflow: hidden; margin-bottom: var(--space-2); } @@ -149,6 +171,7 @@ background-size: 200% 100%; animation: shimmer-progress 1.5s infinite ease-in-out; transition: width 0.3s ease; + border-radius: 3px; } @keyframes shimmer-progress { @@ -160,7 +183,7 @@ display: flex; justify-content: space-between; align-items: center; - font-size: var(--font-size-base); + font-size: 0.8125rem; .progress-text { color: var(--color-text-primary); @@ -170,29 +193,31 @@ .progress-current { color: var(--color-text-secondary); font-family: var(--font-family-mono); - font-size: var(--font-size-sm); + font-size: 0.75rem; } } -// Error +// ── Error ── .error-banner { display: flex; align-items: center; gap: var(--space-3); - padding: var(--space-4); + padding: var(--space-3) var(--space-4); background: var(--color-status-error-bg); - border: 1px solid var(--color-status-error); + border: 1px solid var(--color-status-error-border, var(--color-status-error)); border-radius: var(--radius-lg); - margin-bottom: var(--space-6); + margin-bottom: var(--space-5); + animation: slideDown 0.2s ease; - .error-icon { - font-size: var(--font-size-xl); + &__icon { + flex-shrink: 0; color: var(--color-status-error); } .error-message { flex: 1; color: var(--color-status-error); + font-size: 0.8125rem; } .error-dismiss { @@ -200,7 +225,8 @@ border: none; color: var(--color-status-error); cursor: pointer; - font-size: var(--font-size-base); + font-size: 0.8125rem; + white-space: nowrap; &:hover { text-decoration: underline; @@ -208,70 +234,152 @@ } } -// Packs -.pack-section { - margin-bottom: var(--space-6); - padding: var(--space-4); - background: var(--color-surface-secondary); +@keyframes slideDown { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +// ── Summary Bar ── +.summary-bar { + display: flex; + align-items: center; + gap: var(--space-4); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-5); + background: var(--color-surface-primary); border-radius: var(--radius-lg); + border: 1px solid var(--color-border-primary); + border-left: 3px solid var(--color-status-success); + flex-wrap: wrap; - .section-header { - display: flex; - justify-content: space-between; - align-items: baseline; - margin-bottom: var(--space-4); - gap: var(--space-4); - - h2 { - margin: 0; - font-size: var(--font-size-lg); - color: var(--color-text-primary); - } - - p { - margin: 0; - font-size: var(--font-size-sm); - color: var(--color-text-secondary); - } + &--issues { + border-left-color: var(--color-status-error); } } -.pack-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - gap: var(--space-4); +.summary-bar__status { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; } -.pack-card { +.summary-bar__icon { + flex-shrink: 0; + + &--ok { color: var(--color-status-success); } + &--issue { color: var(--color-status-error); } +} + +.summary-bar__text { + font-size: 0.875rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + white-space: nowrap; +} + +// ── Doctor Packs — Tabbed ── +.pack-section { + margin-bottom: var(--space-5); background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); - padding: var(--space-3); - transition: transform 150ms ease, box-shadow 150ms ease, border-color 150ms ease; + overflow: hidden; +} - &:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); - border-color: var(--color-brand-primary); +.pack-section__header { + padding: var(--space-4) var(--space-4) 0; + + h2 { + margin: 0 0 0.125rem 0; + font-size: 0.9375rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); } } -.pack-header { +.pack-section__desc { + margin: 0; + font-size: 0.75rem; + color: var(--color-text-tertiary); +} + +.pack-tabs { display: flex; - justify-content: space-between; + gap: 0; + padding: 0 var(--space-4); + margin-top: var(--space-3); + border-bottom: 2px solid var(--color-border-primary); + overflow-x: auto; + scrollbar-width: none; + -webkit-overflow-scrolling: touch; + + &::-webkit-scrollbar { display: none; } +} + +.pack-tabs__tab { + display: inline-flex; align-items: center; - margin-bottom: var(--space-3); + gap: 0.375rem; + padding: 0.5rem 0.875rem; + border: none; + background: transparent; + color: var(--color-text-secondary); + font-size: 0.8125rem; + font-weight: var(--font-weight-medium); + cursor: pointer; + white-space: nowrap; + position: relative; + margin-bottom: -2px; + transition: color 0.15s ease, background 0.15s ease; - h3 { - margin: 0; - font-size: var(--font-size-md); + &:hover:not(.pack-tabs__tab--active) { color: var(--color-text-primary); + background: var(--color-surface-secondary); } - .pack-meta { - font-size: var(--font-size-sm); - color: var(--color-text-secondary); + &:focus-visible { + outline: 2px solid var(--color-brand-primary); + outline-offset: -2px; } + + &--active { + color: var(--color-brand-primary); + font-weight: var(--font-weight-semibold); + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 2px; + background: var(--color-brand-primary); + } + } +} + +.pack-tabs__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.3rem; + border-radius: var(--radius-full); + background: var(--color-surface-tertiary); + font-size: 0.6875rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + + .pack-tabs__tab--active & { + background: color-mix(in srgb, var(--color-brand-primary) 15%, transparent); + color: var(--color-brand-primary); + } +} + +.pack-content { + padding: var(--space-4); } .plugin-list { @@ -284,132 +392,167 @@ border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: var(--space-3); - background: var(--color-surface-primary); -} + background: var(--color-surface-secondary); + transition: border-color 0.15s ease; -.plugin-header { - display: flex; - justify-content: space-between; - gap: var(--space-3); + &:hover { + border-color: var(--color-border-secondary); + } - .plugin-name { + &__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: var(--space-3); + } + + &__info { + display: flex; + flex-direction: column; + gap: 0.125rem; + min-width: 0; + } + + &__name { font-weight: var(--font-weight-semibold); - font-size: var(--font-size-base); + font-size: 0.875rem; color: var(--color-text-primary); } - .plugin-meta { - font-size: var(--font-size-sm); + &__id { + font-size: 0.75rem; color: var(--color-text-secondary); + font-family: var(--font-family-mono); word-break: break-all; } - .plugin-stats { + &__meta { display: flex; flex-direction: column; align-items: flex-end; - gap: var(--space-0-5); - font-size: var(--font-size-sm); + gap: 0.125rem; + flex-shrink: 0; + } + + &__checks { + font-size: 0.75rem; + font-weight: var(--font-weight-medium); color: var(--color-text-secondary); } - .plugin-version { - font-size: var(--font-size-xs); + &__version { + font-size: 0.6875rem; + color: var(--color-text-muted); + font-family: var(--font-family-mono); + } + + &__check-list { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: var(--space-2); + padding-top: var(--space-2); + border-top: 1px solid var(--color-border-primary); + } + + &__check-id { + display: inline-block; + padding: 0.125rem 0.5rem; + background: var(--color-surface-primary); + border-radius: var(--radius-sm); + font-family: var(--font-family-mono); + font-size: 0.6875rem; + color: var(--color-text-secondary); + border: 1px solid var(--color-border-primary); + } + + &__empty { + margin-top: var(--space-2); + font-size: 0.75rem; color: var(--color-text-muted); } } -.plugin-checks { - list-style: none; - padding: 0; - margin: var(--space-2) 0 0 0; +// ── Filter Bar ── +.filter-bar { display: flex; - flex-direction: column; - gap: var(--space-1); - - li { - padding: var(--space-1) var(--space-2); - background: var(--color-surface-secondary); - border-radius: var(--radius-sm); - font-family: var(--font-family-mono); - font-size: var(--font-size-sm); - } -} - -.plugin-empty { - margin-top: var(--space-2); - font-size: var(--font-size-sm); - color: var(--color-text-muted); -} - -// Filters -.filters-container { - display: flex; - gap: var(--space-4); - align-items: flex-end; - margin-bottom: var(--space-6); - padding: var(--space-4); + gap: var(--space-3); + align-items: center; + margin-bottom: var(--space-4); + padding: var(--space-3); background: var(--color-surface-secondary); border-radius: var(--radius-lg); + border: 1px solid var(--color-border-primary); flex-wrap: wrap; } -.filter-group { - display: flex; - flex-direction: column; - gap: var(--space-1-5); - - label { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - text-transform: uppercase; - letter-spacing: 0.025em; - } +.filter-bar__search { + position: relative; + flex: 1; + min-width: 200px; } -.filter-select { - padding: var(--space-2) var(--space-8) var(--space-2) var(--space-3); +.filter-bar__search-icon { + position: absolute; + left: 0.625rem; + top: 50%; + transform: translateY(-50%); + color: var(--color-text-muted); + pointer-events: none; +} + +.filter-bar__input { + width: 100%; + padding: 0.4rem 0.75rem 0.4rem 2rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - font-size: var(--font-size-base); + font-size: 0.8125rem; background: var(--color-surface-primary); color: var(--color-text-primary); - cursor: pointer; - min-width: 150px; - transition: border-color 150ms ease, box-shadow 150ms ease; + transition: border-color 0.15s ease, box-shadow 0.15s ease; &:focus { outline: none; border-color: var(--color-brand-primary); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand-primary) 15%, transparent); + } + + &::placeholder { + color: var(--color-text-muted); } } -.severity-filters { - .severity-checkboxes { - display: flex; - gap: var(--space-3); - } -} - -.severity-checkbox { +.filter-bar__severity { display: flex; + gap: 0.375rem; + flex-wrap: wrap; +} + +.severity-chip { + display: inline-flex; align-items: center; - gap: var(--space-1-5); - font-size: var(--font-size-base); + gap: 0.25rem; + padding: 0.25rem 0.625rem; + border-radius: var(--radius-full); + font-size: 0.75rem; cursor: pointer; - padding: var(--space-1-5) var(--space-2-5); - border-radius: var(--radius-sm); - transition: background var(--motion-duration-fast) var(--motion-ease-default); + border: 1px solid var(--color-border-primary); + background: var(--color-surface-primary); + transition: background 0.15s ease, border-color 0.15s ease; + + input { display: none; } &:hover { - background: var(--color-surface-tertiary); + border-color: var(--color-border-secondary); } - input { - margin: 0; - accent-color: var(--color-brand-primary); + &--active { + border-color: var(--color-brand-primary); + background: var(--color-brand-primary); + + span { + color: var(--color-text-inverse, #fff) !important; + } } &.severity-fail span { color: var(--color-status-error); } @@ -418,84 +561,139 @@ &.severity-info span { color: var(--color-status-info); } } -.search-group { - flex: 1; - min-width: 200px; +.filter-bar__clear { + flex-shrink: 0; } -.search-input { - width: 100%; - padding: var(--space-2) var(--space-3); +// Tab content handled by stella-page-tabs + +// ── Check Rows ── +.check-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - font-size: var(--font-size-base); - background: var(--color-surface-primary); - color: var(--color-text-primary); - transition: border-color 150ms ease, box-shadow 150ms ease; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease; - &:focus { - outline: none; - border-color: var(--color-brand-primary); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15); + &:hover { + border-color: var(--color-border-secondary); + background: var(--color-surface-secondary); } - &::placeholder { + &--expanded { + border-color: var(--color-brand-primary); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } +} + +.check-row__name { + font-family: var(--font-family-mono); + font-size: 0.75rem; + color: var(--color-text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +} + +.check-row__badge { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.625rem; + font-weight: var(--font-weight-semibold); + letter-spacing: 0.05em; + flex-shrink: 0; + margin-left: var(--space-3); + + &.badge--pass { + background: var(--color-status-success-bg, rgba(34, 197, 94, 0.15)); + color: var(--color-status-success); + } + &.badge--fail { + background: var(--color-status-error-bg, rgba(239, 68, 68, 0.15)); + color: var(--color-status-error); + } + &.badge--warn { + background: var(--color-status-warning-bg, rgba(234, 179, 8, 0.15)); + color: var(--color-status-warning); + } + &.badge--info { + background: var(--color-status-info-bg, rgba(59, 130, 246, 0.15)); + color: var(--color-status-info); + } + &.badge--skip { + background: var(--color-surface-tertiary); color: var(--color-text-muted); } } -.clear-filters { - align-self: flex-end; +.check-detail { + margin-bottom: var(--space-1); + padding: var(--space-3); + background: var(--color-surface-secondary); + border: 1px solid var(--color-brand-primary); + border-top: none; + border-radius: 0 0 var(--radius-md) var(--radius-md); + animation: fadeIn 0.15s ease; } -// Results +// ── Results ── .results-container { - min-height: 300px; + min-height: 200px; } .results-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: var(--space-4); + margin-bottom: var(--space-3); .results-count { - font-size: var(--font-size-base); - color: var(--color-text-secondary); + font-size: 0.75rem; + color: var(--color-text-tertiary); } } .results-list { display: flex; flex-direction: column; - gap: var(--space-3); + gap: var(--space-2); } -// Empty state +// ── Empty state ── .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; - padding: var(--space-16) var(--space-8); + padding: 4rem 2rem; text-align: center; .empty-icon { - font-size: 4rem; margin-bottom: var(--space-4); - opacity: 0.5; + opacity: 0.3; + color: var(--color-text-muted); } h3 { margin: 0 0 var(--space-2) 0; - font-size: var(--font-size-xl); + font-size: 1.125rem; color: var(--color-text-primary); } p { margin: 0; color: var(--color-text-secondary); - max-width: 400px; + max-width: 380px; + font-size: 0.875rem; } } @@ -503,18 +701,13 @@ text-align: center; padding: var(--space-8); color: var(--color-text-secondary); + font-size: 0.875rem; - p { - margin: 0 0 var(--space-2) 0; - } + p { margin: 0 0 var(--space-2) 0; } } -// Responsive +// ── Responsive ── @include screen-below-md { - .doctor-dashboard { - padding: var(--space-4); - } - .dashboard-header { flex-direction: column; align-items: stretch; @@ -524,21 +717,26 @@ } } - .filters-container { + .filter-bar { flex-direction: column; align-items: stretch; - .filter-group { - width: 100%; - } + .filter-bar__search { width: 100%; } + .filter-bar__severity { flex-wrap: wrap; } + } - .filter-select, - .search-input { - width: 100%; - } - - .severity-checkboxes { - flex-wrap: wrap; - } + .summary-bar { + flex-direction: column; + align-items: flex-start; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; } } diff --git a/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.ts index ca9615658..da6dc2d62 100644 --- a/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/doctor/doctor-dashboard.component.ts @@ -1,5 +1,5 @@ -import { Component, OnInit, inject, signal } from '@angular/core'; +import { Component, OnInit, inject, signal, computed, effect } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; @@ -9,6 +9,17 @@ import { SummaryStripComponent } from './components/summary-strip/summary-strip. import { CheckResultComponent } from './components/check-result/check-result.component'; import { ExportDialogComponent } from './components/export-dialog/export-dialog.component'; import { AppConfigService } from '../../core/config/app-config.service'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +const DOCTOR_CATEGORY_TABS: readonly StellaPageTab[] = [ + { id: 'all', label: 'All', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' }, + { id: 'core', label: 'Core', icon: 'M18 12h2|||M4 12h2|||M12 4v2|||M12 18v2|||M9 9h6v6H9z|||M17 4h2v2|||M17 18h2v2|||M5 4h2v2|||M5 18h2v2' }, + { id: 'database', label: 'Database', icon: 'M12 2C6.48 2 2 4.02 2 6.5v11c0 2.49 4.48 4.5 10 4.5s10-2.01 10-4.5v-11C22 4.02 17.52 2 12 2z|||M2 6.5c0 2.49 4.48 4.5 10 4.5s10-2.01 10-4.5|||M2 12c0 2.49 4.48 4.5 10 4.5s10-2.01 10-4.5' }, + { id: 'servicegraph', label: 'Service Graph', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, + { id: 'integration', label: 'Integration', icon: 'M16 18l6-6-6-6|||M8 6l-6 6 6 6' }, + { id: 'security', label: 'Security', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'observability', label: 'Observability', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' }, +]; @Component({ selector: 'st-doctor-dashboard', @@ -16,7 +27,8 @@ import { AppConfigService } from '../../core/config/app-config.service'; FormsModule, SummaryStripComponent, CheckResultComponent, - ExportDialogComponent + ExportDialogComponent, + StellaPageTabsComponent, ], templateUrl: './doctor-dashboard.component.html', styleUrl: './doctor-dashboard.component.scss' @@ -30,6 +42,9 @@ export class DoctorDashboardComponent implements OnInit { readonly showExportDialog = signal(false); readonly selectedResult = signal(null); + readonly activeTab = signal('all'); + readonly activePackTab = signal(''); + readonly doctorCategoryTabs = DOCTOR_CATEGORY_TABS; readonly categories: { value: DoctorCategory | null; label: string }[] = [ { value: null, label: 'All Categories' }, @@ -41,6 +56,16 @@ export class DoctorDashboardComponent implements OnInit { { value: 'observability', label: 'Observability' }, ]; + readonly categoryTabs: { value: DoctorCategory | 'all'; label: string }[] = [ + { value: 'all', label: 'All' }, + { value: 'core', label: 'Core' }, + { value: 'database', label: 'Database' }, + { value: 'servicegraph', label: 'Service Graph' }, + { value: 'integration', label: 'Integration' }, + { value: 'security', label: 'Security' }, + { value: 'observability', label: 'Observability' }, + ]; + readonly severities: { value: DoctorSeverity; label: string; class: string }[] = [ { value: 'fail', label: 'Failed', class: 'severity-fail' }, { value: 'warn', label: 'Warnings', class: 'severity-warn' }, @@ -48,6 +73,45 @@ export class DoctorDashboardComponent implements OnInit { { value: 'info', label: 'Info', class: 'severity-info' }, ]; + constructor() { + // Auto-select first pack tab when pack groups load + effect(() => { + const groups = this.store.packGroups(); + if (groups.length > 0 && !this.activePackTab()) { + this.activePackTab.set(groups[0].category); + } + }); + } + + readonly doctorTabsWithStatus = computed(() => { + return DOCTOR_CATEGORY_TABS.map(tab => { + const catStatus = this.getCategoryStatus(tab.id as DoctorCategory | 'all'); + const statusMap: Record = { + pass: 'ok', + warn: 'warn', + fail: 'error', + none: undefined, + }; + return { + ...tab, + status: statusMap[catStatus], + statusHint: catStatus === 'fail' ? 'Has failures' : catStatus === 'warn' ? 'Has warnings' : undefined, + }; + }); + }); + + readonly overallHealthy = computed(() => { + const report = this.store.report(); + if (!report) return true; + return report.overallSeverity === 'pass' || report.overallSeverity === 'info'; + }); + + readonly issueCount = computed(() => { + const summary = this.store.summary(); + if (!summary) return 0; + return summary.failed + summary.warnings; + }); + ngOnInit(): void { // Load metadata on init this.store.fetchPlugins(); @@ -57,6 +121,7 @@ export class DoctorDashboardComponent implements OnInit { const category = this.route.snapshot.queryParamMap.get('category'); if (category) { this.store.setCategoryFilter(category as DoctorCategory); + this.activeTab.set(category as DoctorCategory); } } @@ -85,6 +150,11 @@ export class DoctorDashboardComponent implements OnInit { this.store.startRun(request); } + selectTab(tab: DoctorCategory | 'all'): void { + this.activeTab.set(tab); + this.store.setCategoryFilter(tab === 'all' ? null : tab as DoctorCategory); + } + onCategoryChange(event: Event): void { const select = event.target as HTMLSelectElement; const value = select.value as DoctorCategory | ''; @@ -131,9 +201,28 @@ export class DoctorDashboardComponent implements OnInit { clearFilters(): void { this.store.clearFilters(); + this.activeTab.set('all'); } trackResult(_index: number, result: CheckResult): string { return result.checkId; } + + getCategoryStatus(category: DoctorCategory | 'all'): 'pass' | 'warn' | 'fail' | 'none' { + const report = this.store.report(); + if (!report) return 'none'; + + const results = category === 'all' + ? report.results + : report.results.filter(r => r.category === category); + + if (results.length === 0) return 'none'; + if (results.some(r => r.severity === 'fail')) return 'fail'; + if (results.some(r => r.severity === 'warn')) return 'warn'; + return 'pass'; + } + + getResultsForTab(): CheckResult[] { + return this.store.filteredResults(); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/environments/environment-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/environments/environment-detail-page.component.ts index a8c5ad1ef..27e14dfd2 100644 --- a/src/Web/StellaOps.Web/src/app/features/environments/environment-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/environments/environment-detail-page.component.ts @@ -8,6 +8,7 @@ import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; interface ReleaseHistoryEntry { version: string; @@ -23,7 +24,7 @@ interface GateSummary { @Component({ selector: 'app-environment-detail-page', standalone: true, - imports: [RouterLink], + imports: [RouterLink, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -53,20 +54,11 @@ interface GateSummary {
- - -
+ @switch (activeTab()) { @case ('overview') { @@ -206,7 +198,7 @@ interface GateSummary { @case ('targets') {

Deployment Targets

-
Deployment ID
+
@@ -238,7 +230,7 @@ interface GateSummary {

Promotion History

Releases promoted to this environment

-
Target
+
@@ -279,7 +271,7 @@ interface GateSummary {

Deployment History

Deployment runs to targets in this environment

-
Release
+
@@ -361,7 +353,7 @@ interface GateSummary { } } - + `, styles: [` @@ -369,7 +361,7 @@ interface GateSummary { /* Header */ .page-header { margin-bottom: 1.5rem; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 1rem; } - .back-link { display: inline-block; margin-bottom: 0.5rem; font-size: 0.875rem; color: var(--color-brand-primary); text-decoration: none; width: 100%; } + .back-link { display: inline-block; margin-bottom: 0.5rem; font-size: 0.875rem; color: var(--color-text-link); text-decoration: none; width: 100%; } .header-main { flex: 1; } .header-title-row { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; } .page-title { margin: 0; font-size: 1.5rem; font-weight: var(--font-weight-semibold); } @@ -380,10 +372,6 @@ interface GateSummary { .header-actions { display: flex; gap: 0.5rem; } /* Tabs */ - .tabs { display: flex; gap: 0.25rem; border-bottom: 1px solid var(--color-border-primary); margin-bottom: 1.5rem; } - .tab { padding: 0.75rem 1rem; background: transparent; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; font-size: 0.875rem; font-weight: var(--font-weight-medium); color: var(--color-text-secondary); cursor: pointer; } - .tab:hover { color: var(--color-text-primary); } - .tab--active { color: var(--color-tab-active-text, var(--color-text-primary)); border-bottom-color: var(--color-tab-active-border, var(--color-brand-primary)); font-weight: 600; } /* Overview Layout */ .overview-layout { display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; } @@ -395,22 +383,22 @@ interface GateSummary { .panel--compact { padding: 1rem; } .panel h3 { margin: 0; font-size: 0.875rem; font-weight: var(--font-weight-semibold); } .panel-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } - .panel-link { font-size: 0.75rem; color: var(--color-brand-primary); text-decoration: none; cursor: pointer; } + .panel-link { font-size: 0.75rem; color: var(--color-text-link); text-decoration: none; cursor: pointer; } .panel-link:hover { text-decoration: underline; } /* Release Timeline */ .release-timeline { display: flex; flex-direction: column; gap: 0; margin-bottom: 1rem; } .timeline-entry { display: flex; align-items: flex-start; gap: 0.75rem; position: relative; padding-bottom: 0.75rem; } .timeline-node { width: 12px; height: 12px; border-radius: var(--radius-full); background: var(--color-border-primary); border: 2px solid var(--color-surface-primary); box-shadow: 0 0 0 2px var(--color-border-primary); flex-shrink: 0; margin-top: 0.25rem; } - .timeline-entry--current .timeline-node { background: var(--color-brand-primary); box-shadow: 0 0 0 2px var(--color-brand-primary); } + .timeline-entry--current .timeline-node { background: var(--color-btn-primary-bg); box-shadow: 0 0 0 2px var(--color-brand-primary); } .timeline-line { position: absolute; left: 5px; top: 14px; bottom: -2px; width: 2px; background: var(--color-border-primary); } .timeline-content { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } - .timeline-version { font-weight: var(--font-weight-semibold); font-size: 0.875rem; color: var(--color-brand-primary); text-decoration: none; } + .timeline-version { font-weight: var(--font-weight-semibold); font-size: 0.875rem; color: var(--color-text-link); text-decoration: none; } .timeline-version:hover { text-decoration: underline; } .current-badge { font-size: 0.625rem; padding: 0.125rem 0.375rem; background: var(--color-severity-low-bg); color: var(--color-status-success-text); border-radius: var(--radius-sm); font-weight: var(--font-weight-semibold); } .timeline-date { font-size: 0.75rem; color: var(--color-text-secondary); } .last-promotion { font-size: 0.75rem; color: var(--color-text-secondary); padding-top: 0.5rem; border-top: 1px solid var(--color-border-primary); display: flex; justify-content: space-between; } - .promotion-link { color: var(--color-brand-primary); text-decoration: none; } + .promotion-link { color: var(--color-text-link); text-decoration: none; } /* Risk Snapshot */ .risk-content { display: flex; flex-direction: column; gap: 0.75rem; } @@ -457,7 +445,6 @@ interface GateSummary { .drift-badge--drifted { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); } /* Data Table */ - .data-table { width: 100%; border-collapse: collapse; } .data-table th, .data-table td { padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); } .data-table th { font-size: 0.75rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; } @@ -498,7 +485,7 @@ interface GateSummary { .gate-chip--block { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); } /* Evidence link */ - .evidence-link { color: var(--color-brand-primary); text-decoration: none; font-size: 0.8125rem; } + .evidence-link { color: var(--color-text-link); text-decoration: none; font-size: 0.8125rem; } .evidence-link:hover { text-decoration: underline; } /* Evidence grid */ @@ -536,6 +523,15 @@ export class EnvironmentDetailPageComponent implements OnInit { { id: 'evidence', label: 'Evidence' }, ]; + pageTabs: readonly StellaPageTab[] = [ + { id: 'overview', label: 'Overview', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8z|||M12 9a3 3 0 1 0 0 6a3 3 0 0 0 0-6z' }, + { id: 'targets', label: 'Targets', icon: 'M12 2a10 10 0 1 0 0 20a10 10 0 0 0 0-20z|||M2 12h20|||M12 2a15.3 15.3 0 0 1 4 10a15.3 15.3 0 0 1-4 10a15.3 15.3 0 0 1-4-10a15.3 15.3 0 0 1 4-10z' }, + { id: 'promotions', label: 'Promotions', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, + { id: 'deployments', label: 'Deployments', icon: 'M12 2L2 7l10 5 10-5-10-5z|||M2 17l10 5 10-5|||M2 12l10 5 10-5' }, + { id: 'drift', label: 'Drift', icon: 'M22 3H2l8 9.46V19l4 2v-8.54L22 3' }, + { id: 'evidence', label: 'Evidence', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + ]; + env = signal({ name: 'Staging', stage: 'Staging', diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-audit/evidence-audit-overview.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence-audit/evidence-audit-overview.component.spec.ts index 86b0f3fb2..0e5f4aa06 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-audit/evidence-audit-overview.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-audit/evidence-audit-overview.component.spec.ts @@ -22,12 +22,13 @@ describe('EvidenceAuditOverviewComponent (persona visibility)', () => { localStorage.clear(); }); - it('should render view-mode toggle', () => { + it('should render without view-mode toggle (moved to global header)', () => { service.setMode('operator'); fixture.detectChanges(); + // View-mode toggle is now in the global header, not embedded in the page. const toggle = fixture.nativeElement.querySelector('stella-view-mode-toggle'); - expect(toggle).toBeTruthy(); + expect(toggle).toBeFalsy(); }); it('removes developer-only state mode toggles from the operator overview', () => { diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-audit/evidence-audit-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-audit/evidence-audit-overview.component.ts index 129184625..edd15342d 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-audit/evidence-audit-overview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-audit/evidence-audit-overview.component.ts @@ -17,7 +17,7 @@ import { import { RouterLink } from '@angular/router'; import { AuditorOnlyDirective } from '../../shared/directives/auditor-only.directive'; import { OperatorOnlyDirective } from '../../shared/directives/operator-only.directive'; -import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggle/view-mode-toggle.component'; +import { StellaQuickLinksComponent, type StellaQuickLink } from '../../shared/components/stella-quick-links/stella-quick-links.component'; interface EvidenceQuickViewTile { id: string; @@ -32,7 +32,7 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty'; @Component({ selector: 'app-evidence-audit-overview', standalone: true, - imports: [RouterLink, AuditorOnlyDirective, OperatorOnlyDirective, ViewModeToggleComponent], + imports: [RouterLink, AuditorOnlyDirective, OperatorOnlyDirective, StellaQuickLinksComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -46,7 +46,6 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty'; Operator mode keeps the action path concise. Auditor mode expands provenance and proof detail for formal review.

- @if (isDegraded()) { @@ -115,14 +114,7 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty';

Shortcuts

- +
@@ -150,39 +142,7 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty'; @@ -265,7 +225,7 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty'; .mode-toggle button.active { border-color: var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); font-weight: var(--font-weight-semibold); } @@ -401,26 +361,7 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty'; line-height: 1.35; } - .shortcut-links { - display: flex; - flex-wrap: wrap; - gap: 0.6rem; - } - - .shortcut-link { - border: 1px solid var(--color-border-primary); - border-radius: 999px; - padding: 0.35rem 0.65rem; - font-size: 0.8rem; - text-decoration: none; - color: var(--color-brand-primary); - background: var(--color-surface-primary); - } - - .shortcut-link:hover { - border-color: var(--color-brand-primary); - background: var(--color-surface-elevated); - } + /* Shortcuts — uses stella-quick-links */ /* Stats Section */ .stats-section { @@ -460,51 +401,7 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty'; letter-spacing: 0.03em; } - /* Cross Links */ - .cross-links-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 0.85rem; - } - - .cross-link { - display: flex; - align-items: flex-start; - gap: 0.75rem; - padding: 0.85rem 1rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-elevated); - text-decoration: none; - color: var(--color-text-primary); - transition: background 150ms ease, border-color 150ms ease, transform 150ms ease, box-shadow 150ms ease; - } - - .cross-link:hover { - background: var(--color-surface-primary); - border-color: var(--color-brand-primary); - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); - } - - .cross-link-icon { - font-size: 0.85rem; - color: var(--color-brand-primary); - margin-top: 0.15rem; - flex-shrink: 0; - } - - .cross-link-title { - font-size: 0.9rem; - font-weight: var(--font-weight-medium); - margin-bottom: 0.15rem; - } - - .cross-link-desc { - font-size: 0.78rem; - color: var(--color-text-secondary); - line-height: 1.35; - } + /* Cross Links — uses stella-quick-links */ /* Ownership Note */ .ownership-note { @@ -526,7 +423,7 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty'; } .ownership-note a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } @@ -535,8 +432,7 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty'; } @media (max-width: 768px) { - .entry-grid, - .cross-links-grid { + .entry-grid { grid-template-columns: 1fr; } @@ -549,6 +445,22 @@ type EvidenceHomeMode = 'normal' | 'degraded' | 'empty'; export class EvidenceAuditOverviewComponent { readonly mode = signal('normal'); + readonly shortcutLinks: StellaQuickLink[] = [ + { label: 'Audit Log', route: '/evidence/audit-log' }, + { label: 'Export Center', route: '/evidence/exports' }, + { label: 'Evidence Bundles', route: '/releases/bundles' }, + { label: 'Replay & Verify', route: '/evidence/verify-replay' }, + { label: 'Proof Chains', route: '/evidence/capsules' }, + { label: 'Trust & Signing', route: '/setup/trust-signing' }, + ]; + + readonly relatedDomainLinks: StellaQuickLink[] = [ + { label: 'Release Control', route: '/releases/runs', hint: 'Evidence attached to releases and promotions' }, + { label: 'Trust & Signing', route: '/setup/trust-signing', hint: 'Key management and signing policy' }, + { label: 'Policy Governance', route: '/ops/policy/governance', hint: 'Policy packs driving evidence requirements' }, + { label: 'Findings', route: '/security/findings', hint: 'Findings linked to evidence records' }, + ]; + readonly quickViews = computed((): EvidenceQuickViewTile[] => { if (this.mode() === 'empty') { return [ diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.spec.ts index 06d8aa72f..ef2e5d9f2 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.spec.ts @@ -1,4 +1,4 @@ -import { Component, computed } from '@angular/core'; +import { computed } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; @@ -6,17 +6,9 @@ import { of } from 'rxjs'; import { AUDIT_BUNDLES_API } from '../../core/api/audit-bundles.client'; import { ViewModeService } from '../../core/services/view-mode.service'; -import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggle/view-mode-toggle.component'; import { EvidenceBundlesComponent } from './evidence-bundles.component'; import { EvidenceBundle } from './evidence-export.models'; -@Component({ - selector: 'stella-view-mode-toggle', - standalone: true, - template: '', -}) -class MockViewModeToggleComponent {} - describe('EvidenceBundlesComponent', () => { let fixture: ComponentFixture; let component: EvidenceBundlesComponent; @@ -79,11 +71,6 @@ describe('EvidenceBundlesComponent', () => { navigate: jasmine.createSpy('navigate').and.returnValue(Promise.resolve(true)), }; - TestBed.overrideComponent(EvidenceBundlesComponent, { - remove: { imports: [ViewModeToggleComponent] }, - add: { imports: [MockViewModeToggleComponent] }, - }); - await TestBed.configureTestingModule({ imports: [FormsModule, EvidenceBundlesComponent], providers: [ diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.ts index 0f1b6fe85..f755423f5 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/evidence-bundles.component.ts @@ -18,15 +18,14 @@ import { import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../core/api/audit-bundles.client'; import { QuickVerifyDrawerComponent, VerifyResult } from '../../shared/components/quick-verify-drawer'; import { AuditorOnlyDirective } from '../../shared/directives/auditor-only.directive'; -import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggle/view-mode-toggle.component'; - +import { DateFormatService } from '../../core/i18n/date-format.service'; /** * Evidence Bundles Component (Sprint: SPRINT_20251229_016) * Lists, downloads, and verifies evidence bundles. */ @Component({ selector: 'app-evidence-bundles', - imports: [FormsModule, QuickVerifyDrawerComponent, AuditorOnlyDirective, ViewModeToggleComponent], + imports: [FormsModule, QuickVerifyDrawerComponent, AuditorOnlyDirective], template: `
- @@ -460,6 +458,8 @@ import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggl changeDetection: ChangeDetectionStrategy.OnPush }) export class EvidenceBundlesComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); @@ -624,7 +624,7 @@ export class EvidenceBundlesComponent implements OnInit { } formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString('en-US', { + return new Date(dateStr).toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.spec.ts index 585dc4e1f..ec8c6ebe2 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.spec.ts @@ -1,21 +1,13 @@ -import { Component, computed } from '@angular/core'; +import { computed } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../core/api/audit-bundles.client'; import { ViewModeService } from '../../core/services/view-mode.service'; -import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggle/view-mode-toggle.component'; import { ExportCenterComponent } from './export-center.component'; import { ExportProfile, ExportRun, StellaBundleExportResult } from './evidence-export.models'; -@Component({ - selector: 'stella-view-mode-toggle', - standalone: true, - template: '', -}) -class MockViewModeToggleComponent {} - describe('ExportCenterComponent', () => { let fixture: ComponentFixture; let component: ExportCenterComponent; @@ -68,11 +60,6 @@ describe('ExportCenterComponent', () => { downloadBundle: jasmine.createSpy('downloadBundle'), } as jasmine.SpyObj; - TestBed.overrideComponent(ExportCenterComponent, { - remove: { imports: [ViewModeToggleComponent] }, - add: { imports: [MockViewModeToggleComponent] }, - }); - await TestBed.configureTestingModule({ imports: [FormsModule, ExportCenterComponent], providers: [ diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.ts index 93b4c5592..f654101ca 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/export-center.component.ts @@ -20,15 +20,20 @@ import { } from './evidence-export.models'; import { StellaBundleExportButtonComponent } from './stella-bundle-export-button/stella-bundle-export-button.component'; import { OperatorOnlyDirective } from '../../shared/directives/operator-only.directive'; -import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggle/view-mode-toggle.component'; - +import { DateFormatService } from '../../core/i18n/date-format.service'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; /** * Export Center Component (Sprint: SPRINT_20251229_016) * Manages export profiles and monitors export runs with SSE updates. */ +const EXPORT_CENTER_TABS: StellaPageTab[] = [ + { id: 'profiles', label: 'Profiles', icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' }, + { id: 'runs', label: 'Export Runs', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, +]; + @Component({ selector: 'app-export-center', - imports: [FormsModule, StellaBundleExportButtonComponent, OperatorOnlyDirective, ViewModeToggleComponent], + imports: [FormsModule, StellaBundleExportButtonComponent, OperatorOnlyDirective, StellaPageTabsComponent], template: `
-
-
- - -
+ @if (activeTab() === 'profiles') { @@ -397,34 +391,6 @@ import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggl } } - .tabs { - display: flex; - gap: 0; - border-bottom: 1px solid var(--color-border-primary); - margin-bottom: 1.5rem; - } - - .tab { - padding: 0.75rem 1.5rem; - background: none; - border: none; - border-bottom: 2px solid transparent; - cursor: pointer; - font-weight: var(--font-weight-medium); - color: var(--color-tab-inactive-text, var(--color-text-secondary)); - transition: color 150ms ease, border-color 150ms ease; - - &:hover { - color: var(--color-text-primary); - } - - &.active { - color: var(--color-tab-active-text, var(--color-brand-primary)); - border-bottom: 2px solid var(--color-tab-active-border, var(--color-brand-primary)); - font-weight: 600; - } - } - .toolbar { display: flex; justify-content: flex-end; @@ -642,7 +608,7 @@ import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggl .progress-fill { height: 100%; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); transition: width 0.3s ease; } @@ -844,8 +810,11 @@ import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggl changeDetection: ChangeDetectionStrategy.OnPush }) export class ExportCenterComponent implements OnInit, OnDestroy { + private readonly dateFmt = inject(DateFormatService); + private readonly router = inject(Router); + readonly exportCenterTabs = EXPORT_CENTER_TABS; readonly activeTab = signal<'profiles' | 'runs'>('profiles'); readonly showProfileModal = signal(false); readonly editingProfile = signal(null); @@ -1166,7 +1135,7 @@ export class ExportCenterComponent implements OnInit, OnDestroy { } formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString('en-US', { + return new Date(dateStr).toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric', @@ -1174,7 +1143,7 @@ export class ExportCenterComponent implements OnInit, OnDestroy { } formatDateTime(dateStr: string): string { - return new Date(dateStr).toLocaleString('en-US', { + return new Date(dateStr).toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.spec.ts index 355874e86..641c858ff 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.spec.ts @@ -1,23 +1,15 @@ -import { Component, computed } from '@angular/core'; +import { computed } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { of } from 'rxjs'; import { ViewModeService } from '../../core/services/view-mode.service'; -import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggle/view-mode-toggle.component'; import { ProvenanceVisualizationComponent, ProvenanceChain, ProvenanceNode, } from './provenance-visualization.component'; -@Component({ - selector: 'stella-view-mode-toggle', - standalone: true, - template: '', -}) -class MockViewModeToggleComponent {} - describe('ProvenanceVisualizationComponent', () => { let fixture: ComponentFixture; let component: ProvenanceVisualizationComponent; @@ -47,11 +39,6 @@ describe('ProvenanceVisualizationComponent', () => { }; beforeEach(async () => { - TestBed.overrideComponent(ProvenanceVisualizationComponent, { - remove: { imports: [ViewModeToggleComponent] }, - add: { imports: [MockViewModeToggleComponent] }, - }); - await TestBed.configureTestingModule({ imports: [ProvenanceVisualizationComponent], providers: [ diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.ts index 9f596b515..8197e92dc 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/provenance-visualization.component.ts @@ -9,8 +9,7 @@ import { import { ActivatedRoute } from '@angular/router'; import { ProofChainViewerComponent, ChainNode } from '../../shared/components/proof-chain-viewer.component'; import { AuditorOnlyDirective } from '../../shared/directives/auditor-only.directive'; -import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggle/view-mode-toggle.component'; - +import { DateFormatService } from '../../core/i18n/date-format.service'; /** * Provenance chain node representing a step in the evidence chain. */ @@ -40,7 +39,7 @@ export interface ProvenanceChain { */ @Component({ selector: 'app-provenance-visualization', - imports: [ProofChainViewerComponent, AuditorOnlyDirective, ViewModeToggleComponent], + imports: [ProofChainViewerComponent, AuditorOnlyDirective], template: `
- @@ -442,7 +440,7 @@ export interface ProvenanceChain { &.type-finding .node-dot { color: var(--color-status-info); } &.type-advisory .node-dot { color: var(--color-status-warning); } - &.type-vex .node-dot { color: var(--color-brand-primary); } + &.type-vex .node-dot { color: var(--color-text-link); } &.type-policy .node-dot { color: var(--color-surface-secondary); } &.type-attestation .node-dot { color: var(--color-status-success); } &.type-verdict .node-dot { color: var(--color-status-success); } @@ -484,7 +482,7 @@ export interface ProvenanceChain { &.type-finding { background: var(--color-status-info-bg); color: var(--color-status-info); } &.type-advisory { background: var(--color-status-warning-bg); color: var(--color-status-warning); } - &.type-vex { background: var(--primary-surface, var(--color-surface-tertiary)); color: var(--color-brand-primary); } + &.type-vex { background: var(--primary-surface, var(--color-surface-tertiary)); color: var(--color-text-link); } &.type-policy { background: var(--color-surface-tertiary); } &.type-attestation { background: var(--color-status-success-bg); color: var(--color-status-success); } &.type-verdict { background: var(--color-status-success-bg); color: var(--color-status-success); } @@ -507,7 +505,7 @@ export interface ProvenanceChain { .btn-link { background: none; border: none; - color: var(--color-brand-primary); + color: var(--color-text-link); cursor: pointer; font-size: 0.875rem; padding: 0; @@ -631,6 +629,8 @@ export interface ProvenanceChain { changeDetection: ChangeDetectionStrategy.OnPush }) export class ProvenanceVisualizationComponent { + private readonly dateFmt = inject(DateFormatService); + private readonly route = inject(ActivatedRoute); readonly selectedArtifactId = signal(''); @@ -888,7 +888,7 @@ export class ProvenanceVisualizationComponent { } formatDateTime(dateStr: string): string { - return new Date(dateStr).toLocaleString('en-US', { + return new Date(dateStr).toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts index 2029f33d4..f82198755 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/replay-controls.component.ts @@ -16,6 +16,7 @@ import { } from './evidence-export.models'; import { QuickVerifyDrawerComponent, VerifyResult } from '../../shared/components/quick-verify-drawer'; +import { DateFormatService } from '../../core/i18n/date-format.service'; /** * Replay Controls Component (Sprint: SPRINT_20251229_016) * Manages verdict replay requests and displays comparison results. @@ -909,6 +910,8 @@ import { QuickVerifyDrawerComponent, VerifyResult } from '../../shared/component changeDetection: ChangeDetectionStrategy.OnPush }) export class ReplayControlsComponent { + private readonly dateFmt = inject(DateFormatService); + private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); @@ -1131,7 +1134,7 @@ export class ReplayControlsComponent { } formatDateTime(dateStr: string): string { - return new Date(dateStr).toLocaleString('en-US', { + return new Date(dateStr).toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-export/stella-bundle-export-button/stella-bundle-export-button.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-export/stella-bundle-export-button/stella-bundle-export-button.component.ts index e30f2d99d..7ad2ff9d7 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-export/stella-bundle-export-button/stella-bundle-export-button.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-export/stella-bundle-export-button/stella-bundle-export-button.component.ts @@ -190,7 +190,7 @@ const STELLA_BUNDLE_MAX_POLLS = 12; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); border: none; border-radius: var(--radius-md); @@ -200,7 +200,7 @@ const STELLA_BUNDLE_MAX_POLLS = 12; transition: all 0.15s ease; &:hover:not(:disabled) { - background: var(--color-brand-primary-hover); + background: var(--color-btn-primary-bg-hover); } &:focus-visible { @@ -382,7 +382,7 @@ const STELLA_BUNDLE_MAX_POLLS = 12; &.toast-btn-link { background: none; border: none; - color: var(--color-brand-primary); + color: var(--color-text-link); padding: 0.375rem; &:hover { diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts index ac54e4076..eef1cc893 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-list.component.ts @@ -310,7 +310,7 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/ font-weight: var(--font-weight-medium); text-transform: uppercase; letter-spacing: 0.02em; - color: var(--color-brand-primary); + color: var(--color-text-link); background: rgba(59, 130, 246, 0.08); padding: 0.1rem 0.5rem; border-radius: 9999px; diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-viewer.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-viewer.component.ts index 72a405118..f6b8175e8 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-viewer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-pack/evidence-pack-viewer.component.ts @@ -393,7 +393,7 @@ import { buildContextReturnTo, readContextRouteParam } from '../../shared/ui/con border: none; padding: 0; background: none; - color: var(--color-brand-primary); + color: var(--color-text-link); cursor: pointer; font-size: 0.85rem; font-weight: var(--font-weight-medium); @@ -601,7 +601,7 @@ import { buildContextReturnTo, readContextRouteParam } from '../../shared/ui/con font-size: 0.75rem; font-weight: var(--font-weight-medium); text-transform: uppercase; - color: var(--color-brand-primary); + color: var(--color-text-link); } .evidence-id { @@ -655,11 +655,11 @@ import { buildContextReturnTo, readContextRouteParam } from '../../shared/ui/con border-radius: var(--radius-sm); background: var(--color-surface-primary); cursor: pointer; - color: var(--color-brand-primary); + color: var(--color-text-link); } .evidence-link:hover { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-surface-primary); } @@ -687,7 +687,7 @@ import { buildContextReturnTo, readContextRouteParam } from '../../shared/ui/con } .detail-value.uri { - color: var(--color-brand-primary); + color: var(--color-text-link); } .detail-value.digest { @@ -701,7 +701,7 @@ import { buildContextReturnTo, readContextRouteParam } from '../../shared/ui/con .snapshot-details summary { font-size: 0.75rem; - color: var(--color-brand-primary); + color: var(--color-text-link); cursor: pointer; } @@ -741,7 +741,7 @@ import { buildContextReturnTo, readContextRouteParam } from '../../shared/ui/con background: none; border: none; padding: 0; - color: var(--color-brand-primary); + color: var(--color-text-link); cursor: pointer; font-family: monospace; font-size: inherit; diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-export-dialog/evidence-export-dialog.component.scss b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-export-dialog/evidence-export-dialog.component.scss index b7efa75b6..d6e6d5e89 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-export-dialog/evidence-export-dialog.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-export-dialog/evidence-export-dialog.component.scss @@ -11,7 +11,7 @@ h2[mat-dialog-title] { gap: var(--space-2); mat-icon { - color: var(--color-brand-primary); + color: var(--color-text-link); } } @@ -99,7 +99,7 @@ mat-dialog-content { border-color: var(--color-brand-primary); mat-icon:first-child { - color: var(--color-brand-primary); + color: var(--color-text-link); } } @@ -129,7 +129,7 @@ mat-dialog-content { } .check-icon { - color: var(--color-brand-primary); + color: var(--color-text-link); } } } diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-node-card/evidence-node-card.component.scss b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-node-card/evidence-node-card.component.scss index aa0724d11..c981a9dc6 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-node-card/evidence-node-card.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-node-card/evidence-node-card.component.scss @@ -49,7 +49,7 @@ mat-card-header { .node-icon { background: var(--color-brand-primary-bg); - color: var(--color-brand-primary); + color: var(--color-text-link); border-radius: var(--radius-full); display: flex; align-items: center; @@ -242,7 +242,7 @@ mat-card-content { .anchor-type { font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); - color: var(--color-brand-primary); + color: var(--color-text-link); text-transform: uppercase; display: block; } diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-node-card/evidence-node-card.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-node-card/evidence-node-card.component.ts index 809a467cc..960560e93 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-node-card/evidence-node-card.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-node-card/evidence-node-card.component.ts @@ -14,6 +14,7 @@ import { MatDividerModule } from '@angular/material/divider'; import { EvidenceNode, EvidenceThreadService } from '../../services/evidence-thread.service'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; @Component({ selector: 'stella-evidence-node-card', standalone: true, @@ -31,6 +32,8 @@ import { EvidenceNode, EvidenceThreadService } from '../../services/evidence-thr changeDetection: ChangeDetectionStrategy.OnPush }) export class EvidenceNodeCardComponent { + private readonly dateFmt = inject(DateFormatService); + private readonly evidenceService = inject(EvidenceThreadService); private readonly sanitizer = inject(DomSanitizer); @@ -99,7 +102,7 @@ export class EvidenceNodeCardComponent { readonly formattedDate = computed(() => { const date = new Date(this.node().createdAt); - return date.toLocaleString('en-US', { + return date.toLocaleString(this.dateFmt.locale(), { year: 'numeric', month: 'short', day: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.html b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.html index 2167d476c..b159da5f6 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.html +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.html @@ -41,10 +41,7 @@ @if (loading()) { -
- -

Loading evidence threads...

-
+ } @if (error() && !loading()) { diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.ts index 4c4f3061f..71485aca9 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-list/evidence-thread-list.component.ts @@ -26,7 +26,9 @@ import { EvidenceThreadService, EvidenceThreadSummary, } from '../../services/evidence-thread.service'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; @Component({ selector: 'stella-evidence-thread-list', standalone: true, @@ -41,12 +43,15 @@ import { MatProgressSpinnerModule, MatTooltipModule, MatCardModule, + LoadingStateComponent, ], templateUrl: './evidence-thread-list.component.html', styleUrls: ['./evidence-thread-list.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class EvidenceThreadListComponent implements OnInit, OnDestroy { + private readonly dateFmt = inject(DateFormatService); + private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); readonly evidenceService = inject(EvidenceThreadService); @@ -120,7 +125,7 @@ export class EvidenceThreadListComponent implements OnInit, OnDestroy { return value; } - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString(this.dateFmt.locale(), { year: 'numeric', month: 'short', day: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.html b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.html index 704a947c3..26f6b4661 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.html +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.html @@ -37,10 +37,7 @@ @if (loading()) { -
- -

Loading evidence thread...

-
+ } @if (error() && !loading()) { diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.ts index 949767fc1..71614bd3a 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-thread-view/evidence-thread-view.component.ts @@ -25,7 +25,9 @@ import { EvidenceThreadService, } from '../../services/evidence-thread.service'; import { DigestChipComponent } from '../../../../shared/domain/digest-chip/digest-chip.component'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; @Component({ selector: 'stella-evidence-thread-view', standalone: true, @@ -38,12 +40,15 @@ import { DigestChipComponent } from '../../../../shared/domain/digest-chip/diges MatProgressSpinnerModule, MatTooltipModule, DigestChipComponent, + LoadingStateComponent, ], templateUrl: './evidence-thread-view.component.html', styleUrls: ['./evidence-thread-view.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class EvidenceThreadViewComponent implements OnInit, OnDestroy { + private readonly dateFmt = inject(DateFormatService); + private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); readonly evidenceService = inject(EvidenceThreadService); @@ -102,7 +107,7 @@ export class EvidenceThreadViewComponent implements OnInit, OnDestroy { return value; } - return date.toLocaleString('en-US', { + return date.toLocaleString(this.dateFmt.locale(), { year: 'numeric', month: 'short', day: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-timeline-panel/evidence-timeline-panel.component.scss b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-timeline-panel/evidence-timeline-panel.component.scss index c8897952c..1aaa7cef2 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-timeline-panel/evidence-timeline-panel.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-timeline-panel/evidence-timeline-panel.component.scss @@ -48,7 +48,7 @@ .date-text { font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); - color: var(--color-brand-primary); + color: var(--color-text-link); text-transform: uppercase; letter-spacing: 0.5px; } @@ -193,7 +193,7 @@ .entry-kind { font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); - color: var(--color-brand-primary); + color: var(--color-text-link); text-transform: uppercase; } diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-timeline-panel/evidence-timeline-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-timeline-panel/evidence-timeline-panel.component.ts index fce723f9c..a62099080 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-timeline-panel/evidence-timeline-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-timeline-panel/evidence-timeline-panel.component.ts @@ -11,6 +11,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { EvidenceNode, EvidenceThreadService } from '../../services/evidence-thread.service'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; interface TimelineEntry { node: EvidenceNode; date: Date; @@ -34,6 +35,8 @@ interface TimelineEntry { changeDetection: ChangeDetectionStrategy.OnPush }) export class EvidenceTimelinePanelComponent { + private readonly dateFmt = inject(DateFormatService); + private readonly evidenceService = inject(EvidenceThreadService); private readonly sanitizer = inject(DomSanitizer); @@ -119,7 +122,7 @@ export class EvidenceTimelinePanelComponent { } private formatDate(date: Date): string { - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString(this.dateFmt.locale(), { year: 'numeric', month: 'short', day: 'numeric' @@ -127,7 +130,7 @@ export class EvidenceTimelinePanelComponent { } private formatTime(date: Date): string { - return date.toLocaleTimeString('en-US', { + return date.toLocaleTimeString(this.dateFmt.locale(), { hour: '2-digit', minute: '2-digit' }); diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-transcript-panel/evidence-transcript-panel.component.scss b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-transcript-panel/evidence-transcript-panel.component.scss index 073932e73..030724a50 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-transcript-panel/evidence-transcript-panel.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-transcript-panel/evidence-transcript-panel.component.scss @@ -18,7 +18,7 @@ mat-card-header { mat-icon[mat-card-avatar] { background: var(--color-brand-primary-bg); - color: var(--color-brand-primary); + color: var(--color-text-link); border-radius: var(--radius-full); padding: var(--space-2); width: 40px; @@ -110,7 +110,7 @@ font-size: var(--font-size-xs); font-weight: var(--font-weight-medium); background: var(--color-brand-primary-bg); - color: var(--color-brand-primary); + color: var(--color-text-link); padding: var(--space-0-5) var(--space-2); border-radius: var(--radius-sm); } @@ -193,7 +193,7 @@ font-size: 14px; width: 14px; height: 14px; - color: var(--color-brand-primary); + color: var(--color-text-link); } } } diff --git a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-transcript-panel/evidence-transcript-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-transcript-panel/evidence-transcript-panel.component.ts index 7fc0dbccb..2de901f40 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-transcript-panel/evidence-transcript-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence-thread/components/evidence-transcript-panel/evidence-transcript-panel.component.ts @@ -13,6 +13,7 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { FormsModule } from '@angular/forms'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; import { EvidenceThread, EvidenceNode, @@ -39,6 +40,8 @@ import { changeDetection: ChangeDetectionStrategy.OnPush }) export class EvidenceTranscriptPanelComponent { + private readonly dateFmt = inject(DateFormatService); + private readonly evidenceService = inject(EvidenceThreadService); private readonly snackBar = inject(MatSnackBar); @@ -139,7 +142,7 @@ export class EvidenceTranscriptPanelComponent { formatGeneratedDate(): string { const date = this.transcript()?.generatedAt; if (!date) return ''; - return new Date(date).toLocaleString('en-US', { + return new Date(date).toLocaleString(this.dateFmt.locale(), { year: 'numeric', month: 'short', day: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-center-page.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-center-page.component.ts index 5a4461503..89d9aee9c 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-center-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-center-page.component.ts @@ -233,7 +233,7 @@ interface EvidencePacket { .data-table tbody tr:hover { background: var(--color-nav-hover); } .evidence-link { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-weight: var(--font-weight-medium); font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace; @@ -259,7 +259,7 @@ interface EvidencePacket { .status-icon--muted { color: var(--color-text-muted); } .proof-chain-link { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.75rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts index 8b36f6068..a52a0d5c8 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-packet-page.component.ts @@ -17,11 +17,19 @@ import type { VerificationSummaryData, EvidencePayloadData, } from '../../shared/ui/witness/index'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +const PACKET_TABS: StellaPageTab[] = [ + { id: 'summary', label: 'Summary', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'contents', label: 'Contents', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' }, + { id: 'verify', label: 'Verify', icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M22 4L12 14.01l-3-3' }, + { id: 'proof-chain', label: 'Proof Chain', icon: 'M6 3v12|||M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M18 9a9 9 0 0 1-9 9' }, +]; @Component({ selector: 'app-evidence-packet-page', standalone: true, - imports: [CommonModule, RouterLink, VerificationSummaryComponent, EvidencePayloadComponent], + imports: [CommonModule, RouterLink, VerificationSummaryComponent, EvidencePayloadComponent, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -72,21 +80,12 @@ import type { - - - -
+ @switch (activeTab()) { @case ('summary') {
@@ -189,7 +188,7 @@ import type { display: inline-block; margin-bottom: 0.5rem; font-size: 0.875rem; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } .header-main { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.5rem; } @@ -206,7 +205,7 @@ import type { font-size: 0.75rem; font-weight: var(--font-weight-semibold); } - .type-badge--promotion { background: var(--color-brand-primary-10); color: var(--color-brand-secondary); border: 1px solid var(--color-brand-primary-20); } + .type-badge--promotion { background: var(--color-brand-primary-10); color: var(--color-text-link); border: 1px solid var(--color-brand-primary-20); } .type-badge--scan { background: var(--color-status-info-bg); color: var(--color-status-info-text); border: 1px solid var(--color-status-info-border); } .type-badge--deployment { background: var(--color-status-success-bg); color: var(--color-status-success-text); border: 1px solid var(--color-status-success-border); } .type-badge--attestation { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); border: 1px solid var(--color-status-warning-border); } @@ -244,28 +243,7 @@ import type { .status-warning { color: var(--color-status-warning-text); } .status-muted { color: var(--color-text-muted); } - .tabs { - display: flex; - gap: 0.25rem; - border-bottom: 1px solid var(--color-border-primary); - margin-bottom: 1.5rem; - } - .tab { - padding: 0.75rem 1rem; - background: transparent; - border: none; - border-bottom: 2px solid transparent; - margin-bottom: -1px; - font-size: 0.875rem; - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - cursor: pointer; - } - .tab:hover { color: var(--color-text-primary); } - .tab--active { - color: var(--color-brand-primary); - border-bottom-color: var(--color-brand-primary); - } + .panel { padding: 1.5rem; @@ -346,12 +324,7 @@ export class EvidencePacketPageComponent implements OnInit { packetId = signal(''); activeTab = signal('summary'); - tabs = [ - { id: 'summary', label: 'Summary' }, - { id: 'contents', label: 'Contents' }, - { id: 'verify', label: 'Verify' }, - { id: 'proof-chain', label: 'Proof Chain' }, - ]; + readonly PACKET_TABS = PACKET_TABS; packet = signal({ id: 'EVD-2026-045', diff --git a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-page.component.ts b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-page.component.ts index eb79763e8..8db05111b 100644 --- a/src/Web/StellaOps.Web/src/app/features/evidence/evidence-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/evidence/evidence-page.component.ts @@ -12,17 +12,17 @@ import { ActivatedRoute, Router } from '@angular/router'; import { EvidenceData } from '../../core/api/evidence.models'; import { EVIDENCE_API } from '../../core/api/evidence.client'; import { EvidencePanelComponent } from './evidence-panel.component'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; @Component({ selector: 'app-evidence-page', standalone: true, - imports: [EvidencePanelComponent], + imports: [EvidencePanelComponent, LoadingStateComponent], template: `
@if (loading()) {
-
-

Loading evidence for {{ advisoryId() }}...

+
} @else if (error()) { `, styles: [` @@ -583,51 +555,10 @@ function generateMockDiff(): SbomDiff { overflow: hidden; } - /* Tabs */ - .panel-tabs { - display: flex; - border-bottom: 1px solid rgba(212, 201, 168, 0.3); - background: var(--color-surface-secondary); - } - - .panel-tab { - flex: 1; - padding: 0.75rem 0.5rem; - border: none; - background: transparent; - color: var(--color-text-secondary); - font-size: 0.8125rem; - font-weight: var(--font-weight-medium); - cursor: pointer; - transition: all 0.15s ease; - position: relative; - - &:hover { - color: var(--color-text-primary); - background: var(--color-surface-tertiary); - } - - &--active { - color: var(--color-brand-primary); - background: var(--color-surface-primary); - - &::after { - content: ''; - position: absolute; - bottom: -1px; - left: 0; - right: 0; - height: 2px; - background: var(--color-brand-primary); - } - } - } - /* Panel content */ .panel-content { flex: 1; overflow-y: auto; - padding: 1rem; max-height: 500px; } @@ -707,7 +638,7 @@ function generateMockDiff(): SbomDiff { &:hover { border-color: var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); } } @@ -750,7 +681,7 @@ function generateMockDiff(): SbomDiff { font-size: 0.8125rem; a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; &:hover { @@ -999,11 +930,11 @@ function generateMockDiff(): SbomDiff { cursor: pointer; &--primary { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); &:hover { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); } } @@ -1014,7 +945,7 @@ function generateMockDiff(): SbomDiff { &:hover { border-color: var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); } } } @@ -1167,7 +1098,7 @@ function generateMockDiff(): SbomDiff { &:hover:not(:disabled) { border-color: var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); } &:disabled { @@ -1293,7 +1224,6 @@ function generateMockDiff(): SbomDiff { /* Reduced motion */ @media (prefers-reduced-motion: reduce) { - .panel-tab, .scenario-card, .action-btn, .copy-btn { @@ -1304,6 +1234,8 @@ function generateMockDiff(): SbomDiff { changeDetection: ChangeDetectionStrategy.OnPush, }) export class GraphSidePanelsComponent { + readonly SIDE_PANEL_TABS = SIDE_PANEL_TABS; + @Input() selectedNode: NodeDetails | null = null; @Output() nodeSelect = new EventEmitter(); diff --git a/src/Web/StellaOps.Web/src/app/features/home/home-dashboard.component.scss b/src/Web/StellaOps.Web/src/app/features/home/home-dashboard.component.scss index 073f9c166..b2008bb4b 100644 --- a/src/Web/StellaOps.Web/src/app/features/home/home-dashboard.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/home/home-dashboard.component.scss @@ -212,14 +212,14 @@ .card__link { font-size: var(--font-size-sm); font-weight: 600; - color: var(--color-brand-secondary); + color: var(--color-text-link); text-decoration: none; transition: all 0.2s ease; letter-spacing: 0.01em; &:hover { text-decoration: underline; - color: var(--color-brand-primary); + color: var(--color-text-link); } } @@ -436,7 +436,7 @@ &--worsening { background-color: var(--color-brand-soft); - color: var(--color-brand-secondary); + color: var(--color-text-link); } } @@ -469,7 +469,7 @@ } .risk-count--critical .risk-count__value { color: var(--color-severity-critical); } -.risk-count--high .risk-count__value { color: var(--color-brand-secondary); } +.risk-count--high .risk-count__value { color: var(--color-text-link); } .risk-count--medium .risk-count__value { color: var(--color-severity-medium); } // ============================================================================= @@ -613,7 +613,7 @@ } .vex-stat--suppressed .vex-stat__value { color: var(--color-status-success); } -.vex-stat--active .vex-stat__value { color: var(--color-brand-secondary); } +.vex-stat--active .vex-stat__value { color: var(--color-text-link); } .vex-stat--investigating .vex-stat__value { color: var(--color-severity-medium); } .vex-stat__label { @@ -655,67 +655,10 @@ } // ============================================================================= -// Quick Actions +// Quick Actions (compact pill row — uses global .quick-links-row / .quick-link-pill) // ============================================================================= -.quick-actions { +.quick-actions-section { margin-bottom: var(--space-8); animation: dash-in 0.5s 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; } - -.quick-actions__title { - font-size: 18px; - font-weight: 700; - color: var(--color-text-heading); - margin: 0 0 var(--space-4); - letter-spacing: -0.01em; -} - -.quick-actions__grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: var(--space-4); - - @include screen-below-md { - grid-template-columns: repeat(2, 1fr); - } - - @include screen-below-xs { - grid-template-columns: 1fr; - } -} - -.quick-action { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--space-3); - padding: var(--space-5); - text-decoration: none; - background: var(--color-surface-primary); - border: 1px solid hsla(45, 34%, 75%, 0.3); - border-radius: 14px; - color: var(--color-text-secondary); - transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1); - - &:hover { - background: linear-gradient(135deg, var(--color-brand-soft), var(--color-surface-secondary)); - border-color: var(--color-border-emphasis); - color: var(--color-brand-secondary); - transform: translateY(-3px); - box-shadow: var(--shadow-brand-md); - } - - svg { - transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); - } - - &:hover svg { - transform: scale(1.15); - } - - span { - font-size: 14px; - font-weight: 600; - } -} diff --git a/src/Web/StellaOps.Web/src/app/features/home/home-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/home/home-dashboard.component.ts index 637d15d78..45192e6e5 100644 --- a/src/Web/StellaOps.Web/src/app/features/home/home-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/home/home-dashboard.component.ts @@ -259,38 +259,33 @@ import { SkeletonComponent } from '../../shared/components/skeleton/skeleton.com
-
-

Quick Actions

-
diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-activity.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-activity.component.ts index 59425f9a9..031a00bc7 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-activity.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-activity.component.ts @@ -5,6 +5,7 @@ import { FormsModule } from '@angular/forms'; import { Subject, interval, takeUntil } from 'rxjs'; import { integrationWorkspaceCommands } from './integration-route-context'; +import { DateFormatService } from '../../core/i18n/date-format.service'; /** * Integration activity timeline component. * Sprint: SPRINT_20251229_011_FE_integration_hub_ui @@ -306,7 +307,7 @@ export type ActivityEventType = .integration-link { font-weight: var(--font-weight-medium); - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } .integration-link:hover { text-decoration: underline; } @@ -336,7 +337,7 @@ export type ActivityEventType = } .load-more-btn { padding: 0.75rem 2rem; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; border: none; border-radius: var(--radius-sm); @@ -349,6 +350,8 @@ export type ActivityEventType = `] }) export class IntegrationActivityComponent implements OnInit, OnDestroy { + private readonly dateFmt = inject(DateFormatService); + private readonly router = inject(Router); private destroy$ = new Subject(); @@ -526,7 +529,7 @@ export class IntegrationActivityComponent implements OnInit, OnDestroy { if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`; - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts index ba3d95ec9..d999db50f 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-detail.component.ts @@ -18,16 +18,25 @@ import { getIntegrationTypeLabel, getProviderLabel, } from './integration.models'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'events' | 'health'; +const HUB_DETAIL_TABS: StellaPageTab[] = [ + { id: 'overview', label: 'Overview', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' }, + { id: 'credentials', label: 'Credentials', icon: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' }, + { id: 'scopes-rules', label: 'Scopes & Rules', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'events', label: 'Events', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, + { id: 'health', label: 'Health', icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M22 4L12 14.01l-3-3' }, +]; + /** * Integration detail component showing health, activity, and configuration. * Sprint: SPRINT_20251229_011_FE_integration_hub_ui */ @Component({ selector: 'app-integration-detail', - imports: [CommonModule, RouterModule], + imports: [CommonModule, RouterModule, StellaPageTabsComponent], template: ` @if (loading) {
Loading integration details...
@@ -64,13 +73,12 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event {{ integration.lastHealthCheckAt ? (integration.lastHealthCheckAt | date:'medium') : 'Never' }}
- +
@switch (activeTab) { @case ('overview') { @@ -144,7 +152,7 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event @case ('events') {

Events

-
Deployment ID
+
@@ -256,26 +264,7 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event margin-bottom: 0.25rem; } - .detail-tabs { - display: flex; - gap: 0; - border-bottom: 1px solid var(--color-border-primary); - margin-bottom: 1.5rem; - } - .detail-tabs button { - padding: 0.75rem 1.5rem; - background: none; - border: none; - border-bottom: 2px solid transparent; - cursor: pointer; - font-weight: var(--font-weight-medium); - } - - .detail-tabs button.active { - border-bottom-color: var(--color-brand-primary); - color: var(--color-brand-primary); - } .tab-panel h2 { margin-top: 0; @@ -348,7 +337,6 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event .event-table { width: 100%; - border-collapse: collapse; } .event-table th, @@ -382,7 +370,7 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event .btn-secondary { background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); border: 1px solid var(--color-brand-primary); } @@ -433,7 +421,7 @@ type IntegrationDetailTab = 'overview' | 'credentials' | 'scopes-rules' | 'event display: inline-block; margin-top: 0.75rem; padding: 0.5rem 1rem; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); border-radius: var(--radius-md); text-decoration: none; @@ -479,6 +467,7 @@ export class IntegrationDetailComponent implements OnInit { integration?: Integration; loading = true; loadErrorMessage: string | null = null; + readonly HUB_DETAIL_TABS = HUB_DETAIL_TABS; activeTab: IntegrationDetailTab = 'overview'; testing = false; checking = false; diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts index b13b7abf9..e4ddc71b9 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-hub.component.ts @@ -201,7 +201,7 @@ interface IntegrationHubStats { } .setup-order__list a { - color: var(--color-brand-primary); + color: var(--color-text-link); font-weight: var(--font-weight-semibold); text-decoration: none; } @@ -245,7 +245,7 @@ interface IntegrationHubStats { border-radius: var(--radius-md); background: var(--color-surface-primary); text-decoration: none; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.74rem; padding: 0.3rem 0.55rem; cursor: pointer; diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts index 50f17da21..12a7523d6 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-list.component.ts @@ -225,7 +225,7 @@ import { color: var(--color-text-secondary); } .actions button:hover, .actions a:hover { - color: var(--color-brand-primary); + color: var(--color-text-link); } .pagination { @@ -319,7 +319,7 @@ import { border: 1px solid var(--color-brand-primary); border-radius: var(--radius-md); background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); font-weight: var(--font-weight-medium); cursor: pointer; } diff --git a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-shell.component.ts index 29fe31f25..82746c940 100644 --- a/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integration-hub/integration-shell.component.ts @@ -1,12 +1,32 @@ -import { Component } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router'; +import { filter } from 'rxjs'; -import { TabbedNavComponent, TabItem } from '../../shared/ui/tabbed-nav/tabbed-nav.component'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +type TabType = 'hub' | 'registries' | 'scm' | 'ci' | 'runtime-hosts' | 'advisory-vex-sources' | 'secrets' | 'activity'; + +const KNOWN_TAB_IDS: readonly string[] = [ + 'hub', 'registries', 'scm', 'ci', 'runtime-hosts', 'advisory-vex-sources', 'secrets', 'activity', +]; + +const PAGE_TABS: readonly StellaPageTab[] = [ + { id: 'hub', label: 'Hub', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M2 12h20|||M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z' }, + { id: 'registries', label: 'Registries', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' }, + { id: 'scm', label: 'SCM', icon: 'M6 3v12|||M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M18 9a9 9 0 0 1-9 9' }, + { id: 'ci', label: 'CI/CD', icon: 'M5 3l14 9-14 9V3z' }, + { id: 'runtime-hosts', label: 'Runtimes / Hosts', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01' }, + { id: 'advisory-vex-sources', label: 'Advisory & VEX', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'secrets', label: 'Secrets', icon: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' }, + { id: 'activity', label: 'Activity', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, +]; @Component({ selector: 'app-integration-shell', standalone: true, - imports: [RouterOutlet, TabbedNavComponent], + imports: [RouterOutlet, StellaPageTabsComponent], + changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -14,11 +34,14 @@ import { TabbedNavComponent, TabItem } from '../../shared/ui/tabbed-nav/tabbed-n

External system connectors for release, security, and evidence flows.

- - -
+ -
+
`, styles: [` @@ -41,21 +64,39 @@ import { TabbedNavComponent, TabItem } from '../../shared/ui/tabbed-nav/tabbed-n color: var(--color-text-secondary); font-size: 0.8rem; } - - .integration-shell__content { - padding-top: 0.25rem; - } `], }) -export class IntegrationShellComponent { - readonly tabs: TabItem[] = [ - { id: 'hub', label: 'Hub', route: './' }, - { id: 'registries', label: 'Registries', route: 'registries' }, - { id: 'scm', label: 'SCM', route: 'scm' }, - { id: 'ci', label: 'CI/CD', route: 'ci' }, - { id: 'runtimes', label: 'Runtimes / Hosts', route: 'runtime-hosts' }, - { id: 'advisory', label: 'Advisory & VEX', route: 'advisory-vex-sources' }, - { id: 'secrets', label: 'Secrets', route: 'secrets' }, - { id: 'activity', label: 'Activity', route: 'activity' }, - ]; +export class IntegrationShellComponent implements OnInit { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); + + readonly pageTabs = PAGE_TABS; + readonly activeTab = signal('hub'); + + ngOnInit(): void { + this.setActiveTabFromUrl(this.router.url); + this.router.events.pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef), + ).subscribe(e => this.setActiveTabFromUrl(e.urlAfterRedirects)); + } + + onTabChange(tabId: string): void { + this.activeTab.set(tabId as TabType); + // 'hub' is the root/default tab — navigate to shell root + const route = tabId === 'hub' ? './' : tabId; + this.router.navigate([route], { relativeTo: this.route, queryParamsHandling: 'merge' }); + } + + private setActiveTabFromUrl(url: string): void { + const segments = url.split('?')[0].split('/').filter(Boolean); + const lastSegment = segments.at(-1) ?? ''; + if (KNOWN_TAB_IDS.includes(lastSegment as TabType)) { + this.activeTab.set(lastSegment as TabType); + } else { + // Default to 'hub' when at the integration root + this.activeTab.set('hub'); + } + } } diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts index 6e56fbb19..4607be844 100644 --- a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/advisory-source-catalog.component.ts @@ -550,7 +550,7 @@ interface CategoryGroup { } .toggle-on .toggle-track { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); } .toggle-thumb { @@ -710,7 +710,7 @@ interface CategoryGroup { .detail-link { font-size: 0.82rem; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } @@ -839,7 +839,7 @@ interface CategoryGroup { .mirror-link { font-size: 0.8rem; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-weight: var(--font-weight-medium); } @@ -887,7 +887,7 @@ interface CategoryGroup { } .btn-mirror { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); } diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-client-setup.component.ts b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-client-setup.component.ts index 0f9fae585..d56adce0f 100644 --- a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-client-setup.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-client-setup.component.ts @@ -768,7 +768,7 @@ interface PreflightCheck { .step-pill.step-active { border-color: var(--color-brand-primary); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); } @@ -933,7 +933,7 @@ interface PreflightCheck { .toggle-section-btn { background: none; border: none; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.82rem; font-weight: var(--font-weight-medium); cursor: pointer; @@ -1043,7 +1043,7 @@ interface PreflightCheck { .mode-option--active { border-color: var(--color-brand-primary); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); background: rgba(59, 130, 246, 0.08); } @@ -1084,7 +1084,7 @@ interface PreflightCheck { .preset-btn--active { border-color: var(--color-brand-primary); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); } diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-domain-builder.component.ts b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-domain-builder.component.ts index 6796740b7..8b08c441b 100644 --- a/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-domain-builder.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integrations/advisory-vex-sources/mirror-domain-builder.component.ts @@ -568,7 +568,7 @@ interface CategorySummary { .step-pill.step-active { border-color: var(--color-brand-primary); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); } @@ -786,7 +786,7 @@ interface CategorySummary { } .summary-total strong { - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 1.1rem; } @@ -1085,7 +1085,7 @@ interface CategorySummary { } .btn-accent { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); } diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/integration-wizard.component.scss b/src/Web/StellaOps.Web/src/app/features/integrations/integration-wizard.component.scss index 1dbd6795f..90b237d19 100644 --- a/src/Web/StellaOps.Web/src/app/features/integrations/integration-wizard.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/integrations/integration-wizard.component.scss @@ -510,12 +510,12 @@ transition: all var(--motion-duration-fast) var(--motion-ease-default); &.btn-primary { - background: var(--color-brand-primary); - color: var(--color-text-inverse); - border: none; + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); + border: 1px solid var(--color-btn-primary-border, transparent); &:hover:not(:disabled) { - background: var(--color-brand-primary-hover); + background: var(--color-btn-primary-bg-hover); } &:disabled { @@ -525,11 +525,11 @@ } &.btn-secondary { - background: var(--color-surface-secondary); - border: 1px solid var(--color-border-primary); + background: var(--color-btn-secondary-bg); + border: 1px solid var(--color-btn-secondary-border); &:hover { - background: var(--color-surface-tertiary); + background: var(--color-btn-secondary-hover-bg); } } diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/integrations-hub.component.ts b/src/Web/StellaOps.Web/src/app/features/integrations/integrations-hub.component.ts index d1888edf9..8344d3fe3 100644 --- a/src/Web/StellaOps.Web/src/app/features/integrations/integrations-hub.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/integrations/integrations-hub.component.ts @@ -82,26 +82,42 @@ import {

CI/CD Pipelines

-

CI onboarding stays unavailable until a real pipeline connector plugin is installed.

+

Connect CI/CD systems for deployment gate signals and pipeline health monitoring.

-

No CI/CD connector plugins are currently available.

+ @if (ciProviders().length > 0) { +
+ @for (provider of ciProviders(); track provider.provider) { + {{ provider.name }} + } +
+ } @else { +

No CI/CD connector plugins are installed in this environment.

+ }

Hosts & Observers

-

Runtime-host onboarding stays unavailable until a real host connector plugin is installed.

+

Connect runtime host agents for process witness telemetry and reachability confidence.

-

No runtime-host connector plugins are currently available.

+ @if (hostProviders().length > 0) { +
+ @for (provider of hostProviders(); track provider.provider) { + {{ provider.name }} + } +
+ } @else { +

No runtime-host connector plugins are installed in this environment.

+ }
diff --git a/src/Web/StellaOps.Web/src/app/features/integrations/models/integration.models.ts b/src/Web/StellaOps.Web/src/app/features/integrations/models/integration.models.ts index b64aedc6f..dc7678ad8 100644 --- a/src/Web/StellaOps.Web/src/app/features/integrations/models/integration.models.ts +++ b/src/Web/StellaOps.Web/src/app/features/integrations/models/integration.models.ts @@ -63,6 +63,7 @@ export interface IntegrationSchedule { } const ALL_PROVIDER_DEFINITIONS: readonly IntegrationProviderDefinition[] = [ + // ── Registry providers ────────────────────────────────────────── { provider: IntegrationProvider.Harbor, type: 'registry', @@ -77,6 +78,141 @@ const ALL_PROVIDER_DEFINITIONS: readonly IntegrationProviderDefinition[] = [ exposeInUi: true, configFields: [], }, + { + provider: IntegrationProvider.Ecr, + type: 'registry', + name: 'AWS ECR', + icon: 'ECR', + description: 'Amazon Elastic Container Registry with IAM or access-key authentication.', + defaultEndpoint: 'https://.dkr.ecr..amazonaws.com', + endpointHint: 'Use the full ECR endpoint URL including account ID and region.', + authRefHint: 'Reference a vault secret containing AWS access key credentials or an IAM role ARN.', + organizationLabel: 'Repository Prefix', + organizationHint: 'Optional prefix to scope image discovery within the registry.', + exposeInUi: true, + configFields: [ + { id: 'awsRegion', label: 'AWS Region', required: true, placeholder: 'us-east-1' }, + { id: 'awsAccountId', label: 'AWS Account ID', required: true, placeholder: '123456789012' }, + ], + }, + { + provider: IntegrationProvider.Gcr, + type: 'registry', + name: 'Google GCR', + icon: 'GCR', + description: 'Google Container Registry or Artifact Registry with service-account authentication.', + defaultEndpoint: 'https://gcr.io', + endpointHint: 'Use gcr.io, us-docker.pkg.dev, or your regional Artifact Registry host.', + authRefHint: 'Reference a vault secret containing a GCP service-account JSON key.', + organizationLabel: 'Project ID', + organizationHint: 'GCP project ID used to scope image discovery.', + exposeInUi: true, + configFields: [ + { id: 'gcpProjectId', label: 'GCP Project ID', required: true, placeholder: 'my-project-123' }, + ], + }, + { + provider: IntegrationProvider.Acr, + type: 'registry', + name: 'Azure ACR', + icon: 'ACR', + description: 'Azure Container Registry with service-principal or managed-identity authentication.', + defaultEndpoint: 'https://.azurecr.io', + endpointHint: 'Use the full ACR login server URL (e.g., myregistry.azurecr.io).', + authRefHint: 'Reference a vault secret containing a service principal client secret or token.', + organizationLabel: 'Repository Prefix', + organizationHint: 'Optional repository prefix to scope image discovery.', + exposeInUi: true, + configFields: [ + { id: 'tenantId', label: 'Azure Tenant ID', required: true, placeholder: '00000000-0000-0000-0000-000000000000' }, + { id: 'clientId', label: 'Client ID', required: true, placeholder: '00000000-0000-0000-0000-000000000000' }, + ], + }, + { + provider: IntegrationProvider.DockerHub, + type: 'registry', + name: 'Docker Hub', + icon: 'DH', + description: 'Docker Hub registry with personal access token or username/password authentication.', + defaultEndpoint: 'https://registry.hub.docker.com', + endpointHint: 'Use the Docker Hub registry URL. Leave as default for Docker Hub Cloud.', + authRefHint: 'Reference a vault secret containing a Docker Hub access token or username:password.', + organizationLabel: 'Namespace / Organization', + organizationHint: 'Docker Hub namespace or organization to scope repository discovery.', + exposeInUi: true, + configFields: [], + }, + { + provider: IntegrationProvider.Quay, + type: 'registry', + name: 'Quay', + icon: 'Q', + description: 'Quay.io or self-hosted Quay registry with robot-account authentication.', + defaultEndpoint: 'https://quay.io', + endpointHint: 'Use https://quay.io for Quay Cloud or your self-hosted Quay base URL.', + authRefHint: 'Reference a vault secret containing a Quay robot-account token or OAuth token.', + organizationLabel: 'Organization', + organizationHint: 'Quay organization to scope repository discovery.', + exposeInUi: true, + configFields: [], + }, + { + provider: IntegrationProvider.Artifactory, + type: 'registry', + name: 'Artifactory', + icon: 'AF', + description: 'JFrog Artifactory Docker registry with API key or access token authentication.', + defaultEndpoint: 'https://artifactory.local', + endpointHint: 'Use the Artifactory base URL (the Docker registry virtual or local repo path).', + authRefHint: 'Reference a vault secret containing an Artifactory API key or access token.', + organizationLabel: 'Repository Key', + organizationHint: 'Artifactory repository key for Docker images.', + exposeInUi: true, + configFields: [], + }, + { + provider: IntegrationProvider.Nexus, + type: 'registry', + name: 'Nexus', + icon: 'NX', + description: 'Sonatype Nexus Repository Docker registry with token or basic-auth.', + defaultEndpoint: 'https://nexus.local', + endpointHint: 'Use the Nexus base URL with the Docker hosted or proxy repository port.', + authRefHint: 'Reference a vault secret containing a Nexus user token or username:password.', + organizationLabel: 'Repository Name', + organizationHint: 'Nexus Docker repository name to scope image discovery.', + exposeInUi: true, + configFields: [], + }, + { + provider: IntegrationProvider.GitHubContainerRegistry, + type: 'registry', + name: 'GitHub Container Registry', + icon: 'GHCR', + description: 'GitHub Container Registry (ghcr.io) with personal access token authentication.', + defaultEndpoint: 'https://ghcr.io', + endpointHint: 'Use https://ghcr.io for the GitHub Container Registry.', + authRefHint: 'Reference a vault secret containing a GitHub personal access token with packages scope.', + organizationLabel: 'Owner / Organization', + organizationHint: 'GitHub owner or organization to scope package discovery.', + exposeInUi: true, + configFields: [], + }, + { + provider: IntegrationProvider.GitLabContainerRegistry, + type: 'registry', + name: 'GitLab Container Registry', + icon: 'GLCR', + description: 'GitLab Container Registry with deploy token or personal access token.', + defaultEndpoint: 'https://registry.gitlab.com', + endpointHint: 'Use the GitLab registry URL or your self-hosted GitLab registry endpoint.', + authRefHint: 'Reference a vault secret containing a GitLab deploy token or personal access token.', + organizationLabel: 'Group / Project', + organizationHint: 'GitLab group or project path to scope image discovery.', + exposeInUi: true, + configFields: [], + }, + // ── SCM providers ─────────────────────────────────────────────── { provider: IntegrationProvider.GitHubApp, type: 'scm', @@ -104,6 +240,203 @@ const ALL_PROVIDER_DEFINITIONS: readonly IntegrationProviderDefinition[] = [ }, ], }, + { + provider: IntegrationProvider.GitLabServer, + type: 'scm', + name: 'GitLab Server', + icon: 'GL', + description: 'GitLab CE/EE or GitLab.com with project or group access token authentication.', + defaultEndpoint: 'https://gitlab.com', + endpointHint: 'Use https://gitlab.com for GitLab Cloud or your self-hosted GitLab base URL.', + authRefHint: 'Reference a vault secret containing a GitLab personal or project access token.', + organizationLabel: 'Group / Namespace', + organizationHint: 'GitLab group or namespace to scope repository discovery.', + exposeInUi: true, + configFields: [], + }, + { + provider: IntegrationProvider.Bitbucket, + type: 'scm', + name: 'Bitbucket', + icon: 'BB', + description: 'Bitbucket Cloud or Bitbucket Server/Data Center with app password or token authentication.', + defaultEndpoint: 'https://bitbucket.org', + endpointHint: 'Use https://bitbucket.org for Bitbucket Cloud or your Bitbucket Server base URL.', + authRefHint: 'Reference a vault secret containing a Bitbucket app password or access token.', + organizationLabel: 'Workspace / Project', + organizationHint: 'Bitbucket workspace or project key to scope repository discovery.', + exposeInUi: true, + configFields: [], + }, + { + provider: IntegrationProvider.Gitea, + type: 'scm', + name: 'Gitea', + icon: 'GT', + description: 'Gitea or Forgejo self-hosted instance with API token authentication.', + defaultEndpoint: 'https://gitea.local', + endpointHint: 'Use your Gitea or Forgejo instance base URL.', + authRefHint: 'Reference a vault secret containing a Gitea API token.', + organizationLabel: 'Organization / Owner', + organizationHint: 'Gitea organization or owner to scope repository discovery.', + exposeInUi: true, + configFields: [], + }, + { + provider: IntegrationProvider.AzureDevOps, + type: 'scm', + name: 'Azure DevOps', + icon: 'AZ', + description: 'Azure DevOps Services or Server with PAT or service principal authentication.', + defaultEndpoint: 'https://dev.azure.com', + endpointHint: 'Use https://dev.azure.com for Azure DevOps Services or your on-premises TFS/Azure DevOps Server URL.', + authRefHint: 'Reference a vault secret containing an Azure DevOps PAT or service principal credentials.', + organizationLabel: 'Organization / Collection', + organizationHint: 'Azure DevOps organization (or collection for on-premises) to scope repository discovery.', + exposeInUi: true, + configFields: [ + { id: 'project', label: 'Project Name', required: false, placeholder: 'MyProject', hint: 'Optional project to narrow repo scope.' }, + ], + }, + // ── CI/CD providers ───────────────────────────────────────────── + { + provider: IntegrationProvider.GitHubActions, + type: 'ci', + name: 'GitHub Actions', + icon: 'GA', + description: 'GitHub Actions workflow monitoring and deployment gate integration.', + defaultEndpoint: 'https://github.com', + endpointHint: 'Use https://github.com or your GitHub Enterprise Server base URL.', + authRefHint: 'Reference a vault secret containing a GitHub token with actions and deployments scope.', + organizationLabel: 'Owner / Organization', + organizationHint: 'GitHub owner or organization to scope workflow discovery.', + exposeInUi: true, + configFields: [], + }, + { + provider: IntegrationProvider.GitLabCi, + type: 'ci', + name: 'GitLab CI', + icon: 'GCI', + description: 'GitLab CI/CD pipeline monitoring and deployment gate integration.', + defaultEndpoint: 'https://gitlab.com', + endpointHint: 'Use https://gitlab.com or your self-hosted GitLab base URL.', + authRefHint: 'Reference a vault secret containing a GitLab token with API access.', + organizationLabel: 'Group / Namespace', + organizationHint: 'GitLab group to scope pipeline discovery.', + exposeInUi: true, + configFields: [], + }, + { + provider: IntegrationProvider.Jenkins, + type: 'ci', + name: 'Jenkins', + icon: 'JK', + description: 'Jenkins build server with API token or username/password authentication.', + defaultEndpoint: 'https://jenkins.local', + endpointHint: 'Use the Jenkins base URL (including context path if configured).', + authRefHint: 'Reference a vault secret containing a Jenkins API token or username:apiToken.', + organizationLabel: 'Folder / View', + organizationHint: 'Optional Jenkins folder or view to scope job discovery.', + exposeInUi: true, + configFields: [], + }, + { + provider: IntegrationProvider.CircleCi, + type: 'ci', + name: 'CircleCI', + icon: 'CC', + description: 'CircleCI pipeline monitoring with API token authentication.', + defaultEndpoint: 'https://circleci.com', + endpointHint: 'Use https://circleci.com for CircleCI Cloud or your self-hosted server URL.', + authRefHint: 'Reference a vault secret containing a CircleCI personal API token.', + organizationLabel: 'Organization', + organizationHint: 'CircleCI organization slug to scope project discovery.', + exposeInUi: true, + configFields: [], + }, + { + provider: IntegrationProvider.AzurePipelines, + type: 'ci', + name: 'Azure Pipelines', + icon: 'AP', + description: 'Azure Pipelines build and release monitoring with PAT authentication.', + defaultEndpoint: 'https://dev.azure.com', + endpointHint: 'Use https://dev.azure.com for Azure DevOps Services or your on-premises URL.', + authRefHint: 'Reference a vault secret containing an Azure DevOps PAT with build scope.', + organizationLabel: 'Organization / Project', + organizationHint: 'Azure DevOps organization and project to scope pipeline discovery.', + exposeInUi: true, + configFields: [ + { id: 'project', label: 'Project Name', required: false, placeholder: 'MyProject', hint: 'Optional project to narrow pipeline scope.' }, + ], + }, + { + provider: IntegrationProvider.ArgoWorkflows, + type: 'ci', + name: 'Argo Workflows', + icon: 'AW', + description: 'Argo Workflows or Argo CD integration for Kubernetes-native CI/CD monitoring.', + defaultEndpoint: 'https://argo.local', + endpointHint: 'Use the Argo Workflows or Argo CD server base URL.', + authRefHint: 'Reference a vault secret containing an Argo API token or service account token.', + organizationLabel: 'Namespace', + organizationHint: 'Kubernetes namespace to scope workflow discovery.', + exposeInUi: true, + configFields: [], + }, + { + provider: IntegrationProvider.Tekton, + type: 'ci', + name: 'Tekton', + icon: 'TK', + description: 'Tekton Pipelines integration for Kubernetes-native CI/CD monitoring.', + defaultEndpoint: 'https://tekton-dashboard.local', + endpointHint: 'Use the Tekton Dashboard or Tekton Results API base URL.', + authRefHint: 'Reference a vault secret containing a Kubernetes service account token.', + organizationLabel: 'Namespace', + organizationHint: 'Kubernetes namespace to scope pipeline run discovery.', + exposeInUi: true, + configFields: [], + }, + // ── Runtime host providers ────────────────────────────────────── + { + provider: IntegrationProvider.EbpfAgent, + type: 'host', + name: 'eBPF Agent', + icon: 'EB', + description: 'Linux eBPF-based runtime agent for process and library witness telemetry.', + defaultEndpoint: 'https://agent.local:9443', + endpointHint: 'Use the eBPF agent gRPC or HTTP endpoint on the target host.', + authRefHint: 'Reference a vault secret containing the agent mTLS client certificate or bearer token.', + exposeInUi: true, + configFields: [], + }, + { + provider: IntegrationProvider.EtwAgent, + type: 'host', + name: 'ETW Agent', + icon: 'ETW', + description: 'Windows ETW-based runtime agent for process and library witness telemetry.', + defaultEndpoint: 'https://agent.local:9443', + endpointHint: 'Use the ETW agent HTTP endpoint on the target Windows host.', + authRefHint: 'Reference a vault secret containing the agent bearer token.', + exposeInUi: true, + configFields: [], + }, + { + provider: IntegrationProvider.DyldInterposer, + type: 'host', + name: 'dyld Interposer', + icon: 'DY', + description: 'macOS dyld interposer agent for runtime library witness telemetry.', + defaultEndpoint: 'https://agent.local:9443', + endpointHint: 'Use the dyld interposer agent HTTP endpoint on the target macOS host.', + authRefHint: 'Reference a vault secret containing the agent bearer token.', + exposeInUi: true, + configFields: [], + }, + // ── InMemory (testing only, not exposed) ──────────────────────── { provider: IntegrationProvider.InMemory, type: 'registry', diff --git a/src/Web/StellaOps.Web/src/app/features/issuer-trust/components/issuer-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/issuer-trust/components/issuer-detail.component.ts index dacf4d1bf..1b8ac275c 100644 --- a/src/Web/StellaOps.Web/src/app/features/issuer-trust/components/issuer-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/issuer-trust/components/issuer-detail.component.ts @@ -5,6 +5,7 @@ import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@ang import { ActivatedRoute, RouterModule } from '@angular/router'; +import { DateFormatService } from '../../../core/i18n/date-format.service'; interface IssuerKey { id: string; algorithm: string; @@ -290,6 +291,8 @@ interface IssuerDetail { `] }) export class IssuerDetailComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + private readonly route = inject(ActivatedRoute); readonly issuer = signal(null); @@ -328,7 +331,7 @@ export class IssuerDetailComponent implements OnInit { } formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString('en-US', { + return new Date(dateStr).toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/issuer-trust/components/issuer-list.component.ts b/src/Web/StellaOps.Web/src/app/features/issuer-trust/components/issuer-list.component.ts index 5b579a042..cc293a639 100644 --- a/src/Web/StellaOps.Web/src/app/features/issuer-trust/components/issuer-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/issuer-trust/components/issuer-list.component.ts @@ -1,11 +1,13 @@ // Issuer List Component // Sprint 024: Issuer Trust UI -import { Component, ChangeDetectionStrategy, signal, computed, OnInit } from '@angular/core'; +import { Component, ChangeDetectionStrategy, signal, computed, OnInit, + inject,} from '@angular/core'; import { RouterModule } from '@angular/router'; import { FormsModule } from '@angular/forms'; +import { DateFormatService } from '../../../core/i18n/date-format.service'; interface Issuer { id: string; name: string; @@ -256,6 +258,8 @@ interface Issuer { `] }) export class IssuerListComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + readonly issuers = signal([]); readonly searchQuery = signal(''); readonly statusFilter = signal<'all' | Issuer['status']>('all'); @@ -312,7 +316,7 @@ export class IssuerListComponent implements OnInit { } formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString('en-US', { + return new Date(dateStr).toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/issuer-trust/issuer-trust.component.ts b/src/Web/StellaOps.Web/src/app/features/issuer-trust/issuer-trust.component.ts index 92dda7254..1575d3617 100644 --- a/src/Web/StellaOps.Web/src/app/features/issuer-trust/issuer-trust.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/issuer-trust/issuer-trust.component.ts @@ -1,18 +1,27 @@ // Issuer Trust Component // Sprint 024: Issuer Trust UI -import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core'; - -import { Router, RouterModule, NavigationEnd } from '@angular/router'; +import { Component, ChangeDetectionStrategy, signal, inject, OnInit, DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router, RouterOutlet, NavigationEnd } from '@angular/router'; import { filter, catchError } from 'rxjs/operators'; import { of } from 'rxjs'; -import { TRUST_API } from '../../core/api/trust.client'; -type TabType = 'list' | 'detail'; +import { TRUST_API } from '../../core/api/trust.client'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +type TabType = 'list' | 'new'; + +const KNOWN_TAB_IDS: readonly string[] = ['list', 'new']; + +const PAGE_TABS: readonly StellaPageTab[] = [ + { id: 'list', label: 'Issuers', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' }, + { id: 'new', label: 'New Issuer', icon: 'M12 5v14|||M5 12h14' }, +]; @Component({ selector: 'app-issuer-trust', - imports: [RouterModule], + imports: [RouterOutlet, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -37,9 +46,14 @@ type TabType = 'list' | 'detail';
-
+ -
+ `, styles: [` @@ -116,20 +130,26 @@ type TabType = 'list' | 'detail'; text-transform: uppercase; letter-spacing: 0.05em; } - - .issuer-trust__content { - min-height: 400px; - } `] }) export class IssuerTrustComponent implements OnInit { private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); private readonly trustApi = inject(TRUST_API); + readonly pageTabs = PAGE_TABS; + readonly activeTab = signal('list'); readonly totalIssuers = signal(0); readonly expiringKeys = signal(0); ngOnInit(): void { + this.setActiveTabFromUrl(this.router.url); + this.router.events.pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef), + ).subscribe(e => this.setActiveTabFromUrl(e.urlAfterRedirects)); + this.trustApi.getDashboardSummary().pipe( catchError(() => of(null)) ).subscribe(summary => { @@ -139,4 +159,19 @@ export class IssuerTrustComponent implements OnInit { } }); } + + onTabChange(tabId: string): void { + this.activeTab.set(tabId as TabType); + this.router.navigate([tabId], { relativeTo: this.route, queryParamsHandling: 'merge' }); + } + + private setActiveTabFromUrl(url: string): void { + const segments = url.split('?')[0].split('/').filter(Boolean); + const lastSegment = segments.at(-1) ?? ''; + if (KNOWN_TAB_IDS.includes(lastSegment as TabType)) { + this.activeTab.set(lastSegment as TabType); + } else { + this.activeTab.set('list'); + } + } } diff --git a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-job-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-job-detail.component.ts index 017635760..bd53d2a0b 100644 --- a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-job-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-job-detail.component.ts @@ -10,6 +10,7 @@ import { } from '../../core/api/jobengine-jobs.client'; import { deadLetterQueuePath, jobEngineJobPath } from '../platform/ops/operations-paths'; +import { DateFormatService } from '../../core/i18n/date-format.service'; @Component({ selector: 'app-jobengine-job-detail', imports: [RouterLink], @@ -330,6 +331,8 @@ import { deadLetterQueuePath, jobEngineJobPath } from '../platform/ops/operation `], }) export class JobEngineJobDetailComponent implements OnInit, OnDestroy { + private readonly dateFmt = inject(DateFormatService); + protected readonly jobEngineJobPath = jobEngineJobPath; protected readonly deadLetterQueuePath = deadLetterQueuePath; private readonly route = inject(ActivatedRoute); @@ -403,7 +406,7 @@ export class JobEngineJobDetailComponent implements OnInit, OnDestroy { return '-'; } - return new Date(value).toLocaleString('en-US', { + return new Date(value).toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-jobs.component.ts b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-jobs.component.ts index 4a7d890d2..ab2f80875 100644 --- a/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-jobs.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/jobengine/jobengine-jobs.component.ts @@ -7,6 +7,7 @@ import { JobEngineJobsClient, type JobEngineJobRecord } from '../../core/api/job import { ORCHESTRATOR_CONTROL_API, type OrchestratorControlApi } from '../../core/api/jobengine-control.client'; import { OPERATIONS_PATHS, deadLetterQueuePath, jobEngineDagPath, jobEngineJobPath } from '../platform/ops/operations-paths'; +import { DateFormatService } from '../../core/i18n/date-format.service'; @Component({ selector: 'app-jobengine-jobs', imports: [FormsModule, RouterLink], @@ -425,6 +426,8 @@ import { OPERATIONS_PATHS, deadLetterQueuePath, jobEngineDagPath, jobEngineJobPa `], }) export class JobEngineJobsComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + protected readonly OPERATIONS_PATHS = OPERATIONS_PATHS; protected readonly jobEngineJobPath = jobEngineJobPath; protected readonly jobEngineDagPath = jobEngineDagPath; @@ -540,7 +543,7 @@ export class JobEngineJobsComponent implements OnInit { return '-'; } - return new Date(value).toLocaleString('en-US', { + return new Date(value).toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/attestation-links/attestation-links.component.ts b/src/Web/StellaOps.Web/src/app/features/lineage/components/attestation-links/attestation-links.component.ts index 6d5514766..09e344a3d 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/attestation-links/attestation-links.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/attestation-links/attestation-links.component.ts @@ -4,7 +4,8 @@ * @description Attestation links component with Rekor verification links. */ -import { Component, Input } from '@angular/core'; +import { Component, Input, + inject,} from '@angular/core'; import { AttestationLink } from '../../models/lineage.models'; import { @@ -13,6 +14,7 @@ import { } from '../../icons/lineage-icons'; import { DigestChipComponent } from '../../../../shared/domain/digest-chip/digest-chip.component'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; /** * Attestation links component showing signed attestations with Rekor links. */ @@ -195,6 +197,8 @@ import { DigestChipComponent } from '../../../../shared/domain/digest-chip/diges `] }) export class AttestationLinksComponent { + private readonly dateFmt = inject(DateFormatService); + readonly externalLinkIcon = ICON_EXTERNAL_LINK; readonly checkIcon = ICON_CHECK; @@ -209,7 +213,7 @@ export class AttestationLinksComponent { formatDate(dateStr: string): string { try { const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/audit-pack-export/audit-pack-export.component.scss b/src/Web/StellaOps.Web/src/app/features/lineage/components/audit-pack-export/audit-pack-export.component.scss index 43168975c..bc767cb71 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/audit-pack-export/audit-pack-export.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/audit-pack-export/audit-pack-export.component.scss @@ -329,7 +329,7 @@ .signature-link, .rekor-link { font-size: var(--font-size-sm); - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-weight: var(--font-weight-medium); @@ -402,25 +402,22 @@ } .btn-primary { - background: var(--color-brand-primary); - color: var(--color-text-inverse); + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); + border: 1px solid var(--color-btn-primary-border, transparent); &:hover:not(:disabled) { - filter: brightness(1.1); - } - - &:active:not(:disabled) { - filter: brightness(0.9); + background: var(--color-btn-primary-bg-hover); } } .btn-secondary { - background: var(--color-surface-tertiary); - color: var(--color-text-primary); - border: 1px solid var(--color-border-primary); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); + border: 1px solid var(--color-btn-secondary-border); &:hover:not(:disabled) { - background: var(--color-surface-tertiary); + background: var(--color-btn-secondary-hover-bg); } } diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/cgs-badge/cgs-badge.component.ts b/src/Web/StellaOps.Web/src/app/features/lineage/components/cgs-badge/cgs-badge.component.ts index 25d473ee1..25e5339a4 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/cgs-badge/cgs-badge.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/cgs-badge/cgs-badge.component.ts @@ -129,7 +129,7 @@ import { padding: 4px 10px; font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; border: none; border-radius: var(--radius-sm); @@ -140,7 +140,7 @@ import { gap: 4px; &:hover:not(:disabled) { - background: var(--color-brand-primary-hover); + background: var(--color-btn-primary-bg-hover); } &:disabled { diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/compare-panel/compare-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/lineage/components/compare-panel/compare-panel.component.ts index 48874bee5..8ff09b382 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/compare-panel/compare-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/compare-panel/compare-panel.component.ts @@ -25,7 +25,9 @@ import { VexDiffViewComponent } from '../vex-diff-view/vex-diff-view.component'; import { ReachabilityDiffViewComponent } from '../reachability-diff-view/reachability-diff-view.component'; import { AttestationLinksComponent } from '../attestation-links/attestation-links.component'; import { ReplayHashDisplayComponent } from '../replay-hash-display/replay-hash-display.component'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; /** * Compare panel component showing side-by-side comparison of two lineage nodes. * @@ -44,8 +46,7 @@ import { ReplayHashDisplayComponent } from '../replay-hash-display/replay-hash-d VexDiffViewComponent, ReachabilityDiffViewComponent, AttestationLinksComponent, - ReplayHashDisplayComponent -], + ReplayHashDisplayComponent, LoadingStateComponent], template: `
@@ -82,8 +83,7 @@ import { ReplayHashDisplayComponent } from '../replay-hash-display/replay-hash-d @if (loading) {
-
- Loading comparison... +
} @@ -433,6 +433,8 @@ import { ReplayHashDisplayComponent } from '../replay-hash-display/replay-hash-d `] }) export class ComparePanelComponent implements OnChanges { + private readonly dateFmt = inject(DateFormatService); + readonly closeIcon = ICON_CLOSE; readonly arrowRightIcon = ICON_ARROW_RIGHT; readonly alertTriangleIcon = ICON_ALERT_TRIANGLE; @@ -503,7 +505,7 @@ export class ComparePanelComponent implements OnChanges { if (!dateStr) return ''; try { const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/diff-table/diff-table.component.html b/src/Web/StellaOps.Web/src/app/features/lineage/components/diff-table/diff-table.component.html index 9cee2a9ad..f39f6700a 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/diff-table/diff-table.component.html +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/diff-table/diff-table.component.html @@ -72,10 +72,7 @@ @if (loading) { -
-
-

Loading components...

-
+ } @else if (displayRows().length === 0) {
diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/diff-table/diff-table.component.scss b/src/Web/StellaOps.Web/src/app/features/lineage/components/diff-table/diff-table.component.scss index 7ccf2c7d4..18ece9cea 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/diff-table/diff-table.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/diff-table/diff-table.component.scss @@ -224,7 +224,7 @@ tbody td { font-size: var(--font-size-xs); &:hover { - color: var(--color-brand-primary); + color: var(--color-text-link); } } diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/diff-table/diff-table.component.ts b/src/Web/StellaOps.Web/src/app/features/lineage/components/diff-table/diff-table.component.ts index 1c9c0b134..b81dc4853 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/diff-table/diff-table.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/diff-table/diff-table.component.ts @@ -14,11 +14,12 @@ import { DiffTableRow, DiffTableColumn, DiffTableFilter, DiffTableSort, ExpandedRowData, VulnImpact } from './models/diff-table.models'; import { PinnedExplanationService } from '../../../../core/services/pinned-explanation.service'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; @Component({ selector: 'app-diff-table', standalone: true, - imports: [FormsModule], + imports: [FormsModule, LoadingStateComponent], templateUrl: './diff-table.component.html', styleUrl: './diff-table.component.scss', changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/explainer-timeline/explainer-timeline.component.html b/src/Web/StellaOps.Web/src/app/features/lineage/components/explainer-timeline/explainer-timeline.component.html index 8dd50286e..fa02c7de2 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/explainer-timeline/explainer-timeline.component.html +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/explainer-timeline/explainer-timeline.component.html @@ -18,10 +18,7 @@
@if (loading) { -
-
-

Loading explanation...

-
+ } @if (error) { diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/explainer-timeline/explainer-timeline.component.scss b/src/Web/StellaOps.Web/src/app/features/lineage/components/explainer-timeline/explainer-timeline.component.scss index c494d46a1..82e7bac1e 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/explainer-timeline/explainer-timeline.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/explainer-timeline/explainer-timeline.component.scss @@ -121,11 +121,11 @@ border: 1px solid var(--color-border-primary); &.btn-secondary { - background: var(--color-surface-primary); - color: var(--color-text-primary); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); &:hover { - background: var(--color-surface-secondary); + background: var(--color-btn-secondary-hover-bg); } } diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/explainer-timeline/explainer-timeline.component.ts b/src/Web/StellaOps.Web/src/app/features/lineage/components/explainer-timeline/explainer-timeline.component.ts index a26fd3103..965a3f897 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/explainer-timeline/explainer-timeline.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/explainer-timeline/explainer-timeline.component.ts @@ -8,11 +8,12 @@ import { Component, Input, Output, EventEmitter, signal, computed, ChangeDetecti import { ExplainerStepComponent } from './explainer-step/explainer-step.component'; import { ExplainerResponse, ExplainerStep } from './models/explainer.models'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; @Component({ selector: 'app-explainer-timeline', standalone: true, - imports: [ExplainerStepComponent], + imports: [ExplainerStepComponent, LoadingStateComponent], templateUrl: './explainer-timeline.component.html', styleUrl: './explainer-timeline.component.scss', changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-compare-panel/lineage-compare-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-compare-panel/lineage-compare-panel.component.ts index 6221f09e7..3480456e1 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-compare-panel/lineage-compare-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-compare-panel/lineage-compare-panel.component.ts @@ -28,6 +28,7 @@ import { ICON_CLOSE, ICON_ARROW_RIGHT, } from '../../icons/lineage-icons'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; /** * Compare panel showing side-by-side comparison of two lineage nodes. @@ -46,8 +47,7 @@ import { imports: [ CommonModule, LineageComponentDiffComponent, - LineageVexDeltaComponent, - ], + LineageVexDeltaComponent, LoadingStateComponent], template: `
@@ -64,8 +64,7 @@ import { @if (loading) {
-
- Loading comparison... +
} diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-compare/lineage-compare.component.ts b/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-compare/lineage-compare.component.ts index dba92b1f9..39de2236e 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-compare/lineage-compare.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-compare/lineage-compare.component.ts @@ -27,6 +27,7 @@ import { ICON_KEYBOARD, ICON_ALERT_TRIANGLE, } from '../../icons/lineage-icons'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; /** * Lineage compare page component. @@ -41,8 +42,7 @@ import { ExportDialogComponent, WhySafePanelComponent, KeyboardShortcutsHelpComponent, - LineageKeyboardShortcutsDirective -], + LineageKeyboardShortcutsDirective, LoadingStateComponent], template: `
@if (loading) {
-
- Loading artifacts... +
} diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-graph-container/lineage-graph-container.component.ts b/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-graph-container/lineage-graph-container.component.ts index 928989aa4..fc4b5666d 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-graph-container/lineage-graph-container.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-graph-container/lineage-graph-container.component.ts @@ -17,6 +17,7 @@ import { LineageMinimapComponent } from '../lineage-minimap/lineage-minimap.comp import { LineageTimelineSliderComponent } from '../lineage-timeline-slider/lineage-timeline-slider.component'; import { LineageNode, LineageViewOptions } from '../../models/lineage.models'; import { ICON_ALERT_TRIANGLE, ICON_ARROW_RIGHT } from '../../icons/lineage-icons'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; /** * Container component that orchestrates the lineage graph visualization. @@ -34,8 +35,7 @@ import { ICON_ALERT_TRIANGLE, ICON_ARROW_RIGHT } from '../../icons/lineage-icons LineageHoverCardComponent, LineageControlsComponent, LineageMinimapComponent, - LineageTimelineSliderComponent -], + LineageTimelineSliderComponent, LoadingStateComponent], template: `
@@ -62,8 +62,7 @@ import { ICON_ALERT_TRIANGLE, ICON_ARROW_RIGHT } from '../../icons/lineage-icons
@if (lineageService.loading()) {
-
- Loading lineage graph... +
} diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-mobile-compare/lineage-mobile-compare.component.ts b/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-mobile-compare/lineage-mobile-compare.component.ts index 132cd74d6..ec43bba78 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-mobile-compare/lineage-mobile-compare.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-mobile-compare/lineage-mobile-compare.component.ts @@ -11,9 +11,11 @@ import { EventEmitter, signal, computed, + inject, } from '@angular/core'; import { CommonModule } from '@angular/common'; import { LineageNode, LineageDiffResponse } from '../../models/lineage.models'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; import { ICON_BAR_CHART, ICON_SCALE, @@ -26,10 +28,18 @@ import { ICON_UPLOAD, ICON_FILE_TEXT, } from '../../icons/lineage-icons'; +import { StellaPageTabsComponent, StellaPageTab } from '../../../../shared/components/stella-page-tabs/stella-page-tabs.component'; /** * Mobile view mode. */ +const MOBILE_COMPARE_TABS: StellaPageTab[] = [ + { id: 'graph', label: 'Graph', icon: 'M18 20V10|||M12 20V4|||M6 20v-6' }, + { id: 'node-a', label: 'Node A', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' }, + { id: 'node-b', label: 'Node B', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' }, + { id: 'diff', label: 'Diff', icon: 'M16 3h5v5|||M8 3H3v5|||M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3|||M15 9l6-6' }, +]; + export type MobileViewMode = | 'graph' | 'node-a' @@ -59,48 +69,16 @@ export interface MobileSection { @Component({ selector: 'app-lineage-mobile-compare', standalone: true, - imports: [CommonModule], + imports: [CommonModule, StellaPageTabsComponent], template: `
-
- - - - -
+
@@ -376,89 +354,7 @@ export interface MobileSection { background: var(--color-surface-primary); } - .mobile-tabs { - display: flex; - background: var(--color-surface-primary); - border-bottom: 1px solid var(--color-surface-secondary); - overflow-x: auto; - -webkit-overflow-scrolling: touch; - } - .tab-btn { - flex: 1; - min-width: 70px; - padding: 12px 8px; - border: none; - background: transparent; - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - cursor: pointer; - color: var(--color-text-secondary); - position: relative; - } - - .tab-btn.active { - color: var(--color-status-info); - } - - .tab-btn.active::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 2px; - background: var(--color-status-info); - } - - .tab-btn:disabled { - opacity: 0.4; - } - - .tab-icon { - font-size: var(--font-size-lg); - } - - .tab-badge { - width: 20px; - height: 20px; - border-radius: var(--radius-full); - display: flex; - align-items: center; - justify-content: center; - font-size: var(--font-size-xs); - font-weight: bold; - color: white; - } - - .tab-badge.a { background: var(--color-status-info); } - .tab-badge.b { background: var(--color-status-excepted); } - - .tab-label { - font-size: var(--font-size-xs); - max-width: 60px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .tab-count { - position: absolute; - top: 6px; - right: 6px; - min-width: 16px; - height: 16px; - padding: 0 4px; - border-radius: var(--radius-lg); - background: var(--color-status-error); - color: white; - font-size: var(--font-size-xs); - display: flex; - align-items: center; - justify-content: center; - } .mobile-content { flex: 1; @@ -895,6 +791,8 @@ export interface MobileSection { `] }) export class LineageMobileCompareComponent { + private readonly dateFmt = inject(DateFormatService); + @Input() nodeA: LineageNode | null = null; @Input() nodeB: LineageNode | null = null; @Input() diffData: LineageDiffResponse | null = null; @@ -917,6 +815,7 @@ export class LineageMobileCompareComponent { readonly uploadIcon = ICON_UPLOAD; readonly fileTextIcon = ICON_FILE_TEXT; + readonly MOBILE_COMPARE_TABS = MOBILE_COMPARE_TABS; readonly currentView = signal('graph'); readonly currentSection = signal('components'); readonly exportSheetVisible = signal(false); @@ -969,7 +868,7 @@ export class LineageMobileCompareComponent { } formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString('en-US', { + return new Date(dateStr).toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-timeline-slider/lineage-timeline-slider.component.ts b/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-timeline-slider/lineage-timeline-slider.component.ts index 0e9ee1e63..825f6fe49 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-timeline-slider/lineage-timeline-slider.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-timeline-slider/lineage-timeline-slider.component.ts @@ -13,9 +13,11 @@ import { computed, OnChanges, SimpleChanges, + inject, } from '@angular/core'; import { LineageNode } from '../../models/lineage.models'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; import { ICON_SKIP_BACK, ICON_SKIP_FORWARD, @@ -457,6 +459,8 @@ export interface TimelineMarker { `] }) export class LineageTimelineSliderComponent implements OnChanges { + private readonly dateFmt = inject(DateFormatService); + @Input() nodes: LineageNode[] = []; @Input() markers: TimelineMarker[] = []; @Output() rangeChange = new EventEmitter(); @@ -599,7 +603,7 @@ export class LineageTimelineSliderComponent implements OnChanges { } formatDate(date: Date): string { - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-vex-diff/lineage-vex-diff.component.ts b/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-vex-diff/lineage-vex-diff.component.ts index 8ee3638ef..29cfe828c 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-vex-diff/lineage-vex-diff.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/lineage-vex-diff/lineage-vex-diff.component.ts @@ -17,6 +17,7 @@ import { VexDelta, VexStatus } from '../../models/lineage.models'; import { ICON_CLOSE, ICON_ARROW_RIGHT_SM, ICON_ARROW_UP, ICON_ARROW_DOWN, ICON_CHECK, ICON_DOT, } from '../../icons/lineage-icons'; +import { StellaPageTabsComponent, StellaPageTab } from '../../../../shared/components/stella-page-tabs/stella-page-tabs.component'; /** * Status order for grouping. @@ -40,10 +41,17 @@ const STATUS_ORDER: Record = { * - Filter by CVE ID * - Evidence document links */ +const VEX_DIFF_TABS: StellaPageTab[] = [ + { id: 'all', label: 'All', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' }, + { id: 'improved', label: 'Improved', icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M22 4L12 14.01l-3-3' }, + { id: 'regressed', label: 'Regressed', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01' }, + { id: 'unchanged', label: 'Unchanged', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, +]; + @Component({ selector: 'app-lineage-vex-diff', standalone: true, - imports: [], + imports: [, StellaPageTabsComponent], template: `
@@ -81,36 +89,13 @@ const STATUS_ORDER: Record = {
- + +
@@ -504,6 +489,7 @@ export class LineageVexDiffComponent { @Input() deltas: VexDelta[] = []; @Output() evidenceClick = new EventEmitter(); + readonly VEX_DIFF_TABS = VEX_DIFF_TABS; readonly filterText = signal(''); readonly activeGroup = signal<'all' | 'improved' | 'regressed' | 'unchanged'>('all'); readonly expandedItems = signal>(new Set()); diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/node-diff-table/diff-table.component.html b/src/Web/StellaOps.Web/src/app/features/lineage/components/node-diff-table/diff-table.component.html index 926c74eb7..937073ca4 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/node-diff-table/diff-table.component.html +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/node-diff-table/diff-table.component.html @@ -1,10 +1,7 @@
@if (loading()) { -
-
-

Loading component differences...

-
+ } diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/node-diff-table/diff-table.component.scss b/src/Web/StellaOps.Web/src/app/features/lineage/components/node-diff-table/diff-table.component.scss index b5027ac0e..e77bb6615 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/node-diff-table/diff-table.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/node-diff-table/diff-table.component.scss @@ -177,7 +177,7 @@ padding: var(--space-1-5) var(--space-3); font-size: var(--font-size-sm); background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); border: none; cursor: pointer; font-weight: var(--font-weight-medium); @@ -228,7 +228,7 @@ } &.sorted { - color: var(--color-brand-primary); + color: var(--color-text-link); } .sort-indicator { diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/node-diff-table/diff-table.component.ts b/src/Web/StellaOps.Web/src/app/features/lineage/components/node-diff-table/diff-table.component.ts index 1833e6bfd..887258848 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/node-diff-table/diff-table.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/node-diff-table/diff-table.component.ts @@ -20,11 +20,12 @@ import { LineageGraphService } from '../../services/lineage-graph.service'; import { ComponentChange } from '../../models/lineage.models'; import { PaginationComponent, PageChangeEvent } from '../../../../shared/components/pagination/pagination.component'; import { debounceTime, Subject } from 'rxjs'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; @Component({ selector: 'app-node-diff-table', standalone: true, - imports: [FormsModule, PaginationComponent], + imports: [FormsModule, PaginationComponent, LoadingStateComponent], templateUrl: './diff-table.component.html', styleUrls: ['./diff-table.component.scss'] }) diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/reachability-diff/call-path-mini/call-path-mini.component.scss b/src/Web/StellaOps.Web/src/app/features/lineage/components/reachability-diff/call-path-mini/call-path-mini.component.scss index 4dc0a3f16..f7e0be1e0 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/reachability-diff/call-path-mini/call-path-mini.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/reachability-diff/call-path-mini/call-path-mini.component.scss @@ -44,7 +44,7 @@ border: 1px solid var(--color-border-primary); border-radius: var(--radius-xs); cursor: pointer; - color: var(--color-brand-primary); + color: var(--color-text-link); transition: all var(--motion-duration-fast) var(--motion-ease-default); &:hover { diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/reachability-diff/reachability-diff-view.component.html b/src/Web/StellaOps.Web/src/app/features/lineage/components/reachability-diff/reachability-diff-view.component.html index 68396e103..b689a0e59 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/reachability-diff/reachability-diff-view.component.html +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/reachability-diff/reachability-diff-view.component.html @@ -9,10 +9,7 @@
@if (loading) { -
-
-

Analyzing reachability...

-
+ } @if (error) { diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/reachability-diff/reachability-diff-view.component.ts b/src/Web/StellaOps.Web/src/app/features/lineage/components/reachability-diff/reachability-diff-view.component.ts index 9aa62963d..b2e07e134 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/reachability-diff/reachability-diff-view.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/reachability-diff/reachability-diff-view.component.ts @@ -12,6 +12,7 @@ import { CallPathMiniComponent } from './call-path-mini/call-path-mini.component import { PathComparisonComponent } from './path-comparison/path-comparison.component'; import { ReachabilityDeltaDisplay } from './models/reachability-diff.models'; import { PinnedExplanationService } from '../../../../core/services/pinned-explanation.service'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; @Component({ selector: 'app-reachability-diff-view', @@ -20,7 +21,8 @@ import { PinnedExplanationService } from '../../../../core/services/pinned-expla GateChipComponent, ConfidenceBarComponent, CallPathMiniComponent, - PathComparisonComponent + PathComparisonComponent, + LoadingStateComponent ], templateUrl: './reachability-diff-view.component.html', styleUrl: './reachability-diff-view.component.scss', diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/timeline-slider/timeline-slider.component.ts b/src/Web/StellaOps.Web/src/app/features/lineage/components/timeline-slider/timeline-slider.component.ts index 6ec45d66d..81f06b61c 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/timeline-slider/timeline-slider.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/timeline-slider/timeline-slider.component.ts @@ -4,10 +4,12 @@ * @description Timeline slider for filtering lineage nodes by date range. */ -import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges } from '@angular/core'; +import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, + inject,} from '@angular/core'; import { ICON_PLAY, ICON_PAUSE, ICON_ROTATE_CCW } from '../../icons/lineage-icons'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; export interface TimelineRange { start: Date; end: Date; @@ -274,6 +276,8 @@ export interface TimelineMark { `] }) export class TimelineSliderComponent implements OnInit, OnChanges { + private readonly dateFmt = inject(DateFormatService); + readonly playIcon = ICON_PLAY; readonly pauseIcon = ICON_PAUSE; readonly resetIcon = ICON_ROTATE_CCW; @@ -381,7 +385,7 @@ export class TimelineSliderComponent implements OnInit, OnChanges { } formatDate(date: Date): string { - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric' diff --git a/src/Web/StellaOps.Web/src/app/features/lineage/components/why-safe-panel/why-safe-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/lineage/components/why-safe-panel/why-safe-panel.component.ts index 2c12d38fe..244128825 100644 --- a/src/Web/StellaOps.Web/src/app/features/lineage/components/why-safe-panel/why-safe-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/lineage/components/why-safe-panel/why-safe-panel.component.ts @@ -17,6 +17,7 @@ import { ICON_CONSTRUCTION, ICON_FILE_TEXT, ICON_FLAG, ICON_SETTINGS, ICON_GLOBE, ICON_WRENCH, ICON_PACKAGE, } from '../../icons/lineage-icons'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; export interface WhySafeExplanation { cve: string; @@ -60,7 +61,7 @@ export interface GateInfo { */ @Component({ selector: 'app-why-safe-panel', - imports: [], + imports: [LoadingStateComponent], template: `
@@ -75,8 +76,7 @@ export interface GateInfo { @if (loading) {
-
- Loading explanation... +
} diff --git a/src/Web/StellaOps.Web/src/app/features/mission-control/mission-activity-page.component.ts b/src/Web/StellaOps.Web/src/app/features/mission-control/mission-activity-page.component.ts index 0c2d3931d..bc74db5be 100644 --- a/src/Web/StellaOps.Web/src/app/features/mission-control/mission-activity-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/mission-control/mission-activity-page.component.ts @@ -40,7 +40,7 @@ import { RouterLink } from '@angular/router'; .cards { display: grid; gap: 0.75rem; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } article { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.8rem; display: grid; gap: 0.5rem; } h2 { margin: 0; font-size: 0.9rem; } - a { color: var(--color-brand-primary); text-decoration: none; } + a { color: var(--color-text-link); text-decoration: none; } a:hover { text-decoration: underline; } `, ], diff --git a/src/Web/StellaOps.Web/src/app/features/mission-control/mission-alerts-page.component.ts b/src/Web/StellaOps.Web/src/app/features/mission-control/mission-alerts-page.component.ts index aabe389d8..d34f009cf 100644 --- a/src/Web/StellaOps.Web/src/app/features/mission-control/mission-alerts-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/mission-control/mission-alerts-page.component.ts @@ -35,7 +35,7 @@ import { RouterLink } from '@angular/router'; h1 { margin: 0; font-size: 1.35rem; } p { margin: 0; color: var(--color-text-secondary); } ul { margin: 0; padding-left: 1.2rem; display: grid; gap: 0.45rem; } - a { color: var(--color-brand-primary); text-decoration: none; } + a { color: var(--color-text-link); text-decoration: none; } a:hover { text-decoration: underline; } `, ], diff --git a/src/Web/StellaOps.Web/src/app/features/offline-kit/components/bundle-management.component.ts b/src/Web/StellaOps.Web/src/app/features/offline-kit/components/bundle-management.component.ts index 773a9546d..fbfb74c3b 100644 --- a/src/Web/StellaOps.Web/src/app/features/offline-kit/components/bundle-management.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/offline-kit/components/bundle-management.component.ts @@ -7,6 +7,7 @@ import { ManifestValidatorComponent } from '../../../shared/components/manifest- import { OfflineModeService } from '../../../core/services/offline-mode.service'; import { OfflineManifest, BundleValidationResult } from '../../../core/api/offline-kit.models'; +import { DateFormatService } from '../../../core/i18n/date-format.service'; interface LoadedBundle { id: string; version: string; @@ -364,6 +365,8 @@ interface LoadedBundle { `] }) export class BundleManagementComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + private readonly offlineService = inject(OfflineModeService); readonly loadedBundles = signal([]); @@ -405,7 +408,7 @@ export class BundleManagementComponent implements OnInit { } formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString('en-US', { + return new Date(dateStr).toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric' }); } diff --git a/src/Web/StellaOps.Web/src/app/features/offline-kit/components/jwks-management.component.ts b/src/Web/StellaOps.Web/src/app/features/offline-kit/components/jwks-management.component.ts index a9cae166a..3d6c80cb6 100644 --- a/src/Web/StellaOps.Web/src/app/features/offline-kit/components/jwks-management.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/offline-kit/components/jwks-management.component.ts @@ -1,11 +1,13 @@ // JWKS Management Component // Sprint 026: Offline Kit Integration -import { ChangeDetectionStrategy, Component, computed, OnInit, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, OnInit, signal, + inject,} from '@angular/core'; import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { InlineCodeComponent } from '../../../shared/ui/inline-code/inline-code.component'; +import { DateFormatService } from '../../../core/i18n/date-format.service'; interface JwkEntry { kid: string; kty: string; @@ -646,6 +648,8 @@ interface TrustAnchor { `] }) export class JwksManagementComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + readonly jwkEntries = signal([]); readonly trustAnchors = signal([]); readonly jwksLastUpdated = signal('2025-01-15 10:30 UTC'); @@ -733,7 +737,7 @@ export class JwksManagementComponent implements OnInit { } formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString('en-US', { + return new Date(dateStr).toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric' }); } diff --git a/src/Web/StellaOps.Web/src/app/features/offline-kit/components/verification-center.component.ts b/src/Web/StellaOps.Web/src/app/features/offline-kit/components/verification-center.component.ts index 4ea40bfd6..f8395acaf 100644 --- a/src/Web/StellaOps.Web/src/app/features/offline-kit/components/verification-center.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/offline-kit/components/verification-center.component.ts @@ -1,11 +1,13 @@ // Verification Center Component // Sprint 026: Offline Kit Integration -import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed, signal, + inject,} from '@angular/core'; import { OfflineVerificationComponent } from '../../../shared/components/offline-verification.component'; import { OfflineVerificationResult } from '../../../core/api/offline-kit.models'; +import { DateFormatService } from '../../../core/i18n/date-format.service'; interface VerificationHistory { id: string; bundleName: string; @@ -84,21 +86,18 @@ interface VerificationHistory {

Quick Actions

-
- - -
@@ -213,45 +212,7 @@ interface VerificationHistory { color: var(--color-text-secondary); } - .quick-actions { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 1rem; - } - - .action-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.5rem; - padding: 1.5rem 1rem; - background: rgba(15, 23, 42, 0.5); - border: 1px solid var(--color-text-primary); - border-radius: var(--radius-lg); - cursor: pointer; - transition: border-color 0.2s, background 0.2s; - text-align: center; - } - - .action-card:hover { - border-color: var(--color-text-primary); - background: rgba(30, 41, 59, 0.5); - } - - .action-icon { - font-size: 1.5rem; - } - - .action-label { - font-size: 0.875rem; - font-weight: var(--font-weight-medium); - color: var(--color-border-primary); - } - - .action-desc { - font-size: 0.75rem; - color: var(--color-text-secondary); - } + /* Quick Actions — uses global .quick-links-row / .quick-link-pill */ .empty-state { text-align: center; @@ -290,6 +251,8 @@ interface VerificationHistory { `] }) export class VerificationCenterComponent { + private readonly dateFmt = inject(DateFormatService); + readonly history = signal([ { id: '1', @@ -329,7 +292,7 @@ export class VerificationCenterComponent { } formatTime(timestamp: string): string { - return new Date(timestamp).toLocaleString('en-US', { + return new Date(timestamp).toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } diff --git a/src/Web/StellaOps.Web/src/app/features/offline-kit/offline-kit.component.ts b/src/Web/StellaOps.Web/src/app/features/offline-kit/offline-kit.component.ts index b40c4f99b..19f97d551 100644 --- a/src/Web/StellaOps.Web/src/app/features/offline-kit/offline-kit.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/offline-kit/offline-kit.component.ts @@ -2,15 +2,29 @@ // Sprint 026: Offline Kit Integration // Sprint 027: Adopted canonical ContextHeaderComponent -import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; +import { Component, ChangeDetectionStrategy, inject, OnInit, signal, DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, NavigationEnd, Router, RouterModule, RouterOutlet } from '@angular/router'; +import { filter } from 'rxjs'; -import { RouterModule } from '@angular/router'; import { OfflineModeService } from '../../core/services/offline-mode.service'; import { ContextHeaderComponent } from '../../shared/ui/context-header/context-header.component'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +type TabType = 'dashboard' | 'bundles' | 'verify' | 'jwks'; + +const KNOWN_TAB_IDS: readonly string[] = ['dashboard', 'bundles', 'verify', 'jwks']; + +const PAGE_TABS: readonly StellaPageTab[] = [ + { id: 'dashboard', label: 'Dashboard', icon: 'M3 3h7v7H3z|||M14 3h7v7h-7z|||M14 14h7v7h-7z|||M3 14h7v7H3z' }, + { id: 'bundles', label: 'Bundles', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' }, + { id: 'verify', label: 'Verification', icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M22 4L12 14.01l-3-3' }, + { id: 'jwks', label: 'JWKS', icon: 'M3 11h18v11H3z|||M7 11V7a5 5 0 0 1 10 0v4' }, +]; @Component({ selector: 'app-offline-kit', - imports: [RouterModule, ContextHeaderComponent], + imports: [RouterModule, RouterOutlet, ContextHeaderComponent, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -29,50 +43,23 @@ import { ContextHeaderComponent } from '../../shared/ui/context-header/context-h
- `, styles: [` @@ -81,27 +68,7 @@ import { ContextHeaderComponent } from '../../shared/ui/context-header/context-h background: var(--color-surface-primary); } - .page-shortcuts { - padding: 0 2rem; - display: flex; - gap: 0.45rem; - flex-wrap: wrap; - margin-top: 0.85rem; - } - - .page-shortcuts a { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-full); - color: var(--color-text-muted); - font-size: 0.72rem; - padding: 0.18rem 0.55rem; - text-decoration: none; - } - - .page-shortcuts a:hover { - color: var(--color-text-link); - border-color: var(--color-text-link); - } + /* Page shortcuts — uses global .quick-links-row / .quick-link-pill */ .connection-status { display: flex; @@ -139,49 +106,38 @@ import { ContextHeaderComponent } from '../../shared/ui/context-header/context-h .connection-status.offline .status-text { color: var(--color-status-error-border); } - - .tab-nav { - display: flex; - gap: 0.25rem; - padding: 0 2rem; - background: var(--color-surface-primary); - border-bottom: 1px solid var(--color-border-primary); - } - - .tab-link { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 1rem 1.25rem; - color: var(--color-text-secondary); - text-decoration: none; - font-size: 0.875rem; - font-weight: var(--font-weight-medium); - border-bottom: 2px solid transparent; - transition: color 0.2s, border-color 0.2s; - } - - .tab-link:hover { - color: var(--color-text-muted); - } - - .tab-link.active { - color: var(--color-status-info); - border-bottom-color: var(--color-status-info); - } - - .tab-link svg { - width: 18px; - height: 18px; - } - - .content { - padding: 2rem; - } `] }) -export class OfflineKitComponent { +export class OfflineKitComponent implements OnInit { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); private readonly offlineService = inject(OfflineModeService); + readonly pageTabs = PAGE_TABS; + readonly activeTab = signal('dashboard'); readonly isOffline = this.offlineService.isOffline; + + ngOnInit(): void { + this.setActiveTabFromUrl(this.router.url); + this.router.events.pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef), + ).subscribe(e => this.setActiveTabFromUrl(e.urlAfterRedirects)); + } + + onTabChange(tabId: string): void { + this.activeTab.set(tabId as TabType); + this.router.navigate([tabId], { relativeTo: this.route, queryParamsHandling: 'merge' }); + } + + private setActiveTabFromUrl(url: string): void { + const segments = url.split('?')[0].split('/').filter(Boolean); + const lastSegment = segments.at(-1) ?? ''; + if (KNOWN_TAB_IDS.includes(lastSegment as TabType)) { + this.activeTab.set(lastSegment as TabType); + } else { + this.activeTab.set('dashboard'); + } + } } diff --git a/src/Web/StellaOps.Web/src/app/features/opsmemory/components/evidence-card/evidence-card.component.ts b/src/Web/StellaOps.Web/src/app/features/opsmemory/components/evidence-card/evidence-card.component.ts index 18b29d4f3..2afd38897 100644 --- a/src/Web/StellaOps.Web/src/app/features/opsmemory/components/evidence-card/evidence-card.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/opsmemory/components/evidence-card/evidence-card.component.ts @@ -131,7 +131,7 @@ import { .evidence-card__similarity { font-size: var(--font-size-sm); padding: 2px 8px; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; border-radius: var(--radius-xl); } @@ -201,7 +201,7 @@ import { padding: 0; background: none; border: none; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: var(--font-size-sm); cursor: pointer; text-decoration: none; diff --git a/src/Web/StellaOps.Web/src/app/features/platform-health/incident-timeline.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-health/incident-timeline.component.ts index 940aa6c77..e95a008b9 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-health/incident-timeline.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-health/incident-timeline.component.ts @@ -170,7 +170,7 @@ function buildExpandablePayload(incident: Incident): string | undefined { /* 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 { color: var(--color-text-link); 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; } @@ -181,7 +181,7 @@ function buildExpandablePayload(incident: Incident): string | undefined { 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); } + .btn-secondary:hover { background: var(--color-btn-secondary-hover-bg); } .checkbox-label { display: flex; align-items: center; gap: 0.375rem; font-size: 0.8125rem; color: var(--color-text-secondary); } /* Summary cards */ diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity-overview.component.ts index 07991033a..ffeec8492 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity-overview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity-overview.component.ts @@ -292,7 +292,7 @@ interface FailureItem { .list a, .drilldowns a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/data-quality-slos-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/data-quality-slos-page.component.ts index 6f179fdd6..0a2ebaafc 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/data-quality-slos-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/data-quality-slos-page.component.ts @@ -126,7 +126,7 @@ interface SloRow { } .links a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.84rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/dlq-replays-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/dlq-replays-page.component.ts index 5a59dbef9..26b3121a4 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/dlq-replays-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/dlq-replays-page.component.ts @@ -170,7 +170,7 @@ interface DlqItem { } a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.82rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/feeds-freshness-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/feeds-freshness-page.component.ts index 19779ae0b..7015d5d88 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/feeds-freshness-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/feeds-freshness-page.component.ts @@ -130,7 +130,7 @@ interface FeedRow { } .links a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.84rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/integration-connectivity-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/integration-connectivity-page.component.ts index e6e043e26..418fe2a2d 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/integration-connectivity-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/integration-connectivity-page.component.ts @@ -128,7 +128,7 @@ interface ConnectorRow { } a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.82rem; white-space: nowrap; diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/job-run-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/job-run-detail-page.component.ts index d4f6d1577..35619a50b 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/job-run-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/job-run-detail-page.component.ts @@ -116,7 +116,7 @@ interface AffectedItem { } a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.84rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/nightly-ops-report-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/nightly-ops-report-page.component.ts index 1f5279e2b..6afe05906 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/nightly-ops-report-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/nightly-ops-report-page.component.ts @@ -177,7 +177,7 @@ interface NightlyJobRow { } .actions a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; white-space: nowrap; } diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/reachability-ingest-health-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/reachability-ingest-health-page.component.ts index 2d37c5b48..e1ed6dbc0 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/reachability-ingest-health-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/reachability-ingest-health-page.component.ts @@ -157,7 +157,7 @@ interface IngestRow { } .links a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.84rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/scan-pipeline-health-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/scan-pipeline-health-page.component.ts index 6cf4a9653..d11e47aa9 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/scan-pipeline-health-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/data-integrity/scan-pipeline-health-page.component.ts @@ -124,7 +124,7 @@ interface Stage { } .links a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.84rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/platform-ops/federation-telemetry/consent-management.component.ts b/src/Web/StellaOps.Web/src/app/features/platform-ops/federation-telemetry/consent-management.component.ts index 2c3360080..112476ac5 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform-ops/federation-telemetry/consent-management.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform-ops/federation-telemetry/consent-management.component.ts @@ -159,8 +159,8 @@ import { } .btn--primary { - background: var(--color-brand-primary, #4f46e5); - color: #fff; + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); } .btn--danger { diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/event-stream-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/event-stream-page.component.ts index a7005a55d..e17676796 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/event-stream-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/event-stream-page.component.ts @@ -124,7 +124,7 @@ interface EventType { } .event-stream__breadcrumb a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } @@ -343,7 +343,7 @@ interface EventType { .event-type-key { font-size: 0.72rem; font-family: var(--font-mono, monospace); - color: var(--color-brand-primary); + color: var(--color-text-link); } .event-type-label { diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/feeds-offline-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/feeds-offline-shell.component.ts new file mode 100644 index 000000000..916f2fae2 --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/feeds-offline-shell.component.ts @@ -0,0 +1,70 @@ +import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router'; +import { filter } from 'rxjs'; + +import { StellaPageTabsComponent, StellaPageTab } from '../../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +type TabType = 'feeds-airgap' | 'offline-kit'; + +const KNOWN_TAB_IDS: readonly string[] = ['feeds-airgap', 'offline-kit']; + +const PAGE_TABS: readonly StellaPageTab[] = [ + { id: 'feeds-airgap', label: 'Feeds & Airgap', icon: 'M1 1l22 22|||M16.72 11.06A10.94 10.94 0 0 1 19 12.55|||M5 12.55a10.94 10.94 0 0 1 5.17-2.39|||M10.71 5.05A16 16 0 0 1 22.56 9|||M1.42 9a15.91 15.91 0 0 1 4.7-2.88|||M8.53 16.11a6 6 0 0 1 6.95 0|||M12 20h.01' }, + { id: 'offline-kit', label: 'Offline Kit', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' }, +]; + +@Component({ + selector: 'app-feeds-offline-shell', + standalone: true, + imports: [RouterOutlet, StellaPageTabsComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + `, + styles: [` + :host { + display: block; + } + `], +}) +export class FeedsOfflineShellComponent implements OnInit { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); + + readonly pageTabs = PAGE_TABS; + readonly activeTab = signal('feeds-airgap'); + + ngOnInit(): void { + this.setActiveTabFromUrl(this.router.url); + this.router.events.pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef), + ).subscribe(e => this.setActiveTabFromUrl(e.urlAfterRedirects)); + } + + onTabChange(tabId: string): void { + this.activeTab.set(tabId as TabType); + // 'feeds-airgap' is the root/default tab — navigate to shell root + const route = tabId === 'feeds-airgap' ? './' : tabId; + this.router.navigate([route], { relativeTo: this.route, queryParamsHandling: 'merge' }); + } + + private setActiveTabFromUrl(url: string): void { + const segments = url.split('?')[0].split('/').filter(Boolean); + const lastSegment = segments.at(-1) ?? ''; + if (KNOWN_TAB_IDS.includes(lastSegment)) { + this.activeTab.set(lastSegment); + } else { + this.activeTab.set('feeds-airgap'); + } + } +} diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-feeds-airgap-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-feeds-airgap-page.component.ts index c825ef9bf..72d552bc4 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-feeds-airgap-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-feeds-airgap-page.component.ts @@ -6,14 +6,22 @@ import { OPERATIONS_PATHS, dataIntegrityPath, } from './operations-paths'; +import { StellaPageTabsComponent, StellaPageTab } from '../../../shared/components/stella-page-tabs/stella-page-tabs.component'; -type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks'; +type FeedsOfflineTab = 'feed-mirrors' | 'airgap-bundles' | 'version-locks' | 'offline-kit'; + +const FEEDS_TABS: StellaPageTab[] = [ + { id: 'feed-mirrors', label: 'Feed Mirrors', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01' }, + { id: 'airgap-bundles', label: 'Airgap Bundles', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' }, + { id: 'version-locks', label: 'Version Locks', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'offline-kit', label: 'Offline Kit', icon: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4|||M7 10l5 5 5-5|||M12 15V3' }, +]; type FeedsAirgapAction = 'import' | 'export' | null; @Component({ selector: 'app-platform-feeds-airgap-page', standalone: true, - imports: [RouterLink], + imports: [RouterLink, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -28,36 +36,15 @@ type FeedsAirgapAction = 'import' | 'export' | null; - +
Mirrors 2 @@ -122,7 +109,6 @@ type FeedsAirgapAction = 'import' | 'export' | null; }

Offline import/export workflows and bundle verification controls.

@@ -134,6 +120,29 @@ type FeedsAirgapAction = 'import' | 'export' | null; Open Freshness Lens
} + @if (tab() === 'offline-kit') { + + }
`, @@ -191,35 +200,7 @@ type FeedsAirgapAction = 'import' | 'export' | null; transform: translateY(1px); } - .tabs { - display: flex; - gap: 0; - border-bottom: 2px solid var(--color-border-primary); - } - .tabs a { - border: none; - border-bottom: 2px solid transparent; - border-radius: 0; - background: transparent; - color: var(--color-tab-inactive-text, var(--color-text-secondary)); - padding: 0.5rem 1rem; - font-size: 0.82rem; - cursor: pointer; - text-decoration: none; - margin-bottom: -2px; - transition: color 150ms ease, border-color 150ms ease; - } - - .tabs a:hover { - color: var(--color-text-primary); - } - - .tabs a.active { - color: var(--color-tab-active-text, var(--color-brand-primary)); - border-bottom: 2px solid var(--color-tab-active-border, var(--color-brand-primary)); - font-weight: 600; - } .summary { display: flex; @@ -248,9 +229,9 @@ type FeedsAirgapAction = 'import' | 'export' | null; } .status-banner--healthy { - border: 1px solid rgba(16, 185, 129, 0.35); - background: rgba(16, 185, 129, 0.08); - color: var(--color-status-success-border); + border: 1px solid var(--color-status-success-border); + background: var(--color-status-success-bg); + color: var(--color-status-success-text); } .status-banner code { @@ -267,7 +248,7 @@ type FeedsAirgapAction = 'import' | 'export' | null; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.72rem; font-weight: 500; padding: 0.18rem 0.5rem; @@ -365,7 +346,7 @@ type FeedsAirgapAction = 'import' | 'export' | null; .panel__links a { font-size: 0.78rem; font-weight: 500; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; padding: 0.3rem 0.6rem; border: 1px solid var(--color-border-primary); @@ -378,6 +359,49 @@ type FeedsAirgapAction = 'import' | 'export' | null; border-color: var(--color-brand-primary); background: var(--color-surface-secondary); } + + .offline-kit-section { + display: grid; + gap: 0.75rem; + } + + .offline-kit-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.5rem; + } + + .offline-kit-card { + display: grid; + gap: 0.2rem; + padding: 0.75rem 1rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + text-decoration: none; + transition: border-color 150ms ease, box-shadow 150ms ease; + } + + .offline-kit-card:hover { + border-color: var(--color-brand-primary); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + } + + .offline-kit-card strong { + font-size: 0.82rem; + color: var(--color-text-primary); + } + + .offline-kit-card span { + font-size: 0.72rem; + color: var(--color-text-secondary); + } + + @media (max-width: 640px) { + .offline-kit-grid { + grid-template-columns: 1fr; + } + } `], }) export class PlatformFeedsAirgapPageComponent implements OnInit { @@ -386,6 +410,7 @@ export class PlatformFeedsAirgapPageComponent implements OnInit { readonly OPERATIONS_PATHS = OPERATIONS_PATHS; readonly OPERATIONS_INTEGRATION_PATHS = OPERATIONS_INTEGRATION_PATHS; + readonly feedsTabs = FEEDS_TABS; readonly feedsFreshnessPath = dataIntegrityPath('feeds-freshness'); readonly tab = signal('feed-mirrors'); readonly airgapAction = signal(null); @@ -398,7 +423,8 @@ export class PlatformFeedsAirgapPageComponent implements OnInit { if ( requested === 'feed-mirrors' || requested === 'airgap-bundles' || - requested === 'version-locks' + requested === 'version-locks' || + requested === 'offline-kit' ) { this.tab.set(requested); } diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts index fa8bc7bbe..f75c48416 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-jobs-queues-page.component.ts @@ -3,8 +3,17 @@ import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; import { OPERATIONS_PATHS, dataIntegrityPath, deadLetterQueuePath } from './operations-paths'; +import { StellaPageTabsComponent, StellaPageTab } from '../../../shared/components/stella-page-tabs/stella-page-tabs.component'; type JobsQueuesTab = 'jobs' | 'runs' | 'schedules' | 'dead-letters' | 'workers'; + +const JOBS_QUEUES_TABS: StellaPageTab[] = [ + { id: 'jobs', label: 'Jobs', icon: 'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2|||M8 2h8v4H8z' }, + { id: 'runs', label: 'Runs', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, + { id: 'schedules', label: 'Schedules', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' }, + { id: 'dead-letters', label: 'Dead Letters', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01' }, + { id: 'workers', label: 'Workers', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01' }, +]; type JobImpact = 'BLOCKING' | 'DEGRADED' | 'INFO'; type Cadence = 'Hourly' | 'Daily'; @@ -62,7 +71,7 @@ interface WorkerRow { @Component({ selector: 'app-platform-jobs-queues-page', standalone: true, - imports: [FormsModule, RouterLink], + imports: [FormsModule, RouterLink, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -87,13 +96,12 @@ interface WorkerRow { } - +
Running {{ runsByStatus('RUNNING') }} @@ -395,7 +403,7 @@ interface WorkerRow { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.74rem; padding: 0.3rem 0.55rem; @@ -410,26 +418,7 @@ interface WorkerRow { font-size: 0.75rem; } - .tabs { - display: flex; - gap: 0.35rem; - flex-wrap: wrap; - } - .tabs button { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-full); - background: var(--color-surface-primary); - color: var(--color-text-secondary); - padding: 0.16rem 0.6rem; - font-size: 0.72rem; - cursor: pointer; - } - - .tabs button.active { - border-color: var(--color-brand-primary); - color: var(--color-brand-primary); - } .kpis { display: flex; @@ -584,7 +573,7 @@ interface WorkerRow { border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-surface-secondary); - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; cursor: pointer; font-size: 0.67rem; @@ -619,7 +608,7 @@ interface WorkerRow { .drawer__links a, .drawer a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.73rem; } @@ -627,6 +616,7 @@ interface WorkerRow { }) export class PlatformJobsQueuesPageComponent { readonly OPERATIONS_PATHS = OPERATIONS_PATHS; + readonly jobsQueuesTabs = JOBS_QUEUES_TABS; readonly dataIntegrityDlqPath = dataIntegrityPath('dlq'); readonly tab = signal('jobs'); readonly searchQuery = signal(''); diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.html b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.html index 1ac13430b..cdd088d12 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.html +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.html @@ -1,7 +1,6 @@
-

Ops / Operations

Operations

Consolidated operator shell for blocking platform issues, execution control, diagnostics, @@ -9,44 +8,49 @@

-
- Run Doctor - Audit Log - Export Ops Report - +
+ +
+ Run Doctor + Audit Log + Export Ops Report + +
- - -
-
- Blocking subsystems - 3 - Release affecting -
-
- Degraded surfaces - 5 - Operator follow-up -
-
- Setup-owned handoffs - 2 - Topology boundary -
-
- Queued actions - {{ pendingActions.length }} - Needs review -
-
+ + + + + +
@@ -76,38 +80,60 @@ - +
+
+ -
-
-

Setup Boundary

-

- Operations can monitor topology impact, but inventory ownership remains in Setup. These - links deliberately route out of Operations to avoid duplicating infrastructure management. -

- -
+
+
+

Pending Operator Actions

+
    + @for (item of pendingActions; track item.id) { +
  • + {{ item.title }} + {{ item.detail }} + {{ item.owner }} +
  • + } +
+
+
@if (refreshedAt()) { diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.scss b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.scss index 023ca33c8..3c397ca9f 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.scss @@ -19,12 +19,18 @@ font-size: 1.55rem; } -.ops-overview__eyebrow { - margin: 0 0 0.2rem; - color: var(--color-brand-primary); - font-size: 0.76rem; - letter-spacing: 0.06em; - text-transform: uppercase; +.ops-overview__header-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + flex-shrink: 0; +} + +.ops-overview__inline-links { + border-top: none; + padding-top: 0; + margin-top: 0; } .ops-overview__subtitle { @@ -164,19 +170,73 @@ } .ops-overview__blocking-grid, -.ops-overview__group-grid, -.ops-overview__footer-grid { +.ops-overview__group-grid { display: grid; gap: 0.8rem; -} - -.ops-overview__blocking-grid, -.ops-overview__group-grid { grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); } -.ops-overview__footer-grid { - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +.ops-overview__columns-layout { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 0.8rem; +} + +.ops-overview__columns-left, +.ops-overview__columns-right { + display: grid; + gap: 0.8rem; + align-content: start; +} + +.ops-overview__group-column { + gap: 0.35rem; + + &:hover { + transform: none; + box-shadow: none; + } +} + +.ops-overview__group-desc { + margin: 0; + font-size: 0.76rem; + color: var(--color-text-secondary); + line-height: 1.4; +} + +.ops-overview__badge-owner { + margin-left: auto; + font-size: 0.65rem; + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-link); +} + +.ops-overview__badge-owner--setup { + color: var(--color-status-warning-text); +} + +.ops-overview__badge-grid { + display: grid; + gap: 0.45rem; +} + +.ops-overview__badge-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.6rem; + border-radius: var(--radius-md); + text-decoration: none; + color: inherit; + font-size: 0.8rem; + transition: background 150ms ease; + + &:hover { + background: var(--color-surface-secondary); + } } .blocking-card, @@ -215,7 +275,7 @@ } .ops-card__owner--setup { - color: var(--color-brand-primary); + color: var(--color-text-link); } .blocking-card h3, @@ -270,7 +330,7 @@ .ops-overview__panel li a, .ops-overview__boundary-links a { - color: var(--color-brand-primary); + color: var(--color-text-link); transition: color 150ms ease; &:hover { @@ -284,26 +344,7 @@ letter-spacing: 0.05em; } -.ops-overview__boundary-links { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; - margin-top: 0.6rem; -} - -.ops-overview__boundary-links a { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - padding: 0.35rem 0.65rem; - font-size: 0.76rem; - font-weight: 500; - transition: background 150ms ease, border-color 150ms ease; - - &:hover { - background: var(--color-surface-secondary); - border-color: var(--color-brand-primary); - } -} +/* Boundary links removed — Setup Boundary section consolidated */ .ops-overview__note { margin: 0; @@ -316,7 +357,15 @@ flex-direction: column; } + .ops-overview__header-right { + align-items: flex-start; + } + .ops-overview__actions { justify-content: flex-start; } + + .ops-overview__columns-layout { + grid-template-columns: 1fr; + } } diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts index 041caf1aa..c913f470d 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/platform-ops-overview-page.component.ts @@ -3,11 +3,14 @@ import { RouterLink } from '@angular/router'; import { DoctorChecksInlineComponent } from '../../doctor/components/doctor-checks-inline/doctor-checks-inline.component'; import { - OverviewCardGroupsComponent, type OverviewCardGroup, - type TabItem, - TabbedNavComponent, } from '../../../shared/ui'; +import { + StellaQuickLinksComponent, + type StellaQuickLink, +} from '../../../shared/components/stella-quick-links/stella-quick-links.component'; +import { StellaMetricCardComponent } from '../../../shared/components/stella-metric-card/stella-metric-card.component'; +import { StellaMetricGridComponent } from '../../../shared/components/stella-metric-card/stella-metric-grid.component'; import { OPERATIONS_INTEGRATION_PATHS, OPERATIONS_PATHS, @@ -37,7 +40,7 @@ interface PendingAction { @Component({ selector: 'app-platform-ops-overview-page', standalone: true, - imports: [RouterLink, DoctorChecksInlineComponent, TabbedNavComponent, OverviewCardGroupsComponent], + imports: [RouterLink, DoctorChecksInlineComponent, StellaQuickLinksComponent, StellaMetricCardComponent, StellaMetricGridComponent], templateUrl: './platform-ops-overview-page.component.html', styleUrls: ['./platform-ops-overview-page.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, @@ -47,19 +50,14 @@ export class PlatformOpsOverviewPageComponent { readonly OPERATIONS_SETUP_PATHS = OPERATIONS_SETUP_PATHS; readonly refreshedAt = signal(null); - readonly quickNav: readonly TabItem[] = [ - { id: 'overview', label: 'Overview', route: OPERATIONS_PATHS.overview, testId: 'operations-nav-overview' }, - { id: 'data-integrity', label: 'Data Integrity', route: OPERATIONS_PATHS.dataIntegrity, testId: 'operations-nav-data-integrity' }, - { id: 'jobs-queues', label: 'Jobs & Queues', route: OPERATIONS_PATHS.jobsQueues, testId: 'operations-nav-jobs-queues' }, - { id: 'health-slo', label: 'Health & SLO', route: OPERATIONS_PATHS.healthSlo, testId: 'operations-nav-health-slo' }, - { id: 'feeds-airgap', label: 'Feeds & Airgap', route: OPERATIONS_PATHS.feedsAirgap, testId: 'operations-nav-feeds-airgap' }, - { id: 'offline-kit', label: 'Offline Kit', route: OPERATIONS_PATHS.offlineKit, testId: 'operations-nav-offline-kit' }, - { id: 'quotas', label: 'Quotas & Limits', route: OPERATIONS_PATHS.quotas, testId: 'operations-nav-quotas' }, - { id: 'aoc', label: 'AOC Compliance', route: OPERATIONS_PATHS.aoc, testId: 'operations-nav-aoc' }, - { id: 'doctor', label: 'Diagnostics', route: OPERATIONS_PATHS.doctor, testId: 'operations-nav-doctor' }, - { id: 'signals', label: 'Signals', route: OPERATIONS_PATHS.signals, testId: 'operations-nav-signals' }, - { id: 'packs', label: 'Pack Registry', route: OPERATIONS_PATHS.packs, testId: 'operations-nav-packs' }, - { id: 'notifications', label: 'Notifications', route: OPERATIONS_PATHS.notifications, testId: 'operations-nav-notifications' }, + readonly quickNav: readonly StellaQuickLink[] = [ + { label: 'Overview', route: OPERATIONS_PATHS.overview }, + { label: 'Data Integrity', route: OPERATIONS_PATHS.dataIntegrity }, + { label: 'Jobs & Queues', route: OPERATIONS_PATHS.jobsQueues }, + { label: 'Health & SLO', route: OPERATIONS_PATHS.healthSlo }, + { label: 'Quotas & Limits', route: OPERATIONS_PATHS.quotas }, + { label: 'AOC Compliance', route: OPERATIONS_PATHS.aoc }, + { label: 'Pack Registry', route: OPERATIONS_PATHS.packs }, ]; readonly blockingCards: readonly BlockingCard[] = [ @@ -93,12 +91,12 @@ export class PlatformOpsOverviewPageComponent { { id: 'blocking', title: 'Blocking', - description: 'Operator-first paths for issues that can block release, evidence, or trust decisions.', + description: 'Issues that can block releases or trust decisions.', cards: [ { id: 'data-integrity', title: 'Data Integrity', - detail: 'Feeds freshness, scan pipeline health, integration reachability, and DLQ replay safety.', + detail: 'Feeds, scans, reachability, DLQ.', metric: '5 trust signals', impact: 'blocking', route: OPERATIONS_PATHS.dataIntegrity, @@ -107,7 +105,7 @@ export class PlatformOpsOverviewPageComponent { { id: 'aoc', title: 'AOC Compliance', - detail: 'Control attestation, provenance validation, and violation triage.', + detail: 'Attestation and violation triage.', metric: '4 violations', impact: 'blocking', route: OPERATIONS_PATHS.aoc, @@ -118,12 +116,12 @@ export class PlatformOpsOverviewPageComponent { { id: 'execution', title: 'Execution', - description: 'Execution control for queues, workers, schedules, runtime signals, and replay flow.', + description: 'Queues, workers, schedules, and signals.', cards: [ { id: 'jobs-queues', title: 'Jobs & Queues', - detail: 'Orchestrator jobs, dead-letter posture, worker fleet, and immediate drill-ins.', + detail: 'Jobs, DLQ, worker fleet.', metric: '1 blocking run', impact: 'degraded', route: OPERATIONS_PATHS.jobsQueues, @@ -132,7 +130,7 @@ export class PlatformOpsOverviewPageComponent { { id: 'scheduler', title: 'Scheduler', - detail: 'Run inventory, schedules, and worker coordination windows.', + detail: 'Schedules and coordination.', metric: '19 active schedules', impact: 'info', route: OPERATIONS_PATHS.schedulerRuns, @@ -141,7 +139,7 @@ export class PlatformOpsOverviewPageComponent { { id: 'signals', title: 'Signals', - detail: 'Runtime signal freshness, event volume, and missing telemetry indicators.', + detail: 'Signal freshness and telemetry.', metric: '97% fresh', impact: 'info', route: OPERATIONS_PATHS.signals, @@ -152,12 +150,12 @@ export class PlatformOpsOverviewPageComponent { { id: 'health', title: 'Health', - description: 'System posture, diagnostics, and operational evidence for degraded subsystems.', + description: 'Diagnostics and system posture.', cards: [ { id: 'health-slo', title: 'Health & SLO', - detail: 'Service health, burn-rate posture, and subsystem incident context.', + detail: 'Service health and burn-rate.', metric: '2 degraded services', impact: 'degraded', route: OPERATIONS_PATHS.healthSlo, @@ -166,7 +164,7 @@ export class PlatformOpsOverviewPageComponent { { id: 'doctor', title: 'Diagnostics', - detail: 'Interactive doctor checks, dependency probes, and operator troubleshooting.', + detail: 'Doctor checks and probes.', metric: '11 checks', impact: 'info', route: OPERATIONS_PATHS.doctor, @@ -175,7 +173,7 @@ export class PlatformOpsOverviewPageComponent { { id: 'status', title: 'System Status', - detail: 'Global status, service heartbeat, and cross-product availability view.', + detail: 'Heartbeat and availability.', metric: '1 regional incident', impact: 'degraded', route: OPERATIONS_PATHS.status, @@ -186,12 +184,12 @@ export class PlatformOpsOverviewPageComponent { { id: 'supply-airgap', title: 'Supply And Airgap', - description: 'Feed sourcing, offline delivery, and pack distribution in one operator view.', + description: 'Feed sourcing, offline delivery, and packs.', cards: [ { id: 'feeds-airgap', title: 'Feeds & Airgap', - detail: 'Mirror freshness, version locks, airgap bundle flows, and source configuration.', + detail: 'Mirrors and bundle flows.', metric: '1 degraded mirror', impact: 'blocking', route: OPERATIONS_PATHS.feedsAirgap, @@ -200,7 +198,7 @@ export class PlatformOpsOverviewPageComponent { { id: 'offline-kit', title: 'Offline Kit', - detail: 'Offline import/export operations, evidence bundle movement, and sealed-mode transfers.', + detail: 'Import/export and transfers.', metric: '3 queued exports', impact: 'info', route: OPERATIONS_PATHS.offlineKit, @@ -209,7 +207,7 @@ export class PlatformOpsOverviewPageComponent { { id: 'packs', title: 'Pack Registry', - detail: 'Pack distribution, bundle integrity, and package availability for workflows.', + detail: 'Distribution and integrity.', metric: '14 active packs', impact: 'info', route: OPERATIONS_PATHS.packs, @@ -218,37 +216,19 @@ export class PlatformOpsOverviewPageComponent { ], }, { - id: 'capacity-boundary', - title: 'Capacity And Setup Boundary', - description: 'Capacity remains in Operations, while topology and agent inventory stay owned by Setup.', + id: 'capacity', + title: 'Capacity', + description: 'Capacity and quota management.', cards: [ { id: 'quotas', title: 'Quotas & Limits', - detail: 'JobEngine quotas, operator limits, and burst-capacity review.', + detail: 'Quotas and burst-capacity.', metric: '1 tenant near limit', impact: 'degraded', route: OPERATIONS_PATHS.quotas, owner: 'Ops', }, - { - id: 'topology', - title: 'Topology Overview', - detail: 'Infrastructure placement, regions, environments, and promotion-path ownership.', - metric: 'Setup owned', - impact: 'info', - route: OPERATIONS_SETUP_PATHS.topologyOverview, - owner: 'Setup', - }, - { - id: 'agents', - title: 'Agent Fleet', - detail: 'Agent enrollment, placement, and health remain under Setup > Topology.', - metric: 'Setup owned', - impact: 'info', - route: OPERATIONS_SETUP_PATHS.topologyAgents, - owner: 'Setup', - }, ], }, ]; diff --git a/src/Web/StellaOps.Web/src/app/features/platform/platform-home-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/platform-home-page.component.ts index c7eca34b2..09d297a09 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/platform-home-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/platform-home-page.component.ts @@ -124,7 +124,7 @@ import { RouterLink } from '@angular/router'; border: 1px solid var(--color-border-primary); border-radius: var(--radius-full); font-size: 0.68rem; - color: var(--color-brand-primary); + color: var(--color-text-link); padding: 0.1rem 0.4rem; } @@ -182,7 +182,7 @@ import { RouterLink } from '@angular/router'; background: var(--color-surface-primary); padding: 0.3rem 0.55rem; text-decoration: none; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.74rem; } `], diff --git a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-defaults-guardrails-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-defaults-guardrails-page.component.ts index 9adac9d75..e09af4781 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-defaults-guardrails-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-defaults-guardrails-page.component.ts @@ -24,7 +24,7 @@ interface GuardrailRow {

Default Controls

-
Timestamp
+
@@ -92,25 +92,7 @@ interface GuardrailRow { font-size: 0.95rem; } - table { - width: 100%; - border-collapse: collapse; - } - - th, - td { - border-bottom: 1px solid var(--color-border-primary); - padding: 0.38rem 0.42rem; - text-align: left; - font-size: 0.74rem; - } - - th { - font-size: 0.66rem; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--color-text-secondary); - } + /* Table styling provided by global .stella-table class */ .impact { border-radius: var(--radius-full); @@ -150,7 +132,7 @@ interface GuardrailRow { } .links a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.74rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-feed-policy-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-feed-policy-page.component.ts index 8f45b8517..8a3b18047 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-feed-policy-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-feed-policy-page.component.ts @@ -25,7 +25,7 @@ interface FeedSlaRow {

Freshness SLA

-
Domain
+
@@ -95,26 +95,7 @@ interface FeedSlaRow { font-size: 0.95rem; } - table { - width: 100%; - border-collapse: collapse; - } - - th, - td { - border-bottom: 1px solid var(--color-border-primary); - padding: 0.38rem 0.42rem; - text-align: left; - font-size: 0.74rem; - white-space: nowrap; - } - - th { - font-size: 0.66rem; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--color-text-secondary); - } + /* Table styling provided by global .stella-table class */ .impact { border-radius: var(--radius-full); @@ -154,7 +135,7 @@ interface FeedSlaRow { } .links a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.74rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-gate-profiles-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-gate-profiles-page.component.ts index 78b17a8c1..ed609956b 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-gate-profiles-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-gate-profiles-page.component.ts @@ -25,7 +25,7 @@ interface GateProfileRow {

Profiles

-
Source
+
@@ -95,25 +95,7 @@ interface GateProfileRow { font-size: 0.95rem; } - table { - width: 100%; - border-collapse: collapse; - } - - th, - td { - border-bottom: 1px solid var(--color-border-primary); - padding: 0.38rem 0.42rem; - text-align: left; - font-size: 0.74rem; - } - - th { - font-size: 0.66rem; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--color-text-secondary); - } + /* Table styling provided by global .stella-table class */ ul { margin: 0; @@ -131,7 +113,7 @@ interface GateProfileRow { } .links a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.74rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-home.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-home.component.ts index 19758df40..7ad7e2112 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-home.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-home.component.ts @@ -123,14 +123,13 @@ interface TopoLink extends d3.SimulationLinkDatum { Promotion path -
Profile
+
@@ -96,25 +96,7 @@ interface PromotionRule { font-size: 0.78rem; } - table { - width: 100%; - border-collapse: collapse; - } - - th, - td { - border-bottom: 1px solid var(--color-border-primary); - padding: 0.35rem 0.4rem; - text-align: left; - font-size: 0.74rem; - } - - th { - font-size: 0.66rem; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--color-text-secondary); - } + /* Table styling provided by global .stella-table class */ .links { display: flex; @@ -123,7 +105,7 @@ interface PromotionRule { } .links a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.74rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-regions-environments-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-regions-environments-page.component.ts index ce2aa7b8c..2a1658ef9 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-regions-environments-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-regions-environments-page.component.ts @@ -32,7 +32,7 @@ interface RegionRow {

Region: us-east

-
Rule
+
@@ -56,7 +56,7 @@ interface RegionRow {

Region: eu-west

-
Environment
+
@@ -132,25 +132,7 @@ interface RegionRow { font-size: 0.92rem; } - table { - width: 100%; - border-collapse: collapse; - } - - th, - td { - border-bottom: 1px solid var(--color-border-primary); - padding: 0.35rem 0.4rem; - text-align: left; - font-size: 0.74rem; - } - - th { - font-size: 0.66rem; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--color-text-secondary); - } + /* Table styling provided by global .stella-table class */ .links { display: flex; @@ -159,7 +141,7 @@ interface RegionRow { } .links a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.74rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-release-templates-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-release-templates-page.component.ts index b954e2939..9c97eacc5 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-release-templates-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-release-templates-page.component.ts @@ -21,7 +21,7 @@ interface TemplateRow {
-
Environment
+
@@ -73,25 +73,7 @@ interface TemplateRow { padding: 0.6rem; } - table { - width: 100%; - border-collapse: collapse; - } - - th, - td { - border-bottom: 1px solid var(--color-border-primary); - padding: 0.35rem 0.4rem; - text-align: left; - font-size: 0.74rem; - } - - th { - font-size: 0.66rem; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--color-text-secondary); - } + /* Table styling provided by global .stella-table class */ .links { display: flex; @@ -100,7 +82,7 @@ interface TemplateRow { } .links a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.74rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-workflows-gates-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-workflows-gates-page.component.ts index cfa660a7a..90f25de68 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-workflows-gates-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/setup/platform-setup-workflows-gates-page.component.ts @@ -24,7 +24,7 @@ interface WorkflowRow {

Workflow Catalog

-
Name
+
@@ -95,25 +95,7 @@ interface WorkflowRow { font-size: 0.92rem; } - table { - width: 100%; - border-collapse: collapse; - } - - th, - td { - border-bottom: 1px solid var(--color-border-primary); - padding: 0.35rem 0.4rem; - text-align: left; - font-size: 0.74rem; - } - - th { - font-size: 0.66rem; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--color-text-secondary); - } + /* Table styling provided by global .stella-table class */ .profiles ul { margin: 0; @@ -131,7 +113,7 @@ interface WorkflowRow { } .links a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.74rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/platform/setup/topology-wizard/topology-wizard.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/setup/topology-wizard/topology-wizard.component.ts index ddc978f31..70f644cdf 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/setup/topology-wizard/topology-wizard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/setup/topology-wizard/topology-wizard.component.ts @@ -11,6 +11,7 @@ import { Component, signal, computed, effect, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { RouterLink } from '@angular/router'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; import { Subscription } from 'rxjs'; import { @@ -25,7 +26,7 @@ import { @Component({ selector: 'st-topology-wizard', standalone: true, - imports: [FormsModule, RouterLink], + imports: [FormsModule, RouterLink, LoadingStateComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -491,10 +492,7 @@ import {

Running readiness checks for the configured target and environment.

@if (validationLoading()) { -
-
- Running validation gates... -
+ } @else if (wizard.readinessReport()) {
@if (wizard.readinessReport()?.isReady) { @@ -658,7 +656,7 @@ import { align-items: center; gap: 0.25rem; margin-bottom: 0.5rem; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.875rem; @@ -691,7 +689,7 @@ import { .wizard-progress-bar__fill { height: 100%; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border-radius: 2px; transition: width 300ms ease; } @@ -750,16 +748,16 @@ import { .progress-step--active .progress-step__number { border-color: var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); } .progress-step--active .progress-step__label { - color: var(--color-brand-primary); + color: var(--color-text-link); font-weight: var(--font-weight-medium); } .progress-step--completed .progress-step__number { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border-color: var(--color-brand-primary); color: white; } @@ -891,14 +889,14 @@ import { .btn-link { background: none; border: none; - color: var(--color-brand-primary); + color: var(--color-text-link); cursor: pointer; font-size: 0.82rem; text-decoration: underline; padding: 0; &:hover { - color: var(--color-brand-primary-hover); + color: var(--color-text-link-hover); } } @@ -1099,7 +1097,7 @@ import { width: 24px; height: 24px; border-radius: var(--radius-full); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; display: flex; align-items: center; diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-audit-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-audit-shell.component.ts index 4a6e96387..df4d6c976 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-audit-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-audit-shell.component.ts @@ -3,7 +3,6 @@ import { ChangeDetectionStrategy, Component, DestroyRef, - computed, inject, signal, } from '@angular/core'; @@ -19,16 +18,19 @@ import { filter, startWith } from 'rxjs'; import { buildContextRouteParams, } from '../../shared/ui/context-route-state/context-route-state'; -import { - TabItem, - TabbedNavComponent, -} from '../../shared/ui'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type AuditSubview = 'policy' | 'vex' | 'log'; +const PAGE_TABS: readonly StellaPageTab[] = [ + { id: 'policy', label: 'Policy Audit', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'vex', label: 'VEX Audit', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'log', label: 'Unified Log', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' }, +]; + @Component({ selector: 'app-policy-decisioning-audit-shell', - imports: [CommonModule, RouterOutlet, TabbedNavComponent], + imports: [CommonModule, RouterOutlet, StellaPageTabsComponent], template: `
@@ -42,14 +44,14 @@ type AuditSubview = 'policy' | 'vex' | 'log';
- - -
+ ariaLabel="Audit tabs" + (tabChange)="onTabChange($event)" + > -
+ `, styles: [` @@ -91,42 +93,9 @@ export class PolicyDecisioningAuditShellComponent { private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); + readonly pageTabs = PAGE_TABS; readonly activeSubview = signal(this.readSubview()); - readonly tabItems = computed(() => { - const queryParams = buildContextRouteParams({ - releaseId: coerceString(this.route.snapshot.root.queryParams['releaseId']), - approvalId: coerceString(this.route.snapshot.root.queryParams['approvalId']), - environment: coerceString(this.route.snapshot.root.queryParams['environment']), - artifact: coerceString(this.route.snapshot.root.queryParams['artifact']), - returnTo: coerceString(this.route.snapshot.root.queryParams['returnTo']), - }); - - return [ - { - id: 'policy', - label: 'Policy Audit', - route: ['/ops/policy/audit/policy'], - queryParams, - testId: 'policy-audit-tab-policy', - }, - { - id: 'vex', - label: 'VEX Audit', - route: ['/ops/policy/audit/vex'], - queryParams, - testId: 'policy-audit-tab-vex', - }, - { - id: 'log', - label: 'Unified Log', - route: ['/ops/policy/audit/log'], - queryParams, - testId: 'policy-audit-tab-log', - }, - ]; - }); - constructor() { this.router.events .pipe( @@ -139,6 +108,20 @@ export class PolicyDecisioningAuditShellComponent { }); } + onTabChange(tabId: string): void { + this.activeSubview.set(tabId as AuditSubview); + + const queryParams = buildContextRouteParams({ + releaseId: coerceString(this.route.snapshot.root.queryParams['releaseId']), + approvalId: coerceString(this.route.snapshot.root.queryParams['approvalId']), + environment: coerceString(this.route.snapshot.root.queryParams['environment']), + artifact: coerceString(this.route.snapshot.root.queryParams['artifact']), + returnTo: coerceString(this.route.snapshot.root.queryParams['returnTo']), + }); + + void this.router.navigate(['/ops/policy/audit', tabId], { queryParams }); + } + private readSubview(): AuditSubview { const url = this.router.url.split('?')[0] ?? ''; diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-gates-page.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-gates-page.component.ts index 64bf86605..4cf303b45 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-gates-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-gates-page.component.ts @@ -238,7 +238,7 @@ type DecisioningGateScope = 'global' | 'environment' | 'release' | 'approval'; .page-action--primary { border-color: var(--color-brand-primary); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); } @@ -288,7 +288,7 @@ type DecisioningGateScope = 'global' | 'environment' | 'release' | 'approval'; } .context-links a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-shell.component.ts index cc14a415e..99f80c994 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-shell.component.ts @@ -20,12 +20,11 @@ import { filter, startWith } from 'rxjs'; import { ContextHeaderComponent, - TabItem, - TabbedNavComponent, } from '../../shared/ui'; import { buildContextRouteParams, } from '../../shared/ui/context-route-state/context-route-state'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type DecisioningPrimaryTab = | 'overview' @@ -57,6 +56,16 @@ interface DecisioningShellState { readonly evidenceId: string | null; } +const PAGE_TABS: readonly StellaPageTab[] = [ + { id: 'overview', label: 'Overview', icon: 'M3 3h7v7H3z|||M14 3h7v7h-7z|||M14 14h7v7h-7z|||M3 14h7v7H3z' }, + { id: 'packs', label: 'Packs', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' }, + { id: 'governance', label: 'Governance', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'simulation', label: 'Simulation', icon: 'M5 3l14 9-14 9V3z' }, + { id: 'vex', label: 'VEX & Exceptions', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'gates', label: 'Release Gates', icon: 'M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' }, + { id: 'audit', label: 'Audit', icon: 'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2|||M8 2h8v4H8z' }, +]; + @Component({ selector: 'app-policy-decisioning-shell', imports: [ @@ -64,7 +73,7 @@ interface DecisioningShellState { RouterLink, RouterOutlet, ContextHeaderComponent, - TabbedNavComponent, + StellaPageTabsComponent, ], template: `
@@ -87,14 +96,14 @@ interface DecisioningShellState { - - -
+ ariaLabel="Policy decisioning tabs" + (tabChange)="onTabChange($event)" + > -
+
`, styles: [` @@ -109,10 +118,6 @@ interface DecisioningShellState { padding: 1.25rem; } - .policy-decisioning-shell__body { - min-width: 0; - } - .shell-action { display: inline-flex; align-items: center; @@ -132,67 +137,9 @@ export class PolicyDecisioningShellComponent { private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); + readonly pageTabs = PAGE_TABS; readonly shellState = signal(this.readShellState()); - readonly primaryTabs = computed(() => { - const state = this.shellState(); - const queryParams = this.contextQueryParams(); - - return [ - { - id: 'overview', - label: 'Overview', - route: this.overviewRoute(), - queryParams, - testId: 'policy-tab-overview', - }, - { - id: 'packs', - label: 'Packs', - route: state.packId - ? ['/ops/policy/packs', state.packId] - : ['/ops/policy/packs'], - queryParams, - testId: 'policy-tab-packs', - }, - { - id: 'governance', - label: 'Governance', - route: ['/ops/policy/governance'], - queryParams, - testId: 'policy-tab-governance', - }, - { - id: 'simulation', - label: 'Simulation', - route: ['/ops/policy/simulation'], - queryParams, - testId: 'policy-tab-simulation', - }, - { - id: 'vex', - label: 'VEX & Exceptions', - route: ['/ops/policy/vex'], - queryParams, - testId: 'policy-tab-vex', - }, - { - id: 'gates', - label: 'Release Gates', - route: this.gatesRoute(), - queryParams, - testId: 'policy-tab-gates', - }, - { - id: 'audit', - label: 'Audit', - route: ['/ops/policy/audit'], - queryParams, - testId: 'policy-tab-audit', - }, - ]; - }); - readonly headerTitle = computed(() => { const state = this.shellState(); @@ -290,6 +237,42 @@ export class PolicyDecisioningShellComponent { }); } + onTabChange(tabId: string): void { + const state = this.shellState(); + const queryParams = this.contextQueryParams(); + + let route: readonly unknown[]; + switch (tabId) { + case 'overview': + route = this.overviewRoute(); + break; + case 'packs': + route = state.packId + ? ['/ops/policy/packs', state.packId] + : ['/ops/policy/packs']; + break; + case 'governance': + route = ['/ops/policy/governance']; + break; + case 'simulation': + route = ['/ops/policy/simulation']; + break; + case 'vex': + route = ['/ops/policy/vex']; + break; + case 'gates': + route = this.gatesRoute(); + break; + case 'audit': + route = ['/ops/policy/audit']; + break; + default: + route = this.overviewRoute(); + } + + void this.router.navigate([...route], { queryParams }); + } + overviewRoute(): readonly unknown[] { return ['/ops/policy/overview']; } diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-vex-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-vex-shell.component.ts index 2150c5bc0..32cb49831 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-vex-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-decisioning-vex-shell.component.ts @@ -3,7 +3,6 @@ import { ChangeDetectionStrategy, Component, DestroyRef, - computed, inject, signal, } from '@angular/core'; @@ -19,10 +18,7 @@ import { filter, startWith } from 'rxjs'; import { buildContextRouteParams, } from '../../shared/ui/context-route-state/context-route-state'; -import { - TabItem, - TabbedNavComponent, -} from '../../shared/ui'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type VexSubview = | 'dashboard' @@ -34,9 +30,20 @@ type VexSubview = | 'conflicts' | 'exceptions'; +const PAGE_TABS: readonly StellaPageTab[] = [ + { id: 'dashboard', label: 'Dashboard', icon: 'M3 3h7v7H3z|||M14 3h7v7h-7z|||M14 14h7v7h-7z|||M3 14h7v7H3z' }, + { id: 'search', label: 'Search', icon: 'M11 11m-8 0a8 8 0 1 0 16 0 8 8 0 1 0-16 0|||M21 21l-4.35-4.35' }, + { id: 'create', label: 'Create', icon: 'M12 5v14|||M5 12h14' }, + { id: 'stats', label: 'Stats', icon: 'M18 20V10|||M12 20V4|||M6 20v-6' }, + { id: 'consensus', label: 'Consensus', icon: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|||M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|||M20 8v6|||M23 11h-6' }, + { id: 'explorer', label: 'Explorer', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' }, + { id: 'conflicts', label: 'Conflicts', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01' }, + { id: 'exceptions', label: 'Exceptions', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, +]; + @Component({ selector: 'app-policy-decisioning-vex-shell', - imports: [CommonModule, RouterOutlet, TabbedNavComponent], + imports: [CommonModule, RouterOutlet, StellaPageTabsComponent], template: `
@@ -50,14 +57,14 @@ type VexSubview =
- - -
+ ariaLabel="VEX tabs" + (tabChange)="onTabChange($event)" + > -
+ `, styles: [` @@ -99,77 +106,9 @@ export class PolicyDecisioningVexShellComponent { private readonly router = inject(Router); private readonly destroyRef = inject(DestroyRef); + readonly pageTabs = PAGE_TABS; readonly activeSubview = signal(this.readSubview()); - readonly tabItems = computed(() => { - const queryParams = buildContextRouteParams({ - releaseId: coerceString(this.route.snapshot.root.queryParams['releaseId']), - approvalId: coerceString(this.route.snapshot.root.queryParams['approvalId']), - environment: coerceString(this.route.snapshot.root.queryParams['environment']), - artifact: coerceString(this.route.snapshot.root.queryParams['artifact']), - returnTo: coerceString(this.route.snapshot.root.queryParams['returnTo']), - }); - - return [ - { - id: 'dashboard', - label: 'Dashboard', - route: ['/ops/policy/vex'], - queryParams, - testId: 'policy-vex-tab-dashboard', - }, - { - id: 'search', - label: 'Search', - route: ['/ops/policy/vex/search'], - queryParams, - testId: 'policy-vex-tab-search', - }, - { - id: 'create', - label: 'Create', - route: ['/ops/policy/vex/create'], - queryParams, - testId: 'policy-vex-tab-create', - }, - { - id: 'stats', - label: 'Stats', - route: ['/ops/policy/vex/stats'], - queryParams, - testId: 'policy-vex-tab-stats', - }, - { - id: 'consensus', - label: 'Consensus', - route: ['/ops/policy/vex/consensus'], - queryParams, - testId: 'policy-vex-tab-consensus', - }, - { - id: 'explorer', - label: 'Explorer', - route: ['/ops/policy/vex/explorer'], - queryParams, - testId: 'policy-vex-tab-explorer', - }, - { - id: 'conflicts', - label: 'Conflicts', - route: ['/ops/policy/vex/conflicts'], - queryParams, - testId: 'policy-vex-tab-conflicts', - }, - { - id: 'exceptions', - label: 'Exceptions', - route: ['/ops/policy/vex/exceptions'], - queryParams, - testId: 'policy-vex-tab-exceptions', - }, - ]; - }); - constructor() { this.router.events .pipe( @@ -182,6 +121,25 @@ export class PolicyDecisioningVexShellComponent { }); } + onTabChange(tabId: string): void { + this.activeSubview.set(tabId as VexSubview); + + const queryParams = buildContextRouteParams({ + releaseId: coerceString(this.route.snapshot.root.queryParams['releaseId']), + approvalId: coerceString(this.route.snapshot.root.queryParams['approvalId']), + environment: coerceString(this.route.snapshot.root.queryParams['environment']), + artifact: coerceString(this.route.snapshot.root.queryParams['artifact']), + returnTo: coerceString(this.route.snapshot.root.queryParams['returnTo']), + }); + + // 'dashboard' is the VEX root — navigate to /ops/policy/vex without a sub-segment + const segments = tabId === 'dashboard' + ? ['/ops/policy/vex'] + : ['/ops/policy/vex', tabId]; + + void this.router.navigate(segments, { queryParams }); + } + private readSubview(): VexSubview { const url = this.router.url.split('?')[0] ?? ''; diff --git a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-pack-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-pack-shell.component.ts index 901f5f442..aab98ec40 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-pack-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-decisioning/policy-pack-shell.component.ts @@ -17,10 +17,7 @@ import { } from '@angular/router'; import { filter, startWith } from 'rxjs'; -import { - TabItem, - TabbedNavComponent, -} from '../../shared/ui'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type PackSubview = | 'workspace' @@ -32,9 +29,22 @@ type PackSubview = | 'simulate' | 'explain'; +const WORKSPACE_TABS: readonly StellaPageTab[] = [ + { id: 'workspace', label: 'Workspace', icon: 'M3 3h7v7H3z|||M14 3h7v7h-7z|||M14 14h7v7h-7z|||M3 14h7v7H3z' }, +]; + +const PACK_DETAIL_TABS: readonly StellaPageTab[] = [ + { id: 'dashboard', label: 'Dashboard', icon: 'M3 3h7v7H3z|||M14 3h7v7h-7z|||M14 14h7v7h-7z|||M3 14h7v7H3z' }, + { id: 'edit', label: 'Edit', icon: 'M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7|||M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z' }, + { id: 'rules', label: 'Rules', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' }, + { id: 'yaml', label: 'YAML', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'approvals', label: 'Approvals', icon: 'M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' }, + { id: 'simulate', label: 'Simulate', icon: 'M5 3l14 9-14 9V3z' }, +]; + @Component({ selector: 'app-policy-pack-shell', - imports: [CommonModule, RouterOutlet, TabbedNavComponent], + imports: [CommonModule, RouterOutlet, StellaPageTabsComponent], template: `
@@ -51,14 +61,14 @@ type PackSubview =
- - -
+ ariaLabel="Pack tabs" + (tabChange)="onTabChange($event)" + > -
+
`, styles: [` @@ -103,58 +113,8 @@ export class PolicyPackShellComponent { readonly packId = signal(this.readPackId()); readonly activeSubview = signal(this.readSubview()); - readonly tabItems = computed(() => { - const packId = this.packId(); - - if (!packId) { - return [ - { - id: 'workspace', - label: 'Workspace', - route: ['/ops/policy/packs'], - testId: 'policy-pack-tab-workspace', - }, - ]; - } - - return [ - { - id: 'dashboard', - label: 'Dashboard', - route: ['/ops/policy/packs', packId], - testId: 'policy-pack-tab-dashboard', - }, - { - id: 'edit', - label: 'Edit', - route: ['/ops/policy/packs', packId, 'edit'], - testId: 'policy-pack-tab-edit', - }, - { - id: 'rules', - label: 'Rules', - route: ['/ops/policy/packs', packId, 'rules'], - testId: 'policy-pack-tab-rules', - }, - { - id: 'yaml', - label: 'YAML', - route: ['/ops/policy/packs', packId, 'yaml'], - testId: 'policy-pack-tab-yaml', - }, - { - id: 'approvals', - label: 'Approvals', - route: ['/ops/policy/packs', packId, 'approvals'], - testId: 'policy-pack-tab-approvals', - }, - { - id: 'simulate', - label: 'Simulate', - route: ['/ops/policy/packs', packId, 'simulate'], - testId: 'policy-pack-tab-simulate', - }, - ]; + readonly pageTabs = computed(() => { + return this.packId() ? PACK_DETAIL_TABS : WORKSPACE_TABS; }); constructor() { @@ -170,6 +130,23 @@ export class PolicyPackShellComponent { }); } + onTabChange(tabId: string): void { + this.activeSubview.set(tabId as PackSubview); + const packId = this.packId(); + + if (!packId || tabId === 'workspace') { + void this.router.navigate(['/ops/policy/packs']); + return; + } + + if (tabId === 'dashboard') { + void this.router.navigate(['/ops/policy/packs', packId]); + return; + } + + void this.router.navigate(['/ops/policy/packs', packId, tabId]); + } + private readPackId(): string | null { const params = collectParams(this.route.snapshot.root); return typeof params['packId'] === 'string' && params['packId'].length > 0 diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/conflict-resolution-wizard.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/conflict-resolution-wizard.component.ts index cc0861f29..2a3610d59 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/conflict-resolution-wizard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/conflict-resolution-wizard.component.ts @@ -538,10 +538,10 @@ import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope'; } .badge--type { background: var(--color-text-primary); color: var(--color-text-muted); } - .badge--info { background: var(--color-status-info-text); color: var(--color-status-info-border); } - .badge--warning { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } + .badge--info { background: var(--color-status-info-text); color: #fff; } + .badge--warning { background: var(--color-status-warning-text); color: #fff; } .badge--error { background: var(--color-severity-high); color: var(--color-severity-high-bg); } - .badge--critical { background: var(--color-status-error-text); color: var(--color-status-error-border); } + .badge--critical { background: var(--color-status-error-text); color: #fff; } .conflict-title { margin: 0 0 0.5rem; diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-audit.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-audit.component.ts index aa48aa0fc..124190ee4 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-audit.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/governance-audit.component.ts @@ -392,9 +392,9 @@ import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope'; text-transform: uppercase; } - .actor-badge--user { background: var(--color-status-info-text); color: var(--color-status-info-border); } + .actor-badge--user { background: var(--color-status-info-text); color: #fff; } .actor-badge--system { background: var(--color-text-primary); color: var(--color-text-muted); } - .actor-badge--automation { background: var(--color-status-success-text); color: var(--color-status-success-border); } + .actor-badge--automation { background: var(--color-status-success-text); color: #fff; } .event-card__time { font-size: 0.75rem; diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/impact-preview.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/impact-preview.component.ts index c310da5c4..35f4d0b83 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/impact-preview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/impact-preview.component.ts @@ -406,10 +406,10 @@ import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope'; text-transform: uppercase; } - .severity--critical { background: var(--color-status-error-text); color: var(--color-status-error-border); } + .severity--critical { background: var(--color-status-error-text); color: #fff; } .severity--high { background: var(--color-severity-high); color: var(--color-severity-high-bg); } - .severity--medium { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } - .severity--low { background: var(--color-status-info-text); color: var(--color-status-info-border); } + .severity--medium { background: var(--color-status-warning-text); color: #fff; } + .severity--low { background: var(--color-status-info-text); color: #fff; } .severity--info { background: var(--color-text-primary); color: var(--color-text-muted); } .change-indicator { @@ -429,9 +429,9 @@ import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope'; font-weight: var(--font-weight-semibold); } - .decision--allow { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .decision--deny { background: var(--color-status-error-text); color: var(--color-status-error-border); } - .decision--warn { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } + .decision--allow { background: var(--color-status-success-text); color: #fff; } + .decision--deny { background: var(--color-status-error-text); color: #fff; } + .decision--warn { background: var(--color-status-warning-text); color: #fff; } .decision-change { display: inline-flex; diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-conflict-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-conflict-dashboard.component.ts index 7aa1e700a..4fbfee583 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-conflict-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-conflict-dashboard.component.ts @@ -458,15 +458,15 @@ import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope'; } .badge--type { background: var(--color-text-primary); color: var(--color-text-muted); } - .badge--info { background: var(--color-status-info-text); color: var(--color-status-info-border); } - .badge--warning { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } + .badge--info { background: var(--color-status-info-text); color: #fff; } + .badge--warning { background: var(--color-status-warning-text); color: #fff; } .badge--error { background: var(--color-severity-high); color: var(--color-severity-high-bg); } - .badge--critical { background: var(--color-status-error-text); color: var(--color-status-error-border); } + .badge--critical { background: var(--color-status-error-text); color: #fff; } .badge--status { background: var(--color-text-primary); color: var(--color-text-muted); } - .badge--status-open { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } - .badge--status-acknowledged { background: var(--color-status-info-text); color: var(--color-status-info-border); } - .badge--status-resolved { background: var(--color-status-success-text); color: var(--color-status-success-border); } + .badge--status-open { background: var(--color-status-warning-text); color: #fff; } + .badge--status-acknowledged { background: var(--color-status-info-text); color: #fff; } + .badge--status-resolved { background: var(--color-status-success-text); color: #fff; } .badge--status-ignored { background: var(--color-text-primary); color: var(--color-text-muted); } .conflict-card__time { diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.ts index aab265c5a..582113f70 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-governance.component.ts @@ -1,6 +1,7 @@ -import { Component, ChangeDetectionStrategy } from '@angular/core'; -import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; +import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core'; +import { Router, RouterOutlet } from '@angular/router'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; /** * Policy Governance main component with tabbed navigation. @@ -8,10 +9,23 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; * * @sprint SPRINT_20251229_021a_FE */ +const GOVERNANCE_TABS: readonly StellaPageTab[] = [ + { id: 'budget', label: 'Risk Budget', icon: 'M18 20V10|||M12 20V4|||M6 20v-6' }, + { id: 'trust', label: 'Trust Weights', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'staleness', label: 'Staleness', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' }, + { id: 'sealed', label: 'Sealed Mode', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'profiles', label: 'Profiles', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' }, + { id: 'validator', label: 'Validator', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'audit', label: 'Audit Log', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'conflicts', label: 'Conflicts', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01', badge: 2, status: 'warn', statusHint: '2 conflicts detected' }, + { id: 'schema-playground', label: 'Playground', icon: 'M16 18l6-6-6-6|||M8 6l-6 6 6 6' }, + { id: 'schema-docs', label: 'Docs', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, +]; + @Component({ selector: 'app-policy-governance', standalone: true, - imports: [RouterOutlet, RouterLink, RouterLinkActive], + imports: [RouterOutlet, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -21,29 +35,14 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';

Configure risk budgets, trust weights, staleness rules, sealed mode, and risk profiles.

- - -
+ -
+
`, styles: [` @@ -86,100 +85,32 @@ import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router'; font-size: 0.95rem; } - .governance__tabs { - display: flex; - gap: 0.25rem; - border-bottom: 1px solid var(--color-border-primary); - margin-bottom: 1.5rem; - overflow-x: auto; - scrollbar-width: thin; - scrollbar-color: var(--color-scrollbar-thumb) transparent; - } - - .governance__tabs::-webkit-scrollbar { - height: 4px; - } - - .governance__tabs::-webkit-scrollbar-track { - background: transparent; - } - - .governance__tabs::-webkit-scrollbar-thumb { - background: var(--color-scrollbar-thumb); - border-radius: var(--radius-sm); - } - - .governance__tab { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1rem; - color: var(--color-text-muted); - text-decoration: none; - font-size: 0.9rem; - font-weight: var(--font-weight-medium); - border-bottom: 2px solid transparent; - transition: all 0.15s ease; - white-space: nowrap; - } - - .governance__tab:hover { - color: var(--color-text-primary); - background: var(--color-brand-soft); - } - - .governance__tab--active { - color: var(--color-status-info); - border-bottom-color: var(--color-status-info); - } - - .governance__tab-label { - font-weight: var(--font-weight-medium); - } - - .governance__tab-badge { - padding: 0.125rem 0.4rem; - font-size: 0.7rem; - font-weight: var(--font-weight-semibold); - border-radius: var(--radius-full); - background: var(--color-surface-tertiary); - color: var(--color-text-primary); - } - - .governance__tab-badge--warning { - background: var(--color-status-warning-text); - color: var(--color-status-warning-border); - } - - .governance__tab-badge--error { - background: var(--color-status-error-text); - color: var(--color-status-error-border); - } - - .governance__tab-badge--info { - background: var(--color-status-info-text); - color: var(--color-status-info-border); - } - - .governance__content { - background: var(--color-surface-secondary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-xl); - min-height: 400px; - } `] }) export class PolicyGovernanceComponent { - protected readonly tabs = [ - { id: 'budget', label: 'Risk Budget', route: '/ops/policy/governance', exact: true, badge: null, badgeType: null }, - { id: 'trust', label: 'Trust Weights', route: '/ops/policy/governance/trust-weights', exact: false, badge: null, badgeType: null }, - { id: 'staleness', label: 'Staleness', route: '/ops/policy/governance/staleness', exact: false, badge: null, badgeType: null }, - { id: 'sealed', label: 'Sealed Mode', route: '/ops/policy/governance/sealed-mode', exact: false, badge: null, badgeType: null }, - { id: 'profiles', label: 'Profiles', route: '/ops/policy/governance/profiles', exact: false, badge: null, badgeType: null }, - { id: 'validator', label: 'Validator', route: '/ops/policy/governance/validator', exact: false, badge: null, badgeType: null }, - { id: 'audit', label: 'Audit Log', route: '/ops/policy/governance/audit', exact: false, badge: null, badgeType: null }, - { id: 'conflicts', label: 'Conflicts', route: '/ops/policy/governance/conflicts', exact: false, badge: '2', badgeType: 'warning' }, - { id: 'schema-playground', label: 'Playground', route: '/ops/policy/governance/schema-playground', exact: false, badge: null, badgeType: null }, - { id: 'schema-docs', label: 'Docs', route: '/ops/policy/governance/schema-docs', exact: false, badge: null, badgeType: null }, - ]; + private static readonly TAB_ROUTES: Record = { + budget: '/ops/policy/governance', + trust: '/ops/policy/governance/trust-weights', + staleness: '/ops/policy/governance/staleness', + sealed: '/ops/policy/governance/sealed-mode', + profiles: '/ops/policy/governance/profiles', + validator: '/ops/policy/governance/validator', + audit: '/ops/policy/governance/audit', + conflicts: '/ops/policy/governance/conflicts', + 'schema-playground': '/ops/policy/governance/schema-playground', + 'schema-docs': '/ops/policy/governance/schema-docs', + }; + + private readonly router = inject(Router); + + protected readonly activeTab = signal('budget'); + protected readonly GOVERNANCE_TABS = GOVERNANCE_TABS; + + protected onTabChange(tabId: string): void { + this.activeTab.set(tabId); + const route = PolicyGovernanceComponent.TAB_ROUTES[tabId]; + if (route) { + this.router.navigate([route]); + } + } } diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-validator.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-validator.component.ts index cc1552ccb..2305671a5 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-validator.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/policy-validator.component.ts @@ -358,8 +358,8 @@ import { font-weight: var(--font-weight-medium); } - .count--error { background: var(--color-status-error-text); color: var(--color-status-error-border); } - .count--warning { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } + .count--error { background: var(--color-status-error-text); color: #fff; } + .count--warning { background: var(--color-status-warning-text); color: #fff; } /* Issues */ .issues-section { diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/risk-profile-list.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/risk-profile-list.component.ts index 44ac26377..3c0a2b91d 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/risk-profile-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/risk-profile-list.component.ts @@ -279,8 +279,8 @@ import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope'; letter-spacing: 0.03em; } - .status--active { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .status--draft { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } + .status--active { background: var(--color-status-success-text); color: #fff; } + .status--draft { background: var(--color-status-warning-text); color: #fff; } .status--deprecated { background: var(--color-text-primary); color: var(--color-text-muted); } .status--archived { background: var(--color-text-heading); color: var(--color-text-secondary); } diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/schema-docs.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/schema-docs.component.ts index 8857eed86..e933ceaae 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/schema-docs.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/schema-docs.component.ts @@ -518,7 +518,7 @@ interface SchemaSection { font-weight: var(--font-weight-semibold); } - .badge--required { background: var(--color-status-error-text); color: var(--color-status-error-border); } + .badge--required { background: var(--color-status-error-text); color: #fff; } .badge--optional { background: var(--color-text-primary); color: var(--color-text-muted); } /* Field Details (expanded) */ @@ -725,9 +725,9 @@ interface SchemaSection { font-weight: var(--font-weight-semibold); } - .severity--error { background: var(--color-status-error-text); color: var(--color-status-error-border); } - .severity--warning { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } - .severity--info { background: var(--color-status-info-text); color: var(--color-status-info-border); } + .severity--error { background: var(--color-status-error-text); color: #fff; } + .severity--warning { background: var(--color-status-warning-text); color: #fff; } + .severity--info { background: var(--color-status-info-text); color: #fff; } .rule-desc { margin: 0 0 0.5rem; diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/sealed-mode-control.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/sealed-mode-control.component.ts index 560f29b18..13f3988a9 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/sealed-mode-control.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/sealed-mode-control.component.ts @@ -431,9 +431,9 @@ import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope'; border-radius: var(--radius-sm); } - .verification-badge--verified { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .verification-badge--pending { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } - .verification-badge--failed { background: var(--color-status-error-text); color: var(--color-status-error-border); } + .verification-badge--verified { background: var(--color-status-success-text); color: #fff; } + .verification-badge--pending { background: var(--color-status-warning-text); color: #fff; } + .verification-badge--failed { background: var(--color-status-error-text); color: #fff; } .status-card__details { color: var(--color-text-muted); @@ -560,8 +560,8 @@ import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope'; border-radius: var(--radius-sm); } - .override-card__status--active { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .override-card__status--expired { background: var(--color-status-error-text); color: var(--color-status-error-border); } + .override-card__status--active { background: var(--color-status-success-text); color: #fff; } + .override-card__status--expired { background: var(--color-status-error-text); color: #fff; } .override-card__target { font-family: monospace; diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/sealed-mode-overrides.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/sealed-mode-overrides.component.ts index 6b36bac5a..b40f7faf0 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/sealed-mode-overrides.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/sealed-mode-overrides.component.ts @@ -388,7 +388,7 @@ import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope'; } .badge--type { background: var(--color-text-primary); color: var(--color-text-muted); } - .badge--active { background: var(--color-status-success-text); color: var(--color-status-success-border); } + .badge--active { background: var(--color-status-success-text); color: #fff; } .badge--expired { background: var(--color-text-primary); color: var(--color-text-muted); } .override-card__expires { diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/staleness-config.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/staleness-config.component.ts index e1656e1e8..dd1bdb58d 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/staleness-config.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/staleness-config.component.ts @@ -286,10 +286,10 @@ import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope'; border-radius: var(--radius-sm); } - .level--fresh { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .level--aging { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } + .level--fresh { background: var(--color-status-success-text); color: #fff; } + .level--aging { background: var(--color-status-warning-text); color: #fff; } .level--stale { background: var(--color-severity-high); color: var(--color-severity-high-bg); } - .level--expired { background: var(--color-status-error-text); color: var(--color-status-error-border); } + .level--expired { background: var(--color-status-error-text); color: #fff; } .status-card__name { font-weight: var(--font-weight-semibold); @@ -551,10 +551,10 @@ import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope'; min-width: 60px; } - .timeline__segment--fresh { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .timeline__segment--aging { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } + .timeline__segment--fresh { background: var(--color-status-success-text); color: #fff; } + .timeline__segment--aging { background: var(--color-status-warning-text); color: #fff; } .timeline__segment--stale { background: var(--color-severity-high); color: var(--color-severity-high-bg); } - .timeline__segment--expired { background: var(--color-status-error-text); color: var(--color-status-error-border); } + .timeline__segment--expired { background: var(--color-status-error-text); color: #fff; } .timeline__label { font-size: 0.7rem; diff --git a/src/Web/StellaOps.Web/src/app/features/policy-governance/trust-weighting.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-governance/trust-weighting.component.ts index bb42e05ea..81c1a80cc 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-governance/trust-weighting.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-governance/trust-weighting.component.ts @@ -692,11 +692,11 @@ import { injectPolicyGovernanceScopeResolver } from './policy-governance-scope'; text-transform: uppercase; } - .severity-badge--critical { background: var(--color-status-error-text); color: var(--color-status-error-border); } - .severity-badge--high { background: var(--color-severity-high); color: var(--color-severity-high-bg); } - .severity-badge--medium { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } - .severity-badge--low { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .severity-badge--info { background: var(--color-status-info-text); color: var(--color-status-info-border); } + .severity-badge--critical { background: var(--color-status-error-text); color: #fff; } + .severity-badge--high { background: var(--color-severity-high); color: #fff; } + .severity-badge--medium { background: var(--color-status-warning-text); color: #fff; } + .severity-badge--low { background: var(--color-status-success-text); color: #fff; } + .severity-badge--info { background: var(--color-status-info-text); color: #fff; } .loading-state { display: flex; diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-simulation.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-simulation.component.ts index 20cea3b8d..cd030bba7 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-simulation.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/policy-simulation.component.ts @@ -14,6 +14,7 @@ import { PolicyMergePreviewComponent } from './policy-merge-preview.component'; import { SimulationHistoryComponent } from './simulation-history.component'; import { ConflictDetectionComponent } from './conflict-detection.component'; import { BatchEvaluationComponent } from './batch-evaluation.component'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; /** * Main Policy Simulation Studio component with tabbed navigation. @@ -21,6 +22,22 @@ import { BatchEvaluationComponent } from './batch-evaluation.component'; * Effective Policies, and Exceptions management. * @sprint SPRINT_20251229_021b_FE */ +const POLICY_SIM_TABS: readonly StellaPageTab[] = [ + { id: 'shadow', label: 'Shadow Mode', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' }, + { id: 'simulation', label: 'Simulation Console', icon: 'M5 3l14 9-14 9V3z' }, + { id: 'coverage', label: 'Coverage', icon: 'M18 20V10|||M12 20V4|||M6 20v-6' }, + { id: 'lint', label: 'Lint', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'audit', label: 'Audit Log', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'effective', label: 'Effective Policies', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'exceptions', label: 'Exceptions', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'promotion', label: 'Promotion Gate', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'diff', label: 'Diff Viewer', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6' }, + { id: 'merge', label: 'Merge Preview', icon: 'M6 3v12|||M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M18 9a9 9 0 0 1-9 9' }, + { id: 'history', label: 'History', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' }, + { id: 'conflicts', label: 'Conflicts', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01' }, + { id: 'batch', label: 'Batch Evaluation', icon: 'M18 20V10|||M12 20V4|||M6 20v-6' }, +]; + @Component({ selector: 'app-policy-simulation-studio', standalone: true, @@ -37,29 +54,18 @@ import { BatchEvaluationComponent } from './batch-evaluation.component'; PolicyMergePreviewComponent, SimulationHistoryComponent, ConflictDetectionComponent, - BatchEvaluationComponent + BatchEvaluationComponent, + StellaPageTabsComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, template: `
- - - - -
+ @if (activeTab() === 'shadow') { } @@ -99,7 +105,7 @@ import { BatchEvaluationComponent } from './batch-evaluation.component'; @if (activeTab() === 'batch') { } -
+
`, styles: [ @@ -107,10 +113,8 @@ import { BatchEvaluationComponent } from './batch-evaluation.component'; :host { display: block; min-height: 100vh; - background: radial-gradient(circle at 20% 20%, rgba(59, 130, 246, 0.08), transparent 25%), - radial-gradient(circle at 80% 0%, rgba(16, 185, 129, 0.08), transparent 22%), - var(--color-surface-inverse); - color: var(--color-border-primary); + background: var(--color-surface-primary); + color: var(--color-text-primary); } .simulation-studio { @@ -119,151 +123,11 @@ import { BatchEvaluationComponent } from './batch-evaluation.component'; min-height: 100vh; } - .studio-nav { - display: flex; - flex-wrap: wrap; - gap: 0.25rem; - padding: 1rem; - background: var(--color-text-heading); - border-bottom: 1px solid var(--color-text-heading); - position: sticky; - top: 0; - z-index: 10; - } - - .nav-tab { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.6rem 1rem; - background: transparent; - border: 1px solid transparent; - border-radius: var(--radius-lg); - color: var(--color-text-muted); - font-size: 0.9rem; - font-weight: var(--font-weight-medium); - cursor: pointer; - transition: all 150ms ease; - } - - .nav-tab:hover { - background: var(--color-text-primary); - color: rgba(212, 201, 168, 0.3); - } - - .nav-tab--active { - background: linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(139, 92, 246, 0.15)); - border-color: var(--color-status-info); - color: var(--color-surface-secondary); - } - - .nav-tab__icon { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - } - - .nav-tab__icon svg { - width: 18px; - height: 18px; - } - - .nav-tab__label { - white-space: nowrap; - } - - .studio-content { - flex: 1; - } - - @media (max-width: 1024px) { - .studio-nav { - overflow-x: auto; - flex-wrap: nowrap; - -webkit-overflow-scrolling: touch; - } - - .nav-tab { - flex-shrink: 0; - } - } `, ], }) export class PolicySimulationStudioComponent { readonly activeTab = signal('shadow'); - readonly tabs = [ - { - id: 'shadow', - label: 'Shadow Mode', - icon: '', - }, - { - id: 'simulation', - label: 'Simulation Console', - icon: '', - }, - { - id: 'coverage', - label: 'Coverage', - icon: '', - }, - { - id: 'lint', - label: 'Lint', - icon: '', - }, - { - id: 'audit', - label: 'Audit Log', - icon: '', - }, - { - id: 'effective', - label: 'Effective Policies', - icon: '', - }, - { - id: 'exceptions', - label: 'Exceptions', - icon: '', - }, - { - id: 'promotion', - label: 'Promotion Gate', - icon: '', - }, - { - id: 'diff', - label: 'Diff Viewer', - icon: '', - }, - { - id: 'merge', - label: 'Merge Preview', - icon: '', - }, - { - id: 'history', - label: 'History', - icon: '', - }, - { - id: 'conflicts', - label: 'Conflicts', - icon: '', - }, - { - id: 'batch', - label: 'Batch Evaluation', - icon: '', - }, - ]; - - setActiveTab(tabId: string): void { - this.activeTab.set(tabId); - } + readonly POLICY_SIM_TABS = POLICY_SIM_TABS; } diff --git a/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts index 415cde443..bc865c315 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-simulation/simulation-dashboard.component.ts @@ -4,6 +4,7 @@ import { RouterModule, Router } from '@angular/router'; import { ShadowModeIndicatorComponent } from './shadow-mode-indicator.component'; import { ShadowModeStateService } from './shadow-mode-state.service'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; /** * Main Policy Simulation Studio dashboard component with tabbed navigation. @@ -14,9 +15,21 @@ import { ShadowModeStateService } from './shadow-mode-state.service'; * * @sprint SPRINT_20251229_021b_FE */ +const SIMULATION_TABS: readonly StellaPageTab[] = [ + { id: 'shadow', label: 'Shadow Mode', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' }, + { id: 'console', label: 'Simulation Console', icon: 'M5 3l14 9-14 9V3z' }, + { id: 'lint', label: 'Lint', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'coverage', label: 'Coverage', icon: 'M18 20V10|||M12 20V4|||M6 20v-6', badge: '72%', status: 'warn', statusHint: '72% coverage (80%+ required)' }, + { id: 'effective', label: 'Effective Policies', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'audit', label: 'Audit Log', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'exceptions', label: 'Exceptions', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', badge: 3 }, + { id: 'promotion', label: 'Promotion Gate', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'merge', label: 'Merge Preview', icon: 'M6 3v12|||M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M18 9a9 9 0 0 1-9 9' }, +]; + @Component({ selector: 'app-simulation-dashboard', - imports: [RouterModule, ShadowModeIndicatorComponent], + imports: [RouterModule, ShadowModeIndicatorComponent, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -152,30 +165,14 @@ import { ShadowModeStateService } from './shadow-mode-state.service'; } - - -
+ -
+
`, styles: [` @@ -302,105 +299,6 @@ import { ShadowModeStateService } from './shadow-mode-state.service'; cursor: not-allowed; } - .simulation__tabs { - display: flex; - gap: 0.25rem; - border-bottom: 1px solid var(--color-text-heading); - margin-bottom: 1.5rem; - overflow-x: auto; - scrollbar-width: thin; - scrollbar-color: var(--color-text-primary) transparent; - } - - .simulation__tabs::-webkit-scrollbar { - height: 4px; - } - - .simulation__tabs::-webkit-scrollbar-track { - background: transparent; - } - - .simulation__tabs::-webkit-scrollbar-thumb { - background: var(--color-text-primary); - border-radius: var(--radius-sm); - } - - .simulation__tab { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1rem; - color: var(--color-text-muted); - text-decoration: none; - font-size: 0.9rem; - font-weight: var(--font-weight-medium); - border-bottom: 2px solid transparent; - transition: all 0.15s ease; - white-space: nowrap; - } - - .simulation__tab:hover { - color: var(--color-border-primary); - background: rgba(139, 92, 246, 0.05); - } - - .simulation__tab--active { - color: var(--color-status-excepted-border); - border-bottom-color: var(--color-status-excepted); - } - - .simulation__tab-icon { - display: flex; - align-items: center; - justify-content: center; - width: 18px; - height: 18px; - } - - .simulation__tab-icon :deep(svg) { - width: 18px; - height: 18px; - } - - .simulation__tab-label { - font-weight: var(--font-weight-medium); - } - - .simulation__tab-badge { - padding: 0.125rem 0.4rem; - font-size: 0.7rem; - font-weight: var(--font-weight-semibold); - border-radius: var(--radius-full); - background: var(--color-text-primary); - color: var(--color-border-primary); - } - - .simulation__tab-badge--warning { - background: var(--color-status-warning-text); - color: var(--color-status-warning-border); - } - - .simulation__tab-badge--error { - background: var(--color-status-error-text); - color: var(--color-status-error-border); - } - - .simulation__tab-badge--info { - background: var(--color-status-info-text); - color: var(--color-status-info-border); - } - - .simulation__tab-badge--success { - background: var(--color-status-success-text); - color: var(--color-status-success-border); - } - - .simulation__content { - background: var(--color-text-heading); - border: 1px solid var(--color-text-heading); - border-radius: var(--radius-xl); - min-height: 400px; - } @media (max-width: 1024px) { .checklist { @@ -434,89 +332,19 @@ export class SimulationDashboardComponent implements OnInit { stakeholderApproval: false, }); - protected readonly tabs = [ - { - id: 'shadow', - label: 'Shadow Mode', - route: './shadow', - exact: false, - icon: ``, - badge: null, - badgeType: null, - }, - { - id: 'console', - label: 'Simulation Console', - route: './console', - exact: false, - icon: ``, - badge: null, - badgeType: null, - }, - { - id: 'lint', - label: 'Lint', - route: './lint', - exact: false, - icon: ``, - badge: null, - badgeType: null, - }, - { - id: 'coverage', - label: 'Coverage', - route: './coverage', - exact: false, - icon: ``, - badge: '72%', - badgeType: 'warning', - }, - { - id: 'effective', - label: 'Effective Policies', - route: './effective', - exact: false, - icon: ``, - badge: null, - badgeType: null, - }, - { - id: 'audit', - label: 'Audit Log', - route: './audit', - exact: false, - icon: ``, - badge: null, - badgeType: null, - }, - { - id: 'exceptions', - label: 'Exceptions', - route: './exceptions', - exact: false, - icon: ``, - badge: '3', - badgeType: 'info', - }, - { - id: 'promotion', - label: 'Promotion Gate', - route: './promotion', - exact: false, - icon: ``, - badge: null, - badgeType: null, - }, - { - id: 'merge', - label: 'Merge Preview', - route: './merge', - exact: false, - icon: ``, - badge: null, - badgeType: null, - }, - ]; + private static readonly TAB_ROUTES: Record = { + shadow: './shadow', + console: './console', + lint: './lint', + coverage: './coverage', + effective: './effective', + audit: './audit', + exceptions: './exceptions', + promotion: './promotion', + merge: './merge', + }; + + protected readonly SIMULATION_TABS: readonly StellaPageTab[] = SIMULATION_TABS; ngOnInit(): void { this.loadShadowStatus(); @@ -526,6 +354,14 @@ export class SimulationDashboardComponent implements OnInit { this.activeTab.set(tabId); } + protected onTabChange(tabId: string): void { + this.activeTab.set(tabId); + const route = SimulationDashboardComponent.TAB_ROUTES[tabId]; + if (route) { + this.router.navigate([route], { relativeTo: undefined }); + } + } + protected shadowDaysRemaining(): number { const config = this.shadowConfig(); if (!config?.enabled || !config.enabledAt) return 7; diff --git a/src/Web/StellaOps.Web/src/app/features/policy-studio/ai/version-history.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-studio/ai/version-history.component.ts index 611f79998..f267ba069 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-studio/ai/version-history.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-studio/ai/version-history.component.ts @@ -1,7 +1,9 @@ -import { Component, EventEmitter, Input, Output, signal, computed } from '@angular/core'; +import { Component, EventEmitter, Input, Output, signal, computed, + inject,} from '@angular/core'; import type { PolicyVersion, PolicyPackStatus } from '../models/policy.models'; +import { DateFormatService } from '../../../core/i18n/date-format.service'; /** * Version history component for policy packs. * @@ -559,6 +561,8 @@ import type { PolicyVersion, PolicyPackStatus } from '../models/policy.models'; `] }) export class VersionHistoryComponent { + private readonly dateFmt = inject(DateFormatService); + @Input() versions: PolicyVersion[] = []; @Output() readonly restore = new EventEmitter(); @@ -605,7 +609,7 @@ export class VersionHistoryComponent { formatDate(iso: string): string { try { - return new Date(iso).toLocaleDateString('en-US', { + return new Date(iso).toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/policy-studio/nl-input/policy-nl-input.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-studio/nl-input/policy-nl-input.component.ts index 6d7d38b8a..c79c06b80 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-studio/nl-input/policy-nl-input.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-studio/nl-input/policy-nl-input.component.ts @@ -440,7 +440,7 @@ import type { PolicyParseResult, PolicyIntent } from '../../../core/api/advisory } .intent-badge.type-ScopeRestriction { - color: var(--color-brand-primary); + color: var(--color-text-link); background: var(--color-status-excepted-bg); } diff --git a/src/Web/StellaOps.Web/src/app/features/policy-studio/workspace/policy-workspace.component.ts b/src/Web/StellaOps.Web/src/app/features/policy-studio/workspace/policy-workspace.component.ts index 84806566d..9050c4117 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy-studio/workspace/policy-workspace.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy-studio/workspace/policy-workspace.component.ts @@ -140,7 +140,7 @@ import { PolicyPackStore } from '../services/policy-pack.store'; .workspace__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; } .pack-card { background: var(--color-surface-elevated); border: 1px solid var(--color-border-primary); border-radius: var(--radius-xl); padding: 1rem; box-shadow: var(--shadow-md); display: grid; gap: 0.6rem; } .pack-card__head { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; } - .pack-card__eyebrow { margin: 0; color: var(--color-brand-secondary); font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; } + .pack-card__eyebrow { margin: 0; color: var(--color-text-link); font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; } .pack-card__desc { margin: 0.2rem 0 0; color: var(--color-text-muted); } .pack-card__meta { display: grid; justify-items: end; gap: 0.2rem; color: var(--color-text-muted); font-size: 0.9rem; } .pack-card__tags { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; } @@ -158,8 +158,8 @@ import { PolicyPackStore } from '../services/policy-pack.store'; .workspace__empty { grid-column: 1 / -1; text-align: center; padding: 3rem 1.5rem; border: 1px dashed var(--color-border-primary); border-radius: var(--radius-xl); background: var(--color-surface-elevated); } .workspace__empty h3 { margin: 0 0 0.5rem; color: var(--color-text-heading); } .workspace__empty p { margin: 0 0 1rem; color: var(--color-text-muted); max-width: 480px; margin-inline: auto; } - .workspace__empty-link { display: inline-block; color: var(--color-brand-primary); border: 1px solid var(--color-brand-primary); border-radius: var(--radius-lg); padding: 0.45rem 0.8rem; text-decoration: none; } - .workspace__empty-link:hover { background: var(--color-brand-primary); color: var(--color-text-inverse); } + .workspace__empty-link { display: inline-block; color: var(--color-text-link); border: 1px solid var(--color-brand-primary); border-radius: var(--radius-lg); padding: 0.45rem 0.8rem; text-decoration: none; } + .workspace__empty-link:hover { background: var(--color-btn-primary-bg); color: var(--color-text-inverse); } .workspace__create-form { display: grid; gap: 0.75rem; max-width: 400px; margin: 1.5rem auto 0; text-align: left; } .workspace__create-form h4 { margin: 0; text-align: center; color: var(--color-text-heading); } .workspace__create-form label { display: grid; gap: 0.25rem; font-size: 0.85rem; color: var(--color-text-muted); } diff --git a/src/Web/StellaOps.Web/src/app/features/policy/components/verdict-proof-panel/verdict-proof-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/policy/components/verdict-proof-panel/verdict-proof-panel.component.ts index 4623b58cf..ce115cd92 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy/components/verdict-proof-panel/verdict-proof-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy/components/verdict-proof-panel/verdict-proof-panel.component.ts @@ -13,18 +13,18 @@ import { VerifyVerdictResponse, Evidence, } from '../../../../core/api/verdict.models'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; @Component({ selector: 'app-verdict-proof-panel', standalone: true, - imports: [CommonModule], + imports: [CommonModule, LoadingStateComponent], template: `
@if (loading()) {
-
- Loading verdict... +
} @@ -323,7 +323,7 @@ import { width: 12px; height: 12px; border-radius: var(--radius-full); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border: 2px solid white; box-shadow: 0 0 0 2px var(--color-brand-primary); } diff --git a/src/Web/StellaOps.Web/src/app/features/policy/policy-studio.component.ts b/src/Web/StellaOps.Web/src/app/features/policy/policy-studio.component.ts index 878c18251..91762f576 100644 --- a/src/Web/StellaOps.Web/src/app/features/policy/policy-studio.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/policy/policy-studio.component.ts @@ -1,4 +1,5 @@ import { CommonModule } from '@angular/common'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; import { ChangeDetectionStrategy, Component, @@ -16,6 +17,8 @@ import { AuthSessionStore } from '../../core/auth/auth-session.store'; import { hasScope, hasAnyScope, PolicyScope } from '../../core/policy/policy.guard'; import { PolicyQuotaService } from '../../core/policy/policy-quota.service'; import { PolicyStudioMetricsService } from '../../core/policy/policy-studio-metrics.service'; +import { DateFormatService } from '../../core/i18n/date-format.service'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; import { RiskProfileSummary, PolicyPackSummary, @@ -30,9 +33,16 @@ type ViewMode = 'profiles' | 'packs' | 'simulation' | 'decisions'; type SortField = 'profileId' | 'version' | 'status' | 'createdAt'; type SortOrder = 'asc' | 'desc'; +const POLICY_STUDIO_TABS: readonly StellaPageTab[] = [ + { id: 'profiles', label: 'Risk Profiles', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'packs', label: 'Policy Packs', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' }, + { id: 'simulation', label: 'Simulation', icon: 'M5 3l14 9-14 9V3z' }, + { id: 'decisions', label: 'Decisions', icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M22 4L12 14.01l-3-3' }, +]; + @Component({ selector: 'app-policy-studio', - imports: [CommonModule, FormsModule, RouterModule], + imports: [CommonModule, FormsModule, RouterModule, LoadingStateComponent, StellaPageTabsComponent], template: `
@@ -43,55 +53,16 @@ type SortOrder = 'asc' | 'desc';
- + @if (store.loading()) { -
- - Loading... -
+ } @@ -178,7 +149,7 @@ type SortOrder = 'asc' | 'desc';
} @else {
-
Name
+
@@ -273,7 +244,7 @@ type SortOrder = 'asc' | 'desc'; } @else {
- +
@@ -414,7 +385,7 @@ type SortOrder = 'asc' | 'desc'; @if (store.currentSimulation()!.findingScores.length > 0) {

Finding Scores

-
Pack ID
+
@@ -482,7 +453,7 @@ type SortOrder = 'asc' | 'desc';

Decisions for {{ store.currentDecisions()!.snapshotId }}

-
Finding ID
+
@@ -513,6 +484,7 @@ type SortOrder = 'asc' | 'desc'; } } + `, styles: [` @@ -538,35 +510,6 @@ type SortOrder = 'asc' | 'desc'; color: var(--color-text-secondary); } - .policy-studio__tabs { - display: flex; - gap: 0.25rem; - margin-bottom: 1.5rem; - border-bottom: 1px solid rgba(212, 201, 168, 0.3); - } - - .policy-studio__tab { - padding: 0.75rem 1.25rem; - border: none; - background: transparent; - font-size: 0.875rem; - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - cursor: pointer; - border-bottom: 2px solid transparent; - margin-bottom: -1px; - transition: color 0.15s, border-color 0.15s; - - &:hover { - color: var(--color-text-secondary); - } - - &--active { - color: var(--color-brand-primary); - border-bottom-color: var(--color-brand-primary); - } - } - .policy-studio__loading { display: flex; align-items: center; @@ -649,32 +592,15 @@ type SortOrder = 'asc' | 'desc'; .policy-studio__table { width: 100%; - border-collapse: collapse; font-size: 0.875rem; - th, td { - padding: 0.75rem; - text-align: left; - border-bottom: 1px solid rgba(212, 201, 168, 0.3); - } - - th { - font-weight: var(--font-weight-semibold); - color: var(--color-text-secondary); - background: var(--color-surface-secondary); - } - - td { - color: var(--color-text-primary); - } - tbody tr:hover { background: var(--color-surface-secondary); } } .policy-studio__link { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-weight: var(--font-weight-medium); @@ -719,12 +645,12 @@ type SortOrder = 'asc' | 'desc'; } &--primary { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border-color: var(--color-brand-primary); color: var(--color-text-heading); &:hover:not(:disabled) { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border-color: var(--color-brand-primary); } } @@ -942,6 +868,8 @@ type SortOrder = 'asc' | 'desc'; changeDetection: ChangeDetectionStrategy.OnPush }) export class PolicyStudioComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + readonly store = inject(PolicyEngineStore); readonly quotaService = inject(PolicyQuotaService); readonly metricsService = inject(PolicyStudioMetricsService); @@ -950,6 +878,7 @@ export class PolicyStudioComponent implements OnInit { private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); + readonly policyTabs = POLICY_STUDIO_TABS; readonly viewMode = signal('profiles'); readonly selectedProfileId = signal(''); readonly simulationMode = signal<'quick' | 'full' | 'whatIf'>('quick'); @@ -1209,7 +1138,7 @@ export class PolicyStudioComponent implements OnInit { } formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString('en-US', { + return new Date(dateStr).toLocaleDateString(this.dateFmt.locale(), { year: 'numeric', month: 'short', day: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/promotions/create-promotion.component.ts b/src/Web/StellaOps.Web/src/app/features/promotions/create-promotion.component.ts index 8665421dd..a61bdd9ee 100644 --- a/src/Web/StellaOps.Web/src/app/features/promotions/create-promotion.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/promotions/create-promotion.component.ts @@ -508,7 +508,7 @@ type Step = 1 | 2 | 3 | 4 | 5 | 6; } .btn-primary { - background: var(--color-brand-primary, #4f46e5); + background: var(--color-btn-primary-bg); color: #fff; } diff --git a/src/Web/StellaOps.Web/src/app/features/promotions/promotion-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/promotions/promotion-detail.component.ts index 943f99915..43abbf17b 100644 --- a/src/Web/StellaOps.Web/src/app/features/promotions/promotion-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/promotions/promotion-detail.component.ts @@ -15,8 +15,8 @@ import { APPROVAL_API } from '../../core/api/approval.client'; import type { ApprovalDetail, GateStatus } from '../../core/api/approval.models'; import { AuditorOnlyDirective } from '../../shared/directives/auditor-only.directive'; import { OperatorOnlyDirective } from '../../shared/directives/operator-only.directive'; -import { ViewModeToggleComponent } from '../../shared/components/view-mode-toggle/view-mode-toggle.component'; - +import { DateFormatService } from '../../core/i18n/date-format.service'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type DetailTab = | 'overview' | 'gates' @@ -27,10 +27,21 @@ type DetailTab = | 'replay' | 'history'; +const PROMOTION_TABS: readonly StellaPageTab[] = [ + { id: 'overview', label: 'Overview', icon: 'M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z|||M9 22V12h6v10' }, + { id: 'gates', label: 'Gates', icon: 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M22 4L12 14.01l-3-3' }, + { id: 'security', label: 'Security', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'reachability', label: 'Reachability', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, + { id: 'ops-data', label: 'Ops/Data', icon: 'M12 2C6.48 2 2 4.02 2 6.5v11c0 2.49 4.48 4.5 10 4.5s10-2.01 10-4.5v-11C22 4.02 17.52 2 12 2z|||M2 6.5c0 2.49 4.48 4.5 10 4.5s10-2.01 10-4.5|||M2 12c0 2.49 4.48 4.5 10 4.5s10-2.01 10-4.5' }, + { id: 'evidence', label: 'Evidence', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'replay', label: 'Replay/Verify', icon: 'M5 3l14 9-14 9V3z' }, + { id: 'history', label: 'History', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' }, +]; + @Component({ selector: 'app-promotion-detail', standalone: true, - imports: [CommonModule, FormsModule, RouterLink, AuditorOnlyDirective, OperatorOnlyDirective, ViewModeToggleComponent], + imports: [CommonModule, FormsModule, RouterLink, AuditorOnlyDirective, OperatorOnlyDirective, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -58,7 +69,6 @@ type DetailTab =

- {{ promotion()!.status }} @@ -84,19 +94,12 @@ type DetailTab = -
- @for (tab of tabs; track tab.id) { - - } -
- + @switch (activeTab()) { @case ('overview') {
@@ -244,6 +247,7 @@ type DetailTab =
} } +
} @else {
Promotion detail not found.
} @@ -320,34 +324,7 @@ type DetailTab = font-size: 0.84rem; } - .promotion-detail__tabs { - display: flex; - flex-wrap: wrap; - border-bottom: 2px solid var(--color-border, #e5e7eb); - margin-bottom: 1rem; - } - - .promotion-detail__tabs button { - padding: 0.45rem 0.85rem; - border: none; - background: transparent; - font-size: 0.82rem; - cursor: pointer; - color: var(--color-text-secondary, #666); - border-bottom: 2px solid transparent; - margin-bottom: -2px; - } - - .tab--active { - color: var(--color-brand-primary, #4f46e5) !important; - border-bottom-color: var(--color-brand-primary, #4f46e5) !important; - font-weight: 600; - } - .panel { - border: 1px solid var(--color-border, #e5e7eb); - border-radius: var(--radius-md, 8px); - padding: 0.9rem 1rem; display: grid; gap: 0.7rem; } @@ -562,6 +539,8 @@ type DetailTab = ], }) export class PromotionDetailComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + private readonly api = inject(APPROVAL_API); private readonly route = inject(ActivatedRoute); @@ -573,16 +552,7 @@ export class PromotionDetailComponent implements OnInit { readonly activeTab = signal('overview'); readonly decisionComment = signal(''); - readonly tabs: ReadonlyArray<{ id: DetailTab; label: string }> = [ - { id: 'overview', label: 'Overview' }, - { id: 'gates', label: 'Gates' }, - { id: 'security', label: 'Security' }, - { id: 'reachability', label: 'Reachability' }, - { id: 'ops-data', label: 'Ops/Data' }, - { id: 'evidence', label: 'Evidence' }, - { id: 'replay', label: 'Replay/Verify' }, - { id: 'history', label: 'History' }, - ]; + readonly tabs = PROMOTION_TABS; readonly manifestDigest = computed(() => { return this.promotion()?.releaseComponents[0]?.digest ?? null; @@ -626,10 +596,6 @@ export class PromotionDetailComponent implements OnInit { this.loadPromotion(); } - setTab(tab: DetailTab): void { - this.activeTab.set(tab); - } - loadPromotion(): void { const id = this.promotionId(); if (!id) { @@ -704,7 +670,7 @@ export class PromotionDetailComponent implements OnInit { } formatDate(value: string): string { - return new Date(value).toLocaleString('en-US', { + return new Date(value).toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/src/Web/StellaOps.Web/src/app/features/promotions/promotions-list.component.ts b/src/Web/StellaOps.Web/src/app/features/promotions/promotions-list.component.ts index d5258865a..67e1d0828 100644 --- a/src/Web/StellaOps.Web/src/app/features/promotions/promotions-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/promotions/promotions-list.component.ts @@ -12,6 +12,7 @@ import { RouterLink } from '@angular/router'; import { catchError, forkJoin, map, of, switchMap } from 'rxjs'; import { APPROVAL_API } from '../../core/api/approval.client'; +import { DateFormatService } from '../../core/i18n/date-format.service'; import type { ApprovalDetail, ApprovalRequest, @@ -100,7 +101,19 @@ interface PromotionRow { @if (loading()) { -
Loading promotions...
+
+ @for (i of [1, 2, 3, 4]; track i) { +
+
+
+
+
+
+
+
+
+ } +
} @if (error()) { @@ -303,6 +316,42 @@ interface PromotionRow { 100% { background-position: -200% 0; } } + .skeleton-loading { + display: flex; + flex-direction: column; + gap: 0.75rem; + border: 1px solid var(--color-border-primary, #e5e7eb); + border-radius: var(--radius-lg, 8px); + background: var(--color-surface-primary); + padding: 0.75rem; + overflow: hidden; + } + + .skeleton-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + border-bottom: 1px solid var(--color-border-primary, #e5e7eb); + } + + .skeleton-row:last-child { + border-bottom: none; + } + + .skeleton-cell { + height: 0.85rem; + border-radius: var(--radius-sm, 4px); + background: linear-gradient(90deg, rgba(128,128,128,0.08) 25%, rgba(128,128,128,0.14) 50%, rgba(128,128,128,0.08) 75%); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; + } + + .skeleton-cell--wide { flex: 2; min-width: 120px; } + .skeleton-cell--md { flex: 1; min-width: 80px; } + .skeleton-cell--sm { width: 60px; flex: 0 0 auto; } + .skeleton-cell--badge { width: 72px; height: 1.25rem; border-radius: 9999px; flex: 0 0 auto; } + .state-block--error { background: #fff5f5; border-color: #fecaca; @@ -474,7 +523,7 @@ interface PromotionRow { .link-sm { font-size: 0.8125rem; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-weight: 500; } @@ -609,7 +658,7 @@ interface PromotionRow { } .empty-context a { - color: var(--color-brand-primary, #4f46e5); + color: var(--color-text-link); text-decoration: none; } @@ -630,6 +679,8 @@ interface PromotionRow { ], }) export class PromotionsListComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + private readonly api = inject(APPROVAL_API); readonly loading = signal(true); @@ -708,7 +759,7 @@ export class PromotionsListComponent implements OnInit { } formatRequestedAt(requestedAt: string): string { - return new Date(requestedAt).toLocaleDateString('en-US', { + return new Date(requestedAt).toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/src/Web/StellaOps.Web/src/app/features/proof-chain/components/proof-detail-panel.component.html b/src/Web/StellaOps.Web/src/app/features/proof-chain/components/proof-detail-panel.component.html index 6a808c1b1..245e57009 100644 --- a/src/Web/StellaOps.Web/src/app/features/proof-chain/components/proof-detail-panel.component.html +++ b/src/Web/StellaOps.Web/src/app/features/proof-chain/components/proof-detail-panel.component.html @@ -8,10 +8,7 @@
@if (loading()) { -
-
-

Loading proof details...

-
+ } @if (error(); as errorMessage) { diff --git a/src/Web/StellaOps.Web/src/app/features/proof-chain/components/proof-detail-panel.component.scss b/src/Web/StellaOps.Web/src/app/features/proof-chain/components/proof-detail-panel.component.scss index 0c645a126..24dea0e55 100644 --- a/src/Web/StellaOps.Web/src/app/features/proof-chain/components/proof-detail-panel.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/proof-chain/components/proof-detail-panel.component.scss @@ -205,7 +205,7 @@ } a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; word-break: break-all; @@ -329,21 +329,23 @@ } .btn-primary { - background: var(--color-brand-primary); - color: var(--color-text-inverse); + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); + border: 1px solid var(--color-btn-primary-border, transparent); &:hover:not(:disabled) { - background: var(--color-brand-primary-hover); + background: var(--color-btn-primary-bg-hover); transform: translateY(-1px); } } .btn-secondary { - background: var(--color-text-muted); - color: var(--color-text-inverse); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); + border: 1px solid var(--color-btn-secondary-border); &:hover:not(:disabled) { - background: var(--color-text-secondary); + background: var(--color-btn-secondary-hover-bg); transform: translateY(-1px); } } diff --git a/src/Web/StellaOps.Web/src/app/features/proof-chain/components/proof-detail-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/proof-chain/components/proof-detail-panel.component.ts index 142fd5280..89aadb442 100644 --- a/src/Web/StellaOps.Web/src/app/features/proof-chain/components/proof-detail-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/proof-chain/components/proof-detail-panel.component.ts @@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common'; import { ProofChainService } from '../proof-chain.service'; import { ProofDetail, ProofVerificationResult } from '../proof-chain.models'; import { VerificationBadgeComponent } from './verification-badge.component'; +import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component'; /** * Proof Detail Panel Component @@ -35,7 +36,7 @@ import { VerificationBadgeComponent } from './verification-badge.component'; @Component({ selector: 'stella-proof-detail-panel', standalone: true, - imports: [CommonModule, VerificationBadgeComponent], + imports: [CommonModule, VerificationBadgeComponent, LoadingStateComponent], templateUrl: './proof-detail-panel.component.html', styleUrls: ['./proof-detail-panel.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/Web/StellaOps.Web/src/app/features/proof-chain/proof-chain.component.html b/src/Web/StellaOps.Web/src/app/features/proof-chain/proof-chain.component.html index 3730cb557..8b20c3679 100644 --- a/src/Web/StellaOps.Web/src/app/features/proof-chain/proof-chain.component.html +++ b/src/Web/StellaOps.Web/src/app/features/proof-chain/proof-chain.component.html @@ -32,10 +32,7 @@ } @if (loading()) { -
-
-

Loading proof chain...

-
+ } @if (error(); as errorMessage) { @@ -50,25 +47,28 @@
-
-
- Total Proofs - {{ nodeCount() }} + + + + + + @if (proofChain()?.summary?.hasRekorAnchoring) { +
+ Rekor Anchored
-
- Verified - {{ proofChain()?.summary?.verifiedCount }} -
-
- Unverified - {{ proofChain()?.summary?.unverifiedCount }} -
- @if (proofChain()?.summary?.hasRekorAnchoring) { -
- Rekor Anchored -
- } -
+ }
diff --git a/src/Web/StellaOps.Web/src/app/features/proof-chain/proof-chain.component.scss b/src/Web/StellaOps.Web/src/app/features/proof-chain/proof-chain.component.scss index 3bff6c4a0..baf063fc2 100644 --- a/src/Web/StellaOps.Web/src/app/features/proof-chain/proof-chain.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/proof-chain/proof-chain.component.scss @@ -391,7 +391,7 @@ user-select: none; &:hover { - color: var(--color-brand-primary); + color: var(--color-text-link); } } @@ -440,19 +440,21 @@ } .btn-primary { - background: var(--color-brand-primary); - color: var(--color-text-inverse); + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); + border: 1px solid var(--color-btn-primary-border, transparent); &:hover:not(:disabled) { - background: var(--color-brand-primary-hover); + background: var(--color-btn-primary-bg-hover); } } .btn-secondary { - background: var(--color-text-muted); - color: var(--color-text-inverse); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); + border: 1px solid var(--color-btn-secondary-border); &:hover:not(:disabled) { - background: var(--color-text-secondary); + background: var(--color-btn-secondary-hover-bg); } } diff --git a/src/Web/StellaOps.Web/src/app/features/proof-chain/proof-chain.component.ts b/src/Web/StellaOps.Web/src/app/features/proof-chain/proof-chain.component.ts index ec6e05c6b..7fb258311 100644 --- a/src/Web/StellaOps.Web/src/app/features/proof-chain/proof-chain.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/proof-chain/proof-chain.component.ts @@ -19,6 +19,9 @@ import { FormsModule } from '@angular/forms'; import { ActivatedRoute } from '@angular/router'; import { ProofChainService } from './proof-chain.service'; import { ProofNode, ProofChainResponse } from './proof-chain.models'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; +import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component'; +import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component'; /** * Proof Chain Component @@ -46,7 +49,7 @@ import { ProofNode, ProofChainResponse } from './proof-chain.models'; */ @Component({ selector: 'stella-proof-chain', - imports: [CommonModule, FormsModule], + imports: [CommonModule, FormsModule, LoadingStateComponent, StellaMetricCardComponent, StellaMetricGridComponent], templateUrl: './proof-chain.component.html', styleUrls: ['./proof-chain.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/Web/StellaOps.Web/src/app/features/proof-studio/components/proof-studio-container/proof-studio-container.component.html b/src/Web/StellaOps.Web/src/app/features/proof-studio/components/proof-studio-container/proof-studio-container.component.html index dba087660..c6ff50c8f 100644 --- a/src/Web/StellaOps.Web/src/app/features/proof-studio/components/proof-studio-container/proof-studio-container.component.html +++ b/src/Web/StellaOps.Web/src/app/features/proof-studio/components/proof-studio-container/proof-studio-container.component.html @@ -44,10 +44,7 @@ @if (loading()) { -
-
-

Loading proof trace...

-
+ } diff --git a/src/Web/StellaOps.Web/src/app/features/proof-studio/components/proof-studio-container/proof-studio-container.component.scss b/src/Web/StellaOps.Web/src/app/features/proof-studio/components/proof-studio-container/proof-studio-container.component.scss index 491055d21..dcf7a4129 100644 --- a/src/Web/StellaOps.Web/src/app/features/proof-studio/components/proof-studio-container/proof-studio-container.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/proof-studio/components/proof-studio-container/proof-studio-container.component.scss @@ -238,7 +238,7 @@ &.active { background: var(--color-surface-primary); border-bottom-color: var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); } &:focus-visible { @@ -365,7 +365,7 @@ color: var(--color-text-muted); strong { - color: var(--color-brand-primary); + color: var(--color-text-link); font-weight: var(--font-weight-semibold); } } diff --git a/src/Web/StellaOps.Web/src/app/features/proof-studio/components/proof-studio-container/proof-studio-container.component.ts b/src/Web/StellaOps.Web/src/app/features/proof-studio/components/proof-studio-container/proof-studio-container.component.ts index ace905a6d..56cf10f09 100644 --- a/src/Web/StellaOps.Web/src/app/features/proof-studio/components/proof-studio-container/proof-studio-container.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/proof-studio/components/proof-studio-container/proof-studio-container.component.ts @@ -14,6 +14,7 @@ import { WhatIfSliderComponent } from '../what-if-slider/what-if-slider.componen import { CgsBadgeComponent } from '../../../lineage/components/cgs-badge/cgs-badge.component'; import { ProofStudioService } from '../../services/proof-studio.service'; import { ProofTrace, FindingKey } from '../../models/proof-trace.model'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; @Component({ selector: 'app-proof-studio-container', @@ -21,7 +22,8 @@ import { ProofTrace, FindingKey } from '../../models/proof-trace.model'; imports: [ ConfidenceBreakdownComponent, WhatIfSliderComponent, - CgsBadgeComponent + CgsBadgeComponent, + LoadingStateComponent ], templateUrl: './proof-studio-container.component.html', styleUrl: './proof-studio-container.component.scss', diff --git a/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.scss b/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.scss index 918ce9a8c..0d4e6fdc8 100644 --- a/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.scss @@ -372,7 +372,7 @@ display: inline-flex; align-items: center; gap: var(--space-1); - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-weight: var(--font-weight-medium); diff --git a/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.ts b/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.ts index 7fbfc8839..6c1a82fa4 100644 --- a/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/proof/proof-ledger-view.component.ts @@ -24,6 +24,7 @@ import { } from '../../core/api/proof.models'; import { MANIFEST_API, PROOF_BUNDLE_API } from '../../core/api/proof.client'; +import { DateFormatService } from '../../core/i18n/date-format.service'; @Component({ selector: 'app-proof-ledger-view', standalone: true, @@ -33,6 +34,8 @@ import { MANIFEST_API, PROOF_BUNDLE_API } from '../../core/api/proof.client'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProofLedgerViewComponent { + private readonly dateFmt = inject(DateFormatService); + /** Scan ID to display proof ledger for */ readonly scanId = input.required(); @@ -195,7 +198,7 @@ export class ProofLedgerViewComponent { if (!dateStr) return 'N/A'; try { const date = new Date(dateStr); - return date.toLocaleString('en-US', { + return date.toLocaleString(this.dateFmt.locale(), { year: 'numeric', month: 'short', day: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/proof/proof-replay-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/proof/proof-replay-dashboard.component.ts index 08a209532..ca6a65a3f 100644 --- a/src/Web/StellaOps.Web/src/app/features/proof/proof-replay-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/proof/proof-replay-dashboard.component.ts @@ -22,6 +22,7 @@ import { } from '../../core/api/proof.models'; import { ScoreComparisonViewComponent } from './score-comparison-view.component'; +import { DateFormatService } from '../../core/i18n/date-format.service'; @Component({ selector: 'app-proof-replay-dashboard', standalone: true, @@ -210,7 +211,7 @@ import { ScoreComparisonViewComponent } from './score-comparison-view.component' align-items: center; gap: 0.5rem; padding: 0.75rem 1.5rem; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); border: none; border-radius: var(--radius-lg); @@ -407,6 +408,8 @@ import { ScoreComparisonViewComponent } from './score-comparison-view.component' changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProofReplayDashboardComponent { + private readonly dateFmt = inject(DateFormatService); + /** Scan ID for replay */ readonly scanId = input.required(); @@ -467,7 +470,7 @@ export class ProofReplayDashboardComponent { formatDate(dateStr: string): string { if (!dateStr) return 'N/A'; try { - return new Date(dateStr).toLocaleString('en-US', { + return new Date(dateStr).toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/src/Web/StellaOps.Web/src/app/features/proof/score-comparison-view.component.ts b/src/Web/StellaOps.Web/src/app/features/proof/score-comparison-view.component.ts index a3e721444..9229149e3 100644 --- a/src/Web/StellaOps.Web/src/app/features/proof/score-comparison-view.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/proof/score-comparison-view.component.ts @@ -11,15 +11,23 @@ import { input, output, signal, + inject, } from '@angular/core'; import { ScoreBreakdown, ScoreComponent, ScoreDrift } from '../../core/api/proof.models'; +import { DateFormatService } from '../../core/i18n/date-format.service'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type ViewMode = 'side-by-side' | 'time-series'; +const SCORE_COMPARISON_TABS: readonly StellaPageTab[] = [ + { id: 'side-by-side', label: 'Side by Side', icon: 'M18 20V10|||M12 20V4|||M6 20v-6' }, + { id: 'time-series', label: 'Time Series', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, +]; + @Component({ selector: 'app-score-comparison-view', standalone: true, - imports: [CommonModule], + imports: [CommonModule, StellaPageTabsComponent], template: `
@@ -28,30 +36,14 @@ type ViewMode = 'side-by-side' | 'time-series'; Score Comparison -
- - -
+ @if (viewMode() === 'side-by-side') {
@@ -192,6 +184,7 @@ type ViewMode = 'side-by-side' | 'time-series';
Component
} + `, styles: [` @@ -208,7 +201,7 @@ type ViewMode = 'side-by-side' | 'time-series'; display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1.5rem; + margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--color-border); } @@ -221,29 +214,6 @@ type ViewMode = 'side-by-side' | 'time-series'; font-size: 1.25rem; } - .view-toggle { - display: flex; - gap: 0.25rem; - background: var(--color-bg-subtle); - padding: 0.25rem; - border-radius: var(--radius-lg); - } - - .view-btn { - padding: 0.5rem 1rem; - border: none; - background: transparent; - border-radius: var(--radius-md); - cursor: pointer; - font-size: 0.875rem; - transition: all 0.15s; - } - - .view-btn--active { - background: var(--color-surface-primary); - box-shadow: 0 1px 3px rgba(0,0,0,0.1); - } - .comparison-grid { display: grid; grid-template-columns: 1fr auto 1fr; @@ -422,6 +392,8 @@ type ViewMode = 'side-by-side' | 'time-series'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ScoreComparisonViewComponent { + private readonly dateFmt = inject(DateFormatService); + /** Original score data */ readonly originalScore = input.required(); @@ -437,6 +409,9 @@ export class ScoreComparisonViewComponent { /** Emits when user wants to drill into a component */ readonly componentClicked = output(); + // Tabs + readonly scoreTabs = SCORE_COMPARISON_TABS; + // State readonly viewMode = signal('side-by-side'); @@ -474,7 +449,7 @@ export class ScoreComparisonViewComponent { formatDate(dateStr: string): string { if (!dateStr) return 'N/A'; try { - return new Date(dateStr).toLocaleDateString('en-US', { + return new Date(dateStr).toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/src/Web/StellaOps.Web/src/app/features/proofs/proof-replay-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/proofs/proof-replay-dashboard.component.ts index 204fcb3a3..c337ca120 100644 --- a/src/Web/StellaOps.Web/src/app/features/proofs/proof-replay-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/proofs/proof-replay-dashboard.component.ts @@ -315,7 +315,7 @@ export interface ReplayApi {

Replay History

- +
@@ -452,7 +452,7 @@ export interface ReplayApi { align-items: center; gap: 0.5rem; padding: 0.75rem 1.5rem; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; border: none; border-radius: var(--radius-sm); @@ -657,7 +657,7 @@ export interface ReplayApi { .proof-replay__phase-bar { height: 100%; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border-radius: var(--radius-md); } @@ -817,7 +817,6 @@ export interface ReplayApi { /* History */ .proof-replay__history-table { width: 100%; - border-collapse: collapse; } .proof-replay__history-table th, diff --git a/src/Web/StellaOps.Web/src/app/features/quota-dashboard/quota-alert-config.component.ts b/src/Web/StellaOps.Web/src/app/features/quota-dashboard/quota-alert-config.component.ts index 37931c7fc..47153e4aa 100644 --- a/src/Web/StellaOps.Web/src/app/features/quota-dashboard/quota-alert-config.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/quota-dashboard/quota-alert-config.component.ts @@ -305,7 +305,7 @@ import { quotasPath } from '../platform/ops/operations-paths'; } .btn-primary { - background: var(--color-primary); + background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border-color: var(--color-primary); } diff --git a/src/Web/StellaOps.Web/src/app/features/quota-dashboard/quota-forecast.component.ts b/src/Web/StellaOps.Web/src/app/features/quota-dashboard/quota-forecast.component.ts index 6bc8a520b..1324657e3 100644 --- a/src/Web/StellaOps.Web/src/app/features/quota-dashboard/quota-forecast.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/quota-dashboard/quota-forecast.component.ts @@ -255,7 +255,7 @@ import { quotasPath } from '../platform/ops/operations-paths'; } .btn-primary { - background: var(--color-primary); + background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border-color: var(--color-primary); } diff --git a/src/Web/StellaOps.Web/src/app/features/quota-dashboard/quota-report-export.component.ts b/src/Web/StellaOps.Web/src/app/features/quota-dashboard/quota-report-export.component.ts index 6363abefe..02495ad21 100644 --- a/src/Web/StellaOps.Web/src/app/features/quota-dashboard/quota-report-export.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/quota-dashboard/quota-report-export.component.ts @@ -460,7 +460,7 @@ import { quotasPath } from '../platform/ops/operations-paths'; } .btn-primary { - background: var(--color-primary); + background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border-color: var(--color-primary); } diff --git a/src/Web/StellaOps.Web/src/app/features/quota-dashboard/tenant-quota-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/quota-dashboard/tenant-quota-detail.component.ts index d91ac4478..d17c03a30 100644 --- a/src/Web/StellaOps.Web/src/app/features/quota-dashboard/tenant-quota-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/quota-dashboard/tenant-quota-detail.component.ts @@ -418,7 +418,7 @@ import { quotasPath } from '../platform/ops/operations-paths'; } .btn-primary { - background: var(--color-primary); + background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); border-color: var(--color-primary); } diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/components/path-viewer/path-viewer.component.scss b/src/Web/StellaOps.Web/src/app/features/reachability/components/path-viewer/path-viewer.component.scss index 780f6f521..53fe7f1fd 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/components/path-viewer/path-viewer.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/reachability/components/path-viewer/path-viewer.component.scss @@ -60,7 +60,7 @@ } &--expand { - color: var(--color-brand-primary); + color: var(--color-text-link); border-color: var(--color-brand-primary); &:hover { @@ -170,7 +170,7 @@ &__confidence { font-size: var(--font-size-xs); - color: var(--color-brand-primary); + color: var(--color-text-link); background: var(--color-brand-light); padding: var(--space-0-5) var(--space-1-5); border-radius: var(--radius-sm); @@ -262,7 +262,7 @@ .path-node__icon { background: rgba(139, 92, 246, 0.15); - color: var(--color-brand-secondary); + color: var(--color-text-link); } } diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/components/risk-drift-card/risk-drift-card.component.scss b/src/Web/StellaOps.Web/src/app/features/reachability/components/risk-drift-card/risk-drift-card.component.scss index 817a5e15f..24a679411 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/components/risk-drift-card/risk-drift-card.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/reachability/components/risk-drift-card/risk-drift-card.component.scss @@ -316,7 +316,7 @@ padding: var(--space-2) var(--space-4); font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); - color: var(--color-brand-primary); + color: var(--color-text-link); background: transparent; border: 1px solid var(--color-brand-primary); border-radius: var(--radius-md); diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/poe-drawer.component.ts b/src/Web/StellaOps.Web/src/app/features/reachability/poe-drawer.component.ts index 13cd9a615..d9c72e766 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/poe-drawer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/reachability/poe-drawer.component.ts @@ -11,10 +11,12 @@ * - Reproducibility instructions */ -import { Component, input, output } from '@angular/core'; +import { Component, input, output, + inject,} from '@angular/core'; import { RekorLinkComponent } from '../../shared/components/rekor-link.component'; +import { DateFormatService } from '../../core/i18n/date-format.service'; /** * PoE artifact data model. */ @@ -612,7 +614,7 @@ export interface PoEEdge { .poe-drawer__action--primary { border: none; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); } @@ -636,6 +638,8 @@ export interface PoEEdge { `] }) export class PoEDrawerComponent { + private readonly dateFmt = inject(DateFormatService); + /** * PoE artifact to display. */ @@ -678,7 +682,7 @@ export class PoEDrawerComponent { } formatDate(isoDate: string): string { - return new Intl.DateTimeFormat('en-US', { + return new Intl.DateTimeFormat(this.dateFmt.locale(), { day: '2-digit', hour: '2-digit', minute: '2-digit', diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.html b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.html index f0b7f88c4..62514bcd7 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.html +++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.html @@ -24,28 +24,32 @@ } -
-
- Healthy assets - {{ okCount() }} - {{ fleetCoveragePercent() }}% fleet coverage -
-
- Stale facts - {{ staleCount() }} - {{ staleWitnessCount() }} stale witness observations -
-
- Missing sensors - {{ missingCount() }} - {{ sensorCoveragePercent() }}% sensor coverage -
-
- Confirmed witnesses - {{ confirmedWitnessCount() }} - {{ witnesses().length }} total witness records -
-
+ + + + + +
-
Date
+
@@ -150,12 +154,12 @@ @if (witnessLoading()) { -
Loading witnesses...
+ } @else if (!filteredWitnesses().length) {
No witnesses match the current filters.
} @else {
-
Asset
+
@@ -217,7 +221,7 @@
No proof-of-exposure artifacts are available.
} @else {
-
Witness
+
diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.scss b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.scss index bd1194df9..49e475e20 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.scss @@ -47,9 +47,9 @@ h1 { } .btn-secondary { - border: 1px solid var(--color-border-primary); - background: var(--color-surface-primary); - color: var(--color-text-primary); + border: 1px solid var(--color-btn-secondary-border); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); border-radius: var(--radius-md); padding: 0.55rem 0.85rem; } @@ -195,25 +195,7 @@ h1 { overflow: hidden; } -table { - width: 100%; - border-collapse: collapse; -} - -th, -td { - padding: 0.8rem 0.9rem; - border-bottom: 1px solid var(--color-border-primary); - text-align: left; - vertical-align: top; -} - -th { - font-size: 0.73rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--color-text-secondary); -} +/* Table styling provided by global .stella-table class */ td code { font-family: ui-monospace, monospace; @@ -229,7 +211,7 @@ td code { .btn-link { border: none; background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); padding: 0; text-decoration: none; } diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts index 931fdc5aa..8353dce8f 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-center.component.ts @@ -30,6 +30,10 @@ import { } from '../../shared/ui/context-route-state/context-route-state'; import { PoEDrawerComponent } from './poe-drawer.component'; import { ReachabilityStateChipComponent, type ReachabilityState } from '../../shared/domain/reachability-state-chip/reachability-state-chip.component'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; +import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component'; +import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component'; +import { DateFormatService } from '../../core/i18n/date-format.service'; import { type CoverageStatus, DEFAULT_REACHABILITY_SCAN_ID, @@ -74,12 +78,17 @@ const TIER_FILTERS: readonly TierFilter[] = [ ContextHeaderComponent, TabbedNavComponent, ReachabilityStateChipComponent, + LoadingStateComponent, + StellaMetricCardComponent, + StellaMetricGridComponent, ], templateUrl: './reachability-center.component.html', styleUrls: ['./reachability-center.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class ReachabilityCenterComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + private readonly witnessApi = inject(WITNESS_API); private readonly route = inject(ActivatedRoute); readonly router = inject(Router); @@ -374,7 +383,7 @@ export class ReachabilityCenterComponent implements OnInit { return 'n/a'; } - return new Intl.DateTimeFormat('en-US', { + return new Intl.DateTimeFormat(this.dateFmt.locale(), { day: '2-digit', hour: '2-digit', minute: '2-digit', diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain-widget.component.ts b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain-widget.component.ts index 359ceec0a..33e0bdd7e 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain-widget.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain-widget.component.ts @@ -504,7 +504,7 @@ interface NodePosition { .reachability-explain__factor-bar { height: 8px; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border-radius: var(--radius-sm); } diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain.component.scss b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain.component.scss index c5386c924..2d227de84 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/reachability/reachability-explain.component.scss @@ -400,7 +400,7 @@ summary { cursor: pointer; font-size: var(--font-size-sm); - color: var(--color-brand-primary); + color: var(--color-text-link); padding: var(--space-2); &:hover { @@ -497,7 +497,7 @@ .step-location { font-size: var(--font-size-xs); - color: var(--color-brand-primary); + color: var(--color-text-link); } .step-call-type { diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.html b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.html index a261f6be0..59707d0bc 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.html +++ b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.html @@ -57,38 +57,36 @@ } @if (loading()) { -
Loading witness...
+ } @else if (error()) {
{{ error() }}
} @else if (witness(); as item) { -
-
- Reachability - {{ item.isReachable ? 'Reachable' : 'Unreachable' }} - {{ item.confidenceTier }} -
-
- Confidence - {{ confidencePercent(item.confidenceScore) }} - Observed {{ formatDate(item.observedAt) }} -
-
- Runtime posture - {{ runtimeLabel() }} - - {{ - item.runtimeEvidence?.invocationCount - ? item.runtimeEvidence?.invocationCount + ' invocations' - : 'No invocation count' - }} - -
-
- Signature - {{ signatureLabel() }} - {{ item.signature?.keyId ?? 'No signing key' }} -
-
+ + + + + +
diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.scss b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.scss index ee3421cc7..d1cd963ba 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.scss @@ -23,7 +23,7 @@ justify-self: start; border: none; background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); cursor: pointer; padding: 0; font-size: 0.82rem; @@ -79,15 +79,15 @@ h2 { } .btn-primary { - border: 1px solid var(--color-brand-primary); - background: var(--color-brand-primary); - color: var(--color-text-heading); + border: 1px solid var(--color-btn-primary-border, transparent); + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); } .btn-secondary { - border: 1px solid var(--color-border-primary); - background: var(--color-surface-primary); - color: var(--color-text-primary); + border: 1px solid var(--color-btn-secondary-border); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); } .message-banner { @@ -269,7 +269,7 @@ h2 { } .link-list a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } diff --git a/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.ts b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.ts index 3fa53be55..4184c3997 100644 --- a/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/reachability/witness-page.component.ts @@ -32,6 +32,10 @@ import { SignatureInspectorComponent, EvidencePayloadComponent, } from '../../shared/ui/witness/index'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; +import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component'; +import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component'; +import { DateFormatService } from '../../core/i18n/date-format.service'; import type { VerificationSummaryData, SignatureData, @@ -57,12 +61,17 @@ type MessageType = 'success' | 'error'; VerificationSummaryComponent, SignatureInspectorComponent, EvidencePayloadComponent, + LoadingStateComponent, + StellaMetricCardComponent, + StellaMetricGridComponent, ], templateUrl: './witness-page.component.html', styleUrls: ['./witness-page.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class WitnessPageComponent { + private readonly dateFmt = inject(DateFormatService); + private readonly witnessApi = inject(WITNESS_API); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); @@ -376,7 +385,7 @@ export class WitnessPageComponent { return 'n/a'; } - return new Intl.DateTimeFormat('en-US', { + return new Intl.DateTimeFormat(this.dateFmt.locale(), { day: '2-digit', hour: '2-digit', minute: '2-digit', diff --git a/src/Web/StellaOps.Web/src/app/features/registry-admin/components/plan-audit.component.ts b/src/Web/StellaOps.Web/src/app/features/registry-admin/components/plan-audit.component.ts index 8e625d431..c5fa1760d 100644 --- a/src/Web/StellaOps.Web/src/app/features/registry-admin/components/plan-audit.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/registry-admin/components/plan-audit.component.ts @@ -7,10 +7,12 @@ import { FormsModule } from '@angular/forms'; import { firstValueFrom } from 'rxjs'; import { REGISTRY_ADMIN_API } from '../../../core/api/registry-admin.client'; import { PlanAuditEntry, PaginatedResponse } from '../../../core/api/registry-admin.models'; +import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component'; +import { DateFormatService } from '../../../core/i18n/date-format.service'; @Component({ selector: 'app-plan-audit', - imports: [FormsModule], + imports: [FormsModule, LoadingStateComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -48,8 +50,7 @@ import { PlanAuditEntry, PaginatedResponse } from '../../../core/api/registry-ad @if (loading()) {
-
- Loading audit history... +
} @@ -391,6 +392,8 @@ import { PlanAuditEntry, PaginatedResponse } from '../../../core/api/registry-ad `] }) export class PlanAuditComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + private readonly api = inject(REGISTRY_ADMIN_API); private lastAppliedPlanIdFilter = ''; @@ -456,7 +459,7 @@ export class PlanAuditComponent implements OnInit { return timestamp; } - return date.toLocaleString('en-US', { + return date.toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/registry-admin/components/plan-editor.component.ts b/src/Web/StellaOps.Web/src/app/features/registry-admin/components/plan-editor.component.ts index 192a36333..8737c34b3 100644 --- a/src/Web/StellaOps.Web/src/app/features/registry-admin/components/plan-editor.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/registry-admin/components/plan-editor.component.ts @@ -4,6 +4,7 @@ import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core'; import { Router, ActivatedRoute, RouterModule } from '@angular/router'; +import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component'; import { FormBuilder, FormArray, ReactiveFormsModule, Validators } from '@angular/forms'; import { firstValueFrom } from 'rxjs'; import { REGISTRY_ADMIN_API } from '../../../core/api/registry-admin.client'; @@ -17,7 +18,7 @@ import { @Component({ selector: 'app-plan-editor', - imports: [RouterModule, ReactiveFormsModule], + imports: [RouterModule, ReactiveFormsModule, LoadingStateComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -29,10 +30,7 @@ import { @if (loading()) { -
-
- Loading... -
+ } @else {
diff --git a/src/Web/StellaOps.Web/src/app/features/registry-admin/components/plan-list.component.ts b/src/Web/StellaOps.Web/src/app/features/registry-admin/components/plan-list.component.ts index 36e3a5fda..7b31e0f64 100644 --- a/src/Web/StellaOps.Web/src/app/features/registry-admin/components/plan-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/registry-admin/components/plan-list.component.ts @@ -8,10 +8,12 @@ import { FormsModule } from '@angular/forms'; import { firstValueFrom } from 'rxjs'; import { REGISTRY_ADMIN_API } from '../../../core/api/registry-admin.client'; import { PlanRuleDto } from '../../../core/api/registry-admin.models'; +import { LoadingStateComponent } from '../../../shared/components/loading-state/loading-state.component'; +import { DateFormatService } from '../../../core/i18n/date-format.service'; @Component({ selector: 'app-plan-list', - imports: [RouterModule, FormsModule], + imports: [RouterModule, FormsModule, LoadingStateComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -52,8 +54,7 @@ import { PlanRuleDto } from '../../../core/api/registry-admin.models'; @if (loading()) {
-
- Loading plans... +
} @@ -343,6 +344,8 @@ import { PlanRuleDto } from '../../../core/api/registry-admin.models'; `] }) export class PlanListComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + private readonly api = inject(REGISTRY_ADMIN_API); private readonly router = inject(Router); private readonly route = inject(ActivatedRoute); @@ -427,7 +430,7 @@ export class PlanListComponent implements OnInit { formatDate(dateString: string): string { const date = new Date(dateString); - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/registry-admin/registry-admin.component.ts b/src/Web/StellaOps.Web/src/app/features/registry-admin/registry-admin.component.ts index 5b4ac4620..b5310f6cb 100644 --- a/src/Web/StellaOps.Web/src/app/features/registry-admin/registry-admin.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/registry-admin/registry-admin.component.ts @@ -13,12 +13,18 @@ import { } from '../../core/api/registry-admin.client'; import { PlanRuleDto } from '../../core/api/registry-admin.models'; import { ContextHeaderComponent } from '../../shared/ui/context-header/context-header.component'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +const REGISTRY_ADMIN_TABS: readonly StellaPageTab[] = [ + { id: 'plans', label: 'Plans', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' }, + { id: 'audit', label: 'Audit Log', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, +]; type TabType = 'plans' | 'audit'; @Component({ selector: 'app-registry-admin', - imports: [RouterModule, ContextHeaderComponent], + imports: [RouterModule, ContextHeaderComponent, StellaPageTabsComponent], providers: [ { provide: REGISTRY_ADMIN_API, useClass: RegistryAdminHttpService }, ], @@ -44,32 +50,14 @@ type TabType = 'plans' | 'audit';
- - -
+ -
+ @if (error()) {
@@ -81,8 +69,8 @@ type TabType = 'plans' | 'audit'; styles: [` :host { display: block; - background: var(--color-surface-inverse); - color: var(--color-border-primary); + background: var(--color-surface-primary); + color: var(--color-text-primary); min-height: 100vh; } @@ -121,37 +109,6 @@ type TabType = 'plans' | 'audit'; letter-spacing: 0.05em; } - .registry-admin__tabs { - display: flex; - gap: 0.25rem; - border-bottom: 1px solid var(--color-text-heading); - margin-bottom: 1.5rem; - } - - .registry-admin__tab { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1.25rem; - color: var(--color-text-muted); - text-decoration: none; - border-bottom: 2px solid transparent; - cursor: pointer; - transition: all 0.2s; - } - - .registry-admin__tab:hover { - color: var(--color-border-primary); - } - - .registry-admin__tab--active { - color: var(--color-status-info); - border-bottom-color: var(--color-status-info); - } - - .registry-admin__content { - min-height: 400px; - } .registry-admin__error { position: fixed; @@ -170,6 +127,8 @@ export class RegistryAdminComponent implements OnInit { private readonly api = inject(REGISTRY_ADMIN_API); private readonly router = inject(Router); + readonly REGISTRY_ADMIN_TABS = REGISTRY_ADMIN_TABS; + readonly loading = signal(false); readonly error = signal(null); readonly activeTab = signal('plans'); @@ -207,6 +166,11 @@ export class RegistryAdminComponent implements OnInit { } } + onTabChange(tabId: TabType): void { + this.activeTab.set(tabId); + this.router.navigate([tabId], { relativeTo: undefined, queryParamsHandling: 'merge' }); + } + private syncActiveTabFromRoute(): void { const url = this.router.url; if (url.includes('/audit')) { diff --git a/src/Web/StellaOps.Web/src/app/features/release-control/hotfixes/hotfixes-queue.component.ts b/src/Web/StellaOps.Web/src/app/features/release-control/hotfixes/hotfixes-queue.component.ts index 7e70700e3..5a1410126 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-control/hotfixes/hotfixes-queue.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-control/hotfixes/hotfixes-queue.component.ts @@ -1,130 +1,750 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { RouterLink } from '@angular/router'; +// Filter bar adoption: aligned with release-list (versions) page patterns +import { ChangeDetectionStrategy, Component, signal, computed } from '@angular/core'; +import { RouterLink, Router } from '@angular/router'; + +import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../shared/ui/filter-bar/filter-bar.component'; interface HotfixRow { hotfixId: string; bundle: string; targetEnv: string; - urgency: string; - gates: string; + urgency: 'Critical' | 'High' | 'Medium'; + gates: 'PASS' | 'WARN' | 'BLOCK'; + status: 'queued' | 'reviewing' | 'approved' | 'blocked'; + requestedBy: string; + requestedAt: string; + patchRef: string; } @Component({ selector: 'app-hotfixes-queue', standalone: true, - imports: [RouterLink], + imports: [RouterLink, FilterBarComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
-
-

Hotfixes

-

Dedicated queue for expedited release-control promotions.

+
+
+
+

Hotfix Queue

+

Expedited release-control promotions with fast-track gate evaluation

+
+
- @if (hotfixes.length === 0) { -

No active hotfixes.

- } @else { -
Proof
- - - - - - - - - - - @for (row of hotfixes; track row.bundle + row.targetEnv) { - - - - - - - +
+
+ + @for (card of statusCards(); track card.label) { + + } +
+
+ + + + @if (filteredHotfixes().length === 0) { +
+
+ + + +
+

No hotfixes found

+

+ @if (hasActiveFilters()) { + No hotfixes match the current filters. Try broadening your search or clearing filters. + } @else { + No active hotfixes in the queue. Create a hotfix to begin an expedited promotion run. } -

-
BundleTarget EnvUrgencyGatesAction
{{ row.bundle }}{{ row.targetEnv }}{{ row.urgency }}{{ row.gates }} - - Review - -
+

+
+ @if (hasActiveFilters()) { + + } + + + Create Hotfix + +
+ + } @else { +
+ + + + + + + + + + + + + + @for (row of filteredHotfixes(); track row.hotfixId) { + + + + + + + + + + } + +
BundleTarget EnvUrgencyGatesStatusRequested
+ + {{ row.bundle }} + +
{{ row.patchRef }}
+
+ {{ row.targetEnv }} + + + @if (row.urgency === 'Critical') { + + } + {{ row.urgency }} + + + + {{ row.gates }} + + + + {{ statusLabel(row.status) }} + + +
{{ row.requestedBy }}
+
{{ row.requestedAt }}
+
+ + + +
+
} - + `, - styles: [ - ` - .hotfixes { - display: grid; - gap: 0.8rem; + styles: [` + /* ─── Page layout ─── */ + .hotfix-queue { + display: grid; + gap: 0.5rem; + max-width: 1600px; + margin: 0 auto; + } + + /* ─── Header ─── */ + .list-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + flex-wrap: wrap; + } + + .list-header h1 { + margin: 0; + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-tight, 1.25); + } + + .subtitle { + margin: 0.2rem 0 0; + color: var(--color-text-secondary); + font-size: var(--font-size-sm, 0.75rem); + } + + .header-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; + } + + /* ─── Buttons ─── */ + .btn-primary, + .btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + border-radius: var(--radius-md); + font-size: var(--font-size-sm, 0.75rem); + font-weight: var(--font-weight-medium); + cursor: pointer; + padding: 0.4rem 0.75rem; + text-decoration: none; + white-space: nowrap; + transition: background var(--motion-duration-sm, 140ms) ease, + border-color var(--motion-duration-sm, 140ms) ease, + box-shadow var(--motion-duration-sm, 140ms) ease; + } + + .btn-primary { + border: 1px solid var(--color-btn-primary-border); + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); + } + + .btn-primary:hover { + background: var(--color-btn-primary-bg-hover); + box-shadow: var(--shadow-sm); + } + + .btn-secondary { + border: 1px solid var(--color-btn-secondary-border); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); + } + + .btn-secondary:hover { + background: var(--color-btn-secondary-hover-bg); + border-color: var(--color-btn-secondary-hover-border); + } + + .btn-primary svg, + .btn-secondary svg { + flex-shrink: 0; + } + + /* ─── Toolbar row ─── */ + .toolbar-row { + display: flex; + align-items: center; + gap: 0.75rem; + } + + /* ─── Status Switcher (segmented control) ─── */ + .status-switcher { + display: inline-flex; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--color-surface-secondary); + } + + .status-segment { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border: none; + background: var(--color-surface-secondary); + color: var(--color-text-muted); + font-size: var(--font-size-sm, 0.75rem); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: background-color 150ms ease, color 150ms ease; + white-space: nowrap; + } + + .status-segment:not(:last-child) { + border-right: 1px solid var(--color-border-primary); + } + + .status-segment:hover:not(.status-segment--active) { + background: var(--color-surface-tertiary); + color: var(--color-text-secondary); + } + + .status-segment:focus-visible { + outline: 2px solid var(--color-brand-primary); + outline-offset: -2px; + } + + .status-segment--active { + background: var(--color-text-heading); + color: var(--color-surface-primary); + font-weight: var(--font-weight-semibold); + } + + .status-segment--active:hover { + background: var(--color-text-primary); + } + + .status-segment__count { + font-size: 0.625rem; + font-weight: var(--font-weight-bold); + opacity: 0.7; + } + + /* ─── Empty state ─── */ + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 3.5rem 1.5rem 4rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + } + + .empty-state__icon { + display: flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + border-radius: var(--radius-xl); + background: var(--color-brand-primary-10, var(--color-surface-secondary)); + color: var(--color-brand-primary); + margin-bottom: 1.25rem; + } + + .empty-state__title { + margin: 0 0 0.4rem; + font-size: var(--font-size-md, 1rem); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + } + + .empty-state__desc { + margin: 0 0 1.5rem; + max-width: 420px; + font-size: var(--font-size-sm, 0.75rem); + color: var(--color-text-secondary); + line-height: var(--line-height-relaxed, 1.625); + } + + .empty-state__actions { + display: flex; + gap: 0.5rem; + align-items: center; + } + + /* ─── Table ─── */ + .table-container { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--color-surface-primary); + } + + .hotfix-table { + width: 100%; + border-collapse: collapse; + } + + .hotfix-table th, + .hotfix-table td { + text-align: left; + padding: 0.5rem 0.6rem; + vertical-align: top; + font-size: var(--font-size-sm, 0.75rem); + } + + .hotfix-table thead { + border-bottom: 1px solid var(--color-border-primary); + } + + .hotfix-table th { + font-size: var(--font-size-xs, 0.6875rem); + font-weight: var(--font-weight-medium); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-secondary); + background: var(--color-surface-secondary); + padding: 0.45rem 0.6rem; + white-space: nowrap; + } + + .hotfix-table tbody tr { + border-bottom: 1px solid var(--color-border-primary); + transition: background var(--motion-duration-sm, 140ms) ease; + } + + .hotfix-table tbody tr:last-child { + border-bottom: none; + } + + .hotfix-table tbody tr:hover { + background: var(--color-surface-secondary); + } + + .hotfix-table tbody tr.blocked { + background: var(--color-status-error-bg); + } + + .hotfix-table tbody tr.blocked:hover { + background: color-mix(in srgb, var(--color-status-error-bg) 80%, var(--color-surface-secondary)); + } + + /* Column sizing */ + .col-identity { min-width: 180px; } + .col-env { min-width: 90px; } + .col-urgency { min-width: 90px; } + .col-gate { min-width: 80px; } + .col-status { min-width: 100px; } + .col-actor { min-width: 100px; } + .col-actions { width: 40px; text-align: center; vertical-align: middle; } + + .identity-link { + color: var(--color-text-link); + text-decoration: none; + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: var(--font-size-xs, 0.6875rem); + line-height: 1.3; + } + + .identity-link:hover { + text-decoration: underline; + } + + .meta { + margin-top: 0.15rem; + color: var(--color-text-muted); + font-size: var(--font-size-xs, 0.6875rem); + } + + /* ─── Badges & chips ─── */ + .badge { + display: inline-flex; + align-items: center; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-full); + padding: 0.06rem 0.45rem; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: var(--font-size-xs, 0.6875rem); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + line-height: 1.4; + } + + .urgency-chip { + display: inline-flex; + align-items: center; + gap: 0.2rem; + border-radius: var(--radius-full); + padding: 0.06rem 0.45rem; + font-size: var(--font-size-xs, 0.6875rem); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + line-height: 1.4; + border: 1px solid var(--color-border-primary); + } + + .urgency-chip--critical { + color: var(--color-status-error-text); + border-color: var(--color-status-error-border); + background: var(--color-status-error-bg); + } + + .urgency-chip--high { + color: var(--color-status-warning-text); + border-color: var(--color-status-warning-border); + background: var(--color-status-warning-bg); + } + + .urgency-chip--medium { + color: var(--color-text-secondary); + border-color: var(--color-border-primary); + background: var(--color-surface-secondary); + } + + .gate-chip { + display: inline-flex; + align-items: center; + border-radius: var(--radius-full); + padding: 0.06rem 0.45rem; + font-size: var(--font-size-xs, 0.6875rem); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + line-height: 1.4; + border: 1px solid var(--color-border-primary); + } + + .gate-chip--pass { + color: var(--color-status-success-text); + border-color: var(--color-status-success-border); + background: var(--color-status-success-bg); + } + + .gate-chip--warn { + color: var(--color-status-warning-text); + border-color: var(--color-status-warning-border); + background: var(--color-status-warning-bg); + } + + .gate-chip--block { + color: var(--color-status-error-text); + border-color: var(--color-status-error-border); + background: var(--color-status-error-bg); + } + + .status-chip { + display: inline-flex; + align-items: center; + border-radius: var(--radius-full); + padding: 0.06rem 0.45rem; + font-size: var(--font-size-xs, 0.6875rem); + font-weight: var(--font-weight-medium); + line-height: 1.4; + border: 1px solid var(--color-border-primary); + } + + .status-chip--queued { + color: var(--color-text-secondary); + background: var(--color-surface-secondary); + } + + .status-chip--reviewing { + color: var(--color-status-warning-text); + border-color: var(--color-status-warning-border); + background: var(--color-status-warning-bg); + } + + .status-chip--approved { + color: var(--color-status-success-text); + border-color: var(--color-status-success-border); + background: var(--color-status-success-bg); + } + + .status-chip--blocked { + color: var(--color-status-error-text); + border-color: var(--color-status-error-border); + background: var(--color-status-error-bg); + } + + /* Row action (chevron) */ + .row-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + color: var(--color-text-muted); + text-decoration: none; + transition: color var(--motion-duration-sm, 140ms) ease, + background var(--motion-duration-sm, 140ms) ease; + } + + .row-action:hover { + color: var(--color-text-link); + background: var(--color-surface-tertiary); + } + + /* ─── Responsive ─── */ + @media (max-width: 920px) { + .table-container { + overflow-x: auto; } - .header h1 { - margin: 0 0 0.2rem; - font-size: 1.4rem; + .list-header { + flex-direction: column; + gap: 0.75rem; } - .header p { - margin: 0; - color: var(--color-text-secondary); - font-size: 0.84rem; - } - - .empty { - margin: 0; - color: var(--color-text-secondary); - font-size: 0.83rem; - } - - table { + .header-actions { width: 100%; - border-collapse: collapse; } - th, - td { - border-bottom: 1px solid var(--color-border-primary); - text-align: left; - padding: 0.46rem; - font-size: 0.8rem; + .header-actions .btn-primary, + .header-actions .btn-secondary { + flex: 1; } - th { - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--color-text-secondary); + .status-switcher { + flex-wrap: wrap; } - - .action-link { - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid var(--color-border-primary); - background: var(--color-surface-primary); - border-radius: var(--radius-md); - padding: 0.25rem 0.45rem; - font-size: 0.74rem; - cursor: pointer; - color: inherit; - text-decoration: none; - } - `, - ], + } + `], }) export class HotfixesQueueComponent { - readonly hotfixes: HotfixRow[] = [ + searchTerm = ''; + urgencyFilter = 'all'; + gateFilter = 'all'; + readonly statusFilter = signal(''); + + readonly activeFilters = signal([]); + + readonly filterOptions: FilterOption[] = [ + { key: 'urgency', label: 'Urgency', options: [{ value: 'Critical', label: 'Critical' }, { value: 'High', label: 'High' }, { value: 'Medium', label: 'Medium' }] }, + { key: 'gate', label: 'Gate', options: [{ value: 'PASS', label: 'Pass' }, { value: 'WARN', label: 'Warn' }, { value: 'BLOCK', label: 'Block' }] }, + { key: 'status', label: 'Status', options: [{ value: 'queued', label: 'Queued' }, { value: 'reviewing', label: 'Under Review' }, { value: 'approved', label: 'Approved' }, { value: 'blocked', label: 'Blocked' }] }, + ]; + + readonly hotfixes = signal([ { hotfixId: 'platform-bundle-1-3-1-hotfix1', bundle: 'platform-bundle@1.3.1-hotfix1', targetEnv: 'prod-eu', urgency: 'Critical', gates: 'WARN', + status: 'reviewing', + requestedBy: 'ops-lead', + requestedAt: '12m ago', + patchRef: 'sha256:7aa1b2c3...d6e7f8a9', }, - ]; + { + hotfixId: 'billing-svc-2-0-4-hotfix1', + bundle: 'billing-svc@2.0.4-hotfix1', + targetEnv: 'prod-us', + urgency: 'Critical', + gates: 'BLOCK', + status: 'blocked', + requestedBy: 'sre-team', + requestedAt: '38m ago', + patchRef: 'sha256:9cc3d4e5...a8b9c0d1', + }, + { + hotfixId: 'auth-proxy-4-1-0-hotfix2', + bundle: 'auth-proxy@4.1.0-hotfix2', + targetEnv: 'prod-eu', + urgency: 'High', + gates: 'PASS', + status: 'approved', + requestedBy: 'security-eng', + requestedAt: '2h ago', + patchRef: 'sha256:5bb2c3d4...e7f8a9b0', + }, + { + hotfixId: 'api-gateway-3-5-2-hotfix1', + bundle: 'api-gateway@3.5.2-hotfix1', + targetEnv: 'staging', + urgency: 'Medium', + gates: 'PASS', + status: 'queued', + requestedBy: 'dev-team', + requestedAt: '4h ago', + patchRef: 'sha256:2dd4e5f6...b0c1d2e3', + }, + ]); + + readonly filteredHotfixes = computed(() => { + let result = this.hotfixes(); + const query = this.searchTerm.toLowerCase(); + const urgency = this.urgencyFilter; + const gate = this.gateFilter; + const status = this.statusFilter(); + + if (query) { + result = result.filter( + (r) => + r.bundle.toLowerCase().includes(query) || + r.targetEnv.toLowerCase().includes(query) || + r.patchRef.toLowerCase().includes(query), + ); + } + if (urgency !== 'all') result = result.filter((r) => r.urgency === urgency); + if (gate !== 'all') result = result.filter((r) => r.gates === gate); + if (status) result = result.filter((r) => r.status === status); + + return result; + }); + + readonly statusCards = computed(() => { + const all = this.hotfixes(); + return [ + { label: 'Queued', count: all.filter((h) => h.status === 'queued').length, filterValue: 'queued', color: 'var(--color-text-secondary)' }, + { label: 'Reviewing', count: all.filter((h) => h.status === 'reviewing').length, filterValue: 'reviewing', color: 'var(--color-brand-primary)' }, + { label: 'Approved', count: all.filter((h) => h.status === 'approved').length, filterValue: 'approved', color: 'var(--color-status-success-text, #15803D)' }, + { label: 'Blocked', count: all.filter((h) => h.status === 'blocked').length, filterValue: 'blocked', color: 'var(--color-status-error-text, #B91C1C)' }, + ]; + }); + + toggleStatusCard(value: string): void { + this.statusFilter.set(this.statusFilter() === value ? '' : value); + } + + onSearch(value: string): void { + this.searchTerm = value; + } + + onFilterChanged(filter: ActiveFilter): void { + if (filter.key === 'urgency') this.urgencyFilter = filter.value; + if (filter.key === 'gate') this.gateFilter = filter.value; + if (filter.key === 'status') this.statusFilter.set(filter.value); + this.rebuildActiveFilters(); + } + + onFilterRemoved(filter: ActiveFilter): void { + if (filter.key === 'urgency') this.urgencyFilter = 'all'; + if (filter.key === 'gate') this.gateFilter = 'all'; + if (filter.key === 'status') this.statusFilter.set(''); + this.rebuildActiveFilters(); + } + + clearAllFilters(): void { + this.searchTerm = ''; + this.urgencyFilter = 'all'; + this.gateFilter = 'all'; + this.statusFilter.set(''); + this.activeFilters.set([]); + } + + hasActiveFilters(): boolean { + return this.activeFilters().length > 0 || this.searchTerm.trim().length > 0; + } + + statusLabel(status: string): string { + const labels: Record = { + queued: 'Queued', + reviewing: 'Under Review', + approved: 'Approved', + blocked: 'Blocked', + }; + return labels[status] ?? status; + } + + private rebuildActiveFilters(): void { + const filters: ActiveFilter[] = []; + if (this.urgencyFilter !== 'all') { + filters.push({ key: 'urgency', value: this.urgencyFilter, label: 'Urgency: ' + this.urgencyFilter }); + } + if (this.gateFilter !== 'all') { + const opt = this.filterOptions.find((f) => f.key === 'gate')?.options.find((o) => o.value === this.gateFilter); + filters.push({ key: 'gate', value: this.gateFilter, label: 'Gate: ' + (opt?.label || this.gateFilter) }); + } + const status = this.statusFilter(); + if (status) { + filters.push({ key: 'status', value: status, label: 'Status: ' + this.statusLabel(status) }); + } + this.activeFilters.set(filters); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/approval-detail/approval-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/approval-detail/approval-detail.component.ts index 046da56fe..c1e55f9cd 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/approval-detail/approval-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/approval-detail/approval-detail.component.ts @@ -16,16 +16,17 @@ import { getGateStatusColor, formatTimeRemaining, } from '../../../../core/api/approval.models'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; @Component({ selector: 'app-approval-detail', - imports: [RouterLink, FormsModule], + imports: [RouterLink, FormsModule, LoadingStateComponent], template: `
@if (store.loading()) {
-
-

Loading approval details...

+
} @else if (approval()) { @@ -776,10 +777,10 @@ import { transition: all 0.2s; } - .btn-primary { background: var(--color-status-info); color: var(--color-surface-primary); } - .btn-secondary { background: var(--color-surface-secondary); color: var(--color-text-primary); } + .btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } + .btn-secondary { background: var(--color-btn-secondary-bg); color: var(--color-text-primary); } .btn-success { background: var(--color-status-success); color: var(--color-surface-primary); } - .btn-danger { background: var(--color-status-error); color: var(--color-surface-primary); } + .btn-danger { background: var(--color-status-error); color: white; } .btn-danger-outline { background: var(--color-surface-primary); color: var(--color-status-error); border: 1px solid var(--color-status-error); } .btn:hover:not(:disabled) { filter: brightness(0.95); } @@ -818,6 +819,8 @@ import { `] }) export class ApprovalDetailComponent implements OnInit, OnDestroy { + private readonly dateFmt = inject(DateFormatService); + private readonly route = inject(ActivatedRoute); readonly store = inject(ApprovalStore); @@ -893,7 +896,7 @@ export class ApprovalDetailComponent implements OnInit, OnDestroy { } formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString('en-US', { + return new Date(dateStr).toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/approval-queue/approval-queue.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/approval-queue/approval-queue.component.ts index 16459ef2e..2752175b1 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/approval-queue/approval-queue.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/approvals/approval-queue/approval-queue.component.ts @@ -15,10 +15,12 @@ import { getUrgencyColor, formatTimeRemaining, } from '../../../../core/api/approval.models'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; @Component({ selector: 'app-approval-queue', - imports: [RouterLink, FormsModule], + imports: [RouterLink, FormsModule, LoadingStateComponent], template: `
- -
-
- {{ store.approvalsByStatus().pending }} - Pending -
-
- {{ store.approvalsByStatus().approved }} - Approved -
-
- {{ store.approvalsByStatus().rejected }} - Rejected -
-
- {{ store.approvals().length }} - All -
+ +
+ + + +
@@ -85,8 +99,7 @@ import { @if (store.loading()) {
-
-

Loading approvals...

+
} @@ -270,47 +283,58 @@ import { color: var(--color-text-secondary); } - .status-summary { - display: flex; - gap: 16px; - margin-bottom: 24px; - } - - .status-card { - background: var(--color-surface-primary); + .status-switcher { + display: inline-flex; border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - padding: 16px 24px; + border-radius: var(--radius-md); + overflow: hidden; + background: var(--color-surface-secondary); + margin-bottom: 0.5rem; + } + + .status-segment { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border: none; + background: var(--color-surface-secondary); + color: var(--color-text-muted); + font-size: var(--font-size-sm, 0.75rem); + font-weight: var(--font-weight-medium); cursor: pointer; - transition: all 0.2s; - text-align: center; - min-width: 100px; + transition: background-color 150ms ease, color 150ms ease; + white-space: nowrap; } - .status-card:hover { - border-color: var(--color-status-info); + .status-segment:not(:last-child) { + border-right: 1px solid var(--color-border-primary); } - .status-card.active { - border-color: var(--color-status-info); - background: var(--color-status-info-bg); - } - - .status-card .count { - display: block; - font-size: var(--font-size-2xl); - font-weight: var(--font-weight-semibold); - color: var(--color-text-heading); - } - - .status-card .count.pending { color: var(--color-status-warning); } - .status-card .count.approved { color: var(--color-status-success); } - .status-card .count.rejected { color: var(--color-status-error); } - - .status-card .label { - font-size: var(--font-size-sm); + .status-segment:hover:not(.status-segment--active) { + background: var(--color-surface-tertiary); color: var(--color-text-secondary); - text-transform: uppercase; + } + + .status-segment:focus-visible { + outline: 2px solid var(--color-brand-primary); + outline-offset: -2px; + } + + .status-segment--active { + background: var(--color-text-heading); + color: var(--color-surface-primary); + font-weight: var(--font-weight-semibold); + } + + .status-segment--active:hover { + background: var(--color-text-primary); + } + + .status-segment__count { + font-size: 0.625rem; + font-weight: var(--font-weight-bold); + opacity: 0.7; } .filters-bar { @@ -545,13 +569,13 @@ import { transition: all 0.2s; } - .btn-primary { background: var(--color-status-info); color: var(--color-surface-primary); } - .btn-primary:hover { background: var(--color-status-info-text); } - .btn-secondary { background: var(--color-surface-secondary); color: var(--color-text-primary); } - .btn-secondary:hover { background: var(--color-border-primary); } + .btn-primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } + .btn-primary:hover { background: var(--color-btn-primary-bg-hover); } + .btn-secondary { background: var(--color-btn-secondary-bg); color: var(--color-text-primary); } + .btn-secondary:hover { background: var(--color-btn-secondary-hover-bg); } .btn-success { background: var(--color-status-success); color: var(--color-surface-primary); } .btn-success:hover { background: var(--color-status-success-text); } - .btn-danger { background: var(--color-status-error); color: var(--color-surface-primary); } + .btn-danger { background: var(--color-status-error); color: white; } .btn-danger:hover { background: var(--color-status-error); } .btn-sm { padding: 6px 12px; font-size: var(--font-size-sm); } .btn-icon { padding: 8px; background: transparent; border: 1px solid var(--color-border-primary); } @@ -647,6 +671,8 @@ import { `] }) export class ApprovalQueueComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + readonly store = inject(ApprovalStore); // Exposed helpers @@ -733,7 +759,7 @@ export class ApprovalQueueComponent implements OnInit { } formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString('en-US', { + return new Date(dateStr).toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/dashboard/components/pipeline-overview/pipeline-overview.component.scss b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/dashboard/components/pipeline-overview/pipeline-overview.component.scss index ea0a5ab0f..589776574 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/dashboard/components/pipeline-overview/pipeline-overview.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/dashboard/components/pipeline-overview/pipeline-overview.component.scss @@ -19,15 +19,18 @@ .pipeline-overview__flow { display: flex; align-items: center; - justify-content: center; gap: var(--space-2); - flex-wrap: wrap; + flex-wrap: nowrap; + overflow-x: auto; + padding-bottom: var(--space-2); } .env-card { display: flex; flex-direction: column; min-width: 140px; + max-height: 160px; + flex-shrink: 0; padding: var(--space-4); background: var(--color-surface-secondary); border: 2px solid var(--color-border-primary); @@ -173,19 +176,10 @@ } } -/* Responsive */ +/* Responsive -- horizontal scroll handles overflow */ @include screen-below-md { - .pipeline-overview__arrow { - transform: rotate(90deg); - } - - .pipeline-overview__flow { - flex-direction: column; - } - .env-card { - width: 100%; - max-width: 200px; + min-width: 120px; } } diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/dashboard/components/recent-releases/recent-releases.component.scss b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/dashboard/components/recent-releases/recent-releases.component.scss index 69bb4ad40..10872be8d 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/dashboard/components/recent-releases/recent-releases.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/dashboard/components/recent-releases/recent-releases.component.scss @@ -27,7 +27,7 @@ .recent-releases__view-all { font-size: var(--font-size-sm); - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; transition: color var(--motion-duration-fast) var(--motion-ease-default); @@ -121,7 +121,7 @@ transition: color var(--motion-duration-fast) var(--motion-ease-default); &:hover { - color: var(--color-brand-primary); + color: var(--color-text-link); } &:focus-visible { diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/dashboard/dashboard.component.scss b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/dashboard/dashboard.component.scss index 91507944a..d2a1b1cdf 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/dashboard/dashboard.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/dashboard/dashboard.component.scss @@ -51,7 +51,7 @@ padding: 0 var(--space-4); border: 1px solid var(--color-brand-primary); border-radius: var(--radius-md); - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); @@ -80,7 +80,7 @@ &:hover:not(:disabled) { border-color: var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); } &:disabled { diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/deployments/deployment-list/deployment-list.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/deployments/deployment-list/deployment-list.component.ts index 6de1801d8..1e8159b6f 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/deployments/deployment-list/deployment-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/deployments/deployment-list/deployment-list.component.ts @@ -13,10 +13,12 @@ import { getStrategyLabel, formatDuration, } from '../../../../core/api/deployment.models'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; @Component({ selector: 'app-deployment-list', - imports: [RouterLink], + imports: [RouterLink, LoadingStateComponent], template: `
- +
@if (activeTab() === 'overview') { @@ -464,7 +472,7 @@ interface AuditEventRow { } .back-link { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.82rem; } @@ -518,7 +526,7 @@ interface AuditEventRow { .quick-links a, .footer-links a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-size: 0.82rem; } @@ -540,7 +548,7 @@ interface AuditEventRow { } .tab-nav button.active { - color: var(--color-brand-primary); + color: var(--color-text-link); border-color: var(--color-brand-primary); background: var(--color-brand-soft); } @@ -677,6 +685,7 @@ export class EnvironmentDetailComponent implements OnInit, OnDestroy { private readonly title = inject(Title); private readonly breadcrumbService = inject(BreadcrumbService); + readonly ENV_CASEFILE_TABS = ENV_CASEFILE_TABS; readonly activeTab = signal('overview'); readonly regionLabel = signal('global'); diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/environment-list/environment-list.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/environment-list/environment-list.component.ts index b5a171b34..da146a315 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/environment-list/environment-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/environments/environment-list/environment-list.component.ts @@ -339,7 +339,7 @@ import { } .env-name:hover { - color: var(--color-brand-primary); + color: var(--color-text-link); } .production-badge { diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.ts index 454cc2631..2bcf7e868 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-detail/evidence-detail.component.ts @@ -16,19 +16,27 @@ import { buildContextReturnTo } from '../../../../shared/ui/context-route-state/ import { ProofChainViewerComponent, ChainNode } from '../../../../shared/components/proof-chain-viewer.component'; import { DsseEnvelopeViewerComponent, DsseEnvelope, EnvelopeDisplayData } from '../../../../shared/components/dsse-envelope-viewer.component'; import { QuickVerifyDrawerComponent, VerifyResult } from '../../../../shared/components/quick-verify-drawer'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; +import { StellaPageTabsComponent, StellaPageTab } from '../../../../shared/components/stella-page-tabs/stella-page-tabs.component'; type TabType = 'overview' | 'content' | 'signature' | 'timeline'; +const EVIDENCE_DETAIL_TABS: StellaPageTab[] = [ + { id: 'overview', label: 'Overview', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' }, + { id: 'content', label: 'Content', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'signature', label: 'Signature', icon: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' }, + { id: 'timeline', label: 'Timeline', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' }, +]; + @Component({ selector: 'so-evidence-detail', - imports: [CommonModule, RouterModule, ProofChainViewerComponent, DsseEnvelopeViewerComponent, QuickVerifyDrawerComponent], + imports: [CommonModule, RouterModule, ProofChainViewerComponent, DsseEnvelopeViewerComponent, QuickVerifyDrawerComponent, LoadingStateComponent, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@if (store.loading()) {
-
-

Loading evidence...

+
} @else if (packet()) { @@ -130,28 +138,12 @@ type TabType = 'overview' | 'content' | 'signature' | 'timeline'; } -
- - - - -
+
@@ -1476,6 +1468,7 @@ export class EvidenceDetailComponent implements OnInit, OnDestroy { private readonly router = inject(Router); readonly store = inject(EvidenceStore); + readonly EVIDENCE_DETAIL_TABS = EVIDENCE_DETAIL_TABS; activeTab = signal('overview'); viewMode = signal<'formatted' | 'raw'>('formatted'); showExportDialog = signal(false); diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-list/evidence-list.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-list/evidence-list.component.ts index 16b814968..a94cb2ab2 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-list/evidence-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/evidence/evidence-list/evidence-list.component.ts @@ -17,10 +17,11 @@ import { type EvidencePacketSummary as DrawerEvidencePacketSummary, type EvidenceContentItem, } from '../../../../shared/overlays/evidence-packet-drawer/evidence-packet-drawer.component'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; @Component({ selector: 'so-evidence-list', - imports: [CommonModule, RouterModule, FormsModule, EvidencePacketDrawerComponent], + imports: [CommonModule, RouterModule, FormsModule, EvidencePacketDrawerComponent, LoadingStateComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -85,8 +86,7 @@ import { @if (store.loading()) {
-
-

Loading evidence packets...

+
} diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.ts index 6681f2fd2..97b84066b 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/create-release/create-release.component.ts @@ -17,65 +17,92 @@ import { template: `
-
+

Create Release Version

-

Canonical release version workflow with deterministic draft seal semantics.

+

Define identity, attach components, set the config contract, and seal.

- + + + Back to Versions +
-
    -
  1. 1. Basic Info
  2. -
  3. 2. Components
  4. -
  5. 3. Inputs & Contract
  6. -
  7. 4. Review & Seal
  8. -
+ + +
@switch (step()) { @case (1) {
-

Basic Release Version Identity

+
+

Basic Release Version Identity

+

Define the canonical identity fields for this version. All fields marked * are required.

+
- +
+ + +
- +
+ + +
- +
+ + +
- - - - - - -
@@ -83,17 +110,23 @@ import { @case (2) {
-

Components

-

Search and add image digests that compose this release version.

+
+

Components

+

Search your registry and attach image digests that compose this release version.

+
-
} @case (3) {
-

Inputs and Config Contract

+
+

Inputs and Config Contract

+

Declare the operational contract for this release. These fields are sealed into the evidence chain.

+
- +
+ + +
- - -
} @case (4) {
-

Review and Seal Draft

- -
-

Release version identity preview

-

{{ form.name }} · {{ form.version }}

-

Type: {{ form.releaseType }} · Path: {{ form.targetPathIntent }}

-

Policy pin: {{ form.policyPackPin || 'not pinned' }}

-

Draft identity: {{ draftIdentityPreview() }}

+
+

Review and Seal Draft

+

Verify all fields before sealing. Once sealed, the draft identity becomes immutable.

-
-

Contract summary

-

Config profile: {{ contract.configProfile }}

-

Change ticket: {{ contract.changeTicket }}

-

Replay parity: {{ contract.requireReplayParity ? 'required' : 'optional' }}

-

Components: {{ components.length }}

+
+
+
+ +

Release identity

+
+
+
Name
{{ form.name }}
+
Version
{{ form.version }}
+
Type
{{ form.releaseType }}
+
Path
{{ form.targetPathIntent }}
+
Policy pin
{{ form.policyPackPin || 'not pinned' }}
+
Draft ID
{{ draftIdentityPreview() }}
+
+
+ +
+
+ +

Contract summary

+
+
+
Config profile
{{ contract.configProfile }}
+
Change ticket
{{ contract.changeTicket }}
+
Strategy
{{ contract.deploymentStrategy }}
+
Replay parity
{{ contract.requireReplayParity ? 'required' : 'optional' }}
+
Components
{{ components.length }}
+
+
-
} @@ -218,23 +291,33 @@ import {
@if (submitError(); as submitError) { - + }
- -
+ +
+ Step {{ step() }} of 4 @if (step() < 4) { } @else { - } @@ -242,13 +325,15 @@ import {
`, styles: [` + /* ─── Page layout ─── */ .create-release { display: grid; - gap: 0.9rem; - max-width: 980px; + gap: 0.75rem; + max-width: 820px; margin: 0 auto; } + /* ─── Header ─── */ .wizard-header { display: flex; justify-content: space-between; @@ -258,81 +343,176 @@ import { .wizard-header h1 { margin: 0; - font-size: 1.5rem; - font-weight: var(--font-weight-semibold, 600); + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-tight, 1.25); } - .wizard-header p { - margin: 0.25rem 0 0; + .wizard-header__sub { + margin: 0.2rem 0 0; color: var(--color-text-secondary); - font-size: 0.875rem; + font-size: var(--font-size-sm, 0.75rem); } - .wizard-steps { - margin: 0; - padding: 0; - list-style: none; - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 0.5rem; - } - - .wizard-steps li { + .btn-back { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.35rem 0.65rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); - padding: 0.6rem 0.75rem; - font-size: 0.8125rem; - font-weight: 500; color: var(--color-text-secondary); - text-align: center; - transition: border-color 150ms ease, color 150ms ease, background 150ms ease, box-shadow 150ms ease; + font-size: var(--font-size-sm, 0.75rem); + text-decoration: none; + white-space: nowrap; + transition: color var(--motion-duration-sm, 140ms) ease, + border-color var(--motion-duration-sm, 140ms) ease; } - .wizard-steps li.active { + .btn-back:hover { + color: var(--color-text-primary); + border-color: var(--color-border-secondary); + } + + /* ─── Stepper ─── */ + .stepper { + display: flex; + align-items: center; + gap: 0; + padding: 0.5rem 0; + } + + .stepper__line { + flex: 1; + height: 2px; + background: var(--color-border-primary); + transition: background var(--motion-duration-md, 200ms) ease; + } + + .stepper__line.done { + background: var(--color-status-success-text); + } + + .stepper__step { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + background: none; + border: none; + padding: 0 0.25rem; + cursor: pointer; + transition: opacity var(--motion-duration-sm, 140ms) ease; + } + + .stepper__step:disabled { + cursor: default; + opacity: 0.5; + } + + .stepper__circle { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + border: 2px solid var(--color-border-primary); + background: var(--color-surface-primary); + font-size: var(--font-size-sm, 0.75rem); + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + transition: border-color var(--motion-duration-sm, 140ms) ease, + background var(--motion-duration-sm, 140ms) ease, + color var(--motion-duration-sm, 140ms) ease; + } + + .stepper__step.active .stepper__circle { border-color: var(--color-brand-primary); - color: var(--color-brand-primary); - background: var(--color-surface-elevated); - font-weight: 600; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + background: var(--color-brand-primary); + color: var(--color-btn-primary-text, #fff); + box-shadow: 0 0 0 3px var(--color-brand-primary-10, rgba(180, 140, 50, 0.15)); } - .wizard-steps li.done { + .stepper__step.done .stepper__circle { border-color: var(--color-status-success-text); + background: var(--color-status-success-bg); color: var(--color-status-success-text); - background: var(--color-surface-elevated); } + .stepper__label { + font-size: var(--font-size-xs, 0.6875rem); + font-weight: var(--font-weight-medium); + color: var(--color-text-muted); + white-space: nowrap; + } + + .stepper__step.active .stepper__label { + color: var(--color-text-primary); + font-weight: var(--font-weight-semibold); + } + + .stepper__step.done .stepper__label { + color: var(--color-status-success-text); + } + + /* ─── Wizard body ─── */ .wizard-body { border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); + border-radius: var(--radius-lg); background: var(--color-surface-primary); - padding: 0.9rem; + padding: 1.25rem; } .step-panel { display: grid; - gap: 0.65rem; + gap: 0.85rem; } - .step-panel h2 { + .step-intro { + margin-bottom: 0.25rem; + } + + .step-intro h2 { margin: 0; - font-size: 1rem; + font-size: var(--font-size-md, 1rem); + font-weight: var(--font-weight-semibold); } - .step-panel h3 { - margin: 0.2rem 0; - font-size: 0.86rem; + .step-intro p { + margin: 0.2rem 0 0; + font-size: var(--font-size-sm, 0.75rem); color: var(--color-text-secondary); - text-transform: uppercase; - letter-spacing: 0.04em; + line-height: var(--line-height-relaxed, 1.625); } - label { + /* ─── Form fields ─── */ + .form-row-2 { display: grid; - gap: 0.25rem; - font-size: 0.78rem; - color: var(--color-text-secondary); + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + } + + .field { + display: grid; + gap: 0.3rem; + } + + .field__label { + font-size: var(--font-size-sm, 0.75rem); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); + } + + .field__label abbr { + color: var(--color-status-error-text); + text-decoration: none; + } + + .field__hint { + font-size: var(--font-size-xs, 0.6875rem); + color: var(--color-text-muted); } input, @@ -341,12 +521,13 @@ import { width: 100%; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - background: var(--color-surface-primary); + background: var(--color-surface-secondary); color: var(--color-text-primary); - padding: 0.5rem 0.625rem; - font-size: 0.8125rem; + padding: 0.45rem 0.6rem; + font-size: var(--font-size-base, 0.8125rem); font-family: inherit; - transition: border-color 150ms ease, box-shadow 150ms ease; + transition: border-color var(--motion-duration-sm, 140ms) ease, + box-shadow var(--motion-duration-sm, 140ms) ease; } input:focus, @@ -355,26 +536,53 @@ import { outline: none; border-color: var(--color-brand-primary); box-shadow: 0 0 0 2px var(--color-focus-ring); + background: var(--color-surface-primary); } + input::placeholder, + textarea::placeholder { + color: var(--color-text-muted); + } + + /* ─── Search input with icon ─── */ + .search-input-wrap { + position: relative; + display: flex; + align-items: center; + } + + .search-input-wrap__icon { + position: absolute; + left: 0.6rem; + color: var(--color-text-muted); + pointer-events: none; + } + + .search-input-wrap__input { + padding-left: 2rem; + } + + /* ─── Search results ─── */ .search-results { display: grid; - gap: 0.35rem; - max-height: 220px; + gap: 0.3rem; + max-height: 200px; overflow: auto; } .search-item { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - padding: 0.5rem 0.625rem; + padding: 0.45rem 0.6rem; display: grid; - gap: 0.15rem; + gap: 0.1rem; text-align: left; cursor: pointer; background: var(--color-surface-primary); color: var(--color-text-primary); - transition: border-color 150ms ease, background 150ms ease; + font-size: var(--font-size-sm, 0.75rem); + transition: border-color var(--motion-duration-sm, 140ms) ease, + background var(--motion-duration-sm, 140ms) ease; } .search-item:hover { @@ -384,27 +592,36 @@ import { .search-item span { color: var(--color-text-secondary); - font-size: 0.75rem; + font-size: var(--font-size-xs, 0.6875rem); } + /* ─── Selection panel ─── */ .selection-panel { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - padding: 0.7rem; + border: 1px solid var(--color-brand-primary-20, var(--color-border-secondary)); + border-radius: var(--radius-lg); + padding: 0.75rem; display: grid; - gap: 0.5rem; - background: var(--color-surface-primary); + gap: 0.6rem; + background: var(--color-brand-primary-10, var(--color-surface-subtle)); } - .selection-panel p { + .selection-panel__header h3 { margin: 0; - font-size: 0.72rem; + font-size: var(--font-size-base, 0.8125rem); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + text-transform: none; + letter-spacing: normal; + } + + .selection-panel__repo { + font-size: var(--font-size-xs, 0.6875rem); color: var(--color-text-secondary); } .digest-options { display: grid; - gap: 0.35rem; + gap: 0.3rem; } .digest-option { @@ -414,23 +631,24 @@ import { gap: 0.5rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); - padding: 0.5rem 0.625rem; - font-size: 0.8125rem; + padding: 0.4rem 0.6rem; + font-size: var(--font-size-sm, 0.75rem); cursor: pointer; background: var(--color-surface-primary); color: var(--color-text-primary); - transition: border-color 150ms ease, background 150ms ease, box-shadow 150ms ease; + transition: border-color var(--motion-duration-sm, 140ms) ease, + background var(--motion-duration-sm, 140ms) ease, + box-shadow var(--motion-duration-sm, 140ms) ease; } .digest-option:hover { border-color: var(--color-brand-primary); - background: var(--color-surface-elevated); } .digest-option code { - font-family: ui-monospace, SFMono-Regular, monospace; + font-family: var(--font-family-mono, ui-monospace, monospace); color: var(--color-text-secondary); - font-size: 0.75rem; + font-size: var(--font-size-xs, 0.6875rem); } .digest-option.selected { @@ -439,12 +657,81 @@ import { box-shadow: 0 0 0 2px var(--color-focus-ring); } + .btn-add-component { + display: inline-flex; + align-items: center; + gap: 0.35rem; + justify-self: start; + } + + /* ─── Components section ─── */ + .components-section { + display: grid; + gap: 0.5rem; + } + + .components-section__header { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .components-section__header h3 { + margin: 0; + font-size: var(--font-size-sm, 0.75rem); + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-secondary); + } + + .components-section__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + border-radius: var(--radius-full); + background: var(--color-brand-primary-10, var(--color-surface-secondary)); + color: var(--color-text-link); + font-size: var(--font-size-xs, 0.6875rem); + font-weight: var(--font-weight-semibold); + padding: 0 0.3rem; + } + + .components-empty { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 1.5rem 1rem; + border: 1px dashed var(--color-border-primary); + border-radius: var(--radius-md); + color: var(--color-text-muted); + } + + .components-empty svg { + margin-bottom: 0.5rem; + opacity: 0.5; + } + + .components-empty p { + margin: 0; + font-size: var(--font-size-sm, 0.75rem); + } + + .validation-note { + color: var(--color-status-warning-text); + font-size: var(--font-size-xs, 0.6875rem); + margin-top: 0.25rem; + } + .component-list { list-style: none; padding: 0; margin: 0; display: grid; - gap: 0.35rem; + gap: 0.3rem; } .component-list li { @@ -453,152 +740,289 @@ import { align-items: center; gap: 0.5rem; border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - padding: 0.35rem 0.45rem; + border-radius: var(--radius-md); + padding: 0.4rem 0.5rem; + transition: background var(--motion-duration-sm, 140ms) ease; } - .component-list li div { + .component-list li:hover { + background: var(--color-surface-secondary); + } + + .component-list__info { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; - font-size: 0.76rem; + font-size: var(--font-size-sm, 0.75rem); + } + + .component-list__version { + color: var(--color-text-secondary); } .component-list code { - font-family: ui-monospace, SFMono-Regular, monospace; - color: var(--color-text-secondary); + font-family: var(--font-family-mono, ui-monospace, monospace); + color: var(--color-text-muted); + font-size: var(--font-size-xs, 0.6875rem); } - .empty { - margin: 0; - color: var(--color-text-secondary); - font-size: 0.8rem; - } - - .validation-note, - .wizard-error { - margin: 0; - font-size: 0.78rem; - } - - .validation-note { - color: var(--color-status-warning-text); - } - - .wizard-error { - color: var(--color-status-error-text); - } - - .review-card { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - padding: 0.875rem; - background: var(--color-surface-primary); - } - - .review-card h3 { - margin: 0 0 0.5rem; - font-size: 0.875rem; - color: var(--color-text-primary); - text-transform: none; - letter-spacing: normal; - } - - .review-card p { - margin: 0.25rem 0; - font-size: 0.8125rem; - color: var(--color-text-secondary); - line-height: 1.5; - } - - .review-card code { - font-family: ui-monospace, SFMono-Regular, monospace; - color: var(--color-text-primary); - font-size: 0.75rem; - background: var(--color-surface-secondary); - padding: 0.125rem 0.375rem; + .btn-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + border: none; border-radius: var(--radius-sm); + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: color var(--motion-duration-sm, 140ms) ease, + background var(--motion-duration-sm, 140ms) ease; } + .btn-remove:hover { + color: var(--color-status-error-text); + background: var(--color-status-error-bg); + } + + /* ─── Checkbox row ─── */ .checkbox-row { display: inline-flex; align-items: center; - gap: 0.45rem; + gap: 0.5rem; color: var(--color-text-primary); + font-size: var(--font-size-sm, 0.75rem); + cursor: pointer; } .checkbox-row input { width: auto; } - .checkbox-row.seal { + /* ─── Review cards ─── */ + .review-cards { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + } + + .review-card { border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - padding: 0.5rem; + border-radius: var(--radius-lg); + padding: 0.85rem; background: var(--color-surface-primary); } + .review-card__header { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.6rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border-primary); + color: var(--color-text-secondary); + } + + .review-card__header h3 { + margin: 0; + font-size: var(--font-size-sm, 0.75rem); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + text-transform: none; + letter-spacing: normal; + } + + .review-card__dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.3rem 0.75rem; + margin: 0; + font-size: var(--font-size-sm, 0.75rem); + } + + .review-card__dl dt { + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); + } + + .review-card__dl dd { + margin: 0; + color: var(--color-text-primary); + } + + .review-card__dl code { + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: var(--font-size-xs, 0.6875rem); + background: var(--color-surface-secondary); + padding: 0.1rem 0.35rem; + border-radius: var(--radius-sm); + } + + .review-badge { + display: inline-block; + padding: 0.05rem 0.4rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-full); + font-size: var(--font-size-xs, 0.6875rem); + text-transform: uppercase; + letter-spacing: 0.03em; + } + + /* ─── Seal confirm ─── */ + .seal-confirm { + display: flex; + align-items: flex-start; + gap: 0.6rem; + padding: 0.75rem; + border: 1px solid var(--color-brand-primary-20, var(--color-border-secondary)); + border-radius: var(--radius-lg); + background: var(--color-brand-primary-10, var(--color-surface-subtle)); + cursor: pointer; + } + + .seal-confirm input { + width: auto; + margin-top: 0.15rem; + } + + .seal-confirm__text { + display: grid; + gap: 0.15rem; + } + + .seal-confirm__text strong { + font-size: var(--font-size-sm, 0.75rem); + color: var(--color-text-primary); + } + + .seal-confirm__text span { + font-size: var(--font-size-xs, 0.6875rem); + color: var(--color-text-secondary); + } + + /* ─── Error ─── */ + .wizard-error { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.55rem 0.75rem; + border: 1px solid var(--color-status-error-border); + border-radius: var(--radius-md); + background: var(--color-status-error-bg); + color: var(--color-status-error-text); + font-size: var(--font-size-sm, 0.75rem); + } + + .wizard-error svg { flex-shrink: 0; } + + /* ─── Footer actions ─── */ .wizard-actions { display: flex; align-items: center; gap: 0.5rem; } - .spacer { - flex: 1; + .wizard-actions__spacer { flex: 1; } + + .wizard-actions__step-label { + font-size: var(--font-size-xs, 0.6875rem); + color: var(--color-text-muted); + margin-right: 0.25rem; } + /* ─── Buttons ─── */ .btn-primary, .btn-secondary, - .btn-ghost { - border: 1px solid var(--color-border-primary); + .btn-ghost, + .btn-seal { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; border-radius: var(--radius-md); - padding: 0.5rem 1rem; - font-size: 0.8125rem; - font-weight: 600; + padding: 0.45rem 0.85rem; + font-size: var(--font-size-sm, 0.75rem); + font-weight: var(--font-weight-semibold); cursor: pointer; - background: var(--color-surface-primary); - color: var(--color-text-primary); - transition: opacity 150ms ease, transform 150ms ease, box-shadow 150ms ease; + white-space: nowrap; + transition: background var(--motion-duration-sm, 140ms) ease, + border-color var(--motion-duration-sm, 140ms) ease, + box-shadow var(--motion-duration-sm, 140ms) ease; } .btn-primary { - border: none; + border: 1px solid var(--color-btn-primary-border); background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } .btn-primary:hover:not(:disabled) { - opacity: 0.9; - transform: translateY(-1px); + background: var(--color-btn-primary-bg-hover); + box-shadow: var(--shadow-sm); } .btn-secondary { - background: var(--color-surface-secondary); + border: 1px solid var(--color-btn-secondary-border); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); } .btn-secondary:hover:not(:disabled) { - border-color: var(--color-brand-primary); - transform: translateY(-1px); + background: var(--color-btn-secondary-hover-bg); + border-color: var(--color-btn-secondary-hover-border); } .btn-ghost { + border: 1px solid var(--color-border-primary); background: transparent; - border-color: var(--color-border-primary); + color: var(--color-text-secondary); } .btn-ghost:hover:not(:disabled) { background: var(--color-surface-secondary); - transform: translateY(-1px); + color: var(--color-text-primary); } + .btn-seal { + border: 1px solid var(--color-btn-primary-border); + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); + padding: 0.45rem 1.1rem; + } + + .btn-seal:hover:not(:disabled) { + background: var(--color-btn-primary-bg-hover); + box-shadow: var(--shadow-sm); + } + + .btn-seal__spinner { + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: spin 0.7s linear infinite; + } + + @keyframes spin { to { transform: rotate(360deg); } } + .btn-primary:disabled, .btn-secondary:disabled, - .btn-ghost:disabled { - opacity: 0.5; + .btn-ghost:disabled, + .btn-seal:disabled { + opacity: 0.45; cursor: not-allowed; } + + /* ─── Responsive ─── */ + @media (max-width: 720px) { + .form-row-2 { grid-template-columns: 1fr; } + .review-cards { grid-template-columns: 1fr; } + .stepper__label { display: none; } + .wizard-header { flex-direction: column; gap: 0.5rem; } + } `], }) export class CreateReleaseComponent implements OnInit { diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts index 6fa8f65c5..1e41ae413 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-detail/release-detail.component.ts @@ -13,8 +13,8 @@ import { DegradedStateBannerComponent } from '../../../../shared/components/degr import { buildContextReturnTo } from '../../../../shared/ui/context-route-state/context-route-state'; import { AuditorOnlyDirective } from '../../../../shared/directives/auditor-only.directive'; import { OperatorOnlyDirective } from '../../../../shared/directives/operator-only.directive'; -import { ViewModeToggleComponent } from '../../../../shared/components/view-mode-toggle/view-mode-toggle.component'; - +import { DateFormatService } from '../../../../core/i18n/date-format.service'; +import { StellaPageTabsComponent, StellaPageTab } from '../../../../shared/components/stella-page-tabs/stella-page-tabs.component'; interface PlatformListResponse { items: T[]; total: number; limit: number; offset: number; } interface PlatformItemResponse { item: T; } interface ReleaseActivityProjection { activityId: string; releaseId: string; releaseName: string; eventType: string; status: string; targetEnvironment?: string | null; targetRegion?: string | null; actorId: string; occurredAt: string; correlationKey: string; } @@ -138,7 +138,7 @@ interface ReloadOptions { @Component({ selector: 'app-release-detail', standalone: true, - imports: [RouterLink, FormsModule, DegradedStateBannerComponent, AuditorOnlyDirective, OperatorOnlyDirective, ViewModeToggleComponent], + imports: [RouterLink, FormsModule, DegradedStateBannerComponent, AuditorOnlyDirective, OperatorOnlyDirective, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -156,7 +156,6 @@ interface ReloadOptions { {{ getEvidencePostureLabel(release()!.evidencePosture) }}
- @@ -191,11 +190,12 @@ interface ReloadOptions { /> } - + @switch (activeTab()) { @case ('overview') { @@ -352,8 +352,8 @@ interface ReloadOptions { styles: [` .workbench{display:grid;gap:.6rem}.header,.tabs a,article,aside,table,.banner{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary)} .header{padding:.6rem}.header h1{margin:0;font-size:1.2rem}.header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.75rem;word-break:break-all} - .chips,.actions,.tabs,.tabs-inline{display:flex;gap:.3rem;flex-wrap:wrap}.chips span,.tabs a,.tabs-inline button{padding:.1rem .45rem;font-size:.72rem;color:var(--color-text-secondary);text-decoration:none} - .tabs a.active,.tabs-inline .active{color:var(--color-tab-active-text, var(--color-text-primary));border-color:var(--color-tab-active-border, var(--color-brand-primary));font-weight:600} + .chips,.actions,.tabs-inline{display:flex;gap:.3rem;flex-wrap:wrap}.chips span,.tabs-inline button{padding:.1rem .45rem;font-size:.72rem;color:var(--color-text-secondary);text-decoration:none} + .tabs-inline .active{color:var(--color-tab-active-text, var(--color-text-primary));border-color:var(--color-tab-active-border, var(--color-brand-primary));font-weight:600} .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:.5rem}article{padding:.55rem}article h3{margin:0 0 .3rem;font-size:.88rem}article p,article li{font-size:.75rem;color:var(--color-text-secondary)} .split{display:grid;grid-template-columns:1fr 220px;gap:.5rem}aside{padding:.55rem;display:grid;gap:.25rem;align-content:start} table{width:100%;border-collapse:collapse}th,td{border-bottom:1px solid var(--color-border-primary);padding:.38rem .45rem;font-size:.72rem;text-align:left;vertical-align:top}th{font-size:.66rem;color:var(--color-text-secondary);text-transform:uppercase} @@ -365,6 +365,8 @@ interface ReloadOptions { `], }) export class ReleaseDetailComponent { + private readonly dateFmt = inject(DateFormatService); + private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); private readonly http = inject(HttpClient); @@ -391,6 +393,25 @@ export class ReleaseDetailComponent { ] as const; readonly tabs = computed(() => (this.mode() === 'version' ? this.versionTabs : this.runTabs)); + private readonly TAB_ICONS: Record = { + 'overview': 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0', + 'timeline': 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2', + 'gate-decision': 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', + 'approvals': 'M22 11.08V12a10 10 0 1 1-5.93-9.14|||M22 4L12 14.01l-3-3', + 'deployments': 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01', + 'security-inputs': 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01', + 'evidence': 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8', + 'rollback': 'M16 18l6-6-6-6|||M8 6l-6 6 6 6', + 'replay': 'M16 18l6-6-6-6|||M8 6l-6 6 6 6', + }; + readonly RELEASE_RO_TABS = computed(() => + this.tabs().map(t => ({ id: t.id, label: t.label, icon: this.TAB_ICONS[t.id] || 'M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0-2 0' })) + ); + + onTabChange(tabId: string): void { + void this.router.navigate([this.detailBasePath(), this.releaseId(), tabId]); + } + readonly loading = signal(false); readonly error = signal(null); readonly refreshing = signal(false); @@ -741,7 +762,7 @@ export class ReleaseDetailComponent { fmt(value: string): string { const d = new Date(value); if (Number.isNaN(d.getTime())) return value; - return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + return d.toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } private reload(entityId: string, options: ReloadOptions = {}): void { diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/release-list.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/release-list.component.ts index dbe66561f..bebcf16a0 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/release-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/releases/release-list/release-list.component.ts @@ -1,5 +1,5 @@ // Filter bar adoption: SPRINT_20260308_015_FE (FE-OFB-003) -import { Component, OnInit, inject, signal } from '@angular/core'; +import { Component, OnInit, inject, signal, computed } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, Router, RouterModule } from '@angular/router'; @@ -16,44 +16,29 @@ import { } from '../../../../core/api/release-management.models'; import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../../shared/ui/filter-bar/filter-bar.component'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; @Component({ selector: 'app-release-list', imports: [FormsModule, RouterModule, FilterBarComponent], template: `
-
+

Release Versions

Digest-first release version catalog across standard and hotfix lanes

- - + +
-
- {{ context.regionSummary() }} - {{ context.environmentSummary() }} - {{ context.timeWindow() }} -
- -
- - - - -
- + @if (selectedCount() > 0) { +
+ +
+ + @if (selectedCount() >= 2) { + + } + +
+ } + + @if (store.releases().length > 0) { +
+
+ Gates + + {{ healthSummary().gatePass }} + + @if (healthSummary().gateWarn > 0) { + + {{ healthSummary().gateWarn }} + + } + @if (healthSummary().gateBlock > 0) { + + {{ healthSummary().gateBlock }} blocked + + } +
+ +
+ Risk + @if (healthSummary().riskCritical > 0) { + {{ healthSummary().riskCritical }} critical + } + @if (healthSummary().riskHigh > 0) { + {{ healthSummary().riskHigh }} high + } + @if (healthSummary().riskCritical === 0 && healthSummary().riskHigh === 0) { + clear + } +
+ +
+ Evidence + {{ healthSummary().evidenceVerified }} verified + @if (healthSummary().evidencePartial > 0) { + {{ healthSummary().evidencePartial }} partial + } + @if (healthSummary().evidenceMissing > 0) { + {{ healthSummary().evidenceMissing }} missing + } +
+ @if (healthSummary().blockedCount > 0) { + +
+ + {{ healthSummary().blockedCount }} release{{ healthSummary().blockedCount > 1 ? 's' : '' }} blocked +
+ } +
+ } + @if (store.error()) {
+ {{ store.error() }} - +
} @if (store.loading() && store.releases().length === 0) { -
Loading releases...
- } @else if (store.releases().length === 0) { -
No releases match the current combined filters.
- } @else { - - - - - - - - - - - - - - - @for (release of store.releases(); track release.id) { - - - - - - - - - - +
+
+ Loading releases... +
+ } @else if (store.releases().length === 0 && !store.error()) { +
+
+ + + + + + +
+

No release versions found

+

+ @if (hasActiveFilters()) { + No releases match the current filters. Try broadening your search or clearing filters. + } @else { + Create your first release version to start tracking governed deployments with policy gates and verifiable evidence. } -

-
Digest IdentityType / StageGate PostureRisk DeltaEvidence PostureActor / Last UpdateActions
- - - - {{ release.digest || 'digest-unavailable' }} - -
{{ release.name }} · {{ release.version }}
-
-
- {{ release.releaseType }} - {{ release.currentEnvironment || release.targetEnvironment || release.currentStage || '-' }} -
-
Region: {{ release.targetRegion || '-' }}
-
-
- {{ getGateStatusLabel(release.gateStatus) }} -
-
- Blocking: {{ release.gateBlockingCount }} · Pending approvals: {{ release.gatePendingApprovals }} -
-
-
{{ getRiskTierLabel(release.riskTier) }}
-
- Critical reachable {{ release.riskCriticalReachable }} · High reachable {{ release.riskHighReachable }} -
-
-
{{ getEvidencePostureLabel(release.evidencePosture) }}
-
Replay mismatch: {{ release.replayMismatch ? 'yes' : 'no' }}
-
-
{{ release.lastActor || release.createdBy }}
-
{{ formatDateTime(release.updatedAt) }}
-
- Open -
+

+
+ @if (hasActiveFilters()) { + + } + +
+
+ } @else { +
+ + + + + + + + + + + + + + + @for (release of store.releases(); track release.id) { + + + + + + + + + + + } + +
+ + Digest IdentityType / StageGate PostureRisk DeltaEvidence PostureActor / Last Update
+ + + + {{ release.digest || 'digest-unavailable' }} + +
{{ release.name }} · {{ release.version }}
+
+
+ {{ release.releaseType }} + {{ release.currentEnvironment || release.targetEnvironment || release.currentStage || '-' }} +
+
{{ release.targetRegion || '-' }}
+
+ + {{ getGateStatusLabel(release.gateStatus) }} + +
+ {{ release.gateBlockingCount }} blocking · {{ release.gatePendingApprovals }} pending +
+
+
{{ getRiskTierLabel(release.riskTier) }}
+
+ {{ release.riskCriticalReachable }} critical · {{ release.riskHighReachable }} high +
+
+
{{ getEvidencePostureLabel(release.evidencePosture) }}
+
Replay: {{ release.replayMismatch ? 'mismatch' : 'ok' }}
+
+
{{ release.lastActor || release.createdBy }}
+
{{ formatDateTime(release.updatedAt) }}
+
+ + + +
+
@if (store.totalCount() > store.pageSize()) { } @@ -160,133 +258,356 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../../shar
`, styles: [` + /* ─── Page layout ─── */ .release-list { display: grid; - gap: 0.9rem; + gap: 0.5rem; max-width: 1600px; margin: 0 auto; } + /* ─── Header ─── */ .list-header { display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; gap: 1rem; + flex-wrap: wrap; } .list-header h1 { margin: 0; + font-size: var(--font-size-xl, 1.25rem); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-tight, 1.25); } .subtitle { margin: 0.2rem 0 0; color: var(--color-text-secondary); - font-size: 0.82rem; + font-size: var(--font-size-sm, 0.75rem); } .header-actions { display: flex; - gap: 0.4rem; + gap: 0.5rem; + flex-shrink: 0; } - .context-strip { - display: flex; - gap: 0.4rem; - flex-wrap: wrap; - } - - .context-strip span { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-full); - padding: 0.15rem 0.55rem; - font-size: 0.72rem; - color: var(--color-text-secondary); - background: var(--color-surface-primary); - } - - .bulk-actions { - display: flex; - gap: 0.4rem; - align-items: center; - flex-wrap: wrap; - } - - .bulk-select-all { + /* ─── Buttons ─── */ + .btn-primary, + .btn-secondary { display: inline-flex; align-items: center; - gap: 0.4rem; - font-size: 0.8rem; - color: var(--color-text-secondary); - margin-right: 0.5rem; + justify-content: center; + gap: 0.35rem; + border-radius: var(--radius-md); + font-size: var(--font-size-sm, 0.75rem); + font-weight: var(--font-weight-medium); + cursor: pointer; + padding: 0.4rem 0.75rem; + text-decoration: none; + white-space: nowrap; + transition: background var(--motion-duration-sm, 140ms) ease, + border-color var(--motion-duration-sm, 140ms) ease, + box-shadow var(--motion-duration-sm, 140ms) ease; } - /* Filter bar styles handled by shared FilterBarComponent */ + .btn-primary { + border: 1px solid var(--color-btn-primary-border); + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); + } - .status-banner { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - padding: 0.8rem; - font-size: 0.82rem; + .btn-primary:hover { + background: var(--color-btn-primary-bg-hover); + box-shadow: var(--shadow-sm); + } + + .btn-secondary { + border: 1px solid var(--color-btn-secondary-border); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); + } + + .btn-secondary:hover { + background: var(--color-btn-secondary-hover-bg); + border-color: var(--color-btn-secondary-hover-border); + } + + .btn-primary svg, + .btn-secondary svg { + flex-shrink: 0; + } + + /* ─── Bulk action bar ─── */ + .bulk-bar { display: flex; - gap: 0.6rem; align-items: center; - justify-content: space-between; + gap: 0.5rem; + padding: 0.4rem 0.75rem; + font-size: var(--font-size-sm, 0.75rem); + color: var(--color-text-primary); + background: var(--color-selection-bg, var(--color-brand-primary-10)); + border: 1px solid var(--color-selection-border, var(--color-brand-primary-20)); + border-radius: var(--radius-md); + animation: slideDown var(--motion-duration-md, 200ms) var(--motion-ease-entrance, ease-out); + } + + @keyframes slideDown { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } + } + + .bulk-bar__select-all { + display: inline-flex; + align-items: center; + gap: 0.35rem; + cursor: pointer; + white-space: nowrap; + } + + .bulk-bar__divider { + width: 1px; + height: 16px; + background: var(--color-border-primary); + flex-shrink: 0; + } + + .bulk-bar__action { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.2rem 0.55rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-primary); + color: var(--color-text-primary); + font-size: var(--font-size-xs, 0.6875rem); + cursor: pointer; + white-space: nowrap; + transition: background var(--motion-duration-sm, 140ms) ease, + border-color var(--motion-duration-sm, 140ms) ease; + } + + .bulk-bar__action:hover { + background: var(--color-surface-secondary); + border-color: var(--color-border-secondary); + } + + .bulk-bar__action--ghost { + border-color: transparent; + background: transparent; + color: var(--color-text-secondary); + } + + .bulk-bar__action--ghost:hover { + color: var(--color-text-primary); + background: transparent; + text-decoration: underline; + } + + /* ─── Status / Error banner ─── */ + .status-banner { + border: 1px solid var(--color-status-error-border); + border-radius: var(--radius-md); + background: var(--color-status-error-bg); + padding: 0.6rem 0.8rem; + font-size: var(--font-size-sm, 0.75rem); + display: flex; + gap: 0.5rem; + align-items: center; } .status-banner.error { color: var(--color-status-error-text); } + .status-banner__icon { + flex-shrink: 0; + } + + .status-banner span { + flex: 1; + } + + .status-banner__retry { + flex-shrink: 0; + padding: 0.25rem 0.6rem; + border: 1px solid var(--color-status-error-border); + border-radius: var(--radius-sm); + background: transparent; + color: var(--color-status-error-text); + font-size: var(--font-size-xs, 0.6875rem); + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: background var(--motion-duration-sm, 140ms) ease; + } + + .status-banner__retry:hover { + background: color-mix(in srgb, var(--color-status-error-text) 10%, transparent); + } + + /* ─── Loading state ─── */ + .loading-state { + display: flex; + align-items: center; + justify-content: center; + gap: 0.6rem; + padding: 3rem 1rem; + font-size: var(--font-size-sm, 0.75rem); + color: var(--color-text-secondary); + } + + .loading-state__spinner { + width: 18px; + height: 18px; + border: 2px solid var(--color-border-primary); + border-top-color: var(--color-brand-primary); + border-radius: 50%; + animation: spin 0.7s linear infinite; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + + /* ─── Empty state ─── */ + .empty-state { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 3.5rem 1.5rem 4rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + } + + .empty-state__icon { + display: flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + border-radius: var(--radius-xl); + background: var(--color-brand-primary-10, var(--color-surface-secondary)); + color: var(--color-brand-primary); + margin-bottom: 1.25rem; + } + + .empty-state__title { + margin: 0 0 0.4rem; + font-size: var(--font-size-md, 1rem); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + } + + .empty-state__desc { + margin: 0 0 1.5rem; + max-width: 420px; + font-size: var(--font-size-sm, 0.75rem); + color: var(--color-text-secondary); + line-height: var(--line-height-relaxed, 1.625); + } + + .empty-state__actions { + display: flex; + gap: 0.5rem; + align-items: center; + } + + /* ─── Table ─── */ + .table-container { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + overflow: hidden; + background: var(--color-surface-primary); + } + .release-table { width: 100%; border-collapse: collapse; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - overflow: hidden; - background: var(--color-surface-primary); } .release-table th, .release-table td { text-align: left; - border-bottom: 1px solid var(--color-border-primary); - padding: 0.55rem 0.65rem; + padding: 0.5rem 0.6rem; vertical-align: top; - font-size: 0.78rem; + font-size: var(--font-size-sm, 0.75rem); + } + + .release-table thead { + border-bottom: 1px solid var(--color-border-primary); } .release-table th { - font-size: 0.7rem; + font-size: var(--font-size-xs, 0.6875rem); + font-weight: var(--font-weight-medium); text-transform: uppercase; letter-spacing: 0.04em; color: var(--color-text-secondary); + background: var(--color-surface-secondary); + padding: 0.45rem 0.6rem; + white-space: nowrap; } - .release-table tr:last-child td { + .release-table tbody tr { + border-bottom: 1px solid var(--color-border-primary); + transition: background var(--motion-duration-sm, 140ms) ease; + } + + .release-table tbody tr:last-child { border-bottom: none; } - .release-table tr.blocked { - background: color-mix(in srgb, var(--color-status-error-bg, rgba(255, 90, 95, 0.12)) 35%, transparent); + .release-table tbody tr:hover { + background: var(--color-surface-secondary); } - .narrow { - width: 42px; + .release-table tbody tr.selected { + background: var(--color-selection-bg, var(--color-brand-primary-10)); + } + + .release-table tbody tr.blocked { + background: var(--color-status-error-bg); + } + + .release-table tbody tr.blocked:hover { + background: color-mix(in srgb, var(--color-status-error-bg) 80%, var(--color-surface-secondary)); + } + + /* Column sizing */ + .col-check { width: 36px; text-align: center; vertical-align: middle; } + .col-identity { min-width: 180px; } + .col-type { min-width: 120px; } + .col-gate { min-width: 110px; } + .col-risk { min-width: 100px; } + .col-evidence { min-width: 100px; } + .col-actor { min-width: 110px; } + .col-actions { width: 40px; text-align: center; vertical-align: middle; } + + .col-check input[type="checkbox"] { + cursor: pointer; } .identity-link { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; - font-family: ui-monospace, SFMono-Regular, monospace; - font-size: 0.74rem; - line-height: 1.2; + font-family: var(--font-family-mono, ui-monospace, monospace); + font-size: var(--font-size-xs, 0.6875rem); + line-height: 1.3; } - .identity-meta { - margin-top: 0.2rem; - color: var(--color-text-secondary); - font-size: 0.7rem; + .identity-link:hover { + text-decoration: underline; + } + + .meta { + margin-top: 0.15rem; + color: var(--color-text-muted); + font-size: var(--font-size-xs, 0.6875rem); } .type-stage { @@ -296,93 +617,211 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../../../shar flex-wrap: wrap; } - .badge { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-full); - padding: 0.08rem 0.5rem; - text-transform: uppercase; - letter-spacing: 0.04em; - font-size: 0.66rem; - color: var(--color-text-secondary); + .stage-label { + font-size: var(--font-size-sm, 0.75rem); } - .gate-status { + .badge { + display: inline-flex; + align-items: center; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-full); + padding: 0.06rem 0.45rem; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: var(--font-size-xs, 0.6875rem); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + line-height: 1.4; + } + + .badge--hotfix { + color: var(--color-status-warning-text); + border-color: var(--color-status-warning-border); + background: var(--color-status-warning-bg); + } + + .gate-chip { display: inline-flex; align-items: center; border-radius: var(--radius-full); - border: 1px solid var(--color-border-primary); - padding: 0.08rem 0.5rem; - font-size: 0.68rem; + padding: 0.06rem 0.45rem; + font-size: var(--font-size-xs, 0.6875rem); + font-weight: var(--font-weight-semibold); text-transform: uppercase; letter-spacing: 0.04em; - font-weight: var(--font-weight-semibold); - } - - .gate-status--pass { - color: var(--color-status-success-text); - border-color: var(--color-status-success-text); - } - - .gate-status--warn, - .gate-status--pending { - color: var(--color-status-warning-text); - border-color: var(--color-status-warning-text); - } - - .gate-status--block { - color: var(--color-status-error-text); - border-color: var(--color-status-error-text); - } - - .btn-primary, - .btn-secondary, - .btn-ghost, - .btn-link { - border-radius: var(--radius-sm); - font-size: 0.75rem; - cursor: pointer; - padding: 0.34rem 0.55rem; + line-height: 1.4; border: 1px solid var(--color-border-primary); - background: var(--color-surface-primary); - color: var(--color-text-primary); + } + + .gate-chip--pass { + color: var(--color-status-success-text); + border-color: var(--color-status-success-border); + background: var(--color-status-success-bg); + } + + .gate-chip--warn, + .gate-chip--pending { + color: var(--color-status-warning-text); + border-color: var(--color-status-warning-border); + background: var(--color-status-warning-bg); + } + + .gate-chip--block { + color: var(--color-status-error-text); + border-color: var(--color-status-error-border); + background: var(--color-status-error-bg); + } + + /* Row action (chevron) */ + .row-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + color: var(--color-text-muted); text-decoration: none; + transition: color var(--motion-duration-sm, 140ms) ease, + background var(--motion-duration-sm, 140ms) ease; } - .btn-primary { - border-color: var(--color-brand-primary); - background: var(--color-btn-primary-bg); - color: var(--color-btn-primary-text); - } - - .btn-ghost { - border-style: dashed; - color: var(--color-text-secondary); - } - - .btn-secondary:disabled, - .btn-ghost:disabled { - opacity: 0.5; - cursor: not-allowed; + .row-action:hover { + color: var(--color-text-link); + background: var(--color-surface-tertiary); } + /* ─── Pagination ─── */ .pagination { display: flex; justify-content: center; align-items: center; - gap: 0.5rem; - font-size: 0.8rem; + gap: 0.75rem; + padding-top: 0.25rem; + } + + .pagination__btn { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.3rem 0.6rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + color: var(--color-text-primary); + font-size: var(--font-size-sm, 0.75rem); + cursor: pointer; + transition: background var(--motion-duration-sm, 140ms) ease, + border-color var(--motion-duration-sm, 140ms) ease; + } + + .pagination__btn:hover:not(:disabled) { + background: var(--color-surface-secondary); + border-color: var(--color-border-secondary); + } + + .pagination__btn:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .pagination__info { + font-size: var(--font-size-sm, 0.75rem); color: var(--color-text-secondary); } + /* ─── Health summary strip ─── */ + .health-strip { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.65rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + font-size: var(--font-size-xs, 0.6875rem); + overflow-x: auto; + } + + .health-strip__group { + display: flex; + align-items: center; + gap: 0.4rem; + white-space: nowrap; + } + + .health-strip__group--alert { + color: var(--color-status-warning-text); + font-weight: var(--font-weight-medium); + } + + .health-strip__group--alert svg { + flex-shrink: 0; + } + + .health-strip__label { + color: var(--color-text-muted); + font-weight: var(--font-weight-medium); + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 0.6rem; + } + + .health-strip__sep { + width: 1px; + height: 16px; + background: var(--color-border-primary); + flex-shrink: 0; + } + + .health-strip__metric { + display: inline-flex; + align-items: center; + gap: 0.2rem; + color: var(--color-text-secondary); + } + + .health-strip__dot { + width: 6px; + height: 6px; + border-radius: 50%; + flex-shrink: 0; + } + + .health-strip__dot.hs--pass { background: var(--color-status-success-text); } + .health-strip__dot.hs--warn { background: var(--color-status-warning-text); } + .health-strip__dot.hs--block { background: var(--color-status-error-text); } + + .health-strip__metric.hs--pass { color: var(--color-status-success-text); } + .health-strip__metric.hs--warn { color: var(--color-status-warning-text); } + .health-strip__metric.hs--block { color: var(--color-status-error-text); } + + /* ─── Responsive ─── */ @media (max-width: 920px) { - .release-table { - display: block; + .table-container { overflow-x: auto; } + + .list-header { + flex-direction: column; + gap: 0.75rem; + } + + .header-actions { + width: 100%; + } + + .header-actions .btn-primary, + .header-actions .btn-secondary { + flex: 1; + } } `], }) export class ReleaseListComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + readonly store = inject(ReleaseManagementStore); readonly context = inject(PlatformContextStore); private readonly route = inject(ActivatedRoute); @@ -420,6 +859,28 @@ export class ReleaseListComponent implements OnInit { readonly getRiskTierLabel = getRiskTierLabel; readonly getEvidencePostureLabel = getEvidencePostureLabel; + readonly healthSummary = computed(() => { + const releases = this.store.releases(); + let gatePass = 0, gateWarn = 0, gateBlock = 0; + let riskCritical = 0, riskHigh = 0; + let evidenceVerified = 0, evidencePartial = 0, evidenceMissing = 0; + let blockedCount = 0; + for (const r of releases) { + if (r.gateStatus === 'pass') gatePass++; + else if (r.gateStatus === 'warn' || r.gateStatus === 'pending') gateWarn++; + else if (r.gateStatus === 'block') gateBlock++; + else gatePass++; // unknown counts as non-blocking + riskCritical += r.riskCriticalReachable; + riskHigh += r.riskHighReachable; + if (r.evidencePosture === 'verified') evidenceVerified++; + else if (r.evidencePosture === 'partial') evidencePartial++; + else if (r.evidencePosture === 'missing' || r.evidencePosture === 'replay_mismatch') evidenceMissing++; + else evidencePartial++; // unknown + if (r.blocked) blockedCount++; + } + return { gatePass, gateWarn, gateBlock, riskCritical, riskHigh, evidenceVerified, evidencePartial, evidenceMissing, blockedCount }; + }); + ngOnInit(): void { this.context.initialize(); this.route.queryParamMap.subscribe((params) => { @@ -588,6 +1049,10 @@ export class ReleaseListComponent implements OnInit { return this.selectedReleaseIds().size; } + hasActiveFilters(): boolean { + return this.activeReleaseFilters().length > 0 || this.searchTerm.trim().length > 0; + } + clearSelection(): void { this.selectedReleaseIds.set(new Set()); } @@ -633,7 +1098,7 @@ export class ReleaseListComponent implements OnInit { return value; } - return parsed.toLocaleString('en-US', { + return parsed.toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', diff --git a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/workflows/workflow-list/workflow-list.component.ts b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/workflows/workflow-list/workflow-list.component.ts index 9924aaf26..81ca15bd1 100644 --- a/src/Web/StellaOps.Web/src/app/features/release-orchestrator/workflows/workflow-list/workflow-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/release-orchestrator/workflows/workflow-list/workflow-list.component.ts @@ -9,10 +9,12 @@ import { FormsModule } from '@angular/forms'; import { WorkflowStore } from '../workflow.store'; import type { Workflow, WorkflowStatus } from '../../../../core/api/workflow.models'; import { getStatusLabel, getStatusColor } from '../../../../core/api/workflow.models'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; @Component({ selector: 'app-workflow-list', - imports: [RouterLink, FormsModule], + imports: [RouterLink, FormsModule, LoadingStateComponent], template: `
`, - styles: [ - ` - .hotfix-create { display: grid; gap: 1rem; max-width: 680px; } - h1 { margin: 0; font-size: 1.35rem; } - p { margin: 0; color: var(--color-text-secondary); } - form { display: grid; gap: 0.7rem; } - label { display: grid; gap: 0.2rem; font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--color-text-secondary); } - input, select { border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 0.45rem; background: var(--color-surface-primary); color: var(--color-text-primary); text-transform: none; letter-spacing: 0; } - button { justify-self: start; border: 1px solid var(--color-brand-primary); border-radius: var(--radius-sm); padding: 0.45rem 0.7rem; background: var(--color-brand-primary); color: #fff; } - `, - ], + styles: [` + .create-page { max-width: 720px; margin: 0 auto; } + + .breadcrumb { + display: flex; + align-items: center; + gap: 0.375rem; + margin-bottom: 1rem; + font-size: 0.8125rem; + color: var(--color-text-secondary); + } + .breadcrumb a { + color: var(--color-text-link); + text-decoration: none; + } + .breadcrumb a:hover { text-decoration: underline; } + + .page-header { margin-bottom: 1.25rem; } + .page-title { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: var(--font-weight-semibold); } + .page-subtitle { margin: 0; color: var(--color-text-secondary); font-size: 0.875rem; } + + .form-card { + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + padding: 1.5rem; + } + + .form { display: flex; flex-direction: column; gap: 1.25rem; } + .form-group { display: flex; flex-direction: column; gap: 0.375rem; } + .form-label { + font-size: 0.75rem; + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-secondary); + } + .form-input, .form-select, .form-textarea { + padding: 0.5rem 0.75rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + font-size: 0.875rem; + background: var(--color-surface-primary); + color: var(--color-text-primary); + transition: border-color 150ms ease, box-shadow 150ms ease; + } + .form-input:focus, .form-select:focus, .form-textarea:focus { + outline: none; + border-color: var(--color-brand-primary); + box-shadow: 0 0 0 2px var(--color-focus-ring); + } + .form-input--mono { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.8125rem; + } + .form-textarea { resize: vertical; min-height: 80px; } + .form-hint { + font-size: 0.6875rem; + color: var(--color-text-secondary); + } + + /* Urgency radio cards */ + .urgency-options { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.625rem; + } + .urgency-option { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.75rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + cursor: pointer; + transition: border-color 150ms ease, box-shadow 150ms ease; + } + .urgency-option:has(input:checked) { + border-color: var(--color-brand-primary); + box-shadow: 0 0 0 1px var(--color-brand-primary); + } + .urgency-option input { position: absolute; opacity: 0; pointer-events: none; } + .urgency-option__label { + font-size: 0.8125rem; + font-weight: var(--font-weight-semibold); + } + .urgency-option--critical .urgency-option__label { color: var(--color-status-error-text, #B91C1C); } + .urgency-option--high .urgency-option__label { color: var(--color-status-warning-text, #92400E); } + .urgency-option--medium .urgency-option__label { color: var(--color-severity-medium, #854D0E); } + .urgency-option__desc { + font-size: 0.6875rem; + color: var(--color-text-secondary); + } + + .form-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding-top: 0.75rem; + border-top: 1px solid var(--color-border-primary); + } + + /* Buttons */ + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border-radius: var(--radius-md); + font-size: 0.8125rem; + font-weight: var(--font-weight-medium); + cursor: pointer; + text-decoration: none; + transition: opacity 150ms ease, transform 150ms ease; + border: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); + color: var(--color-text-primary); + } + .btn:hover { opacity: 0.85; transform: translateY(-1px); } + .btn--primary { background: var(--color-btn-primary-bg); border: none; color: var(--color-btn-primary-text); } + .btn--primary:hover { opacity: 0.9; } + .btn--secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); } + `], }) export class HotfixCreatePageComponent {} diff --git a/src/Web/StellaOps.Web/src/app/features/releases/hotfix-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/hotfix-detail-page.component.ts index 3e82582b1..b71fe306d 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/hotfix-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/hotfix-detail-page.component.ts @@ -1,37 +1,443 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { ActivatedRoute, RouterLink } from '@angular/router'; +interface GateOutcome { + name: string; + result: 'pass' | 'warn' | 'block'; + detail: string; +} + +interface TimelineEntry { + time: string; + action: string; + actor: string; +} + @Component({ selector: 'app-hotfix-detail-page', standalone: true, imports: [RouterLink], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
-
-

Hotfix {{ hotfixId }}

-

Review gate outcomes, waivers/VEX disposition, and evidence before final approval.

+
+ + + + + -
- Open Approval Queue - Open Evidence Capsules - Open Disposition Center + +
+
+ Target Environment + prod-eu +
+
+ Bundle + platform-bundle@1.3.1-hotfix1 +
+
+ Patch Reference + sha256:7aa1b2...d6e7f8a9 +
+
+ Requested By + ops-lead · 12m ago +
-
+ + +
+ +
+

+ + Gate Outcomes +

+
+ @for (gate of gates; track gate.name) { +
+ +
+ {{ gate.name }} + {{ gate.detail }} +
+ + {{ gate.result | uppercase }} + +
+ } +
+
+ + +
+

+ + Activity Timeline +

+
+ @for (entry of timeline; track entry.time) { +
+ +
+ {{ entry.action }} + {{ entry.actor }} · {{ entry.time }} +
+
+ } +
+
+
+ + +
+

+ + Related Resources +

+ +
+
`, - styles: [ - ` - .hotfix-detail { display: grid; gap: 1rem; } - h1 { margin: 0; font-size: 1.35rem; } - p { margin: 0; color: var(--color-text-secondary); } - .actions { display: flex; flex-wrap: wrap; gap: 0.6rem; } - .actions a { border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 0.4rem 0.55rem; text-decoration: none; color: var(--color-text-primary); } - .actions a:hover { border-color: var(--color-brand-primary); color: var(--color-brand-primary); } - `, - ], + styles: [` + .detail-page { max-width: 1200px; margin: 0 auto; } + + /* Breadcrumb */ + .breadcrumb { + display: flex; + align-items: center; + gap: 0.375rem; + margin-bottom: 1rem; + font-size: 0.8125rem; + color: var(--color-text-secondary); + } + .breadcrumb a { + color: var(--color-text-link); + text-decoration: none; + } + .breadcrumb a:hover { text-decoration: underline; } + + /* Header */ + .page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1.25rem; + } + .page-header__title-row { + display: flex; + align-items: center; + gap: 0.625rem; + flex-wrap: wrap; + } + .page-title { + margin: 0; + font-size: 1.5rem; + font-weight: var(--font-weight-semibold); + } + .page-subtitle { + margin: 0.25rem 0 0; + color: var(--color-text-secondary); + font-size: 0.875rem; + } + .page-actions { display: flex; gap: 0.75rem; align-items: center; } + + .urgency-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.625rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.03em; + } + .urgency-badge--critical { + background: var(--color-severity-critical-bg, rgba(185, 28, 28, 0.08)); + color: var(--color-status-error-text, #B91C1C); + } + + .status-pill { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: var(--font-weight-medium); + } + .status-pill--reviewing { + background: var(--color-brand-soft, rgba(245, 166, 35, 0.1)); + color: var(--color-brand-primary); + } + + /* Info Cards */ + .info-cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.75rem; + margin-bottom: 1.25rem; + } + .info-card { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.875rem 1rem; + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + } + .info-card__label { + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-secondary); + } + .info-card__value { + font-size: 0.875rem; + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); + } + .info-card__value--mono { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.8125rem; + word-break: break-all; + } + + /* Detail Grid */ + .detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1rem; + } + + /* Panels */ + .panel { + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + padding: 1rem; + } + .panel__title { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0 0 0.875rem; + font-size: 0.875rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + } + + /* Gate List */ + .gate-list { display: flex; flex-direction: column; gap: 0.5rem; } + .gate-row { + display: flex; + align-items: center; + gap: 0.625rem; + padding: 0.5rem 0.625rem; + border-radius: var(--radius-md); + background: var(--color-surface-secondary); + } + .gate-row__indicator { + width: 4px; + height: 28px; + border-radius: 2px; + flex-shrink: 0; + } + .gate-row__indicator--pass { background: var(--color-status-success-text, #15803D); } + .gate-row__indicator--warn { background: var(--color-status-warning-text, #92400E); } + .gate-row__indicator--block { background: var(--color-status-error-text, #B91C1C); } + .gate-row__info { flex: 1; display: flex; flex-direction: column; gap: 0.0625rem; } + .gate-row__name { + font-size: 0.8125rem; + font-weight: var(--font-weight-medium); + } + .gate-row__detail { + font-size: 0.6875rem; + color: var(--color-text-secondary); + } + .gate-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.625rem; + font-weight: var(--font-weight-semibold); + text-transform: uppercase; + letter-spacing: 0.03em; + flex-shrink: 0; + } + .gate-badge--pass { background: var(--color-severity-low-bg); color: var(--color-status-success-text); } + .gate-badge--warn { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); } + .gate-badge--block { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); } + + /* Timeline */ + .timeline { display: flex; flex-direction: column; gap: 0; } + .timeline__entry { + display: flex; + gap: 0.75rem; + padding: 0.5rem 0; + position: relative; + } + .timeline__entry:not(:last-child)::before { + content: ''; + position: absolute; + left: 5px; + top: calc(0.5rem + 12px); + bottom: 0; + width: 1px; + background: var(--color-border-primary); + } + .timeline__dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--color-brand-primary); + border: 2px solid var(--color-surface-primary); + flex-shrink: 0; + margin-top: 0.25rem; + } + .timeline__content { flex: 1; } + .timeline__action { + display: block; + font-size: 0.8125rem; + font-weight: var(--font-weight-medium); + } + .timeline__meta { + display: block; + font-size: 0.6875rem; + color: var(--color-text-secondary); + margin-top: 0.125rem; + } + + /* Related Links */ + .related-links { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0.625rem; + } + .related-link { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.75rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + text-decoration: none; + color: var(--color-text-primary); + font-size: 0.8125rem; + font-weight: var(--font-weight-medium); + transition: border-color 150ms ease, color 150ms ease; + cursor: pointer; + } + .related-link:hover { + border-color: var(--color-brand-primary); + color: var(--color-text-link); + } + + /* Buttons */ + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border-radius: var(--radius-md); + font-size: 0.8125rem; + font-weight: var(--font-weight-medium); + cursor: pointer; + text-decoration: none; + transition: opacity 150ms ease, transform 150ms ease; + border: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); + color: var(--color-text-primary); + } + .btn:hover { opacity: 0.85; transform: translateY(-1px); } + .btn--primary { background: var(--color-btn-primary-bg); border: none; color: var(--color-btn-primary-text); } + .btn--primary:hover { opacity: 0.9; } + .btn--danger { + background: var(--color-severity-critical-bg, rgba(185, 28, 28, 0.08)); + border: 1px solid var(--color-status-error-text, #B91C1C); + color: var(--color-status-error-text, #B91C1C); + } + .btn--danger:hover { opacity: 0.9; } + + @media (max-width: 768px) { + .page-header { flex-direction: column; gap: 0.75rem; } + .info-cards { grid-template-columns: repeat(2, 1fr); } + .detail-grid { grid-template-columns: 1fr; } + .related-links { grid-template-columns: repeat(2, 1fr); } + } + `], }) export class HotfixDetailPageComponent { private readonly route = inject(ActivatedRoute); readonly hotfixId = this.route.snapshot.paramMap.get('hotfixId') ?? 'unknown'; + + readonly gates: GateOutcome[] = [ + { name: 'Vulnerability Scan', result: 'pass', detail: '0 critical, 2 medium findings' }, + { name: 'Policy Compliance', result: 'warn', detail: '1 waiver active for CVE-2026-1234' }, + { name: 'SBOM Integrity', result: 'pass', detail: 'All components verified against baseline' }, + { name: 'Reachability Check', result: 'pass', detail: 'No reachable paths to affected functions' }, + { name: 'Approval Quorum', result: 'warn', detail: '1 of 2 required approvals received' }, + ]; + + readonly timeline: TimelineEntry[] = [ + { time: '12m ago', action: 'Hotfix submitted for review', actor: 'ops-lead' }, + { time: '10m ago', action: 'Automated gate evaluation started', actor: 'system' }, + { time: '8m ago', action: 'Vulnerability scan completed (PASS)', actor: 'scanner' }, + { time: '6m ago', action: 'Policy waiver applied for CVE-2026-1234', actor: 'security-eng' }, + { time: '3m ago', action: 'First approval received', actor: 'release-mgr' }, + ]; + + approve(): void { + console.log('Approve hotfix:', this.hotfixId); + } + + reject(): void { + console.log('Reject hotfix:', this.hotfixId); + } } diff --git a/src/Web/StellaOps.Web/src/app/features/releases/release-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/release-detail-page.component.ts index d936e21e8..8b34ca856 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/release-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/release-detail-page.component.ts @@ -10,6 +10,7 @@ import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@ang import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { buildContextReturnTo } from '../../shared/ui/context-route-state/context-route-state'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type ReleaseDetailTabId = | 'overview' @@ -23,7 +24,7 @@ type ReleaseDetailTabId = @Component({ selector: 'app-release-detail-page', standalone: true, - imports: [RouterLink], + imports: [RouterLink, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -78,24 +79,11 @@ type ReleaseDetailTabId =
- - - -
+ @switch (activeTab()) { @case ('overview') {
@@ -166,7 +154,7 @@ type ReleaseDetailTabId =

Components ({{ release().components }} total)

- +
@@ -244,7 +232,7 @@ type ReleaseDetailTabId = @case ('deployments') {

Deployment Runs

-
Name
+
@@ -269,7 +257,7 @@ type ReleaseDetailTabId = @case ('evidence') {

Evidence Packets

-
Deployment ID
+
@@ -323,7 +311,7 @@ type ReleaseDetailTabId = } } - + `, styles: [` @@ -339,7 +327,7 @@ type ReleaseDetailTabId = display: inline-block; margin-bottom: 0.5rem; font-size: 0.875rem; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } .header-main { display: flex; align-items: center; gap: 1rem; margin-bottom: 0.5rem; } @@ -398,7 +386,7 @@ type ReleaseDetailTabId = top: -0.5rem; right: -0.5rem; padding: 0.125rem 0.375rem; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; font-size: 0.625rem; font-weight: var(--font-weight-semibold); @@ -406,38 +394,6 @@ type ReleaseDetailTabId = } .env-arrow { color: var(--color-text-secondary); } - .tabs { - display: flex; - gap: 0.25rem; - border-bottom: 1px solid var(--color-border-primary); - margin-bottom: 1.5rem; - overflow-x: auto; - } - .tab { - padding: 0.75rem 1rem; - background: transparent; - border: none; - border-bottom: 2px solid transparent; - margin-bottom: -1px; - font-size: 0.875rem; - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - cursor: pointer; - white-space: nowrap; - } - .tab:hover { color: var(--color-text-primary); } - .tab--active { - color: var(--color-brand-primary); - border-bottom-color: var(--color-brand-primary); - } - .tab-count { - margin-left: 0.25rem; - padding: 0.125rem 0.375rem; - background: var(--color-surface-secondary); - border-radius: var(--radius-xl); - font-size: 0.75rem; - } - .overview-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); @@ -450,7 +406,7 @@ type ReleaseDetailTabId = border-radius: var(--radius-lg); } .card h4 { margin: 0 0 1rem; font-size: 0.875rem; font-weight: var(--font-weight-semibold); } - .card-link { display: block; margin-top: 1rem; font-size: 0.875rem; color: var(--color-brand-primary); text-decoration: none; } + .card-link { display: block; margin-top: 1rem; font-size: 0.875rem; color: var(--color-text-link); text-decoration: none; } .security-metrics { display: flex; gap: 1.5rem; } .metric { text-align: center; } @@ -488,8 +444,6 @@ type ReleaseDetailTabId = .badge--success { background: var(--color-severity-low-bg); color: var(--color-status-success-text); } .badge--warning { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); } .badge--danger { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); } - - .data-table { width: 100%; border-collapse: collapse; } .data-table th, .data-table td { padding: 0.75rem; text-align: left; @@ -501,7 +455,7 @@ type ReleaseDetailTabId = color: var(--color-text-secondary); text-transform: uppercase; } - .data-table a { color: var(--color-brand-primary); text-decoration: none; } + .data-table a { color: var(--color-text-link); text-decoration: none; } .section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } .section-header h4 { margin: 0; } @@ -591,6 +545,16 @@ export class ReleaseDetailPageComponent implements OnInit { { id: 'proof-chain', label: 'Proof Chain' }, ]; + pageTabs: readonly StellaPageTab[] = [ + { id: 'overview', label: 'Overview', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8z|||M12 9a3 3 0 1 0 0 6a3 3 0 0 0 0-6z' }, + { id: 'components', label: 'Components', icon: 'M12 2L2 7l10 5 10-5-10-5z|||M2 17l10 5 10-5|||M2 12l10 5 10-5', badge: 24 }, + { id: 'gates', label: 'Gates', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', badge: 4 }, + { id: 'promotions', label: 'Promotions', icon: 'M22 12h-4l-3 9L9 3l-3 9H2', badge: 2 }, + { id: 'deployments', label: 'Deployments', icon: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4|||M7 10l5 5 5-5|||M12 15V3', badge: 1 }, + { id: 'evidence', label: 'Evidence', icon: 'M20 6L9 17l-5-5', badge: 3 }, + { id: 'proof-chain', label: 'Proof Chain', icon: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' }, + ]; + environments = [ { name: 'Dev', version: 'v1.3.0', isCurrent: false, status: 'ok' }, { name: 'QA', version: 'v1.2.5', isCurrent: true, status: 'ok' }, diff --git a/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.html b/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.html index 43cc4ea4b..100667619 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.html +++ b/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.html @@ -1,331 +1,328 @@ -
- -
-
- @if (viewMode() === 'detail') { - - } -

{{ viewMode() === 'list' ? 'Release Management' : selectedRelease()?.name }}

-
-
- @if (isDeterminismEnabled()) { - - Determinism Gates Active - - } @else { - - Determinism Gates Disabled - - } -
-
- - - @if (loading()) { -
-
-

Loading releases...

-
- } - - - @if (!loading() && viewMode() === 'list') { -
- @for (release of releases(); track trackByReleaseId($index, release)) { -
-
-

{{ release.name }}

- - {{ release.status | titlecase }} - -
-
- - Version: {{ release.version }} - - - Target: {{ release.targetEnvironment }} - - - Artifacts: {{ release.artifacts.length }} - -
-
- @for (artifact of release.artifacts; track trackByArtifactId($index, artifact)) { - @if (artifact.policyEvaluation) { -
- {{ artifact.name }}: - @for (gate of artifact.policyEvaluation.gates; track trackByGateId($index, gate)) { - - } -
- } - } -
- @if (release.status === 'blocked') { -
- - Policy gates blocking publish -
- } -
- } @empty { -

No releases found.

- } -
- } - - - @if (!loading() && viewMode() === 'detail' && selectedRelease()) { -
- -
-

Release Information

-
-
-
Version
-
{{ selectedRelease()?.version }}
-
-
-
Status
-
- - {{ selectedRelease()?.status | titlecase }} - -
-
-
-
Target Environment
-
{{ selectedRelease()?.targetEnvironment }}
-
-
-
Created
-
{{ selectedRelease()?.createdAt }} by {{ selectedRelease()?.createdBy }}
-
-
- @if (selectedRelease()?.notes) { -

{{ selectedRelease()?.notes }}

- } -
- - - @if (isDeterminismEnabled() && determinismBlockingCount() > 0) { -
- - -
- } - - -
-

Artifacts ({{ selectedRelease()?.artifacts?.length }})

-
- @for (artifact of selectedRelease()?.artifacts; track trackByArtifactId($index, artifact)) { - - } -
- - - @if (selectedArtifact()) { -
-
-
-
Digest
-
{{ selectedArtifact()?.digest }}
-
-
-
Size
-
{{ formatBytes(selectedArtifact()!.size) }}
-
-
-
Registry
-
{{ selectedArtifact()?.registry }}
-
-
- - - @if (selectedArtifact()?.policyEvaluation) { -
-

Policy Gates

-
- @for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) { - - } -
- - - @if (selectedArtifact()!.policyEvaluation!.determinismDetails) { -
-

Determinism Evidence

-
-
-
Merkle Root
-
- {{ selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRoot }} - @if (selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRootConsistent) { - Consistent - } @else { - Mismatch - } -
-
-
-
Fragment Verification
-
- {{ selectedArtifact()!.policyEvaluation!.determinismDetails!.verifiedFragments }} / - {{ selectedArtifact()!.policyEvaluation!.determinismDetails!.fragmentCount }} verified -
-
- @if (selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri) { -
-
Composition Manifest
-
{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri }}
-
- } -
- - - @if (selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments?.length) { -
-
Failed Fragment Layers
-
    - @for (fragment of selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments; track fragment) { -
  • {{ fragment }}
  • - } -
-
- } -
- } - - - @for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) { - @if (gate.status === 'failed' && gate.remediation) { - - } - } -
- } -
- } -
- - -
-

Actions

-
- @if (canPublishSelected()) { - - } @else { - - @if (canBypass() && determinismBlockingCount() > 0) { - - } - } - -
-
-
- } - - - @if (showBypassModal()) { - - } -
+
+ +
+
+ @if (viewMode() === 'detail') { + + } +

{{ viewMode() === 'list' ? 'Release Management' : selectedRelease()?.name }}

+
+
+ @if (isDeterminismEnabled()) { + + Determinism Gates Active + + } @else { + + Determinism Gates Disabled + + } +
+
+ + + @if (loading()) { + + } + + + @if (!loading() && viewMode() === 'list') { +
+ @for (release of releases(); track trackByReleaseId($index, release)) { +
+
+

{{ release.name }}

+ + {{ release.status | titlecase }} + +
+
+ + Version: {{ release.version }} + + + Target: {{ release.targetEnvironment }} + + + Artifacts: {{ release.artifacts.length }} + +
+
+ @for (artifact of release.artifacts; track trackByArtifactId($index, artifact)) { + @if (artifact.policyEvaluation) { +
+ {{ artifact.name }}: + @for (gate of artifact.policyEvaluation.gates; track trackByGateId($index, gate)) { + + } +
+ } + } +
+ @if (release.status === 'blocked') { +
+ + Policy gates blocking publish +
+ } +
+ } @empty { +

No releases found.

+ } +
+ } + + + @if (!loading() && viewMode() === 'detail' && selectedRelease()) { +
+ +
+

Release Information

+
+
+
Version
+
{{ selectedRelease()?.version }}
+
+
+
Status
+
+ + {{ selectedRelease()?.status | titlecase }} + +
+
+
+
Target Environment
+
{{ selectedRelease()?.targetEnvironment }}
+
+
+
Created
+
{{ selectedRelease()?.createdAt }} by {{ selectedRelease()?.createdBy }}
+
+
+ @if (selectedRelease()?.notes) { +

{{ selectedRelease()?.notes }}

+ } +
+ + + @if (isDeterminismEnabled() && determinismBlockingCount() > 0) { +
+ + +
+ } + + +
+

Artifacts ({{ selectedRelease()?.artifacts?.length }})

+
+ @for (artifact of selectedRelease()?.artifacts; track trackByArtifactId($index, artifact)) { + + } +
+ + + @if (selectedArtifact()) { +
+
+
+
Digest
+
{{ selectedArtifact()?.digest }}
+
+
+
Size
+
{{ formatBytes(selectedArtifact()!.size) }}
+
+
+
Registry
+
{{ selectedArtifact()?.registry }}
+
+
+ + + @if (selectedArtifact()?.policyEvaluation) { +
+

Policy Gates

+
+ @for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) { + + } +
+ + + @if (selectedArtifact()!.policyEvaluation!.determinismDetails) { +
+

Determinism Evidence

+
+
+
Merkle Root
+
+ {{ selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRoot }} + @if (selectedArtifact()!.policyEvaluation!.determinismDetails!.merkleRootConsistent) { + Consistent + } @else { + Mismatch + } +
+
+
+
Fragment Verification
+
+ {{ selectedArtifact()!.policyEvaluation!.determinismDetails!.verifiedFragments }} / + {{ selectedArtifact()!.policyEvaluation!.determinismDetails!.fragmentCount }} verified +
+
+ @if (selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri) { +
+
Composition Manifest
+
{{ selectedArtifact()!.policyEvaluation!.determinismDetails!.compositionManifestUri }}
+
+ } +
+ + + @if (selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments?.length) { +
+
Failed Fragment Layers
+
    + @for (fragment of selectedArtifact()!.policyEvaluation!.determinismDetails!.failedFragments; track fragment) { +
  • {{ fragment }}
  • + } +
+
+ } +
+ } + + + @for (gate of selectedArtifact()!.policyEvaluation!.gates; track trackByGateId($index, gate)) { + @if (gate.status === 'failed' && gate.remediation) { + + } + } +
+ } +
+ } +
+ + +
+

Actions

+
+ @if (canPublishSelected()) { + + } @else { + + @if (canBypass() && determinismBlockingCount() > 0) { + + } + } + +
+
+
+ } + + + @if (showBypassModal()) { + + } +
diff --git a/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.ts index 0c4c1939c..173625b40 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/release-flow.component.ts @@ -18,13 +18,14 @@ import { import { RELEASE_API } from '../../core/api/release.client'; import { PolicyGateIndicatorComponent } from './policy-gate-indicator.component'; import { RemediationHintsComponent } from './remediation-hints.component'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; type ViewMode = 'list' | 'detail'; @Component({ selector: 'app-release-flow', standalone: true, - imports: [CommonModule, RouterModule, PolicyGateIndicatorComponent, RemediationHintsComponent], + imports: [CommonModule, RouterModule, PolicyGateIndicatorComponent, RemediationHintsComponent, LoadingStateComponent], templateUrl: './release-flow.component.html', styleUrls: ['./release-flow.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/Web/StellaOps.Web/src/app/features/releases/release-ops-overview-page.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/release-ops-overview-page.component.ts index bf9d5281e..cb4b2f6e3 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/release-ops-overview-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/release-ops-overview-page.component.ts @@ -30,7 +30,7 @@ import { RouterLink } from '@angular/router'; p { margin: 0; color: var(--color-text-secondary); } .doors { display: grid; gap: 0.5rem; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } .doors a { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.65rem; text-decoration: none; color: var(--color-text-primary); background: var(--color-surface-primary); } - .doors a:hover { border-color: var(--color-brand-primary); color: var(--color-brand-primary); } + .doors a:hover { border-color: var(--color-brand-primary); color: var(--color-text-link); } `, ], }) diff --git a/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts b/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts index 6ed491f92..f32db3a13 100644 --- a/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/releases/releases-activity.component.ts @@ -7,6 +7,14 @@ import { take } from 'rxjs'; import { PlatformContextStore } from '../../core/context/platform-context.store'; import { TimelineListComponent, TimelineEvent, TimelineEventKind } from '../../shared/ui/timeline-list/timeline-list.component'; +import { DateFormatService } from '../../core/i18n/date-format.service'; + +const VIEW_MODE_TABS: StellaPageTab[] = [ + { id: 'timeline', label: 'Timeline', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' }, + { id: 'table', label: 'Table', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' }, + { id: 'correlations', label: 'Correlations', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, +]; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; interface ReleaseActivityProjection { activityId: string; releaseId: string; @@ -46,7 +54,7 @@ function deriveOutcomeIcon(status: string): string { @Component({ selector: 'app-releases-activity', standalone: true, - imports: [RouterLink, FormsModule, TimelineListComponent], + imports: [RouterLink, FormsModule, TimelineListComponent, StellaPageTabsComponent], template: `
@@ -60,11 +68,12 @@ function deriveOutcomeIcon(status: string): string { {{ context.timeWindow() }} - +
Evidence ID
+
@@ -585,7 +585,7 @@ export interface ScoreComparison { } .score-comparison__toggle--active { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; border-color: var(--color-brand-primary); } @@ -806,7 +806,6 @@ export interface ScoreComparison { /* Table */ .score-comparison__table { width: 100%; - border-collapse: collapse; } .score-comparison__table th, @@ -866,7 +865,7 @@ export interface ScoreComparison { display: block; font-size: 2rem; font-weight: var(--font-weight-bold); - color: var(--color-brand-primary); + color: var(--color-text-link); } .score-comparison__vex-label { diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/exception-manager.component.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/exception-manager.component.ts index b53c10145..e78f8f0f0 100644 --- a/src/Web/StellaOps.Web/src/app/features/secret-detection/exception-manager.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/exception-manager.component.ts @@ -6,11 +6,13 @@ * Component for managing secret detection exceptions (allowlist patterns). */ -import { Component, input, output, signal } from '@angular/core'; +import { Component, input, output, signal, + inject,} from '@angular/core'; import { SecretException, SecretRuleCategory, RULE_CATEGORIES } from './models/secret-detection.models'; import { ExceptionFormComponent } from './exception-form.component'; +import { DateFormatService } from '../../core/i18n/date-format.service'; @Component({ selector: 'stella-exception-manager', standalone: true, @@ -313,6 +315,8 @@ import { ExceptionFormComponent } from './exception-form.component'; `] }) export class ExceptionManagerComponent { + private readonly dateFmt = inject(DateFormatService); + // Inputs exceptions = input([]); disabled = input(false); @@ -342,7 +346,7 @@ export class ExceptionManagerComponent { formatDate(dateStr: string): string { const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric' diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/finding-detail-drawer.component.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/finding-detail-drawer.component.ts index e791db9fa..9a4b4e639 100644 --- a/src/Web/StellaOps.Web/src/app/features/secret-detection/finding-detail-drawer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/finding-detail-drawer.component.ts @@ -6,7 +6,8 @@ * Slide-out drawer for viewing and managing secret finding details. */ -import { Component, input, output, signal } from '@angular/core'; +import { Component, input, output, signal, + inject,} from '@angular/core'; import { FormsModule } from '@angular/forms'; import { @@ -17,6 +18,7 @@ import { } from './models/secret-finding.models'; import { MaskedValueDisplayComponent } from './masked-value-display.component'; +import { DateFormatService } from '../../core/i18n/date-format.service'; @Component({ selector: 'stella-finding-detail-drawer', standalone: true, @@ -458,6 +460,8 @@ import { MaskedValueDisplayComponent } from './masked-value-display.component'; `] }) export class FindingDetailDrawerComponent { + private readonly dateFmt = inject(DateFormatService); + // Inputs finding = input.required(); @@ -492,7 +496,7 @@ export class FindingDetailDrawerComponent { formatDateTime(dateStr: string): string { const date = new Date(dateStr); - return date.toLocaleString('en-US', { + return date.toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/secret-detection-settings.component.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/secret-detection-settings.component.ts index 4af185744..7899d4b23 100644 --- a/src/Web/StellaOps.Web/src/app/features/secret-detection/secret-detection-settings.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/secret-detection-settings.component.ts @@ -17,6 +17,25 @@ import { AlertDestinationConfigComponent } from './alert-destination-config.comp import { SecretRuleCategory, SecretException } from './models/secret-detection.models'; import { RevelationPolicy } from './models/revelation-policy.models'; import { AlertDestinationSettings } from './models/alert-destination.models'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +const SECRET_DETECTION_TABS: readonly StellaPageTab[] = [ + { + id: 'general', + label: 'General', + icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0', + }, + { + id: 'exceptions', + label: 'Exceptions', + icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', + }, + { + id: 'alerts', + label: 'Alerts', + icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01', + }, +]; @Component({ selector: 'stella-secret-detection-settings', @@ -26,7 +45,8 @@ import { AlertDestinationSettings } from './models/alert-destination.models'; RevelationPolicyConfigComponent, RuleCategorySelectorComponent, ExceptionManagerComponent, - AlertDestinationConfigComponent + AlertDestinationConfigComponent, + StellaPageTabsComponent, ], template: `
@@ -66,38 +86,12 @@ import { AlertDestinationSettings } from './models/alert-destination.models';
} @else if (settingsService.settings()) { -
- - -
+ @switch (activeTab()) { @case ('general') {
@@ -134,8 +128,7 @@ import { AlertDestinationSettings } from './models/alert-destination.models';
} } -
-
+ } @if (settingsService.saving()) { @@ -294,55 +287,6 @@ import { AlertDestinationSettings } from './models/alert-destination.models'; to { transform: rotate(360deg); } } - .settings-tabs__nav { - display: flex; - gap: var(--space-1); - margin-bottom: var(--space-6); - border-bottom: 1px solid var(--color-border); - } - - .settings-tabs__tab { - display: flex; - align-items: center; - gap: var(--space-1); - padding: var(--space-2) var(--space-4); - border: none; - background: none; - color: var(--color-text-secondary); - font-size: var(--font-size-sm); - font-weight: var(--font-weight-medium); - cursor: pointer; - border-bottom: 2px solid transparent; - margin-bottom: -1px; - transition: color 0.2s ease, border-color 0.2s ease; - } - - .settings-tabs__tab:hover { - color: var(--color-text-primary); - } - - .settings-tabs__tab--active { - color: var(--color-primary); - border-bottom-color: var(--color-primary); - } - - .settings-tabs__badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 20px; - height: 20px; - padding: 0 6px; - background-color: var(--color-background-tertiary); - border-radius: var(--radius-xl); - font-size: var(--font-size-xs); - } - - .settings-tabs__tab--active .settings-tabs__badge { - background-color: var(--color-primary-light); - color: var(--color-primary); - } - .settings-section { display: flex; flex-direction: column; @@ -386,6 +330,15 @@ export class SecretDetectionSettingsComponent implements OnInit { // Computed readonly exceptionCount = computed(() => this.settingsService.exceptions().length); + readonly SECRET_DETECTION_TABS = computed(() => { + const count = this.exceptionCount(); + return SECRET_DETECTION_TABS.map(tab => + tab.id === 'exceptions' && count > 0 + ? { ...tab, badge: count } + : tab + ); + }); + // TODO: Get from auth service readonly canFullReveal = signal(false); diff --git a/src/Web/StellaOps.Web/src/app/features/secret-detection/secret-findings-list.component.ts b/src/Web/StellaOps.Web/src/app/features/secret-detection/secret-findings-list.component.ts index 97b0a29c9..f6ca2a6a6 100644 --- a/src/Web/StellaOps.Web/src/app/features/secret-detection/secret-findings-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/secret-detection/secret-findings-list.component.ts @@ -26,6 +26,7 @@ import { import { SecretRuleCategory, RULE_CATEGORIES } from './models/secret-detection.models'; import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/filter-bar/filter-bar.component'; +import { DateFormatService } from '../../core/i18n/date-format.service'; @Component({ selector: 'stella-secret-findings-list', standalone: true, @@ -511,6 +512,8 @@ import { FilterBarComponent, FilterOption, ActiveFilter } from '../../shared/ui/ `] }) export class SecretFindingsListComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + readonly findingsService = inject(SecretFindingsService); // Static data @@ -724,7 +727,7 @@ export class SecretFindingsListComponent implements OnInit { formatDate(dateStr: string): string { const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric' diff --git a/src/Web/StellaOps.Web/src/app/features/security-risk/finding-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security-risk/finding-detail-page.component.ts index 803b11b42..d1678369d 100644 --- a/src/Web/StellaOps.Web/src/app/features/security-risk/finding-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security-risk/finding-detail-page.component.ts @@ -3,6 +3,8 @@ import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@a import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { take } from 'rxjs'; +import { DateFormatService } from '../../core/i18n/date-format.service'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; interface PlatformItemResponse { item: T; } interface SecurityDispositionProjection { findingId: string; @@ -29,12 +31,20 @@ interface SecurityFindingProjection { } interface SecurityFindingsResponse { items: SecurityFindingProjection[]; } +const DETAIL_PAGE_TABS: StellaPageTab[] = [ + { id: 'why', label: 'Why', icon: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3|||M12 17h.01|||M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0' }, + { id: 'effective-vex', label: 'Effective VEX', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'waivers', label: 'Waivers/Exceptions', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'policy-trace', label: 'Policy Gate Trace', icon: 'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2|||M8 2h8v4H8z' }, + { id: 'evidence', label: 'Evidence Export', icon: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4|||M7 10l5 5 5-5|||M12 15V3' }, +]; + type DetailTab = 'why' | 'effective-vex' | 'waivers' | 'policy-trace' | 'evidence'; @Component({ selector: 'app-finding-detail-page', standalone: true, - imports: [RouterLink], + imports: [RouterLink, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -50,11 +60,12 @@ type DetailTab = 'why' | 'effective-vex' | 'waivers' | 'policy-trace' | 'evidenc Updated: {{ disposition() ? fmt(disposition()!.updatedAt) : 'n/a' }} - + @if (error()) { } @if (loading()) { } @@ -141,9 +152,7 @@ type DetailTab = 'why' | 'effective-vex' | 'waivers' | 'policy-trace' | 'evidenc .summary-strip{display:flex;flex-wrap:wrap;gap:.25rem;padding:.45rem} .chip{border:1px solid var(--color-border-primary);border-radius:var(--radius-full);padding:.08rem .42rem;font-size:.68rem;color:var(--color-text-secondary)} - .tabs{display:flex;gap:.3rem;flex-wrap:wrap} - .tabs a{padding:.12rem .5rem;font-size:.72rem;color:var(--color-text-secondary);text-decoration:none} - .tabs a.active{border-color:var(--color-tab-active-border, var(--color-brand-primary));color:var(--color-tab-active-text, var(--color-text-primary));font-weight:600} + .banner{padding:.65rem;font-size:.8rem;color:var(--color-text-secondary)} .banner--error{color:var(--color-status-error-text)} @@ -158,6 +167,8 @@ type DetailTab = 'why' | 'effective-vex' | 'waivers' | 'policy-trace' | 'evidenc `], }) export class FindingDetailPageComponent { + private readonly dateFmt = inject(DateFormatService); + private readonly http = inject(HttpClient); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); @@ -169,13 +180,7 @@ export class FindingDetailPageComponent { readonly disposition = signal(null); readonly finding = signal(null); - readonly tabs: Array<{ id: DetailTab; label: string }> = [ - { id: 'why', label: 'Why' }, - { id: 'effective-vex', label: 'Effective VEX' }, - { id: 'waivers', label: 'Waivers/Exceptions' }, - { id: 'policy-trace', label: 'Policy Gate Trace' }, - { id: 'evidence', label: 'Evidence Export' }, - ]; + readonly DETAIL_PAGE_TABS = DETAIL_PAGE_TABS; readonly birCoverage = computed(() => { const score = this.finding()?.reachabilityScore ?? 0; @@ -205,7 +210,7 @@ export class FindingDetailPageComponent { this.route.queryParamMap.subscribe((params) => { const tab = (params.get('tab') ?? 'why').toLowerCase(); - if (this.tabs.some((item) => item.id === tab)) { + if (DETAIL_PAGE_TABS.some((item) => item.id === tab)) { this.activeTab.set(tab as DetailTab); } else { this.activeTab.set('why'); @@ -237,7 +242,7 @@ export class FindingDetailPageComponent { fmt(value: string): string { const d = new Date(value); if (Number.isNaN(d.getTime())) return value; - return d.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + return d.toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } reachabilityQueryParams(): Record { diff --git a/src/Web/StellaOps.Web/src/app/features/security-risk/remediation/remediation-browse.component.ts b/src/Web/StellaOps.Web/src/app/features/security-risk/remediation/remediation-browse.component.ts index 18d0e365b..5d89f373b 100644 --- a/src/Web/StellaOps.Web/src/app/features/security-risk/remediation/remediation-browse.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security-risk/remediation/remediation-browse.component.ts @@ -176,7 +176,7 @@ import { RemediationApiService, FixTemplate } from './remediation.api'; } .chip--active { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: #fff; border-color: var(--color-brand-primary); } diff --git a/src/Web/StellaOps.Web/src/app/features/security-risk/remediation/remediation-fix-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/security-risk/remediation/remediation-fix-detail.component.ts index 5f22b7715..2675c6962 100644 --- a/src/Web/StellaOps.Web/src/app/features/security-risk/remediation/remediation-fix-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security-risk/remediation/remediation-fix-detail.component.ts @@ -107,7 +107,7 @@ import { RemediationApiService, FixTemplate } from './remediation.api'; .back-link { font-size: 0.85rem; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } diff --git a/src/Web/StellaOps.Web/src/app/features/security-risk/remediation/remediation-submit.component.ts b/src/Web/StellaOps.Web/src/app/features/security-risk/remediation/remediation-submit.component.ts index 297be2afe..497e376da 100644 --- a/src/Web/StellaOps.Web/src/app/features/security-risk/remediation/remediation-submit.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security-risk/remediation/remediation-submit.component.ts @@ -117,7 +117,7 @@ interface PipelineStep { .back-link { font-size: 0.85rem; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } @@ -214,7 +214,7 @@ interface PipelineStep { } .info-value { font-size: 0.875rem; } - .info-value.link { color: var(--color-brand-primary); text-decoration: none; } + .info-value.link { color: var(--color-text-link); text-decoration: none; } .status--opened { color: var(--color-status-info); } .status--scanning { color: var(--color-status-warning); } @@ -262,7 +262,7 @@ interface PipelineStep { } .step--done .step-dot { background: var(--color-status-success); border-color: var(--color-status-success); } - .step--active .step-dot { background: var(--color-brand-primary); border-color: var(--color-brand-primary); } + .step--active .step-dot { background: var(--color-btn-primary-bg); border-color: var(--color-brand-primary); } .step--failed .step-dot { background: var(--color-status-error); border-color: var(--color-status-error); } .step-label { font-size: 0.85rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts index c39f4eec2..666fabca0 100644 --- a/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security-risk/security-risk-overview.component.ts @@ -2,6 +2,8 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; import { DoctorChecksInlineComponent } from '../doctor/components/doctor-checks-inline/doctor-checks-inline.component'; +import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component'; +import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component'; import { forkJoin, of } from 'rxjs'; import { catchError, map, take } from 'rxjs/operators'; import { AdvisorySourcesApi, AdvisorySourceListItemDto } from './advisory-sources.api'; @@ -61,7 +63,7 @@ interface PlatformListResponse { @Component({ selector: 'app-security-risk-overview', standalone: true, - imports: [RouterLink, DoctorChecksInlineComponent], + imports: [RouterLink, DoctorChecksInlineComponent, StellaMetricCardComponent, StellaMetricGridComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -74,10 +76,6 @@ interface PlatformListResponse { Scan an Image View Active Findings -
- Scope - {{ scopeSummary() }} -
@@ -106,39 +104,50 @@ interface PlatformListResponse { } @if (!loading()) { -
- -
-

Blocking Items

-

{{ blockerCount() }}

- {{ triageCriticalCount() }} critical, {{ triageHighCount() }} high severity -
-
-

VEX Coverage

-

{{ vexCoveragePct() }}%

- {{ vexCoveredCount() }}/{{ dispositions().length }} findings -
-
-

SBOM Health

-

{{ sbomFreshCount() }}/{{ sbomRows().length }}

- fresh components -
-
-

Reachability Coverage

-

{{ reachabilityCoveragePct() }}%

- {{ reachableCount() }} reachable -
-
-

Unknown Reachability

-

{{ unknownReachabilityCount() }}

- needs deeper runtime evidence -
-
+ + + + + + + + @@ -221,108 +230,95 @@ interface PlatformListResponse {
`, styles: [` - .overview{display:grid;gap:.75rem} - .page-header{display:flex;justify-content:space-between;gap:1rem;align-items:flex-start} - .page-header h1{margin:0} - .page-header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.82rem} - .header-actions{display:flex;gap:.45rem;align-items:center} - .btn{ - display:inline-flex;align-items:center;gap:.3rem; - padding:.35rem .7rem;border-radius:var(--radius-md); - font-size:.76rem;font-weight:var(--font-weight-semibold); - text-decoration:none;cursor:pointer;border:1px solid transparent; - transition:background .15s,border-color .15s; + .overview { display: grid; gap: 1rem; } + .page-header { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; } + .page-header h1 { margin: 0; font-size: 1.35rem; color: var(--color-text-heading, var(--color-text-primary)); } + .page-header p { margin: 0.25rem 0 0; color: var(--color-text-secondary); font-size: 0.875rem; } + .header-actions { display: flex; gap: 0.5rem; align-items: center; } + .btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.875rem; + border-radius: var(--radius-md); + font-size: 0.8125rem; + font-weight: var(--font-weight-semibold); + text-decoration: none; + cursor: pointer; + border: 1px solid transparent; + transition: background 150ms ease, border-color 150ms ease; } - .btn-primary{ - background: var(--color-btn-primary-bg);color:var(--color-btn-primary-text);border-color:var(--color-brand-primary); + .btn-primary { + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); + border-color: var(--color-btn-primary-bg); } - .btn-primary:hover{opacity:.9} - .btn-secondary{ - background:transparent;color:var(--color-brand-primary);border-color:var(--color-border-primary); + .btn-primary:hover { opacity: 0.9; } + .btn-secondary { + background: var(--color-btn-secondary-bg, transparent); + color: var(--color-btn-secondary-text); + border-color: var(--color-btn-secondary-border, var(--color-border-primary)); } - .btn-secondary:hover{background:var(--color-surface-secondary)} - .scope{display:grid;gap:.1rem;text-align:right} - .scope span{font-size:.65rem;text-transform:uppercase;color:var(--color-text-secondary)} - .scope strong{font-size:.78rem} + .btn-secondary:hover { background: var(--color-btn-secondary-hover-bg); } - .status-rail,.banner,.kpis article,.panel{ - border:1px solid var(--color-border-primary); - border-radius:var(--radius-md); - background:var(--color-surface-primary); + .status-rail, .banner, .kpis article, .panel { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); } - .status-rail{ - display:flex; - flex-wrap:wrap; - gap:.35rem .45rem; - align-items:center; - padding:.55rem .65rem; - font-size:.75rem; + .status-rail { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 0.625rem; + align-items: center; + padding: 0.75rem 1rem; + font-size: 0.8125rem; } - .status-rail .chip{ - border:1px solid var(--color-border-primary); - border-radius:var(--radius-full); - padding:.1rem .4rem; - color:var(--color-text-secondary); - font-size:.68rem; + .status-rail .chip { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-full); + padding: 0.125rem 0.5rem; + color: var(--color-text-secondary); + font-size: 0.75rem; } - .status-rail .summary{color:var(--color-text-secondary)} - .status-rail a{margin-left:auto;color:var(--color-brand-primary);text-decoration:none} - .status-rail--warn{border-color:var(--color-status-warning-text)} - .status-rail--fail{border-color:var(--color-status-error-text)} + .status-rail .summary { color: var(--color-text-secondary); } + .status-rail a { margin-left: auto; color: var(--color-text-link); text-decoration: none; } + .status-rail a:hover { color: var(--color-text-link-hover); text-decoration: underline; } + .status-rail--warn { border-color: var(--color-status-warning-text); } + .status-rail--fail { border-color: var(--color-status-error-text); } - .banner{padding:.65rem;font-size:.8rem;color:var(--color-text-secondary)} - .banner--error{color:var(--color-status-error-text)} - .banner--warn{color:var(--color-status-warning-text);border-color:var(--color-status-warning-text)} + .banner { padding: 0.75rem 1rem; font-size: 0.875rem; color: var(--color-text-secondary); } + .banner--error { color: var(--color-status-error-text); } + .banner--warn { color: var(--color-status-warning-text); border-color: var(--color-status-warning-text); } - .kpis{ - display:grid; - grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); - gap:.5rem; - } - .kpis article{ - padding:.6rem; - transition:transform 150ms ease, box-shadow 150ms ease; - } - .kpis article:hover{ - transform:translateY(-2px); - box-shadow:0 4px 12px rgba(0,0,0,0.08); - } - .kpis h2{ - margin:0; - font-size:.66rem; - text-transform:uppercase; - letter-spacing:.02em; - color:var(--color-text-secondary); - } - .kpis .value{margin:.2rem 0 0;font-size:1.15rem;font-weight:var(--font-weight-semibold)} - .kpis small{font-size:.68rem;color:var(--color-text-secondary)} - .kpi-link{display:block;margin-top:.25rem;font-size:.66rem;color:var(--color-brand-primary);text-decoration:none} - .kpi-link:hover{text-decoration:underline} + /* KPI cards now use stella-metric-card/grid */ - .grid{ - display:grid; - grid-template-columns:repeat(2,minmax(0,1fr)); - gap:.5rem; + .grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; } - .panel{padding:.65rem;display:grid;gap:.45rem;transition:transform 150ms ease, box-shadow 150ms ease} - .panel:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,0.08)} - .panel-header{display:flex;justify-content:space-between;align-items:center;gap:.5rem} - .panel-header h3{margin:0;font-size:.85rem} - .panel-header a{font-size:.72rem;color:var(--color-brand-primary);text-decoration:none} - .panel .meta{margin:0;font-size:.74rem;color:var(--color-text-secondary)} + .panel { padding: 1rem; display: grid; gap: 0.625rem; } + .panel-header { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; } + .panel-header h3 { margin: 0; font-size: 0.9375rem; font-weight: var(--font-weight-semibold); } + .panel-header a { font-size: 0.8125rem; color: var(--color-text-link); text-decoration: none; } + .panel-header a:hover { color: var(--color-text-link-hover); text-decoration: underline; } + .panel .meta { margin: 0; font-size: 0.8125rem; color: var(--color-text-secondary); } /* Blocking items compact table styling */ - .panel ul{margin:0;padding:0;list-style:none;display:grid;gap:0} - .panel li{ - font-size:.76rem;color:var(--color-text-secondary); - padding:.35rem .5rem; - border-bottom:1px solid var(--color-border-primary); + .panel ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 0; } + .panel li { + font-size: 0.8125rem; + color: var(--color-text-secondary); + padding: 0.5rem 0.625rem; + border-bottom: 1px solid var(--color-border-primary); } - .panel li:last-child{border-bottom:none} - .panel li:hover{background:var(--color-nav-hover)} - .panel li a{color:var(--color-brand-primary);text-decoration:none} - .panel li span{margin-left:.35rem} - .panel li.empty{padding:.5rem;color:var(--color-text-secondary)} + .panel li:last-child { border-bottom: none; } + .panel li:hover { background: var(--color-nav-hover); } + .panel li a { color: var(--color-text-link); text-decoration: none; } + .panel li a:hover { color: var(--color-text-link-hover); text-decoration: underline; } + .panel li span { margin-left: 0.375rem; } + .panel li.empty { padding: 0.75rem 0.625rem; color: var(--color-text-secondary); } /* Shimmer skeleton loading */ .skeleton-grid{ @@ -354,7 +350,6 @@ interface PlatformListResponse { @media (max-width: 1080px){ .grid{grid-template-columns:1fr} - .scope{text-align:left} } `], }) @@ -483,14 +478,6 @@ export class SecurityRiskOverviewComponent { .slice(0, 10) ); - readonly scopeSummary = computed(() => { - const regions = this.context.selectedRegions(); - const environments = this.context.selectedEnvironments(); - const regionText = regions.length > 0 ? regions.join(', ') : 'all regions'; - const envText = environments.length > 0 ? environments.join(', ') : 'all environments'; - return `${regionText} / ${envText}`; - }); - readonly confidence = computed(() => { const feeds = this.feedHealth(); const vex = this.vexSourceHealth(); diff --git a/src/Web/StellaOps.Web/src/app/features/security/exceptions-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/exceptions-page.component.ts index 4dadac115..eaca65db1 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/exceptions-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/exceptions-page.component.ts @@ -73,7 +73,7 @@ import { EXCEPTION_API, type ExceptionApi } from '../../core/api/exception.clien .data-table { width: 100%; border-collapse: collapse; } .data-table th, .data-table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); } .data-table th { background: var(--color-surface-secondary); font-size: 0.75rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; } - .data-table a { color: var(--color-brand-primary); text-decoration: none; } + .data-table a { color: var(--color-text-link); text-decoration: none; } .status-badge { padding: 0.125rem 0.5rem; border-radius: var(--radius-sm); font-size: 0.625rem; font-weight: var(--font-weight-semibold); } .status-badge--active { background: var(--color-severity-low-bg); color: var(--color-status-success-text); } diff --git a/src/Web/StellaOps.Web/src/app/features/security/lineage-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/lineage-page.component.ts index 985507319..7619971bf 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/lineage-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/lineage-page.component.ts @@ -17,9 +17,60 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';

Track provenance and artifact ancestry.

-

Lineage views will appear here when data sources are configured.

+ +

Lineage views coming soon

+

Provenance and artifact ancestry tracking will appear here when data sources are configured.

- ` + `, + styles: [` + .page { display: grid; gap: 1rem; } + .page-header { display: flex; flex-direction: column; gap: 0.25rem; } + .page-title { margin: 0; font-size: 1.35rem; color: var(--color-text-heading, var(--color-text-primary)); } + .page-subtitle { margin: 0; color: var(--color-text-secondary); font-size: 0.875rem; } + .panel { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + padding: 2.5rem 1.5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + } + .empty-icon { + width: 2.5rem; + height: 2.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + color: var(--color-text-link); + background: var(--color-brand-soft, var(--color-surface-secondary)); + margin-bottom: 0.25rem; + } + .empty-heading { + margin: 0; + font-size: 1rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-heading, var(--color-text-primary)); + } + .empty-text { + margin: 0; + font-size: 0.875rem; + color: var(--color-text-secondary); + max-width: 48ch; + text-align: center; + line-height: 1.5; + } + `] }) export class LineagePageComponent {} diff --git a/src/Web/StellaOps.Web/src/app/features/security/patch-map-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/patch-map-page.component.ts index 0a37f3ea2..9a6ee4514 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/patch-map-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/patch-map-page.component.ts @@ -17,9 +17,59 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';

Track patch coverage across environments.

-

Patch map dashboards will be available in a future release.

+ +

Patch map dashboards coming soon

+

Patch coverage tracking across environments will be available in a future release.

- ` + `, + styles: [` + .page { display: grid; gap: 1rem; } + .page-header { display: flex; flex-direction: column; gap: 0.25rem; } + .page-title { margin: 0; font-size: 1.35rem; color: var(--color-text-heading, var(--color-text-primary)); } + .page-subtitle { margin: 0; color: var(--color-text-secondary); font-size: 0.875rem; } + .panel { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + padding: 2.5rem 1.5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + } + .empty-icon { + width: 2.5rem; + height: 2.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + color: var(--color-text-link); + background: var(--color-brand-soft, var(--color-surface-secondary)); + margin-bottom: 0.25rem; + } + .empty-heading { + margin: 0; + font-size: 1rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-heading, var(--color-text-primary)); + } + .empty-text { + margin: 0; + font-size: 0.875rem; + color: var(--color-text-secondary); + max-width: 48ch; + text-align: center; + line-height: 1.5; + } + `] }) export class PatchMapPageComponent {} diff --git a/src/Web/StellaOps.Web/src/app/features/security/reachability-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/reachability-page.component.ts index 8e41916d3..3f93b2dfb 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/reachability-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/reachability-page.component.ts @@ -17,9 +17,60 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';

Analyze runtime reachability across findings.

-

Reachability analytics are queued for implementation.

+ +

Reachability analytics are queued for implementation

+

Runtime reachability analysis will appear here once data sources are configured.

- ` + `, + styles: [` + .page { display: grid; gap: 1rem; } + .page-header { display: flex; flex-direction: column; gap: 0.25rem; } + .page-title { margin: 0; font-size: 1.35rem; color: var(--color-text-heading, var(--color-text-primary)); } + .page-subtitle { margin: 0; color: var(--color-text-secondary); font-size: 0.875rem; } + .panel { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + padding: 2.5rem 1.5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + } + .empty-icon { + width: 2.5rem; + height: 2.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + color: var(--color-text-link); + background: var(--color-brand-soft, var(--color-surface-secondary)); + margin-bottom: 0.25rem; + } + .empty-heading { + margin: 0; + font-size: 1rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-heading, var(--color-text-primary)); + } + .empty-text { + margin: 0; + font-size: 0.875rem; + color: var(--color-text-secondary); + max-width: 48ch; + text-align: center; + line-height: 1.5; + } + `] }) export class ReachabilityPageComponent {} diff --git a/src/Web/StellaOps.Web/src/app/features/security/risk-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/risk-page.component.ts index d659f2d39..fd7699c8c 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/risk-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/risk-page.component.ts @@ -17,9 +17,58 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';

Monitor risk trends and remediation status.

-

Risk scoring visuals are not yet wired.

+ +

Risk scoring visuals are not yet wired

+

Risk trends and remediation tracking dashboards will be available in a future release.

- ` + `, + styles: [` + .page { display: grid; gap: 1rem; } + .page-header { display: flex; flex-direction: column; gap: 0.25rem; } + .page-title { margin: 0; font-size: 1.35rem; color: var(--color-text-heading, var(--color-text-primary)); } + .page-subtitle { margin: 0; color: var(--color-text-secondary); font-size: 0.875rem; } + .panel { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + padding: 2.5rem 1.5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + } + .empty-icon { + width: 2.5rem; + height: 2.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + color: var(--color-text-link); + background: var(--color-brand-soft, var(--color-surface-secondary)); + margin-bottom: 0.25rem; + } + .empty-heading { + margin: 0; + font-size: 1rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-heading, var(--color-text-primary)); + } + .empty-text { + margin: 0; + font-size: 0.875rem; + color: var(--color-text-secondary); + max-width: 48ch; + text-align: center; + line-height: 1.5; + } + `] }) export class RiskPageComponent {} diff --git a/src/Web/StellaOps.Web/src/app/features/security/sbom-graph-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/sbom-graph-page.component.ts index 2977ff531..ddcb8b2ff 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/sbom-graph-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/sbom-graph-page.component.ts @@ -80,7 +80,7 @@ import { RouterLink } from '@angular/router'; align-items: center; justify-content: center; border-radius: var(--radius-full); - color: var(--color-brand-primary); + color: var(--color-text-link); background: var(--color-brand-soft); } @@ -115,9 +115,9 @@ import { RouterLink } from '@angular/router'; } .btn--primary { - color: #fff; + color: var(--color-btn-primary-text); background: var(--color-btn-primary-bg); - border-color: var(--color-brand-primary); + border-color: var(--color-btn-primary-border, transparent); } .btn--secondary { diff --git a/src/Web/StellaOps.Web/src/app/features/security/security-disposition-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/security-disposition-page.component.ts index 6c2957ea2..06f533a00 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/security-disposition-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/security-disposition-page.component.ts @@ -5,7 +5,13 @@ import { forkJoin, of } from 'rxjs'; import { catchError, map, take } from 'rxjs/operators'; import { PlatformContextStore } from '../../core/context/platform-context.store'; +import { + StellaQuickLinksComponent, + type StellaQuickLink, +} from '../../shared/components/stella-quick-links/stella-quick-links.component'; +import { DateFormatService } from '../../core/i18n/date-format.service'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; interface SecurityDispositionProjection { findingId: string; cveId: string; @@ -47,28 +53,33 @@ interface PlatformListResponse { type AdvisoryTab = 'providers' | 'vex-library' | 'conflicts' | 'issuer-trust'; +const ADVISORY_TABS: StellaPageTab[] = [ + { id: 'providers', label: 'Providers', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01' }, + { id: 'vex-library', label: 'VEX Library', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'conflicts', label: 'Conflicts', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01' }, + { id: 'issuer-trust', label: 'Issuer Trust', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, +]; + @Component({ selector: 'app-security-disposition-page', standalone: true, - imports: [RouterLink], + imports: [RouterLink, StellaQuickLinksComponent, StellaPageTabsComponent], template: `
-
-

Security / Advisories & VEX

-

Intel and attestation workspace for provider health, statement conflicts, and issuer trust.

+
+
+

Security / Advisories & VEX

+

Intel and attestation workspace for provider health, statement conflicts, and issuer trust.

+
+
- - - - + @if (error()) { } @if (loading()) { } @@ -196,61 +207,91 @@ type AdvisoryTab = 'providers' | 'vex-library' | 'conflicts' | 'issuer-trust'; } } } +
`, styles: [` - .advisories{display:grid;gap:.65rem} - .advisories header h1{margin:0} - .advisories header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem} - - .ownership-links{display:flex;gap:.35rem;flex-wrap:wrap} - .ownership-links a,.tabs a{ - border:1px solid var(--color-border-primary); - border-radius:var(--radius-full); - padding:.12rem .5rem; - font-size:.72rem; - text-decoration:none; - background:var(--color-surface-primary); + .advisories { display: grid; gap: 1rem; } + .advisories__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + } + .advisories__header h1 { margin: 0; font-size: 1.35rem; color: var(--color-text-heading, var(--color-text-primary)); } + .advisories__header p { margin: 0.25rem 0 0; color: var(--color-text-secondary); font-size: 0.875rem; } + .advisories__quick-links { + flex-shrink: 0; + border-top: none; + padding-top: 0; + margin-top: 0; } - .ownership-links a{color:var(--color-brand-primary)} - .tabs{display:flex;gap:.3rem;flex-wrap:wrap} - .tabs a{color:var(--color-text-secondary)} - .tabs a.active{border-color:var(--color-tab-active-border, var(--color-brand-primary));color:var(--color-tab-active-text, var(--color-text-primary));font-weight:600} + .banner, .panel { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + } + .banner { padding: 0.75rem 1rem; font-size: 0.875rem; color: var(--color-text-secondary); } + .banner--error { color: var(--color-status-error-text); } - .banner,.panel{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary)} - .banner{padding:.65rem;font-size:.8rem;color:var(--color-text-secondary)} - .banner--error{color:var(--color-status-error-text)} + .panel { padding: 1.25rem; display: grid; gap: 0.75rem; } + .panel h2 { + margin: 0; + font-size: 1rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-heading, var(--color-text-primary)); + } - .panel{padding:.55rem;display:grid;gap:.45rem} - .panel h2{margin:0;font-size:.85rem} - - table{width:100%;border-collapse:collapse} - th,td{border-bottom:1px solid var(--color-border-primary);padding:.38rem .45rem;font-size:.72rem;text-align:left;vertical-align:top} - th{font-size:.64rem;color:var(--color-text-secondary);text-transform:uppercase;letter-spacing:.02em} - tr:last-child td{border-bottom:none} - a{color:var(--color-brand-primary)} + table { width: 100%; border-collapse: collapse; } + th { + padding: 0.5rem 0.875rem; + font-size: 0.6875rem; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + text-align: left; + background: var(--color-surface-secondary); + border-bottom: 1px solid var(--color-border-primary); + white-space: nowrap; + vertical-align: middle; + } + td { + padding: 0.625rem 0.875rem; + font-size: 0.8125rem; + color: var(--color-text-primary); + border-bottom: 1px solid var(--color-border-primary); + text-align: left; + vertical-align: middle; + } + tr:last-child td { border-bottom: none; } + tbody tr:nth-child(even) { background: var(--color-surface-secondary); } + tbody tr:hover { background: var(--color-surface-tertiary, var(--color-nav-hover)); } + a { color: var(--color-text-link); text-decoration: none; } + a:hover { text-decoration: underline; } `], changeDetection: ChangeDetectionStrategy.OnPush, }) export class SecurityDispositionPageComponent { + private readonly dateFmt = inject(DateFormatService); + private readonly http = inject(HttpClient); private readonly route = inject(ActivatedRoute); readonly context = inject(PlatformContextStore); + readonly quickLinks: readonly StellaQuickLink[] = [ + { label: 'Configure advisory feeds', route: '/ops/integrations/advisory-vex-sources' }, + { label: 'Configure VEX sources', route: '/ops/integrations/advisory-vex-sources' }, + ]; + readonly loading = signal(false); readonly error = signal(null); readonly rows = signal([]); readonly feedRows = signal([]); readonly vexSourceRows = signal([]); readonly activeTab = signal('providers'); - - readonly tabs: Array<{ id: AdvisoryTab; label: string }> = [ - { id: 'providers', label: 'Providers' }, - { id: 'vex-library', label: 'VEX Library' }, - { id: 'conflicts', label: 'Conflicts' }, - { id: 'issuer-trust', label: 'Issuer Trust' }, - ]; + readonly ADVISORY_TABS = ADVISORY_TABS; readonly providerRows = computed(() => { const fromFeeds = this.feedRows().map((row) => ({ ...row, channel: 'advisory-feed' })); @@ -297,7 +338,7 @@ export class SecurityDispositionPageComponent { this.route.queryParamMap.subscribe((params) => { const tab = (params.get('tab') ?? 'providers') as AdvisoryTab; - if (this.tabs.some((item) => item.id === tab)) { + if (ADVISORY_TABS.some((item) => item.id === tab)) { this.activeTab.set(tab); } else { this.activeTab.set('providers'); @@ -313,7 +354,7 @@ export class SecurityDispositionPageComponent { fmt(value: string): string { const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return value; - return parsed.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + return parsed.toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } conflictResolution(row: SecurityDispositionProjection): string { diff --git a/src/Web/StellaOps.Web/src/app/features/security/security-findings-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/security-findings-page.component.ts index 2bdb5bd43..74bde544c 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/security-findings-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/security-findings-page.component.ts @@ -6,6 +6,19 @@ import { take } from 'rxjs'; import { PlatformContextStore } from '../../core/context/platform-context.store'; +import { DateFormatService } from '../../core/i18n/date-format.service'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +const EVIDENCE_RAIL_TABS: StellaPageTab[] = [ + { id: 'why', label: 'Why', icon: 'M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3|||M12 17h.01|||M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0' }, + { id: 'sbom', label: 'SBOM', icon: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z|||M3.27 6.96L12 12.01l8.73-5.05|||M12 22.08V12' }, + { id: 'reachability', label: 'Reachability', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, + { id: 'vex', label: 'Effective VEX', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'waiver', label: 'Waiver', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'policy', label: 'Policy Trace', icon: 'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2|||M8 2h8v4H8z' }, + { id: 'export', label: 'Export', icon: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4|||M7 10l5 5 5-5|||M12 15V3' }, +]; + interface SecurityFindingProjection { findingId: string; cveId: string; @@ -37,7 +50,7 @@ type EvidenceTab = 'why' | 'sbom' | 'reachability' | 'vex' | 'waiver' | 'policy' @Component({ selector: 'app-security-findings-page', standalone: true, - imports: [RouterLink, FormsModule], + imports: [RouterLink, FormsModule, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -184,16 +197,12 @@ type EvidenceTab = 'why' | 'sbom' | 'reachability' | 'vex' | 'waiver' | 'policy' {{ selected()!.cveId }} · {{ selected()!.componentName }} · {{ selected()!.region }}/{{ selected()!.environment }}

- + @switch (activeEvidenceTab()) { @case ('why') { @@ -246,6 +255,7 @@ type EvidenceTab = 'why' | 'sbom' | 'reachability' | 'vex' | 'waiver' | 'policy' } } +
Open detail @@ -261,111 +271,135 @@ type EvidenceTab = 'why' | 'sbom' | 'reachability' | 'vex' | 'waiver' | 'policy'
`, styles: [` - .triage{display:grid;gap:.7rem} - .triage header h1{margin:0} - .triage header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem} + .triage { display: grid; gap: 1rem; } + .triage header h1 { margin: 0; font-size: 1.35rem; color: var(--color-text-heading, var(--color-text-primary)); } + .triage header p { margin: 0.25rem 0 0; color: var(--color-text-secondary); font-size: 0.875rem; } - .pivots{display:flex;gap:.3rem;flex-wrap:wrap} - .pivots button{ - border:1px solid var(--color-border-primary); - background:var(--color-surface-primary); - color:var(--color-text-secondary); - border-radius:var(--radius-full); - padding:.15rem .55rem; - font-size:.72rem; - cursor:pointer; + .pivots { display: flex; gap: 0.375rem; flex-wrap: wrap; } + .pivots button { + border: 1px solid var(--color-border-primary); + background: var(--color-surface-primary); + color: var(--color-text-secondary); + border-radius: var(--radius-full); + padding: 0.25rem 0.75rem; + font-size: 0.8125rem; + cursor: pointer; + transition: color 150ms ease, border-color 150ms ease, background-color 150ms ease; } - .pivots button.active{ - border-color:var(--color-brand-primary); - color:var(--color-brand-primary); - background:color-mix(in srgb, var(--color-brand-primary) 8%, var(--color-surface-primary)); + .pivots button:hover { + color: var(--color-text-primary); + background: var(--color-surface-secondary); + } + .pivots button.active { + border-color: var(--color-brand-primary); + color: var(--color-text-link); + background: color-mix(in srgb, var(--color-brand-primary) 8%, var(--color-surface-primary)); } - .banner,.filters,.table-panel,.rail{ - border:1px solid var(--color-border-primary); - border-radius:var(--radius-md); - background:var(--color-surface-primary); + .banner, .filters, .table-panel, .rail { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); } - .banner{padding:.65rem;font-size:.8rem;color:var(--color-text-secondary)} - .banner--error{color:var(--color-status-error-text)} + .banner { padding: 0.75rem 1rem; font-size: 0.875rem; color: var(--color-text-secondary); } + .banner--error { color: var(--color-status-error-text); } - .workspace{display:grid;grid-template-columns:240px 1fr 320px;gap:.45rem;align-items:start} + .workspace { display: grid; grid-template-columns: 240px 1fr 320px; gap: 0.75rem; align-items: start; } - .filters{padding:.55rem;display:grid;gap:.35rem} - .filters h2,.filters h3{margin:0} - .filters h2{font-size:.84rem} - .filters h3{font-size:.72rem;color:var(--color-text-secondary)} - .filters label{display:grid;gap:.18rem} - .filters label span{font-size:.66rem;color:var(--color-text-secondary);text-transform:uppercase} - .filters input,.filters select{ - border:1px solid var(--color-border-primary); - border-radius:var(--radius-sm); - background:var(--color-surface-primary); - padding:.24rem .4rem; - font-size:.72rem; - color:var(--color-text-primary); - transition:border-color 150ms ease, box-shadow 150ms ease; + .filters { padding: 0.875rem; display: grid; gap: 0.5rem; } + .filters h2, .filters h3 { margin: 0; } + .filters h2 { font-size: 0.9375rem; font-weight: var(--font-weight-semibold); } + .filters h3 { font-size: 0.8125rem; color: var(--color-text-secondary); } + .filters label { display: grid; gap: 0.25rem; } + .filters label span { font-size: 0.6875rem; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: 0.04em; } + .filters input, .filters select { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-primary); + padding: 0.375rem 0.5rem; + font-size: 0.8125rem; + color: var(--color-text-primary); + transition: border-color 150ms ease, box-shadow 150ms ease; } - .filters input:focus,.filters select:focus{ - outline:none; - border-color:var(--color-brand-primary); - box-shadow:0 0 0 2px var(--color-focus-ring, rgba(59, 130, 246, 0.25)); + .filters input:focus, .filters select:focus { + outline: none; + border-color: var(--color-brand-primary); + box-shadow: 0 0 0 2px var(--color-focus-ring, rgba(59, 130, 246, 0.25)); } - .saved{display:grid;gap:.2rem;padding-top:.25rem} - .saved button{ - text-align:left; - border:1px solid var(--color-border-primary); - border-radius:var(--radius-sm); - background:var(--color-surface-secondary); - color:var(--color-text-secondary); - font-size:.7rem; - padding:.2rem .35rem; - cursor:pointer; + .saved { display: grid; gap: 0.375rem; padding-top: 0.5rem; } + .saved button { + text-align: left; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-sm); + background: var(--color-surface-secondary); + color: var(--color-text-secondary); + font-size: 0.8125rem; + padding: 0.375rem 0.5rem; + cursor: pointer; + transition: background 150ms ease; } + .saved button:hover { background: var(--color-nav-hover); } - .table-panel{overflow:auto} - table{width:100%;border-collapse:collapse} - th,td{border-bottom:1px solid var(--color-border-primary);padding:.35rem .5rem;font-size:.72rem;text-align:left;vertical-align:top} - th{ - font-size:.64rem;color:var(--color-text-secondary);text-transform:uppercase;letter-spacing:.02em; - position:sticky;top:0;background:var(--color-surface-secondary);z-index:1;white-space:nowrap; + .table-panel { overflow: auto; } + table { width: 100%; border-collapse: collapse; } + th, td { border-bottom: 1px solid var(--color-border-primary); text-align: left; vertical-align: middle; } + th { + padding: 0.5rem 0.875rem; + font-size: 0.6875rem; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + position: sticky; + top: 0; + background: var(--color-surface-secondary); + z-index: 1; + white-space: nowrap; } - tr:last-child td{border-bottom:none} - tbody tr{cursor:pointer} - tbody tr:nth-child(even){background:var(--color-surface-secondary)} - tbody tr:hover{background:var(--color-nav-hover)} - tbody tr.selected{background:color-mix(in srgb,var(--color-brand-primary) 10%, transparent)} - - .verdict{display:inline-block;border-radius:var(--radius-full);padding:.05rem .38rem;font-size:.64rem;border:1px solid transparent} - .verdict--block{color:var(--color-status-error-text);border-color:color-mix(in srgb, var(--color-status-error-text) 45%, transparent)} - .verdict--waiver{color:var(--color-status-warning-text);border-color:color-mix(in srgb, var(--color-status-warning-text) 45%, transparent)} - .verdict--ship{color:var(--color-status-success);border-color:color-mix(in srgb, var(--color-status-success) 45%, transparent)} - - .rail{padding:.55rem;display:grid;gap:.45rem;position:sticky;top:.5rem} - .rail h2{margin:0;font-size:.84rem} - .identity{margin:0;font-size:.74rem;color:var(--color-text-secondary)} - .rail-tabs{display:flex;flex-wrap:wrap;gap:.22rem} - .rail-tabs button{ - border:1px solid var(--color-border-primary); - background:var(--color-surface-primary); - color:var(--color-text-secondary); - border-radius:var(--radius-full); - padding:.1rem .42rem; - font-size:.66rem; - cursor:pointer; + td { + padding: 0.625rem 0.875rem; + font-size: 0.8125rem; + color: var(--color-text-primary); + } + tr:last-child td { border-bottom: none; } + tbody tr { cursor: pointer; } + tbody tr:nth-child(even) { background: var(--color-surface-secondary); } + tbody tr:hover { background: var(--color-surface-tertiary, var(--color-nav-hover)); } + tbody tr.selected { background: color-mix(in srgb, var(--color-brand-primary) 10%, transparent); } + + .verdict { + display: inline-block; + border-radius: var(--radius-full); + padding: 0.125rem 0.5rem; + font-size: 0.75rem; + font-weight: var(--font-weight-medium); + border: 1px solid transparent; + } + .verdict--block { color: var(--color-status-error-text); border-color: color-mix(in srgb, var(--color-status-error-text) 45%, transparent); } + .verdict--waiver { color: var(--color-status-warning-text); border-color: color-mix(in srgb, var(--color-status-warning-text) 45%, transparent); } + .verdict--ship { color: var(--color-status-success); border-color: color-mix(in srgb, var(--color-status-success) 45%, transparent); } + + .rail { padding: 0.875rem; display: grid; gap: 0.625rem; position: sticky; top: 0.5rem; } + .rail h2 { margin: 0; font-size: 0.9375rem; font-weight: var(--font-weight-semibold); } + .identity { margin: 0; font-size: 0.8125rem; color: var(--color-text-secondary); } + .rail-body { display: grid; gap: 0.375rem; } + .rail-body p { margin: 0; font-size: 0.8125rem; color: var(--color-text-secondary); line-height: 1.5; } + .rail-body a, .rail-actions a { font-size: 0.8125rem; color: var(--color-text-link); text-decoration: none; } + .rail-body a:hover, .rail-actions a:hover { text-decoration: underline; } + .rail-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + padding-top: 0.5rem; + border-top: 1px solid var(--color-border-primary); } - .rail-tabs button.active{border-color:var(--color-tab-active-border, var(--color-brand-primary));color:var(--color-tab-active-text, var(--color-text-primary));font-weight:600} - .rail-body{display:grid;gap:.25rem} - .rail-body p{margin:0;font-size:.74rem;color:var(--color-text-secondary)} - .rail-body a,.rail-actions a{font-size:.72rem;color:var(--color-brand-primary);text-decoration:none} - .rail-actions{display:flex;flex-wrap:wrap;gap:.35rem;padding-top:.2rem;border-top:1px solid var(--color-border-primary)} /* Severity badges */ - .severity-badge{font-weight:500} - .severity-badge--critical{color:var(--color-severity-critical, #dc2626)} - .severity-badge--high{color:var(--color-severity-high, #ea580c)} - .severity-badge--medium{color:var(--color-severity-medium, #d97706)} - .severity-badge--low{color:var(--color-severity-low, #16a34a)} + .severity-badge { font-weight: 500; } + .severity-badge--critical { color: var(--color-severity-critical, #dc2626); } + .severity-badge--high { color: var(--color-severity-high, #ea580c); } + .severity-badge--medium { color: var(--color-severity-medium, #d97706); } + .severity-badge--low { color: var(--color-severity-low, #16a34a); } /* Shimmer skeleton loading */ .skeleton-container{ @@ -401,6 +435,8 @@ type EvidenceTab = 'why' | 'sbom' | 'reachability' | 'vex' | 'waiver' | 'policy' `], }) export class SecurityFindingsPageComponent { + private readonly dateFmt = inject(DateFormatService); + private readonly http = inject(HttpClient); private readonly route = inject(ActivatedRoute); private readonly router = inject(Router); @@ -421,15 +457,7 @@ export class SecurityFindingsPageComponent { { id: 'release', label: 'Releases' }, ]; - readonly evidenceTabs: Array<{ id: EvidenceTab; label: string }> = [ - { id: 'why', label: 'Why' }, - { id: 'sbom', label: 'SBOM' }, - { id: 'reachability', label: 'Reachability' }, - { id: 'vex', label: 'Effective VEX' }, - { id: 'waiver', label: 'Waiver' }, - { id: 'policy', label: 'Policy Trace' }, - { id: 'export', label: 'Export' }, - ]; + readonly EVIDENCE_RAIL_TABS = EVIDENCE_RAIL_TABS; pivot: PivotId = 'cve'; search = ''; @@ -459,7 +487,7 @@ export class SecurityFindingsPageComponent { this.load(); const activeTab = (params.get('tab') ?? '').toLowerCase(); - if (this.evidenceTabs.some((tab) => tab.id === activeTab)) { + if (EVIDENCE_RAIL_TABS.some((tab) => tab.id === activeTab)) { this.activeEvidenceTab.set(activeTab as EvidenceTab); } }); @@ -555,7 +583,7 @@ export class SecurityFindingsPageComponent { fmt(value: string): string { const parsed = new Date(value); if (Number.isNaN(parsed.getTime())) return value; - return parsed.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + return parsed.toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); } private load(): void { diff --git a/src/Web/StellaOps.Web/src/app/features/security/security-overview-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/security-overview-page.component.ts index 31215fb63..6ef2bfecf 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/security-overview-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/security-overview-page.component.ts @@ -11,10 +11,12 @@ import { Router, RouterLink } from '@angular/router'; import { catchError, of } from 'rxjs'; import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client'; import { scannerOpsPath } from '../platform/ops/operations-paths'; +import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component'; +import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component'; @Component({ selector: 'app-security-overview-page', - imports: [RouterLink], + imports: [RouterLink, StellaMetricCardComponent, StellaMetricGridComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -31,28 +33,36 @@ import { scannerOpsPath } from '../platform/ops/operations-paths'; -
-
- {{ stats().critical }} - Critical -
-
- {{ stats().high }} - High -
-
- {{ stats().medium }} - Medium -
-
- {{ stats().low }} - Low -
-
- {{ stats().reachable }} - Reachable -
-
+ + + + + + +
@@ -156,27 +166,8 @@ import { scannerOpsPath } from '../platform/ops/operations-paths'; .page-title { margin: 0 0 0.25rem; font-size: 1.5rem; font-weight: var(--font-weight-semibold); } .page-subtitle { margin: 0; color: var(--color-text-secondary); } - .stats-grid { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 1rem; - margin-bottom: 1.5rem; - } - .stat-card { - padding: 1.25rem; - background: var(--color-surface-primary); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - text-align: center; - border-top: 4px solid; - } - .stat-card--critical { border-top-color: var(--color-status-excepted); } - .stat-card--high { border-top-color: var(--color-severity-critical); } - .stat-card--medium { border-top-color: var(--color-severity-medium); } - .stat-card--low { border-top-color: var(--color-severity-info); } - .stat-card--reachable { border-top-color: var(--color-severity-high); } - .stat-value { display: block; font-size: 2rem; font-weight: var(--font-weight-bold); } - .stat-label { font-size: 0.75rem; color: var(--color-text-secondary); text-transform: uppercase; } + /* Stats — now uses stella-metric-grid/card */ + stella-metric-grid { margin-bottom: 1.5rem; } .content-grid { display: grid; @@ -197,7 +188,7 @@ import { scannerOpsPath } from '../platform/ops/operations-paths'; margin-bottom: 1rem; } .panel-header h3 { margin: 0; font-size: 0.875rem; font-weight: var(--font-weight-semibold); } - .panel-link { font-size: 0.75rem; color: var(--color-brand-primary); text-decoration: none; } + .panel-link { font-size: 0.75rem; color: var(--color-text-link); text-decoration: none; } .findings-list { display: flex; flex-direction: column; gap: 0.5rem; } .finding-item { diff --git a/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts index e279e882c..8b9e0f13c 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/security-reports-page.component.ts @@ -8,6 +8,10 @@ import { downloadCsv, downloadJson } from '../../shared/utils/download-helpers'; import { ExportCenterComponent } from '../evidence-export/export-center.component'; import { SecurityRiskOverviewComponent } from '../security-risk/security-risk-overview.component'; import { SecurityDispositionPageComponent } from './security-disposition-page.component'; +import { + StellaPageTabsComponent, + type StellaPageTab, +} from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type ReportTab = 'risk' | 'vex' | 'evidence'; @@ -41,6 +45,24 @@ interface PlatformListResponse { items: T[]; } +const REPORT_TABS: readonly StellaPageTab[] = [ + { + id: 'risk', + label: 'Risk Report', + icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', + }, + { + id: 'vex', + label: 'VEX Ledger', + icon: 'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2|||M8 2h8v4H8z|||M9 14l2 2 4-4', + }, + { + id: 'evidence', + label: 'Evidence Export', + icon: 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4|||M7 10l5 5 5-5|||M12 15V3', + }, +]; + @Component({ selector: 'app-security-reports-page', standalone: true, @@ -49,249 +71,214 @@ interface PlatformListResponse { SecurityRiskOverviewComponent, SecurityDispositionPageComponent, ExportCenterComponent, + StellaPageTabsComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
-
-

Security Reports

-

Review risk posture, VEX decisions, and evidence exports without leaving the reports workspace.

+
+
+
+

Security Reports

+

Risk posture, VEX decisions, and evidence exports for the current scope.

+
- - -
+ @switch (activeTab()) { @case ('risk') { -
-
- - +
+
+
+ + +
+ + VEX exports available on the + VEX Ledger tab. +
-

- VEX decision exports are available on the - VEX Ledger tab. -

- -
+ +
} @case ('vex') { -
-
- +
+
+
+ +
- -
+ +
} @case ('evidence') { -
-
+
+

Evidence Bundles

Evidence bundles (StellaBundle OCI) are managed from the Export Center. - The Export Center lets you create export profiles, schedule automated runs, - and download signed audit packs with DSSE envelopes, Rekor tile receipts, - and replay logs suitable for auditor delivery via OCI referrer. + Create export profiles, schedule automated runs, and download signed audit packs.

- + Open Export Center - +
- -
+ +
} } - + `, - styles: [ - ` - .security-reports { display: grid; gap: 1rem; } - h1 { margin: 0; font-size: 1.35rem; } - header > p { margin: 0; color: var(--color-text-secondary); font-size: 0.85rem; } + styles: [` + .reports { + max-width: 1200px; + animation: reports-enter 200ms ease; + } - .tabs { - display: flex; - gap: 0; - border-bottom: 1px solid var(--color-border-primary); - } + @keyframes reports-enter { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } + } - .tabs button { - padding: 0.5rem 1rem; - border: none; - border-bottom: 2px solid transparent; - background: transparent; - color: var(--color-tab-inactive-text, var(--color-text-secondary)); - font-size: 0.82rem; - font-weight: 500; - cursor: pointer; - transition: color 150ms ease, border-color 150ms ease; - } + /* ---- Header ---- */ + .reports__header { + margin-bottom: 1rem; + } - .tabs button:hover { - color: var(--color-text-primary); - } + .reports__title { + margin: 0 0 0.15rem; + font-size: 1.35rem; + font-weight: var(--font-weight-bold, 700); + color: var(--color-text-heading); + } - .tabs button.active { - color: var(--color-tab-active-text, var(--color-brand-primary)); - border-bottom: 2px solid var(--color-tab-active-border, var(--color-brand-primary)); - font-weight: 600; - } + .reports__subtitle { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text-secondary); + line-height: 1.5; + } - .report-panel { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - overflow: hidden; - transition: transform 150ms ease, box-shadow 150ms ease; - } + /* ---- Panels (inside stella-page-tabs) ---- */ + .reports__panel { + /* Content flows naturally inside the spt__panel */ + } - .report-panel:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0,0,0,0.08); - } + /* ---- Toolbar ---- */ + .reports__toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.5rem 0; + margin-bottom: 1rem; + border-bottom: 1px solid var(--color-border-primary); + flex-wrap: wrap; + } - .export-toolbar { - display: flex; - gap: 0.5rem; - padding: 0.65rem 0.75rem; - border-bottom: 1px solid var(--color-border-primary); - background: var(--color-surface-secondary); - } + .reports__toolbar-group { + display: flex; + gap: 0.375rem; + } - .export-btn { - display: inline-flex; - align-items: center; - gap: 0.35rem; - padding: 0.35rem 0.7rem; - border: 1px solid var(--color-brand-primary); - border-radius: var(--radius-md); - background: var(--color-btn-primary-bg, var(--color-brand-primary)); - color: var(--color-btn-primary-text, #fff); - font-size: 0.76rem; - font-weight: 600; - cursor: pointer; - transition: opacity 150ms ease, box-shadow 150ms ease; - } + .reports__toolbar-hint { + font-size: 0.75rem; + color: var(--color-text-muted); + } - .export-btn:hover:not(:disabled) { - opacity: 0.9; - } + .reports__toolbar-hint a { + color: var(--color-text-link); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; + } - .export-btn:focus-visible { - outline: none; - box-shadow: 0 0 0 2px var(--color-focus-ring, rgba(59, 130, 246, 0.25)); - } + /* ---- Action buttons (secondary style) ---- */ + .reports__action { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border: 1px solid var(--color-btn-secondary-border); + border-radius: var(--radius-md); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + text-decoration: none; + transition: background 150ms ease, border-color 150ms ease; + } - .export-btn:disabled { - opacity: 0.5; - cursor: not-allowed; - } + .reports__action:hover:not(:disabled) { + background: var(--color-btn-secondary-hover-bg); + border-color: var(--color-btn-secondary-hover-border); + } - .evidence-tab .evidence-explainer { - padding: 1rem 1.25rem; - border-bottom: 1px solid var(--color-border-primary); - background: var(--color-surface-secondary); - } + .reports__action:disabled { + opacity: 0.45; + cursor: not-allowed; + } - .evidence-explainer h2 { - margin: 0 0 0.35rem; - font-size: 0.95rem; - } + .reports__action:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + } - .evidence-explainer p { - margin: 0 0 0.75rem; - color: var(--color-text-secondary); - font-size: 0.82rem; - line-height: 1.45; - } + /* ---- Evidence intro ---- */ + .reports__evidence-intro { + margin-bottom: 1.25rem; + padding: 1rem; + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + } - .export-center-link { - display: inline-flex; - align-items: center; - gap: 0.35rem; - padding: 0.4rem 0.85rem; - background: var(--color-btn-primary-bg, var(--color-brand-primary)); - color: var(--color-btn-primary-text, #fff); - border-radius: var(--radius-md); - font-size: 0.82rem; - font-weight: 600; - text-decoration: none; - transition: opacity 150ms ease; - } + .reports__evidence-intro h2 { + margin: 0 0 0.35rem; + font-size: 0.95rem; + font-weight: var(--font-weight-semibold, 600); + color: var(--color-text-heading); + } - .export-center-link:hover { - opacity: 0.9; - } + .reports__evidence-intro p { + margin: 0 0 0.75rem; + color: var(--color-text-secondary); + font-size: 0.8125rem; + line-height: 1.5; + max-width: 600px; + } - .export-center-link:focus-visible { - outline: none; - box-shadow: 0 0 0 2px var(--color-focus-ring, rgba(59, 130, 246, 0.25)); + /* ---- Print ---- */ + @media print { + .reports__toolbar, + .reports__evidence-intro, + stella-page-tabs .spt__bar { + display: none !important; } - - .vex-export-note { - margin: 0; - padding: 0.5rem 0.75rem; - font-size: 0.78rem; - color: var(--color-text-secondary); - border-bottom: 1px solid var(--color-border-primary); - } - - .vex-export-note__link { - color: var(--color-brand-primary); - cursor: pointer; - text-decoration: underline; - font-weight: 500; - } - - .vex-export-note__link:hover { - opacity: 0.85; - } - - @media print { - .tabs, .export-toolbar, .evidence-explainer, .export-center-link, .vex-export-note { - display: none !important; - } - } - `, - ], + } + `], }) export class SecurityReportsPageComponent { private readonly http = inject(HttpClient); private readonly context = inject(PlatformContextStore); + readonly tabs = REPORT_TABS; readonly activeTab = signal('risk'); readonly riskExporting = signal(false); readonly vexExporting = signal(false); diff --git a/src/Web/StellaOps.Web/src/app/features/security/security-sbom-explorer-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/security-sbom-explorer-page.component.ts index 1b8842ca0..84873c532 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/security-sbom-explorer-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/security-sbom-explorer-page.component.ts @@ -4,6 +4,15 @@ import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { take } from 'rxjs'; import { PlatformContextStore } from '../../core/context/platform-context.store'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +const SUPPLY_CHAIN_TABS: StellaPageTab[] = [ + { id: 'viewer', label: 'SBOM Viewer', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'graph', label: 'SBOM Graph', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M2 12h20|||M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z' }, + { id: 'lake', label: 'SBOM Lake', icon: 'M12 2C6.48 2 2 4.02 2 6.5v11c0 2.49 4.48 4.5 10 4.5s10-2.01 10-4.5v-11C22 4.02 17.52 2 12 2z|||M2 6.5c0 2.49 4.48 4.5 10 4.5s10-2.01 10-4.5|||M2 12c0 2.49 4.48 4.5 10 4.5s10-2.01 10-4.5' }, + { id: 'reachability', label: 'Reachability', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, + { id: 'coverage', label: 'Coverage/Unknowns', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' }, +]; interface SecuritySbomComponentRow { componentId: string; @@ -35,7 +44,7 @@ type SupplyChainMode = 'viewer' | 'graph' | 'lake' | 'reachability' | 'coverage' @Component({ selector: 'app-security-sbom-explorer-page', standalone: true, - imports: [RouterLink], + imports: [RouterLink, StellaPageTabsComponent], template: `
@@ -48,11 +57,12 @@ type SupplyChainMode = 'viewer' | 'graph' | 'lake' | 'reachability' | 'coverage' {{ freshnessSummary() }} - + @if (error()) { } @if (loading()) { } @@ -176,38 +186,103 @@ type SupplyChainMode = 'viewer' | 'graph' | 'lake' | 'reachability' | 'coverage'
`, styles: [` - .supply-chain{display:grid;gap:.65rem} - .supply-chain header h1{margin:0} - .supply-chain header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem} + .supply-chain { display: grid; gap: 1rem; } + .supply-chain header h1 { margin: 0; font-size: 1.35rem; color: var(--color-text-heading, var(--color-text-primary)); } + .supply-chain header p { margin: 0.25rem 0 0; color: var(--color-text-secondary); font-size: 0.875rem; } - .status,.tabs a,.banner,.panel{ - border:1px solid var(--color-border-primary); - border-radius:var(--radius-md); - background:var(--color-surface-primary); + .status, .banner, .panel { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); } - .status{display:flex;gap:.45rem;align-items:center;flex-wrap:wrap;padding:.55rem;font-size:.78rem} - .status--warn{border-color:var(--color-status-warning-text)} + .status { + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; + padding: 0.75rem 1rem; + font-size: 0.875rem; + } + .status--warn { border-color: var(--color-status-warning-text); } + .status--ok { border-color: var(--color-status-success); } - .tabs{display:flex;gap:.3rem;flex-wrap:wrap} - .tabs a{padding:.12rem .5rem;font-size:.72rem;color:var(--color-text-secondary);text-decoration:none} - .tabs a.active{border-color:var(--color-tab-active-border, var(--color-brand-primary));color:var(--color-tab-active-text, var(--color-text-primary));font-weight:600} + .banner { + padding: 0.75rem 1rem; + font-size: 0.875rem; + color: var(--color-text-secondary); + } + .banner--error { color: var(--color-status-error-text); } - .banner{padding:.65rem;font-size:.8rem;color:var(--color-text-secondary)} - .banner--error{color:var(--color-status-error-text)} + .panel { + padding: 1.25rem; + display: grid; + gap: 0.75rem; + } + .panel h2 { + margin: 0; + font-size: 1rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-heading, var(--color-text-primary)); + } + .panel .hint { + margin: 0; + font-size: 0.8125rem; + color: var(--color-text-secondary); + line-height: 1.5; + } - .panel{padding:.55rem;display:grid;gap:.45rem} - .panel h2{margin:0;font-size:.85rem} - .panel .hint{margin:0;font-size:.74rem;color:var(--color-text-secondary)} + .stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.75rem; + } + .stats-grid article { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + padding: 1rem; + } + .stats-grid h3 { + margin: 0; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-secondary); + } + .stats-grid p { + margin: 0.25rem 0 0; + font-size: 1.5rem; + font-weight: var(--font-weight-bold); + } - .stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:.4rem} - .stats-grid article{border:1px solid var(--color-border-primary);border-radius:var(--radius-sm);padding:.45rem} - .stats-grid h3{margin:0;font-size:.66rem;text-transform:uppercase;color:var(--color-text-secondary)} - .stats-grid p{margin:.18rem 0 0;font-size:1.05rem} - - table{width:100%;border-collapse:collapse} - th,td{border-bottom:1px solid var(--color-border-primary);padding:.38rem .45rem;font-size:.72rem;text-align:left;vertical-align:top} - th{font-size:.64rem;color:var(--color-text-secondary);text-transform:uppercase;letter-spacing:.02em} - tr:last-child td{border-bottom:none} + table { width: 100%; border-collapse: collapse; } + th { + padding: 0.5rem 0.875rem; + font-size: 0.6875rem; + font-weight: 600; + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + text-align: left; + background: var(--color-surface-secondary); + border-bottom: 1px solid var(--color-border-primary); + white-space: nowrap; + vertical-align: middle; + } + td { + padding: 0.625rem 0.875rem; + font-size: 0.8125rem; + color: var(--color-text-primary); + border-bottom: 1px solid var(--color-border-primary); + text-align: left; + vertical-align: middle; + } + tr:last-child td { border-bottom: none; } + tbody tr:nth-child(even) { background: var(--color-surface-secondary); } + tbody tr:hover { background: var(--color-surface-tertiary, var(--color-nav-hover)); } + td a { color: var(--color-text-link); text-decoration: none; font-weight: 500; } + td a:hover { text-decoration: underline; } `], changeDetection: ChangeDetectionStrategy.OnPush, }) @@ -222,13 +297,11 @@ export class SecuritySbomExplorerPageComponent { readonly mode = signal('viewer'); readonly response = signal(null); - readonly tabs: Array<{ id: SupplyChainMode; label: string }> = [ - { id: 'viewer', label: 'SBOM Viewer' }, - { id: 'graph', label: 'SBOM Graph' }, - { id: 'lake', label: 'SBOM Lake' }, - { id: 'reachability', label: 'Reachability' }, - { id: 'coverage', label: 'Coverage/Unknowns' }, - ]; + readonly SUPPLY_CHAIN_TABS = SUPPLY_CHAIN_TABS; + + onTabChange(tabId: string): void { + void this.router.navigate(['/security/supply-chain-data', tabId]); + } readonly tableRows = computed(() => this.response()?.table ?? []); readonly graphNodeCount = computed(() => this.response()?.graphNodes.length ?? 0); diff --git a/src/Web/StellaOps.Web/src/app/features/security/unknowns-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/unknowns-page.component.ts index f9c6c51e1..76ede998f 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/unknowns-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/unknowns-page.component.ts @@ -17,25 +17,58 @@ import { ChangeDetectionStrategy, Component } from '@angular/core';

Review findings pending classification.

-

No unknowns data is available yet.

+ +

No unknowns data available yet

+

Findings pending classification will appear here once scans produce results.

`, styles: [` - .page { display: grid; gap: 0.75rem; } - .page-header { display: flex; flex-direction: column; gap: 0.2rem; } - .page-title { margin: 0; } - .page-subtitle { margin: 0; color: var(--color-text-secondary); font-size: 0.82rem; } + .page { display: grid; gap: 1rem; } + .page-header { display: flex; flex-direction: column; gap: 0.25rem; } + .page-title { margin: 0; font-size: 1.35rem; color: var(--color-text-heading, var(--color-text-primary)); } + .page-subtitle { margin: 0; color: var(--color-text-secondary); font-size: 0.875rem; } .panel { border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); + border-radius: var(--radius-lg); background: var(--color-surface-primary); - padding: 1.5rem; - text-align: center; - color: var(--color-text-secondary); - font-size: 0.82rem; + padding: 2.5rem 1.5rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + } + .empty-icon { + width: 2.5rem; + height: 2.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-full); + color: var(--color-text-link); + background: var(--color-brand-soft, var(--color-surface-secondary)); + margin-bottom: 0.25rem; + } + .empty-heading { + margin: 0; + font-size: 1rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-heading, var(--color-text-primary)); + } + .empty-text { + margin: 0; + font-size: 0.875rem; + color: var(--color-text-secondary); + max-width: 48ch; + text-align: center; + line-height: 1.5; } - .panel p { margin: 0; } `] }) export class UnknownsPageComponent {} diff --git a/src/Web/StellaOps.Web/src/app/features/security/vex-hub-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/vex-hub-page.component.ts index 9402a0179..0280d4252 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/vex-hub-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/vex-hub-page.component.ts @@ -173,19 +173,29 @@ import { FormsModule } from '@angular/forms'; } .data-table { width: 100%; border-collapse: collapse; } .data-table th, .data-table td { - padding: 0.75rem 1rem; + padding: 0.625rem 0.875rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); + vertical-align: middle; } .data-table th { background: var(--color-surface-secondary); - font-size: 0.75rem; - font-weight: var(--font-weight-semibold); + font-size: 0.6875rem; + font-weight: 600; color: var(--color-text-secondary); text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; } + .data-table td { + font-size: 0.8125rem; + color: var(--color-text-primary); + } + .data-table tbody tr:last-child td { border-bottom: none; } + .data-table tbody tr:nth-child(even) { background: var(--color-surface-secondary); } + .data-table tbody tr:hover { background: var(--color-surface-tertiary, var(--color-nav-hover)); } - .cve-link { color: var(--color-brand-primary); text-decoration: none; font-family: monospace; } + .cve-link { color: var(--color-text-link); text-decoration: none; font-family: monospace; } .status-badge { display: inline-block; diff --git a/src/Web/StellaOps.Web/src/app/features/security/vulnerability-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/security/vulnerability-detail-page.component.ts index 6d072b8ed..f3f470eab 100644 --- a/src/Web/StellaOps.Web/src/app/features/security/vulnerability-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/security/vulnerability-detail-page.component.ts @@ -185,7 +185,7 @@ type DetailState = 'loading' | 'ready' | 'not-found' | 'malformed' | 'error'; .vuln-detail__back { font-size: 0.82rem; text-decoration: none; - color: var(--color-brand-primary); + color: var(--color-text-link); } .vuln-detail__banner { diff --git a/src/Web/StellaOps.Web/src/app/features/settings/admin/admin-settings-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/admin/admin-settings-page.component.ts index 97dae7e99..aa10486c2 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/admin/admin-settings-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/admin/admin-settings-page.component.ts @@ -21,6 +21,7 @@ import { type UpdateUserRequest, } from '../../../core/api/authority-admin.client'; import { buildAdminScopeCatalog } from './admin-scope-catalog'; +import { StellaPageTabsComponent, StellaPageTab } from '../../../shared/components/stella-page-tabs/stella-page-tabs.component'; type AdminTab = 'users' | 'roles' | 'clients' | 'tokens' | 'tenants'; type UserStatusFilter = 'all' | 'active' | 'disabled' | 'locked'; @@ -50,31 +51,41 @@ interface TenantEditorState { isolationMode: 'shared' | 'dedicated'; } -const TABS: ReadonlyArray<{ id: AdminTab; label: string }> = [ - { id: 'users', label: 'Users' }, - { id: 'roles', label: 'Roles' }, - { id: 'clients', label: 'OAuth Clients' }, - { id: 'tokens', label: 'API Tokens' }, - { id: 'tenants', label: 'Tenants' }, +const TABS: readonly StellaPageTab[] = [ + { id: 'users', label: 'Users', icon: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|||M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|||M20 8v6|||M23 11h-6' }, + { id: 'roles', label: 'Roles', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'clients', label: 'OAuth Clients', icon: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' }, + { id: 'tokens', label: 'API Tokens', icon: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' }, + { id: 'tenants', label: 'Tenants', icon: 'M12 2L2 7l10 5 10-5-10-5z|||M2 17l10 5 10-5|||M2 12l10 5 10-5' }, ]; @Component({ selector: 'app-admin-settings-page', standalone: true, - imports: [CommonModule, FormsModule, RouterLink], + imports: [CommonModule, FormsModule, RouterLink, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
-
-

Identity & Access

-

Create users safely, inspect role permissions, manage tenant lifecycle, and jump to the canonical client or token surfaces when needed.

+ - + @if (error()) { @@ -458,38 +469,54 @@ const TABS: ReadonlyArray<{ id: AdminTab; label: string }> = [ } } +
`, styles: [` :host { display: block; } - /* Layout */ + /* ── Layout ── */ .page, .panel, .editor, .detail, .catalog, .group { display: grid; gap: 1rem; } .page { max-width: 1400px; gap: 0.75rem; } - .page > header { margin-bottom: -0.25rem; } - .page > header h1 { margin: 0 0 0.15rem 0; } - .page > header p { margin: 0; font-size: 0.82rem; } - .tabs, .toolbar, .actions { display: flex; flex-wrap: wrap; gap: .5rem; } + .toolbar, .actions { display: flex; flex-wrap: wrap; gap: .5rem; } .two-col { display: grid; grid-template-columns: minmax(0,2fr) minmax(18rem,1fr); gap: 1rem; align-items: start; } .section-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; } .section-head h2 { margin: 0 0 0.15rem 0; font-size: 1rem; } .section-head p { margin: 0; } - /* Panel, Banner, Editor, Detail, Note */ - .panel, .banner, .editor, .detail, .note { + /* ── Page header ── */ + .page-header { + display: flex; + align-items: center; + gap: 0.875rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--color-border-primary); + } + .page-header__icon { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-brand-primary) 10%, var(--color-surface-primary)); + border: 1px solid color-mix(in srgb, var(--color-brand-primary) 25%, var(--color-border-primary)); + color: var(--color-brand-primary); + flex-shrink: 0; + } + .page-header h1 { margin: 0 0 0.1rem 0; font-size: 1.25rem; font-weight: var(--font-weight-semibold, 600); letter-spacing: -0.01em; } + .page-header p { margin: 0; font-size: 0.8125rem; } + + /* ── Panel, Banner, Editor, Detail, Note ── */ + .banner, .editor, .detail, .note { padding: 1rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); background: var(--color-surface-primary); } .panel { - padding: 1.25rem; animation: fadeIn 0.15s ease; } - @keyframes panel-fade-in { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: translateY(0); } - } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } @@ -499,19 +526,16 @@ const TABS: ReadonlyArray<{ id: AdminTab; label: string }> = [ padding: 1.25rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); - box-shadow: 0 1px 3px rgba(0,0,0,.06); - animation: fadeIn 0.15s ease; + box-shadow: 0 1px 4px rgba(0,0,0,.06); + animation: slideDown 0.2s ease; } - .editor .section-head { - margin-bottom: 0.25rem; - } - .editor .section-head h3 { - margin: 0; - font-size: 0.9rem; - } - .editor .grid { - gap: 0.75rem; + @keyframes slideDown { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } } + .editor .section-head { margin-bottom: 0.25rem; } + .editor .section-head h3 { margin: 0; font-size: 0.9375rem; font-weight: var(--font-weight-semibold, 600); } + .editor .grid { gap: 0.75rem; } .editor .grid label span { font-size: 0.72rem; font-weight: var(--font-weight-medium, 500); @@ -532,7 +556,7 @@ const TABS: ReadonlyArray<{ id: AdminTab; label: string }> = [ display: flex; justify-content: flex-end; gap: 0.5rem; - padding-top: 0.5rem; + padding-top: 0.75rem; border-top: 1px solid var(--color-border-primary); margin-top: 0.25rem; } @@ -548,57 +572,35 @@ const TABS: ReadonlyArray<{ id: AdminTab; label: string }> = [ color: var(--color-text-primary); } .note { - background: rgba(245,166,35,.06); - border-color: rgba(245,166,35,.15); + background: color-mix(in srgb, var(--color-status-warning-bg) 40%, var(--color-surface-primary)); + border-color: var(--color-status-warning-border); font-size: .82rem; color: var(--color-text-secondary); line-height: 1.5; + padding: 0.75rem 1rem; } .banner { + display: flex; + align-items: center; + gap: 0.5rem; font-size: .82rem; - animation: panel-fade-in var(--motion-duration-sm, 140ms) var(--motion-ease-standard, ease); + padding: 0.625rem 1rem; + animation: slideDown 0.2s ease; } .banner.error { background: var(--color-status-error-bg); color: var(--color-status-error-text); border-color: var(--color-status-error-border); } .banner.success { background: var(--color-status-success-bg); color: var(--color-status-success-text); border-color: var(--color-status-success-border); } - /* Tab bar */ - .tabs { - gap: 0; - border-bottom: 1px solid var(--color-border-primary); - margin-bottom: 0; - } - .tab { - position: relative; - padding: 0.5rem 1rem; - background: transparent; - border: none; - border-bottom: 2px solid transparent; - cursor: pointer; - color: var(--color-text-muted); - font-size: 0.8125rem; - line-height: 1; - font-weight: var(--font-weight-medium, 500); - transition: color var(--motion-duration-sm, 140ms) ease, - border-color var(--motion-duration-sm, 140ms) ease, - background-color var(--motion-duration-sm, 140ms) ease; - } - .tab:hover { - color: var(--color-text-primary); - background: var(--color-brand-soft); - } - .tab.active { - color: var(--color-tab-active-text, var(--color-text-primary)); - border-bottom-color: var(--color-tab-active-border, var(--color-brand-primary)); - font-weight: var(--font-weight-semibold, 600); - } - - /* Typography helpers */ + /* ── Typography helpers ── */ .muted { color: var(--color-text-secondary); } .small { font-size: .82rem; } - .state { text-align: center; color: var(--color-text-muted); padding: 1.5rem 0; font-size: .82rem; } + .state { text-align: center; color: var(--color-text-muted); padding: 2rem 0; font-size: .82rem; } - /* Buttons */ + /* ── Buttons ── */ .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.375rem; padding: .4rem .75rem; border-radius: var(--radius-md); border: 1px solid var(--color-border-primary); @@ -609,15 +611,17 @@ const TABS: ReadonlyArray<{ id: AdminTab; label: string }> = [ font: inherit; font-size: .82rem; font-weight: var(--font-weight-medium, 500); - transition: background-color var(--motion-duration-sm, 140ms) ease, - border-color var(--motion-duration-sm, 140ms) ease, - color var(--motion-duration-sm, 140ms) ease, - box-shadow var(--motion-duration-sm, 140ms) ease; + white-space: nowrap; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; } .btn:hover { border-color: var(--color-border-secondary); background: var(--color-surface-secondary); } + .btn:focus-visible { + outline: 2px solid var(--color-brand-primary); + outline-offset: 2px; + } .btn.primary { background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); @@ -625,7 +629,7 @@ const TABS: ReadonlyArray<{ id: AdminTab; label: string }> = [ } .btn.primary:hover { background: var(--color-btn-primary-bg-hover); - box-shadow: var(--shadow-sm); + box-shadow: 0 1px 3px rgba(0,0,0,.12); } .btn.sm { padding: .25rem .5rem; @@ -641,7 +645,7 @@ const TABS: ReadonlyArray<{ id: AdminTab; label: string }> = [ background: var(--color-status-error-bg); } - /* Tables */ + /* ── Tables ── */ .table { width: 100%; border-collapse: collapse; @@ -653,49 +657,55 @@ const TABS: ReadonlyArray<{ id: AdminTab; label: string }> = [ z-index: 1; } .table th { - padding: .55rem .75rem; + padding: .6rem .75rem; border-bottom: 2px solid var(--color-border-primary); text-align: left; vertical-align: middle; - font-size: .7rem; + font-size: .6875rem; font-weight: var(--font-weight-semibold, 600); text-transform: uppercase; - letter-spacing: 0.04em; + letter-spacing: 0.05em; color: var(--color-text-muted); background: var(--color-surface-primary); white-space: nowrap; } .table td { - padding: .55rem .75rem; + padding: .6rem .75rem; border-bottom: 1px solid var(--color-border-primary); text-align: left; vertical-align: top; } .table tbody tr { - transition: background-color var(--motion-duration-sm, 140ms) ease; - } - .table tbody tr:nth-child(even) { - background: var(--color-surface-subtle, rgba(0,0,0,.015)); + transition: background 0.15s ease; } .table tbody tr:hover { - background: var(--color-brand-soft); + background: color-mix(in srgb, var(--color-brand-primary) 4%, var(--color-surface-primary)); } .table tbody tr.selected-row { background: var(--color-selection-bg); - border-left: 2px solid var(--color-brand-primary); + box-shadow: inset 3px 0 0 var(--color-brand-primary); } - /* Status badges */ + /* ── Status badges ── */ .status-badge { display: inline-flex; align-items: center; - padding: .15rem .45rem; + gap: 0.3rem; + padding: .175rem .5rem; border-radius: var(--radius-full, 9999px); - font-size: .7rem; + font-size: .6875rem; font-weight: var(--font-weight-medium, 500); letter-spacing: 0.02em; white-space: nowrap; } + .status-badge::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + opacity: 0.7; + } .status-badge--active, .status-badge--enabled { background: var(--color-status-success-bg); color: var(--color-status-success-text); @@ -709,15 +719,15 @@ const TABS: ReadonlyArray<{ id: AdminTab; label: string }> = [ color: var(--color-status-error-text); } - /* Tags/chips */ + /* ── Tags/chips ── */ .tag { display: inline-flex; align-items: center; - padding: 0.15rem 0.4rem; + padding: 0.15rem 0.45rem; border-radius: var(--radius-full, 9999px); background: var(--color-brand-soft); - color: var(--color-brand-primary); - font-size: 0.7rem; + color: var(--color-text-link); + font-size: 0.6875rem; font-weight: var(--font-weight-medium, 500); white-space: nowrap; line-height: 1.3; @@ -727,59 +737,60 @@ const TABS: ReadonlyArray<{ id: AdminTab; label: string }> = [ flex-wrap: wrap; gap: 0.25rem; } - .table .chips { - max-width: 200px; - } + .table .chips { max-width: 220px; } - /* Toolbar */ + /* ── Toolbar ── */ .toolbar { align-items: center; + gap: 0.5rem; } - .toolbar input[type="search"] { - max-width: 240px; - padding: .35rem .6rem; - font-size: .8rem; - } + .toolbar input[type="search"], .toolbar select { - max-width: 180px; - padding: .35rem .6rem; + padding: .4rem .625rem; font-size: .8rem; + border-radius: var(--radius-md); } + .toolbar input[type="search"] { max-width: 260px; } + .toolbar select { max-width: 180px; } - /* Form grid */ + /* ── Form grid ── */ .grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: .75rem; } .grid .wide { grid-column: 1 / -1; } label { display: grid; gap: .3rem; } - label span { font-size: .72rem; font-weight: var(--font-weight-medium, 500); text-transform: uppercase; letter-spacing: 0.03em; color: var(--color-text-muted); } + label span { + font-size: .72rem; + font-weight: var(--font-weight-medium, 500); + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--color-text-muted); + } input, select { width: 100%; - padding: .55rem .7rem; + padding: .5rem .7rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); color: var(--color-text-primary); font-size: .82rem; - transition: border-color var(--motion-duration-sm, 140ms) ease, - box-shadow var(--motion-duration-sm, 140ms) ease; + transition: border-color 0.15s ease, box-shadow 0.15s ease; } input:focus, select:focus { outline: none; border-color: var(--color-brand-primary); - box-shadow: 0 0 0 2px var(--color-focus-ring); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand-primary) 15%, transparent); } - /* Pick items (role/scope selectors) */ + /* ── Pick items (role/scope selectors) ── */ .pick { display: flex; gap: .75rem; align-items: flex-start; - padding: .6rem .7rem; + padding: .6rem .75rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); background: var(--color-surface-primary); cursor: pointer; - transition: background-color var(--motion-duration-sm, 140ms) ease, - border-color var(--motion-duration-sm, 140ms) ease; + transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; } .pick:hover { border-color: var(--color-border-secondary); @@ -787,30 +798,39 @@ const TABS: ReadonlyArray<{ id: AdminTab; label: string }> = [ } .pick.selected { background: var(--color-selection-bg); - border-color: var(--color-selection-border); + border-color: var(--color-brand-primary); + box-shadow: inset 3px 0 0 var(--color-brand-primary); } .selected-row { background: var(--color-selection-bg); } - /* Skeleton loading */ + /* ── Detail sidebar ── */ + .detail { + position: sticky; + top: 1rem; + background: var(--color-surface-secondary); + max-height: calc(100vh - 8rem); + overflow-y: auto; + } + .detail h3 { + margin: 0 0 0.25rem; + font-size: 0.9375rem; + } + + /* ── Skeleton loading ── */ @keyframes admin-shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } - .skeleton-table { - display: grid; - gap: 0; - } + .skeleton-table { display: grid; gap: 0; } .skeleton-row { display: flex; gap: .75rem; padding: .65rem .75rem; border-bottom: 1px solid var(--color-border-primary); } - .skeleton-row.skeleton-header { - border-bottom-width: 2px; - } + .skeleton-row.skeleton-header { border-bottom-width: 2px; } .skeleton-row span { flex: 1; height: .75rem; @@ -819,12 +839,29 @@ const TABS: ReadonlyArray<{ id: AdminTab; label: string }> = [ background-size: 200% 100%; animation: admin-shimmer 1.5s ease-in-out infinite; } - .skeleton-header span { - height: .55rem; - opacity: 0.6; + .skeleton-header span { height: .55rem; opacity: 0.6; } + + /* ── Responsive ── */ + @media (max-width: 1024px) { + .two-col, .grid { grid-template-columns: 1fr; } + .grid .wide { grid-column: auto; } + .detail { position: static; max-height: none; } + } + @media (max-width: 640px) { + .section-head { flex-direction: column; gap: 0.5rem; } + .toolbar { flex-direction: column; align-items: stretch; } + .toolbar input[type="search"], + .toolbar select { max-width: 100%; } } - @media (max-width: 1024px) { .two-col, .grid { grid-template-columns: 1fr; } .grid .wide { grid-column: auto; } } + /* ── Reduced motion ── */ + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + } `], }) export class AdminSettingsPageComponent implements OnInit { diff --git a/src/Web/StellaOps.Web/src/app/features/settings/ai-preferences.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/ai-preferences.component.ts index bf8aeffda..19342d70b 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/ai-preferences.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/ai-preferences.component.ts @@ -342,7 +342,7 @@ export const DEFAULT_AI_PREFERENCES: AiPreferences = { } .ai-preferences__toggle-option input[type="checkbox"]:checked { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); } .ai-preferences__toggle-option input[type="checkbox"]:checked::after { @@ -396,13 +396,13 @@ export const DEFAULT_AI_PREFERENCES: AiPreferences = { } .ai-preferences__button--primary { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border: 1px solid var(--color-brand-primary); color: var(--color-text-heading); } .ai-preferences__button--primary:hover:not(:disabled) { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); } .ai-preferences__button--primary:disabled { diff --git a/src/Web/StellaOps.Web/src/app/features/settings/identity-providers/add-provider-wizard.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/identity-providers/add-provider-wizard.component.ts index dcbb41b89..c12d19539 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/identity-providers/add-provider-wizard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/identity-providers/add-provider-wizard.component.ts @@ -318,7 +318,7 @@ type WizardStep = 'select-type' | 'configure' | 'test' | 'save'; } .wizard-step-indicator--active { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); font-weight: var(--font-weight-medium); } @@ -402,7 +402,7 @@ type WizardStep = 'select-type' | 'configure' | 'test' | 'save'; } .type-card--selected .type-card__icon { - color: var(--color-brand-primary); + color: var(--color-text-link); } .type-card__name { @@ -501,7 +501,7 @@ type WizardStep = 'select-type' | 'configure' | 'test' | 'save'; .btn--outline { background: transparent; border: 1px solid var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); } .btn--outline:hover:not(:disabled) { diff --git a/src/Web/StellaOps.Web/src/app/features/settings/identity-providers/identity-providers-settings-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/identity-providers/identity-providers-settings-page.component.ts index a7f18697a..c54d38241 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/identity-providers/identity-providers-settings-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/identity-providers/identity-providers-settings-page.component.ts @@ -27,12 +27,20 @@ import { AddProviderWizardComponent } from './add-provider-wizard.component';
@@ -198,39 +206,63 @@ import { AddProviderWizardComponent } from './add-provider-wizard.component'; styles: [` .idp-settings-page { max-width: 1200px; - animation: idp-page-enter var(--motion-duration-md, 200ms) var(--motion-ease-standard, ease); + animation: idp-enter 0.2s ease; } - @keyframes idp-page-enter { + @keyframes idp-enter { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } + /* ── Page header ── */ .page-header { display: flex; justify-content: space-between; - align-items: flex-start; + align-items: center; margin-bottom: 1.25rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--color-border-primary); + } + + .page-header__left { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .page-header__icon { + display: flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-brand-primary) 10%, var(--color-surface-primary)); + border: 1px solid color-mix(in srgb, var(--color-brand-primary) 25%, var(--color-border-primary)); + color: var(--color-brand-primary); + flex-shrink: 0; } .page-title { - margin: 0 0 0.15rem; - font-size: 1.35rem; - font-weight: var(--font-weight-bold, 700); + margin: 0 0 0.1rem; + font-size: 1.25rem; + font-weight: var(--font-weight-semibold, 600); color: var(--color-text-heading); + letter-spacing: -0.01em; } .page-subtitle { margin: 0; - font-size: .82rem; + font-size: .8125rem; color: var(--color-text-secondary); } - /* Buttons */ + /* ── Buttons ── */ .btn { display: inline-flex; align-items: center; justify-content: center; + gap: 0.375rem; padding: 0.4rem 0.85rem; border-radius: var(--radius-md); font-weight: var(--font-weight-medium, 500); @@ -239,16 +271,12 @@ import { AddProviderWizardComponent } from './add-provider-wizard.component'; border: 1px solid var(--color-border-primary); background: transparent; color: var(--color-text-primary); - transition: background-color var(--motion-duration-sm, 140ms) ease, - border-color var(--motion-duration-sm, 140ms) ease, - color var(--motion-duration-sm, 140ms) ease, - box-shadow var(--motion-duration-sm, 140ms) ease; + white-space: nowrap; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; } - .btn:disabled { - opacity: 0.5; - cursor: not-allowed; - } + .btn:disabled { opacity: 0.45; cursor: not-allowed; } + .btn:focus-visible { outline: 2px solid var(--color-brand-primary); outline-offset: 2px; } .btn--primary { background: var(--color-btn-primary-bg); @@ -256,108 +284,48 @@ import { AddProviderWizardComponent } from './add-provider-wizard.component'; color: var(--color-btn-primary-text); font-weight: var(--font-weight-semibold, 600); } - .btn--primary:hover:not(:disabled) { background: var(--color-btn-primary-bg-hover); - box-shadow: var(--shadow-brand-sm); + box-shadow: 0 1px 3px rgba(0,0,0,.12); } .btn--secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); - color: var(--color-text-primary); } + .btn--secondary:hover:not(:disabled) { border-color: var(--color-border-secondary); } - .btn--secondary:hover:not(:disabled) { - border-color: var(--color-border-secondary); - } + .btn--sm { padding: 0.25rem 0.55rem; font-size: 0.72rem; border-radius: var(--radius-sm); } - .btn--sm { - padding: 0.22rem 0.55rem; - font-size: 0.72rem; - border-radius: var(--radius-sm); - } + .btn--outline { background: transparent; border: 1px solid var(--color-border-primary); } + .btn--outline:hover:not(:disabled) { background: var(--color-surface-secondary); border-color: var(--color-border-secondary); } - .btn--outline { - background: transparent; - border: 1px solid var(--color-border-primary); - color: var(--color-text-primary); - } + .btn--success { background: var(--color-status-success-bg); border-color: var(--color-status-success-border); color: var(--color-status-success-text); } + .btn--success:hover:not(:disabled) { background: var(--color-severity-low-bg); } - .btn--outline:hover:not(:disabled) { - background: var(--color-surface-secondary); - border-color: var(--color-border-secondary); - } + .btn--warning { background: var(--color-status-warning-bg); border-color: var(--color-status-warning-border); color: var(--color-status-warning-text); } + .btn--warning:hover:not(:disabled) { background: var(--color-severity-medium-bg); } - .btn--success { - background: var(--color-status-success-bg); - border: 1px solid var(--color-status-success-border); - color: var(--color-status-success-text); - } + .btn--danger { background: transparent; border-color: var(--color-status-error-border); color: var(--color-status-error-text); } + .btn--danger:hover:not(:disabled) { background: var(--color-status-error-bg); } - .btn--success:hover:not(:disabled) { - background: var(--color-severity-low-bg); - } - - .btn--warning { - background: var(--color-status-warning-bg); - border: 1px solid var(--color-status-warning-border); - color: var(--color-status-warning-text); - } - - .btn--warning:hover:not(:disabled) { - background: var(--color-severity-medium-bg); - } - - .btn--danger { - background: transparent; - border: 1px solid var(--color-status-error-border); - color: var(--color-status-error-text); - } - - .btn--danger:hover:not(:disabled) { - background: var(--color-status-error-bg); - } - - /* Skeleton loading */ + /* ── Skeleton loading ── */ @keyframes idp-shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } - .loading-skeleton { - display: grid; - gap: 1.25rem; - } - - .skeleton-kpi-strip { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 1rem; - } - + .loading-skeleton { display: grid; gap: 1.25rem; } + .skeleton-kpi-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.75rem; } .skeleton-kpi { - height: 72px; + height: 76px; border-radius: var(--radius-lg); background: linear-gradient(90deg, var(--color-skeleton-base) 25%, var(--color-skeleton-highlight) 50%, var(--color-skeleton-base) 75%); background-size: 200% 100%; animation: idp-shimmer 1.5s ease-in-out infinite; } - - .skeleton-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); - gap: 1rem; - } - - .skeleton-card { - padding: 1.25rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - display: grid; - gap: 0.65rem; - } - + .skeleton-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 0.75rem; } + .skeleton-card { padding: 1.25rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); display: grid; gap: 0.65rem; } .skeleton-line { height: 0.75rem; border-radius: var(--radius-sm); @@ -365,12 +333,11 @@ import { AddProviderWizardComponent } from './add-provider-wizard.component'; background-size: 200% 100%; animation: idp-shimmer 1.5s ease-in-out infinite; } - .skeleton-line--short { width: 30%; } .skeleton-line--medium { width: 60%; } .skeleton-line--long { width: 90%; } - /* Error banner */ + /* ── Error banner ── */ .error-banner { display: flex; align-items: center; @@ -382,10 +349,10 @@ import { AddProviderWizardComponent } from './add-provider-wizard.component'; border-radius: var(--radius-lg); color: var(--color-status-error-text); font-size: .82rem; - animation: idp-page-enter var(--motion-duration-sm, 140ms) ease; + animation: idp-enter 0.15s ease; } - /* KPI strip */ + /* ── KPI strip ── */ .kpi-strip { display: grid; grid-template-columns: repeat(4, 1fr); @@ -394,107 +361,72 @@ import { AddProviderWizardComponent } from './add-provider-wizard.component'; } .kpi-card { - padding: 0.85rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + padding: 0.875rem 1rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); text-align: center; - transition: border-color var(--motion-duration-sm, 140ms) ease, - box-shadow var(--motion-duration-sm, 140ms) ease; + transition: border-color 0.15s ease, box-shadow 0.15s ease; } - .kpi-card:hover { border-color: var(--color-border-secondary); - box-shadow: var(--shadow-sm); - } - - .kpi-card--success { - border-color: var(--color-status-success-border); - background: var(--color-status-success-bg); - } - - .kpi-card--warning { - border-color: var(--color-status-warning-border); - background: var(--color-status-warning-bg); + box-shadow: 0 1px 4px rgba(0,0,0,.06); } + .kpi-card--success { border-color: var(--color-status-success-border); background: var(--color-status-success-bg); } + .kpi-card--warning { border-color: var(--color-status-warning-border); background: var(--color-status-warning-bg); } .kpi-value { display: block; - font-size: 1.75rem; + font-size: 1.625rem; font-weight: var(--font-weight-bold, 700); line-height: 1.2; } - .kpi-label { - font-size: 0.68rem; + font-size: 0.6875rem; font-weight: var(--font-weight-medium, 500); color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.05em; + margin-top: 0.125rem; } - /* Empty state */ + /* ── Empty state ── */ .empty-state { padding: 3.5rem 2rem; text-align: center; color: var(--color-text-secondary); - border: 1px dashed var(--color-border-primary); + border: 2px dashed var(--color-border-primary); border-radius: var(--radius-lg); background: var(--color-surface-subtle, var(--color-surface-primary)); } + .empty-state__icon { margin-bottom: 1rem; opacity: 0.3; color: var(--color-text-muted); } + .empty-state__text { margin: 0 0 0.25rem; font-size: 1rem; font-weight: var(--font-weight-semibold, 600); color: var(--color-text-heading); } + .empty-state__hint { margin: 0 0 1.25rem; font-size: .82rem; } - .empty-state__icon { - margin-bottom: 1rem; - opacity: 0.35; - color: var(--color-text-muted); - } - - .empty-state__text { - margin: 0 0 0.25rem; - font-size: 1rem; - font-weight: var(--font-weight-semibold, 600); - color: var(--color-text-heading); - } - - .empty-state__hint { - margin: 0 0 1.25rem; - font-size: .82rem; - } - - /* Provider card grid */ + /* ── Provider card grid ── */ .provider-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 0.75rem; } .provider-card { - padding: 1rem 1.1rem; + display: flex; + flex-direction: column; + padding: 1.125rem 1.25rem; background: var(--color-surface-primary); border: 1px solid var(--color-border-primary); border-radius: var(--radius-lg); - transition: transform var(--motion-duration-sm, 140ms) var(--motion-ease-standard, ease), - border-color var(--motion-duration-sm, 140ms) var(--motion-ease-standard, ease), - box-shadow var(--motion-duration-sm, 140ms) var(--motion-ease-standard, ease); + transition: border-color 0.15s ease, box-shadow 0.15s ease; } - .provider-card:hover { - transform: translateY(-2px); - border-color: var(--color-border-emphasis); - box-shadow: var(--shadow-md); - } - - .provider-card:active { - transform: translateY(0); - } - - .provider-card--disabled { - opacity: 0.6; - } - - .provider-card--disabled:hover { - transform: none; + border-color: var(--color-border-secondary); + box-shadow: 0 2px 8px rgba(0,0,0,.06); } + .provider-card--disabled { opacity: 0.55; } .provider-card__header { display: flex; @@ -506,9 +438,9 @@ import { AddProviderWizardComponent } from './add-provider-wizard.component'; .provider-card__type-badge { display: inline-flex; align-items: center; - padding: 0.12rem 0.45rem; + padding: 0.15rem 0.5rem; border-radius: var(--radius-full, 9999px); - font-size: 0.6rem; + font-size: 0.625rem; font-weight: var(--font-weight-semibold, 600); background: var(--color-surface-tertiary); color: var(--color-text-secondary); @@ -518,92 +450,84 @@ import { AddProviderWizardComponent } from './add-provider-wizard.component'; .provider-card__status { display: inline-flex; align-items: center; - padding: 0.12rem 0.45rem; + gap: 0.3rem; + padding: 0.15rem 0.5rem; border-radius: var(--radius-full, 9999px); - font-size: 0.6rem; + font-size: 0.625rem; font-weight: var(--font-weight-semibold, 600); text-transform: uppercase; letter-spacing: 0.04em; } - - .provider-card__status--healthy { - background: var(--color-status-success-bg); - color: var(--color-status-success-text); - } - - .provider-card__status--degraded { - background: var(--color-status-warning-bg); - color: var(--color-status-warning-text); - } - - .provider-card__status--unhealthy, - .provider-card__status--error { - background: var(--color-status-error-bg); - color: var(--color-status-error-text); - } - - .provider-card__status--unknown { - background: var(--color-surface-tertiary); - color: var(--color-text-muted); + .provider-card__status::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + opacity: 0.7; } + .provider-card__status--healthy { background: var(--color-status-success-bg); color: var(--color-status-success-text); } + .provider-card__status--degraded { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); } + .provider-card__status--unhealthy, .provider-card__status--error { background: var(--color-status-error-bg); color: var(--color-status-error-text); } + .provider-card__status--unknown { background: var(--color-surface-tertiary); color: var(--color-text-muted); } .provider-card__name { margin: 0 0 0.2rem; - font-size: 0.92rem; + font-size: 0.9375rem; font-weight: var(--font-weight-semibold, 600); color: var(--color-text-heading); } - .provider-card__description { margin: 0 0 0.6rem; - font-size: 0.78rem; + font-size: 0.8125rem; color: var(--color-text-secondary); line-height: 1.45; } - .provider-card__meta { display: flex; gap: 0.75rem; margin-bottom: 0.6rem; } - .provider-card__meta-item { - font-size: 0.7rem; + font-size: 0.72rem; color: var(--color-text-muted); } - .provider-card__actions { display: flex; flex-wrap: wrap; - gap: 0.35rem; - padding-top: 0.5rem; + gap: 0.375rem; + padding-top: 0.625rem; border-top: 1px solid var(--color-border-primary); + margin-top: auto; } - .provider-card__test-result { - margin-top: 0.6rem; + margin-top: 0.625rem; padding: 0.4rem 0.65rem; border-radius: var(--radius-md); font-size: 0.72rem; - animation: idp-page-enter var(--motion-duration-sm, 140ms) ease; - } - - .provider-card__test-result--success { - background: var(--color-status-success-bg); - color: var(--color-status-success-text); - border: 1px solid var(--color-status-success-border); - } - - .provider-card__test-result--failure { - background: var(--color-status-error-bg); - color: var(--color-status-error-text); - border: 1px solid var(--color-status-error-border); + display: flex; + align-items: center; + gap: 0.375rem; + animation: idp-enter 0.15s ease; } + .provider-card__test-result--success { background: var(--color-status-success-bg); color: var(--color-status-success-text); border: 1px solid var(--color-status-success-border); } + .provider-card__test-result--failure { background: var(--color-status-error-bg); color: var(--color-status-error-text); border: 1px solid var(--color-status-error-border); } + /* ── Responsive ── */ @media (max-width: 768px) { + .page-header { flex-direction: column; align-items: flex-start; gap: 0.75rem; } .kpi-strip, .skeleton-kpi-strip { grid-template-columns: repeat(2, 1fr); } .provider-grid, .skeleton-grid { grid-template-columns: 1fr; } } + + /* ── Reduced motion ── */ + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + } `] }) export class IdentityProvidersSettingsPageComponent { diff --git a/src/Web/StellaOps.Web/src/app/features/settings/integrations/integration-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/integrations/integration-detail-page.component.ts index 79d0f0f9b..a96d7011b 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/integrations/integration-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/integrations/integration-detail-page.component.ts @@ -20,10 +20,20 @@ import { getIntegrationStatusLabel, getIntegrationTypeLabel, } from '../../integration-hub/integration.models'; +import { StellaPageTabsComponent, StellaPageTab } from '../../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +const INTEGRATION_DETAIL_TABS: StellaPageTab[] = [ + { id: 'Overview', label: 'Overview', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' }, + { id: 'Health', label: 'Health', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, + { id: 'Activity', label: 'Activity', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' }, + { id: 'Permissions', label: 'Permissions', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'Secrets', label: 'Secrets', icon: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4' }, + { id: 'Webhooks', label: 'Webhooks', icon: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z' }, +]; @Component({ selector: 'app-integration-detail-page', - imports: [CommonModule, RouterLink], + imports: [CommonModule, RouterLink, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -60,17 +70,12 @@ import { - +
@if (activeTab() === 'Overview') { @@ -115,7 +120,7 @@ import { display: inline-block; margin-bottom: 0.5rem; font-size: 0.875rem; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } @@ -161,33 +166,7 @@ import { font-size: 0.9rem; } - .tabs { - display: flex; - gap: 0.25rem; - border-bottom: 1px solid var(--color-border-primary); - margin-bottom: 1rem; - } - .tab { - padding: 0.75rem 1rem; - background: transparent; - border: none; - border-bottom: 2px solid transparent; - margin-bottom: -1px; - font-size: 0.875rem; - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - cursor: pointer; - } - - .tab:hover { - color: var(--color-text-primary); - } - - .tab--active { - color: var(--color-brand-primary); - border-bottom-color: var(--color-brand-primary); - } .state-card { padding: 1rem; @@ -269,7 +248,7 @@ export class IntegrationDetailPageComponent { readonly loading = signal(true); readonly error = signal(null); readonly activeTab = signal('Overview'); - readonly tabs = ['Overview', 'Health', 'Activity', 'Permissions', 'Secrets', 'Webhooks'] as const; + readonly INTEGRATION_DETAIL_TABS = INTEGRATION_DETAIL_TABS; constructor() { this.route.paramMap diff --git a/src/Web/StellaOps.Web/src/app/features/settings/integrations/integrations-settings-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/integrations/integrations-settings-page.component.ts index c82224b0c..8eb9670c3 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/integrations/integrations-settings-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/integrations/integrations-settings-page.component.ts @@ -249,7 +249,7 @@ interface Integration { } .type-filter__btn--active { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border-color: var(--color-brand-primary); color: white; } diff --git a/src/Web/StellaOps.Web/src/app/features/settings/notifications/notifications-settings-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/notifications/notifications-settings-page.component.ts index c8adbed88..1e40cbdd5 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/notifications/notifications-settings-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/notifications/notifications-settings-page.component.ts @@ -190,7 +190,7 @@ import { Component, ChangeDetectionStrategy } from '@angular/core'; .section-icon--rules { background: var(--color-brand-primary-10, rgba(245, 166, 35, 0.1)); - color: var(--color-brand-primary); + color: var(--color-text-link); } .section-icon--channels { diff --git a/src/Web/StellaOps.Web/src/app/features/settings/system/system-settings-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/system/system-settings-page.component.ts index f9388f966..4df88cfcd 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/system/system-settings-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/system/system-settings-page.component.ts @@ -5,6 +5,7 @@ import { Component, ChangeDetectionStrategy, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; +import { StellaPageTabsComponent, StellaPageTab } from '../../../shared/components/stella-page-tabs/stella-page-tabs.component'; type SystemTab = 'health' | 'doctor' | 'slo' | 'jobs'; @@ -40,7 +41,7 @@ const TABS: ReadonlyArray = [ { id: 'slo', label: 'SLO Monitoring', - icon: '', + icon: 'M18 20V10|||M12 20V4|||M6 20v-6', description: 'View and configure Service Level Objectives for uptime, latency, and error budgets. SLO monitoring tracks compliance windows, burn rates, and alerts when error budgets approach exhaustion.', route: '/ops/operations/health-slo', actionLabel: 'Open SLO Monitoring', @@ -49,7 +50,7 @@ const TABS: ReadonlyArray = [ { id: 'jobs', label: 'Background Jobs', - icon: '', + icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2', description: 'Monitor and manage background job processing, queues, and scheduled tasks. View active workers, retry policies, dead-letter queues, and historical execution statistics across all job types.', route: '/ops/operations/jobs-queues', actionLabel: 'Open Background Jobs', @@ -57,9 +58,15 @@ const TABS: ReadonlyArray = [ }, ]; +const SYSTEM_SETTINGS_TABS: readonly StellaPageTab[] = TABS.map(t => ({ + id: t.id, + label: t.label, + icon: t.icon, +})); + @Component({ selector: 'app-system-settings-page', - imports: [RouterLink], + imports: [RouterLink, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
@@ -68,34 +75,12 @@ const TABS: ReadonlyArray = [

Use the live health and diagnostics workspaces below to validate readiness. This setup route is a handoff, not a health verdict.

- - -
+ @for (tab of tabs; track tab.id) { @if (activeTab() === tab.id) {
@@ -119,7 +104,7 @@ const TABS: ReadonlyArray = [
} } -
+
`, styles: [` @@ -153,63 +138,6 @@ const TABS: ReadonlyArray = [ max-width: 640px; } - /* Tab bar */ - .tabs { - display: flex; - flex-wrap: wrap; - gap: 0; - border-bottom: 1px solid var(--color-border-primary); - margin-bottom: 0; - } - - .tab { - position: relative; - display: inline-flex; - align-items: center; - gap: 0.375rem; - padding: 0.5rem 1rem; - background: transparent; - border: none; - border-bottom: 2px solid transparent; - cursor: pointer; - color: var(--color-text-secondary); - font-size: 0.8125rem; - line-height: 1; - font-weight: var(--font-weight-medium, 500); - transition: color var(--motion-duration-sm, 140ms) ease, - border-color var(--motion-duration-sm, 140ms) ease, - background-color var(--motion-duration-sm, 140ms) ease; - } - - .tab:hover { - color: var(--color-text-primary); - background: var(--color-brand-soft, rgba(245, 166, 35, 0.04)); - } - - .tab.active { - color: var(--color-text-primary); - border-bottom-color: var(--color-brand-primary); - font-weight: 600; - } - - .tab-icon { - flex-shrink: 0; - opacity: 0.7; - } - - .tab.active .tab-icon { - opacity: 1; - } - - /* Tab panel */ - .tab-panel { - padding: 1.5rem; - background: var(--color-surface-primary); - border: 1px solid var(--color-border-primary); - border-top: none; - border-radius: 0 0 var(--radius-lg) var(--radius-lg); - } - .panel-content { animation: sys-panel-enter var(--motion-duration-sm, 140ms) ease; } @@ -269,32 +197,28 @@ const TABS: ReadonlyArray = [ font-weight: 600; cursor: pointer; text-decoration: none; - background: #1e293b; - color: #ffffff; - border: 1px solid #1e293b; + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); + border: 1px solid var(--color-btn-primary-border, transparent); transition: background-color 150ms ease, box-shadow 150ms ease; } .btn-open:hover { - background: #334155; - border-color: #334155; + background: var(--color-btn-primary-bg-hover); + border-color: var(--color-btn-primary-border, transparent); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); } .btn-open:active { - background: #0f172a; + background: var(--color-btn-primary-bg-hover); transform: translateY(1px); } - @media (max-width: 640px) { - .tabs { overflow-x: auto; flex-wrap: nowrap; } - .tab { white-space: nowrap; } - .tab-panel { padding: 1rem; } - } `] }) export class SystemSettingsPageComponent { readonly tabs = TABS; + readonly SYSTEM_SETTINGS_TABS = SYSTEM_SETTINGS_TABS; readonly activeTab = signal('health'); } diff --git a/src/Web/StellaOps.Web/src/app/features/settings/usage/usage-settings-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/usage/usage-settings-page.component.ts index bff0597a4..356dd2690 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/usage/usage-settings-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/usage/usage-settings-page.component.ts @@ -183,7 +183,7 @@ import { RouterLink } from '@angular/router'; .usage-card__icon--scans { background: var(--color-brand-primary-10, rgba(245, 166, 35, 0.1)); - color: var(--color-brand-primary); + color: var(--color-text-link); } .usage-card__icon--storage { @@ -252,7 +252,7 @@ import { RouterLink } from '@angular/router'; } .usage-bar__fill--normal { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); } .usage-bar__fill--high { @@ -299,7 +299,7 @@ import { RouterLink } from '@angular/router'; height: 2rem; border-radius: var(--radius-md); background: var(--color-brand-primary-10, rgba(245, 166, 35, 0.1)); - color: var(--color-brand-primary); + color: var(--color-text-link); flex-shrink: 0; } diff --git a/src/Web/StellaOps.Web/src/app/features/settings/user-preferences/user-preferences-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/user-preferences/user-preferences-page.component.ts index cd13c03e7..efbf6a399 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/user-preferences/user-preferences-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/user-preferences/user-preferences-page.component.ts @@ -16,439 +16,928 @@ type LocaleSaveState = 'idle' | 'saving' | 'saved' | 'syncFailed'; imports: [AiPreferencesComponent, PlainLanguageToggleComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
-

User Preferences

-

Personalize your console experience

- - -
-
- - -
- -

Choose how the console looks. System mode follows your OS preference.

- -
- @for (opt of themeOptions; track opt.value) { - - } -
-
- - -
- -

Changes apply immediately. Your preference is synced across devices when signed in.

- - - - @if (localeStatus(); as msg) { -

{{ msg }}

- } -
- - -
- -

Control the default layout of the console sidebar.

+ Layout +
+ + + AI Assistant + + -
-
- Sidebar collapsed by default - Start with the sidebar minimized to icons only + +
+ +
+
+

Preferences

+

Personalize your StellaOps console experience

- -
-
+ - -
-
- -

AI Assistant

-
-

Configure how AI assistance appears across StellaOps.

+ +
+
+ +
+

Profile

+

Manage your account identity and contact details

+
+
- +
+
+ +
+ + + + IdP managed + +
+
-
- -
-
+
+ +
+ + +
+ @if (emailError()) { +

+ + {{ emailError() }} +

+ } + @if (emailStatus()) { +

+ + {{ emailStatus() }} +

+ } +
+
+
+ + +
+
+
+ +
+
+

Appearance

+

Choose how the console looks. System follows your OS.

+
+
+ +
+
+ @for (opt of themeOptions; track opt.value) { + + } +
+
+
+ + +
+
+
+ +
+
+

Language & Region

+

Changes apply immediately and sync across devices when signed in

+
+
+ +
+
+ +
+ + +
+ @if (localeStatus(); as msg) { +

+ @if (localeSaveState() === 'saving') { + + } @else { + + } + {{ msg }} +

+ } +
+
+
+ + +
+
+
+ +
+
+

Layout

+

Control console layout defaults

+
+
+ +
+
+
+ Collapse sidebar by default + Start with the sidebar minimized to icons only +
+ +
+
+
+ + +
+
+
+ +
+
+

AI Assistant

+

Configure how AI assistance appears across StellaOps

+
+
+ +
+ + +
+ + +
+
+
`, styles: [` - .prefs { - max-width: 720px; - } - - .prefs__title { - margin: 0 0 0.25rem; - font-size: 1.5rem; - font-weight: var(--font-weight-semibold); - } - - .prefs__subtitle { - margin: 0 0 1.75rem; - color: var(--color-text-secondary); - } - - .prefs__card { - display: flex; - flex-direction: column; - gap: 0.75rem; - padding: 1.5rem; - margin-bottom: 1.25rem; - border-radius: var(--radius-lg); - border: 1px solid var(--color-border-primary); - background: var(--color-surface-primary); - } - - .prefs__card-header { - display: flex; - align-items: center; - gap: 0.5rem; - color: var(--color-text-primary); - } - - .prefs__card-header h2 { - margin: 0; - font-size: 1rem; - font-weight: var(--font-weight-semibold); - } - - .prefs__description { - margin: 0; - color: var(--color-text-secondary); - font-size: 0.8125rem; - line-height: 1.5; - } - - /* Theme grid */ - .prefs__theme-grid { + /* ------------------------------------------------------------------ */ + /* Page layout: sidebar nav + scrollable content */ + /* ------------------------------------------------------------------ */ + .prefs-page { display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 0.75rem; - margin-top: 0.25rem; + grid-template-columns: 200px 1fr; + gap: 2rem; + max-width: 920px; + min-height: 0; } - .prefs__theme-option { + /* ------------------------------------------------------------------ */ + /* Section nav */ + /* ------------------------------------------------------------------ */ + .prefs-nav { + position: sticky; + top: 1rem; + align-self: start; display: flex; flex-direction: column; - align-items: center; - gap: 0.5rem; - padding: 1rem 0.75rem; - border: 2px solid var(--color-border-primary); - border-radius: var(--radius-lg); - background: var(--color-surface-secondary); - color: var(--color-text-secondary); - cursor: pointer; - position: relative; - transition: border-color 0.15s, background 0.15s, color 0.15s; + gap: 0.125rem; + padding: 0.5rem 0; } - .prefs__theme-option:hover { - border-color: var(--color-border-secondary); - color: var(--color-text-primary); - } - - .prefs__theme-option:focus-visible { - outline: 2px solid var(--color-brand-primary); - outline-offset: 2px; - } - - .prefs__theme-option--active { - border-color: var(--color-brand-primary); - background: color-mix(in srgb, var(--color-brand-primary) 8%, var(--color-surface-primary)); - color: var(--color-text-primary); - } - - .prefs__theme-icon { + .prefs-nav__link { display: flex; align-items: center; - justify-content: center; - width: 36px; - height: 36px; - } - - .prefs__theme-label { + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: var(--radius-md); font-size: 0.8125rem; font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + text-decoration: none; + transition: color 0.15s, background 0.15s; + cursor: pointer; } - .prefs__theme-hint { - font-size: 0.6875rem; + .prefs-nav__link:hover { + color: var(--color-text-primary); + background: var(--color-surface-secondary); + } + + .prefs-nav__link svg { + flex-shrink: 0; + opacity: 0.6; + } + + .prefs-nav__link:hover svg { + opacity: 1; + } + + /* ------------------------------------------------------------------ */ + /* Content column */ + /* ------------------------------------------------------------------ */ + .prefs-content { + display: flex; + flex-direction: column; + gap: 1.25rem; + min-width: 0; + } + + /* ------------------------------------------------------------------ */ + /* Page header */ + /* ------------------------------------------------------------------ */ + .prefs-header { + display: flex; + align-items: center; + gap: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--color-border-primary); + } + + .prefs-header__title { + margin: 0; + font-size: 1.375rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + letter-spacing: -0.01em; + } + + .prefs-header__subtitle { + margin: 0.125rem 0 0; + font-size: 0.8125rem; color: var(--color-text-tertiary); } - .prefs__check { - position: absolute; - top: 0.5rem; - right: 0.5rem; + /* ------------------------------------------------------------------ */ + /* Cards */ + /* ------------------------------------------------------------------ */ + .prefs-card { + display: flex; + flex-direction: column; + border-radius: var(--radius-lg); + border: 1px solid var(--color-border-primary); + background: var(--color-surface-primary); + overflow: hidden; + scroll-margin-top: 1rem; + } + + .prefs-card__top { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); + } + + .prefs-card__avatar { + width: 40px; + height: 40px; + border-radius: var(--radius-full); + background: linear-gradient(135deg, var(--color-brand-primary), color-mix(in srgb, var(--color-brand-primary) 60%, #6366f1)); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.875rem; + font-weight: var(--font-weight-semibold); + letter-spacing: 0.02em; + flex-shrink: 0; + user-select: none; + } + + .prefs-card__icon-wrap { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + background: var(--color-surface-primary); + border: 1px solid var(--color-border-primary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--color-text-secondary); + } + + .prefs-card__icon-wrap--accent { + background: color-mix(in srgb, var(--color-brand-primary) 10%, var(--color-surface-primary)); + border-color: color-mix(in srgb, var(--color-brand-primary) 25%, var(--color-border-primary)); color: var(--color-brand-primary); } - /* Language */ - .prefs__label { + .prefs-card__heading { + min-width: 0; + } + + .prefs-card__title { + margin: 0; + font-size: 0.9375rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + } + + .prefs-card__desc { + margin: 0.125rem 0 0; + font-size: 0.75rem; + color: var(--color-text-tertiary); + line-height: 1.4; + } + + .prefs-card__body { + padding: 1.25rem 1.5rem; + display: flex; + flex-direction: column; + gap: 1.25rem; + } + + /* ------------------------------------------------------------------ */ + /* Form fields */ + /* ------------------------------------------------------------------ */ + .field { + display: flex; + flex-direction: column; + gap: 0.375rem; + } + + .field__label { font-size: 0.8125rem; font-weight: var(--font-weight-medium); color: var(--color-text-primary); } - .prefs__select { - width: min(320px, 100%); + .field__input-wrap { + display: flex; + align-items: center; + gap: 0.75rem; + } + + .field__input { + flex: 1; + max-width: 340px; 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.875rem; - padding: 0.5rem 0.625rem; + padding: 0.5rem 0.75rem; + transition: border-color 0.15s, box-shadow 0.15s; } - .prefs__select:focus-visible { + .field__input::placeholder { + color: var(--color-text-tertiary); + } + + .field__input:focus-visible { + outline: none; + border-color: var(--color-brand-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand-primary) 15%, transparent); + } + + .field__input--disabled { + opacity: 0.55; + cursor: not-allowed; + } + + .field__input--error { + border-color: var(--color-status-danger-text, #dc3545); + } + .field__input--error:focus-visible { + border-color: var(--color-status-danger-text, #dc3545); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-status-danger-text, #dc3545) 15%, transparent); + } + + .field__badge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.625rem; + border-radius: var(--radius-full); + font-size: 0.6875rem; + font-weight: var(--font-weight-medium); + color: var(--color-text-tertiary); + background: var(--color-surface-secondary); + border: 1px solid var(--color-border-primary); + white-space: nowrap; + } + + .field__row { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .field__select-wrap { + position: relative; + max-width: 340px; + } + + .field__select { + width: 100%; + 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.875rem; + padding: 0.5rem 2rem 0.5rem 0.75rem; + appearance: none; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s; + } + + .field__select:focus-visible { + outline: none; + border-color: var(--color-brand-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand-primary) 15%, transparent); + } + + .field__select:disabled { + opacity: 0.55; + cursor: not-allowed; + } + + .field__select-chevron { + position: absolute; + right: 0.625rem; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: var(--color-text-tertiary); + } + + .field__msg { + display: flex; + align-items: center; + gap: 0.375rem; + margin: 0; + font-size: 0.75rem; + line-height: 1.4; + } + + .field__msg--success { + color: var(--color-status-success-text); + } + + .field__msg--warn { + color: var(--color-status-warning-text); + } + + .field__msg--error { + color: var(--color-status-danger-text, #dc3545); + } + + /* ------------------------------------------------------------------ */ + /* Buttons */ + /* ------------------------------------------------------------------ */ + .btn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + border-radius: var(--radius-md); + font-weight: var(--font-weight-medium); + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, opacity 0.15s, box-shadow 0.15s; + border: none; + } + + .btn--sm { + padding: 0.5rem 1rem; + font-size: 0.8125rem; + } + + .btn--primary { + background: var(--color-btn-primary-bg); + color: #fff; + } + + .btn--primary:hover:not(:disabled) { + background: var(--color-btn-primary-bg-hover); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); + } + + .btn--primary:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + .btn--primary:focus-visible { outline: 2px solid var(--color-brand-primary); outline-offset: 2px; } - .prefs__status { - margin: 0; - font-size: 0.75rem; - color: var(--color-status-success-text); + .btn__spinner { + animation: spin 0.8s linear infinite; } - .prefs__status--warn { - color: var(--color-status-warning-text); + @keyframes spin { + to { transform: rotate(360deg); } } - /* Toggle / Switch */ - .prefs__toggle-row { + /* ------------------------------------------------------------------ */ + /* Theme cards with mini UI preview */ + /* ------------------------------------------------------------------ */ + .theme-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; + } + + .theme-card { + display: flex; + flex-direction: column; + border: 2px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-secondary); + cursor: pointer; + position: relative; + overflow: hidden; + transition: border-color 0.2s, box-shadow 0.2s; + padding: 0; + } + + .theme-card:hover { + border-color: var(--color-border-secondary); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + } + + .theme-card:focus-visible { + outline: 2px solid var(--color-brand-primary); + outline-offset: 2px; + } + + .theme-card--active { + border-color: var(--color-brand-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand-primary) 15%, transparent); + } + + /* Mini UI preview */ + .theme-card__preview { + display: flex; + height: 72px; + padding: 0.5rem; + gap: 0.375rem; + background: #f8fafc; + border-bottom: 1px solid var(--color-border-primary); + } + + .theme-card__preview--dark { + background: #1e293b; + } + + .theme-card__preview--dark .preview-sidebar { + background: #0f172a; + border-color: #334155; + } + + .theme-card__preview--dark .preview-dot { + background: #3b82f6; + } + + .theme-card__preview--dark .preview-line { + background: #334155; + } + + .theme-card__preview--dark .preview-topbar { + background: #334155; + } + + .theme-card__preview--dark .preview-block { + background: #334155; + } + + .theme-card__preview--system { + background: linear-gradient(135deg, #f8fafc 50%, #1e293b 50%); + } + + .preview-sidebar { + width: 24%; + border-radius: 3px; + background: #e2e8f0; + border: 1px solid #cbd5e1; + padding: 0.25rem; + display: flex; + flex-direction: column; + gap: 3px; + } + + .preview-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--color-brand-primary, #3b82f6); + margin-bottom: 2px; + } + + .preview-line { + height: 3px; + border-radius: 1px; + background: #cbd5e1; + } + + .preview-line--short { + width: 60%; + } + + .preview-main { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .preview-topbar { + height: 8px; + border-radius: 2px; + background: #e2e8f0; + } + + .preview-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 3px; + } + + .preview-block { + height: 10px; + border-radius: 2px; + background: #e2e8f0; + } + + .preview-block--wide { + width: 80%; + } + + .theme-card__footer { + display: flex; + align-items: center; + justify-content: center; + gap: 0.375rem; + padding: 0.625rem 0.5rem; + } + + .theme-card__label { + font-size: 0.8125rem; + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); + } + + .theme-card__hint { + font-size: 0.6875rem; + color: var(--color-text-tertiary); + } + + .theme-card__check { + color: var(--color-brand-primary); + } + + /* ------------------------------------------------------------------ */ + /* Toggle switch */ + /* ------------------------------------------------------------------ */ + .toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; } - .prefs__toggle-info { + .toggle-row__info { display: flex; flex-direction: column; gap: 0.125rem; } - .prefs__toggle-label { + .toggle-row__label { font-size: 0.875rem; font-weight: var(--font-weight-medium); color: var(--color-text-primary); } - .prefs__toggle-hint { + .toggle-row__hint { font-size: 0.75rem; color: var(--color-text-tertiary); } - .prefs__switch { - position: relative; - width: 40px; - height: 22px; + .switch { + appearance: none; + background: none; border: none; - border-radius: 11px; - background: var(--color-surface-tertiary); - cursor: pointer; - flex-shrink: 0; - transition: background 0.15s; padding: 0; + cursor: pointer; } - .prefs__switch--on { - background: var(--color-brand-primary); + .switch:focus-visible .switch__track { + outline: 2px solid var(--color-brand-primary); + outline-offset: 2px; } - .prefs__switch-thumb { + .switch__track { + position: relative; + display: block; + width: 44px; + height: 24px; + border-radius: 12px; + background: var(--color-surface-tertiary); + transition: background 0.2s; + } + + .switch--on .switch__track { + background: var(--color-btn-primary-bg); + } + + .switch__thumb { position: absolute; top: 2px; left: 2px; - width: 18px; - height: 18px; + width: 20px; + height: 20px; border-radius: 50%; - background: white; - transition: transform 0.15s; + background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); } - .prefs__switch--on .prefs__switch-thumb { - transform: translateX(18px); + .switch--on .switch__thumb { + transform: translateX(20px); } - .prefs__switch:focus-visible { - outline: 2px solid var(--color-brand-primary); - outline-offset: 2px; + /* ------------------------------------------------------------------ */ + /* AI section divider */ + /* ------------------------------------------------------------------ */ + .ai-divider { + height: 1px; + background: var(--color-border-primary); } - /* Profile inputs */ - .prefs__input { - width: min(320px, 100%); - 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.875rem; - padding: 0.5rem 0.625rem; - } - .prefs__input:disabled { - opacity: 0.6; - cursor: not-allowed; - } - .prefs__input:focus-visible { - outline: 2px solid var(--color-brand-primary); - outline-offset: 2px; - } - .prefs__input-row { - display: flex; - gap: 0.5rem; - align-items: center; - } - .prefs__save-btn { - padding: 0.5rem 1rem; - border: 1px solid var(--color-brand-primary); - border-radius: var(--radius-md); - background: var(--color-brand-primary); - color: white; - font-size: 0.8125rem; - cursor: pointer; - white-space: nowrap; - } - .prefs__save-btn:disabled { - opacity: 0.5; - cursor: not-allowed; - } - .prefs__save-btn:hover:not(:disabled) { - background: var(--color-brand-primary-hover); - } - .prefs__hint { - margin: 0; - font-size: 0.75rem; - color: var(--color-text-tertiary); - } - - /* AI section */ - .prefs__ai-plain-language { - padding-top: 0.5rem; - border-top: 1px solid var(--color-border-primary); - } - - @media (max-width: 480px) { - .prefs__theme-grid { + /* ------------------------------------------------------------------ */ + /* Responsive */ + /* ------------------------------------------------------------------ */ + @media (max-width: 720px) { + .prefs-page { grid-template-columns: 1fr; + gap: 0; + } + + .prefs-nav { + position: static; + flex-direction: row; + overflow-x: auto; + gap: 0; + padding: 0 0 0.75rem; + border-bottom: 1px solid var(--color-border-primary); + margin-bottom: 1rem; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .prefs-nav::-webkit-scrollbar { + display: none; + } + + .prefs-nav__link { + white-space: nowrap; + padding: 0.375rem 0.625rem; + font-size: 0.75rem; + } + + .prefs-nav__link svg { + display: none; + } + + .theme-grid { + grid-template-columns: 1fr; + } + + .field__input { + max-width: 100%; + } + + .field__select-wrap { + max-width: 100%; + } + + .field__row { + flex-direction: column; + align-items: stretch; + } + + .field__row .btn { + align-self: flex-start; + } + } + + /* ------------------------------------------------------------------ */ + /* Reduced motion */ + /* ------------------------------------------------------------------ */ + @media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; } } `], @@ -463,10 +952,25 @@ export class UserPreferencesPageComponent implements OnInit { private readonly authService = inject(AUTH_SERVICE) as AuthService; readonly userName = computed(() => this.authService.user()?.name ?? 'User'); + readonly userInitials = computed(() => { + const name = this.userName(); + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + return name.slice(0, 2).toUpperCase(); + }); + private static readonly EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + private static readonly LOCAL_STORAGE_EMAIL_KEY = 'stellaops_user_email'; + readonly emailValue = signal(''); readonly emailDirty = signal(false); readonly emailSaving = signal(false); readonly emailStatus = signal(null); + readonly emailError = signal(null); + readonly emailValid = computed(() => { + const val = this.emailValue().trim(); + if (!val) return true; // empty is allowed (not required) + return UserPreferencesPageComponent.EMAIL_PATTERN.test(val); + }); readonly currentLocale = this.i18n.locale; readonly sidebarCollapsed = this.sidebarPrefs.sidebarCollapsed; @@ -514,7 +1018,13 @@ export class UserPreferencesPageComponent implements OnInit { const email = this.authService.user()?.email?.trim() ?? ''; const lower = email.toLowerCase(); if (lower.includes('@unknown.local') || !email.includes('@')) { - this.emailValue.set(''); + // Try loading from localStorage fallback + try { + const stored = localStorage.getItem(UserPreferencesPageComponent.LOCAL_STORAGE_EMAIL_KEY); + this.emailValue.set(stored ?? ''); + } catch { + this.emailValue.set(''); + } } else { this.emailValue.set(email); } @@ -525,25 +1035,43 @@ export class UserPreferencesPageComponent implements OnInit { this.emailValue.set(val); this.emailDirty.set(true); this.emailStatus.set(null); + if (val.trim() && !UserPreferencesPageComponent.EMAIL_PATTERN.test(val.trim())) { + this.emailError.set('Please enter a valid email address'); + } else { + this.emailError.set(null); + } } async saveEmail(): Promise { + const trimmed = this.emailValue().trim(); + if (trimmed && !UserPreferencesPageComponent.EMAIL_PATTERN.test(trimmed)) { + this.emailError.set('Please enter a valid email address'); + return; + } this.emailSaving.set(true); this.emailStatus.set(null); + this.emailError.set(null); try { const resp = await fetch('/api/v1/platform/preferences/email', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: this.emailValue() }), + body: JSON.stringify({ email: trimmed }), }); if (resp.ok) { this.emailStatus.set('Email updated successfully.'); this.emailDirty.set(false); + try { localStorage.setItem(UserPreferencesPageComponent.LOCAL_STORAGE_EMAIL_KEY, trimmed); } catch {} } else { - this.emailStatus.set('Failed to update email. The endpoint may not be available yet.'); + // Server endpoint not available -- fall back to localStorage + try { localStorage.setItem(UserPreferencesPageComponent.LOCAL_STORAGE_EMAIL_KEY, trimmed); } catch {} + this.emailStatus.set('Preferences saved locally (server sync pending)'); + this.emailDirty.set(false); } } catch { - this.emailStatus.set('Failed to update email. The endpoint may not be available yet.'); + // Network error -- fall back to localStorage + try { localStorage.setItem(UserPreferencesPageComponent.LOCAL_STORAGE_EMAIL_KEY, trimmed); } catch {} + this.emailStatus.set('Preferences saved locally (server sync pending)'); + this.emailDirty.set(false); } finally { this.emailSaving.set(false); } @@ -578,6 +1106,10 @@ export class UserPreferencesPageComponent implements OnInit { // Will be wired to a backend persistence endpoint } + scrollTo(section: string): void { + document.getElementById(`section-${section}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + localeDisplayName(locale: string): string { const key = `ui.locale.${locale.toLowerCase().replaceAll('-', '_')}`; return this.i18n.tryT(key) ?? locale; diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/config-missing.component.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/config-missing.component.ts index a82308f9c..c64d734b2 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/config-missing.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/config-missing.component.ts @@ -221,7 +221,7 @@ type TestingStep = 'fetch' | 'probe' | 'setup-check'; } .config-missing__logo { - color: var(--color-brand-primary); + color: var(--color-text-link); margin-bottom: var(--space-4); animation: logo-entrance var(--motion-duration-lg, 300ms) var(--motion-ease-bounce, ease-out) both; } @@ -507,7 +507,7 @@ type TestingStep = 'fetch' | 'probe' | 'setup-check'; } &--primary { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-surface-primary); box-shadow: var(--shadow-brand-sm); diff --git a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.ts b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.ts index 9fe52a2ec..5399f4c4c 100644 --- a/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/setup-wizard/components/setup-wizard.component.ts @@ -957,7 +957,7 @@ import { DoctorRecheckService } from '../../doctor/services/doctor-recheck.servi } .wiz__err-body { flex: 1; font-size: var(--font-size-base); font-weight: var(--font-weight-semibold); - color: var(--color-brand-secondary); + color: var(--color-text-link); display: flex; flex-direction: column; gap: 6px; } .wiz__err-msg { font-size: var(--font-size-base); } @@ -987,7 +987,7 @@ import { DoctorRecheckService } from '../../doctor/services/doctor-recheck.servi } .wiz__err-x { appearance: none; background: none; border: none; padding: 4px; - cursor: pointer; color: var(--color-brand-secondary); + cursor: pointer; color: var(--color-text-link); opacity: .5; border-radius: var(--radius-md); transition: all 200ms var(--so-ease-in-out); } diff --git a/src/Web/StellaOps.Web/src/app/features/signals/signals-runtime-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/signals/signals-runtime-dashboard.component.ts index a09ead005..a37545f60 100644 --- a/src/Web/StellaOps.Web/src/app/features/signals/signals-runtime-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/signals/signals-runtime-dashboard.component.ts @@ -85,7 +85,7 @@ import { MetricCardComponent } from '../../shared/ui/metric-card/metric-card.com @if (dashboard.hostProbes.length === 0) {

No probe telemetry available in the current signal window.

} @else { -
Metric
+
@@ -243,29 +243,11 @@ import { MetricCardComponent } from '../../shared/ui/metric-card/metric-card.com color: var(--color-text-secondary); } + /* Table styling provided by global .stella-table class */ table { - border-collapse: collapse; - width: 100%; min-width: 720px; } - th, - td { - text-align: left; - border-top: 1px solid var(--color-surface-secondary); - padding: 0.6rem 0.35rem; - font-size: 0.88rem; - } - - th { - border-top: 0; - color: var(--color-text-secondary); - font-weight: var(--font-weight-semibold); - font-size: 0.8rem; - text-transform: uppercase; - letter-spacing: 0.04em; - } - .badge { display: inline-flex; border-radius: var(--radius-full); diff --git a/src/Web/StellaOps.Web/src/app/features/snapshot/components/merge-preview/merge-preview.component.scss b/src/Web/StellaOps.Web/src/app/features/snapshot/components/merge-preview/merge-preview.component.scss index eb5cdbf8b..6489d40b8 100644 --- a/src/Web/StellaOps.Web/src/app/features/snapshot/components/merge-preview/merge-preview.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/snapshot/components/merge-preview/merge-preview.component.scss @@ -82,7 +82,7 @@ &.status-Fixed { background: var(--color-brand-light); - color: var(--color-brand-primary); + color: var(--color-text-link); } &.status-UnderInvestigation { @@ -215,7 +215,7 @@ border-left: 3px solid var(--color-brand-primary); strong { - color: var(--color-brand-primary); + color: var(--color-text-link); } } } diff --git a/src/Web/StellaOps.Web/src/app/features/snapshot/components/snapshot-panel/snapshot-panel.component.scss b/src/Web/StellaOps.Web/src/app/features/snapshot/components/snapshot-panel/snapshot-panel.component.scss index 391771e68..9a6f44ee4 100644 --- a/src/Web/StellaOps.Web/src/app/features/snapshot/components/snapshot-panel/snapshot-panel.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/snapshot/components/snapshot-panel/snapshot-panel.component.scss @@ -74,7 +74,7 @@ .count { font-size: var(--font-size-2xl); font-weight: var(--font-weight-bold); - color: var(--color-brand-primary); + color: var(--color-text-link); } } diff --git a/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.html b/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.html index c2a87f6f4..feb397b21 100644 --- a/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.html +++ b/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.html @@ -1,294 +1,291 @@ -
-
-

Sources Dashboard

-

Attestation of Conformance (AOC) Metrics

-
- - @if (loading()) { -
-
-

Loading dashboard...

-
- } - - @if (!loading() && dashboard()) { - -
- -
-
-

AOC Pass Rate

- Last 24h -
-
-
- {{ passRate() }}% - - -
-
-
- Passed - {{ formatNumber(dashboard()!.passFail.passed) }} -
-
- Failed - {{ formatNumber(dashboard()!.passFail.failed) }} -
-
- Pending - {{ formatNumber(dashboard()!.passFail.pending) }} -
-
- -
- @for (point of chartData(); track point.timestamp) { -
- } -
-
-
- - -
-
-

Recent Violations

- @if (criticalViolations() > 0) { - {{ criticalViolations() }} critical - } -
-
-
    - @for (violation of dashboard()!.recentViolations; track trackByCode($index, violation)) { -
  • - - {{ violation.severity | uppercase }} - - {{ violation.code }} - {{ violation.name }} - {{ violation.count }} -
  • - } @empty { -
  • No recent violations
  • - } -
-
-
- - -
-
-

Ingest Throughput

- Last 24h -
-
-
-
- {{ formatNumber(totalThroughput().docs) }} - Documents -
-
- {{ formatBytes(totalThroughput().bytes) }} - Total Size -
-
-
Host
- - - - - - - - - @for (tenant of dashboard()!.throughputByTenant; track trackByTenantId($index, tenant)) { - - - - - - } - -
TenantDocsRate
{{ tenant.tenantName }}{{ formatNumber(tenant.documentsIngested) }}{{ tenant.documentsPerMinute.toFixed(1) }}/min
-
- -
- - -
-
-

Sources

- -
- - - @if (verificationRequest()) { -
-
- - @if (verificationRequest()!.status === 'completed') { - Verification Complete - } @else if (verificationRequest()!.status === 'running') { - Verification Running... - } @else { - Verification {{ verificationRequest()!.status | titlecase }} - } - - @if (verificationRequest()!.completedAt) { - {{ formatDate(verificationRequest()!.completedAt!) }} - } -
- @if (verificationRequest()!.status === 'completed') { -
-
- {{ verificationRequest()!.passed }} - Passed -
-
- {{ verificationRequest()!.failed }} - Failed -
-
- {{ verificationRequest()!.documentsVerified }} - Total -
-
- } - @if (verificationRequest()!.cliCommand) { -
- CLI Equivalent: - {{ verificationRequest()!.cliCommand }} -
- } -
- } - -
- @for (source of dashboard()!.sources; track trackBySourceId($index, source)) { -
-
- - @switch (source.type) { - @case ('registry') { } - @case ('pipeline') { } - @case ('repository') { } - @case ('manual') { } - } - -
-

{{ source.name }}

- {{ source.type | titlecase }} -
- {{ source.status | titlecase }} -
-
-
- {{ source.checkCount }} - Checks -
-
- {{ (source.passRate * 100).toFixed(1) }}% - Pass Rate -
-
- @if (source.recentViolations.length > 0) { -
- Recent: - @for (v of source.recentViolations; track v.code) { - - {{ v.code }} - - } -
- } -
- Last check: {{ formatDate(source.lastCheck) }} -
-
- } -
-
- - - @if (selectedViolation()) { - - } - } - +
+
+

Sources Dashboard

+

Attestation of Conformance (AOC) Metrics

+
+ + @if (loading()) { + + } + + @if (!loading() && dashboard()) { + +
+ +
+
+

AOC Pass Rate

+ Last 24h +
+
+
+ {{ passRate() }}% + + +
+
+
+ Passed + {{ formatNumber(dashboard()!.passFail.passed) }} +
+
+ Failed + {{ formatNumber(dashboard()!.passFail.failed) }} +
+
+ Pending + {{ formatNumber(dashboard()!.passFail.pending) }} +
+
+ +
+ @for (point of chartData(); track point.timestamp) { +
+ } +
+
+
+ + +
+
+

Recent Violations

+ @if (criticalViolations() > 0) { + {{ criticalViolations() }} critical + } +
+
+
    + @for (violation of dashboard()!.recentViolations; track trackByCode($index, violation)) { +
  • + + {{ violation.severity | uppercase }} + + {{ violation.code }} + {{ violation.name }} + {{ violation.count }} +
  • + } @empty { +
  • No recent violations
  • + } +
+
+
+ + +
+
+

Ingest Throughput

+ Last 24h +
+
+
+
+ {{ formatNumber(totalThroughput().docs) }} + Documents +
+
+ {{ formatBytes(totalThroughput().bytes) }} + Total Size +
+
+ + + + + + + + + + @for (tenant of dashboard()!.throughputByTenant; track trackByTenantId($index, tenant)) { + + + + + + } + +
TenantDocsRate
{{ tenant.tenantName }}{{ formatNumber(tenant.documentsIngested) }}{{ tenant.documentsPerMinute.toFixed(1) }}/min
+
+
+
+ + +
+
+

Sources

+ +
+ + + @if (verificationRequest()) { +
+
+ + @if (verificationRequest()!.status === 'completed') { + Verification Complete + } @else if (verificationRequest()!.status === 'running') { + Verification Running... + } @else { + Verification {{ verificationRequest()!.status | titlecase }} + } + + @if (verificationRequest()!.completedAt) { + {{ formatDate(verificationRequest()!.completedAt!) }} + } +
+ @if (verificationRequest()!.status === 'completed') { +
+
+ {{ verificationRequest()!.passed }} + Passed +
+
+ {{ verificationRequest()!.failed }} + Failed +
+
+ {{ verificationRequest()!.documentsVerified }} + Total +
+
+ } + @if (verificationRequest()!.cliCommand) { +
+ CLI Equivalent: + {{ verificationRequest()!.cliCommand }} +
+ } +
+ } + +
+ @for (source of dashboard()!.sources; track trackBySourceId($index, source)) { +
+
+ + @switch (source.type) { + @case ('registry') { } + @case ('pipeline') { } + @case ('repository') { } + @case ('manual') { } + } + +
+

{{ source.name }}

+ {{ source.type | titlecase }} +
+ {{ source.status | titlecase }} +
+
+
+ {{ source.checkCount }} + Checks +
+
+ {{ (source.passRate * 100).toFixed(1) }}% + Pass Rate +
+
+ @if (source.recentViolations.length > 0) { +
+ Recent: + @for (v of source.recentViolations; track v.code) { + + {{ v.code }} + + } +
+ } +
+ Last check: {{ formatDate(source.lastCheck) }} +
+
+ } +
+
+ + + @if (selectedViolation()) { + + } + } +
diff --git a/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.scss b/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.scss index 95c011fc5..83da31070 100644 --- a/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.scss @@ -302,7 +302,7 @@ .throughput-value { font-size: 1.75rem; font-weight: var(--font-weight-bold); - color: var(--color-brand-primary); + color: var(--color-text-link); } .throughput-label { @@ -694,7 +694,7 @@ .docs-link { display: inline-block; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: var(--font-size-base); text-decoration: none; diff --git a/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.ts index e42195ca2..d9965306b 100644 --- a/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/sources/aoc-dashboard.component.ts @@ -17,11 +17,12 @@ import { VerificationRequest, } from '../../core/api/aoc.models'; import { AOC_API } from '../../core/api/aoc.client'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; @Component({ selector: 'app-aoc-dashboard', standalone: true, - imports: [CommonModule, RouterModule], + imports: [CommonModule, RouterModule, LoadingStateComponent], templateUrl: './aoc-dashboard.component.html', styleUrls: ['./aoc-dashboard.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/Web/StellaOps.Web/src/app/features/system-health/system-health-page.component.ts b/src/Web/StellaOps.Web/src/app/features/system-health/system-health-page.component.ts index fca9fa0d8..7f4e02541 100644 --- a/src/Web/StellaOps.Web/src/app/features/system-health/system-health-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/system-health/system-health-page.component.ts @@ -14,6 +14,30 @@ import { CheckResultComponent } from '../doctor/components/check-result/check-re import { INCIDENT_SEVERITY_COLORS, } from '../../core/api/platform-health.models'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +const SYSTEM_HEALTH_TABS: readonly StellaPageTab[] = [ + { + id: 'overview', + label: 'Overview', + icon: 'M22 12h-4l-3 9L9 3l-3 9H2', + }, + { + id: 'services', + label: 'Services', + icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0', + }, + { + id: 'diagnostics', + label: 'Diagnostics', + icon: 'M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z', + }, + { + id: 'incidents', + label: 'Incidents', + icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01', + }, +]; @Component({ selector: 'app-system-health-page', @@ -26,6 +50,7 @@ import { DoctorChecksInlineComponent, SummaryStripComponent, CheckResultComponent, + StellaPageTabsComponent, ], template: `
@@ -48,22 +73,17 @@ import {
- - - @if (error()) {
{{ error() }}
} - + + @switch (activeTab()) { @case ('overview') {
@@ -155,6 +175,7 @@ import {
} } +
`, styles: [` @@ -175,28 +196,6 @@ import { border-radius: var(--radius-full); } - .system-health__tabs { - display: flex; - gap: 0; - border-bottom: 1px solid var(--color-border-primary); - } - .tab { - padding: .5rem 1rem; - font-size: .82rem; - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - background: none; - border: none; - border-bottom: 2px solid transparent; - cursor: pointer; - font-family: inherit; - } - .tab:hover { color: var(--color-text-primary); } - .tab--active { - color: var(--color-brand-primary); - border-bottom-color: var(--color-brand-primary); - } - .tab-content { display: grid; gap: .75rem; } .error-banner { padding: .65rem; @@ -303,13 +302,7 @@ export class SystemHealthPageComponent implements OnInit, OnDestroy { readonly activeTab = signal<'overview' | 'services' | 'diagnostics' | 'incidents'>('overview'); readonly INCIDENT_SEVERITY_COLORS = INCIDENT_SEVERITY_COLORS; - - readonly tabs = [ - { id: 'overview' as const, label: 'Overview' }, - { id: 'services' as const, label: 'Services' }, - { id: 'diagnostics' as const, label: 'Diagnostics' }, - { id: 'incidents' as const, label: 'Incidents' }, - ]; + readonly SYSTEM_HEALTH_TABS = SYSTEM_HEALTH_TABS; ngOnInit(): void { interval(10000) diff --git a/src/Web/StellaOps.Web/src/app/features/timeline/pages/timeline-page/timeline-page.component.html b/src/Web/StellaOps.Web/src/app/features/timeline/pages/timeline-page/timeline-page.component.html index c1b5e51ec..83d5f975d 100644 --- a/src/Web/StellaOps.Web/src/app/features/timeline/pages/timeline-page/timeline-page.component.html +++ b/src/Web/StellaOps.Web/src/app/features/timeline/pages/timeline-page/timeline-page.component.html @@ -27,10 +27,7 @@ @if (loading()) { -
- -

{{ 'ui.timeline.loading' | translate }}

-
+ } diff --git a/src/Web/StellaOps.Web/src/app/features/timeline/pages/timeline-page/timeline-page.component.scss b/src/Web/StellaOps.Web/src/app/features/timeline/pages/timeline-page/timeline-page.component.scss index 631f117f4..691ee5c8e 100644 --- a/src/Web/StellaOps.Web/src/app/features/timeline/pages/timeline-page/timeline-page.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/timeline/pages/timeline-page/timeline-page.component.scss @@ -40,7 +40,7 @@ font-size: 28px; width: 28px; height: 28px; - color: var(--color-brand-primary); + color: var(--color-text-link); } } diff --git a/src/Web/StellaOps.Web/src/app/features/timeline/pages/timeline-page/timeline-page.component.ts b/src/Web/StellaOps.Web/src/app/features/timeline/pages/timeline-page/timeline-page.component.ts index 3705773ce..c5f136fa9 100644 --- a/src/Web/StellaOps.Web/src/app/features/timeline/pages/timeline-page/timeline-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/timeline/pages/timeline-page/timeline-page.component.ts @@ -22,6 +22,7 @@ import { EventDetailPanelComponent } from '../../components/event-detail-panel/e import { TimelineFilterComponent } from '../../components/timeline-filter/timeline-filter.component'; import { ExportButtonComponent } from '../../components/export-button/export-button.component'; import { TranslatePipe } from '../../../../core/i18n'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; /** * Timeline page component. @@ -38,7 +39,8 @@ import { TranslatePipe } from '../../../../core/i18n'; EventDetailPanelComponent, TimelineFilterComponent, ExportButtonComponent, - TranslatePipe + TranslatePipe, + LoadingStateComponent ], templateUrl: './timeline-page.component.html', styleUrls: ['./timeline-page.component.scss'], diff --git a/src/Web/StellaOps.Web/src/app/features/topology/environment-posture-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/environment-posture-page.component.ts index ba98c9506..0edbfe2e1 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/environment-posture-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/environment-posture-page.component.ts @@ -1,9 +1,12 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; -import { ActivatedRoute, RouterLink } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { combineLatest, forkJoin, of } from 'rxjs'; import { catchError, map, take } from 'rxjs/operators'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; +import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component'; +import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component'; import { PlatformContextStore } from '../../core/context/platform-context.store'; import { summarizeEnvironmentScope } from './environment-scope-summary'; @@ -44,40 +47,44 @@ interface EvidenceCapsuleRow { @Component({ selector: 'app-environment-posture-page', standalone: true, - imports: [RouterLink], + imports: [LoadingStateComponent, StellaMetricCardComponent, StellaMetricGridComponent], template: `

Release Health

-

{{ environmentLabel() }} · {{ regionLabel() }}

+

{{ environmentLabel() }} · {{ regionLabel() }}

@if (error()) { - + } @if (loading()) { - + } @else { -
- - - - - -
+ + + + +

Top Blockers

@@ -85,7 +92,7 @@ interface EvidenceCapsuleRow { @for (blocker of blockers(); track blocker) {
  • {{ blocker }}
  • } @empty { -
  • No active blockers for this environment.
  • +
  • No active blockers for this environment.
  • }
    @@ -93,11 +100,34 @@ interface EvidenceCapsuleRow {
    `, styles: [` - .posture{display:grid;gap:.6rem}.posture header h1{margin:0}.posture header p{margin:.2rem 0 0;color:var(--color-text-secondary);font-size:.8rem} - .banner,.cards article,.blockers{border:1px solid var(--color-border-primary);border-radius:var(--radius-md);background:var(--color-surface-primary)} - .banner{padding:.7rem;font-size:.8rem;color:var(--color-text-secondary)}.banner.error{color:var(--color-status-error-text)} - .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:.45rem}.cards article{padding:.6rem}.cards h3{margin:0 0 .25rem;font-size:.86rem}.cards p{margin:0 0 .45rem;font-size:.74rem;color:var(--color-text-secondary)}.cards a{font-size:.74rem;color:var(--color-brand-primary);text-decoration:none} - .blockers{padding:.6rem}.blockers h3{margin:0 0 .25rem;font-size:.86rem}.blockers ul{margin:.25rem 0 0;padding-left:1rem}.blockers li{font-size:.74rem;color:var(--color-text-secondary)} + .posture { display: grid; gap: 0.75rem; } + .posture header h1 { margin: 0; font-size: 1.35rem; color: var(--color-text-heading, var(--color-text-primary)); } + .posture__subtitle { margin: 0.2rem 0 0; color: var(--color-text-secondary); font-size: 0.84rem; } + + .banner { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + background: var(--color-surface-primary); + padding: 0.75rem 1rem; + font-size: 0.84rem; + color: var(--color-text-secondary); + } + .banner--error { color: var(--color-status-error-text); } + + .blockers { + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); + padding: 0.85rem; + } + .blockers h3 { margin: 0 0 0.35rem; font-size: 0.9rem; color: var(--color-text-heading, var(--color-text-primary)); } + .blockers ul { margin: 0.25rem 0 0; padding-left: 1.2rem; } + .blockers li { + font-size: 0.8rem; + color: var(--color-text-secondary); + line-height: 1.55; + } + .blockers__none { color: var(--color-text-muted); } `], changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/src/Web/StellaOps.Web/src/app/features/topology/pending-deletions-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/pending-deletions-panel.component.ts index 6881a589b..bf80f4a7a 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/pending-deletions-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/pending-deletions-panel.component.ts @@ -15,6 +15,7 @@ import { computed, } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; import { TopologySetupClient, PendingDeletion } from '../../core/api/topology-setup.client'; import { catchError, of } from 'rxjs'; @@ -29,7 +30,7 @@ interface DeletionRow { @Component({ selector: 'app-pending-deletions-panel', standalone: true, - imports: [CommonModule], + imports: [CommonModule, LoadingStateComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
    @@ -42,7 +43,7 @@ interface DeletionRow {
    @if (loading() && rows().length === 0) { -
    Loading pending deletions...
    + } @if (!loading() && rows().length === 0) { diff --git a/src/Web/StellaOps.Web/src/app/features/topology/readiness-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/readiness-dashboard.component.ts index 7348886ad..3e7f46d44 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/readiness-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/readiness-dashboard.component.ts @@ -1,5 +1,6 @@ import { Component, OnInit, signal, computed, inject, ChangeDetectionStrategy } from '@angular/core'; import { CommonModule } from '@angular/common'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; import { HttpClient } from '@angular/common/http'; import { catchError, of, forkJoin, interval } from 'rxjs'; @@ -37,7 +38,7 @@ interface Target { @Component({ selector: 'app-readiness-dashboard', standalone: true, - imports: [CommonModule], + imports: [CommonModule, LoadingStateComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
    @@ -50,7 +51,7 @@ interface Target {
    @if (loading() && reports().length === 0) { -
    Loading readiness data...
    + } @if (!loading() && reports().length === 0) { diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-agents-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-agents-page.component.ts index 56ea1f593..c7d395d89 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-agents-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-agents-page.component.ts @@ -78,7 +78,7 @@ interface AgentGroupRow { @if (viewMode() === 'groups') {

    Agent Groups

    - +
    @@ -111,7 +111,7 @@ interface AgentGroupRow { } @else {

    All Agents

    -
    Group
    +
    @@ -353,37 +353,13 @@ interface AgentGroupRow { border-radius: var(--radius-sm); } - table { - width: 100%; - border-collapse: collapse; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - overflow: hidden; - background: var(--color-surface-secondary); - } - - th, td { - text-align: left; - font-size: 0.74rem; - padding: 0.34rem 0.5rem; - border-bottom: 1px solid var(--color-border-primary); - vertical-align: middle; - } - + /* Table styling provided by global .stella-table class */ th { - text-transform: uppercase; - color: var(--color-text-muted); - font-size: 0.66rem; - letter-spacing: 0.04em; - font-weight: 600; - background: var(--color-surface-primary); position: sticky; top: 0; z-index: 1; } - tr:last-child td { border-bottom: none; } - tbody tr { cursor: pointer; transition: background 120ms ease; @@ -447,7 +423,7 @@ interface AgentGroupRow { display: inline-flex; align-items: center; gap: 0.2rem; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.73rem; font-weight: 500; text-decoration: none; @@ -458,7 +434,7 @@ interface AgentGroupRow { .actions a:hover { background: var(--color-brand-soft); - color: var(--color-brand-primary-hover); + color: var(--color-text-link-hover); } /* --- Empty states --- */ diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts index 067ccc758..d48919cba 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-environment-detail-page.component.ts @@ -1,8 +1,13 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; -import { ActivatedRoute, RouterLink } from '@angular/router'; +import { ActivatedRoute } from '@angular/router'; import { catchError, forkJoin, of, take } from 'rxjs'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; +import { + StellaQuickLinksComponent, + type StellaQuickLink, +} from '../../shared/components/stella-quick-links/stella-quick-links.component'; import { PlatformContextStore } from '../../core/context/platform-context.store'; import { TopologyDataService } from './topology-data.service'; import { summarizeEnvironmentScope } from './environment-scope-summary'; @@ -15,13 +20,24 @@ import { TopologyEnvironment, TopologyTarget, } from './topology.models'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'security' | 'evidence' | 'data-quality'; +const ENV_DETAIL_TABS: StellaPageTab[] = [ + { id: 'overview', label: 'Overview', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' }, + { id: 'targets', label: 'Targets', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01' }, + { id: 'deployments', label: 'Runs', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, + { id: 'agents', label: 'Agents', icon: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|||M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|||M20 8v6|||M23 11h-6' }, + { id: 'security', label: 'Security', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'evidence', label: 'Evidence', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'data-quality', label: 'Data Quality', icon: 'M18 20V10|||M12 20V4|||M6 20v-6' }, +]; + @Component({ selector: 'app-topology-environment-detail-page', standalone: true, - imports: [RouterLink], + imports: [LoadingStateComponent, StellaQuickLinksComponent, StellaPageTabsComponent], template: `
    @@ -36,18 +52,19 @@ type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'secur
    - + @if (error()) { } @if (loading()) { - + } @else { @switch (activeTab()) { @case ('overview') { @@ -59,13 +76,8 @@ type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'secur

    Capsules stale {{ staleCapsules() }}

    @@ -84,7 +96,7 @@ type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'secur @case ('targets') {

    Targets

    -
    Agent
    +
    @@ -116,7 +128,7 @@ type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'secur @case ('deployments') {

    Runs

    -
    Target
    +
    @@ -144,7 +156,7 @@ type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'secur @case ('agents') {

    Agents

    -
    Release
    +
    @@ -174,7 +186,7 @@ type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'secur @case ('security') {

    Security

    -
    Agent
    +
    @@ -200,7 +212,7 @@ type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'secur @case ('evidence') {

    Evidence

    -
    CVE
    +
    @@ -283,32 +295,7 @@ type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'secur white-space: nowrap; } - .tabs { - display: flex; - flex-wrap: wrap; - gap: 0.35rem; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - padding: 0.45rem; - } - .tabs button { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - background: var(--color-surface-secondary); - color: var(--color-text-primary); - font-size: 0.74rem; - padding: 0.25rem 0.45rem; - cursor: pointer; - } - - .tabs button.active { - border-color: var(--color-tab-active-border, var(--color-brand-primary)); - background: var(--color-brand-primary-10); - color: var(--color-tab-active-text, var(--color-text-primary)); - font-weight: 600; - } .banner { border: 1px solid var(--color-border-primary); @@ -359,39 +346,12 @@ type EnvironmentTab = 'overview' | 'targets' | 'deployments' | 'agents' | 'secur } .actions a { - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.74rem; text-decoration: none; } - table { - width: 100%; - border-collapse: collapse; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - overflow: hidden; - background: var(--color-surface-secondary); - } - - th, - td { - text-align: left; - font-size: 0.74rem; - padding: 0.36rem 0.42rem; - border-bottom: 1px solid var(--color-border-primary); - vertical-align: middle; - } - - th { - text-transform: uppercase; - font-size: 0.67rem; - letter-spacing: 0.03em; - color: var(--color-text-secondary); - } - - tr:last-child td { - border-bottom: none; - } + /* Table styling provided by global .stella-table class */ .list { margin: 0; @@ -415,15 +375,7 @@ export class TopologyEnvironmentDetailPageComponent { private readonly route = inject(ActivatedRoute); readonly context = inject(PlatformContextStore); - readonly tabs: Array<{ id: EnvironmentTab; label: string }> = [ - { id: 'overview', label: 'Overview' }, - { id: 'targets', label: 'Targets' }, - { id: 'deployments', label: 'Runs' }, - { id: 'agents', label: 'Agents' }, - { id: 'security', label: 'Security' }, - { id: 'evidence', label: 'Evidence' }, - { id: 'data-quality', label: 'Data Quality' }, - ]; + readonly ENV_DETAIL_TABS = ENV_DETAIL_TABS; readonly activeTab = signal('overview'); readonly loading = signal(false); @@ -440,6 +392,13 @@ export class TopologyEnvironmentDetailPageComponent { readonly findingRows = signal([]); readonly capsuleRows = signal([]); + readonly envQuickLinks = computed(() => [ + { label: 'Environment', route: `/setup/topology/environments/${this.environmentId()}` }, + { label: 'Targets', route: '/setup/topology/targets' }, + { label: 'Agents', route: '/setup/topology/agents' }, + { label: 'Runs', route: '/releases/runs' }, + ]); + readonly healthyTargets = computed(() => this.targetRows().filter((item) => item.healthStatus.trim().toLowerCase() === 'healthy').length, ); diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-hosts-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-hosts-page.component.ts index 205f2afaa..f0a981dcf 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-hosts-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-hosts-page.component.ts @@ -66,7 +66,7 @@ import { TopologyHost, TopologyTarget } from './topology.models';

    Hosts

    -
    Capsule
    +
    @@ -288,37 +288,13 @@ import { TopologyHost, TopologyTarget } from './topology.models'; border-radius: var(--radius-sm); } - table { - width: 100%; - border-collapse: collapse; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - overflow: hidden; - background: var(--color-surface-secondary); - } - - th, td { - text-align: left; - font-size: 0.74rem; - padding: 0.34rem 0.5rem; - border-bottom: 1px solid var(--color-border-primary); - vertical-align: middle; - } - + /* Table styling provided by global .stella-table class */ th { - text-transform: uppercase; - color: var(--color-text-muted); - font-size: 0.66rem; - letter-spacing: 0.04em; - font-weight: 600; - background: var(--color-surface-primary); position: sticky; top: 0; z-index: 1; } - tr:last-child td { border-bottom: none; } - tbody tr { cursor: pointer; transition: background 120ms ease; @@ -382,7 +358,7 @@ import { TopologyHost, TopologyTarget } from './topology.models'; display: inline-flex; align-items: center; gap: 0.2rem; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.73rem; font-weight: 500; text-decoration: none; @@ -393,7 +369,7 @@ import { TopologyHost, TopologyTarget } from './topology.models'; .actions a:hover { background: var(--color-brand-soft); - color: var(--color-brand-primary-hover); + color: var(--color-text-link-hover); } /* --- Empty states --- */ diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-inventory-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-inventory-page.component.ts index cacaa5b4a..344b817cc 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-inventory-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-inventory-page.component.ts @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, computed, effect, inject, signal } import { ActivatedRoute } from '@angular/router'; import { take } from 'rxjs'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; import { PlatformContextStore } from '../../core/context/platform-context.store'; interface PlatformListResponse { @@ -19,6 +20,7 @@ interface TopologyRouteData { @Component({ selector: 'app-topology-inventory-page', standalone: true, + imports: [LoadingStateComponent], template: `
    @@ -36,11 +38,11 @@ interface TopologyRouteData { } @if (loading()) { -
    Loading {{ title().toLowerCase() }}...
    + } @else if (rows().length === 0) {
    No data is available for the selected context.
    } @else { -
    Host
    +
    @for (key of columnKeys(); track key) { @@ -92,33 +94,7 @@ interface TopologyRouteData { background: var(--color-surface-primary); } - .topology__table { - width: 100%; - border-collapse: collapse; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); - background: var(--color-surface-primary); - overflow: hidden; - } - - .topology__table th, - .topology__table td { - text-align: left; - padding: 0.55rem 0.7rem; - border-bottom: 1px solid var(--color-border-primary); - font-size: 0.78rem; - } - - .topology__table th { - color: var(--color-text-secondary); - text-transform: uppercase; - font-size: 0.7rem; - letter-spacing: 0.03em; - } - - .topology__table tr:last-child td { - border-bottom: none; - } + /* Table styling provided by global .stella-table class */ .topology__loading, .topology__empty, diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-map-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-map-page.component.ts index 211266210..679746442 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-map-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-map-page.component.ts @@ -10,6 +10,7 @@ import { signal, } from '@angular/core'; import { FormsModule } from '@angular/forms'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; import { Router } from '@angular/router'; import { catchError, forkJoin, of, take } from 'rxjs'; import * as d3 from 'd3'; @@ -43,7 +44,7 @@ interface TopoLink extends d3.SimulationLinkDatum { @Component({ selector: 'app-topology-map-page', standalone: true, - imports: [FormsModule], + imports: [FormsModule, LoadingStateComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
    @@ -74,7 +75,7 @@ interface TopoLink extends d3.SimulationLinkDatum {
    @if (loading()) { -
    Loading topology...
    + }
    @@ -182,7 +183,7 @@ interface TopoLink extends d3.SimulationLinkDatum { .topo-map__zoom-controls button:hover { background: var(--color-brand-soft); border-color: var(--color-border-emphasis); - color: var(--color-brand-primary); + color: var(--color-text-link); } .topo-map__graph { diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-overview-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-overview-page.component.ts index 190161a5e..714805b21 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-overview-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-overview-page.component.ts @@ -501,7 +501,7 @@ interface SearchHit { .card__link { justify-self: start; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.74rem; text-decoration: none; font-weight: 500; @@ -509,12 +509,12 @@ interface SearchHit { } .card__link:hover { - color: var(--color-brand-primary-hover); + color: var(--color-text-link-hover); } .card a { justify-self: start; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.74rem; text-decoration: none; } @@ -596,7 +596,7 @@ interface SearchHit { .icon-btn:hover { background: var(--color-brand-soft); border-color: var(--color-border-emphasis); - color: var(--color-brand-primary); + color: var(--color-text-link); } /* --- Empty state --- */ diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-promotion-paths-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-promotion-paths-page.component.ts index e80a65a25..9ec9aa2d3 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-promotion-paths-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-promotion-paths-page.component.ts @@ -85,7 +85,7 @@ interface PathRow extends TopologyPromotionPath { } @else if (viewMode() === 'rules') {

    Rules Table

    -
    +
    @@ -115,7 +115,7 @@ interface PathRow extends TopologyPromotionPath { } @else {

    Environment Inventory

    -
    From
    +
    @@ -323,42 +323,16 @@ interface PathRow extends TopologyPromotionPath { } /* --- Tables --- */ - table { - width: 100%; - border-collapse: collapse; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - overflow: hidden; - background: var(--color-surface-secondary); - } - - th, td { - text-align: left; - font-size: 0.74rem; - padding: 0.34rem 0.5rem; - border-bottom: 1px solid var(--color-border-primary); - vertical-align: middle; - } - + /* Table styling provided by global .stella-table class */ th { - text-transform: uppercase; - color: var(--color-text-muted); - font-size: 0.66rem; - letter-spacing: 0.04em; - font-weight: 600; - background: var(--color-surface-primary); position: sticky; top: 0; z-index: 1; } - tbody tr { transition: background 120ms ease; } - tbody tr:nth-child(even) { background: var(--color-surface-primary); } - tbody tr:hover { background: var(--color-brand-soft); } - tr:last-child td { border-bottom: none; } /* --- Status badges --- */ @@ -399,7 +373,7 @@ interface PathRow extends TopologyPromotionPath { .icon-btn:hover { background: var(--color-brand-soft); border-color: var(--color-border-emphasis); - color: var(--color-brand-primary); + color: var(--color-text-link); } .muted { diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-regions-environments-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-regions-environments-page.component.ts index 86d166e83..a788bda9e 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-regions-environments-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-regions-environments-page.component.ts @@ -79,7 +79,7 @@ type RegionsView = 'region-first' | 'flat' | 'graph';

    Environments · {{ selectedRegionLabel() }}

    -
    Environment
    +
    @@ -117,7 +117,7 @@ type RegionsView = 'region-first' | 'flat' | 'graph'; } @else if (viewMode() === 'flat') {

    Environment Inventory

    -
    Environment
    +
    @@ -403,48 +403,16 @@ type RegionsView = 'region-first' | 'flat' | 'graph'; } /* --- Tables --- */ - table { - width: 100%; - border-collapse: collapse; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - overflow: hidden; - background: var(--color-surface-secondary); - } - - th, - td { - text-align: left; - font-size: 0.75rem; - padding: 0.38rem 0.5rem; - border-bottom: 1px solid var(--color-border-primary); - vertical-align: middle; - } - + /* Table styling provided by global .stella-table class */ th { - text-transform: uppercase; - color: var(--color-text-muted); - font-size: 0.66rem; - letter-spacing: 0.04em; - font-weight: 600; - background: var(--color-surface-primary); position: sticky; top: 0; z-index: 1; } - tbody tr { transition: background 120ms ease; } - tbody tr:nth-child(even) { - background: var(--color-surface-primary); - } - - tbody tr:hover { - background: var(--color-brand-soft); - } - tr:last-child td { border-bottom: none; } @@ -510,7 +478,7 @@ type RegionsView = 'region-first' | 'flat' | 'graph'; .icon-btn:hover { background: var(--color-brand-soft); border-color: var(--color-border-emphasis); - color: var(--color-brand-primary); + color: var(--color-text-link); } /* --- Empty cell --- */ @@ -563,7 +531,7 @@ type RegionsView = 'region-first' | 'flat' | 'graph'; display: inline-flex; align-items: center; gap: 0.2rem; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.73rem; font-weight: 500; text-decoration: none; @@ -574,7 +542,7 @@ type RegionsView = 'region-first' | 'flat' | 'graph'; .actions a:hover { background: var(--color-brand-soft); - color: var(--color-brand-primary-hover); + color: var(--color-text-link-hover); } @media (max-width: 960px) { diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-shell.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-shell.component.ts index f3c9c253f..a1e6f82ad 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-shell.component.ts @@ -1,25 +1,69 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { RouterOutlet } from '@angular/router'; +import { ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit, signal } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, NavigationEnd, Router, RouterOutlet } from '@angular/router'; +import { filter } from 'rxjs'; -import { TabbedNavComponent, TabItem } from '../../shared/ui/tabbed-nav/tabbed-nav.component'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; + +type TabType = + | 'regions' + | 'map' + | 'targets' + | 'hosts' + | 'agents' + | 'posture' + | 'promotion-graph' + | 'workflows' + | 'gate-profiles' + | 'connectivity' + | 'runtime-drift'; + +const KNOWN_TAB_IDS: readonly string[] = [ + 'regions', 'map', 'targets', 'hosts', 'agents', 'posture', + 'promotion-graph', 'workflows', 'gate-profiles', 'connectivity', 'runtime-drift', +]; + +const PAGE_TABS: readonly StellaPageTab[] = [ + { id: 'regions', label: 'Regions & Environments', icon: 'M1 6v16l7-4 8 4 7-4V2l-7 4-8-4-7 4z|||M8 2v16|||M16 6v16' }, + { id: 'map', label: 'Map', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M2 12h20|||M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z' }, + { id: 'targets', label: 'Targets', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 12m-6 0a6 6 0 1 0 12 0 6 6 0 1 0-12 0|||M12 12m-2 0a2 2 0 1 0 4 0 2 2 0 1 0-4 0' }, + { id: 'hosts', label: 'Hosts', icon: 'M2 2h20v8H2z|||M2 14h20v8H2z|||M6 6h.01|||M6 18h.01' }, + { id: 'agents', label: 'Agents', icon: 'M18 12h2|||M4 12h2|||M12 4v2|||M12 18v2|||M9 9h6v6H9z' }, + { id: 'posture', label: 'Posture', icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z' }, + { id: 'promotion-graph', label: 'Promotion Graph', icon: 'M6 3v12|||M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M18 9a9 9 0 0 1-9 9' }, + { id: 'workflows', label: 'Workflows', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, + { id: 'gate-profiles', label: 'Gate Profiles', icon: 'M9 11l3 3L22 4|||M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' }, + { id: 'connectivity', label: 'Connectivity', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M2 12h20|||M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z' }, + { id: 'runtime-drift', label: 'Runtime Drift', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01' }, +]; @Component({ selector: 'app-topology-shell', standalone: true, - imports: [RouterOutlet, TabbedNavComponent], + imports: [RouterOutlet, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
    -

    Topology

    -

    Regions, environments, targets, hosts, agents, and promotion flows.

    +
    + +
    +
    +

    Environments

    +

    Regions, targets, hosts, agents, promotion flows, and release posture

    +
    - - -
    + -
    +
    `, styles: [` @@ -29,37 +73,65 @@ import { TabbedNavComponent, TabItem } from '../../shared/ui/tabbed-nav/tabbed-n } .topology-shell__header { + display: flex; + align-items: center; + gap: 0.75rem; padding: 0 0 0.5rem; } + .topology-shell__icon { + display: flex; + align-items: center; + justify-content: center; + width: 42px; + height: 42px; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--color-brand-primary) 10%, var(--color-surface-primary)); + border: 1px solid color-mix(in srgb, var(--color-brand-primary) 25%, var(--color-border-primary)); + color: var(--color-brand-primary); + flex-shrink: 0; + } + .topology-shell__header h1 { margin: 0; - font-size: 1.35rem; + font-size: 1.25rem; + font-weight: var(--font-weight-semibold); + letter-spacing: -0.01em; } .topology-shell__header p { - margin: 0.25rem 0 0; + margin: 0.1rem 0 0; color: var(--color-text-secondary); - font-size: 0.8rem; - } - - .topology-shell__content { - padding-top: 0.25rem; + font-size: 0.8125rem; } `], }) -export class TopologyShellComponent { - readonly tabs: TabItem[] = [ - { id: 'overview', label: 'Overview', route: 'overview', queryParamsHandling: 'merge' }, - { id: 'map', label: 'Map', route: 'map', queryParamsHandling: 'merge' }, - { id: 'regions', label: 'Regions & Environments', route: 'regions', queryParamsHandling: 'merge' }, - { id: 'targets', label: 'Targets', route: 'targets', queryParamsHandling: 'merge' }, - { id: 'hosts', label: 'Hosts', route: 'hosts', queryParamsHandling: 'merge' }, - { id: 'agents', label: 'Agents', route: 'agents', queryParamsHandling: 'merge' }, - { id: 'promotion', label: 'Promotion Graph', route: 'promotion-graph', queryParamsHandling: 'merge' }, - { id: 'workflows', label: 'Workflows', route: 'workflows', queryParamsHandling: 'merge' }, - { id: 'gate-profiles', label: 'Gate Profiles', route: 'gate-profiles', queryParamsHandling: 'merge' }, - { id: 'connectivity', label: 'Connectivity', route: 'connectivity', queryParamsHandling: 'merge' }, - { id: 'drift', label: 'Runtime Drift', route: 'runtime-drift', queryParamsHandling: 'merge' }, - ]; +export class TopologyShellComponent implements OnInit { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly destroyRef = inject(DestroyRef); + + readonly pageTabs = PAGE_TABS; + readonly activeTab = signal('regions'); + + ngOnInit(): void { + this.setActiveTabFromUrl(this.router.url); + this.router.events.pipe( + filter((e): e is NavigationEnd => e instanceof NavigationEnd), + takeUntilDestroyed(this.destroyRef), + ).subscribe(e => this.setActiveTabFromUrl(e.urlAfterRedirects)); + } + + onTabChange(tabId: string): void { + this.activeTab.set(tabId as TabType); + this.router.navigate([tabId], { relativeTo: this.route, queryParamsHandling: 'merge' }); + } + + private setActiveTabFromUrl(url: string): void { + const segments = url.split('?')[0].split('/').filter(Boolean); + const lastSegment = segments.at(-1) ?? ''; + if (KNOWN_TAB_IDS.includes(lastSegment as TabType)) { + this.activeTab.set(lastSegment as TabType); + } + } } diff --git a/src/Web/StellaOps.Web/src/app/features/topology/topology-targets-page.component.ts b/src/Web/StellaOps.Web/src/app/features/topology/topology-targets-page.component.ts index 808ffd325..19d3cb892 100644 --- a/src/Web/StellaOps.Web/src/app/features/topology/topology-targets-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/topology/topology-targets-page.component.ts @@ -68,7 +68,7 @@ import { TopologyAgent, TopologyHost, TopologyTarget } from './topology.models';

    Targets

    -
    Environment
    +
    @@ -300,37 +300,13 @@ import { TopologyAgent, TopologyHost, TopologyTarget } from './topology.models'; border-radius: var(--radius-sm); } - table { - width: 100%; - border-collapse: collapse; - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-sm); - overflow: hidden; - background: var(--color-surface-secondary); - } - - th, td { - text-align: left; - font-size: 0.74rem; - padding: 0.34rem 0.5rem; - border-bottom: 1px solid var(--color-border-primary); - vertical-align: middle; - } - + /* Table styling provided by global .stella-table class */ th { - text-transform: uppercase; - color: var(--color-text-muted); - font-size: 0.66rem; - letter-spacing: 0.04em; - font-weight: 600; - background: var(--color-surface-primary); position: sticky; top: 0; z-index: 1; } - tr:last-child td { border-bottom: none; } - tbody tr { cursor: pointer; transition: background 120ms ease; @@ -434,7 +410,7 @@ import { TopologyAgent, TopologyHost, TopologyTarget } from './topology.models'; display: inline-flex; align-items: center; gap: 0.2rem; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.73rem; font-weight: 500; text-decoration: none; @@ -445,7 +421,7 @@ import { TopologyAgent, TopologyHost, TopologyTarget } from './topology.models'; .actions a:hover { background: var(--color-brand-soft); - color: var(--color-brand-primary-hover); + color: var(--color-text-link-hover); } /* --- Empty states --- */ diff --git a/src/Web/StellaOps.Web/src/app/features/triage-inbox/triage-inbox.component.html b/src/Web/StellaOps.Web/src/app/features/triage-inbox/triage-inbox.component.html index b70af41ff..e72e80a8d 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage-inbox/triage-inbox.component.html +++ b/src/Web/StellaOps.Web/src/app/features/triage-inbox/triage-inbox.component.html @@ -22,7 +22,7 @@
    -
    Loading...
    +
    {{ error() }}
    [] = [ @Component({ selector: 'app-triage-inbox', standalone: true, - imports: [CommonModule, FormsModule], + imports: [CommonModule, FormsModule, LoadingStateComponent], templateUrl: './triage-inbox.component.html', styleUrls: ['./triage-inbox.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/ai-recommendation-panel/ai-recommendation-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/ai-recommendation-panel/ai-recommendation-panel.component.ts index c134be356..6c24a62ce 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/ai-recommendation-panel/ai-recommendation-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/ai-recommendation-panel/ai-recommendation-panel.component.ts @@ -287,7 +287,7 @@ export interface ApplySuggestionEvent { padding: 0.375rem 0.875rem; border: none; border-radius: var(--radius-md); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-surface-primary); font-size: 0.8125rem; font-weight: var(--font-weight-medium); @@ -405,7 +405,7 @@ export interface ApplySuggestionEvent { .confidence-fill { display: block; height: 100%; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); transition: width 0.3s ease; } @@ -431,7 +431,7 @@ export interface ApplySuggestionEvent { padding: 0.375rem 0.75rem; border: none; border-radius: var(--radius-md); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-surface-primary); font-size: 0.8125rem; font-weight: var(--font-weight-medium); @@ -457,7 +457,7 @@ export interface ApplySuggestionEvent { } .recommendation-card__details summary { - color: var(--color-brand-primary); + color: var(--color-text-link); cursor: pointer; } @@ -524,7 +524,7 @@ export interface ApplySuggestionEvent { } .explanation-sources summary { - color: var(--color-brand-primary); + color: var(--color-text-link); cursor: pointer; } @@ -539,7 +539,7 @@ export interface ApplySuggestionEvent { border: 1px solid var(--color-brand-primary); border-radius: var(--radius-md); background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.8125rem; font-weight: var(--font-weight-medium); cursor: pointer; @@ -610,7 +610,7 @@ export interface ApplySuggestionEvent { .vex--not_affected { background: var(--color-status-success-bg); color: var(--color-status-success-text); } .vex--affected_mitigated { background: var(--color-status-info-bg); color: var(--color-status-info-text); } - .vex--affected_unmitigated { background: var(--color-status-error-border); color: var(--color-status-error-text); } + .vex--affected_unmitigated { background: var(--color-status-error-border); color: #fff; } .vex--fixed { background: var(--color-status-success-bg); color: var(--color-status-success-text); } .vex--under_investigation { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); } @@ -669,7 +669,7 @@ export interface ApplySuggestionEvent { padding: 0.5rem 1rem; border: none; border-radius: var(--radius-md); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-surface-primary); font-size: 0.8125rem; font-weight: var(--font-weight-medium); @@ -699,7 +699,7 @@ export interface ApplySuggestionEvent { margin: 0 0 0.5rem; font-size: 0.75rem; font-weight: var(--font-weight-medium); - color: var(--color-brand-primary); + color: var(--color-text-link); } .custom-answer__answer { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/attestation-viewer/attestation-viewer.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/attestation-viewer/attestation-viewer.component.ts index 8343529ce..80d1d411d 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/attestation-viewer/attestation-viewer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/attestation-viewer/attestation-viewer.component.ts @@ -105,7 +105,7 @@ export interface AttestationData { } a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; &:hover { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/bulk-action-modal/bulk-action-modal.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/bulk-action-modal/bulk-action-modal.component.ts index 46ad6598c..66a07df4f 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/bulk-action-modal/bulk-action-modal.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/bulk-action-modal/bulk-action-modal.component.ts @@ -326,10 +326,10 @@ export type BulkActionType = 'mark_not_affected' | 'mark_affected' | 'request_an text-align: center; } - .severity--critical { background: var(--color-status-error-border); color: var(--color-status-error-text); } + .severity--critical { background: var(--color-status-error-border); color: #fff; } .severity--high { background: var(--color-severity-high-bg); color: var(--color-severity-high); } - .severity--medium { background: var(--color-status-warning-border); color: var(--color-status-warning-text); } - .severity--low { background: var(--color-status-success-border); color: var(--color-status-success-text); } + .severity--medium { background: var(--color-status-warning-border); color: #fff; } + .severity--low { background: var(--color-status-success-border); color: #fff; } .severity--none { background: var(--color-border-primary); color: var(--color-text-primary); } .bar-track { @@ -479,7 +479,7 @@ export type BulkActionType = 'mark_not_affected' | 'mark_affected' | 'request_an .vuln-preview summary { padding: 0.5rem 0; font-size: 0.875rem; - color: var(--color-brand-primary); + color: var(--color-text-link); cursor: pointer; } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/case-header/case-header.component.scss b/src/Web/StellaOps.Web/src/app/features/triage/components/case-header/case-header.component.scss index 0755646b8..0e56d183d 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/case-header/case-header.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/case-header/case-header.component.scss @@ -47,7 +47,7 @@ } .signed-badge { - color: var(--color-brand-primary); + color: var(--color-text-link); } .delta-section { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/decision-drawer/decision-drawer-enhanced.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/decision-drawer/decision-drawer-enhanced.component.ts index 8fc6eb3ab..1c53b8b9e 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/decision-drawer/decision-drawer-enhanced.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/decision-drawer/decision-drawer-enhanced.component.ts @@ -358,7 +358,7 @@ export interface ApprovalResponse { } .radio-option.selected .key-hint { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; } @@ -405,7 +405,7 @@ export interface ApprovalResponse { color: var(--color-text-secondary); } - .policy-note a { color: var(--color-brand-primary); } + .policy-note a { color: var(--color-text-link); } /* Policy Display */ .policy-display { display: flex; gap: 8px; align-items: center; } @@ -464,7 +464,7 @@ export interface ApprovalResponse { border: none; } - .btn-primary:hover:not(:disabled) { background: var(--color-brand-secondary); } + .btn-primary:hover:not(:disabled) { background: var(--color-btn-primary-bg-hover); } .btn-secondary { background: transparent; @@ -511,7 +511,7 @@ export interface ApprovalResponse { .undo-message { font-size: var(--font-size-base); } .undo-btn { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; border: none; padding: 8px 12px; diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/decision-drawer/decision-drawer.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/decision-drawer/decision-drawer.component.ts index aadb7b143..665b3a444 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/decision-drawer/decision-drawer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/decision-drawer/decision-drawer.component.ts @@ -257,7 +257,7 @@ export interface AlertSummary { } .radio-option.selected .key-hint { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/binary-diff-tab.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/binary-diff-tab.component.ts index 81756cd13..711d78c45 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/binary-diff-tab.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/binary-diff-tab.component.ts @@ -339,7 +339,7 @@ export interface BinaryDiffSummary { align-items: center; gap: 6px; padding: 8px 12px; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; border: none; border-radius: var(--radius-sm); @@ -680,7 +680,7 @@ export interface BinaryDiffSummary { border-radius: var(--radius-sm); cursor: pointer; font-size: var(--font-size-sm); - color: var(--color-brand-primary); + color: var(--color-text-link); } .show-more-btn:hover { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/diff-tab.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/diff-tab.component.ts index a28bdca1d..24b4a44e3 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/diff-tab.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/diff-tab.component.ts @@ -256,7 +256,7 @@ import { DiffEvidenceService } from '../../services/diff-evidence.service'; .retry-btn { padding: 8px 16px; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; border: none; border-radius: var(--radius-sm); @@ -351,7 +351,7 @@ import { DiffEvidenceService } from '../../services/diff-evidence.service'; align-items: center; gap: 4px; font-size: var(--font-size-sm); - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } @@ -485,7 +485,7 @@ import { DiffEvidenceService } from '../../services/diff-evidence.service'; border-bottom: 1px solid var(--color-border-primary); cursor: pointer; font-size: var(--font-size-base); - color: var(--color-brand-primary); + color: var(--color-text-link); } .load-diff-btn:hover:not(:disabled) { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/evidence-uri-link.component.scss b/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/evidence-uri-link.component.scss index ef53678ad..e25e7e626 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/evidence-uri-link.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/evidence-uri-link.component.scss @@ -16,7 +16,7 @@ font-family: var(--font-family-mono); font-size: var(--font-size-xs); text-decoration: none; - color: var(--color-brand-primary); + color: var(--color-text-link); background-color: var(--color-brand-light); border: 1px solid transparent; transition: all var(--motion-duration-fast) var(--motion-ease-default); diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/function-trace.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/function-trace.component.ts index aa726aeb7..4f6658aaf 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/function-trace.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/function-trace.component.ts @@ -371,7 +371,7 @@ import { .frame-location { font-size: var(--font-size-xs); - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; } @@ -471,7 +471,7 @@ import { .action-btn--view { background: var(--color-brand-light); border-color: var(--color-brand-primary-20); - color: var(--color-brand-primary); + color: var(--color-text-link); } /* Dark mode */ diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/runtime-tab.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/runtime-tab.component.ts index 665422a2e..968e7889f 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/runtime-tab.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/runtime-tab.component.ts @@ -239,7 +239,7 @@ import { RuntimeEvidenceService } from '../../services/runtime-evidence.service' .retry-btn { padding: 8px 16px; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; border: none; border-radius: var(--radius-sm); @@ -388,7 +388,7 @@ import { RuntimeEvidenceService } from '../../services/runtime-evidence.service' .filter-btn--active { background: var(--color-brand-light); border-color: var(--color-brand-primary-20); - color: var(--color-brand-primary); + color: var(--color-text-link); } .traces-list { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/symbol-path-viewer.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/symbol-path-viewer.component.ts index 0b031c37f..d7cd28977 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/symbol-path-viewer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/symbol-path-viewer.component.ts @@ -152,7 +152,7 @@ import { CallPath, CallPathNode } from '../../models/reachability.models'; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background-color: var(--color-surface-tertiary); - color: var(--color-brand-primary); + color: var(--color-text-link); cursor: pointer; transition: all 0.15s ease; } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/tabbed-evidence-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/tabbed-evidence-panel.component.ts index e721d7512..8cbabbfc4 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/tabbed-evidence-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-panel/tabbed-evidence-panel.component.ts @@ -25,6 +25,14 @@ import { ProvenanceTabComponent } from './provenance-tab.component'; import { DiffTabComponent } from './diff-tab.component'; import { RuntimeTabComponent } from './runtime-tab.component'; import { ReachabilityTabComponent } from './reachability-tab.component'; +const EVIDENCE_PANEL_TABS: StellaPageTab[] = [ + { id: 'provenance', label: 'Provenance', icon: 'M6 3v12|||M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6z|||M18 9a9 9 0 0 1-9 9' }, + { id: 'reachability', label: 'Reachability', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, + { id: 'diff', label: 'Diff', icon: 'M16 3h5v5|||M8 3H3v5|||M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3|||M15 9l6-6' }, + { id: 'runtime', label: 'Runtime', icon: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z' }, + { id: 'policy', label: 'Policy', icon: 'M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2|||M8 2h8v4H8z' }, +]; + import { EvidenceTabType, EvidenceTab, @@ -38,6 +46,7 @@ import { } from '../../models/evidence-panel.models'; import { EvidenceTabService } from '../../services/evidence-tab.service'; import { TabUrlPersistenceService } from '../../services/tab-url-persistence.service'; +import { StellaPageTabsComponent, StellaPageTab } from '../../../../shared/components/stella-page-tabs/stella-page-tabs.component'; @Component({ selector: 'app-tabbed-evidence-panel', @@ -48,8 +57,7 @@ import { TabUrlPersistenceService } from '../../services/tab-url-persistence.ser ProvenanceTabComponent, DiffTabComponent, RuntimeTabComponent, - ReachabilityTabComponent -], + ReachabilityTabComponent, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
    @@ -59,48 +67,12 @@ import { TabUrlPersistenceService } from '../../services/tab-url-persistence.ser - +
    @@ -277,102 +249,7 @@ import { TabUrlPersistenceService } from '../../services/tab-url-persistence.ser color: var(--color-text-secondary); } - /* Tab Navigation */ - .tab-nav { - display: flex; - gap: 0; - padding: 0 0.5rem; - border-bottom: 1px solid var(--color-border); - background: var(--color-bg); - overflow-x: auto; - } - .tab-btn { - display: inline-flex; - align-items: center; - gap: 0.375rem; - padding: 0.625rem 0.875rem; - font-size: 0.8125rem; - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - background: transparent; - border: none; - border-bottom: 2px solid transparent; - cursor: pointer; - transition: all 0.15s ease; - white-space: nowrap; - } - - .tab-btn:hover:not(:disabled) { - color: var(--color-text-primary); - background: var(--color-bg-hover); - } - - .tab-btn:focus-visible { - outline: 2px solid var(--color-focus-ring); - outline-offset: -2px; - } - - .tab-btn--active { - color: var(--color-primary); - border-bottom-color: var(--color-primary); - } - - .tab-btn--disabled { - opacity: 0.5; - cursor: not-allowed; - } - - .tab-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 1.25rem; - height: 1.25rem; - padding: 0 0.375rem; - font-size: 0.6875rem; - font-weight: var(--font-weight-semibold); - border-radius: var(--radius-full); - background: var(--color-badge-bg); - color: var(--color-badge-text); - } - - .tab-badge--success { - background: var(--color-success-bg); - color: var(--color-success-text); - } - - .tab-badge--warning { - background: var(--color-warning-bg); - color: var(--color-warning-text); - } - - .tab-badge--error { - background: var(--color-error-bg); - color: var(--color-error-text); - } - - .tab-badge--info { - background: var(--color-info-bg); - color: var(--color-info-text); - } - - .badge-dot { - display: block; - width: 0.375rem; - height: 0.375rem; - border-radius: var(--radius-full); - background: currentColor; - } - - .tab-spinner { - width: 0.875rem; - height: 0.875rem; - border: 2px solid var(--color-border); - border-top-color: var(--color-primary); - border-radius: var(--radius-full); - animation: spin 0.8s linear infinite; - } /* Tab Panels */ .tab-panels { @@ -544,6 +421,7 @@ export class TabbedEvidencePanelComponent implements OnInit, OnDestroy { readonly tabs = DEFAULT_EVIDENCE_TABS; /** Currently selected tab */ + readonly EVIDENCE_PANEL_TABS = EVIDENCE_PANEL_TABS; readonly selectedTab = signal('provenance'); /** Loading states per tab */ diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-pills/evidence-pills.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-pills/evidence-pills.component.ts index a8599765b..6c8bdfef9 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-pills/evidence-pills.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/evidence-pills/evidence-pills.component.ts @@ -308,7 +308,7 @@ export type EvidencePillType = .download-link:hover { background: var(--color-nav-hover); - color: var(--color-brand-primary); + color: var(--color-text-link); } .download-link:focus-visible { @@ -338,7 +338,7 @@ export type EvidencePillType = font-size: var(--font-size-base); font-weight: var(--font-weight-semibold); cursor: pointer; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; border: none; transition: all 0.15s ease; @@ -346,7 +346,7 @@ export type EvidencePillType = } .quick-verify-btn:hover:not(.disabled) { - background: var(--color-brand-primary-hover); + background: var(--color-btn-primary-bg-hover); transform: translateY(-1px); box-shadow: 0 2px 8px rgba(25, 118, 210, 0.3); } @@ -370,7 +370,7 @@ export type EvidencePillType = .why-link { background: none; border: none; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: var(--font-size-sm); cursor: pointer; text-decoration: underline; @@ -378,7 +378,7 @@ export type EvidencePillType = } .why-link:hover { - color: var(--color-brand-primary-hover); + color: var(--color-text-link-hover); } .why-link:focus-visible { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/export-evidence-button/export-evidence-button.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/export-evidence-button/export-evidence-button.component.ts index ecfcfaeca..31628e645 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/export-evidence-button/export-evidence-button.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/export-evidence-button/export-evidence-button.component.ts @@ -161,7 +161,7 @@ interface ExportStatusResponse { gap: 0.5rem; padding: 0.5rem 1rem; border: 1px solid var(--color-brand-primary); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; border-radius: var(--radius-md); font-size: 0.875rem; @@ -172,7 +172,7 @@ interface ExportStatusResponse { } .export-btn:hover:not(:disabled) { - background: var(--color-brand-primary-hover); + background: var(--color-btn-primary-bg-hover); } .export-btn:focus-visible { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/findings-detail-page/findings-detail-page.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/findings-detail-page/findings-detail-page.component.ts index 1fc5ee362..7fd8c878b 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/findings-detail-page/findings-detail-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/findings-detail-page/findings-detail-page.component.ts @@ -487,7 +487,7 @@ export interface FindingDetail { .policy-gate-link { margin-left: auto; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-weight: 600; } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/gated-buckets/gated-buckets.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/gated-buckets/gated-buckets.component.ts index 11ff368a8..265ae5a2a 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/gated-buckets/gated-buckets.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/gated-buckets/gated-buckets.component.ts @@ -186,7 +186,7 @@ export interface BucketExpandEvent { .bucket-chip.expanded { background: var(--color-brand-light); border-color: var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); } .bucket-chip .icon { @@ -279,7 +279,7 @@ export interface BucketExpandEvent { .show-all-toggle.active { background: var(--color-brand-light); border: 1px solid var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); } `] }) diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/gating-explainer/gating-explainer.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/gating-explainer/gating-explainer.component.ts index 44ca88a34..f91d74031 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/gating-explainer/gating-explainer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/gating-explainer/gating-explainer.component.ts @@ -215,7 +215,7 @@ import { background: var(--color-surface-tertiary); border-radius: var(--radius-sm); font-size: var(--font-size-sm); - color: var(--color-brand-primary); + color: var(--color-text-link); cursor: pointer; text-decoration: none; transition: background 0.15s ease; @@ -276,7 +276,7 @@ import { margin-top: 8px; padding: 4px 8px; font-size: var(--font-size-xs); - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; border-radius: var(--radius-sm); transition: all 0.15s ease; @@ -299,13 +299,13 @@ import { border-radius: var(--radius-sm); font-size: var(--font-size-sm); font-weight: var(--font-weight-medium); - color: var(--color-brand-primary); + color: var(--color-text-link); cursor: pointer; transition: all 0.15s ease; } .ungating-btn:hover { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/noise-gating/noise-gating-delta-report.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/noise-gating/noise-gating-delta-report.component.ts index 389b4803f..e24c26fb5 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/noise-gating/noise-gating-delta-report.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/noise-gating/noise-gating-delta-report.component.ts @@ -208,7 +208,7 @@ interface SectionTab { color: var(--color-text-primary); } .ng-delta-report__tab.active { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-surface-primary); } .ng-delta-report__tab.empty { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/playbook-suggestion/playbook-suggestion.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/playbook-suggestion/playbook-suggestion.component.ts index c663d80a0..5b2d46f08 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/playbook-suggestion/playbook-suggestion.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/playbook-suggestion/playbook-suggestion.component.ts @@ -191,7 +191,7 @@ export interface SituationContext { } .playbook-panel__badge { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; font-size: var(--font-size-sm); font-weight: var(--font-weight-semibold); @@ -394,14 +394,14 @@ export interface SituationContext { } .playbook-btn--use { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border: none; color: white; font-weight: var(--font-weight-semibold); } .playbook-btn--use:hover { - background: var(--color-brand-primary-hover); + background: var(--color-btn-primary-bg-hover); } `, ], diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/provenance-breadcrumb/provenance-breadcrumb.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/provenance-breadcrumb/provenance-breadcrumb.component.ts index ab794a5d9..74f2d970e 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/provenance-breadcrumb/provenance-breadcrumb.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/provenance-breadcrumb/provenance-breadcrumb.component.ts @@ -268,7 +268,7 @@ export interface FindingProvenance { padding: 0.375rem 0.625rem; font-size: 0.8125rem; font-weight: var(--font-weight-semibold); - color: var(--color-brand-primary); + color: var(--color-text-link); } .breadcrumb-icon { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/quiet-lane/parked-item-card.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/quiet-lane/parked-item-card.component.ts index 2a10f9dbe..e7095d417 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/quiet-lane/parked-item-card.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/quiet-lane/parked-item-card.component.ts @@ -12,6 +12,7 @@ import { EventEmitter, signal, computed, + inject, } from '@angular/core'; import { TtlCountdownChipComponent } from './ttl-countdown-chip.component'; @@ -19,6 +20,7 @@ import { VexEvidenceSheetComponent } from '../../../vex_gate/vex-evidence-sheet. import { VexGateButtonDirective } from '../../../vex_gate/vex-gate-button.directive'; import { VexEvidenceLine, VexGateButtonState } from '../../../vex_gate/models/vex-gate.models'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; /** Reason badges for why item is parked */ export type ParkedReason = | 'low_evidence' @@ -344,13 +346,13 @@ const REASON_LABELS: Record = { } .action-btn.primary { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border-color: var(--color-brand-primary); color: white; } .action-btn.primary:hover:not(:disabled) { - background: var(--color-brand-primary-hover); + background: var(--color-btn-primary-bg-hover); } .action-btn.primary.vex-gate-btn--green { @@ -369,7 +371,7 @@ const REASON_LABELS: Record = { } .action-btn.secondary { - color: var(--color-brand-primary); + color: var(--color-text-link); border-color: var(--color-brand-primary); } @@ -410,6 +412,8 @@ const REASON_LABELS: Record = { `] }) export class ParkedItemCardComponent { + private readonly dateFmt = inject(DateFormatService); + @Input({ required: true }) finding!: ParkedFinding; @Output() recheckRequested = new EventEmitter(); @@ -487,7 +491,7 @@ export class ParkedItemCardComponent { formatDate(dateStr: string): string { try { - return new Date(dateStr).toLocaleDateString('en-US', { + return new Date(dateStr).toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/quiet-lane/quiet-lane-container.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/quiet-lane/quiet-lane-container.component.ts index cb992fa74..ec0539209 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/quiet-lane/quiet-lane-container.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/quiet-lane/quiet-lane-container.component.ts @@ -18,6 +18,7 @@ import { ParkedItemCardComponent, ParkedFinding } from './parked-item-card.compo import { VexEvidenceSheetComponent } from '../../../vex_gate/vex-evidence-sheet.component'; import { VexGateButtonDirective } from '../../../vex_gate/vex-gate-button.directive'; import { VexEvidenceLine, VexGateButtonState } from '../../../vex_gate/models/vex-gate.models'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; export type { ParkedFinding } from './parked-item-card.component'; @@ -27,7 +28,7 @@ export type TriageLaneType = 'active' | 'parked' | 'review'; @Component({ selector: 'app-quiet-lane-container', standalone: true, - imports: [ParkedItemCardComponent, VexGateButtonDirective, VexEvidenceSheetComponent], + imports: [ParkedItemCardComponent, VexGateButtonDirective, VexEvidenceSheetComponent, LoadingStateComponent], template: `
    @@ -88,8 +89,7 @@ export type TriageLaneType = 'active' | 'parked' | 'review';
    @if (loading()) {
    -
    - Loading parked items... +
    } @else if (error()) {
    @@ -311,7 +311,7 @@ export type TriageLaneType = 'active' | 'parked' | 'review'; .retry-btn { margin-top: 16px; padding: 8px 16px; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; border: none; border-radius: var(--radius-md); diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/quiet-lane/ttl-countdown-chip.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/quiet-lane/ttl-countdown-chip.component.ts index 67fe7384b..3ca7b41cf 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/quiet-lane/ttl-countdown-chip.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/quiet-lane/ttl-countdown-chip.component.ts @@ -12,7 +12,9 @@ import { signal, effect, OnDestroy, + inject, } from '@angular/core'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; /** Color threshold configuration */ @@ -85,6 +87,8 @@ const TTL_THRESHOLDS: TtlThreshold[] = [ `] }) export class TtlCountdownChipComponent implements OnDestroy { + private readonly dateFmt = inject(DateFormatService); + private _expiresAt = signal(null); private _showExact = signal(false); private _now = signal(new Date()); @@ -163,7 +167,7 @@ export class TtlCountdownChipComponent implements OnDestroy { if (this._showExact()) { const expires = this._expiresAt(); if (!expires) return '--'; - return expires.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + return expires.toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric' }); } if (days === 0) { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/reachability-context/reachability-context.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/reachability-context/reachability-context.component.ts index 59179f066..15b2e75d9 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/reachability-context/reachability-context.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/reachability-context/reachability-context.component.ts @@ -278,7 +278,7 @@ export interface ReachabilityData { text-transform: uppercase; } - .status--reachable { background: var(--color-status-error-border); color: var(--color-status-error-text); } + .status--reachable { background: var(--color-status-error-border); color: #fff; } .status--unreachable { background: var(--color-status-success-bg); color: var(--color-status-success-text); } .status--unknown { background: var(--color-surface-secondary); color: var(--color-text-primary); } .status--partial { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); } @@ -293,7 +293,7 @@ export interface ReachabilityData { .confidence-icon { font-size: 0.5rem; - color: var(--color-brand-primary); + color: var(--color-text-link); } .view-controls { @@ -322,7 +322,7 @@ export interface ReachabilityData { .view-btn--active { background: var(--color-surface-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); box-shadow: 0 1px 2px rgba(0,0,0,0.05); } @@ -537,7 +537,7 @@ export interface ReachabilityData { .location-btn:hover { border-color: var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); } .node-confidence { @@ -558,7 +558,7 @@ export interface ReachabilityData { .confidence-bar-fill { height: 100%; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); } /* Graph View */ @@ -632,7 +632,7 @@ export interface ReachabilityData { border: 1px solid var(--color-brand-primary); border-radius: var(--radius-md); background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.8125rem; font-weight: var(--font-weight-medium); cursor: pointer; @@ -669,7 +669,7 @@ export interface ReachabilityData { border: 1px solid var(--color-brand-primary); border-radius: var(--radius-md); background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.8125rem; font-weight: var(--font-weight-medium); cursor: pointer; diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/replay-command/replay-command.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/replay-command/replay-command.component.ts index 5337e30f0..22b881f76 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/replay-command/replay-command.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/replay-command/replay-command.component.ts @@ -154,7 +154,7 @@ import { InlineCodeComponent } from '../../../../shared/ui/inline-code/inline-co } .tab.active { - color: var(--color-brand-primary); + color: var(--color-text-link); border-bottom-color: var(--color-brand-primary); } @@ -238,7 +238,7 @@ import { InlineCodeComponent } from '../../../../shared/ui/inline-code/inline-co .bundle-link { padding: 6px 12px; background: var(--color-brand-light); - color: var(--color-brand-primary); + color: var(--color-text-link); border-radius: var(--radius-sm); text-decoration: none; font-size: var(--font-size-base); @@ -247,7 +247,7 @@ import { InlineCodeComponent } from '../../../../shared/ui/inline-code/inline-co } .bundle-link:hover { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/risk-line/risk-line.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/risk-line/risk-line.component.ts index 248e091b0..a9b653c9a 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/risk-line/risk-line.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/risk-line/risk-line.component.ts @@ -270,7 +270,7 @@ export interface RiskLineData { display: inline-flex; align-items: center; gap: 0.25rem; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-family: monospace; font-size: 0.8125rem; diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/triage-canvas/triage-canvas.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/triage-canvas/triage-canvas.component.ts index 32933ce38..a7f74de66 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/triage-canvas/triage-canvas.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/triage-canvas/triage-canvas.component.ts @@ -35,10 +35,20 @@ import { NoiseGatingDeltaEntry, AggregatedGatingStatistics, } from '../../../../core/api/noise-gating.models'; +import { StellaPageTabsComponent, StellaPageTab } from '../../../../shared/components/stella-page-tabs/stella-page-tabs.component'; export type CanvasPaneMode = 'list' | 'split' | 'detail'; export type CanvasDetailTab = 'overview' | 'reachability' | 'ai' | 'history' | 'evidence' | 'delta'; +const CANVAS_DETAIL_TABS: StellaPageTab[] = [ + { id: 'overview', label: 'Overview', icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0' }, + { id: 'reachability', label: 'Reachability', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, + { id: 'ai', label: 'AI', icon: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z' }, + { id: 'history', label: 'History', icon: 'M12 12m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0|||M12 6v6l4 2' }, + { id: 'evidence', label: 'Evidence', icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8' }, + { id: 'delta', label: 'Delta', icon: 'M16 3h5v5|||M8 3H3v5|||M12 22v-8.3a4 4 0 0 0-1.172-2.872L3 3|||M15 9l6-6' }, +]; + interface CanvasLayout { leftPaneWidth: number; rightPaneWidth: number; @@ -48,7 +58,7 @@ interface CanvasLayout { @Component({ selector: 'app-triage-canvas', standalone: true, - imports: [CommonModule, RouterLink, NoiseGatingDeltaReportComponent, GatingStatisticsCardComponent, ErrorStateComponent], + imports: [CommonModule, RouterLink, NoiseGatingDeltaReportComponent, GatingStatisticsCardComponent, ErrorStateComponent, StellaPageTabsComponent], template: `
    @@ -229,24 +239,12 @@ interface CanvasLayout {
    - +
    @@ -570,7 +568,7 @@ interface CanvasLayout { } .filter-chip--active { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border-color: var(--color-brand-primary); color: var(--color-surface-primary); } @@ -649,10 +647,10 @@ interface CanvasLayout { font-weight: var(--font-weight-semibold); } - .severity--critical { background: var(--color-status-error-border); color: var(--color-status-error-text); } + .severity--critical { background: var(--color-status-error-border); color: #fff; } .severity--high { background: var(--color-severity-high-bg); color: var(--color-severity-high); } - .severity--medium { background: var(--color-status-warning-border); color: var(--color-status-warning-text); } - .severity--low { background: var(--color-status-success-border); color: var(--color-status-success-text); } + .severity--medium { background: var(--color-status-warning-border); color: #fff; } + .severity--low { background: var(--color-status-success-border); color: #fff; } .severity--none { background: var(--color-border-primary); color: var(--color-text-primary); } .vuln-card__cve { @@ -667,7 +665,7 @@ interface CanvasLayout { font-weight: var(--font-weight-semibold); } - .vuln-card__badge--kev { background: var(--color-status-error-border); color: var(--color-status-error-text); } + .vuln-card__badge--kev { background: var(--color-status-error-border); color: #fff; } .vuln-card__badge--exploit { background: var(--color-severity-high-border); color: var(--color-severity-high); } .vuln-card__title { @@ -706,7 +704,7 @@ interface CanvasLayout { } .triage-canvas__resize-handle:hover { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); } .triage-canvas__detail-pane { @@ -774,7 +772,7 @@ interface CanvasLayout { } .detail-tab--active { - color: var(--color-brand-primary); + color: var(--color-text-link); border-bottom-color: var(--color-brand-primary); } @@ -782,7 +780,7 @@ interface CanvasLayout { margin-left: 0.375rem; padding: 0.125rem 0.375rem; background: var(--color-brand-primary-10); - color: var(--color-brand-secondary); + color: var(--color-text-link); border-radius: var(--radius-full); font-size: 0.625rem; font-weight: var(--font-weight-semibold); @@ -901,7 +899,7 @@ interface CanvasLayout { .ai-card__type { padding: 0.125rem 0.5rem; background: var(--color-brand-primary-10); - color: var(--color-brand-secondary); + color: var(--color-text-link); border-radius: var(--radius-sm); font-size: 0.625rem; font-weight: var(--font-weight-semibold); @@ -932,7 +930,7 @@ interface CanvasLayout { .ai-card__reasoning summary { cursor: pointer; - color: var(--color-brand-primary); + color: var(--color-text-link); } .vex-timeline { @@ -969,7 +967,7 @@ interface CanvasLayout { width: 16px; height: 16px; border-radius: var(--radius-full); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border: 2px solid var(--color-surface-primary); } @@ -1031,7 +1029,7 @@ interface CanvasLayout { .evidence-link { font-size: 0.8125rem; - color: var(--color-brand-primary); + color: var(--color-text-link); } .replay-cmd { @@ -1256,6 +1254,7 @@ export class TriageCanvasComponent implements OnInit, OnDestroy { { id: 'delta', label: 'Delta' }, ]; + readonly CANVAS_DETAIL_TABS = CANVAS_DETAIL_TABS; readonly layout = signal({ leftPaneWidth: 400, rightPaneWidth: 0, // flex diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/triage-lane-toggle/triage-lane-toggle.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/triage-lane-toggle/triage-lane-toggle.component.ts index 4285a5bb3..22ce30150 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/triage-lane-toggle/triage-lane-toggle.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/triage-lane-toggle/triage-lane-toggle.component.ts @@ -148,7 +148,7 @@ export type TriageLaneType = TriageLane; } .lane-toggle__btn--active .lane-toggle__count { - color: var(--color-brand-primary); + color: var(--color-text-link); font-weight: var(--font-weight-semibold); } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/triage-list/triage-list.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/triage-list/triage-list.component.ts index e09c6c5d0..8ea061a35 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/triage-list/triage-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/triage-list/triage-list.component.ts @@ -425,7 +425,7 @@ export interface FilterChange { .filter-chip--active { background: var(--color-brand-primary-10); border-color: var(--color-brand-primary); - color: var(--color-brand-secondary); + color: var(--color-text-link); } .filter-chip__indicator { @@ -473,7 +473,7 @@ export interface FilterChange { .filter-toggle--active { background: var(--color-brand-primary-10); border-color: var(--color-brand-primary); - color: var(--color-brand-secondary); + color: var(--color-text-link); } .filter-toggle__icon { @@ -505,7 +505,7 @@ export interface FilterChange { border: none; border-radius: var(--radius-md); background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.75rem; font-weight: var(--font-weight-medium); cursor: pointer; @@ -536,7 +536,7 @@ export interface FilterChange { } .list-header__selected { - color: var(--color-brand-primary); + color: var(--color-text-link); font-weight: var(--font-weight-medium); } @@ -594,10 +594,10 @@ export interface FilterChange { letter-spacing: 0.025em; } - .severity--critical { background: var(--color-status-error-border); color: var(--color-status-error-text); } + .severity--critical { background: var(--color-status-error-border); color: #fff; } .severity--high { background: var(--color-severity-high-bg); color: var(--color-severity-high); } - .severity--medium { background: var(--color-status-warning-border); color: var(--color-status-warning-text); } - .severity--low { background: var(--color-status-success-border); color: var(--color-status-success-text); } + .severity--medium { background: var(--color-status-warning-border); color: #fff; } + .severity--low { background: var(--color-status-success-border); color: #fff; } .severity--none { background: var(--color-border-primary); color: var(--color-text-primary); } .vuln-item__content { @@ -627,9 +627,9 @@ export interface FilterChange { letter-spacing: 0.025em; } - .badge--kev { background: var(--color-status-error-border); color: var(--color-status-error-text); } + .badge--kev { background: var(--color-status-error-border); color: #fff; } .badge--exploit { background: var(--color-severity-high-border); color: var(--color-severity-high); } - .badge--fix { background: var(--color-status-success-border); color: var(--color-status-success-text); } + .badge--fix { background: var(--color-status-success-border); color: #fff; } .vuln-item__title { margin: 0; @@ -764,7 +764,7 @@ export interface FilterChange { border: 1px solid var(--color-brand-primary); border-radius: var(--radius-md); background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.8125rem; font-weight: var(--font-weight-medium); cursor: pointer; @@ -793,7 +793,7 @@ export interface FilterChange { .load-more-btn:hover:not(:disabled) { border-color: var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); } .load-more-btn:disabled { @@ -840,7 +840,7 @@ export interface FilterChange { .bulk-btn--primary { background: var(--color-surface-primary); - color: var(--color-brand-secondary); + color: var(--color-text-link); border-color: var(--color-surface-primary); } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/triage-queue/triage-queue.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/triage-queue/triage-queue.component.ts index 9483b1978..158ddd74a 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/triage-queue/triage-queue.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/triage-queue/triage-queue.component.ts @@ -292,7 +292,7 @@ export interface TriageDecision { .progress-fill { height: 100%; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); transition: width 0.3s ease; } @@ -312,7 +312,7 @@ export interface TriageDecision { display: inline-block; margin-bottom: 0.75rem; padding: 0.25rem 0.75rem; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-surface-primary); border-radius: var(--radius-full); font-size: 0.6875rem; @@ -341,10 +341,10 @@ export interface TriageDecision { font-weight: var(--font-weight-bold); } - .severity--critical { background: var(--color-status-error-border); color: var(--color-status-error-text); } + .severity--critical { background: var(--color-status-error-border); color: #fff; } .severity--high { background: var(--color-severity-high-bg); color: var(--color-severity-high); } - .severity--medium { background: var(--color-status-warning-border); color: var(--color-status-warning-text); } - .severity--low { background: var(--color-status-success-border); color: var(--color-status-success-text); } + .severity--medium { background: var(--color-status-warning-border); color: #fff; } + .severity--low { background: var(--color-status-success-border); color: #fff; } .severity--none { background: var(--color-border-primary); color: var(--color-text-primary); } .vuln-cve { @@ -360,7 +360,7 @@ export interface TriageDecision { font-weight: var(--font-weight-semibold); } - .badge--kev { background: var(--color-status-error-border); color: var(--color-status-error-text); } + .badge--kev { background: var(--color-status-error-border); color: #fff; } .badge--exploit { background: var(--color-severity-high-bg); color: var(--color-severity-high); } .vuln-title { @@ -408,12 +408,12 @@ export interface TriageDecision { } .action-btn--primary { - background: var(--color-brand-primary); - color: var(--color-surface-primary); + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); } .action-btn--primary:hover { - background: var(--color-brand-secondary); + background: var(--color-btn-primary-bg-hover); } .action-btn--secondary { @@ -579,7 +579,7 @@ export interface TriageDecision { border: none; border-radius: var(--radius-sm); background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.6875rem; cursor: pointer; } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/unknowns-list/unknowns-list.component.html b/src/Web/StellaOps.Web/src/app/features/triage/components/unknowns-list/unknowns-list.component.html index 99f292f44..311bcdc20 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/unknowns-list/unknowns-list.component.html +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/unknowns-list/unknowns-list.component.html @@ -32,10 +32,7 @@ @if (loading()) { -
    -
    - Loading unknowns... -
    + } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/unknowns-list/unknowns-list.component.scss b/src/Web/StellaOps.Web/src/app/features/triage/components/unknowns-list/unknowns-list.component.scss index 994200490..1b0f81972 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/unknowns-list/unknowns-list.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/unknowns-list/unknowns-list.component.scss @@ -176,7 +176,7 @@ } &.sorted { - color: var(--color-brand-primary); + color: var(--color-text-link); } } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/unknowns-list/unknowns-list.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/unknowns-list/unknowns-list.component.ts index 9cb0007c4..e8899ab65 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/unknowns-list/unknowns-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/unknowns-list/unknowns-list.component.ts @@ -13,6 +13,7 @@ import { UnknownsListResponse, UnknownsFilter, } from '../../services/unknowns.service'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; /** * Unknowns List Component @@ -28,7 +29,7 @@ import { @Component({ selector: 'app-unknowns-list', standalone: true, - imports: [FormsModule], + imports: [FormsModule, LoadingStateComponent], templateUrl: './unknowns-list.component.html', styleUrls: ['./unknowns-list.component.scss'] }) diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/verdict-ladder/verdict-ladder.component.scss b/src/Web/StellaOps.Web/src/app/features/triage/components/verdict-ladder/verdict-ladder.component.scss index dde6cf55b..4050737ea 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/verdict-ladder/verdict-ladder.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/verdict-ladder/verdict-ladder.component.scss @@ -80,7 +80,7 @@ height: 24px; border-radius: 50%; background: var(--color-brand-light); - color: var(--color-brand-primary); + color: var(--color-text-link); display: flex; align-items: center; justify-content: center; @@ -126,7 +126,7 @@ margin-bottom: var(--space-2); mat-icon { - color: var(--color-brand-primary); + color: var(--color-text-link); } .evidence-title { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/vex-history/vex-history.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/vex-history/vex-history.component.ts index b7fc127fc..e07469444 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/vex-history/vex-history.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/vex-history/vex-history.component.ts @@ -25,6 +25,7 @@ import { type VexHistoryEntry, type VexStatus, } from '../../services/vex-decision.service'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; interface TimelineNode { decision: VexDecision; @@ -37,7 +38,7 @@ interface TimelineNode { @Component({ selector: 'app-vex-history', standalone: true, - imports: [CommonModule], + imports: [CommonModule, LoadingStateComponent], template: `
    @@ -51,8 +52,7 @@ interface TimelineNode { @if (loading()) {
    -
    -

    Loading history...

    +
    } @@ -264,7 +264,7 @@ interface TimelineNode { padding: 0.375rem 0.75rem; border: none; border-radius: var(--radius-md); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-surface-primary); font-size: 0.8125rem; font-weight: var(--font-weight-medium); @@ -362,7 +362,7 @@ interface TimelineNode { } .marker-dot--active { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); box-shadow: 0 0 0 2px var(--color-brand-primary-20); } @@ -399,13 +399,13 @@ interface TimelineNode { .status--not_affected { background: var(--color-status-success-bg); color: var(--color-status-success-text); } .status--affected_mitigated { background: var(--color-status-info-bg); color: var(--color-status-info-text); } - .status--affected_unmitigated { background: var(--color-status-error-border); color: var(--color-status-error-text); } + .status--affected_unmitigated { background: var(--color-status-error-border); color: #fff; } .status--fixed { background: var(--color-status-success-bg); color: var(--color-status-success-text); } .status--under_investigation { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); } .active-badge { padding: 0.125rem 0.375rem; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-surface-primary); border-radius: var(--radius-sm); font-size: 0.5625rem; @@ -447,7 +447,7 @@ interface TimelineNode { border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); font-size: 0.6875rem; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; transition: all 0.15s ease; } @@ -528,7 +528,7 @@ interface TimelineNode { border: 1px solid var(--color-brand-primary); border-radius: var(--radius-sm); background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.75rem; font-weight: var(--font-weight-medium); cursor: pointer; @@ -575,7 +575,7 @@ interface TimelineNode { } .legend-marker--active { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); box-shadow: 0 0 0 2px var(--color-brand-primary-20); } @@ -640,7 +640,7 @@ interface TimelineNode { border: 1px solid var(--color-brand-primary); border-radius: var(--radius-md); background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.8125rem; cursor: pointer; } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/components/vex-trust-display/vex-trust-display.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/components/vex-trust-display/vex-trust-display.component.ts index 2504c5c8a..29156ef51 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/components/vex-trust-display/vex-trust-display.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/components/vex-trust-display/vex-trust-display.component.ts @@ -297,7 +297,7 @@ import { .factor-fill { height: 100%; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); border-radius: var(--radius-sm); transition: width 0.3s ease; } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html index 235c54a68..a53de298b 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.html @@ -36,41 +36,71 @@ /> } -
    - + + +
    + + + + +
    @if (selectedCount() > 0) {
    @@ -86,42 +116,8 @@
    } -
    - - -
    -
    - - -
    -
    -
    - @if (loading()) { -
    - - Loading artifacts... -
    + } @else {
    @if (filteredRows().length > 0) { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.scss b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.scss index 0d082eaef..595d31ef2 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.scss @@ -26,57 +26,60 @@ justify-content: flex-end; } -.lane-strip { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - margin-bottom: var(--space-4); +.lane-switcher { + display: inline-flex; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-md); + overflow: hidden; + background: var(--color-surface-secondary); + flex-shrink: 0; } -.lane-pill { +.lane-segment { display: inline-flex; align-items: center; - gap: var(--space-2); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-full); - padding: var(--space-2) var(--space-3); - background: var(--color-surface-primary); - color: var(--color-text-primary); + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border: none; + background: var(--color-surface-secondary); + color: var(--color-text-muted); + font-size: var(--font-size-sm, 0.75rem); + font-weight: var(--font-weight-medium); cursor: pointer; - font-weight: var(--font-weight-semibold); - transition: border-color var(--motion-duration-fast) var(--motion-ease-default), - background-color var(--motion-duration-fast) var(--motion-ease-default); + transition: background-color 150ms ease, color 150ms ease; + white-space: nowrap; - span { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 1.75rem; - border-radius: var(--radius-full); - background: var(--color-surface-tertiary); - padding: 0 var(--space-1-5); - font-size: var(--font-size-xs); + &:not(:last-child) { + border-right: 1px solid var(--color-border-primary); } - &:hover { - background: var(--color-surface-secondary); + &:hover:not(.lane-segment--active) { + background: var(--color-surface-tertiary); + color: var(--color-text-secondary); } &:focus-visible { outline: 2px solid var(--color-brand-primary); - outline-offset: 2px; + outline-offset: -2px; } } -.lane-pill--active { - border-color: var(--color-brand-primary); - background: color-mix(in srgb, var(--color-brand-primary) 10%, var(--color-surface-primary)); +.lane-segment--active { + background: var(--color-text-heading); + color: var(--color-surface-primary); + font-weight: var(--font-weight-semibold); - span { - background: color-mix(in srgb, var(--color-brand-primary) 18%, var(--color-surface-primary)); + &:hover { + background: var(--color-text-primary); } } +.lane-segment__count { + font-size: 0.625rem; + font-weight: var(--font-weight-bold); + opacity: 0.7; +} + .bulk-bar { display: flex; justify-content: space-between; @@ -98,9 +101,8 @@ .triage-artifacts__toolbar { display: flex; - gap: var(--space-4); + gap: var(--space-3); align-items: center; - flex-wrap: wrap; margin-bottom: var(--space-4); } @@ -109,16 +111,19 @@ display: flex; align-items: center; gap: var(--space-2); - min-width: min(520px, 100%); + flex: 1 1 0; + min-width: 0; } .search-box__input { width: 100%; - padding: var(--space-2) var(--space-3); + height: 32px; + padding: 0 var(--space-3); border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); + border-radius: var(--radius-md); background: var(--color-surface-primary); color: var(--color-text-primary); + font-size: var(--font-size-sm, 0.75rem); transition: border-color 150ms ease, box-shadow 150ms ease; &:focus { @@ -131,36 +136,27 @@ .search-box__clear { border: 1px solid var(--color-border-primary); background: var(--color-surface-primary); - border-radius: var(--radius-lg); + border-radius: var(--radius-md); padding: var(--space-1) var(--space-2); cursor: pointer; color: var(--color-text-primary); + font-size: var(--font-size-sm, 0.75rem); &:hover { background: var(--color-surface-secondary); } } -.filters { - display: flex; - gap: var(--space-4); - flex-wrap: wrap; - align-items: center; -} - -.filter-group__label { - display: block; - font-size: var(--font-size-xs); - color: var(--color-text-muted); - margin-bottom: var(--space-1); -} - -.filter-group__select { - padding: var(--space-2) var(--space-2); +.toolbar-select { + height: 32px; + padding: 0 var(--space-3); border: 1px solid var(--color-border-primary); - border-radius: var(--radius-lg); + border-radius: var(--radius-md); background: var(--color-surface-primary); color: var(--color-text-primary); + font-size: var(--font-size-sm, 0.75rem); + cursor: pointer; + flex-shrink: 0; transition: border-color 150ms ease, box-shadow 150ms ease; &:focus { @@ -417,11 +413,11 @@ } .btn--primary { - background: var(--color-btn-primary-bg, var(--color-brand-primary)); - color: var(--color-btn-primary-text, var(--color-text-inverse)); + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); &:hover { - background: var(--color-brand-primary-hover); + background: var(--color-btn-primary-bg-hover); } } @@ -465,12 +461,16 @@ } .triage-artifacts__toolbar { - flex-direction: column; - align-items: stretch; + flex-wrap: wrap; + } + + .lane-switcher { + flex-basis: 100%; } .search-box { - min-width: 100%; + flex: 1 1 0; + min-width: 120px; } .bulk-bar { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts index fc444643a..7819a6c86 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-artifacts.component.ts @@ -21,6 +21,7 @@ import { TriageLaneStateService, type TriageArtifactLane, } from './services/triage-lane-state.service'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; type SortField = 'artifact' | 'open' | 'total' | 'maxSeverity' | 'lastScan'; type SortOrder = 'asc' | 'desc'; @@ -59,7 +60,7 @@ export interface TriageArtifactRow { @Component({ selector: 'app-triage-artifacts', - imports: [AiCodeGuardBadgeComponent, ErrorStateComponent], + imports: [AiCodeGuardBadgeComponent, ErrorStateComponent, LoadingStateComponent], templateUrl: './triage-artifacts.component.html', styleUrls: ['./triage-artifacts.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundle-new.component.scss b/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundle-new.component.scss index 1449ca03a..c5f372f05 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundle-new.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundle-new.component.scss @@ -16,7 +16,7 @@ .back { display: inline-block; - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; margin-bottom: var(--space-1); @@ -55,7 +55,7 @@ .step--active { border-color: var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); } .panel { @@ -162,11 +162,11 @@ } .btn--primary { - background: var(--color-brand-primary); - color: var(--color-text-inverse); + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); &:hover { - background: var(--color-brand-primary-hover); + background: var(--color-btn-primary-bg-hover); } } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.html b/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.html index 56d733650..2a3492af7 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.html +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.html @@ -15,7 +15,7 @@ } @if (loading()) { -
    Loading bundles...
    + } @else if (completedBundles().length === 0) {
    No bundles created yet.
    } @else { diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.scss b/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.scss index a1eb42d1c..96fdcf64d 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.scss @@ -126,11 +126,11 @@ } .btn--primary { - background: var(--color-brand-primary); - color: var(--color-text-inverse); + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); &:hover { - background: var(--color-brand-primary-hover); + background: var(--color-btn-primary-bg-hover); } } diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.ts b/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.ts index 3389bee6e..d79f77fb6 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-audit-bundles.component.ts @@ -5,10 +5,11 @@ import { firstValueFrom } from 'rxjs'; import { AUDIT_BUNDLES_API, type AuditBundlesApi } from '../../core/api/audit-bundles.client'; import type { AuditBundleJobResponse } from '../../core/api/audit-bundles.models'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; @Component({ selector: 'app-triage-audit-bundles', - imports: [RouterLink], + imports: [RouterLink, LoadingStateComponent], templateUrl: './triage-audit-bundles.component.html', styleUrls: ['./triage-audit-bundles.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html index 488964548..f79c3675b 100644 --- a/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html +++ b/src/Web/StellaOps.Web/src/app/features/triage/triage-workspace.component.html @@ -26,10 +26,7 @@ @if (gatingLoading()) { -
    - - Loading gating summary... -
    + } @else if (gatingError()) { @if (loading()) { -
    Loading findings...
    + } @else if (findings().length === 0) {
    No findings for this artifact.
    } @else { @@ -269,10 +266,7 @@ > @if (evidenceLoading()) { -
    - - Loading unified evidence... -
    + } @if (evidenceError()) { } @else { -
    Target
    +
    @@ -313,7 +313,7 @@ import { Certificate, CertificateChain, CertificateStatus, RegisterCertificateRe } .header-actions, .form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } .certificate-inventory__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; } - .filter-field { display: grid; gap: 0.25rem; min-width: 15rem; } + .filter-field { display: grid; gap: 0.25rem; min-width: 0; flex: 1 1 14rem; max-width: 22rem; } .filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; } .filter-field input, .filter-field select, .filter-field textarea { padding: 0.55rem 0.75rem; @@ -429,20 +429,8 @@ import { Certificate, CertificateChain, CertificateStatus, RegisterCertificateRe background-size: 200% 100%; animation: shimmer 1.5s ease-in-out infinite; } - .certificate-table { width: 100%; border-collapse: collapse; } - .certificate-table th, .certificate-table td { - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--color-border-primary); - text-align: left; - vertical-align: middle; - font-size: 0.875rem; - } + /* Table styling provided by global .stella-table class */ .certificate-table th { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--color-text-secondary); - background: var(--color-surface-primary); position: sticky; top: 0; z-index: 1; @@ -450,12 +438,6 @@ import { Certificate, CertificateChain, CertificateStatus, RegisterCertificateRe .certificate-table tbody tr { transition: background-color 0.15s ease; } - .certificate-table tbody tr:nth-child(even) { - background: rgba(255, 255, 255, 0.015); - } - .certificate-table tbody tr:hover { - background: rgba(34, 211, 238, 0.05); - } .certificate-table tr.is-selected { background: rgba(34, 211, 238, 0.08); } .serial-cell { display: grid; gap: 0.2rem; } .serial-cell span { color: var(--color-text-secondary); font-family: monospace; font-size: 0.8rem; } diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/issuer-trust-list.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/issuer-trust-list.component.ts index 2aa70144c..0ded9b212 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/issuer-trust-list.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/issuer-trust-list.component.ts @@ -154,7 +154,7 @@ type IssuerMutationKind = 'block' | 'unblock';

    Add a trusted publisher before relying on external advisory or attestation content.

    } @else { -
    Serial Number
    +
    @@ -296,7 +296,7 @@ type IssuerMutationKind = 'block' | 'unblock'; } .header-actions, .form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } .issuer-list__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; } - .filter-field { display: grid; gap: 0.25rem; min-width: 15rem; } + .filter-field { display: grid; gap: 0.25rem; min-width: 0; flex: 1 1 14rem; max-width: 22rem; } .filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; } .filter-field input, .filter-field select, .filter-field textarea { padding: 0.55rem 0.75rem; @@ -412,20 +412,8 @@ type IssuerMutationKind = 'block' | 'unblock'; background-size: 200% 100%; animation: shimmer 1.5s ease-in-out infinite; } - .issuer-table { width: 100%; border-collapse: collapse; } - .issuer-table th, .issuer-table td { - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--color-border-primary); - text-align: left; - vertical-align: middle; - font-size: 0.875rem; - } + /* Table styling provided by global .stella-table class */ .issuer-table th { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--color-text-secondary); - background: var(--color-surface-primary); position: sticky; top: 0; z-index: 1; @@ -433,12 +421,6 @@ type IssuerMutationKind = 'block' | 'unblock'; .issuer-table tbody tr { transition: background-color 0.15s ease; } - .issuer-table tbody tr:nth-child(even) { - background: rgba(255, 255, 255, 0.015); - } - .issuer-table tbody tr:hover { - background: rgba(34, 211, 238, 0.05); - } .issuer-table tr.is-selected { background: rgba(34, 211, 238, 0.08); } .issuer-table a { color: var(--color-status-info); word-break: break-word; } .actions-cell { white-space: nowrap; } diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/signing-key-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/signing-key-dashboard.component.ts index 385b4a168..6ad3f4730 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/signing-key-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/signing-key-dashboard.component.ts @@ -153,7 +153,7 @@ type KeyMutationKind = 'rotate' | 'revoke';

    Register the first key to enable evidence signing and release approvals.

    } @else { -
    Name
    +
    @@ -300,7 +300,7 @@ type KeyMutationKind = 'rotate' | 'revoke'; } .header-actions, .form-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; } .key-dashboard__filters { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; } - .filter-field { display: grid; gap: 0.25rem; min-width: 15rem; } + .filter-field { display: grid; gap: 0.25rem; min-width: 0; flex: 1 1 14rem; max-width: 22rem; } .filter-field span { font-size: 0.78rem; color: var(--color-text-secondary); text-transform: uppercase; } .filter-field input, .filter-field select, .filter-field textarea { padding: 0.55rem 0.75rem; @@ -424,20 +424,8 @@ type KeyMutationKind = 'rotate' | 'revoke'; background-size: 200% 100%; animation: shimmer 1.5s ease-in-out infinite; } - .key-table { width: 100%; border-collapse: collapse; } - .key-table th, .key-table td { - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--color-border-primary); - text-align: left; - vertical-align: middle; - font-size: 0.875rem; - } + /* Table styling provided by global .stella-table class */ .key-table th { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.03em; - color: var(--color-text-secondary); - background: var(--color-surface-primary); position: sticky; top: 0; z-index: 1; @@ -445,12 +433,6 @@ type KeyMutationKind = 'rotate' | 'revoke'; .key-table tbody tr { transition: background-color 0.15s ease; } - .key-table tbody tr:nth-child(even) { - background: rgba(255, 255, 255, 0.015); - } - .key-table tbody tr:hover { - background: rgba(34, 211, 238, 0.05); - } .key-table tr.is-selected { background: rgba(34, 211, 238, 0.08); } .actions-cell { white-space: nowrap; } .link-button, .btn-sm, .btn-primary, .btn-secondary, .btn-link { diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts index 2de789abc..d003fe7a7 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.component.ts @@ -6,16 +6,16 @@ import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit, DestroyRef } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { Router, RouterLink, RouterOutlet, NavigationEnd } from '@angular/router'; +import { ActivatedRoute, Router, RouterOutlet, NavigationEnd } from '@angular/router'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { filter } from 'rxjs'; import { TRUST_API, TrustApi } from '../../core/api/trust.client'; import { TrustAdministrationOverview } from '../../core/api/trust.models'; import { GlossaryTooltipDirective } from '../../shared/directives/glossary-tooltip.directive'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; export type TrustAdminTab = - | 'overview' | 'keys' | 'issuers' | 'certificates' @@ -25,7 +25,6 @@ export type TrustAdminTab = | 'incidents' | 'analytics'; const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ - 'overview', 'keys', 'issuers', 'certificates', @@ -36,9 +35,52 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ 'analytics', ]; +const TRUST_PAGE_TABS: readonly StellaPageTab[] = [ + { + id: 'keys', + label: 'Signing Keys', + icon: 'M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4', + }, + { + id: 'issuers', + label: 'Trusted Issuers', + icon: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2|||M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z|||M20 8v6|||M23 11h-6', + }, + { + id: 'certificates', + label: 'Certificates', + icon: 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z', + }, + { + id: 'watchlist', + label: 'Watchlist', + icon: 'M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z|||M12 12m-3 0a3 3 0 1 0 6 0 3 3 0 1 0-6 0', + }, + { + id: 'audit', + label: 'Audit Log', + icon: 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z|||M14 2v6h6|||M16 13H8|||M16 17H8|||M10 9H8', + }, + { + id: 'airgap', + label: 'Air-Gap', + icon: 'M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z|||M13 14l-2 2 2 2', + }, + { + id: 'incidents', + label: 'Incidents', + icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01', + }, + { + id: 'analytics', + label: 'Analytics', + icon: 'M18 20V10|||M12 20V4|||M6 20v-6', + }, +]; + @Component({ selector: 'app-trust-admin', - imports: [CommonModule, RouterLink, RouterOutlet, GlossaryTooltipDirective], + imports: [CommonModule, RouterOutlet, GlossaryTooltipDirective, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` `, styles: [` @@ -410,71 +357,8 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ color: var(--color-text-secondary); } - .trust-admin__tabs { - display: flex; - gap: 0; - border-bottom: 1px solid var(--color-border-primary); - margin-bottom: 1.5rem; - overflow-x: auto; - scrollbar-width: none; - } - - .trust-admin__tabs::-webkit-scrollbar { - display: none; - } - - .trust-admin__tab { - display: inline-flex; - align-items: center; - gap: 0.4rem; - padding: 0.625rem 1rem; - color: var(--color-text-muted); - text-decoration: none; - border-bottom: 2px solid transparent; - font-size: 0.875rem; - font-weight: var(--font-weight-medium); - white-space: nowrap; - position: relative; - transition: color 0.15s ease, border-color 0.15s ease; - } - - .trust-admin__tab:hover { - color: var(--color-text-primary); - background: rgba(255, 255, 255, 0.03); - } - - .trust-admin__tab--active { - color: var(--color-status-info); - border-bottom-color: var(--color-status-info); - } - - .tab-badge { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 1.25rem; - height: 1.25rem; - padding: 0 0.35rem; - border-radius: var(--radius-full); - font-size: 0.7rem; - font-weight: var(--font-weight-semibold); - } - - .tab-badge--warning { - background: rgba(251, 191, 36, 0.2); - color: var(--color-status-warning-border); - } - - .tab-badge--danger { - background: rgba(239, 68, 68, 0.2); - color: var(--color-status-error); - } - - .trust-admin__content { - background: var(--color-surface-elevated); - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-xl); - min-height: 400px; + stella-page-tabs { + margin-top: 0.5rem; } /* Skeleton loading animation */ @@ -535,6 +419,7 @@ const TRUST_ADMIN_TABS: readonly TrustAdminTab[] = [ export class TrustAdminComponent implements OnInit { private readonly trustApi = inject(TRUST_API); private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); // State @@ -542,7 +427,7 @@ export class TrustAdminComponent implements OnInit { readonly refreshing = signal(false); readonly error = signal(null); readonly overview = signal(null); - readonly activeTab = signal('overview'); + readonly activeTab = signal('keys'); readonly workspaceLabel = signal<'Setup' | 'Administration'>('Setup'); // Computed @@ -556,6 +441,16 @@ export class TrustAdminComponent implements OnInit { (this.overview()?.signals ?? []).filter((signal) => signal.status === 'warning').length ); + readonly pageTabs = computed(() => { + const warnCount = this.warningSignalCount(); + return TRUST_PAGE_TABS.map(tab => { + if ((tab.id === 'keys' || tab.id === 'certificates') && warnCount > 0) { + return { ...tab, badge: warnCount, status: 'warn' as const, statusHint: `${warnCount} warning signal(s)` }; + } + return tab; + }); + }); + ngOnInit(): void { this.loadDashboard(); this.setActiveTabFromUrl(this.router.url); @@ -602,21 +497,26 @@ export class TrustAdminComponent implements OnInit { }); } + onTabChange(tabId: string): void { + this.activeTab.set(tabId as TrustAdminTab); + this.router.navigate([tabId], { relativeTo: this.route, queryParamsHandling: 'merge' }); + } + private setActiveTabFromUrl(url: string): void { const segments = url.split('?')[0].split('/').filter(Boolean); const routeRoot = segments[0]; const path = segments.includes('watchlist') ? 'watchlist' : segments.at(-1) === 'trust-signing' - ? 'overview' - : segments.at(-1) ?? 'overview'; + ? 'keys' + : segments.at(-1) ?? 'keys'; this.workspaceLabel.set(routeRoot === 'administration' ? 'Administration' : 'Setup'); if (TRUST_ADMIN_TABS.includes(path as TrustAdminTab)) { this.activeTab.set(path as TrustAdminTab); } else { - this.activeTab.set('overview'); + this.activeTab.set('keys'); } } } diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.routes.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.routes.ts index b64b78077..d37c68b21 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.routes.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-admin.routes.ts @@ -29,19 +29,8 @@ export const trustAdminRoutes: Routes = [ children: [ { path: '', - loadComponent: () => - import('./trust-overview.component').then( - (m) => m.TrustOverviewComponent - ), - data: { requiredScopes: [StellaOpsScopes.SIGNER_READ] }, - }, - { - path: 'overview', - loadComponent: () => - import('./trust-overview.component').then( - (m) => m.TrustOverviewComponent - ), - data: { requiredScopes: [StellaOpsScopes.SIGNER_READ] }, + redirectTo: 'keys', + pathMatch: 'full', }, { path: 'keys', diff --git a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-overview.component.ts b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-overview.component.ts index ac8a060ed..610b61790 100644 --- a/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-overview.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/trust-admin/trust-overview.component.ts @@ -110,7 +110,7 @@ import { RouterLink } from '@angular/router'; } .trust-overview__card a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; font-weight: var(--font-weight-medium); transition: color 0.15s ease; @@ -154,8 +154,8 @@ import { RouterLink } from '@angular/router'; align-items: center; gap: 0.35rem; padding: 0.45rem 1rem; - background: var(--color-brand-primary); - color: #fff; + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); border-radius: var(--radius-md); font-size: 0.85rem; font-weight: var(--font-weight-medium); diff --git a/src/Web/StellaOps.Web/src/app/features/unknowns-tracking/unknowns-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/unknowns-tracking/unknowns-dashboard.component.ts index d3836758d..0a42de42b 100644 --- a/src/Web/StellaOps.Web/src/app/features/unknowns-tracking/unknowns-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/unknowns-tracking/unknowns-dashboard.component.ts @@ -13,10 +13,12 @@ import { UNKNOWN_STATUS_COLORS, getConfidenceColor, } from '../../core/api/unknowns.models'; +import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component'; +import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component'; @Component({ selector: 'app-unknowns-dashboard', - imports: [RouterModule, FormsModule], + imports: [RouterModule, FormsModule, StellaMetricCardComponent, StellaMetricGridComponent], template: `
    @@ -53,28 +55,38 @@ import { } @if (stats()) { -
    -
    - Total -

    {{ stats()!.total }}

    -
    -
    - Binaries -

    {{ stats()!.byType?.binary ?? 0 }}

    -
    -
    - Symbols -

    {{ stats()!.byType?.symbol ?? 0 }}

    -
    -
    - Resolution Rate -

    {{ (stats()!.resolutionRate ?? 0).toFixed(1) }}%

    -
    -
    - Avg Confidence -

    {{ (stats()!.avgConfidence ?? 0).toFixed(0) }}%

    -
    -
    + + + + + + + }
    @@ -100,7 +112,7 @@ import {
    } @else { -
    Alias
    +
    @@ -177,36 +189,7 @@ import { box-shadow: 0 0 0 2px var(--color-focus-ring, rgba(59, 130, 246, 0.25)); } - /* Stats cards */ - .stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); - gap: 0.5rem; - } - .stat-card { - border: 1px solid var(--color-border-primary); - border-radius: var(--radius-md); - background: var(--color-surface-primary); - padding: 0.65rem; - transition: transform 150ms ease, box-shadow 150ms ease; - } - .stat-card:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0,0,0,0.08); - } - .stat-label { - font-size: 0.68rem; - text-transform: uppercase; - letter-spacing: 0.02em; - color: var(--color-text-secondary); - } - .stat-value { - margin: 0.15rem 0 0; - font-size: 1.15rem; - font-weight: 700; - } - .stat-value--success { color: var(--color-status-success-text, #16a34a); } - .stat-value--info { color: var(--color-brand-primary); } + /* Stats cards are now handled by stella-metric-card / stella-metric-grid */ /* Filters */ .filters-bar { @@ -240,39 +223,8 @@ import { background: var(--color-surface-primary); overflow: auto; } - .unknowns-table { - width: 100%; - border-collapse: collapse; - font-size: 0.78rem; - } - .unknowns-table th { - text-align: left; - padding: 0.5rem 0.75rem; - font-size: 0.68rem; - font-weight: 600; - color: var(--color-text-secondary); - text-transform: uppercase; - letter-spacing: 0.02em; - border-bottom: 1px solid var(--color-border-primary); - position: sticky; - top: 0; - background: var(--color-surface-secondary); - white-space: nowrap; - } - .unknowns-table td { - padding: 0.5rem 0.75rem; - border-bottom: 1px solid var(--color-border-primary); - vertical-align: middle; - } - .unknowns-table tbody tr:nth-child(even) { - background: var(--color-surface-secondary); - } - .unknowns-table tbody tr:hover { - background: var(--color-nav-hover, var(--color-surface-tertiary)); - } - .unknowns-table tbody tr:last-child td { - border-bottom: none; - } + /* Table base styling handled by global .stella-table */ + .unknowns-table th { position: sticky; top: 0; } .text-right { text-align: right; } /* Badges */ @@ -311,7 +263,7 @@ import { font-weight: 500; } .action-link { - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.76rem; text-decoration: none; font-weight: 500; @@ -326,7 +278,7 @@ import { /* Shimmer skeleton */ .skeleton-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + grid-template-columns: repeat(auto-fill, 184px); gap: 0.5rem; } .skeleton-card { @@ -394,7 +346,7 @@ import { padding: 0.4rem 0.85rem; font-size: 0.82rem; font-weight: 500; - color: var(--color-brand-primary); + color: var(--color-text-link); border: 1px solid var(--color-brand-primary); border-radius: var(--radius-md); text-decoration: none; diff --git a/src/Web/StellaOps.Web/src/app/features/unknowns/grey-queue-panel.component.spec.ts b/src/Web/StellaOps.Web/src/app/features/unknowns/grey-queue-panel.component.spec.ts index 0dc7ebc7b..6558a6a16 100644 --- a/src/Web/StellaOps.Web/src/app/features/unknowns/grey-queue-panel.component.spec.ts +++ b/src/Web/StellaOps.Web/src/app/features/unknowns/grey-queue-panel.component.spec.ts @@ -5,8 +5,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { GreyQueuePanelComponent } from './grey-queue-panel.component'; import { PolicyUnknown, - BAND_COLORS, - OBSERVATION_STATE_COLORS, } from '../../core/api/unknowns.models'; describe('GreyQueuePanelComponent', () => { @@ -76,24 +74,28 @@ describe('GreyQueuePanelComponent', () => { }); describe('band display', () => { - it('should display HOT band with correct color', () => { - expect(component.getBandColor()).toBe(BAND_COLORS['hot']); + it('should display HOT band label', () => { expect(component.getBandLabel()).toBe('HOT'); }); - it('should display WARM band with correct color', () => { + it('should display WARM band label', () => { component.unknown = { ...mockUnknown, band: 'warm' }; fixture.detectChanges(); - expect(component.getBandColor()).toBe(BAND_COLORS['warm']); expect(component.getBandLabel()).toBe('WARM'); }); - it('should display COLD band with correct color', () => { + it('should display COLD band label', () => { component.unknown = { ...mockUnknown, band: 'cold' }; fixture.detectChanges(); - expect(component.getBandColor()).toBe(BAND_COLORS['cold']); expect(component.getBandLabel()).toBe('COLD'); }); + + it('should render band badge with correct CSS class', () => { + fixture.detectChanges(); + const badge = fixture.nativeElement.querySelector('.band-badge'); + expect(badge).toBeTruthy(); + expect(badge.classList.contains('band-badge--hot')).toBe(true); + }); }); describe('observation state', () => { @@ -158,17 +160,10 @@ describe('GreyQueuePanelComponent', () => { expect(component.showConflicts()).toBe(false); }); - it('should return correct severity color for high severity', () => { - expect(component.getConflictSeverityColor()).toBe('text-red-600'); - }); - - it('should return correct severity color for medium severity', () => { - component.unknown = { - ...mockUnknown, - conflictInfo: { ...mockUnknown.conflictInfo!, severity: 0.6 }, - }; + it('should render conflict items in the template', () => { fixture.detectChanges(); - expect(component.getConflictSeverityColor()).toBe('text-orange-600'); + const conflictItems = fixture.nativeElement.querySelectorAll('.conflict-item'); + expect(conflictItems.length).toBe(1); }); }); diff --git a/src/Web/StellaOps.Web/src/app/features/unknowns/grey-queue-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/unknowns/grey-queue-panel.component.ts index cdb0e7647..c329dffba 100644 --- a/src/Web/StellaOps.Web/src/app/features/unknowns/grey-queue-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/unknowns/grey-queue-panel.component.ts @@ -5,75 +5,55 @@ import { Component, Input, Output, EventEmitter, computed } from '@angular/core' import { CommonModule, DatePipe } from '@angular/common'; import { PolicyUnknown, - ReanalysisTrigger, - ConflictInfo, TriageAction, - BAND_COLORS, BAND_LABELS, - OBSERVATION_STATE_COLORS, OBSERVATION_STATE_LABELS, - TRIAGE_ACTION_LABELS, isGreyQueueState, hasConflicts, - getConflictSeverityColor, } from '../../core/api/unknowns.models'; @Component({ selector: 'stella-grey-queue-panel', imports: [CommonModule, DatePipe], template: ` -
    +
    -
    -
    - +
    +
    + {{ getBandLabel() }} @if (unknown.observationState) { - + {{ getObservationStateLabel() }} } @if (isInGreyQueue()) { - - Grey Queue - + Grey Queue }
    -
    - Score: {{ unknown.score | number: '1.1-1' }} +
    + Score: {{ unknown.score | number: '1.1-1' }}
    @if (unknown.fingerprintId) { -
    -

    Fingerprint

    - - {{ unknown.fingerprintId }} - +
    +

    Fingerprint

    + {{ unknown.fingerprintId }}
    } @if (unknown.triggers && unknown.triggers.length > 0) { -
    -

    - Triggers ({{ unknown.triggers.length }}) -

    -
    +
    +

    Triggers ({{ unknown.triggers.length }})

    +
    @for (trigger of sortedTriggers(); track trigger.receivedAt + ':' + trigger.eventType + ':' + trigger.eventVersion + ':' + (trigger.source ?? '')) { -
    - - {{ trigger.eventType }}@{{ trigger.eventVersion }} - - - {{ trigger.receivedAt | date: 'short' }} - +
    + {{ trigger.eventType }}@{{ trigger.eventVersion }} + {{ trigger.receivedAt | date: 'short' }}
    }
    @@ -82,29 +62,25 @@ import { @if (showConflicts()) { -
    -

    - ! +
    +

    + Conflicts - - (Severity: {{ unknown.conflictInfo!.severity | number: '1.2-2' }}) - + (Severity: {{ unknown.conflictInfo!.severity | number: '1.2-2' }})

    -
    +
    @for (conflict of unknown.conflictInfo!.conflicts; track $index) { -
    -
    {{ conflict.type }}
    -
    - {{ conflict.signal1 }} vs {{ conflict.signal2 }} -
    +
    +
    {{ conflict.type }}
    +
    {{ conflict.signal1 }} vs {{ conflict.signal2 }}
    @if (conflict.description) { -
    {{ conflict.description }}
    +
    {{ conflict.description }}
    }
    } @if (unknown.conflictInfo!.suggestedPath) { -
    - Suggested: {{ unknown.conflictInfo!.suggestedPath }} +
    + Suggested: {{ unknown.conflictInfo!.suggestedPath }}
    }
    @@ -113,13 +89,11 @@ import { @if (unknown.nextActions && unknown.nextActions.length > 0) { -
    -

    Next Actions

    -
    +
    +

    Next Actions

    +
    @for (action of unknown.nextActions; track action) { - - {{ formatAction(action) }} - + {{ formatAction(action) }} }
    @@ -127,51 +101,245 @@ import { @if (showTriageActions) { -
    -

    Triage Actions

    -
    - - - - - +
    +

    Triage Actions

    +
    + + + + +
    }
    `, - styles: [ - ` + styles: [` .grey-queue-panel { min-width: 320px; + padding: 1.25rem; + border: 1px solid var(--color-border-primary); + border-radius: var(--radius-lg); + background: var(--color-surface-primary); } - `, - ] + + .panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1rem; + } + + .header-badges { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .band-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + border-radius: var(--radius-full); + font-size: 0.8125rem; + font-weight: var(--font-weight-medium); + border: 1px solid var(--color-border-primary); + background: var(--color-surface-secondary); + } + .band-badge--hot { + background: var(--color-status-error-bg); + color: var(--color-status-error-text); + border-color: var(--color-status-error-text); + } + .band-badge--warm { + background: var(--color-status-warning-bg); + color: var(--color-status-warning-text); + border-color: var(--color-status-warning-text); + } + .band-badge--cold { + background: var(--color-status-info-bg); + color: var(--color-status-info-text); + border-color: var(--color-status-info-text); + } + + .obs-badge { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: var(--font-weight-medium); + background: var(--color-surface-secondary); + color: var(--color-text-secondary); + } + + .grey-badge { + font-size: 0.75rem; + font-weight: var(--font-weight-medium); + color: var(--color-text-link); + } + + .score-display { + font-size: 0.875rem; + color: var(--color-text-secondary); + } + + .section { + margin-bottom: 1rem; + } + + .section-title { + margin: 0 0 0.5rem; + font-size: 0.875rem; + font-weight: var(--font-weight-semibold); + color: var(--color-text-secondary); + } + + .section-title--conflict { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .conflict-icon { + color: var(--color-status-error-text); + font-weight: var(--font-weight-bold); + } + + .conflict-severity { + font-size: 0.75rem; + color: var(--color-status-error-text); + } + + .fingerprint-code { + display: block; + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.75rem; + background: var(--color-surface-secondary); + padding: 0.375rem 0.5rem; + border-radius: var(--radius-sm); + word-break: break-all; + } + + .trigger-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + max-height: 8rem; + overflow-y: auto; + } + + .trigger-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.375rem 0.5rem; + background: var(--color-surface-secondary); + border-radius: var(--radius-sm); + font-size: 0.75rem; + } + + .trigger-name { + font-weight: var(--font-weight-medium); + } + + .trigger-date { + color: var(--color-text-secondary); + } + + .conflict-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .conflict-item { + padding: 0.5rem; + background: var(--color-status-error-bg); + border: 1px solid var(--color-status-error-text); + border-radius: var(--radius-sm); + font-size: 0.75rem; + } + + .conflict-type { + font-weight: var(--font-weight-medium); + color: var(--color-status-error-text); + } + + .conflict-signals { + color: var(--color-status-error-text); + } + + .conflict-desc { + margin-top: 0.25rem; + color: var(--color-text-secondary); + } + + .conflict-suggestion { + font-size: 0.75rem; + color: var(--color-text-secondary); + } + + .action-chips { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + } + + .action-chip { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.5rem; + background: var(--color-status-info-bg); + color: var(--color-status-info-text); + border-radius: var(--radius-sm); + font-size: 0.75rem; + } + + .triage-section { + padding-top: 1rem; + margin-top: 1rem; + border-top: 1px solid var(--color-border-primary); + } + + .triage-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .triage-btn { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + border-radius: var(--radius-sm); + border: 1px solid; + cursor: pointer; + font-weight: var(--font-weight-medium); + transition: opacity 150ms ease; + } + .triage-btn:hover { opacity: 0.85; } + + .triage-btn--success { + background: var(--color-status-success-bg); + color: var(--color-status-success-text); + border-color: var(--color-status-success-text); + } + .triage-btn--error { + background: var(--color-status-error-bg); + color: var(--color-status-error-text); + border-color: var(--color-status-error-text); + } + .triage-btn--warning { + background: var(--color-status-warning-bg); + color: var(--color-status-warning-text); + border-color: var(--color-status-warning-text); + } + .triage-btn--info { + background: var(--color-status-info-bg); + color: var(--color-status-info-text); + border-color: var(--color-status-info-text); + } + `] }) export class GreyQueuePanelComponent { @Input({ required: true }) unknown!: PolicyUnknown; @@ -201,19 +369,10 @@ export class GreyQueuePanelComponent { }); }); - getBandColor(): string { - return BAND_COLORS[this.unknown.band] || 'bg-gray-100 text-gray-800'; - } - getBandLabel(): string { return BAND_LABELS[this.unknown.band] || this.unknown.band.toUpperCase(); } - getObservationStateColor(): string { - if (!this.unknown.observationState) return ''; - return OBSERVATION_STATE_COLORS[this.unknown.observationState] || ''; - } - getObservationStateLabel(): string { if (!this.unknown.observationState) return ''; return OBSERVATION_STATE_LABELS[this.unknown.observationState] || this.unknown.observationState; @@ -227,11 +386,6 @@ export class GreyQueuePanelComponent { return hasConflicts(this.unknown); } - getConflictSeverityColor(): string { - if (!this.unknown.conflictInfo) return ''; - return getConflictSeverityColor(this.unknown.conflictInfo.severity); - } - formatAction(action: string): string { // Convert snake_case to Title Case return action diff --git a/src/Web/StellaOps.Web/src/app/features/unknowns/unknowns-budget-widget.component.ts b/src/Web/StellaOps.Web/src/app/features/unknowns/unknowns-budget-widget.component.ts index 381451a71..fde9c78b5 100644 --- a/src/Web/StellaOps.Web/src/app/features/unknowns/unknowns-budget-widget.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/unknowns/unknowns-budget-widget.component.ts @@ -155,7 +155,7 @@ const REASON_SHORT_CODES: Record = { font-weight: var(--font-weight-medium); text-transform: uppercase; background: var(--color-brand-light); - color: var(--color-brand-primary); + color: var(--color-text-link); } .budget-meter { diff --git a/src/Web/StellaOps.Web/src/app/features/unknowns/unknowns-queue.component.ts b/src/Web/StellaOps.Web/src/app/features/unknowns/unknowns-queue.component.ts index 590f18f4f..448adae60 100644 --- a/src/Web/StellaOps.Web/src/app/features/unknowns/unknowns-queue.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/unknowns/unknowns-queue.component.ts @@ -20,8 +20,16 @@ import { EscalateUnknownRequest, ResolveUnknownRequest } from '../../core/api/unknowns.models'; +import { StellaPageTabsComponent, StellaPageTab } from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type TabId = 'hot' | 'warm' | 'cold' | 'all'; + +const UNKNOWNS_QUEUE_TABS: StellaPageTab[] = [ + { id: 'hot', label: 'Hot', icon: 'M13 2L3 14h9l-1 8 10-12h-9l1-8z', status: 'error' }, + { id: 'warm', label: 'Warm', icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z|||M12 9v4|||M12 17h.01', status: 'warn' }, + { id: 'cold', label: 'Cold', icon: 'M22 12h-4l-3 9L9 3l-3 9H2' }, + { id: 'all', label: 'All', icon: 'M8 6h13|||M8 12h13|||M8 18h13|||M3 6h.01|||M3 12h.01|||M3 18h.01' }, +]; type SortField = 'rank' | 'age' | 'occurrenceCount'; type SortDirection = 'asc' | 'desc'; @@ -33,7 +41,7 @@ interface SortConfig { @Component({ selector: 'stella-unknowns-queue', standalone: true, - imports: [FormsModule], + imports: [FormsModule, StellaPageTabsComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
    @@ -62,23 +70,12 @@ interface SortConfig { - +
    @@ -366,26 +363,30 @@ interface SortConfig { } .unknowns-queue__tab { - padding: 0.75rem 1.5rem; + height: var(--color-tab-height, 48px); + padding: 0 1.5rem; border: none; - background: transparent; + background: var(--color-tab-bg, transparent); cursor: pointer; display: flex; align-items: center; gap: 0.5rem; border-bottom: 2px solid transparent; - color: var(--color-text-secondary); - transition: all 0.2s; + color: var(--color-tab-inactive-text); + font-size: 0.875rem; + font-weight: var(--font-weight-medium); + transition: color 0.15s, border-color 0.15s, background-color 0.15s; } .unknowns-queue__tab:hover { - background: var(--color-nav-hover); + color: var(--color-text-primary); + background: var(--color-tab-hover-bg); } .unknowns-queue__tab--active { - border-bottom-color: var(--color-brand-primary); - color: var(--color-brand-primary); - font-weight: var(--font-weight-medium); + border-bottom-color: var(--color-tab-active-border); + color: var(--color-tab-active-text); + font-weight: var(--font-weight-semibold, 600); } .unknowns-queue__tab-count { @@ -746,6 +747,7 @@ export class UnknownsQueueComponent implements OnInit, OnDestroy { readonly error = signal(null); readonly unknowns = signal([]); readonly summary = signal(null); + readonly UNKNOWNS_QUEUE_TABS = UNKNOWNS_QUEUE_TABS; readonly activeTab = signal('all'); readonly searchQuery = signal(''); readonly statusFilter = signal(''); diff --git a/src/Web/StellaOps.Web/src/app/features/verdicts/components/verdict-actions/verdict-actions.component.scss b/src/Web/StellaOps.Web/src/app/features/verdicts/components/verdict-actions/verdict-actions.component.scss index e9ca81a4b..7a8cd18ad 100644 --- a/src/Web/StellaOps.Web/src/app/features/verdicts/components/verdict-actions/verdict-actions.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/verdicts/components/verdict-actions/verdict-actions.component.scss @@ -33,7 +33,7 @@ &:hover:not(:disabled) { border-color: var(--color-brand-primary); background: var(--color-brand-primary-alpha); - color: var(--color-brand-primary); + color: var(--color-text-link); } &:focus-visible { diff --git a/src/Web/StellaOps.Web/src/app/features/verdicts/components/verdict-detail-panel/verdict-detail-panel.component.scss b/src/Web/StellaOps.Web/src/app/features/verdicts/components/verdict-detail-panel/verdict-detail-panel.component.scss index 7d09d8db9..48c8f5f99 100644 --- a/src/Web/StellaOps.Web/src/app/features/verdicts/components/verdict-detail-panel/verdict-detail-panel.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/verdicts/components/verdict-detail-panel/verdict-detail-panel.component.scss @@ -118,7 +118,7 @@ border: 1px solid var(--color-brand-primary); border-radius: var(--radius-md); background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); font-weight: var(--font-weight-medium); cursor: pointer; transition: background-color var(--motion-duration-fast) var(--motion-ease-default); diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-explain-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-explain-panel.component.ts index d46d666b7..fe25d7c6c 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-explain-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-explain-panel.component.ts @@ -423,10 +423,10 @@ import { AiExplainRequest, AiExplainResponse } from '../../core/api/advisory-ai. width: fit-content; } - .severity-badge--critical { background: var(--color-status-error-text); color: var(--color-status-error-border); } + .severity-badge--critical { background: var(--color-status-error-text); color: #fff; } .severity-badge--high { background: var(--color-severity-high); color: var(--color-severity-high-bg); } - .severity-badge--medium { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } - .severity-badge--low { background: var(--color-status-success-text); color: var(--color-status-success-border); } + .severity-badge--medium { background: var(--color-status-warning-text); color: #fff; } + .severity-badge--low { background: var(--color-status-success-text); color: #fff; } .cvss-score { font-family: ui-monospace, monospace; diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-remediate-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-remediate-panel.component.ts index b9c96ddae..874a7fff9 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-remediate-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/ai-remediate-panel.component.ts @@ -545,9 +545,9 @@ const DEFAULT_REMEDIATION_PR_PREFS = { flex-shrink: 0; } - .effort-trivial { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .effort-easy { background: var(--color-status-info-text); color: var(--color-status-info-border); } - .effort-moderate { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } + .effort-trivial { background: var(--color-status-success-text); color: #fff; } + .effort-easy { background: var(--color-status-info-text); color: #fff; } + .effort-moderate { background: var(--color-status-warning-text); color: #fff; } .effort-complex { background: var(--color-severity-high); color: var(--color-severity-high-border); } .btn-expand { @@ -865,12 +865,12 @@ const DEFAULT_REMEDIATION_PR_PREFS = { text-transform: uppercase; } - .pr-status--open, .pr-status--review_requested { background: var(--color-status-success-text); color: var(--color-status-success-border); } + .pr-status--open, .pr-status--review_requested { background: var(--color-status-success-text); color: #fff; } .pr-status--draft { background: var(--color-text-primary); color: var(--color-text-muted); } - .pr-status--approved { background: var(--color-status-info-text); color: var(--color-status-info-border); } - .pr-status--changes_requested { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } + .pr-status--approved { background: var(--color-status-info-text); color: #fff; } + .pr-status--changes_requested { background: var(--color-status-warning-text); color: #fff; } .pr-status--merged { background: var(--color-status-excepted); color: var(--color-status-excepted-border); } - .pr-status--closed { background: var(--color-status-error-text); color: var(--color-status-error-border); } + .pr-status--closed { background: var(--color-status-error-text); color: #fff; } .pr-info { flex: 1; @@ -899,9 +899,9 @@ const DEFAULT_REMEDIATION_PR_PREFS = { border-radius: var(--radius-sm); } - .ci-status--pending, .ci-status--running { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } - .ci-status--success { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .ci-status--failure { background: var(--color-status-error-text); color: var(--color-status-error-border); } + .ci-status--pending, .ci-status--running { background: var(--color-status-warning-text); color: #fff; } + .ci-status--success { background: var(--color-status-success-text); color: #fff; } + .ci-status--failure { background: var(--color-status-error-text); color: #fff; } .ci-status--skipped { background: var(--color-text-primary); color: var(--color-text-muted); } .btn-copy-link { diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-conflict-resolution.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-conflict-resolution.component.ts index 383a81b0e..ad251adbd 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-conflict-resolution.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-conflict-resolution.component.ts @@ -585,9 +585,9 @@ interface ConflictingStatement { text-transform: uppercase; } - .trust-indicator--high { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .trust-indicator--medium { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } - .trust-indicator--low { background: var(--color-status-error-text); color: var(--color-status-error-border); } + .trust-indicator--high { background: var(--color-status-success-text); color: #fff; } + .trust-indicator--medium { background: var(--color-status-warning-text); color: #fff; } + .trust-indicator--low { background: var(--color-status-error-text); color: #fff; } .card-body { padding: 1rem 1.25rem; diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-consensus.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-consensus.component.ts index 8aa5875f2..1edaebe16 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-consensus.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-consensus.component.ts @@ -523,10 +523,10 @@ import { text-transform: uppercase; } - .consensus-status--affected { background: var(--color-status-error-text); color: var(--color-status-error-border); } - .consensus-status--not_affected { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .consensus-status--fixed { background: var(--color-status-info-text); color: var(--color-status-info-border); } - .consensus-status--under_investigation { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } + .consensus-status--affected { background: var(--color-status-error-text); color: #fff; } + .consensus-status--not_affected { background: var(--color-status-success-text); color: #fff; } + .consensus-status--fixed { background: var(--color-status-info-text); color: #fff; } + .consensus-status--under_investigation { background: var(--color-status-warning-text); color: #fff; } .confidence-badge { display: flex; @@ -722,10 +722,10 @@ import { font-size: 0.875rem; } - .issuer-avatar--vendor { background: var(--color-status-info-text); color: var(--color-status-info-border); } + .issuer-avatar--vendor { background: var(--color-status-info-text); color: #fff; } .issuer-avatar--cert { background: var(--color-status-excepted); color: var(--color-status-excepted-border); } - .issuer-avatar--oss { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .issuer-avatar--researcher { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } + .issuer-avatar--oss { background: var(--color-status-success-text); color: #fff; } + .issuer-avatar--researcher { background: var(--color-status-warning-text); color: #fff; } .issuer-avatar--ai_generated { background: var(--color-status-excepted); color: var(--color-status-excepted-border); } .issuer-details { @@ -752,10 +752,10 @@ import { text-transform: uppercase; } - .vote-status--affected { background: var(--color-status-error-text); color: var(--color-status-error-border); } - .vote-status--not_affected { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .vote-status--fixed { background: var(--color-status-info-text); color: var(--color-status-info-border); } - .vote-status--under_investigation { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } + .vote-status--affected { background: var(--color-status-error-text); color: #fff; } + .vote-status--not_affected { background: var(--color-status-success-text); color: #fff; } + .vote-status--fixed { background: var(--color-status-info-text); color: #fff; } + .vote-status--under_investigation { background: var(--color-status-warning-text); color: #fff; } .vote-card__body { padding: 1rem; @@ -926,9 +926,9 @@ import { border-radius: var(--radius-sm); } - .conflict-severity--low { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } + .conflict-severity--low { background: var(--color-status-warning-text); color: #fff; } .conflict-severity--medium { background: var(--color-severity-high); color: var(--color-severity-high-border); } - .conflict-severity--high { background: var(--color-status-error-text); color: var(--color-status-error-border); } + .conflict-severity--high { background: var(--color-status-error-text); color: #fff; } .conflict-status { font-size: 0.75rem; diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub-dashboard.component.ts index e9367cd85..943efe51c 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub-dashboard.component.ts @@ -195,31 +195,25 @@ import {
    -

    Quick Actions

    -
    - - -
    @@ -354,11 +348,11 @@ import { height: 24px; } - .stat-card--total .stat-card__icon { background: var(--color-status-info-text); color: var(--color-status-info-border); } - .stat-card--affected .stat-card__icon { background: var(--color-status-error-text); color: var(--color-status-error-border); } - .stat-card--not-affected .stat-card__icon { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .stat-card--fixed .stat-card__icon { background: var(--color-status-info-text); color: var(--color-status-info-border); } - .stat-card--investigating .stat-card__icon { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } + .stat-card--total .stat-card__icon { background: var(--color-status-info-text); color: #fff; } + .stat-card--affected .stat-card__icon { background: var(--color-status-error-text); color: #fff; } + .stat-card--not-affected .stat-card__icon { background: var(--color-status-success-text); color: #fff; } + .stat-card--fixed .stat-card__icon { background: var(--color-status-info-text); color: #fff; } + .stat-card--investigating .stat-card__icon { background: var(--color-status-warning-text); color: #fff; } .stat-card__value { font-size: 2rem; @@ -482,8 +476,8 @@ import { height: 16px; } - .activity-item__icon--created { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .activity-item__icon--updated { background: var(--color-status-info-text); color: var(--color-status-info-border); } + .activity-item__icon--created { background: var(--color-status-success-text); color: #fff; } + .activity-item__icon--updated { background: var(--color-status-info-text); color: #fff; } .activity-item__icon--superseded { background: var(--color-text-primary); color: var(--color-text-muted); } .activity-item__content { @@ -510,58 +504,7 @@ import { color: var(--color-text-secondary); } - /* Quick Actions */ - .quick-actions { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 1rem; - } - - .quick-action { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.75rem; - padding: 1.5rem 1rem; - background: var(--color-text-primary); - border: 1px solid var(--color-text-primary); - border-radius: var(--radius-xl); - cursor: pointer; - transition: all 0.2s ease; - } - - .quick-action:hover { - background: var(--color-text-primary); - border-color: var(--color-text-secondary); - transform: translateY(-2px); - } - - .quick-action__icon { - width: 48px; - height: 48px; - border-radius: var(--radius-xl); - background: var(--color-text-heading); - display: flex; - align-items: center; - justify-content: center; - color: var(--color-status-info-border); - } - - .quick-action__icon svg { - width: 24px; - height: 24px; - } - - .quick-action__icon--ai { - background: linear-gradient(135deg, var(--color-status-info) 0%, var(--color-status-excepted) 100%); - color: white; - } - - .quick-action__label { - font-size: 0.875rem; - color: rgba(212, 201, 168, 0.3); - font-weight: var(--font-weight-medium); - } + /* Quick Actions — uses global .quick-links-row / .quick-link-pill */ /* Loading State */ .loading-state { diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub.component.ts index 6c0df07b7..cc0b49d9c 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-hub.component.ts @@ -26,12 +26,16 @@ import { VexIssuerType, } from '../../core/api/vex-hub.models'; import { AiConsentStatus } from '../../core/api/advisory-ai.models'; +import { + StellaPageTabsComponent, + StellaPageTab, +} from '../../shared/components/stella-page-tabs/stella-page-tabs.component'; type VexHubTab = 'search' | 'stats' | 'consensus'; @Component({ selector: 'app-vex-hub', - imports: [CommonModule, RouterModule], + imports: [CommonModule, RouterModule, StellaPageTabsComponent], template: `
    @@ -77,22 +81,11 @@ type VexHubTab = 'search' | 'stats' | 'consensus'; } -
    - - -
    + @if (activeTab() === 'search') { @@ -139,7 +132,7 @@ type VexHubTab = 'search' | 'stats' | 'consensus'; } @if (statements().length > 0) { -

    Type
    +
    @@ -335,6 +328,8 @@ type VexHubTab = 'search' | 'stats' | 'consensus'; } + + @if (error()) {
    {{ error() }}
    } @@ -352,9 +347,9 @@ type VexHubTab = 'search' | 'stats' | 'consensus'; color: var(--color-text-primary); padding: 1rem 1.5rem; border-radius: var(--radius-lg); margin-bottom: 1.5rem; } .consent-info { display: flex; align-items: center; gap: 0.75rem; } - .consent-icon { font-size: 1.5rem; color: var(--color-brand-primary); } + .consent-icon { font-size: 1.5rem; color: var(--color-text-link); } .btn-consent { - background: var(--color-brand-primary); color: var(--color-text-heading); border: none; padding: 0.5rem 1rem; + background: var(--color-btn-primary-bg); color: var(--color-text-heading); border: none; padding: 0.5rem 1rem; border-radius: var(--radius-sm); font-weight: var(--font-weight-semibold); cursor: pointer; } @@ -371,13 +366,6 @@ type VexHubTab = 'search' | 'stats' | 'consensus'; .stat-value { display: block; font-size: 1.5rem; font-weight: var(--font-weight-bold); color: var(--color-text-heading); } .stat-label { font-size: 0.75rem; color: var(--color-text-secondary); text-transform: uppercase; } - .tabs { display: flex; gap: 0.5rem; margin-bottom: 1rem; border-bottom: 1px solid var(--color-border-primary); } - .tab { - padding: 0.75rem 1.5rem; border: none; background: none; cursor: pointer; - font-size: 0.875rem; color: var(--color-text-secondary); border-bottom: 2px solid transparent; - } - .tab.active { color: var(--color-tab-active-text, var(--color-text-primary)); border-bottom-color: var(--color-tab-active-border, var(--color-brand-primary)); font-weight: 600; } - .search-filters { display: flex; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; } @@ -385,12 +373,10 @@ type VexHubTab = 'search' | 'stats' | 'consensus'; padding: 0.5rem; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); min-width: 150px; } .btn-search { - padding: 0.5rem 1rem; background: var(--color-brand-primary); color: var(--color-text-heading); + padding: 0.5rem 1rem; background: var(--color-btn-primary-bg); color: var(--color-text-heading); border: none; border-radius: var(--radius-sm); cursor: pointer; font-weight: var(--font-weight-medium); } - .btn-search:hover { background: var(--color-brand-primary-hover); } - - .statements-table { width: 100%; border-collapse: collapse; } + .btn-search:hover { background: var(--color-btn-primary-bg-hover); } .statements-table th, .statements-table td { padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--color-surface-secondary); } @@ -401,9 +387,9 @@ type VexHubTab = 'search' | 'stats' | 'consensus'; .status-badge { padding: 0.25rem 0.5rem; border-radius: var(--radius-sm); font-size: 0.75rem; font-weight: var(--font-weight-semibold); } - .status-affected { background: var(--color-status-error-border); color: var(--color-status-error-text); } - .status-not_affected { background: var(--color-status-success-border); color: var(--color-status-success-text); } - .status-fixed { background: var(--color-status-info-border); color: var(--color-status-info-text); } + .status-affected { background: var(--color-status-error-border); color: #fff; } + .status-not_affected { background: var(--color-status-success-border); color: #fff; } + .status-fixed { background: var(--color-status-info-border); color: #fff; } .status-under_investigation { background: var(--color-status-warning-border); color: var(--color-status-warning); } .source-badge { font-size: 0.75rem; color: var(--color-text-secondary); } @@ -455,9 +441,9 @@ type VexHubTab = 'search' | 'stats' | 'consensus'; .justification { background: var(--color-surface-secondary); padding: 0.75rem; border-radius: var(--radius-sm); margin: 0.5rem 0 0; } .evidence-list { margin: 0.5rem 0 0; padding-left: 1.5rem; } .panel-actions { padding: 1rem 1.5rem; border-top: 1px solid var(--color-surface-secondary); display: flex; gap: 0.5rem; } - .btn-ai { background: var(--color-brand-primary); color: var(--color-text-heading); border: none; padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; font-weight: var(--font-weight-medium); } - .btn-ai:hover { background: var(--color-brand-primary-hover); } - .btn-secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; } + .btn-ai { background: var(--color-btn-primary-bg); color: var(--color-text-heading); border: none; padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; font-weight: var(--font-weight-medium); } + .btn-ai:hover { background: var(--color-btn-primary-bg-hover); } + .btn-secondary { background: var(--color-btn-secondary-bg); border: 1px solid var(--color-btn-secondary-border); padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; } .consent-content { padding: 1.5rem; } .consent-content ul { margin: 0.5rem 0; padding-left: 1.5rem; } @@ -466,7 +452,7 @@ type VexHubTab = 'search' | 'stats' | 'consensus'; .checkbox-label { display: flex; align-items: center; gap: 0.5rem; margin-top: 0.75rem; cursor: pointer; } .consent-actions { padding: 1rem 1.5rem; border-top: 1px solid var(--color-surface-secondary); display: flex; justify-content: flex-end; gap: 0.5rem; } .btn-cancel { background: none; border: 1px solid var(--color-border-primary); padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; } - .btn-enable { background: var(--color-brand-primary); color: var(--color-text-heading); border: none; padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; font-weight: var(--font-weight-medium); } + .btn-enable { background: var(--color-btn-primary-bg); color: var(--color-text-heading); border: none; padding: 0.5rem 1rem; border-radius: var(--radius-sm); cursor: pointer; font-weight: var(--font-weight-medium); } .btn-enable:disabled { opacity: 0.5; cursor: not-allowed; } `], changeDetection: ChangeDetectionStrategy.OnPush @@ -475,6 +461,10 @@ export class VexHubComponent implements OnInit { private readonly vexHubApi = inject(VEX_HUB_API); private readonly advisoryAiApi = inject(ADVISORY_AI_API); + readonly pageTabs: readonly StellaPageTab[] = [ + { id: 'search', label: 'Search Statements', icon: 'M22 3H2l8 9.46V19l4 2v-8.54L22 3' }, + { id: 'consensus', label: 'Consensus View', icon: 'M20 6L9 17l-5-5' }, + ]; readonly activeTab = signal('search'); readonly loading = signal(false); readonly error = signal(null); diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-detail-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-detail-panel.component.ts index e5b5de1ae..0eafdc772 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-detail-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-detail-panel.component.ts @@ -621,9 +621,9 @@ import { text-transform: uppercase; } - .trust-level--high { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .trust-level--medium { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } - .trust-level--low { background: var(--color-status-error-text); color: var(--color-status-error-border); } + .trust-level--high { background: var(--color-status-success-text); color: #fff; } + .trust-level--medium { background: var(--color-status-warning-text); color: #fff; } + .trust-level--low { background: var(--color-status-error-text); color: #fff; } .consensus-card { display: flex; @@ -644,9 +644,9 @@ import { font-weight: var(--font-weight-semibold); } - .consensus-result--agreed { background: var(--color-status-success-text); color: var(--color-status-success-border); } + .consensus-result--agreed { background: var(--color-status-success-text); color: #fff; } .consensus-result--disputed { background: var(--color-severity-high); color: var(--color-severity-high-border); } - .consensus-result--pending { background: var(--color-status-info-text); color: var(--color-status-info-border); } + .consensus-result--pending { background: var(--color-status-info-text); color: #fff; } .consensus-count { font-size: 0.8125rem; diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-detail.component.ts index 2941af7c7..7fd7eae60 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-detail.component.ts @@ -384,10 +384,10 @@ import { font-size: 0.875rem; } - .status-badge--affected { background: var(--color-status-error-text); color: var(--color-status-error-border); } - .status-badge--not_affected { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .status-badge--fixed { background: var(--color-status-info-text); color: var(--color-status-info-border); } - .status-badge--under_investigation { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } + .status-badge--affected { background: var(--color-status-error-text); color: #fff; } + .status-badge--not_affected { background: var(--color-status-success-text); color: #fff; } + .status-badge--fixed { background: var(--color-status-info-text); color: #fff; } + .status-badge--under_investigation { background: var(--color-status-warning-text); color: #fff; } /* Detail Cards */ .detail-card { @@ -520,10 +520,10 @@ import { height: 20px; } - .evidence-item__icon--advisory { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } - .evidence-item__icon--sbom { background: var(--color-status-info-text); color: var(--color-status-info-border); } + .evidence-item__icon--advisory { background: var(--color-status-warning-text); color: #fff; } + .evidence-item__icon--sbom { background: var(--color-status-info-text); color: #fff; } .evidence-item__icon--reachability { background: var(--color-status-excepted); color: var(--color-status-excepted-border); } - .evidence-item__icon--manual_review { background: var(--color-status-success-text); color: var(--color-status-success-border); } + .evidence-item__icon--manual_review { background: var(--color-status-success-text); color: #fff; } .evidence-item__content { flex: 1; diff --git a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-search.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-search.component.ts index 18fdbcff1..d71dcc015 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-search.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-hub/vex-statement-search.component.ts @@ -360,23 +360,23 @@ import { } .btn--primary { - background: linear-gradient(135deg, var(--color-status-info) 0%, var(--color-status-info-text) 100%); + background: var(--color-btn-primary-bg); color: var(--color-btn-primary-text); } .btn--primary:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); + background: var(--color-btn-primary-bg-hover); } .btn--secondary { - background: var(--color-text-primary); - border: 1px solid var(--color-text-primary); - color: rgba(212, 201, 168, 0.3); + background: var(--color-btn-secondary-bg); + border: 1px solid var(--color-btn-secondary-border); + color: var(--color-btn-secondary-text); } .btn--secondary:hover { - background: var(--color-text-primary); + background: var(--color-btn-secondary-hover-bg); + border-color: var(--color-btn-secondary-hover-border); } .btn--text { @@ -498,10 +498,10 @@ import { font-weight: var(--font-weight-semibold); } - .status-badge--affected { background: var(--color-status-error-text); color: var(--color-status-error-border); } - .status-badge--not_affected { background: var(--color-status-success-text); color: var(--color-status-success-border); } - .status-badge--fixed { background: var(--color-status-info-text); color: var(--color-status-info-border); } - .status-badge--under_investigation { background: var(--color-status-warning-text); color: var(--color-status-warning-border); } + .status-badge--affected { background: var(--color-status-error-text); color: #fff; } + .status-badge--not_affected { background: var(--color-status-success-text); color: #fff; } + .status-badge--fixed { background: var(--color-status-info-text); color: #fff; } + .status-badge--under_investigation { background: var(--color-status-warning-text); color: #fff; } .source-cell { display: flex; diff --git a/src/Web/StellaOps.Web/src/app/features/vex-studio/components/vex-merge-panel/vex-merge-panel.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-studio/components/vex-merge-panel/vex-merge-panel.component.ts index a5ab59ee7..cf80a182b 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-studio/components/vex-merge-panel/vex-merge-panel.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-studio/components/vex-merge-panel/vex-merge-panel.component.ts @@ -20,6 +20,7 @@ import { import { CommonModule } from '@angular/common'; import { RouterModule } from '@angular/router'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; /** VEX claim source types */ export type VexSourceType = 'vendor' | 'distro' | 'internal' | 'community'; @@ -505,7 +506,7 @@ export interface VexMergeConflict { } .prov-link { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; word-break: break-all; } @@ -581,7 +582,7 @@ export interface VexMergeConflict { } .popover-footer a { - color: var(--color-brand-primary); + color: var(--color-text-link); } /* Confidence badges */ @@ -691,7 +692,7 @@ export interface VexMergeConflict { .adjust-link { margin-left: auto; font-size: var(--font-size-base); - color: var(--color-brand-primary); + color: var(--color-text-link); } /* Actions footer */ @@ -733,7 +734,7 @@ export interface VexMergeConflict { align-items: center; gap: 6px; padding: 8px 16px; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; border-radius: var(--radius-md); font-size: var(--font-size-base); @@ -743,7 +744,7 @@ export interface VexMergeConflict { } .trust-algebra-link:hover { - background: var(--color-brand-primary-hover); + background: var(--color-btn-primary-bg-hover); } .trust-algebra-link.disabled { @@ -872,8 +873,10 @@ export interface VexMergeConflict { changeDetection: ChangeDetectionStrategy.OnPush, }) export class VexMergePanelComponent { + private readonly dateFmt = inject(DateFormatService); + private readonly elementRef = inject(ElementRef); - private readonly utcDateFormatter = new Intl.DateTimeFormat('en-US', { + private readonly utcDateFormatter = new Intl.DateTimeFormat(this.dateFmt.locale(), { timeZone: 'UTC', year: 'numeric', month: 'short', diff --git a/src/Web/StellaOps.Web/src/app/features/vex-studio/vex-conflict-studio.component.scss b/src/Web/StellaOps.Web/src/app/features/vex-studio/vex-conflict-studio.component.scss index 435d9f1f5..3bac29c5f 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-studio/vex-conflict-studio.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/vex-studio/vex-conflict-studio.component.scss @@ -140,10 +140,10 @@ color: var(--color-status-success); } .status-fixed { - color: var(--color-brand-primary); + color: var(--color-text-link); } .status-under-investigation { - color: var(--color-brand-secondary); + color: var(--color-text-link); } .merge-explanation { diff --git a/src/Web/StellaOps.Web/src/app/features/vex-timeline/components/vex-timeline/vex-timeline.component.ts b/src/Web/StellaOps.Web/src/app/features/vex-timeline/components/vex-timeline/vex-timeline.component.ts index 097658e42..912eda578 100644 --- a/src/Web/StellaOps.Web/src/app/features/vex-timeline/components/vex-timeline/vex-timeline.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vex-timeline/components/vex-timeline/vex-timeline.component.ts @@ -602,7 +602,7 @@ export interface VerifyDsseEvent { border: none; border-radius: var(--radius-sm); background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.8125rem; cursor: pointer; @@ -701,12 +701,12 @@ export interface VerifyDsseEvent { border: 1px solid var(--color-brand-primary); border-radius: var(--radius-sm); background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.75rem; cursor: pointer; &:hover { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; } } @@ -829,12 +829,12 @@ export interface VerifyDsseEvent { border: 1px solid var(--color-brand-primary); border-radius: var(--radius-sm); background: transparent; - color: var(--color-brand-primary); + color: var(--color-text-link); font-size: 0.75rem; cursor: pointer; &:hover { - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; } } diff --git a/src/Web/StellaOps.Web/src/app/features/vuln-explorer/components/citation-link/citation-link.component.ts b/src/Web/StellaOps.Web/src/app/features/vuln-explorer/components/citation-link/citation-link.component.ts index 3ceda161d..26aa6e744 100644 --- a/src/Web/StellaOps.Web/src/app/features/vuln-explorer/components/citation-link/citation-link.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vuln-explorer/components/citation-link/citation-link.component.ts @@ -12,10 +12,12 @@ import { signal, computed, ChangeDetectionStrategy, + inject, } from '@angular/core'; import { EvidenceCitation, EvidenceEdge } from '../../models/evidence-subgraph.models'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; /** * Citation source type for styling. */ @@ -351,6 +353,8 @@ export type CitationSourceType = `] }) export class CitationLinkComponent { + private readonly dateFmt = inject(DateFormatService); + @Input({ required: true }) citation!: EvidenceCitation; @Input() edge?: EvidenceEdge; @Input() showInGraph = false; @@ -425,7 +429,7 @@ export class CitationLinkComponent { const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) { - return `Today at ${date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}`; + return `Today at ${date.toLocaleTimeString(this.dateFmt.locale(), { hour: '2-digit', minute: '2-digit' })}`; } if (diffDays === 1) { return 'Yesterday'; @@ -434,7 +438,7 @@ export class CitationLinkComponent { return `${diffDays} days ago`; } - return date.toLocaleDateString('en-US', { + return date.toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, diff --git a/src/Web/StellaOps.Web/src/app/features/vuln-explorer/components/evidence-subgraph/evidence-subgraph.component.ts b/src/Web/StellaOps.Web/src/app/features/vuln-explorer/components/evidence-subgraph/evidence-subgraph.component.ts index 7ffd8d9c2..83cdc2a43 100644 --- a/src/Web/StellaOps.Web/src/app/features/vuln-explorer/components/evidence-subgraph/evidence-subgraph.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vuln-explorer/components/evidence-subgraph/evidence-subgraph.component.ts @@ -31,7 +31,9 @@ import { GraphState, } from '../../models/evidence-subgraph.models'; import { EvidenceSubgraphService } from '../../services/evidence-subgraph.service'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; /** * Event emitted when a node is selected. */ @@ -62,7 +64,7 @@ export interface EdgeClickEvent { @Component({ selector: 'app-evidence-subgraph', standalone: true, - imports: [], + imports: [LoadingStateComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
    @@ -87,8 +89,7 @@ export interface EdgeClickEvent { @if (isLoading()) {
    -
    - Loading evidence graph... +
    } @@ -805,6 +806,8 @@ export interface EdgeClickEvent { `] }) export class EvidenceSubgraphComponent implements OnInit, OnDestroy, OnChanges { + private readonly dateFmt = inject(DateFormatService); + @Input() findingId!: string; @Output() nodeSelected = new EventEmitter(); @@ -1093,7 +1096,7 @@ export class EvidenceSubgraphComponent implements OnInit, OnDestroy, OnChanges { } formatDate(dateStr: string): string { - return new Date(dateStr).toLocaleDateString('en-US', { + return new Date(dateStr).toLocaleDateString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/vuln-explorer/components/filter-preset-pills/filter-preset-pills.component.ts b/src/Web/StellaOps.Web/src/app/features/vuln-explorer/components/filter-preset-pills/filter-preset-pills.component.ts index ddd3ee4a6..2de9a758a 100644 --- a/src/Web/StellaOps.Web/src/app/features/vuln-explorer/components/filter-preset-pills/filter-preset-pills.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vuln-explorer/components/filter-preset-pills/filter-preset-pills.component.ts @@ -181,7 +181,7 @@ const ICON_MAP: Record = { .preset-pill.active { background: var(--color-brand-primary-10); border-color: var(--color-brand-primary-20); - color: var(--color-brand-primary); + color: var(--color-text-link); } /* Noise-gating presets have distinct styling */ @@ -193,7 +193,7 @@ const ICON_MAP: Record = { background: var(--color-brand-primary-10); border-color: var(--color-brand-primary-20); border-style: solid; - color: var(--color-brand-primary); + color: var(--color-text-link); } .pill-icon { @@ -251,14 +251,14 @@ const ICON_MAP: Record = { background: transparent; border: 1px solid transparent; border-radius: var(--radius-md); - color: var(--color-brand-primary); + color: var(--color-text-link); cursor: pointer; transition: all 0.15s ease; } .action-btn:hover { background: var(--color-nav-hover); - color: var(--color-brand-primary); + color: var(--color-text-link); } .action-btn:focus-visible { diff --git a/src/Web/StellaOps.Web/src/app/features/vuln-explorer/components/verdict-explanation/verdict-explanation.component.ts b/src/Web/StellaOps.Web/src/app/features/vuln-explorer/components/verdict-explanation/verdict-explanation.component.ts index e39c4236d..58656f3d8 100644 --- a/src/Web/StellaOps.Web/src/app/features/vuln-explorer/components/verdict-explanation/verdict-explanation.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vuln-explorer/components/verdict-explanation/verdict-explanation.component.ts @@ -12,10 +12,12 @@ import { signal, computed, ChangeDetectionStrategy, + inject, } from '@angular/core'; import { VerdictSummary } from '../../models/evidence-subgraph.models'; +import { DateFormatService } from '../../../../core/i18n/date-format.service'; /** * Extended verdict with additional context. */ @@ -477,6 +479,8 @@ export interface VerdictContext extends VerdictSummary { `] }) export class VerdictExplanationComponent { + private readonly dateFmt = inject(DateFormatService); + @Input({ required: true }) verdict!: VerdictContext; @Output() showDetails = new EventEmitter(); @@ -549,7 +553,7 @@ export class VerdictExplanationComponent { formatDate(dateStr: string): string { const date = new Date(dateStr); - return date.toLocaleString('en-US', { + return date.toLocaleString(this.dateFmt.locale(), { month: 'short', day: 'numeric', year: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/vulnerabilities/components/vuln-triage-dashboard/vuln-triage-dashboard.component.ts b/src/Web/StellaOps.Web/src/app/features/vulnerabilities/components/vuln-triage-dashboard/vuln-triage-dashboard.component.ts index 5bc03b89b..3b610f321 100644 --- a/src/Web/StellaOps.Web/src/app/features/vulnerabilities/components/vuln-triage-dashboard/vuln-triage-dashboard.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vulnerabilities/components/vuln-triage-dashboard/vuln-triage-dashboard.component.ts @@ -8,6 +8,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { VULN_ANNOTATION_API, VulnAnnotationApi } from '../../../../core/api/vuln-annotation.client'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; import { VulnFinding, VulnState, @@ -23,7 +24,7 @@ type TabView = 'findings' | 'candidates'; @Component({ selector: 'app-vuln-triage-dashboard', standalone: true, - imports: [CommonModule, FormsModule], + imports: [CommonModule, FormsModule, LoadingStateComponent], template: `
    @@ -100,7 +101,7 @@ type TabView = 'findings' | 'candidates'; @if (loading()) { -
    Loading...
    + } @else { @if (activeTab() === 'findings') {
    @@ -501,7 +502,7 @@ type TabView = 'findings' | 'candidates'; .btn-submit { padding: 0.5rem 1rem; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; border: none; border-radius: var(--radius-sm); diff --git a/src/Web/StellaOps.Web/src/app/features/vulnerabilities/vulnerability-explorer.component.html b/src/Web/StellaOps.Web/src/app/features/vulnerabilities/vulnerability-explorer.component.html index 699a556e1..d96f31d6c 100644 --- a/src/Web/StellaOps.Web/src/app/features/vulnerabilities/vulnerability-explorer.component.html +++ b/src/Web/StellaOps.Web/src/app/features/vulnerabilities/vulnerability-explorer.component.html @@ -1,430 +1,427 @@ -
    - -
    -
    -

    Vulnerability Explorer

    -

    Browse and manage vulnerabilities across your assets

    -
    - -
    - - Refresh - -
    -
    - - - @if (stats(); as s) { - - - - - - - } - - - @if (message(); as msg) { - - {{ msg }} - - } - - -
    - - - - -
    - - - - - - - -
    -
    - - - @if (loading()) { -
    - - Loading vulnerabilities... -
    - } - - - @if (!loading()) { -
    - - @if (filteredVulnerabilities().length > 0) { -
    -
    CVE
    - - - - - - - - - - - - - - @for (vuln of filteredVulnerabilities(); track vuln.vulnId) { - - - - - - - - - - - } - -
    - CVE ID - Title - Severity - - CVSS - - Status - ReachabilityComponentsActions
    -
    - {{ vuln.cveId }} - @if (getExceptionBadgeData(vuln); as badgeData) { - - } -
    -
    - {{ vuln.title | slice:0:60 }}{{ vuln.title.length > 60 ? '...' : '' }} - - - {{ severityLabels[vuln.severity] }} - - - - {{ formatCvss(vuln.cvssScore) }} - - - - {{ statusLabels[vuln.status] }} - - - - {{ getReachabilityLabel(vuln) }} - - - {{ vuln.affectedComponents.length }} - - @if (hasWitnessData(vuln)) { - - Witness - - } - @if (!vuln.hasException) { - - + Exception - - } -
    -
    - } @else { - - } -
    - } - - - @if (selectedVulnerability(); as vuln) { -
    -
    -

    {{ vuln.cveId }}

    - Close -
    - -
    - -
    -

    {{ vuln.title }}

    -

    {{ vuln.description }}

    -
    - - -
    -
    - Severity - - {{ severityLabels[vuln.severity] }} - -
    -
    - CVSS Score - - {{ formatCvss(vuln.cvssScore) }} - -
    -
    - Status - - {{ statusLabels[vuln.status] }} - -
    -
    - Reachability - @if (hasWitnessData(vuln)) { - - - Show Witness - - } @else { - - {{ getReachabilityLabel(vuln) }} - - - Why? - - } -
    -
    - - - @if (getExceptionBadgeData(vuln); as badgeData) { -
    -

    Exception Status

    - -
    - } - - -
    -

    Affected Components ({{ vuln.affectedComponents.length }})

    -
    - @for (comp of vuln.affectedComponents; track comp.purl) { -
    -
    - {{ comp.name }} - {{ comp.version }} -
    -
    {{ comp.purl }}
    - @if (comp.fixedVersion) { -
    - Fixed in: {{ comp.fixedVersion }} -
    - } -
    - Assets: {{ comp.assetIds.join(', ') }} -
    -
    - } -
    -
    - - - @if (vuln.references?.length) { -
    -

    References

    -
      - @for (ref of vuln.references; track ref) { -
    • - {{ ref }} -
    • - } -
    -
    - } - - -
    -

    Timeline

    -
    - @if (vuln.publishedAt) { -
    - Published: - {{ formatDate(vuln.publishedAt) }} -
    - } - @if (vuln.modifiedAt) { -
    - Last Modified: - {{ formatDate(vuln.modifiedAt) }} -
    - } -
    -
    -
    - - - @if (!vuln.hasException && !showExceptionDraft()) { -
    - - Create Exception - -
    - } - - - - - @if (showExceptionDraft() && exceptionDraftContext()) { -
    - -
    - } -
    - } - - - @if (showExceptionExplain() && exceptionExplainData()) { - - - - } - - - - +
    + +
    +
    +

    Vulnerability Explorer

    +

    Browse and manage vulnerabilities across your assets

    +
    + +
    + + Refresh + +
    +
    + + + @if (stats(); as s) { + + + + + + + } + + + @if (message(); as msg) { + + {{ msg }} + + } + + +
    + + + + +
    + + + + + + + +
    +
    + + + @if (loading()) { + + } + + + @if (!loading()) { +
    + + @if (filteredVulnerabilities().length > 0) { +
    + + + + + + + + + + + + + + + @for (vuln of filteredVulnerabilities(); track vuln.vulnId) { + + + + + + + + + + + } + +
    + CVE ID + Title + Severity + + CVSS + + Status + ReachabilityComponentsActions
    +
    + {{ vuln.cveId }} + @if (getExceptionBadgeData(vuln); as badgeData) { + + } +
    +
    + {{ vuln.title | slice:0:60 }}{{ vuln.title.length > 60 ? '...' : '' }} + + + {{ severityLabels[vuln.severity] }} + + + + {{ formatCvss(vuln.cvssScore) }} + + + + {{ statusLabels[vuln.status] }} + + + + {{ getReachabilityLabel(vuln) }} + + + {{ vuln.affectedComponents.length }} + + @if (hasWitnessData(vuln)) { + + Witness + + } + @if (!vuln.hasException) { + + + Exception + + } +
    +
    + } @else { + + } +
    + } + + + @if (selectedVulnerability(); as vuln) { +
    +
    +

    {{ vuln.cveId }}

    + Close +
    + +
    + +
    +

    {{ vuln.title }}

    +

    {{ vuln.description }}

    +
    + + +
    +
    + Severity + + {{ severityLabels[vuln.severity] }} + +
    +
    + CVSS Score + + {{ formatCvss(vuln.cvssScore) }} + +
    +
    + Status + + {{ statusLabels[vuln.status] }} + +
    +
    + Reachability + @if (hasWitnessData(vuln)) { + + + Show Witness + + } @else { + + {{ getReachabilityLabel(vuln) }} + + + Why? + + } +
    +
    + + + @if (getExceptionBadgeData(vuln); as badgeData) { +
    +

    Exception Status

    + +
    + } + + +
    +

    Affected Components ({{ vuln.affectedComponents.length }})

    +
    + @for (comp of vuln.affectedComponents; track comp.purl) { +
    +
    + {{ comp.name }} + {{ comp.version }} +
    +
    {{ comp.purl }}
    + @if (comp.fixedVersion) { +
    + Fixed in: {{ comp.fixedVersion }} +
    + } +
    + Assets: {{ comp.assetIds.join(', ') }} +
    +
    + } +
    +
    + + + @if (vuln.references?.length) { +
    +

    References

    +
      + @for (ref of vuln.references; track ref) { +
    • + {{ ref }} +
    • + } +
    +
    + } + + +
    +

    Timeline

    +
    + @if (vuln.publishedAt) { +
    + Published: + {{ formatDate(vuln.publishedAt) }} +
    + } + @if (vuln.modifiedAt) { +
    + Last Modified: + {{ formatDate(vuln.modifiedAt) }} +
    + } +
    +
    +
    + + + @if (!vuln.hasException && !showExceptionDraft()) { +
    + + Create Exception + +
    + } + + + + + @if (showExceptionDraft() && exceptionDraftContext()) { +
    + +
    + } +
    + } + + + @if (showExceptionExplain() && exceptionExplainData()) { + + + + } + + + +
    diff --git a/src/Web/StellaOps.Web/src/app/features/vulnerabilities/vulnerability-explorer.component.scss b/src/Web/StellaOps.Web/src/app/features/vulnerabilities/vulnerability-explorer.component.scss index 6fd400505..0a26037e4 100644 --- a/src/Web/StellaOps.Web/src/app/features/vulnerabilities/vulnerability-explorer.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/vulnerabilities/vulnerability-explorer.component.scss @@ -44,7 +44,7 @@ // Toolbar .vuln-explorer__toolbar { display: flex; - flex-wrap: wrap; + flex-wrap: nowrap; gap: var(--space-4); align-items: center; padding: var(--space-4); @@ -53,8 +53,8 @@ border: 1px solid var(--color-border-primary); app-search-input { - flex: 1; - min-width: 250px; + flex: 1 1 200px; + min-width: 180px; max-width: 400px; } } @@ -127,7 +127,7 @@ user-select: none; &:hover { - color: var(--color-brand-primary); + color: var(--color-text-link); } } } @@ -458,7 +458,7 @@ } a { - color: var(--color-brand-primary); + color: var(--color-text-link); text-decoration: none; word-break: break-all; diff --git a/src/Web/StellaOps.Web/src/app/features/vulnerabilities/vulnerability-explorer.component.ts b/src/Web/StellaOps.Web/src/app/features/vulnerabilities/vulnerability-explorer.component.ts index b198dd35e..08158a5a8 100644 --- a/src/Web/StellaOps.Web/src/app/features/vulnerabilities/vulnerability-explorer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/vulnerabilities/vulnerability-explorer.component.ts @@ -41,15 +41,16 @@ import { SearchInputComponent, DropdownComponent, DropdownOption, - StatCardComponent, - StatGroupComponent, ButtonComponent, AlertComponent, EmptyStateComponent, ModalComponent, - SpinnerComponent, } from '../../shared/components/ui'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; +import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component'; +import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component'; +import { DateFormatService } from '../../core/i18n/date-format.service'; type SeverityFilter = VulnerabilitySeverity | 'all'; type StatusFilter = VulnerabilityStatus | 'all'; type ReachabilityFilter = 'reachable' | 'unreachable' | 'unknown' | 'all'; @@ -99,13 +100,13 @@ const SEVERITY_ORDER: Record = { // UI Component Library SearchInputComponent, DropdownComponent, - StatCardComponent, - StatGroupComponent, + StellaMetricCardComponent, + StellaMetricGridComponent, ButtonComponent, AlertComponent, EmptyStateComponent, ModalComponent, - SpinnerComponent, + LoadingStateComponent, ], templateUrl: './vulnerability-explorer.component.html', styleUrls: ['./vulnerability-explorer.component.scss'], @@ -113,6 +114,8 @@ const SEVERITY_ORDER: Record = { providers: [] }) export class VulnerabilityExplorerComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + private readonly api = inject(VULNERABILITY_API); private readonly witnessClient = inject(WITNESS_API); @@ -465,7 +468,7 @@ export class VulnerabilityExplorerComponent implements OnInit { formatDate(dateString: string | undefined): string { if (!dateString) return '-'; - return new Date(dateString).toLocaleDateString('en-US', { + return new Date(dateString).toLocaleDateString(this.dateFmt.locale(), { year: 'numeric', month: 'short', day: 'numeric', diff --git a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.html b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.html index 30bcc6e35..7f1f4e615 100644 --- a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.html +++ b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.html @@ -45,28 +45,32 @@ } -
    -
    - Enabled rules - {{ enabledEntryCount() }} - {{ entryCount() }} visible in this scope -
    -
    - Alerts in 24h - {{ alertsLast24hCount() }} - Recent matches routed into Mission Control -
    -
    - Dedup coverage - {{ dedupCoveragePercent() }}% - Average window {{ averageDedupWindow() }} minutes -
    -
    - Routing overrides - {{ channelOverrideCoverage() }}% - {{ regexRuleCount() }} regex-heavy rules to review -
    -
    + + + + + +
    @if (entriesLoading() && !entries().length) { -
    Loading watchlist rules...
    + } @else if (!filteredEntries().length) {

    No watchlist rules match this scope and filter set.

    @@ -401,7 +405,7 @@
    @if (alertsLoading() && !alerts().length) { -
    Loading watchlist alerts...
    + } @else if (!filteredAlerts().length) {
    No alerts match the current scope, window, and filter set.
    } @else { diff --git a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.scss b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.scss index e8571ddca..b9ef924b8 100644 --- a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.scss @@ -294,20 +294,22 @@ textarea { } .btn-primary { - border: 1px solid var(--color-status-info); - background: var(--color-status-info); - color: #0b1220; + border: 1px solid var(--color-btn-primary-border, transparent); + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); } .btn-secondary, .btn-icon { - border: 1px solid var(--color-border-primary); - background: var(--color-surface-secondary); - color: var(--color-text-primary); + border: 1px solid var(--color-btn-secondary-border); + background: var(--color-btn-secondary-bg); + color: var(--color-btn-secondary-text); } .btn-danger { - color: var(--color-status-error-text); + background: var(--color-status-error); + color: white; + border: 1px solid var(--color-status-error); } .form-actions { @@ -322,15 +324,6 @@ textarea { .data-table { width: 100%; - border-collapse: collapse; - - th, - td { - padding: 0.85rem 0.9rem; - text-align: left; - border-bottom: 1px solid var(--color-border-primary); - vertical-align: top; - } thead th { background: color-mix(in srgb, var(--color-surface-secondary) 92%, transparent); diff --git a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts index d6ab77a91..7d3ab6679 100644 --- a/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/watchlist/watchlist-page.component.ts @@ -44,7 +44,11 @@ import { readContextRouteParam, readContextRouteState, } from '../../shared/ui/context-route-state/context-route-state'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; +import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component'; +import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component'; +import { DateFormatService } from '../../core/i18n/date-format.service'; type ViewMode = 'list' | 'edit' | 'alerts'; type WatchlistTab = 'entries' | 'alerts' | 'tuning'; type WatchlistScopeFilter = 'tenant' | 'global' | 'system'; @@ -80,12 +84,17 @@ const ALERT_SORT_ORDERS: readonly AlertSortOrder[] = ['newest', 'oldest']; ContextHeaderComponent, ListDetailShellComponent, TabbedNavComponent, + LoadingStateComponent, + StellaMetricCardComponent, + StellaMetricGridComponent, ], templateUrl: './watchlist-page.component.html', styleUrls: ['./watchlist-page.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class WatchlistPageComponent implements OnInit { + private readonly dateFmt = inject(DateFormatService); + private readonly api = inject(WATCHLIST_API); private readonly fb = inject(NonNullableFormBuilder); private readonly route = inject(ActivatedRoute); @@ -877,7 +886,7 @@ export class WatchlistPageComponent implements OnInit { } formatDate(isoDate: string): string { - return new Intl.DateTimeFormat('en-US', { + return new Intl.DateTimeFormat(this.dateFmt.locale(), { day: '2-digit', hour: '2-digit', minute: '2-digit', diff --git a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/workflow-visualizer/workflow-visualizer.component.ts b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/workflow-visualizer/workflow-visualizer.component.ts index 73b5de2ad..b6df39ab5 100644 --- a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/workflow-visualizer/workflow-visualizer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/components/workflow-visualizer/workflow-visualizer.component.ts @@ -9,6 +9,7 @@ import { Component, Input, Output, EventEmitter, OnInit, OnDestroy, OnChanges, S import { FormsModule } from '@angular/forms'; import { Subject, takeUntil, interval, switchMap, filter } from 'rxjs'; +import { LoadingStateComponent } from '../../../../shared/components/loading-state/loading-state.component'; import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, NodePosition } from '../../services/workflow-visualization.service'; /** @@ -18,7 +19,7 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node @Component({ selector: 'app-workflow-visualizer', standalone: true, - imports: [FormsModule], + imports: [FormsModule, LoadingStateComponent], template: `
    @@ -240,10 +241,7 @@ import { WorkflowVisualizationService, WorkflowGraph, GraphNode, GraphEdge, Node @if (loading) { -
    -
    - Loading workflow... -
    + } diff --git a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.html b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.html index fde9b6cd0..06cd6eeaa 100644 --- a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.html +++ b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.html @@ -17,7 +17,7 @@ /> @if (loading()) { -
    Loading run graph and replay context...
    + } @else if (error()) {
    {{ error() }}
    } @else if (context(); as context) { @@ -25,37 +25,32 @@
    @switch (activeTab()) { @case ('summary') { -
    -
    -

    Gate posture

    -

    {{ context.gateDecision?.verdict || context.detail.statusRow.gateStatus }}

    -

    {{ context.gateDecision?.blockers?.join(', ') || 'No blocking gate reasons reported.' }}

    - -
    - -
    -

    Approvals

    -

    {{ context.approvals?.checkpoints?.length || 0 }}

    -

    - {{ context.detail.needsApproval ? 'This run still depends on approval checkpoints.' : 'No approval checkpoints are required for this run.' }} -

    - -
    - -
    -

    Deployment targets

    -

    {{ context.deployments?.targets?.length || 0 }}

    -

    Track deployment-state detail in the legacy deployment workbench when needed.

    - -
    - -
    -

    Replay determinism

    -

    {{ context.replay?.verdict || context.evidence?.replayDeterminismVerdict || 'unknown' }}

    -

    Replay and evidence tabs use the same run-scoped context.

    - -
    -
    + + + + + +
    diff --git a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.scss b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.scss index 3bfde573c..dca47c202 100644 --- a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.scss +++ b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.scss @@ -83,7 +83,7 @@ .run-tab--active { border-color: var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); } .run-workspace__layout { @@ -223,13 +223,14 @@ } .btn--primary { - border-color: var(--color-brand-primary); - background: color-mix(in srgb, var(--color-brand-primary) 14%, var(--color-surface-primary)); + border-color: var(--color-btn-primary-border, transparent); + background: var(--color-btn-primary-bg); + color: var(--color-btn-primary-text); } .btn--secondary.is-active { border-color: var(--color-brand-primary); - color: var(--color-brand-primary); + color: var(--color-text-link); } @media (max-width: 1100px) { diff --git a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts index 72b7db3f7..eba455d11 100644 --- a/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/workflow-visualization/run-graph-replay-page.component.ts @@ -24,6 +24,9 @@ import { type RunVisualizationContext, type RunWorkspaceTab, } from './services/run-visualization-shell.service'; +import { LoadingStateComponent } from '../../shared/components/loading-state/loading-state.component'; +import { StellaMetricCardComponent } from '../../shared/components/stella-metric-card/stella-metric-card.component'; +import { StellaMetricGridComponent } from '../../shared/components/stella-metric-card/stella-metric-grid.component'; @Component({ selector: 'app-run-graph-replay-page', @@ -38,6 +41,9 @@ import { StepDetailPanelComponent, TimeTravelControlsComponent, ReplayControlsComponent, + LoadingStateComponent, + StellaMetricCardComponent, + StellaMetricGridComponent, ], templateUrl: './run-graph-replay-page.component.html', styleUrl: './run-graph-replay-page.component.scss', diff --git a/src/Web/StellaOps.Web/src/app/features/workspaces/auditor/components/auditor-workspace/auditor-workspace.component.ts b/src/Web/StellaOps.Web/src/app/features/workspaces/auditor/components/auditor-workspace/auditor-workspace.component.ts index 90f214749..2bca33953 100644 --- a/src/Web/StellaOps.Web/src/app/features/workspaces/auditor/components/auditor-workspace/auditor-workspace.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/workspaces/auditor/components/auditor-workspace/auditor-workspace.component.ts @@ -20,6 +20,7 @@ import { import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; +import { LoadingStateComponent } from '../../../../../shared/components/loading-state/loading-state.component'; import { EvidenceRibbonComponent } from '../../../../evidence-ribbon/components/evidence-ribbon/evidence-ribbon.component'; import { ReviewRibbonSummary, @@ -64,7 +65,7 @@ export interface AuditActionEvent { */ @Component({ selector: 'stella-auditor-workspace', - imports: [CommonModule, FormsModule, EvidenceRibbonComponent], + imports: [CommonModule, FormsModule, EvidenceRibbonComponent, LoadingStateComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
    @@ -268,10 +269,7 @@ export interface AuditActionEvent {
    @if (loading()) { -
    - - Loading... -
    + } @else if (quietTriageItems().length === 0) {
    No items in quiet-triage queue. @@ -533,7 +531,7 @@ export interface AuditActionEvent { padding: 0.75rem 1.5rem; border: none; border-radius: var(--radius-md); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; font-size: 1rem; font-weight: var(--font-weight-medium); @@ -541,7 +539,7 @@ export interface AuditActionEvent { transition: background 0.15s ease; &:hover { - background: var(--color-brand-primary-hover); + background: var(--color-btn-primary-bg-hover); } } diff --git a/src/Web/StellaOps.Web/src/app/features/workspaces/developer/components/developer-workspace/developer-workspace.component.ts b/src/Web/StellaOps.Web/src/app/features/workspaces/developer/components/developer-workspace/developer-workspace.component.ts index 9a5737bc9..b04120bc4 100644 --- a/src/Web/StellaOps.Web/src/app/features/workspaces/developer/components/developer-workspace/developer-workspace.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/workspaces/developer/components/developer-workspace/developer-workspace.component.ts @@ -358,7 +358,7 @@ export interface ActionEvent { padding: 0.75rem 1.5rem; border: none; border-radius: var(--radius-md); - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: white; font-size: 1rem; font-weight: var(--font-weight-medium); @@ -366,7 +366,7 @@ export interface ActionEvent { transition: background 0.15s ease; &:hover { - background: var(--color-brand-primary-hover); + background: var(--color-btn-primary-bg-hover); } } diff --git a/src/Web/StellaOps.Web/src/app/features/workspaces/shared/components/workspace-nav-dropdown/workspace-nav-dropdown.component.ts b/src/Web/StellaOps.Web/src/app/features/workspaces/shared/components/workspace-nav-dropdown/workspace-nav-dropdown.component.ts index 3e96b8607..ffacd68e2 100644 --- a/src/Web/StellaOps.Web/src/app/features/workspaces/shared/components/workspace-nav-dropdown/workspace-nav-dropdown.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/workspaces/shared/components/workspace-nav-dropdown/workspace-nav-dropdown.component.ts @@ -205,7 +205,7 @@ import { .dropdown-item.preferred .item-icon { background: var(--color-brand-primary-10); - color: var(--color-brand-secondary); + color: var(--color-text-link); } .item-content { @@ -228,7 +228,7 @@ import { .preferred-badge { display: flex; align-items: center; - color: var(--color-brand-secondary); + color: var(--color-text-link); flex-shrink: 0; } diff --git a/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts index cc1aa9b53..7d4d540d3 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-shell/app-shell.component.ts @@ -99,7 +99,7 @@ import { SearchAssistantHostComponent } from '../search-assistant-host/search-as left: 0; z-index: 9999; padding: 1rem; - background: var(--color-brand-primary); + background: var(--color-btn-primary-bg); color: var(--color-text-heading); text-decoration: none; @@ -146,8 +146,8 @@ import { SearchAssistantHostComponent } from '../search-assistant-host/search-as .shell__breadcrumb { flex-shrink: 0; padding: 0.25rem 1.5rem; - border-bottom: 1px solid var(--color-border-primary); - background: var(--color-surface-primary); + border-bottom: 1px solid color-mix(in srgb, var(--color-border-primary) 25%, transparent); + background: var(--color-header-bg); } .shell__outlet { diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts index ca8287941..b6da13106 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/app-sidebar.component.ts @@ -14,7 +14,7 @@ import { NgZone, } from '@angular/core'; -import { Router, NavigationEnd } from '@angular/router'; +import { Router, RouterLink, NavigationEnd } from '@angular/router'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { AUTH_SERVICE, AuthService, StellaOpsScopes } from '../../core/auth'; import type { StellaOpsScope } from '../../core/auth'; @@ -67,6 +67,7 @@ interface NavSectionGroup { standalone: true, imports: [ SidebarNavItemComponent, + RouterLink, ], template: ` `, styles: [` @@ -296,6 +310,112 @@ interface NavSectionGroup { } } + /* ================================================================ + Brand / Logo + ================================================================ */ + .sidebar__brand { + display: flex; + align-items: center; + justify-content: flex-start; + padding: 1rem 0.75rem 0.625rem; + min-height: 56px; + transition: padding 0.3s cubic-bezier(0.22, 1, 0.36, 1); + } + + .sidebar__brand--collapsed { + padding: 1rem 0 0.5rem; + justify-content: center; + } + + .sidebar__brand-link { + display: flex; + align-items: center; + gap: 0.625rem; + text-decoration: none; + color: var(--color-sidebar-brand-text); + transition: opacity 0.15s ease; + + &:hover { + opacity: 0.85; + } + } + + .sidebar__brand-logo { + flex-shrink: 0; + border-radius: 6px; + transition: width 0.3s cubic-bezier(0.22, 1, 0.36, 1), + height 0.3s cubic-bezier(0.22, 1, 0.36, 1); + } + + .sidebar__brand-info { + display: flex; + flex-direction: column; + min-width: 0; + animation: flyoutFadeIn 0.25s ease-out both; + } + + .sidebar__brand-text { + font-size: 1.05rem; + font-weight: 600; + letter-spacing: 0.01em; + color: var(--color-sidebar-brand-text); + white-space: nowrap; + overflow: hidden; + } + + .sidebar__brand-version { + font-size: 0.5625rem; + font-family: var(--font-family-mono); + letter-spacing: 0.04em; + color: var(--color-sidebar-version); + white-space: nowrap; + margin-top: 0.0625rem; + } + + /* ================================================================ + Collapse button - circle on sidebar/content border + ================================================================ */ + .sidebar__collapse-btn-edge { + position: absolute; + top: 28px; + right: -12px; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + border: 1px solid var(--color-sidebar-border); + background: var(--color-sidebar-bg); + color: var(--color-sidebar-text-muted); + cursor: pointer; + opacity: 0; + transition: opacity 0.2s, color 0.2s, background 0.2s, border-color 0.2s; + } + + .sidebar:hover .sidebar__collapse-btn-edge, + .sidebar__collapse-btn-edge:focus-visible { + opacity: 1; + } + + .sidebar__collapse-btn-edge:hover { + color: var(--color-sidebar-active-text); + background: rgba(245, 166, 35, 0.12); + border-color: rgba(245, 166, 35, 0.25); + } + + .sidebar__collapse-btn-edge:focus-visible { + outline: 1.5px solid var(--color-sidebar-active-border); + outline-offset: 1px; + } + + @media (max-width: 991px) { + .sidebar__collapse-btn-edge { + display: none; + } + } + /* ================================================================ Scrollable nav area ================================================================ */ @@ -493,83 +613,7 @@ interface NavSectionGroup { ================================================================ */ .sidebar__footer { flex-shrink: 0; - padding: 0.5rem 0.625rem 0.625rem; - } - - .sidebar__collapse-btn { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 30px; - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 8px; - background: transparent; - color: var(--color-sidebar-text-muted); - cursor: pointer; - margin-bottom: 0.5rem; - transition: color 0.2s, background 0.2s, border-color 0.2s; - - &:hover { - color: var(--color-sidebar-active-text); - background: rgba(245, 166, 35, 0.06); - border-color: rgba(245, 166, 35, 0.15); - } - - &:focus-visible { - outline: 1.5px solid var(--color-sidebar-active-border); - outline-offset: -1.5px; - } - } - - @media (max-width: 991px) { - .sidebar__collapse-btn { - display: none; - } - } - - .sidebar__footer-divider { - height: 1px; - background: linear-gradient( - 90deg, - transparent 0%, - var(--color-sidebar-divider) 30%, - var(--color-sidebar-divider) 70%, - transparent 100% - ); - margin-bottom: 0.5rem; - } - - .sidebar__version { - display: block; - font-size: 0.5625rem; - font-family: var(--font-family-mono); - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--color-sidebar-version); - text-align: center; - white-space: nowrap; - transition: font-size 0.2s, opacity 0.2s; - } - - .sidebar--collapsed .sidebar__footer { - padding: 0.375rem 0.25rem 0.5rem; - } - - .sidebar--collapsed.sidebar--flyout .sidebar__footer { - padding: 0.5rem 0.625rem 0.625rem; - } - - .sidebar--collapsed .sidebar__version { - font-size: 0; - opacity: 0; - overflow: hidden; - } - - .sidebar--collapsed.sidebar--flyout .sidebar__version { - font-size: 0.5625rem; - opacity: 1; - overflow: visible; + padding: 0.25rem 0.625rem 0.5rem; } /* ---- Flyout: disable on mobile ---- */ @@ -664,8 +708,8 @@ export class AppSidebarComponent implements AfterViewInit { }, { id: 'rel-health', - label: 'Release Health', - route: '/releases/health', + label: 'Release Posture', + route: '/environments/posture', icon: 'activity', }, { id: 'rel-deployments', label: 'Deployments', route: '/releases/deployments', icon: 'upload-cloud' }, @@ -815,7 +859,7 @@ export class AppSidebarComponent implements AfterViewInit { id: 'ops-environments', label: 'Environments', icon: 'globe', - route: '/ops/operations/environments', + route: '/environments/regions', menuGroupId: 'operations', menuGroupLabel: 'Operations', requireAnyScope: [ @@ -837,8 +881,8 @@ export class AppSidebarComponent implements AfterViewInit { label: 'Diagnostics', icon: 'stethoscope', route: '/ops/operations/doctor', - menuGroupId: 'operations', - menuGroupLabel: 'Operations', + menuGroupId: 'setup-admin', + menuGroupLabel: 'Settings', requireAnyScope: [StellaOpsScopes.HEALTH_READ, StellaOpsScopes.UI_ADMIN], }, { @@ -846,8 +890,8 @@ export class AppSidebarComponent implements AfterViewInit { label: 'Notifications', icon: 'bell', route: '/ops/operations/notifications', - menuGroupId: 'operations', - menuGroupLabel: 'Operations', + menuGroupId: 'setup-admin', + menuGroupLabel: 'Settings', requireAnyScope: [StellaOpsScopes.NOTIFY_VIEWER], }, { @@ -862,18 +906,6 @@ export class AppSidebarComponent implements AfterViewInit { StellaOpsScopes.VEX_READ, ], }, - { - id: 'ops-offline-kit', - label: 'Offline Kit', - icon: 'download-cloud', - route: '/ops/operations/offline-kit', - menuGroupId: 'operations', - menuGroupLabel: 'Operations', - requireAnyScope: [ - StellaOpsScopes.UI_ADMIN, - StellaOpsScopes.ORCH_OPERATE, - ], - }, // ── Group 4: Audit & Evidence ──────────────────────────────────── { id: 'evidence-overview', @@ -951,26 +983,13 @@ export class AppSidebarComponent implements AfterViewInit { ], }, // ── Group 5: Setup & Admin ─────────────────────────────────────── - { - id: 'setup-topology', - label: 'Topology', - icon: 'globe', - route: '/setup/topology/overview', - menuGroupId: 'setup-admin', - menuGroupLabel: 'Setup & Admin', - requireAnyScope: [ - StellaOpsScopes.UI_ADMIN, - StellaOpsScopes.ORCH_READ, - StellaOpsScopes.ORCH_OPERATE, - ], - }, { id: 'setup-integrations', label: 'Integrations', icon: 'plug', route: '/setup/integrations', menuGroupId: 'setup-admin', - menuGroupLabel: 'Setup & Admin', + menuGroupLabel: 'Settings', requireAnyScope: [ StellaOpsScopes.UI_ADMIN, StellaOpsScopes.ORCH_OPERATE, @@ -982,7 +1001,7 @@ export class AppSidebarComponent implements AfterViewInit { icon: 'user', route: '/setup/identity-access', menuGroupId: 'setup-admin', - menuGroupLabel: 'Setup & Admin', + menuGroupLabel: 'Settings', requireAnyScope: [StellaOpsScopes.UI_ADMIN], }, { @@ -991,7 +1010,7 @@ export class AppSidebarComponent implements AfterViewInit { icon: 'shield', route: '/setup/trust-signing', menuGroupId: 'setup-admin', - menuGroupLabel: 'Setup & Admin', + menuGroupLabel: 'Settings', requireAnyScope: [ StellaOpsScopes.UI_ADMIN, StellaOpsScopes.SIGNER_READ, @@ -1003,17 +1022,17 @@ export class AppSidebarComponent implements AfterViewInit { icon: 'paintbrush', route: '/setup/tenant-branding', menuGroupId: 'setup-admin', - menuGroupLabel: 'Setup & Admin', + menuGroupLabel: 'Settings', requireAnyScope: [StellaOpsScopes.UI_ADMIN], }, { - id: 'setup-system', - label: 'System Settings', - icon: 'server', - route: '/setup/system', + id: 'setup-preferences', + label: 'User Preferences', + icon: 'user', + route: '/setup/preferences', menuGroupId: 'setup-admin', - menuGroupLabel: 'Setup & Admin', - requireAnyScope: [StellaOpsScopes.UI_ADMIN], + menuGroupLabel: 'Settings', + requireAnyScope: [StellaOpsScopes.UI_READ], }, ]; @@ -1115,7 +1134,7 @@ export class AppSidebarComponent implements AfterViewInit { case 'audit-evidence': return 'Audit & Evidence'; case 'setup-admin': - return 'Setup & Admin'; + return 'Settings'; default: return 'Global Menu'; } diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-nav-item.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-nav-item.component.ts index db5416a37..bc365b434 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-nav-item.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-nav-item.component.ts @@ -253,6 +253,61 @@ export interface NavItem { } + @case ('user') { + + + + + } + @case ('paintbrush') { + + + + + } + @case ('radio') { + + + + + + + + } + @case ('stethoscope') { + + + + + + } + @case ('bell') { + + + + + } + @case ('download-cloud') { + + + + + + } + @case ('upload-cloud') { + + + + + + } + @case ('help-circle') { + + + + + + } @default { diff --git a/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts index 3e2b3938e..68dce5eb0 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts @@ -22,6 +22,7 @@ import { GlobalSearchComponent } from '../global-search/global-search.component' import { ContextChipsComponent } from '../context-chips/context-chips.component'; import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.component'; import { I18nService, UserLocalePreferenceService } from '../../core/i18n'; +import { ViewModeSwitcherComponent } from '../../shared/components/view-mode-switcher/view-mode-switcher.component'; import { OfflineStatusChipComponent } from '../context-chips/offline-status-chip.component'; import { FeedSnapshotChipComponent } from '../context-chips/feed-snapshot-chip.component'; @@ -50,6 +51,7 @@ import { LiveEventStreamChipComponent } from '../context-chips/live-event-stream PolicyBaselineChipComponent, EvidenceModeChipComponent, LiveEventStreamChipComponent, + ViewModeSwitcherComponent, ], template: ` - - -
    - -
    - Stella Ops -
    -