feat: add entropy policy banner and policy gate indicator components
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implemented EntropyPolicyBannerComponent with configuration for entropy policies, including thresholds, current scores, and mitigation steps. - Created PolicyGateIndicatorComponent to display the status of policy gates, including passed, failed, and warning gates, with detailed views for determinism and entropy gates. - Added HTML and SCSS for both components to ensure proper styling and layout. - Introduced computed properties and signals for reactive state management in Angular. - Included remediation hints and actions for user interaction within the policy gate indicator.
This commit is contained in:
@@ -3,7 +3,8 @@
|
||||
"allow": [
|
||||
"Bash(dotnet build:*)",
|
||||
"Bash(dotnet restore:*)",
|
||||
"Bash(chmod:*)"
|
||||
"Bash(chmod:*)",
|
||||
"Bash(cat:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| P1 | PREP-NOTIFY-OBS-51-001-TELEMETRY-SLO-WEBHOOK | DONE (2025-11-19) | Telemetry SLO webhook schema published at `docs/notifications/slo-webhook-schema.md`; share with Telemetry Core for compatibility check. | Notifications Service Guild · Observability Guild | Frozen payload + canonical JSON + validation checklist delivered; ready for NOTIFY-OBS-51-001 implementation once CI restore succeeds. |
|
||||
| 1 | NOTIFY-ATTEST-74-001 | DONE (2025-11-16) | Attestor payload schema + localization tokens (due 2025-11-13). | Notifications Service Guild · Attestor Service Guild (`src/Notifier/StellaOps.Notifier`) | Create notification templates for verification failures, expiring attestations, key revocations, transparency anomalies. |
|
||||
| 2 | NOTIFY-ATTEST-74-002 | TODO | Depends on 74-001. | Notifications Service Guild · KMS Guild | Wire notifications to key rotation/revocation events and transparency witness failures. |
|
||||
| 2 | NOTIFY-ATTEST-74-002 | DONE (2025-11-27) | Depends on 74-001. | Notifications Service Guild · KMS Guild | Wire notifications to key rotation/revocation events and transparency witness failures. |
|
||||
| 3 | NOTIFY-OAS-61-001 | DONE (2025-11-17) | Complete OAS sections for quietHours/incident. | Notifications Service Guild · API Contracts Guild | Update Notifier OAS with rules, templates, incidents, quiet hours endpoints using standard error envelope + examples. |
|
||||
| 4 | NOTIFY-OAS-61-002 | DONE (2025-11-17) | Depends on 61-001. | Notifications Service Guild | Implement `/.well-known/openapi` discovery endpoint with scope metadata. |
|
||||
| 5 | NOTIFY-OAS-62-001 | DONE (2025-11-17) | Depends on 61-002. | Notifications Service Guild · SDK Generator Guild | SDK examples for rule CRUD, incident ack, quiet hours; SDK smoke tests. |
|
||||
|
||||
@@ -27,13 +27,15 @@
|
||||
| 1 | TELEMETRY-OBS-50-001 | DONE (2025-11-19) | Finalize bootstrap + sample host integration. | Telemetry Core Guild (`src/Telemetry/StellaOps.Telemetry.Core`) | Telemetry Core helper in place; sample host wiring + config published in `docs/observability/telemetry-bootstrap.md`. |
|
||||
| 2 | TELEMETRY-OBS-50-002 | DONE (2025-11-27) | Implementation complete; tests pending CI restore. | Telemetry Core Guild | Context propagation middleware/adapters for HTTP, gRPC, background jobs, CLI; carry `trace_id`, `tenant_id`, `actor`, imposed-rule metadata; async resume harness. Prep artefact: `docs/modules/telemetry/prep/2025-11-20-obs-50-002-prep.md`. |
|
||||
| 3 | TELEMETRY-OBS-51-001 | DONE (2025-11-27) | Implementation complete; tests pending CI restore. | Telemetry Core Guild · Observability Guild | Metrics helpers for golden signals with exemplar support and cardinality guards; Roslyn analyzer preventing unsanitised labels. Prep artefact: `docs/modules/telemetry/prep/2025-11-20-obs-51-001-prep.md`. |
|
||||
| 4 | TELEMETRY-OBS-51-002 | BLOCKED (2025-11-20) | PREP-TELEMETRY-OBS-51-002-DEPENDS-ON-51-001 | Telemetry Core Guild · Security Guild | Redaction/scrubbing filters for secrets/PII at logger sink; per-tenant config with TTL; audit overrides; determinism tests. |
|
||||
| 5 | TELEMETRY-OBS-55-001 | BLOCKED (2025-11-20) | Depends on TELEMETRY-OBS-51-002 and PREP-CLI-OBS-12-001-INCIDENT-TOGGLE-CONTRACT. | Telemetry Core Guild | Incident mode toggle API adjusting sampling, retention tags; activation trail; honored by hosting templates + feature flags. |
|
||||
| 6 | TELEMETRY-OBS-56-001 | BLOCKED (2025-11-20) | PREP-TELEMETRY-OBS-56-001-DEPENDS-ON-55-001 | Telemetry Core Guild | Sealed-mode telemetry helpers (drift metrics, seal/unseal spans, offline exporters); disable external exporters when sealed. |
|
||||
| 4 | TELEMETRY-OBS-51-002 | DONE | Implemented scrubbing with LogRedactor, per-tenant config, audit overrides, determinism tests. | Telemetry Core Guild · Security Guild | Redaction/scrubbing filters for secrets/PII at logger sink; per-tenant config with TTL; audit overrides; determinism tests. |
|
||||
| 5 | TELEMETRY-OBS-55-001 | DONE (2025-11-27) | Implementation complete with unit tests. | Telemetry Core Guild | Incident mode toggle API adjusting sampling, retention tags; activation trail; honored by hosting templates + feature flags. |
|
||||
| 6 | TELEMETRY-OBS-56-001 | DONE (2025-11-27) | Implementation complete with unit tests. | Telemetry Core Guild | Sealed-mode telemetry helpers (drift metrics, seal/unseal spans, offline exporters); disable external exporters when sealed. |
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2025-11-27 | Implemented TELEMETRY-OBS-56-001: Added `ISealedModeTelemetryService` interface, `SealedModeTelemetryOptions` (exporter type, file path, max bytes, sampling limits, force scrub), `SealedModeTelemetryService` with drift metrics, seal/unseal activity spans, external export blocking. Added `SealedModeFileExporter` for append-only OTLP file output with rotation (3-file ring buffer, 0600 permissions). DI registration via `AddSealedModeTelemetry()`. Unit tests in `SealedModeTelemetryServiceTests.cs` and `SealedModeFileExporterTests.cs`. | Telemetry Core Guild |
|
||||
| 2025-11-27 | Implemented TELEMETRY-OBS-55-001: Added `IIncidentModeService` interface with activation/deactivation/TTL extension methods, `IncidentModeState` record, `IncidentModeOptions` (TTL min/max/default, sampling rate, persistence, audit events), `IncidentModeService` implementation with timer-based expiry, state persistence to `~/.stellaops/incident-mode.json`, CLI/config activation helpers. DI registration via `AddIncidentMode()`. Comprehensive unit tests in `IncidentModeServiceTests.cs`. | Telemetry Core Guild |
|
||||
| 2025-11-27 | Implemented TELEMETRY-OBS-50-002: Added `TelemetryContext`, `TelemetryContextAccessor` (AsyncLocal-based), `TelemetryContextPropagationMiddleware` (HTTP), `TelemetryContextPropagator` (DelegatingHandler), `TelemetryContextInjector` (gRPC/queue helpers), `TelemetryContextJobScope` (async resume harness). DI extensions added via `AddTelemetryContextPropagation()`. | Telemetry Core Guild |
|
||||
| 2025-11-27 | Implemented TELEMETRY-OBS-51-001: Added `GoldenSignalMetrics` (latency histogram, error/request counters, saturation gauge), `GoldenSignalMetricsOptions` (cardinality limits, exemplar toggle, prefix). Includes `MeasureLatency()` scope helper and `Tag()` factory. DI extensions added via `AddGoldenSignalMetrics()`. | Telemetry Core Guild |
|
||||
| 2025-11-27 | Added unit tests for context propagation (`TelemetryContextTests`, `TelemetryContextAccessorTests`) and golden signal metrics (`GoldenSignalMetricsTests`). Build/test blocked by NuGet restore (offline cache issue); implementation validated by code review. | Telemetry Core Guild |
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
- docs/replay/DETERMINISTIC_REPLAY.md
|
||||
- docs/replay/TEST_STRATEGY.md
|
||||
- docs/modules/scanner/architecture.md
|
||||
- docs/modules/sbomer/architecture.md (for SPDX 3.0.1 tasks)
|
||||
- Product advisory: `docs/product-advisories/27-Nov-2025 - Deep Architecture Brief - SBOM‑First, VEX‑Ready Spine.md` (canonical for SPDX/VEX work)
|
||||
- SPDX 3.0.1 specification: https://spdx.github.io/spdx-spec/v3.0.1/
|
||||
|
||||
## Delivery Tracker
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
@@ -33,12 +36,20 @@
|
||||
| 12 | SCAN-ENTROPY-186-012 | TODO | Depends on 186-011. | Scanner Guild · Provenance Guild | Generate `entropy.report.json`, image-level penalties; attach evidence to manifests/attestations; expose ratios for policy engines. |
|
||||
| 13 | SCAN-CACHE-186-013 | TODO | Parallel with replay work. | Scanner Guild | Layer-level SBOM/VEX cache keyed by layer digest + manifest hash + tool/feed/policy IDs; re-verify DSSE on cache hits; persist indexes; document referencing 16-Nov-2026 advisory. |
|
||||
| 14 | SCAN-DIFF-CLI-186-014 | TODO | Depends on replay+cache scaffolding. | Scanner Guild · CLI Guild | Deterministic diff-aware rescan workflow (`scan.lock.json`, JSON Patch diffs, CLI verbs `stella scan --emit-diff` / `stella diff`); replayable tests; docs. |
|
||||
| 15 | SBOM-BRIDGE-186-015 | TODO | Parallel; coordinate with Sbomer. | Sbomer Guild · Scanner Guild | Establish SPDX 3.0.1 as canonical SBOM persistence; deterministic CycloneDX 1.6 exporter; map table/library; wire snapshot hashes into replay manifests. |
|
||||
| 15 | SBOM-BRIDGE-186-015 | TODO | Parallel; coordinate with Sbomer. | Sbomer Guild · Scanner Guild | Establish SPDX 3.0.1 as canonical SBOM persistence; deterministic CycloneDX 1.6 exporter; map table/library; wire snapshot hashes into replay manifests. See subtasks 15a-15f below. |
|
||||
| 15a | SPDX-MODEL-186-015A | TODO | Foundational for SBOM-BRIDGE. | Sbomer Guild (`src/Sbomer/StellaOps.Sbomer.Spdx`) | Implement SPDX 3.0.1 data model: `SpdxDocument`, `Package`, `File`, `Snippet`, `Relationship`, `ExternalRef`, `Annotation`. Use SPDX 3.0.1 JSON-LD schema. |
|
||||
| 15b | SPDX-SERIAL-186-015B | TODO | Depends on 15a. | Sbomer Guild | Implement SPDX 3.0.1 serializers/deserializers: JSON-LD (canonical), Tag-Value (legacy compat), RDF/XML (optional). Ensure deterministic output ordering. |
|
||||
| 15c | CDX-MAP-186-015C | TODO | Depends on 15a. | Sbomer Guild (`src/Sbomer/StellaOps.Sbomer.CycloneDx`) | Build bidirectional SPDX 3.0.1 ↔ CycloneDX 1.6 mapping table: component→package, dependency→relationship, vulnerability→advisory. Document loss-of-fidelity cases. |
|
||||
| 15d | SBOM-STORE-186-015D | TODO | Depends on 15a. | Sbomer Guild · Scanner Guild | MongoDB/CAS persistence for SPDX 3.0.1 documents; indexed by artifact digest, component PURL, document SPDXID. Enable efficient lookup for VEX correlation. |
|
||||
| 15e | SBOM-HASH-186-015E | TODO | Depends on 15b, 15d. | Sbomer Guild | Implement SBOM content hash computation: canonical JSON → BLAKE3 hash; store as `sbom_content_hash` in replay manifests; enable deduplication. |
|
||||
| 15f | SBOM-TESTS-186-015F | TODO | Depends on 15a-15e. | Sbomer Guild · QA Guild (`src/Sbomer/__Tests`) | Roundtrip tests: SPDX→CDX→SPDX with diff assertion; determinism tests (same input → same hash); SPDX 3.0.1 spec compliance validation. |
|
||||
| 16 | DOCS-REPLAY-186-004 | TODO | After replay schema settled. | Docs Guild | Author `docs/replay/TEST_STRATEGY.md` (golden replay, feed drift, tool upgrade); link from replay docs and Scanner architecture. |
|
||||
| 17 | DOCS-SBOM-186-017 | TODO | Depends on 15a-15f. | Docs Guild (`docs/modules/sbomer/spdx-3.md`) | Document SPDX 3.0.1 implementation: data model, serialization formats, CDX mapping table, storage schema, hash computation, migration guide from SPDX 2.3. |
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2025-11-27 | Expanded SBOM-BRIDGE-186-015 with detailed subtasks (15a-15f) for SPDX 3.0.1 implementation per product advisory `27-Nov-2025 - Deep Architecture Brief - SBOM‑First, VEX‑Ready Spine.md`; added DOCS-SBOM-186-017 for documentation. | Product Mgmt |
|
||||
| 2025-11-26 | Wired record-mode attach helper into scan snapshots and replay status; added replay surface test (build run aborted mid-restore, rerun pending). | Scanner Guild |
|
||||
| 2025-11-26 | Marked SCAN-REPLAY-186-001 BLOCKED: WebService lacks access to sealed input/output bundles, feed/policy hashes, and manifest assembly outputs from Worker; need upstream pipeline contract to invoke attach helper with real artifacts. | Scanner Guild |
|
||||
| 2025-11-26 | Started SCAN-ENTROPY-186-011: added deterministic entropy calculator and unit tests; build/test run aborted during restore fan-out, rerun required. | Scanner Guild |
|
||||
@@ -54,6 +65,10 @@
|
||||
- Signing/verification changes must stay aligned with Provenance library once available.
|
||||
- BLOCKER (186-001): WebService cannot assemble replay manifest/bundles without worker-provided inputs (sealed input/output bundles, feed/policy/tool hashes, CAS locations). Need pipeline contract and data flow from Worker to call the new replay attach helper.
|
||||
- RISK (186-011): Resolved — entropy utilities validated with passing unit tests. Proceed to pipeline integration and evidence emission.
|
||||
- RISK (SPDX 3.0.1): SPDX 3.0.1 uses JSON-LD which has complex serialization rules; ensure canonical output for deterministic hashing. Reference spec carefully.
|
||||
- DECISION (SPDX/CDX): SPDX 3.0.1 is canonical storage format; CycloneDX 1.6 is interchange format. Document loss-of-fidelity cases in mapping table (task 15c).
|
||||
|
||||
## Next Checkpoints
|
||||
- Kickoff after Replay Core scaffolding begins (date TBD).
|
||||
- SPDX 3.0.1 data model review (Sbomer Guild, date TBD).
|
||||
- CDX↔SPDX mapping table draft review (Sbomer Guild, date TBD).
|
||||
|
||||
74
docs/implplan/SPRINT_0190_0001_0001_cvss_v4_receipts.md
Normal file
74
docs/implplan/SPRINT_0190_0001_0001_cvss_v4_receipts.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Sprint 0190 · CVSS v4.0 Score Receipts
|
||||
|
||||
## Topic & Scope
|
||||
- Implement CVSS v4.0 scoring engine with deterministic receipt generation.
|
||||
- Store CVSS-BTE (Base + Threat + Environmental) scores with full audit trail.
|
||||
- Enable policy-driven scoring with evidence linkage and DSSE attestations.
|
||||
- **Working directory:** `src/Policy/StellaOps.Policy.Scoring` (new), `src/Signals/StellaOps.Signals`.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Upstream: Sprint 0127/0128 Policy Engine observability; Sprint 0161 Evidence Locker.
|
||||
- Concurrency: Data model and scoring engine can proceed in parallel; UI/CLI integration follows.
|
||||
- Peers: Align with Concelier for vendor-provided CVSS v4.0 vectors; Excititor for VEX score context.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/README.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/policy/architecture.md`
|
||||
- `docs/modules/signals/architecture.md`
|
||||
- Product advisory: `docs/product-advisories/25-Nov-2025 - Add CVSS v4.0 Score Receipts for Transparency.md`
|
||||
- FIRST CVSS v4.0 Specification: https://www.first.org/cvss/v4-0/specification-document
|
||||
- FIRST CVSS v4.0 Calculator: https://www.first.org/cvss/calculator/4-0
|
||||
- Module AGENTS.md: Create `src/Policy/StellaOps.Policy.Scoring/AGENTS.md` as part of task 1
|
||||
|
||||
## Delivery Tracker
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | CVSS-MODEL-190-001 | TODO | None; foundational. | Policy Guild · Signals Guild (`src/Policy/StellaOps.Policy.Scoring`) | Design and implement CVSS v4.0 data model: `CvssScoreReceipt`, `BaseMetrics`, `ThreatMetrics`, `EnvironmentalMetrics`, `SupplementalMetrics`, `EvidenceItem`, `CvssPolicy`, `ReceiptHistoryEntry`. Include EF Core mappings and MongoDB schema. |
|
||||
| 2 | CVSS-ENGINE-190-002 | TODO | Depends on 190-001 for types. | Policy Guild (`src/Policy/StellaOps.Policy.Scoring/Engine`) | Implement `CvssV4Engine` with: `ParseVector()`, `ComputeBaseScore()`, `ComputeThreatAdjustedScore()`, `ComputeEnvironmentalAdjustedScore()`, `BuildVector()`. Follow FIRST spec v4.0 exactly for math/rounding. |
|
||||
| 3 | CVSS-TESTS-190-003 | TODO | Depends on 190-002. | Policy Guild · QA Guild (`src/Policy/__Tests/StellaOps.Policy.Scoring.Tests`) | Unit tests for CVSS v4.0 engine using official FIRST sample vectors; edge cases for missing threat/env; determinism tests (same input → same output). |
|
||||
| 4 | CVSS-POLICY-190-004 | TODO | Depends on 190-002. | Policy Guild (`src/Policy/StellaOps.Policy.Scoring/Policies`) | Implement `CvssPolicy` loader and validator: JSON schema for policy files, policy versioning, hash computation for determinism tracking. |
|
||||
| 5 | CVSS-RECEIPT-190-005 | TODO | Depends on 190-002, 190-004. | Policy Guild (`src/Policy/StellaOps.Policy.Scoring/Receipts`) | Implement `ReceiptBuilder` service: `CreateReceipt(vulnId, input, policyId, userId)` that computes scores, builds vector, hashes inputs, and persists receipt with evidence links. |
|
||||
| 6 | CVSS-DSSE-190-006 | TODO | Depends on 190-005; uses Attestor primitives. | Policy Guild · Attestor Guild (`src/Policy/StellaOps.Policy.Scoring`, `src/Attestor/StellaOps.Attestor.Envelope`) | Attach DSSE attestations to score receipts: create `stella.ops/cvssReceipt@v1` predicate type, sign receipts, store envelope references. |
|
||||
| 7 | CVSS-HISTORY-190-007 | TODO | Depends on 190-005. | Policy Guild (`src/Policy/StellaOps.Policy.Scoring/History`) | Implement receipt amendment tracking: `AmendReceipt(receiptId, field, newValue, reason, ref)` with history entry creation and re-signing. |
|
||||
| 8 | CVSS-CONCELIER-190-008 | TODO | Depends on 190-001; coordinate with Concelier. | Concelier Guild · Policy Guild (`src/Concelier/__Libraries/StellaOps.Concelier.Core`) | Ingest vendor-provided CVSS v4.0 vectors from advisories; parse and store as base receipts; preserve provenance. |
|
||||
| 9 | CVSS-API-190-009 | TODO | Depends on 190-005, 190-007. | Policy Guild (`src/Policy/StellaOps.Policy.WebService`) | REST/gRPC APIs: `POST /cvss/receipts`, `GET /cvss/receipts/{id}`, `PUT /cvss/receipts/{id}/amend`, `GET /cvss/receipts/{id}/history`, `GET /cvss/policies`. |
|
||||
| 10 | CVSS-CLI-190-010 | TODO | Depends on 190-009. | CLI Guild (`src/Cli/StellaOps.Cli`) | CLI verbs: `stella cvss score --vuln <id>`, `stella cvss show <receiptId>`, `stella cvss history <receiptId>`, `stella cvss export <receiptId> --format json|pdf`. |
|
||||
| 11 | CVSS-UI-190-011 | TODO | Depends on 190-009. | UI Guild (`src/UI/StellaOps.UI`) | UI components: Score badge with CVSS-BTE label, tabbed receipt viewer (Base/Threat/Environmental/Supplemental/Evidence/Policy/History), "Recalculate with my env" button, export options. |
|
||||
| 12 | CVSS-DOCS-190-012 | TODO | Depends on 190-001 through 190-011. | Docs Guild (`docs/modules/policy/cvss-v4.md`, `docs/09_API_CLI_REFERENCE.md`) | Document CVSS v4.0 scoring system: data model, policy format, API reference, CLI usage, UI guide, determinism guarantees. |
|
||||
|
||||
## Wave Coordination
|
||||
| Wave | Guild owners | Shared prerequisites | Status | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| W1 Foundation | Policy Guild | None | TODO | Tasks 1-4: Data model, engine, tests, policy loader. |
|
||||
| W2 Receipt Pipeline | Policy Guild · Attestor Guild | W1 complete | TODO | Tasks 5-7: Receipt builder, DSSE, history. |
|
||||
| W3 Integration | Concelier · Policy · CLI · UI Guilds | W2 complete | TODO | Tasks 8-11: Vendor ingest, APIs, CLI, UI. |
|
||||
| W4 Documentation | Docs Guild | W3 complete | TODO | Task 12: Full documentation. |
|
||||
|
||||
## Interlocks
|
||||
- CVSS v4.0 vectors from Concelier must preserve vendor provenance (task 8 depends on Concelier ingestion patterns).
|
||||
- DSSE attestation format must align with existing `stella.ops/*` predicate catalog (coordinate with Sprint 0401 AUTH-REACH tasks).
|
||||
- Score receipts should integrate with VEX decisions in Excititor for complete vulnerability context.
|
||||
|
||||
## Upcoming Checkpoints
|
||||
- TBD: CVSS v4.0 data model review (Policy Guild).
|
||||
- TBD: Engine implementation demo with FIRST test vectors (Policy Guild).
|
||||
- TBD: UI wireframe review (UI Guild).
|
||||
|
||||
## Action Tracker
|
||||
| # | Action | Owner | Due (UTC) | Status | Notes |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | Review FIRST CVSS v4.0 spec and identify implementation gaps. | Policy Guild | TBD | Open | Reference: https://www.first.org/cvss/v4-0/ |
|
||||
| 2 | Draft CvssPolicy JSON schema for team review. | Policy Guild | TBD | Open | |
|
||||
|
||||
## Decisions & Risks
|
||||
| ID | Risk | Impact | Mitigation / Owner |
|
||||
| --- | --- | --- | --- |
|
||||
| R1 | CVSS v4.0 spec complexity leads to implementation errors. | Incorrect scores, audit failures. | Use official FIRST test vectors; cross-check with FIRST calculator; Policy Guild. |
|
||||
| R2 | Vendor advisories inconsistently provide v4.0 vectors. | Gaps in base scores; fallback to v3.1 conversion. | Implement v3.1→v4.0 heuristic mapping with explicit "converted" flag; Concelier Guild. |
|
||||
| R3 | Receipt storage grows large with evidence links. | Storage costs; query performance. | Implement evidence reference deduplication; use CAS URIs; Platform Guild. |
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2025-11-27 | Sprint created from product advisory `25-Nov-2025 - Add CVSS v4.0 Score Receipts for Transparency.md`; 12 tasks defined across 4 waves. | Product Mgmt |
|
||||
@@ -28,8 +28,8 @@
|
||||
## Delivery Tracker
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | UI-AOC-19-001 | TODO | Align tiles with AOC service metrics | UI Guild (src/UI/StellaOps.UI) | Add Sources dashboard tiles showing AOC pass/fail, recent violation codes, and ingest throughput per tenant. |
|
||||
| 2 | UI-AOC-19-002 | TODO | UI-AOC-19-001 | UI Guild (src/UI/StellaOps.UI) | Implement violation drill-down view highlighting offending document fields and provenance metadata. |
|
||||
| 1 | UI-AOC-19-001 | DONE | Align tiles with AOC service metrics | UI Guild (src/UI/StellaOps.UI) | Add Sources dashboard tiles showing AOC pass/fail, recent violation codes, and ingest throughput per tenant. |
|
||||
| 2 | UI-AOC-19-002 | DONE | UI-AOC-19-001 | UI Guild (src/UI/StellaOps.UI) | Implement violation drill-down view highlighting offending document fields and provenance metadata. |
|
||||
| 3 | UI-AOC-19-003 | TODO | UI-AOC-19-002 | UI Guild (src/UI/StellaOps.UI) | Add "Verify last 24h" action triggering AOC verifier endpoint and surfacing CLI parity guidance. |
|
||||
| 4 | UI-EXC-25-001 | TODO | - | UI Guild; Governance Guild (src/UI/StellaOps.UI) | Build Exception Center (list + kanban) with filters, sorting, workflow transitions, and audit views. |
|
||||
| 5 | UI-EXC-25-002 | TODO | UI-EXC-25-001 | UI Guild (src/UI/StellaOps.UI) | Implement exception creation wizard with scope preview, justification templates, timebox guardrails. |
|
||||
@@ -43,10 +43,10 @@
|
||||
| 13 | UI-GRAPH-24-004 | TODO | UI-GRAPH-24-003 | UI Guild (src/UI/StellaOps.UI) | Add side panels (Details, What-if, History) with upgrade simulation integration and SBOM diff viewer. |
|
||||
| 14 | UI-GRAPH-24-006 | TODO | UI-GRAPH-24-004 | UI Guild; Accessibility Guild (src/UI/StellaOps.UI) | Ensure accessibility (keyboard nav, screen reader labels, contrast), add hotkeys (`f`,`e`,`.`), and analytics instrumentation. |
|
||||
| 15 | UI-LNM-22-001 | TODO | - | UI Guild; Policy Guild (src/UI/StellaOps.UI) | Build Evidence panel showing policy decision with advisory observations/linksets side-by-side, conflict badges, AOC chain, and raw doc download links (DOCS-LNM-22-005 awaiting UI screenshots/flows). |
|
||||
| 16 | UI-SBOM-DET-01 | TODO | - | UI Guild (src/UI/StellaOps.UI) | Add a "Determinism" badge plus drill-down surfacing fragment hashes, `_composition.json`, and Merkle root consistency when viewing scan details. |
|
||||
| 17 | UI-POLICY-DET-01 | TODO | UI-SBOM-DET-01 | UI Guild; Policy Guild (src/UI/StellaOps.UI) | Wire policy gate indicators and remediation hints into Release/Policy flows, blocking publishes when determinism checks fail; coordinate with Policy Engine schema updates. |
|
||||
| 18 | UI-ENTROPY-40-001 | TODO | - | UI Guild (src/UI/StellaOps.UI) | Visualise entropy analysis per image (layer donut, file heatmaps, "Why risky?" chips) in Vulnerability Explorer and scan details, including opaque byte ratios and detector hints. |
|
||||
| 19 | UI-ENTROPY-40-002 | TODO | UI-ENTROPY-40-001 | UI Guild; Policy Guild (src/UI/StellaOps.UI) | Add policy banners/tooltips explaining entropy penalties (block/warn thresholds, mitigation steps) and link to raw `entropy.report.json` evidence downloads. |
|
||||
| 16 | UI-SBOM-DET-01 | DONE | - | UI Guild (src/UI/StellaOps.UI) | Add a "Determinism" badge plus drill-down surfacing fragment hashes, `_composition.json`, and Merkle root consistency when viewing scan details. |
|
||||
| 17 | UI-POLICY-DET-01 | DONE | UI-SBOM-DET-01 | UI Guild; Policy Guild (src/UI/StellaOps.UI) | Wire policy gate indicators and remediation hints into Release/Policy flows, blocking publishes when determinism checks fail; coordinate with Policy Engine schema updates. |
|
||||
| 18 | UI-ENTROPY-40-001 | DONE | - | UI Guild (src/UI/StellaOps.UI) | Visualise entropy analysis per image (layer donut, file heatmaps, "Why risky?" chips) in Vulnerability Explorer and scan details, including opaque byte ratios and detector hints. |
|
||||
| 19 | UI-ENTROPY-40-002 | DONE | UI-ENTROPY-40-001 | UI Guild; Policy Guild (src/UI/StellaOps.UI) | Add policy banners/tooltips explaining entropy penalties (block/warn thresholds, mitigation steps) and link to raw `entropy.report.json` evidence downloads. |
|
||||
|
||||
## Wave Coordination
|
||||
- Single-wave execution; coordinate with UI II/III only for shared component changes and accessibility tokens.
|
||||
@@ -92,3 +92,9 @@
|
||||
| 2025-11-22 | Deduplicated `tasks-all.md` rows for this sprint (kept first occurrence per Task ID); no status changes. | Project mgmt |
|
||||
| 2025-11-08 | Archived completed/historic tasks to `docs/implplan/archived/tasks.md`. | Planning |
|
||||
| 2025-11-22 | Added SDK interlock (SPRINT_0208_0001_0001_sdk) and Action #5 for parity matrix delivery to UI data providers. | Project mgmt |
|
||||
| 2025-11-27 | UI-AOC-19-001 DONE: Created Sources dashboard with AOC pass/fail tiles, violation codes, ingest throughput. Files: `aoc.models.ts`, `aoc.client.ts`, `sources-dashboard.component.{ts,html,scss}`. Added route at `/dashboard/sources`. | Claude Code |
|
||||
| 2025-11-27 | UI-SBOM-DET-01 DONE: Created Determinism badge component with expandable details showing Merkle root, fragment hashes, composition metadata, and issues. Files: `determinism.models.ts`, `determinism-badge.component.{ts,html,scss}`. | Claude Code |
|
||||
| 2025-11-27 | UI-ENTROPY-40-001 DONE: Created Entropy panel with score ring, layer donut chart, high-entropy files heatmap, and detector hint chips. Files: `entropy.models.ts`, `entropy-panel.component.{ts,html,scss}`. | Claude Code |
|
||||
| 2025-11-27 | UI-AOC-19-002 DONE: Created violation drill-down with by-violation/by-document views, field highlighting, provenance metadata, and remediation hints. Extended `aoc.models.ts`, created `violation-drilldown.component.{ts,html,scss}`. | Claude Code |
|
||||
| 2025-11-27 | UI-POLICY-DET-01 DONE: Created policy gate indicator with determinism/entropy details, blocking issue display, and remediation steps. Files: `policy.models.ts`, `policy-gate-indicator.component.{ts,html,scss}`. | Claude Code |
|
||||
| 2025-11-27 | UI-ENTROPY-40-002 DONE: Created entropy policy banner with threshold visualization, score bar, mitigation steps, and evidence download. Files: `entropy-policy-banner.component.{ts,html,scss}`. | Claude Code |
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Sprint 0513 · Public Reachability Benchmark
|
||||
|
||||
## Topic & Scope
|
||||
- Create and publish a public benchmark for evaluating reachability analysis tools.
|
||||
- Deliver reproducible dataset with ground-truth labels, deterministic builds, and scoring harness.
|
||||
- Position Stella Ops as industry leader in deterministic vulnerability reachability.
|
||||
- **Working directory:** `bench/reachability-benchmark/` (new public-facing repo structure).
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Upstream: Sprint 0401 Reachability Evidence Chain for internal reachability implementation.
|
||||
- Upstream: Sprint 0512 Bench for internal performance benchmarks.
|
||||
- Concurrency: Dataset creation (W1) can proceed in parallel with scorer development (W2).
|
||||
- Peers: Marketing/PMM for launch messaging; Legal for licensing review.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/README.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/reachability/function-level-evidence.md`
|
||||
- `docs/reachability/lattice.md`
|
||||
- `docs/modules/scanner/architecture.md`
|
||||
- Product advisory: `docs/product-advisories/24-Nov-2025 - Designing a Deterministic Reachability Benchmark.md`
|
||||
- Related advisory: `docs/product-advisories/archived/23-Nov-2025 - Benchmarking Determinism in Vulnerability Scoring.md`
|
||||
- Related advisory: `docs/product-advisories/archived/23-Nov-2025 - Publishing a Reachability Benchmark Dataset.md`
|
||||
- Existing bench prep docs: `docs/benchmarks/signals/bench-determinism.md`
|
||||
|
||||
## Delivery Tracker
|
||||
| # | Task ID | Status | Key dependency / next step | Owners | Task Definition |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | BENCH-REPO-513-001 | TODO | None; foundational. | Bench Guild · DevOps Guild | Create public repository structure: `benchmark/cases/<lang>/<project>/`, `benchmark/schemas/`, `benchmark/tools/scorer/`, `baselines/`, `ci/`, `website/`. Add LICENSE (Apache-2.0), README, CONTRIBUTING.md. |
|
||||
| 2 | BENCH-SCHEMA-513-002 | TODO | Depends on 513-001. | Bench Guild | Define and publish schemas: `case.schema.yaml` (component, sink, label, evidence), `entrypoints.schema.yaml`, `truth.schema.yaml`, `submission.schema.json`. Include JSON Schema validation. |
|
||||
| 3 | BENCH-CASES-JS-513-003 | TODO | Depends on 513-002. | Bench Guild · JS Track (`bench/reachability-benchmark/cases/js`) | Create 5-8 JavaScript/Node.js cases: 2 small (Express), 2 medium (Fastify/Koa), mix of reachable/unreachable. Include Dockerfiles, package-lock.json, unit test oracles, coverage output. |
|
||||
| 4 | BENCH-CASES-PY-513-004 | TODO | Depends on 513-002. | Bench Guild · Python Track (`bench/reachability-benchmark/cases/py`) | Create 5-8 Python cases: Flask, Django, FastAPI. Include requirements.txt pinned, pytest oracles, coverage.py output. |
|
||||
| 5 | BENCH-CASES-JAVA-513-005 | TODO | Depends on 513-002. | Bench Guild · Java Track (`bench/reachability-benchmark/cases/java`) | Create 5-8 Java cases: Spring Boot, Micronaut. Include pom.xml locked, JUnit oracles, JaCoCo coverage. |
|
||||
| 6 | BENCH-CASES-C-513-006 | TODO | Depends on 513-002. | Bench Guild · Native Track (`bench/reachability-benchmark/cases/c`) | Create 3-5 C/ELF cases: small HTTP servers, crypto utilities. Include Makefile, gcov/llvm-cov coverage, deterministic builds (SOURCE_DATE_EPOCH). |
|
||||
| 7 | BENCH-BUILD-513-007 | TODO | Depends on 513-003 through 513-006. | Bench Guild · DevOps Guild | Implement `build_all.py` and `validate_builds.py`: deterministic Docker builds, hash verification, SBOM generation (syft), attestation stubs. |
|
||||
| 8 | BENCH-SCORER-513-008 | TODO | Depends on 513-002. | Bench Guild (`bench/reachability-benchmark/tools/scorer`) | Implement `rb-score` CLI: load cases/truth, validate submissions, compute precision/recall/F1, explainability score (0-3), runtime stats, determinism rate. |
|
||||
| 9 | BENCH-EXPLAIN-513-009 | TODO | Depends on 513-008. | Bench Guild | Implement explainability scoring rules: 0=no context, 1=path with ≥2 nodes, 2=entry+≥3 nodes, 3=guards/constraints included. Unit tests for each level. |
|
||||
| 10 | BENCH-BASELINE-SEMGREP-513-010 | TODO | Depends on 513-008 and cases. | Bench Guild | Semgrep baseline runner: `baselines/semgrep/run_case.sh`, rule config, output normalization to submission format. |
|
||||
| 11 | BENCH-BASELINE-CODEQL-513-011 | TODO | Depends on 513-008 and cases. | Bench Guild | CodeQL baseline runner: database creation, reachability queries, output normalization. Document CodeQL license requirements. |
|
||||
| 12 | BENCH-BASELINE-STELLA-513-012 | TODO | Depends on 513-008 and Sprint 0401 reachability. | Bench Guild · Scanner Guild | Stella Ops baseline runner: invoke `stella scan` with reachability, normalize output, demonstrate determinism advantage. |
|
||||
| 13 | BENCH-CI-513-013 | TODO | Depends on 513-007, 513-008. | Bench Guild · DevOps Guild | GitHub Actions workflow: lint, test scorer, build cases, run smoke baselines, upload artifacts. |
|
||||
| 14 | BENCH-LEADERBOARD-513-014 | TODO | Depends on 513-008. | Bench Guild | Implement `rb-score compare` to generate `leaderboard.json` from multiple submissions; breakdown by language and case size. |
|
||||
| 15 | BENCH-WEBSITE-513-015 | TODO | Depends on 513-014. | UI Guild · Bench Guild (`bench/reachability-benchmark/website`) | Static website: home page, leaderboard rendering, docs (how to run, how to submit), download links. Use Docusaurus or plain HTML. |
|
||||
| 16 | BENCH-DOCS-513-016 | TODO | Depends on all above. | Docs Guild | CONTRIBUTING.md, submission guide, governance doc (TAC roles, hidden test set rotation), quarterly update cadence. |
|
||||
| 17 | BENCH-LAUNCH-513-017 | TODO | Depends on 513-015, 513-016. | Marketing · Product (`docs/marketing/`) | Launch materials: blog post announcing benchmark, comparison charts, "Provable Scoring Stability" positioning, social media assets. |
|
||||
|
||||
## Wave Coordination
|
||||
| Wave | Guild owners | Shared prerequisites | Status | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| W1 Foundation | Bench Guild · DevOps Guild | None | TODO | Tasks 1-2: Repo, schemas. |
|
||||
| W2 Dataset | Bench Guild (per language track) | W1 complete | TODO | Tasks 3-7: Cases, builds. |
|
||||
| W3 Scoring | Bench Guild | W1 complete | TODO | Tasks 8-9: Scorer, explainability (parallel with W2). |
|
||||
| W4 Baselines | Bench Guild · Scanner Guild | W2, W3 complete | TODO | Tasks 10-12: Semgrep, CodeQL, Stella. |
|
||||
| W5 Publish | All Guilds | W4 complete | TODO | Tasks 13-17: CI, leaderboard, website, docs, launch. |
|
||||
|
||||
## Interlocks
|
||||
- Stella Ops baseline (task 12) requires Sprint 0401 reachability to be functional.
|
||||
- Legal review needed for open-source licensing and third-party tool inclusion.
|
||||
- Marketing coordination for launch timing and messaging.
|
||||
|
||||
## Upcoming Checkpoints
|
||||
- TBD: Schema review (Bench Guild).
|
||||
- TBD: First 10 cases complete (language tracks).
|
||||
- TBD: Scorer MVP demo (Bench Guild).
|
||||
- TBD: Launch readiness review (Product + Marketing).
|
||||
|
||||
## Action Tracker
|
||||
| # | Action | Owner | Due (UTC) | Status | Notes |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | Select 8 seed projects (2 per language tier) for v1 cases. | Bench Guild | TBD | Open | |
|
||||
| 2 | Draft 12 initial sink-cases with unit test oracles. | Language Tracks | TBD | Open | |
|
||||
| 3 | Legal review of Apache-2.0 licensing for benchmark. | Legal | TBD | Open | |
|
||||
|
||||
## Decisions & Risks
|
||||
| ID | Risk | Impact | Mitigation / Owner |
|
||||
| --- | --- | --- | --- |
|
||||
| R1 | Case quality varies across language tracks. | Inconsistent benchmark validity. | Peer review all cases; require oracle tests; Bench Guild. |
|
||||
| R2 | Baseline tools have licensing restrictions. | Cannot include in public benchmark. | Document license requirements; exclude or limit usage; Legal. |
|
||||
| R3 | Hidden test set leakage. | Overfitting by vendors. | Rotate quarterly; governance controls; TAC. |
|
||||
| R4 | Deterministic builds fail on some platforms. | Reproducibility claims undermined. | Pin all toolchain versions; use SOURCE_DATE_EPOCH; DevOps Guild. |
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2025-11-27 | Sprint created from product advisory `24-Nov-2025 - Designing a Deterministic Reachability Benchmark.md`; 17 tasks defined across 5 waves. | Product Mgmt |
|
||||
@@ -7,17 +7,17 @@ Depends on: Sprint 170.A - Notifier.I
|
||||
Summary: Notifications & Telemetry focus on Notifier (phase II).
|
||||
Task ID | State | Task description | Owners (Source)
|
||||
--- | --- | --- | ---
|
||||
NOTIFY-SVC-37-001 | TODO | Define pack approval & policy notification contract, including OpenAPI schema, event payloads, resume token mechanics, and security guidance. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-37-002 | TODO | Implement secure ingestion endpoint, Mongo persistence (`pack_approvals`), idempotent writes, and audit trail for approval events. Dependencies: NOTIFY-SVC-37-001. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-37-003 | TODO | Deliver approval/policy templates, routing predicates, and channel dispatch (email + webhook) with localization + redaction. Dependencies: NOTIFY-SVC-37-002. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-37-004 | TODO | Provide acknowledgement API, Task Runner callback client, metrics for outstanding approvals, and runbook updates. Dependencies: NOTIFY-SVC-37-003. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-38-002 | TODO | Implement channel adapters (email, chat webhook, generic webhook) with retry policies, health checks, and audit logging. Dependencies: NOTIFY-SVC-37-004. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-38-003 | TODO | Deliver template service (versioned templates, localization scaffolding) and renderer with redaction allowlists, Markdown/HTML/JSON outputs, and provenance links. Dependencies: NOTIFY-SVC-38-002. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-38-004 | TODO | Expose REST + WS APIs (rules CRUD, templates preview, incidents list, ack) with audit logging, RBAC checks, and live feed stream. Dependencies: NOTIFY-SVC-38-003. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-39-001 | TODO | Implement correlation engine with pluggable key expressions/windows, throttler (token buckets), quiet hours/maintenance evaluator, and incident lifecycle. Dependencies: NOTIFY-SVC-38-004. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-39-002 | TODO | Build digest generator (queries, formatting) with schedule runner and distribution via existing channels. Dependencies: NOTIFY-SVC-39-001. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-39-003 | TODO | Provide simulation engine/API to dry-run rules against historical events, returning matched actions with explanations. Dependencies: NOTIFY-SVC-39-002. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-39-004 | TODO | Integrate quiet hour calendars and default throttles with audit logging and operator overrides. Dependencies: NOTIFY-SVC-39-003. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-37-001 | DONE (2025-11-27) | Define pack approval & policy notification contract, including OpenAPI schema, event payloads, resume token mechanics, and security guidance. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-37-002 | DONE (2025-11-27) | Implement secure ingestion endpoint, Mongo persistence (`pack_approvals`), idempotent writes, and audit trail for approval events. Dependencies: NOTIFY-SVC-37-001. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-37-003 | DONE (2025-11-27) | Deliver approval/policy templates, routing predicates, and channel dispatch (email + webhook) with localization + redaction. Dependencies: NOTIFY-SVC-37-002. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-37-004 | DONE (2025-11-27) | Provide acknowledgement API, Task Runner callback client, metrics for outstanding approvals, and runbook updates. Dependencies: NOTIFY-SVC-37-003. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-38-002 | DONE (2025-11-27) | Implement channel adapters (email, chat webhook, generic webhook) with retry policies, health checks, and audit logging. Dependencies: NOTIFY-SVC-37-004. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-38-003 | DONE (2025-11-27) | Deliver template service (versioned templates, localization scaffolding) and renderer with redaction allowlists, Markdown/HTML/JSON outputs, and provenance links. Dependencies: NOTIFY-SVC-38-002. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-38-004 | DONE (2025-11-27) | Expose REST + WS APIs (rules CRUD, templates preview, incidents list, ack) with audit logging, RBAC checks, and live feed stream. Dependencies: NOTIFY-SVC-38-003. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-39-001 | DONE (2025-11-27) | Implement correlation engine with pluggable key expressions/windows, throttler (token buckets), quiet hours/maintenance evaluator, and incident lifecycle. Dependencies: NOTIFY-SVC-38-004. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-39-002 | DONE (2025-11-27) | Build digest generator (queries, formatting) with schedule runner and distribution via existing channels. Dependencies: NOTIFY-SVC-39-001. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-39-003 | DONE (2025-11-27) | Provide simulation engine/API to dry-run rules against historical events, returning matched actions with explanations. Dependencies: NOTIFY-SVC-39-002. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-39-004 | DONE (2025-11-27) | Integrate quiet hour calendars and default throttles with audit logging and operator overrides. Dependencies: NOTIFY-SVC-39-003. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-40-001 | DONE (2025-11-27) | Implement escalations + on-call schedules, ack bridge, PagerDuty/OpsGenie adapters, and CLI/in-app inbox channels. Dependencies: NOTIFY-SVC-39-004. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-40-002 | DONE (2025-11-27) | Add summary storm breaker notifications, localization bundles, and localization fallback handling. Dependencies: NOTIFY-SVC-40-001. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-SVC-40-003 | SKIPPED | Harden security: signed ack links (KMS), webhook HMAC/IP allowlists, tenant isolation fuzz tests, HTML sanitization. Dependencies: NOTIFY-SVC-40-002. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
|
||||
@@ -7,4 +7,4 @@ Depends on: Sprint 170.A - Notifier.II
|
||||
Summary: Notifications & Telemetry focus on Notifier (phase III).
|
||||
Task ID | State | Task description | Owners (Source)
|
||||
--- | --- | --- | ---
|
||||
NOTIFY-TEN-48-001 | TODO | Tenant-scope rules/templates/incidents, RLS on storage, tenant-prefixed channels, and inclusion of tenant context in notifications. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
NOTIFY-TEN-48-001 | DONE (2025-11-27) | Tenant-scope rules/templates/incidents, RLS on storage, tenant-prefixed channels, and inclusion of tenant context in notifications. | Notifications Service Guild (src/Notifier/StellaOps.Notifier)
|
||||
130
docs/product-advisories/ADVISORY_INDEX.md
Normal file
130
docs/product-advisories/ADVISORY_INDEX.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Product Advisory Index
|
||||
|
||||
This index consolidates the November 2025 product advisories, identifying canonical documents and duplicates.
|
||||
|
||||
## Canonical Advisories (Active)
|
||||
|
||||
These are the authoritative advisories to reference for implementation:
|
||||
|
||||
### CVSS v4.0
|
||||
- **Canonical:** `25-Nov-2025 - Add CVSS v4.0 Score Receipts for Transparency.md`
|
||||
- **Sprint:** SPRINT_0190_0001_0001_cvss_v4_receipts.md
|
||||
- **Status:** New sprint created
|
||||
|
||||
### SBOM/VEX Pipeline
|
||||
- **Canonical:** `27-Nov-2025 - Deep Architecture Brief - SBOM‑First, VEX‑Ready Spine.md`
|
||||
- **Sprint:** SPRINT_0186_0001_0001_record_deterministic_execution.md (tasks 15a-15f)
|
||||
- **Supersedes:**
|
||||
- `24-Nov-2025 - Bridging OpenVEX and CycloneDX for .NET.md` → archive
|
||||
- `25-Nov-2025 - Revisiting Determinism in SBOM→VEX Pipeline.md` → archive
|
||||
- `26-Nov-2025 - From SBOM to VEX - Building a Transparent Chain.md` → archive
|
||||
|
||||
### Rekor/DSSE Batch Sizing
|
||||
- **Canonical:** `26-Nov-2025 - Handling Rekor v2 and DSSE Air‑Gap Limits.md`
|
||||
- **Sprint:** SPRINT_0401_0001_0001_reachability_evidence_chain.md (DSSE tasks)
|
||||
- **Supersedes:**
|
||||
- `27-Nov-2025 - Rekor Envelope Size Heuristic.md` → archive (duplicate)
|
||||
- `27-Nov-2025 - DSSE and Rekor Envelope Size Heuristic.md` → archive (duplicate)
|
||||
- `27-Nov-2025 - Optimizing DSSE Batch Sizes for Reliable Logging.md` → archive (duplicate)
|
||||
|
||||
### Graph Revision IDs
|
||||
- **Canonical:** `26-Nov-2025 - Use Graph Revision IDs as Public Trust Anchors.md`
|
||||
- **Sprint:** SPRINT_0401_0001_0001_reachability_evidence_chain.md (existing tasks)
|
||||
- **Supersedes:**
|
||||
- `25-Nov-2025 - Hash‑Stable Graph Revisions Across Systems.md` → archive (earlier version)
|
||||
|
||||
### Reachability Benchmark (Public)
|
||||
- **Canonical:** `24-Nov-2025 - Designing a Deterministic Reachability Benchmark.md`
|
||||
- **Sprint:** SPRINT_0513_0001_0001_public_reachability_benchmark.md
|
||||
- **Related:**
|
||||
- `26-Nov-2025 - Opening Up a Reachability Dataset.md` → complementary (dataset focus)
|
||||
|
||||
### Unknowns Registry
|
||||
- **Canonical:** `27-Nov-2025 - Managing Ambiguity Through an Unknowns Registry.md`
|
||||
- **Sprint:** SPRINT_0140_0001_0001_runtime_signals.md (existing implementation)
|
||||
- **Extends:** `archived/18-Nov-2025 - Unknowns-Registry.md`
|
||||
- **Status:** Already implemented in Signals module; advisory validates design
|
||||
|
||||
### Explainability
|
||||
- **Canonical (Graphs):** `27-Nov-2025 - Making Graphs Understandable to Humans.md`
|
||||
- **Canonical (Verdicts):** `27-Nov-2025 - Explainability Layer for Vulnerability Verdicts.md`
|
||||
- **Sprint:** SPRINT_0401_0001_0001_reachability_evidence_chain.md (UI-CLI tasks)
|
||||
- **Status:** Complementary advisories - graphs cover edge reasons, verdicts cover audit trails
|
||||
|
||||
### VEX Proofs
|
||||
- **Canonical:** `25-Nov-2025 - Define Safe VEX 'Not Affected' Claims with Proofs.md`
|
||||
- **Sprint:** SPRINT_0401_0001_0001_reachability_evidence_chain.md (POLICY-VEX tasks)
|
||||
|
||||
### Binary Reachability
|
||||
- **Canonical:** `27-Nov-2025 - Verifying Binary Reachability via DSSE Envelopes.md`
|
||||
- **Sprint:** SPRINT_0401_0001_0001_reachability_evidence_chain.md (GRAPH-HYBRID tasks)
|
||||
|
||||
### Scanner Roadmap
|
||||
- **Canonical:** `27-Nov-2025 - Blueprint for a 2026‑Ready Scanner.md`
|
||||
- **Sprint:** Multiple sprints (0186, 0401, 0512)
|
||||
- **Status:** High-level roadmap document
|
||||
|
||||
## Files to Archive
|
||||
|
||||
The following files should be moved to `archived/` as they are superseded:
|
||||
|
||||
```
|
||||
# Duplicates/superseded
|
||||
24-Nov-2025 - Bridging OpenVEX and CycloneDX for .NET.md
|
||||
25-Nov-2025 - Revisiting Determinism in SBOM→VEX Pipeline.md
|
||||
25-Nov-2025 - Hash‑Stable Graph Revisions Across Systems.md
|
||||
26-Nov-2025 - From SBOM to VEX - Building a Transparent Chain.md
|
||||
27-Nov-2025 - Rekor Envelope Size Heuristic.md
|
||||
27-Nov-2025 - DSSE and Rekor Envelope Size Heuristic.md
|
||||
27-Nov-2025 - Optimizing DSSE Batch Sizes for Reliable Logging.md
|
||||
|
||||
# Junk/malformed files
|
||||
24-Nov-2025 - 1 copy 2.md
|
||||
24-Nov-2025 - Designing a Deterministic Reachability Benchmarkmd (missing dot)
|
||||
25-Nov-2025 - Half‑Life Confidence Decay for Unknownsmd (missing dot)
|
||||
```
|
||||
|
||||
## Sprint Cross-Reference
|
||||
|
||||
| Advisory Topic | Sprint ID | Status |
|
||||
|---------------|-----------|--------|
|
||||
| CVSS v4.0 | SPRINT_0190_0001_0001 | NEW |
|
||||
| SPDX 3.0.1 / SBOM | SPRINT_0186_0001_0001 | AUGMENTED |
|
||||
| Reachability Benchmark | SPRINT_0513_0001_0001 | NEW |
|
||||
| Reachability Evidence | SPRINT_0401_0001_0001 | EXISTING |
|
||||
| Unknowns Registry | SPRINT_0140_0001_0001 | EXISTING (implemented) |
|
||||
| Graph Revision IDs | SPRINT_0401_0001_0001 | EXISTING |
|
||||
| DSSE/Rekor Batching | SPRINT_0401_0001_0001 | EXISTING |
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
Based on gap analysis:
|
||||
|
||||
1. **P0 - CVSS v4.0** (Sprint 0190) - Industry moving to v4.0, genuine gap
|
||||
2. **P1 - SPDX 3.0.1** (Sprint 0186 tasks 15a-15f) - Standards compliance
|
||||
3. **P1 - Public Benchmark** (Sprint 0513) - Differentiation/marketing value
|
||||
4. **P2 - Explainability** (Sprint 0401) - UX enhancement, existing tasks
|
||||
5. **P3 - Already Implemented** - Unknowns, Graph IDs, DSSE batching
|
||||
|
||||
## Implementer Quick Reference
|
||||
|
||||
For each topic, the implementer should read:
|
||||
|
||||
1. **Sprint file** - Contains task definitions, dependencies, working directories
|
||||
2. **Documentation Prerequisites** - Listed in each sprint file
|
||||
3. **Canonical advisory** - Full product context and rationale
|
||||
4. **Module AGENTS.md** - If exists, contains module-specific coding guidance
|
||||
|
||||
### Key Module Docs to Read Before Implementation
|
||||
|
||||
| Module | Architecture Doc | AGENTS.md |
|
||||
|--------|-----------------|-----------|
|
||||
| Policy | `docs/modules/policy/architecture.md` | `src/Policy/*/AGENTS.md` |
|
||||
| Scanner | `docs/modules/scanner/architecture.md` | `src/Scanner/*/AGENTS.md` |
|
||||
| Sbomer | `docs/modules/sbomer/architecture.md` | `src/Sbomer/*/AGENTS.md` |
|
||||
| Signals | `docs/modules/signals/architecture.md` | `src/Signals/*/AGENTS.md` |
|
||||
| Attestor | `docs/modules/attestor/architecture.md` | `src/Attestor/*/AGENTS.md` |
|
||||
|
||||
---
|
||||
*Index created: 2025-11-27*
|
||||
*Last updated: 2025-11-27*
|
||||
@@ -0,0 +1,718 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Telemetry.Core.Tests;
|
||||
|
||||
public sealed class IncidentModeServiceTests : IDisposable
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Mock<ITelemetryContextAccessor> _contextAccessor;
|
||||
private readonly Mock<ILogger<IncidentModeService>> _logger;
|
||||
|
||||
public IncidentModeServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_contextAccessor = new Mock<ITelemetryContextAccessor>();
|
||||
_logger = new Mock<ILogger<IncidentModeService>>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Cleanup if needed
|
||||
}
|
||||
|
||||
private IncidentModeService CreateService(Action<IncidentModeOptions>? configure = null)
|
||||
{
|
||||
var options = new IncidentModeOptions
|
||||
{
|
||||
PersistState = false, // Disable persistence for tests
|
||||
RestoreOnStartup = false
|
||||
};
|
||||
configure?.Invoke(options);
|
||||
var monitor = new TestOptionsMonitor<IncidentModeOptions>(options);
|
||||
return new IncidentModeService(monitor, _contextAccessor.Object, _logger.Object, _timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_ValidActor_ReturnsSuccess()
|
||||
{
|
||||
using var service = CreateService();
|
||||
|
||||
var result = await service.ActivateAsync("test-actor");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.State);
|
||||
Assert.Equal("test-actor", result.State.Actor);
|
||||
Assert.True(service.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_NullActor_ThrowsArgumentException()
|
||||
{
|
||||
using var service = CreateService();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
service.ActivateAsync(null!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_EmptyActor_ThrowsArgumentException()
|
||||
{
|
||||
using var service = CreateService();
|
||||
|
||||
await Assert.ThrowsAsync<ArgumentException>(() =>
|
||||
service.ActivateAsync(""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_WithTenantId_StoresTenantId()
|
||||
{
|
||||
using var service = CreateService();
|
||||
|
||||
var result = await service.ActivateAsync("actor", tenantId: "tenant-123");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.State);
|
||||
Assert.Equal("tenant-123", result.State.TenantId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_WithReason_StoresReason()
|
||||
{
|
||||
using var service = CreateService();
|
||||
|
||||
var result = await service.ActivateAsync("actor", reason: "Production incident INC-001");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.State);
|
||||
Assert.Equal("Production incident INC-001", result.State.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_DefaultTtl_UsesConfiguredDefault()
|
||||
{
|
||||
using var service = CreateService(opt => opt.DefaultTtl = TimeSpan.FromMinutes(45));
|
||||
|
||||
var result = await service.ActivateAsync("actor");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.State);
|
||||
var expectedExpiry = _timeProvider.GetUtcNow() + TimeSpan.FromMinutes(45);
|
||||
Assert.Equal(expectedExpiry, result.State.ExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_CustomTtl_UsesTtlOverride()
|
||||
{
|
||||
using var service = CreateService();
|
||||
|
||||
var result = await service.ActivateAsync("actor", ttlOverride: TimeSpan.FromHours(2));
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.State);
|
||||
var expectedExpiry = _timeProvider.GetUtcNow() + TimeSpan.FromHours(2);
|
||||
Assert.Equal(expectedExpiry, result.State.ExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_TtlBelowMin_ClampedToMin()
|
||||
{
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.MinTtl = TimeSpan.FromMinutes(10);
|
||||
});
|
||||
|
||||
var result = await service.ActivateAsync("actor", ttlOverride: TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.State);
|
||||
var expectedExpiry = _timeProvider.GetUtcNow() + TimeSpan.FromMinutes(10);
|
||||
Assert.Equal(expectedExpiry, result.State.ExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_TtlAboveMax_ClampedToMax()
|
||||
{
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.MaxTtl = TimeSpan.FromHours(4);
|
||||
});
|
||||
|
||||
var result = await service.ActivateAsync("actor", ttlOverride: TimeSpan.FromHours(48));
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.State);
|
||||
var expectedExpiry = _timeProvider.GetUtcNow() + TimeSpan.FromHours(4);
|
||||
Assert.Equal(expectedExpiry, result.State.ExpiresAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_AlreadyActive_ExtendsTtlAndReturnsWasAlreadyActive()
|
||||
{
|
||||
using var service = CreateService();
|
||||
|
||||
var firstResult = await service.ActivateAsync("actor1");
|
||||
var firstActivationId = firstResult.State!.ActivationId;
|
||||
|
||||
var secondResult = await service.ActivateAsync("actor2");
|
||||
|
||||
Assert.True(secondResult.Success);
|
||||
Assert.True(secondResult.WasAlreadyActive);
|
||||
Assert.Equal(firstActivationId, secondResult.State!.ActivationId); // Same activation
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_RaisesActivatedEvent()
|
||||
{
|
||||
using var service = CreateService();
|
||||
IncidentModeActivatedEventArgs? eventArgs = null;
|
||||
service.Activated += (s, e) => eventArgs = e;
|
||||
|
||||
await service.ActivateAsync("actor");
|
||||
|
||||
Assert.NotNull(eventArgs);
|
||||
Assert.NotNull(eventArgs.State);
|
||||
Assert.False(eventArgs.WasReactivation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateAsync_WhenAlreadyActive_RaisesReactivationEvent()
|
||||
{
|
||||
using var service = CreateService();
|
||||
await service.ActivateAsync("actor1");
|
||||
|
||||
IncidentModeActivatedEventArgs? eventArgs = null;
|
||||
service.Activated += (s, e) => eventArgs = e;
|
||||
|
||||
await service.ActivateAsync("actor2");
|
||||
|
||||
Assert.NotNull(eventArgs);
|
||||
Assert.True(eventArgs.WasReactivation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeactivateAsync_WhenActive_ReturnsSuccessWithWasActive()
|
||||
{
|
||||
using var service = CreateService();
|
||||
await service.ActivateAsync("actor");
|
||||
|
||||
var result = await service.DeactivateAsync("deactivator");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.WasActive);
|
||||
Assert.Equal(IncidentModeDeactivationReason.Manual, result.Reason);
|
||||
Assert.False(service.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeactivateAsync_WhenNotActive_ReturnsSuccessWithWasNotActive()
|
||||
{
|
||||
using var service = CreateService();
|
||||
|
||||
var result = await service.DeactivateAsync("actor");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.False(result.WasActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeactivateAsync_RaisesDeactivatedEvent()
|
||||
{
|
||||
using var service = CreateService();
|
||||
await service.ActivateAsync("actor");
|
||||
|
||||
IncidentModeDeactivatedEventArgs? eventArgs = null;
|
||||
service.Deactivated += (s, e) => eventArgs = e;
|
||||
|
||||
await service.DeactivateAsync("deactivator");
|
||||
|
||||
Assert.NotNull(eventArgs);
|
||||
Assert.NotNull(eventArgs.State);
|
||||
Assert.Equal(IncidentModeDeactivationReason.Manual, eventArgs.Reason);
|
||||
Assert.Equal("deactivator", eventArgs.DeactivatedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtendTtlAsync_WhenActive_ExtendsExpiry()
|
||||
{
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.AllowTtlExtension = true;
|
||||
opt.DefaultTtl = TimeSpan.FromMinutes(30);
|
||||
});
|
||||
await service.ActivateAsync("actor");
|
||||
var originalExpiry = service.CurrentState!.ExpiresAt;
|
||||
|
||||
var newExpiry = await service.ExtendTtlAsync(TimeSpan.FromMinutes(15), "extender");
|
||||
|
||||
Assert.NotNull(newExpiry);
|
||||
Assert.Equal(originalExpiry + TimeSpan.FromMinutes(15), newExpiry);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtendTtlAsync_WhenNotActive_ReturnsNull()
|
||||
{
|
||||
using var service = CreateService();
|
||||
|
||||
var result = await service.ExtendTtlAsync(TimeSpan.FromMinutes(15), "actor");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtendTtlAsync_WhenDisabled_ReturnsNull()
|
||||
{
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.AllowTtlExtension = false;
|
||||
});
|
||||
await service.ActivateAsync("actor");
|
||||
|
||||
var result = await service.ExtendTtlAsync(TimeSpan.FromMinutes(15), "actor");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtendTtlAsync_ExceedsMaxExtensions_ReturnsNull()
|
||||
{
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.AllowTtlExtension = true;
|
||||
opt.MaxExtensions = 2;
|
||||
});
|
||||
await service.ActivateAsync("actor");
|
||||
|
||||
await service.ExtendTtlAsync(TimeSpan.FromMinutes(5), "extender");
|
||||
await service.ExtendTtlAsync(TimeSpan.FromMinutes(5), "extender");
|
||||
var thirdExtension = await service.ExtendTtlAsync(TimeSpan.FromMinutes(5), "extender");
|
||||
|
||||
Assert.Null(thirdExtension);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExtendTtlAsync_WouldExceedMaxTtl_ClampedToMax()
|
||||
{
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.AllowTtlExtension = true;
|
||||
opt.DefaultTtl = TimeSpan.FromHours(23);
|
||||
opt.MaxTtl = TimeSpan.FromHours(24);
|
||||
});
|
||||
await service.ActivateAsync("actor");
|
||||
var activatedAt = service.CurrentState!.ActivatedAt;
|
||||
|
||||
var result = await service.ExtendTtlAsync(TimeSpan.FromHours(10), "extender");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(activatedAt + TimeSpan.FromHours(24), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetIncidentTags_WhenActive_ReturnsTagDictionary()
|
||||
{
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.IncidentTagName = "incident_mode";
|
||||
});
|
||||
await service.ActivateAsync("actor", tenantId: "tenant-123");
|
||||
|
||||
var tags = service.GetIncidentTags();
|
||||
|
||||
Assert.NotEmpty(tags);
|
||||
Assert.Equal("true", tags["incident_mode"]);
|
||||
Assert.Equal("actor", tags["incident_actor"]);
|
||||
Assert.Equal("tenant-123", tags["incident_tenant"]);
|
||||
Assert.True(tags.ContainsKey("incident_activation_id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetIncidentTags_WhenNotActive_ReturnsEmptyDictionary()
|
||||
{
|
||||
using var service = CreateService();
|
||||
|
||||
var tags = service.GetIncidentTags();
|
||||
|
||||
Assert.Empty(tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetIncidentTags_WithAdditionalTags_IncludesThem()
|
||||
{
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.AdditionalTags["environment"] = "production";
|
||||
opt.AdditionalTags["region"] = "us-east-1";
|
||||
});
|
||||
await service.ActivateAsync("actor");
|
||||
|
||||
var tags = service.GetIncidentTags();
|
||||
|
||||
Assert.Equal("production", tags["environment"]);
|
||||
Assert.Equal("us-east-1", tags["region"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CurrentState_WhenActive_ReturnsState()
|
||||
{
|
||||
using var service = CreateService();
|
||||
await service.ActivateAsync("actor");
|
||||
|
||||
var state = service.CurrentState;
|
||||
|
||||
Assert.NotNull(state);
|
||||
Assert.True(state.Enabled);
|
||||
Assert.Equal("actor", state.Actor);
|
||||
Assert.Equal(IncidentModeSource.Api, state.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentState_WhenNotActive_ReturnsNull()
|
||||
{
|
||||
using var service = CreateService();
|
||||
|
||||
var state = service.CurrentState;
|
||||
|
||||
Assert.Null(state);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsActive_WhenNotActivated_ReturnsFalse()
|
||||
{
|
||||
using var service = CreateService();
|
||||
|
||||
Assert.False(service.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsActive_WhenActivated_ReturnsTrue()
|
||||
{
|
||||
using var service = CreateService();
|
||||
await service.ActivateAsync("actor");
|
||||
|
||||
Assert.True(service.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsActive_WhenExpired_ReturnsFalse()
|
||||
{
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.DefaultTtl = TimeSpan.FromMinutes(1);
|
||||
});
|
||||
await service.ActivateAsync("actor");
|
||||
|
||||
// Advance time past expiry
|
||||
_timeProvider.Advance(TimeSpan.FromMinutes(2));
|
||||
|
||||
Assert.False(service.IsActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateFromCliAsync_SetsSourceToCli()
|
||||
{
|
||||
using var service = CreateService();
|
||||
|
||||
var result = await service.ActivateFromCliAsync("cli-user");
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.State);
|
||||
Assert.Equal(IncidentModeSource.Cli, result.State.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateFromConfigAsync_WhenEnabled_Activates()
|
||||
{
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.Enabled = true;
|
||||
});
|
||||
|
||||
var result = await service.ActivateFromConfigAsync();
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.NotNull(result.State);
|
||||
Assert.Equal(IncidentModeSource.Configuration, result.State.Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ActivateFromConfigAsync_WhenDisabled_FailsActivation()
|
||||
{
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.Enabled = false;
|
||||
});
|
||||
|
||||
var result = await service.ActivateFromConfigAsync();
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.NotNull(result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentModeOptions_Validate_ValidOptions_ReturnsNoErrors()
|
||||
{
|
||||
var options = new IncidentModeOptions();
|
||||
|
||||
var errors = options.Validate();
|
||||
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentModeOptions_Validate_DefaultTtlBelowMin_ReturnsError()
|
||||
{
|
||||
var options = new IncidentModeOptions
|
||||
{
|
||||
DefaultTtl = TimeSpan.FromMinutes(1),
|
||||
MinTtl = TimeSpan.FromMinutes(5)
|
||||
};
|
||||
|
||||
var errors = options.Validate();
|
||||
|
||||
Assert.Single(errors);
|
||||
Assert.Contains("DefaultTtl", errors[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentModeOptions_Validate_DefaultTtlAboveMax_ReturnsError()
|
||||
{
|
||||
var options = new IncidentModeOptions
|
||||
{
|
||||
DefaultTtl = TimeSpan.FromHours(48),
|
||||
MaxTtl = TimeSpan.FromHours(24)
|
||||
};
|
||||
|
||||
var errors = options.Validate();
|
||||
|
||||
Assert.Single(errors);
|
||||
Assert.Contains("DefaultTtl", errors[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentModeOptions_Validate_InvalidSamplingRate_ReturnsError()
|
||||
{
|
||||
var options = new IncidentModeOptions
|
||||
{
|
||||
IncidentSamplingRate = 1.5
|
||||
};
|
||||
|
||||
var errors = options.Validate();
|
||||
|
||||
Assert.Single(errors);
|
||||
Assert.Contains("IncidentSamplingRate", errors[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentModeOptions_Validate_NegativeMaxExtensions_ReturnsError()
|
||||
{
|
||||
var options = new IncidentModeOptions
|
||||
{
|
||||
MaxExtensions = -1
|
||||
};
|
||||
|
||||
var errors = options.Validate();
|
||||
|
||||
Assert.Single(errors);
|
||||
Assert.Contains("MaxExtensions", errors[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentModeOptions_ClampTtl_BelowMin_ReturnsMin()
|
||||
{
|
||||
var options = new IncidentModeOptions
|
||||
{
|
||||
MinTtl = TimeSpan.FromMinutes(10),
|
||||
MaxTtl = TimeSpan.FromHours(24)
|
||||
};
|
||||
|
||||
var result = options.ClampTtl(TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.Equal(TimeSpan.FromMinutes(10), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentModeOptions_ClampTtl_AboveMax_ReturnsMax()
|
||||
{
|
||||
var options = new IncidentModeOptions
|
||||
{
|
||||
MinTtl = TimeSpan.FromMinutes(5),
|
||||
MaxTtl = TimeSpan.FromHours(4)
|
||||
};
|
||||
|
||||
var result = options.ClampTtl(TimeSpan.FromHours(48));
|
||||
|
||||
Assert.Equal(TimeSpan.FromHours(4), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentModeOptions_ClampTtl_WithinRange_ReturnsSame()
|
||||
{
|
||||
var options = new IncidentModeOptions
|
||||
{
|
||||
MinTtl = TimeSpan.FromMinutes(5),
|
||||
MaxTtl = TimeSpan.FromHours(24)
|
||||
};
|
||||
|
||||
var result = options.ClampTtl(TimeSpan.FromHours(2));
|
||||
|
||||
Assert.Equal(TimeSpan.FromHours(2), result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentModeState_IsExpired_BeforeExpiry_ReturnsFalse()
|
||||
{
|
||||
var state = new IncidentModeState
|
||||
{
|
||||
Enabled = true,
|
||||
ActivatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
|
||||
Actor = "test",
|
||||
Source = IncidentModeSource.Api,
|
||||
ActivationId = "abc123"
|
||||
};
|
||||
|
||||
Assert.False(state.IsExpired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentModeState_IsExpired_AfterExpiry_ReturnsTrue()
|
||||
{
|
||||
var state = new IncidentModeState
|
||||
{
|
||||
Enabled = true,
|
||||
ActivatedAt = DateTimeOffset.UtcNow.AddHours(-2),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(-1),
|
||||
Actor = "test",
|
||||
Source = IncidentModeSource.Api,
|
||||
ActivationId = "abc123"
|
||||
};
|
||||
|
||||
Assert.True(state.IsExpired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentModeState_RemainingTime_WhenNotExpired_ReturnsPositive()
|
||||
{
|
||||
var state = new IncidentModeState
|
||||
{
|
||||
Enabled = true,
|
||||
ActivatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(30),
|
||||
Actor = "test",
|
||||
Source = IncidentModeSource.Api,
|
||||
ActivationId = "abc123"
|
||||
};
|
||||
|
||||
Assert.True(state.RemainingTime > TimeSpan.Zero);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentModeState_RemainingTime_WhenExpired_ReturnsZero()
|
||||
{
|
||||
var state = new IncidentModeState
|
||||
{
|
||||
Enabled = true,
|
||||
ActivatedAt = DateTimeOffset.UtcNow.AddHours(-2),
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(-1),
|
||||
Actor = "test",
|
||||
Source = IncidentModeSource.Api,
|
||||
ActivationId = "abc123"
|
||||
};
|
||||
|
||||
Assert.Equal(TimeSpan.Zero, state.RemainingTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentModeActivationResult_Succeeded_CreatesSuccessResult()
|
||||
{
|
||||
var state = new IncidentModeState
|
||||
{
|
||||
Enabled = true,
|
||||
ActivatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddHours(1),
|
||||
Actor = "test",
|
||||
Source = IncidentModeSource.Api,
|
||||
ActivationId = "abc123"
|
||||
};
|
||||
|
||||
var result = IncidentModeActivationResult.Succeeded(state, wasAlreadyActive: true);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.Same(state, result.State);
|
||||
Assert.True(result.WasAlreadyActive);
|
||||
Assert.Null(result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentModeActivationResult_Failed_CreatesFailureResult()
|
||||
{
|
||||
var result = IncidentModeActivationResult.Failed("Test error message");
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Null(result.State);
|
||||
Assert.Equal("Test error message", result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentModeDeactivationResult_Succeeded_CreatesSuccessResult()
|
||||
{
|
||||
var result = IncidentModeDeactivationResult.Succeeded(wasActive: true, IncidentModeDeactivationReason.Manual);
|
||||
|
||||
Assert.True(result.Success);
|
||||
Assert.True(result.WasActive);
|
||||
Assert.Equal(IncidentModeDeactivationReason.Manual, result.Reason);
|
||||
Assert.Null(result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IncidentModeDeactivationResult_Failed_CreatesFailureResult()
|
||||
{
|
||||
var result = IncidentModeDeactivationResult.Failed("Test error");
|
||||
|
||||
Assert.False(result.Success);
|
||||
Assert.Equal("Test error", result.Error);
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _value;
|
||||
|
||||
public TestOptionsMonitor(T value)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public T CurrentValue => _value;
|
||||
public T Get(string? name) => _value;
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset initialTime)
|
||||
{
|
||||
_utcNow = initialTime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan duration)
|
||||
{
|
||||
_utcNow = _utcNow.Add(duration);
|
||||
}
|
||||
|
||||
public void SetUtcNow(DateTimeOffset time)
|
||||
{
|
||||
_utcNow = time;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Telemetry.Core.Tests;
|
||||
|
||||
public sealed class SealedModeFileExporterTests : IDisposable
|
||||
{
|
||||
private readonly string _testDirectory;
|
||||
private readonly Mock<ILogger<SealedModeFileExporter>> _logger;
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
|
||||
public SealedModeFileExporterTests()
|
||||
{
|
||||
_testDirectory = Path.Combine(Path.GetTempPath(), $"sealed-mode-tests-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_testDirectory);
|
||||
_logger = new Mock<ILogger<SealedModeFileExporter>>();
|
||||
_timeProvider = new FakeTimeProvider(new DateTimeOffset(2025, 11, 27, 10, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_testDirectory))
|
||||
{
|
||||
Directory.Delete(_testDirectory, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore cleanup errors in tests
|
||||
}
|
||||
}
|
||||
|
||||
private SealedModeFileExporter CreateExporter(Action<SealedModeTelemetryOptions>? configure = null)
|
||||
{
|
||||
var options = new SealedModeTelemetryOptions
|
||||
{
|
||||
FilePath = Path.Combine(_testDirectory, "telemetry-sealed.otlp"),
|
||||
MaxBytes = 1024, // Small for testing
|
||||
MaxRotatedFiles = 3,
|
||||
FailOnInsecurePermissions = false // Disable for cross-platform testing
|
||||
};
|
||||
configure?.Invoke(options);
|
||||
var monitor = new TestOptionsMonitor<SealedModeTelemetryOptions>(options);
|
||||
return new SealedModeFileExporter(monitor, _logger.Object, _timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_CreatesFile()
|
||||
{
|
||||
using var exporter = CreateExporter();
|
||||
|
||||
exporter.Initialize();
|
||||
|
||||
Assert.True(exporter.IsInitialized);
|
||||
Assert.NotNull(exporter.CurrentFilePath);
|
||||
Assert.True(File.Exists(exporter.CurrentFilePath));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_CreatesDirectory_WhenNotExists()
|
||||
{
|
||||
var newDir = Path.Combine(_testDirectory, "subdir", "nested");
|
||||
using var exporter = CreateExporter(opt =>
|
||||
{
|
||||
opt.FilePath = Path.Combine(newDir, "telemetry.otlp");
|
||||
});
|
||||
|
||||
exporter.Initialize();
|
||||
|
||||
Assert.True(Directory.Exists(newDir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_CalledMultipleTimes_DoesNotThrow()
|
||||
{
|
||||
using var exporter = CreateExporter();
|
||||
|
||||
exporter.Initialize();
|
||||
exporter.Initialize();
|
||||
|
||||
Assert.True(exporter.IsInitialized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_WritesDataToFile()
|
||||
{
|
||||
using var exporter = CreateExporter();
|
||||
exporter.Initialize();
|
||||
var data = Encoding.UTF8.GetBytes("test data");
|
||||
|
||||
exporter.Write(data, TelemetrySignal.Traces);
|
||||
|
||||
var fileContent = File.ReadAllText(exporter.CurrentFilePath!);
|
||||
Assert.Contains("test data", fileContent);
|
||||
Assert.Contains("[Traces]", fileContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_IncludesTimestamp()
|
||||
{
|
||||
using var exporter = CreateExporter();
|
||||
exporter.Initialize();
|
||||
var data = Encoding.UTF8.GetBytes("test");
|
||||
|
||||
exporter.Write(data, TelemetrySignal.Traces);
|
||||
|
||||
var fileContent = File.ReadAllText(exporter.CurrentFilePath!);
|
||||
Assert.Contains("2025-11-27", fileContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_AutoInitializesIfNotCalled()
|
||||
{
|
||||
using var exporter = CreateExporter();
|
||||
var data = Encoding.UTF8.GetBytes("auto-init test");
|
||||
|
||||
exporter.Write(data, TelemetrySignal.Metrics);
|
||||
|
||||
Assert.True(exporter.IsInitialized);
|
||||
var fileContent = File.ReadAllText(exporter.CurrentFilePath!);
|
||||
Assert.Contains("auto-init test", fileContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WriteRecord_WritesStringData()
|
||||
{
|
||||
using var exporter = CreateExporter();
|
||||
exporter.Initialize();
|
||||
|
||||
exporter.WriteRecord("string record data", TelemetrySignal.Logs);
|
||||
|
||||
var fileContent = File.ReadAllText(exporter.CurrentFilePath!);
|
||||
Assert.Contains("string record data", fileContent);
|
||||
Assert.Contains("[Logs]", fileContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_RotatesFile_WhenMaxBytesExceeded()
|
||||
{
|
||||
using var exporter = CreateExporter(opt =>
|
||||
{
|
||||
opt.MaxBytes = 100; // Very small for testing rotation
|
||||
});
|
||||
exporter.Initialize();
|
||||
var filePath = exporter.CurrentFilePath!;
|
||||
|
||||
// Write enough data to trigger rotation
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
exporter.WriteRecord($"Record {i} with some padding data to exceed limit", TelemetrySignal.Traces);
|
||||
}
|
||||
|
||||
// Check that rotation happened - original file should exist
|
||||
Assert.True(File.Exists(filePath));
|
||||
// And at least one rotated file
|
||||
Assert.True(File.Exists($"{filePath}.1") || exporter.CurrentSize < 100);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CurrentSize_TracksWrittenBytes()
|
||||
{
|
||||
using var exporter = CreateExporter();
|
||||
exporter.Initialize();
|
||||
var initialSize = exporter.CurrentSize;
|
||||
var data = Encoding.UTF8.GetBytes("test data for size tracking");
|
||||
|
||||
exporter.Write(data, TelemetrySignal.Traces);
|
||||
|
||||
Assert.True(exporter.CurrentSize > initialSize);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Flush_DoesNotThrow()
|
||||
{
|
||||
using var exporter = CreateExporter();
|
||||
exporter.Initialize();
|
||||
exporter.WriteRecord("data", TelemetrySignal.Traces);
|
||||
|
||||
exporter.Flush();
|
||||
|
||||
// Should not throw
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_AfterDispose_ThrowsObjectDisposedException()
|
||||
{
|
||||
var exporter = CreateExporter();
|
||||
exporter.Initialize();
|
||||
exporter.Dispose();
|
||||
|
||||
Assert.Throws<ObjectDisposedException>(() =>
|
||||
exporter.Write(Encoding.UTF8.GetBytes("test"), TelemetrySignal.Traces));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Initialize_WithEmptyFilePath_Throws()
|
||||
{
|
||||
using var exporter = CreateExporter(opt =>
|
||||
{
|
||||
opt.FilePath = "";
|
||||
});
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => exporter.Initialize());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Write_DifferentSignals_IncludesSignalType()
|
||||
{
|
||||
using var exporter = CreateExporter();
|
||||
exporter.Initialize();
|
||||
|
||||
exporter.WriteRecord("traces data", TelemetrySignal.Traces);
|
||||
exporter.WriteRecord("metrics data", TelemetrySignal.Metrics);
|
||||
exporter.WriteRecord("logs data", TelemetrySignal.Logs);
|
||||
|
||||
var fileContent = File.ReadAllText(exporter.CurrentFilePath!);
|
||||
Assert.Contains("[Traces]", fileContent);
|
||||
Assert.Contains("[Metrics]", fileContent);
|
||||
Assert.Contains("[Logs]", fileContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rotation_DeletesOldestFile_WhenMaxRotatedFilesExceeded()
|
||||
{
|
||||
using var exporter = CreateExporter(opt =>
|
||||
{
|
||||
opt.MaxBytes = 50;
|
||||
opt.MaxRotatedFiles = 2;
|
||||
});
|
||||
exporter.Initialize();
|
||||
var basePath = exporter.CurrentFilePath!;
|
||||
|
||||
// Write enough to trigger multiple rotations
|
||||
for (var i = 0; i < 10; i++)
|
||||
{
|
||||
exporter.WriteRecord($"Record {i} with padding to exceed", TelemetrySignal.Traces);
|
||||
}
|
||||
|
||||
// Should not have more than MaxRotatedFiles rotated files
|
||||
var rotatedFiles = 0;
|
||||
for (var i = 1; i <= 5; i++)
|
||||
{
|
||||
if (File.Exists($"{basePath}.{i}"))
|
||||
{
|
||||
rotatedFiles++;
|
||||
}
|
||||
}
|
||||
Assert.True(rotatedFiles <= 2);
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _value;
|
||||
|
||||
public TestOptionsMonitor(T value)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public T CurrentValue => _value;
|
||||
public T Get(string? name) => _value;
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset initialTime)
|
||||
{
|
||||
_utcNow = initialTime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan duration)
|
||||
{
|
||||
_utcNow = _utcNow.Add(duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using StellaOps.AirGap.Policy;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Telemetry.Core.Tests;
|
||||
|
||||
public sealed class SealedModeTelemetryServiceTests : IDisposable
|
||||
{
|
||||
private readonly FakeTimeProvider _timeProvider;
|
||||
private readonly Mock<IEgressPolicy> _egressPolicy;
|
||||
private readonly Mock<IIncidentModeService> _incidentModeService;
|
||||
private readonly Mock<ILogger<SealedModeTelemetryService>> _logger;
|
||||
|
||||
public SealedModeTelemetryServiceTests()
|
||||
{
|
||||
_timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
_egressPolicy = new Mock<IEgressPolicy>();
|
||||
_incidentModeService = new Mock<IIncidentModeService>();
|
||||
_logger = new Mock<ILogger<SealedModeTelemetryService>>();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Cleanup if needed
|
||||
}
|
||||
|
||||
private SealedModeTelemetryService CreateService(
|
||||
Action<SealedModeTelemetryOptions>? configure = null,
|
||||
bool useEgressPolicy = false)
|
||||
{
|
||||
var options = new SealedModeTelemetryOptions();
|
||||
configure?.Invoke(options);
|
||||
var monitor = new TestOptionsMonitor<SealedModeTelemetryOptions>(options);
|
||||
|
||||
return new SealedModeTelemetryService(
|
||||
monitor,
|
||||
useEgressPolicy ? _egressPolicy.Object : null,
|
||||
_incidentModeService.Object,
|
||||
_logger.Object,
|
||||
_timeProvider);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSealed_WhenOptionsEnabled_ReturnsTrue()
|
||||
{
|
||||
using var service = CreateService(opt => opt.Enabled = true);
|
||||
|
||||
Assert.True(service.IsSealed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSealed_WhenOptionsDisabled_ReturnsFalse()
|
||||
{
|
||||
using var service = CreateService(opt => opt.Enabled = false);
|
||||
|
||||
Assert.False(service.IsSealed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSealed_WhenEgressPolicySealed_ReturnsTrue()
|
||||
{
|
||||
_egressPolicy.Setup(p => p.IsSealed).Returns(true);
|
||||
using var service = CreateService(opt => opt.Enabled = false, useEgressPolicy: true);
|
||||
|
||||
Assert.True(service.IsSealed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsSealed_WhenEgressPolicyNotSealed_ReturnsFalse()
|
||||
{
|
||||
_egressPolicy.Setup(p => p.IsSealed).Returns(false);
|
||||
using var service = CreateService(opt => opt.Enabled = true, useEgressPolicy: true);
|
||||
|
||||
Assert.False(service.IsSealed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveSamplingRate_WhenNotSealed_ReturnsFullSampling()
|
||||
{
|
||||
using var service = CreateService(opt => opt.Enabled = false);
|
||||
|
||||
Assert.Equal(1.0, service.EffectiveSamplingRate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveSamplingRate_WhenSealed_ReturnsMaxPercent()
|
||||
{
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.Enabled = true;
|
||||
opt.MaxSamplingPercent = 10;
|
||||
});
|
||||
|
||||
Assert.Equal(0.1, service.EffectiveSamplingRate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveSamplingRate_WhenSealedWithIncidentMode_ReturnsFullSampling()
|
||||
{
|
||||
_incidentModeService.Setup(s => s.IsActive).Returns(true);
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.Enabled = true;
|
||||
opt.MaxSamplingPercent = 10;
|
||||
opt.AllowIncidentModeOverride = true;
|
||||
});
|
||||
|
||||
Assert.Equal(1.0, service.EffectiveSamplingRate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EffectiveSamplingRate_WhenSealedWithDisabledIncidentOverride_ReturnsCapped()
|
||||
{
|
||||
_incidentModeService.Setup(s => s.IsActive).Returns(true);
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.Enabled = true;
|
||||
opt.MaxSamplingPercent = 10;
|
||||
opt.AllowIncidentModeOverride = false;
|
||||
});
|
||||
|
||||
Assert.Equal(0.1, service.EffectiveSamplingRate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIncidentModeOverrideActive_WhenConditionsMet_ReturnsTrue()
|
||||
{
|
||||
_incidentModeService.Setup(s => s.IsActive).Returns(true);
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.Enabled = true;
|
||||
opt.AllowIncidentModeOverride = true;
|
||||
});
|
||||
|
||||
Assert.True(service.IsIncidentModeOverrideActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIncidentModeOverrideActive_WhenNotSealed_ReturnsFalse()
|
||||
{
|
||||
_incidentModeService.Setup(s => s.IsActive).Returns(true);
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.Enabled = false;
|
||||
opt.AllowIncidentModeOverride = true;
|
||||
});
|
||||
|
||||
Assert.False(service.IsIncidentModeOverrideActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsIncidentModeOverrideActive_WhenIncidentNotActive_ReturnsFalse()
|
||||
{
|
||||
_incidentModeService.Setup(s => s.IsActive).Returns(false);
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.Enabled = true;
|
||||
opt.AllowIncidentModeOverride = true;
|
||||
});
|
||||
|
||||
Assert.False(service.IsIncidentModeOverrideActive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSealedModeTags_WhenNotSealed_ReturnsEmpty()
|
||||
{
|
||||
using var service = CreateService(opt => opt.Enabled = false);
|
||||
|
||||
var tags = service.GetSealedModeTags();
|
||||
|
||||
Assert.Empty(tags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSealedModeTags_WhenSealed_ReturnsSealedTag()
|
||||
{
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.Enabled = true;
|
||||
opt.SealedTagName = "sealed";
|
||||
});
|
||||
|
||||
var tags = service.GetSealedModeTags();
|
||||
|
||||
Assert.Equal("true", tags["sealed"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSealedModeTags_WhenSealedWithForceScrub_ReturnsScrubbedTag()
|
||||
{
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.Enabled = true;
|
||||
opt.ForceScrub = true;
|
||||
opt.AddScrubbedTag = true;
|
||||
});
|
||||
|
||||
var tags = service.GetSealedModeTags();
|
||||
|
||||
Assert.Equal("true", tags["scrubbed"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSealedModeTags_WhenSealedWithIncidentOverride_ReturnsOverrideTag()
|
||||
{
|
||||
_incidentModeService.Setup(s => s.IsActive).Returns(true);
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.Enabled = true;
|
||||
opt.AllowIncidentModeOverride = true;
|
||||
});
|
||||
|
||||
var tags = service.GetSealedModeTags();
|
||||
|
||||
Assert.Equal("true", tags["incident_override"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetSealedModeTags_WithAdditionalTags_IncludesThem()
|
||||
{
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.Enabled = true;
|
||||
opt.AdditionalTags["environment"] = "production";
|
||||
opt.AdditionalTags["region"] = "us-east-1";
|
||||
});
|
||||
|
||||
var tags = service.GetSealedModeTags();
|
||||
|
||||
Assert.Equal("production", tags["environment"]);
|
||||
Assert.Equal("us-east-1", tags["region"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExternalExportAllowed_WhenNotSealed_ReturnsTrue()
|
||||
{
|
||||
using var service = CreateService(opt => opt.Enabled = false);
|
||||
var endpoint = new Uri("https://collector.example.com");
|
||||
|
||||
var allowed = service.IsExternalExportAllowed(endpoint);
|
||||
|
||||
Assert.True(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsExternalExportAllowed_WhenSealed_ReturnsFalse()
|
||||
{
|
||||
using var service = CreateService(opt => opt.Enabled = true);
|
||||
var endpoint = new Uri("https://collector.example.com");
|
||||
|
||||
var allowed = service.IsExternalExportAllowed(endpoint);
|
||||
|
||||
Assert.False(allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLocalExporterConfig_WhenNotSealed_ReturnsNull()
|
||||
{
|
||||
using var service = CreateService(opt => opt.Enabled = false);
|
||||
|
||||
var config = service.GetLocalExporterConfig();
|
||||
|
||||
Assert.Null(config);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLocalExporterConfig_WhenSealed_ReturnsConfig()
|
||||
{
|
||||
using var service = CreateService(opt =>
|
||||
{
|
||||
opt.Enabled = true;
|
||||
opt.Exporter = SealedModeExporterType.File;
|
||||
opt.FilePath = "./logs/test.otlp";
|
||||
opt.MaxBytes = 5_000_000;
|
||||
opt.MaxRotatedFiles = 5;
|
||||
});
|
||||
|
||||
var config = service.GetLocalExporterConfig();
|
||||
|
||||
Assert.NotNull(config);
|
||||
Assert.Equal(SealedModeExporterType.File, config.Type);
|
||||
Assert.Equal("./logs/test.otlp", config.FilePath);
|
||||
Assert.Equal(5_000_000, config.MaxBytes);
|
||||
Assert.Equal(5, config.MaxRotatedFiles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordSealEvent_RaisesStateChangedEvent()
|
||||
{
|
||||
using var service = CreateService(opt => opt.Enabled = true);
|
||||
SealedModeStateChangedEventArgs? eventArgs = null;
|
||||
service.StateChanged += (s, e) => eventArgs = e;
|
||||
|
||||
service.RecordSealEvent("Test reason", "test-actor");
|
||||
|
||||
Assert.NotNull(eventArgs);
|
||||
Assert.True(eventArgs.IsSealed);
|
||||
Assert.Equal("Test reason", eventArgs.Reason);
|
||||
Assert.Equal("test-actor", eventArgs.Actor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordUnsealEvent_RaisesStateChangedEvent()
|
||||
{
|
||||
using var service = CreateService(opt => opt.Enabled = false);
|
||||
SealedModeStateChangedEventArgs? eventArgs = null;
|
||||
service.StateChanged += (s, e) => eventArgs = e;
|
||||
|
||||
service.RecordUnsealEvent("Test unseal", "admin");
|
||||
|
||||
Assert.NotNull(eventArgs);
|
||||
Assert.False(eventArgs.IsSealed);
|
||||
Assert.Equal("Test unseal", eventArgs.Reason);
|
||||
Assert.Equal("admin", eventArgs.Actor);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordDriftEvent_DoesNotThrow()
|
||||
{
|
||||
using var service = CreateService(opt => opt.Enabled = true);
|
||||
var endpoint = new Uri("https://collector.example.com");
|
||||
|
||||
// Should not throw
|
||||
service.RecordDriftEvent(endpoint, TelemetrySignal.Traces);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealedModeTelemetryOptions_Validate_ValidOptions_ReturnsNoErrors()
|
||||
{
|
||||
var options = new SealedModeTelemetryOptions();
|
||||
|
||||
var errors = options.Validate();
|
||||
|
||||
Assert.Empty(errors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealedModeTelemetryOptions_Validate_InvalidSamplingPercent_ReturnsError()
|
||||
{
|
||||
var options = new SealedModeTelemetryOptions
|
||||
{
|
||||
MaxSamplingPercent = 150
|
||||
};
|
||||
|
||||
var errors = options.Validate();
|
||||
|
||||
Assert.Single(errors);
|
||||
Assert.Contains("MaxSamplingPercent", errors[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealedModeTelemetryOptions_Validate_NegativeSamplingPercent_ReturnsError()
|
||||
{
|
||||
var options = new SealedModeTelemetryOptions
|
||||
{
|
||||
MaxSamplingPercent = -10
|
||||
};
|
||||
|
||||
var errors = options.Validate();
|
||||
|
||||
Assert.Single(errors);
|
||||
Assert.Contains("MaxSamplingPercent", errors[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealedModeTelemetryOptions_Validate_InvalidMaxBytes_ReturnsError()
|
||||
{
|
||||
var options = new SealedModeTelemetryOptions
|
||||
{
|
||||
MaxBytes = 0
|
||||
};
|
||||
|
||||
var errors = options.Validate();
|
||||
|
||||
Assert.Single(errors);
|
||||
Assert.Contains("MaxBytes", errors[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealedModeTelemetryOptions_Validate_MissingFilePath_ReturnsError()
|
||||
{
|
||||
var options = new SealedModeTelemetryOptions
|
||||
{
|
||||
Exporter = SealedModeExporterType.File,
|
||||
FilePath = ""
|
||||
};
|
||||
|
||||
var errors = options.Validate();
|
||||
|
||||
Assert.Single(errors);
|
||||
Assert.Contains("FilePath", errors[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealedModeTelemetryOptions_GetEffectiveSamplingRate_WithoutIncident_ReturnsCapped()
|
||||
{
|
||||
var options = new SealedModeTelemetryOptions
|
||||
{
|
||||
MaxSamplingPercent = 25
|
||||
};
|
||||
|
||||
var rate = options.GetEffectiveSamplingRate(incidentModeActive: false, incidentSamplingRate: 1.0);
|
||||
|
||||
Assert.Equal(0.25, rate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealedModeTelemetryOptions_GetEffectiveSamplingRate_WithIncidentOverride_ReturnsRequested()
|
||||
{
|
||||
var options = new SealedModeTelemetryOptions
|
||||
{
|
||||
MaxSamplingPercent = 10,
|
||||
AllowIncidentModeOverride = true
|
||||
};
|
||||
|
||||
var rate = options.GetEffectiveSamplingRate(incidentModeActive: true, incidentSamplingRate: 0.5);
|
||||
|
||||
Assert.Equal(0.5, rate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealedModeTelemetryOptions_GetEffectiveSamplingRate_WithIncidentOverride_CapsAtOne()
|
||||
{
|
||||
var options = new SealedModeTelemetryOptions
|
||||
{
|
||||
MaxSamplingPercent = 10,
|
||||
AllowIncidentModeOverride = true
|
||||
};
|
||||
|
||||
var rate = options.GetEffectiveSamplingRate(incidentModeActive: true, incidentSamplingRate: 1.5);
|
||||
|
||||
Assert.Equal(1.0, rate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealedModeExporterConfig_PropertiesAreSet()
|
||||
{
|
||||
var config = new SealedModeExporterConfig
|
||||
{
|
||||
Type = SealedModeExporterType.File,
|
||||
FilePath = "/path/to/file.otlp",
|
||||
MaxBytes = 10_000_000,
|
||||
MaxRotatedFiles = 3
|
||||
};
|
||||
|
||||
Assert.Equal(SealedModeExporterType.File, config.Type);
|
||||
Assert.Equal("/path/to/file.otlp", config.FilePath);
|
||||
Assert.Equal(10_000_000, config.MaxBytes);
|
||||
Assert.Equal(3, config.MaxRotatedFiles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SealedModeStateChangedEventArgs_PropertiesAreSet()
|
||||
{
|
||||
var timestamp = DateTimeOffset.UtcNow;
|
||||
var args = new SealedModeStateChangedEventArgs
|
||||
{
|
||||
IsSealed = true,
|
||||
Timestamp = timestamp,
|
||||
Reason = "Test reason",
|
||||
Actor = "test-user"
|
||||
};
|
||||
|
||||
Assert.True(args.IsSealed);
|
||||
Assert.Equal(timestamp, args.Timestamp);
|
||||
Assert.Equal("Test reason", args.Reason);
|
||||
Assert.Equal("test-user", args.Actor);
|
||||
}
|
||||
|
||||
private sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
|
||||
{
|
||||
private readonly T _value;
|
||||
|
||||
public TestOptionsMonitor(T value)
|
||||
{
|
||||
_value = value;
|
||||
}
|
||||
|
||||
public T CurrentValue => _value;
|
||||
public T Get(string? name) => _value;
|
||||
public IDisposable? OnChange(Action<T, string?> listener) => null;
|
||||
}
|
||||
|
||||
private sealed class FakeTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _utcNow;
|
||||
|
||||
public FakeTimeProvider(DateTimeOffset initialTime)
|
||||
{
|
||||
_utcNow = initialTime;
|
||||
}
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _utcNow;
|
||||
|
||||
public void Advance(TimeSpan duration)
|
||||
{
|
||||
_utcNow = _utcNow.Add(duration);
|
||||
}
|
||||
|
||||
public void SetUtcNow(DateTimeOffset time)
|
||||
{
|
||||
_utcNow = time;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing incident mode state in telemetry.
|
||||
/// Incident mode increases sampling rates and adds special tags to telemetry data.
|
||||
/// </summary>
|
||||
public interface IIncidentModeService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether incident mode is currently active.
|
||||
/// </summary>
|
||||
bool IsActive { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current incident mode state.
|
||||
/// </summary>
|
||||
IncidentModeState? CurrentState { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Activates incident mode with optional TTL override.
|
||||
/// </summary>
|
||||
/// <param name="actor">The actor (user/service) activating incident mode.</param>
|
||||
/// <param name="tenantId">Optional tenant identifier.</param>
|
||||
/// <param name="ttlOverride">Optional TTL override (uses default if not specified).</param>
|
||||
/// <param name="reason">Optional reason for activation.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The activation result.</returns>
|
||||
Task<IncidentModeActivationResult> ActivateAsync(
|
||||
string actor,
|
||||
string? tenantId = null,
|
||||
TimeSpan? ttlOverride = null,
|
||||
string? reason = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deactivates incident mode.
|
||||
/// </summary>
|
||||
/// <param name="actor">The actor (user/service) deactivating incident mode.</param>
|
||||
/// <param name="reason">Optional reason for deactivation.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The deactivation result.</returns>
|
||||
Task<IncidentModeDeactivationResult> DeactivateAsync(
|
||||
string actor,
|
||||
string? reason = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extends the current incident mode TTL.
|
||||
/// </summary>
|
||||
/// <param name="extension">The time to add to the current TTL.</param>
|
||||
/// <param name="actor">The actor extending the TTL.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The new expiration time, or null if incident mode is not active.</returns>
|
||||
Task<DateTimeOffset?> ExtendTtlAsync(
|
||||
TimeSpan extension,
|
||||
string actor,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets tags to add to telemetry when incident mode is active.
|
||||
/// </summary>
|
||||
/// <returns>A dictionary of tags, or empty if incident mode is not active.</returns>
|
||||
IReadOnlyDictionary<string, string> GetIncidentTags();
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when incident mode is activated.
|
||||
/// </summary>
|
||||
event EventHandler<IncidentModeActivatedEventArgs>? Activated;
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when incident mode is deactivated or expires.
|
||||
/// </summary>
|
||||
event EventHandler<IncidentModeDeactivatedEventArgs>? Deactivated;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the current state of incident mode.
|
||||
/// </summary>
|
||||
public sealed record IncidentModeState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether incident mode is enabled.
|
||||
/// </summary>
|
||||
public required bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when incident mode was activated.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ActivatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when incident mode will expire.
|
||||
/// </summary>
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actor who activated incident mode.
|
||||
/// </summary>
|
||||
public required string Actor { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the tenant identifier, if applicable.
|
||||
/// </summary>
|
||||
public string? TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the source of the activation (CLI, API, config).
|
||||
/// </summary>
|
||||
public required IncidentModeSource Source { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reason for activation.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique activation ID.
|
||||
/// </summary>
|
||||
public required string ActivationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this state has expired.
|
||||
/// </summary>
|
||||
public bool IsExpired => DateTimeOffset.UtcNow >= ExpiresAt;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the remaining time until expiration.
|
||||
/// </summary>
|
||||
public TimeSpan RemainingTime => IsExpired ? TimeSpan.Zero : ExpiresAt - DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source of incident mode activation.
|
||||
/// </summary>
|
||||
public enum IncidentModeSource
|
||||
{
|
||||
/// <summary>CLI flag activation.</summary>
|
||||
Cli,
|
||||
/// <summary>API activation.</summary>
|
||||
Api,
|
||||
/// <summary>Configuration-based activation.</summary>
|
||||
Configuration,
|
||||
/// <summary>Persisted state restoration.</summary>
|
||||
Restored
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of incident mode activation.
|
||||
/// </summary>
|
||||
public sealed record IncidentModeActivationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether activation was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the activation state if successful.
|
||||
/// </summary>
|
||||
public IncidentModeState? State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message if activation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether incident mode was already active.
|
||||
/// </summary>
|
||||
public bool WasAlreadyActive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful activation result.
|
||||
/// </summary>
|
||||
public static IncidentModeActivationResult Succeeded(IncidentModeState state, bool wasAlreadyActive = false)
|
||||
{
|
||||
return new IncidentModeActivationResult
|
||||
{
|
||||
Success = true,
|
||||
State = state,
|
||||
WasAlreadyActive = wasAlreadyActive
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed activation result.
|
||||
/// </summary>
|
||||
public static IncidentModeActivationResult Failed(string error)
|
||||
{
|
||||
return new IncidentModeActivationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of incident mode deactivation.
|
||||
/// </summary>
|
||||
public sealed record IncidentModeDeactivationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether deactivation was successful.
|
||||
/// </summary>
|
||||
public required bool Success { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether incident mode was active before deactivation.
|
||||
/// </summary>
|
||||
public bool WasActive { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message if deactivation failed.
|
||||
/// </summary>
|
||||
public string? Error { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reason for deactivation.
|
||||
/// </summary>
|
||||
public IncidentModeDeactivationReason Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful deactivation result.
|
||||
/// </summary>
|
||||
public static IncidentModeDeactivationResult Succeeded(bool wasActive, IncidentModeDeactivationReason reason)
|
||||
{
|
||||
return new IncidentModeDeactivationResult
|
||||
{
|
||||
Success = true,
|
||||
WasActive = wasActive,
|
||||
Reason = reason
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed deactivation result.
|
||||
/// </summary>
|
||||
public static IncidentModeDeactivationResult Failed(string error)
|
||||
{
|
||||
return new IncidentModeDeactivationResult
|
||||
{
|
||||
Success = false,
|
||||
Error = error
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reason for incident mode deactivation.
|
||||
/// </summary>
|
||||
public enum IncidentModeDeactivationReason
|
||||
{
|
||||
/// <summary>Manual deactivation by user/service.</summary>
|
||||
Manual,
|
||||
/// <summary>Deactivation due to TTL expiry.</summary>
|
||||
Expired,
|
||||
/// <summary>Deactivation due to system shutdown.</summary>
|
||||
Shutdown,
|
||||
/// <summary>Deactivation due to sealed mode activation.</summary>
|
||||
SealedMode
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event args for incident mode activation.
|
||||
/// </summary>
|
||||
public sealed class IncidentModeActivatedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the activation state.
|
||||
/// </summary>
|
||||
public required IncidentModeState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether this was a reactivation (was already active).
|
||||
/// </summary>
|
||||
public bool WasReactivation { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event args for incident mode deactivation.
|
||||
/// </summary>
|
||||
public sealed class IncidentModeDeactivatedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the state at time of deactivation.
|
||||
/// </summary>
|
||||
public required IncidentModeState State { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reason for deactivation.
|
||||
/// </summary>
|
||||
public required IncidentModeDeactivationReason Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actor who deactivated (if manual).
|
||||
/// </summary>
|
||||
public string? DeactivatedBy { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Service for managing sealed-mode telemetry behavior.
|
||||
/// When sealed mode is active, external exporters are disabled and
|
||||
/// telemetry is written to local storage instead.
|
||||
/// </summary>
|
||||
public interface ISealedModeTelemetryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether sealed mode is currently active.
|
||||
/// </summary>
|
||||
bool IsSealed { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current effective sampling rate (0.0-1.0).
|
||||
/// </summary>
|
||||
double EffectiveSamplingRate { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether incident mode is currently overriding sealed mode sampling.
|
||||
/// </summary>
|
||||
bool IsIncidentModeOverrideActive { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets tags to add to telemetry when sealed mode is active.
|
||||
/// </summary>
|
||||
/// <returns>A dictionary of tags, or empty if sealed mode is not active.</returns>
|
||||
IReadOnlyDictionary<string, string> GetSealedModeTags();
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether an external exporter should be allowed.
|
||||
/// Always returns false when sealed mode is active.
|
||||
/// </summary>
|
||||
/// <param name="endpoint">The exporter endpoint.</param>
|
||||
/// <returns><c>true</c> if external export is allowed.</returns>
|
||||
bool IsExternalExportAllowed(Uri endpoint);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the local exporter configuration for sealed mode.
|
||||
/// </summary>
|
||||
/// <returns>The exporter configuration, or null if sealed mode is not active.</returns>
|
||||
SealedModeExporterConfig? GetLocalExporterConfig();
|
||||
|
||||
/// <summary>
|
||||
/// Records a seal event (entry into sealed mode).
|
||||
/// </summary>
|
||||
/// <param name="reason">Optional reason for sealing.</param>
|
||||
/// <param name="actor">The actor who initiated the seal.</param>
|
||||
void RecordSealEvent(string? reason = null, string? actor = null);
|
||||
|
||||
/// <summary>
|
||||
/// Records an unseal event (exit from sealed mode).
|
||||
/// </summary>
|
||||
/// <param name="reason">Optional reason for unsealing.</param>
|
||||
/// <param name="actor">The actor who initiated the unseal.</param>
|
||||
void RecordUnsealEvent(string? reason = null, string? actor = null);
|
||||
|
||||
/// <summary>
|
||||
/// Records a drift event when external export was blocked.
|
||||
/// </summary>
|
||||
/// <param name="endpoint">The blocked endpoint.</param>
|
||||
/// <param name="signal">The telemetry signal type.</param>
|
||||
void RecordDriftEvent(Uri endpoint, TelemetrySignal signal);
|
||||
|
||||
/// <summary>
|
||||
/// Event raised when sealed mode state changes.
|
||||
/// </summary>
|
||||
event EventHandler<SealedModeStateChangedEventArgs>? StateChanged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the local exporter in sealed mode.
|
||||
/// </summary>
|
||||
public sealed record SealedModeExporterConfig
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the exporter type.
|
||||
/// </summary>
|
||||
public required SealedModeExporterType Type { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the file path for file-based exporters.
|
||||
/// </summary>
|
||||
public string? FilePath { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum bytes before rotation.
|
||||
/// </summary>
|
||||
public long MaxBytes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of rotated files.
|
||||
/// </summary>
|
||||
public int MaxRotatedFiles { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event args for sealed mode state changes.
|
||||
/// </summary>
|
||||
public sealed class SealedModeStateChangedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether sealed mode is now active.
|
||||
/// </summary>
|
||||
public required bool IsSealed { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp of the state change.
|
||||
/// </summary>
|
||||
public required DateTimeOffset Timestamp { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the reason for the state change.
|
||||
/// </summary>
|
||||
public string? Reason { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actor who initiated the change.
|
||||
/// </summary>
|
||||
public string? Actor { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Options for incident mode configuration.
|
||||
/// </summary>
|
||||
public sealed class IncidentModeOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Telemetry:Incident";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether incident mode is enabled by configuration.
|
||||
/// CLI flag can override this.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default TTL for incident mode.
|
||||
/// </summary>
|
||||
public TimeSpan DefaultTtl { get; set; } = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum allowed TTL for incident mode.
|
||||
/// </summary>
|
||||
public TimeSpan MaxTtl { get; set; } = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum allowed TTL for incident mode.
|
||||
/// </summary>
|
||||
public TimeSpan MinTtl { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the sampling rate to use during incident mode (0.0-1.0).
|
||||
/// </summary>
|
||||
public double IncidentSamplingRate { get; set; } = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the flush interval for exporters during incident mode.
|
||||
/// </summary>
|
||||
public TimeSpan IncidentFlushInterval { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the normal flush interval for comparison.
|
||||
/// </summary>
|
||||
public TimeSpan NormalFlushInterval { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to persist incident mode state to local file.
|
||||
/// </summary>
|
||||
public bool PersistState { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the state file path. Uses default if not specified.
|
||||
/// </summary>
|
||||
public string? StateFilePath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to emit audit events for activation/deactivation.
|
||||
/// </summary>
|
||||
public bool EmitAuditEvents { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tag name for incident mode indicator.
|
||||
/// </summary>
|
||||
public string IncidentTagName { get; set; } = "incident";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether sealed mode disables incident mode.
|
||||
/// </summary>
|
||||
public bool DisableInSealedMode { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional tags to add during incident mode.
|
||||
/// </summary>
|
||||
public System.Collections.Generic.Dictionary<string, string> AdditionalTags { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to allow TTL extension.
|
||||
/// </summary>
|
||||
public bool AllowTtlExtension { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of extensions allowed per activation.
|
||||
/// </summary>
|
||||
public int MaxExtensions { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to restore state from persisted file on startup.
|
||||
/// </summary>
|
||||
public bool RestoreOnStartup { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the options and returns any validation errors.
|
||||
/// </summary>
|
||||
public System.Collections.Generic.List<string> Validate()
|
||||
{
|
||||
var errors = new System.Collections.Generic.List<string>();
|
||||
|
||||
if (DefaultTtl < MinTtl)
|
||||
{
|
||||
errors.Add($"DefaultTtl ({DefaultTtl}) cannot be less than MinTtl ({MinTtl})");
|
||||
}
|
||||
|
||||
if (DefaultTtl > MaxTtl)
|
||||
{
|
||||
errors.Add($"DefaultTtl ({DefaultTtl}) cannot be greater than MaxTtl ({MaxTtl})");
|
||||
}
|
||||
|
||||
if (IncidentSamplingRate < 0.0 || IncidentSamplingRate > 1.0)
|
||||
{
|
||||
errors.Add($"IncidentSamplingRate ({IncidentSamplingRate}) must be between 0.0 and 1.0");
|
||||
}
|
||||
|
||||
if (IncidentFlushInterval <= TimeSpan.Zero)
|
||||
{
|
||||
errors.Add("IncidentFlushInterval must be positive");
|
||||
}
|
||||
|
||||
if (MaxExtensions < 0)
|
||||
{
|
||||
errors.Add("MaxExtensions cannot be negative");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clamps a TTL value to the allowed range.
|
||||
/// </summary>
|
||||
public TimeSpan ClampTtl(TimeSpan ttl)
|
||||
{
|
||||
if (ttl < MinTtl) return MinTtl;
|
||||
if (ttl > MaxTtl) return MaxTtl;
|
||||
return ttl;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persisted state for incident mode.
|
||||
/// </summary>
|
||||
public sealed class PersistedIncidentModeState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets whether incident mode is enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when incident mode was activated.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ActivatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the timestamp when incident mode will expire.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the actor who activated incident mode.
|
||||
/// </summary>
|
||||
public string? Actor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tenant identifier.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the activation ID.
|
||||
/// </summary>
|
||||
public string? ActivationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the source of activation.
|
||||
/// </summary>
|
||||
public string? Source { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the reason for activation.
|
||||
/// </summary>
|
||||
public string? Reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the number of TTL extensions applied.
|
||||
/// </summary>
|
||||
public int ExtensionCount { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,531 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IIncidentModeService"/>.
|
||||
/// </summary>
|
||||
public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
{
|
||||
private readonly IOptionsMonitor<IncidentModeOptions> _optionsMonitor;
|
||||
private readonly ITelemetryContextAccessor? _contextAccessor;
|
||||
private readonly ILogger<IncidentModeService>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly object _lock = new();
|
||||
private readonly Timer _expiryTimer;
|
||||
|
||||
private IncidentModeState? _currentState;
|
||||
private int _extensionCount;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsActive => _currentState is not null && !_currentState.IsExpired;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IncidentModeState? CurrentState => _currentState?.IsExpired == true ? null : _currentState;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event EventHandler<IncidentModeActivatedEventArgs>? Activated;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event EventHandler<IncidentModeDeactivatedEventArgs>? Deactivated;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="IncidentModeService"/>.
|
||||
/// </summary>
|
||||
public IncidentModeService(
|
||||
IOptionsMonitor<IncidentModeOptions> optionsMonitor,
|
||||
ITelemetryContextAccessor? contextAccessor = null,
|
||||
ILogger<IncidentModeService>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_contextAccessor = contextAccessor;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
_expiryTimer = new Timer(CheckExpiry, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
|
||||
|
||||
// Restore state if configured
|
||||
if (_optionsMonitor.CurrentValue.RestoreOnStartup)
|
||||
{
|
||||
_ = RestoreStateAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IncidentModeActivationResult> ActivateAsync(
|
||||
string actor,
|
||||
string? tenantId = null,
|
||||
TimeSpan? ttlOverride = null,
|
||||
string? reason = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
|
||||
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
|
||||
// Check sealed mode restriction
|
||||
if (options.DisableInSealedMode && IsSealedModeActive())
|
||||
{
|
||||
return IncidentModeActivationResult.Failed(
|
||||
"Cannot activate incident mode when sealed mode is active");
|
||||
}
|
||||
|
||||
var ttl = ttlOverride.HasValue ? options.ClampTtl(ttlOverride.Value) : options.DefaultTtl;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
var wasAlreadyActive = false;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentState is not null && !_currentState.IsExpired)
|
||||
{
|
||||
wasAlreadyActive = true;
|
||||
_logger?.LogInformation(
|
||||
"Incident mode already active (activation {ActivationId}). Extending TTL.",
|
||||
_currentState.ActivationId);
|
||||
|
||||
// Extend existing activation
|
||||
_currentState = _currentState with
|
||||
{
|
||||
ExpiresAt = now + ttl
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// New activation
|
||||
_currentState = new IncidentModeState
|
||||
{
|
||||
Enabled = true,
|
||||
ActivatedAt = now,
|
||||
ExpiresAt = now + ttl,
|
||||
Actor = actor,
|
||||
TenantId = tenantId ?? _contextAccessor?.Context?.TenantId,
|
||||
Source = IncidentModeSource.Api,
|
||||
Reason = reason,
|
||||
ActivationId = Guid.NewGuid().ToString("N")[..12]
|
||||
};
|
||||
_extensionCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
_logger?.LogInformation(
|
||||
"Incident mode activated by {Actor} for tenant {TenantId}. Expires at {ExpiresAt}. Activation ID: {ActivationId}",
|
||||
actor,
|
||||
_currentState.TenantId ?? "global",
|
||||
_currentState.ExpiresAt,
|
||||
_currentState.ActivationId);
|
||||
|
||||
// Persist state
|
||||
if (options.PersistState)
|
||||
{
|
||||
await PersistStateAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Emit audit event
|
||||
if (options.EmitAuditEvents)
|
||||
{
|
||||
EmitActivationAuditEvent(_currentState, wasAlreadyActive);
|
||||
}
|
||||
|
||||
// Raise event
|
||||
Activated?.Invoke(this, new IncidentModeActivatedEventArgs
|
||||
{
|
||||
State = _currentState,
|
||||
WasReactivation = wasAlreadyActive
|
||||
});
|
||||
|
||||
return IncidentModeActivationResult.Succeeded(_currentState, wasAlreadyActive);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IncidentModeDeactivationResult> DeactivateAsync(
|
||||
string actor,
|
||||
string? reason = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
IncidentModeState? previousState;
|
||||
bool wasActive;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
previousState = _currentState;
|
||||
wasActive = previousState is not null && !previousState.IsExpired;
|
||||
_currentState = null;
|
||||
_extensionCount = 0;
|
||||
}
|
||||
|
||||
if (wasActive && previousState is not null)
|
||||
{
|
||||
_logger?.LogInformation(
|
||||
"Incident mode deactivated by {Actor}. Activation ID: {ActivationId}. Reason: {Reason}",
|
||||
actor,
|
||||
previousState.ActivationId,
|
||||
reason ?? "manual deactivation");
|
||||
|
||||
// Clear persisted state
|
||||
if (options.PersistState)
|
||||
{
|
||||
await ClearPersistedStateAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Emit audit event
|
||||
if (options.EmitAuditEvents)
|
||||
{
|
||||
EmitDeactivationAuditEvent(previousState, IncidentModeDeactivationReason.Manual, actor);
|
||||
}
|
||||
|
||||
// Raise event
|
||||
Deactivated?.Invoke(this, new IncidentModeDeactivatedEventArgs
|
||||
{
|
||||
State = previousState,
|
||||
Reason = IncidentModeDeactivationReason.Manual,
|
||||
DeactivatedBy = actor
|
||||
});
|
||||
}
|
||||
|
||||
return IncidentModeDeactivationResult.Succeeded(wasActive, IncidentModeDeactivationReason.Manual);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<DateTimeOffset?> ExtendTtlAsync(
|
||||
TimeSpan extension,
|
||||
string actor,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
|
||||
if (!options.AllowTtlExtension)
|
||||
{
|
||||
_logger?.LogWarning("TTL extension not allowed by configuration");
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentState is null || _currentState.IsExpired)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_extensionCount >= options.MaxExtensions)
|
||||
{
|
||||
_logger?.LogWarning(
|
||||
"Maximum TTL extensions ({MaxExtensions}) reached for activation {ActivationId}",
|
||||
options.MaxExtensions,
|
||||
_currentState.ActivationId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var newExpiresAt = _currentState.ExpiresAt + extension;
|
||||
var maxAllowedExpiry = _currentState.ActivatedAt + options.MaxTtl;
|
||||
|
||||
if (newExpiresAt > maxAllowedExpiry)
|
||||
{
|
||||
newExpiresAt = maxAllowedExpiry;
|
||||
}
|
||||
|
||||
_currentState = _currentState with { ExpiresAt = newExpiresAt };
|
||||
_extensionCount++;
|
||||
|
||||
_logger?.LogInformation(
|
||||
"Incident mode TTL extended by {Actor}. New expiry: {ExpiresAt}. Extensions: {Count}/{Max}",
|
||||
actor,
|
||||
newExpiresAt,
|
||||
_extensionCount,
|
||||
options.MaxExtensions);
|
||||
|
||||
return newExpiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyDictionary<string, string> GetIncidentTags()
|
||||
{
|
||||
var state = CurrentState;
|
||||
if (state is null)
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
var tags = new Dictionary<string, string>
|
||||
{
|
||||
[options.IncidentTagName] = "true",
|
||||
["incident_activation_id"] = state.ActivationId,
|
||||
["incident_actor"] = state.Actor
|
||||
};
|
||||
|
||||
if (state.TenantId is not null)
|
||||
{
|
||||
tags["incident_tenant"] = state.TenantId;
|
||||
}
|
||||
|
||||
foreach (var (key, value) in options.AdditionalTags)
|
||||
{
|
||||
tags[key] = value;
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates incident mode from CLI flag.
|
||||
/// </summary>
|
||||
public Task<IncidentModeActivationResult> ActivateFromCliAsync(
|
||||
string actor,
|
||||
TimeSpan? ttl = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return ActivateInternalAsync(actor, null, ttl, "CLI activation", IncidentModeSource.Cli, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates incident mode from configuration.
|
||||
/// </summary>
|
||||
public Task<IncidentModeActivationResult> ActivateFromConfigAsync(CancellationToken ct = default)
|
||||
{
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return Task.FromResult(IncidentModeActivationResult.Failed("Incident mode not enabled in configuration"));
|
||||
}
|
||||
|
||||
return ActivateInternalAsync("configuration", null, null, "Configuration activation", IncidentModeSource.Configuration, ct);
|
||||
}
|
||||
|
||||
private async Task<IncidentModeActivationResult> ActivateInternalAsync(
|
||||
string actor,
|
||||
string? tenantId,
|
||||
TimeSpan? ttl,
|
||||
string? reason,
|
||||
IncidentModeSource source,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await ActivateAsync(actor, tenantId, ttl, reason, ct).ConfigureAwait(false);
|
||||
|
||||
if (result.Success && result.State is not null)
|
||||
{
|
||||
// Update source
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentState is not null)
|
||||
{
|
||||
_currentState = _currentState with { Source = source };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void CheckExpiry(object? state)
|
||||
{
|
||||
IncidentModeState? expiredState;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentState is null || !_currentState.IsExpired)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
expiredState = _currentState;
|
||||
_currentState = null;
|
||||
_extensionCount = 0;
|
||||
}
|
||||
|
||||
_logger?.LogInformation(
|
||||
"Incident mode expired. Activation ID: {ActivationId}",
|
||||
expiredState.ActivationId);
|
||||
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
|
||||
// Clear persisted state
|
||||
if (options.PersistState)
|
||||
{
|
||||
_ = ClearPersistedStateAsync(default);
|
||||
}
|
||||
|
||||
// Emit audit event
|
||||
if (options.EmitAuditEvents)
|
||||
{
|
||||
EmitDeactivationAuditEvent(expiredState, IncidentModeDeactivationReason.Expired, null);
|
||||
}
|
||||
|
||||
// Raise event
|
||||
Deactivated?.Invoke(this, new IncidentModeDeactivatedEventArgs
|
||||
{
|
||||
State = expiredState,
|
||||
Reason = IncidentModeDeactivationReason.Expired
|
||||
});
|
||||
}
|
||||
|
||||
private async Task RestoreStateAsync()
|
||||
{
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
var path = GetStateFilePath(options);
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(path).ConfigureAwait(false);
|
||||
var persisted = JsonSerializer.Deserialize<PersistedIncidentModeState>(json);
|
||||
|
||||
if (persisted?.Enabled == true &&
|
||||
persisted.ExpiresAt.HasValue &&
|
||||
persisted.ExpiresAt.Value > _timeProvider.GetUtcNow())
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_currentState = new IncidentModeState
|
||||
{
|
||||
Enabled = true,
|
||||
ActivatedAt = persisted.ActivatedAt ?? _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = persisted.ExpiresAt.Value,
|
||||
Actor = persisted.Actor ?? "restored",
|
||||
TenantId = persisted.TenantId,
|
||||
Source = IncidentModeSource.Restored,
|
||||
Reason = persisted.Reason,
|
||||
ActivationId = persisted.ActivationId ?? Guid.NewGuid().ToString("N")[..12]
|
||||
};
|
||||
_extensionCount = persisted.ExtensionCount;
|
||||
}
|
||||
|
||||
_logger?.LogInformation(
|
||||
"Restored incident mode state. Activation ID: {ActivationId}. Expires at: {ExpiresAt}",
|
||||
_currentState.ActivationId,
|
||||
_currentState.ExpiresAt);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to restore incident mode state from {Path}", path);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PersistStateAsync(CancellationToken ct)
|
||||
{
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
var path = GetStateFilePath(options);
|
||||
var state = _currentState;
|
||||
|
||||
if (state is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
var persisted = new PersistedIncidentModeState
|
||||
{
|
||||
Enabled = true,
|
||||
ActivatedAt = state.ActivatedAt,
|
||||
ExpiresAt = state.ExpiresAt,
|
||||
Actor = state.Actor,
|
||||
TenantId = state.TenantId,
|
||||
ActivationId = state.ActivationId,
|
||||
Source = state.Source.ToString(),
|
||||
Reason = state.Reason,
|
||||
ExtensionCount = _extensionCount
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(persisted, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(path, json, ct).ConfigureAwait(false);
|
||||
|
||||
// Set file permissions (Unix only)
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to persist incident mode state to {Path}", path);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearPersistedStateAsync(CancellationToken ct)
|
||||
{
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
var path = GetStateFilePath(options);
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to clear incident mode state file {Path}", path);
|
||||
}
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static string GetStateFilePath(IncidentModeOptions options)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(options.StateFilePath))
|
||||
{
|
||||
return options.StateFilePath;
|
||||
}
|
||||
|
||||
var homeDir = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
return Path.Combine(homeDir, ".stellaops", "incident-mode.json");
|
||||
}
|
||||
|
||||
private bool IsSealedModeActive()
|
||||
{
|
||||
// This would integrate with the sealed mode service when implemented
|
||||
// For now, check via options or context
|
||||
return false;
|
||||
}
|
||||
|
||||
private void EmitActivationAuditEvent(IncidentModeState state, bool wasReactivation)
|
||||
{
|
||||
_logger?.LogInformation(
|
||||
"Audit: telemetry.incident.{Action} - tenant={Tenant} actor={Actor} source={Source} expires_at={ExpiresAt} activation_id={ActivationId}",
|
||||
wasReactivation ? "reactivated" : "activated",
|
||||
state.TenantId ?? "global",
|
||||
state.Actor,
|
||||
state.Source,
|
||||
state.ExpiresAt.ToString("O"),
|
||||
state.ActivationId);
|
||||
}
|
||||
|
||||
private void EmitDeactivationAuditEvent(IncidentModeState state, IncidentModeDeactivationReason reason, string? deactivatedBy)
|
||||
{
|
||||
_logger?.LogInformation(
|
||||
"Audit: telemetry.incident.{Action} - tenant={Tenant} reason={Reason} deactivated_by={DeactivatedBy} activation_id={ActivationId}",
|
||||
reason == IncidentModeDeactivationReason.Expired ? "expired" : "deactivated",
|
||||
state.TenantId ?? "global",
|
||||
reason,
|
||||
deactivatedBy ?? "system",
|
||||
state.ActivationId);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
_expiryTimer.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// File-based exporter for sealed mode telemetry.
|
||||
/// Writes OTLP data to a local file with rotation support.
|
||||
/// </summary>
|
||||
public sealed class SealedModeFileExporter : IDisposable
|
||||
{
|
||||
private readonly IOptionsMonitor<SealedModeTelemetryOptions> _optionsMonitor;
|
||||
private readonly ILogger<SealedModeFileExporter>? _logger;
|
||||
private readonly object _lock = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private FileStream? _currentStream;
|
||||
private string? _currentFilePath;
|
||||
private long _currentSize;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the exporter has been initialized.
|
||||
/// </summary>
|
||||
public bool IsInitialized => _currentStream is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current file path being written to.
|
||||
/// </summary>
|
||||
public string? CurrentFilePath => _currentFilePath;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current file size in bytes.
|
||||
/// </summary>
|
||||
public long CurrentSize => _currentSize;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="SealedModeFileExporter"/>.
|
||||
/// </summary>
|
||||
public SealedModeFileExporter(
|
||||
IOptionsMonitor<SealedModeTelemetryOptions> optionsMonitor,
|
||||
ILogger<SealedModeFileExporter>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the exporter and creates the output file.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown if the file path has insecure permissions.</exception>
|
||||
public void Initialize()
|
||||
{
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentStream is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var filePath = options.FilePath;
|
||||
if (string.IsNullOrWhiteSpace(filePath))
|
||||
{
|
||||
throw new InvalidOperationException("File path is not configured");
|
||||
}
|
||||
|
||||
var directory = Path.GetDirectoryName(filePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
// Set directory permissions on Unix
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(directory, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to set directory permissions for {Directory}", directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check existing file permissions
|
||||
if (File.Exists(filePath) && options.FailOnInsecurePermissions && !OperatingSystem.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
var mode = File.GetUnixFileMode(filePath);
|
||||
if ((mode & (UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.GroupRead | UnixFileMode.GroupWrite)) != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Sealed mode telemetry file {filePath} has insecure permissions. " +
|
||||
"File must not be readable or writable by group or others.");
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to check file permissions for {FilePath}", filePath);
|
||||
}
|
||||
}
|
||||
|
||||
_currentFilePath = filePath;
|
||||
_currentStream = new FileStream(
|
||||
filePath,
|
||||
FileMode.Append,
|
||||
FileAccess.Write,
|
||||
FileShare.Read,
|
||||
bufferSize: 4096,
|
||||
FileOptions.WriteThrough);
|
||||
|
||||
_currentSize = _currentStream.Length;
|
||||
|
||||
// Set file permissions on Unix
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(filePath, options.FilePermissions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to set file permissions for {FilePath}", filePath);
|
||||
}
|
||||
}
|
||||
|
||||
_logger?.LogInformation(
|
||||
"Sealed mode file exporter initialized at {FilePath} (current size: {Size} bytes)",
|
||||
filePath,
|
||||
_currentSize);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes telemetry data to the file.
|
||||
/// </summary>
|
||||
/// <param name="data">The binary data to write.</param>
|
||||
/// <param name="signal">The telemetry signal type.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public void Write(ReadOnlySpan<byte> data, TelemetrySignal signal, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(SealedModeFileExporter));
|
||||
}
|
||||
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentStream is null)
|
||||
{
|
||||
Initialize();
|
||||
}
|
||||
|
||||
// Check if rotation is needed
|
||||
if (_currentSize + data.Length > options.MaxBytes)
|
||||
{
|
||||
RotateFile();
|
||||
}
|
||||
|
||||
// Write header with timestamp and signal type
|
||||
var timestamp = _timeProvider.GetUtcNow();
|
||||
var header = $"[{timestamp:O}][{signal}][{data.Length}]\n";
|
||||
var headerBytes = Encoding.UTF8.GetBytes(header);
|
||||
|
||||
_currentStream!.Write(headerBytes);
|
||||
_currentStream.Write(data);
|
||||
_currentStream.WriteByte((byte)'\n');
|
||||
_currentStream.Flush();
|
||||
|
||||
_currentSize += headerBytes.Length + data.Length + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a string record to the file.
|
||||
/// </summary>
|
||||
/// <param name="record">The string record to write.</param>
|
||||
/// <param name="signal">The telemetry signal type.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public void WriteRecord(string record, TelemetrySignal signal, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(record);
|
||||
Write(bytes, signal, cancellationToken);
|
||||
}
|
||||
|
||||
private void RotateFile()
|
||||
{
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
var basePath = _currentFilePath!;
|
||||
|
||||
_currentStream?.Dispose();
|
||||
_currentStream = null;
|
||||
|
||||
// Rotate existing files
|
||||
for (var i = options.MaxRotatedFiles; i >= 1; i--)
|
||||
{
|
||||
var oldPath = i == 1 ? basePath : $"{basePath}.{i - 1}";
|
||||
var newPath = $"{basePath}.{i}";
|
||||
|
||||
if (File.Exists(oldPath))
|
||||
{
|
||||
if (i == options.MaxRotatedFiles)
|
||||
{
|
||||
// Delete oldest file
|
||||
try
|
||||
{
|
||||
File.Delete(oldPath);
|
||||
_logger?.LogDebug("Deleted oldest rotated file {Path}", oldPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to delete rotated file {Path}", oldPath);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Rename to next slot
|
||||
try
|
||||
{
|
||||
if (File.Exists(newPath))
|
||||
{
|
||||
File.Delete(newPath);
|
||||
}
|
||||
File.Move(oldPath, newPath);
|
||||
_logger?.LogDebug("Rotated {OldPath} to {NewPath}", oldPath, newPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to rotate {OldPath} to {NewPath}", oldPath, newPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create new file
|
||||
_currentStream = new FileStream(
|
||||
basePath,
|
||||
FileMode.Create,
|
||||
FileAccess.Write,
|
||||
FileShare.Read,
|
||||
bufferSize: 4096,
|
||||
FileOptions.WriteThrough);
|
||||
|
||||
_currentSize = 0;
|
||||
|
||||
// Set file permissions on Unix
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
try
|
||||
{
|
||||
File.SetUnixFileMode(basePath, options.FilePermissions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Failed to set file permissions for {FilePath}", basePath);
|
||||
}
|
||||
}
|
||||
|
||||
_logger?.LogInformation("Rotated sealed mode telemetry file. New file: {Path}", basePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Flushes any buffered data to disk.
|
||||
/// </summary>
|
||||
public void Flush()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_currentStream?.Flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_currentStream?.Dispose();
|
||||
_currentStream = null;
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Options for sealed-mode telemetry behavior.
|
||||
/// </summary>
|
||||
public sealed class SealedModeTelemetryOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "Telemetry:Sealed";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether sealed mode telemetry is enabled.
|
||||
/// This is typically driven by <see cref="StellaOps.AirGap.Policy.IEgressPolicy.IsSealed"/>.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the exporter type to use in sealed mode.
|
||||
/// </summary>
|
||||
public SealedModeExporterType Exporter { get; set; } = SealedModeExporterType.File;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the file path for the file exporter.
|
||||
/// </summary>
|
||||
public string FilePath { get; set; } = "./logs/telemetry-sealed.otlp";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum bytes for the file exporter before rotation.
|
||||
/// Default is 10 MB.
|
||||
/// </summary>
|
||||
public long MaxBytes { get; set; } = 10_485_760;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of rotated files to keep.
|
||||
/// </summary>
|
||||
public int MaxRotatedFiles { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum sampling percentage in sealed mode (0-100).
|
||||
/// Default is 10%.
|
||||
/// </summary>
|
||||
public int MaxSamplingPercent { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to force scrubbing regardless of default settings.
|
||||
/// </summary>
|
||||
public bool ForceScrub { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to suppress exemplars in sealed mode.
|
||||
/// </summary>
|
||||
public bool SuppressExemplars { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tag name for sealed mode indicator.
|
||||
/// </summary>
|
||||
public string SealedTagName { get; set; } = "sealed";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to add scrubbed indicator tag.
|
||||
/// </summary>
|
||||
public bool AddScrubbedTag { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets additional tags to add in sealed mode.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> AdditionalTags { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum clock skew threshold before warning.
|
||||
/// Default is 500ms.
|
||||
/// </summary>
|
||||
public TimeSpan ClockSkewThreshold { get; set; } = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether incident mode can override the sampling ceiling.
|
||||
/// </summary>
|
||||
public bool AllowIncidentModeOverride { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the required file permissions (Unix only).
|
||||
/// Default is 0600 (owner read/write only).
|
||||
/// </summary>
|
||||
public UnixFileMode FilePermissions { get; set; } = UnixFileMode.UserRead | UnixFileMode.UserWrite;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether to fail startup if the file path has insecure permissions.
|
||||
/// </summary>
|
||||
public bool FailOnInsecurePermissions { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the options and returns any validation errors.
|
||||
/// </summary>
|
||||
public List<string> Validate()
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (MaxSamplingPercent < 0 || MaxSamplingPercent > 100)
|
||||
{
|
||||
errors.Add($"MaxSamplingPercent ({MaxSamplingPercent}) must be between 0 and 100");
|
||||
}
|
||||
|
||||
if (MaxBytes <= 0)
|
||||
{
|
||||
errors.Add("MaxBytes must be positive");
|
||||
}
|
||||
|
||||
if (MaxRotatedFiles < 0)
|
||||
{
|
||||
errors.Add("MaxRotatedFiles cannot be negative");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(FilePath) && Exporter == SealedModeExporterType.File)
|
||||
{
|
||||
errors.Add("FilePath is required when Exporter is File");
|
||||
}
|
||||
|
||||
if (ClockSkewThreshold <= TimeSpan.Zero)
|
||||
{
|
||||
errors.Add("ClockSkewThreshold must be positive");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the effective sampling rate as a decimal (0.0-1.0).
|
||||
/// </summary>
|
||||
/// <param name="incidentModeActive">Whether incident mode is active.</param>
|
||||
/// <param name="incidentSamplingRate">The sampling rate requested by incident mode.</param>
|
||||
/// <returns>The effective sampling rate clamped to sealed mode limits.</returns>
|
||||
public double GetEffectiveSamplingRate(bool incidentModeActive, double incidentSamplingRate)
|
||||
{
|
||||
var maxRate = MaxSamplingPercent / 100.0;
|
||||
|
||||
if (incidentModeActive && AllowIncidentModeOverride)
|
||||
{
|
||||
// Incident mode can override up to 100%
|
||||
return Math.Min(incidentSamplingRate, 1.0);
|
||||
}
|
||||
|
||||
return maxRate;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exporter type for sealed mode telemetry.
|
||||
/// </summary>
|
||||
public enum SealedModeExporterType
|
||||
{
|
||||
/// <summary>
|
||||
/// In-memory ring buffer exporter.
|
||||
/// </summary>
|
||||
Memory,
|
||||
|
||||
/// <summary>
|
||||
/// File-based OTLP exporter.
|
||||
/// </summary>
|
||||
File
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Diagnostics.Metrics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.AirGap.Policy;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ISealedModeTelemetryService"/>.
|
||||
/// </summary>
|
||||
public sealed class SealedModeTelemetryService : ISealedModeTelemetryService, IDisposable
|
||||
{
|
||||
private static readonly ActivitySource ActivitySource = new("StellaOps.Telemetry.SealedMode", "1.0.0");
|
||||
private static readonly Meter Meter = new("StellaOps.Telemetry.SealedMode", "1.0.0");
|
||||
|
||||
private readonly IOptionsMonitor<SealedModeTelemetryOptions> _optionsMonitor;
|
||||
private readonly IEgressPolicy? _egressPolicy;
|
||||
private readonly IIncidentModeService? _incidentModeService;
|
||||
private readonly ILogger<SealedModeTelemetryService>? _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly object _lock = new();
|
||||
|
||||
private readonly Counter<long> _sealEventsCounter;
|
||||
private readonly Counter<long> _unsealEventsCounter;
|
||||
private readonly Counter<long> _driftEventsCounter;
|
||||
private readonly Counter<long> _blockedExportsCounter;
|
||||
|
||||
private bool _previousSealedState;
|
||||
private DateTimeOffset? _lastStateChangeTime;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsSealed => _egressPolicy?.IsSealed ?? _optionsMonitor.CurrentValue.Enabled;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public double EffectiveSamplingRate
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!IsSealed)
|
||||
{
|
||||
return 1.0; // Full sampling when not sealed
|
||||
}
|
||||
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
var incidentActive = _incidentModeService?.IsActive ?? false;
|
||||
var incidentRate = incidentActive ? 1.0 : options.MaxSamplingPercent / 100.0;
|
||||
|
||||
return options.GetEffectiveSamplingRate(incidentActive, incidentRate);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsIncidentModeOverrideActive =>
|
||||
IsSealed &&
|
||||
(_incidentModeService?.IsActive ?? false) &&
|
||||
_optionsMonitor.CurrentValue.AllowIncidentModeOverride;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event EventHandler<SealedModeStateChangedEventArgs>? StateChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="SealedModeTelemetryService"/>.
|
||||
/// </summary>
|
||||
public SealedModeTelemetryService(
|
||||
IOptionsMonitor<SealedModeTelemetryOptions> optionsMonitor,
|
||||
IEgressPolicy? egressPolicy = null,
|
||||
IIncidentModeService? incidentModeService = null,
|
||||
ILogger<SealedModeTelemetryService>? logger = null,
|
||||
TimeProvider? timeProvider = null)
|
||||
{
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
_egressPolicy = egressPolicy;
|
||||
_incidentModeService = incidentModeService;
|
||||
_logger = logger;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
// Initialize metrics
|
||||
_sealEventsCounter = Meter.CreateCounter<long>(
|
||||
"stellaops.telemetry.sealed.seal_events",
|
||||
unit: "{event}",
|
||||
description: "Count of seal events (entries into sealed mode)");
|
||||
|
||||
_unsealEventsCounter = Meter.CreateCounter<long>(
|
||||
"stellaops.telemetry.sealed.unseal_events",
|
||||
unit: "{event}",
|
||||
description: "Count of unseal events (exits from sealed mode)");
|
||||
|
||||
_driftEventsCounter = Meter.CreateCounter<long>(
|
||||
"stellaops.telemetry.sealed.drift_events",
|
||||
unit: "{event}",
|
||||
description: "Count of drift events when external export was blocked");
|
||||
|
||||
_blockedExportsCounter = Meter.CreateCounter<long>(
|
||||
"stellaops.telemetry.sealed.blocked_exports",
|
||||
unit: "{request}",
|
||||
description: "Count of blocked external export requests");
|
||||
|
||||
_previousSealedState = IsSealed;
|
||||
|
||||
// Monitor for state changes
|
||||
if (_egressPolicy is null)
|
||||
{
|
||||
_optionsMonitor.OnChange(OnOptionsChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOptionsChanged(SealedModeTelemetryOptions options, string? name)
|
||||
{
|
||||
var currentSealed = options.Enabled;
|
||||
bool stateChanged;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
stateChanged = currentSealed != _previousSealedState;
|
||||
if (stateChanged)
|
||||
{
|
||||
_previousSealedState = currentSealed;
|
||||
_lastStateChangeTime = _timeProvider.GetUtcNow();
|
||||
}
|
||||
}
|
||||
|
||||
if (stateChanged)
|
||||
{
|
||||
if (currentSealed)
|
||||
{
|
||||
RecordSealEvent("Configuration change", "system");
|
||||
}
|
||||
else
|
||||
{
|
||||
RecordUnsealEvent("Configuration change", "system");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IReadOnlyDictionary<string, string> GetSealedModeTags()
|
||||
{
|
||||
if (!IsSealed)
|
||||
{
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
var tags = new Dictionary<string, string>
|
||||
{
|
||||
[options.SealedTagName] = "true"
|
||||
};
|
||||
|
||||
if (options.AddScrubbedTag && options.ForceScrub)
|
||||
{
|
||||
tags["scrubbed"] = "true";
|
||||
}
|
||||
|
||||
if (IsIncidentModeOverrideActive)
|
||||
{
|
||||
tags["incident_override"] = "true";
|
||||
}
|
||||
|
||||
foreach (var (key, value) in options.AdditionalTags)
|
||||
{
|
||||
tags[key] = value;
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsExternalExportAllowed(Uri endpoint)
|
||||
{
|
||||
if (!IsSealed)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
_blockedExportsCounter.Add(1, new KeyValuePair<string, object?>("endpoint_host", endpoint.Host));
|
||||
|
||||
_logger?.LogDebug(
|
||||
"External export to {Endpoint} blocked in sealed mode",
|
||||
endpoint);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SealedModeExporterConfig? GetLocalExporterConfig()
|
||||
{
|
||||
if (!IsSealed)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
return new SealedModeExporterConfig
|
||||
{
|
||||
Type = options.Exporter,
|
||||
FilePath = options.FilePath,
|
||||
MaxBytes = options.MaxBytes,
|
||||
MaxRotatedFiles = options.MaxRotatedFiles
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RecordSealEvent(string? reason = null, string? actor = null)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
using var activity = ActivitySource.StartActivity("SealMode", ActivityKind.Internal);
|
||||
activity?.SetTag("sealed.reason", reason ?? "unspecified");
|
||||
activity?.SetTag("sealed.actor", actor ?? "unknown");
|
||||
activity?.SetTag("sealed.timestamp", now.ToString("O"));
|
||||
|
||||
_sealEventsCounter.Add(1,
|
||||
new KeyValuePair<string, object?>("reason", reason ?? "unspecified"),
|
||||
new KeyValuePair<string, object?>("actor", actor ?? "unknown"));
|
||||
|
||||
_logger?.LogInformation(
|
||||
"Sealed mode activated. Reason: {Reason}, Actor: {Actor}, Timestamp: {Timestamp}",
|
||||
reason ?? "unspecified",
|
||||
actor ?? "unknown",
|
||||
now);
|
||||
|
||||
StateChanged?.Invoke(this, new SealedModeStateChangedEventArgs
|
||||
{
|
||||
IsSealed = true,
|
||||
Timestamp = now,
|
||||
Reason = reason,
|
||||
Actor = actor
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RecordUnsealEvent(string? reason = null, string? actor = null)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
|
||||
using var activity = ActivitySource.StartActivity("UnsealMode", ActivityKind.Internal);
|
||||
activity?.SetTag("sealed.reason", reason ?? "unspecified");
|
||||
activity?.SetTag("sealed.actor", actor ?? "unknown");
|
||||
activity?.SetTag("sealed.timestamp", now.ToString("O"));
|
||||
|
||||
_unsealEventsCounter.Add(1,
|
||||
new KeyValuePair<string, object?>("reason", reason ?? "unspecified"),
|
||||
new KeyValuePair<string, object?>("actor", actor ?? "unknown"));
|
||||
|
||||
_logger?.LogInformation(
|
||||
"Sealed mode deactivated. Reason: {Reason}, Actor: {Actor}, Timestamp: {Timestamp}",
|
||||
reason ?? "unspecified",
|
||||
actor ?? "unknown",
|
||||
now);
|
||||
|
||||
StateChanged?.Invoke(this, new SealedModeStateChangedEventArgs
|
||||
{
|
||||
IsSealed = false,
|
||||
Timestamp = now,
|
||||
Reason = reason,
|
||||
Actor = actor
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void RecordDriftEvent(Uri endpoint, TelemetrySignal signal)
|
||||
{
|
||||
using var activity = ActivitySource.StartActivity("SealedModeDrift", ActivityKind.Internal);
|
||||
activity?.SetTag("drift.endpoint", endpoint.ToString());
|
||||
activity?.SetTag("drift.signal", signal.ToString());
|
||||
activity?.SetTag("drift.timestamp", _timeProvider.GetUtcNow().ToString("O"));
|
||||
|
||||
_driftEventsCounter.Add(1,
|
||||
new KeyValuePair<string, object?>("endpoint_host", endpoint.Host),
|
||||
new KeyValuePair<string, object?>("signal", signal.ToString()));
|
||||
|
||||
_logger?.LogWarning(
|
||||
"Telemetry drift detected: external {Signal} export to {Endpoint} blocked in sealed mode",
|
||||
signal,
|
||||
endpoint);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
// Cleanup if needed
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,71 @@ public static class TelemetryServiceCollectionExtensions
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers incident mode services for toggling enhanced telemetry during incidents.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection to mutate.</param>
|
||||
/// <param name="configuration">Optional configuration section binding.</param>
|
||||
/// <param name="configureOptions">Optional options configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddIncidentMode(
|
||||
this IServiceCollection services,
|
||||
IConfiguration? configuration = null,
|
||||
Action<IncidentModeOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var optionsBuilder = services.AddOptions<IncidentModeOptions>();
|
||||
|
||||
if (configuration is not null)
|
||||
{
|
||||
optionsBuilder.Bind(configuration.GetSection(IncidentModeOptions.SectionName));
|
||||
}
|
||||
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
optionsBuilder.Configure(configureOptions);
|
||||
}
|
||||
|
||||
services.TryAddSingleton<IncidentModeService>();
|
||||
services.TryAddSingleton<IIncidentModeService>(sp => sp.GetRequiredService<IncidentModeService>());
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers sealed-mode telemetry services.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection to mutate.</param>
|
||||
/// <param name="configuration">Optional configuration section binding.</param>
|
||||
/// <param name="configureOptions">Optional options configuration.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddSealedModeTelemetry(
|
||||
this IServiceCollection services,
|
||||
IConfiguration? configuration = null,
|
||||
Action<SealedModeTelemetryOptions>? configureOptions = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var optionsBuilder = services.AddOptions<SealedModeTelemetryOptions>();
|
||||
|
||||
if (configuration is not null)
|
||||
{
|
||||
optionsBuilder.Bind(configuration.GetSection(SealedModeTelemetryOptions.SectionName));
|
||||
}
|
||||
|
||||
if (configureOptions is not null)
|
||||
{
|
||||
optionsBuilder.Configure(configureOptions);
|
||||
}
|
||||
|
||||
services.TryAddSingleton<SealedModeTelemetryService>();
|
||||
services.TryAddSingleton<ISealedModeTelemetryService>(sp => sp.GetRequiredService<SealedModeTelemetryService>());
|
||||
services.TryAddSingleton<SealedModeFileExporter>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the StellaOps telemetry stack with sealed-mode enforcement.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: 'dashboard/sources',
|
||||
loadComponent: () =>
|
||||
import('./features/dashboard/sources-dashboard.component').then(
|
||||
(m) => m.SourcesDashboardComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'console/profile',
|
||||
loadComponent: () =>
|
||||
@@ -15,26 +22,26 @@ export const routes: Routes = [
|
||||
(m) => m.TrivyDbSettingsPageComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'scans/:scanId',
|
||||
loadComponent: () =>
|
||||
import('./features/scans/scan-detail-page.component').then(
|
||||
(m) => m.ScanDetailPageComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'welcome',
|
||||
loadComponent: () =>
|
||||
import('./features/welcome/welcome-page.component').then(
|
||||
(m) => m.WelcomePageComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'notify',
|
||||
loadComponent: () =>
|
||||
import('./features/notify/notify-panel.component').then(
|
||||
(m) => m.NotifyPanelComponent
|
||||
),
|
||||
{
|
||||
path: 'scans/:scanId',
|
||||
loadComponent: () =>
|
||||
import('./features/scans/scan-detail-page.component').then(
|
||||
(m) => m.ScanDetailPageComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'welcome',
|
||||
loadComponent: () =>
|
||||
import('./features/welcome/welcome-page.component').then(
|
||||
(m) => m.WelcomePageComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'notify',
|
||||
loadComponent: () =>
|
||||
import('./features/notify/notify-panel.component').then(
|
||||
(m) => m.NotifyPanelComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'auth/callback',
|
||||
|
||||
@@ -66,9 +66,32 @@ export interface AocViolationDetail {
|
||||
field?: string;
|
||||
expected?: string;
|
||||
actual?: string;
|
||||
provenance?: {
|
||||
sourceId: string;
|
||||
ingestedAt: string;
|
||||
digest: string;
|
||||
};
|
||||
provenance?: AocProvenance;
|
||||
}
|
||||
|
||||
export interface AocProvenance {
|
||||
sourceId: string;
|
||||
ingestedAt: string;
|
||||
digest: string;
|
||||
sourceType?: 'registry' | 'git' | 'upload' | 'api';
|
||||
sourceUrl?: string;
|
||||
submitter?: string;
|
||||
}
|
||||
|
||||
export interface AocViolationGroup {
|
||||
code: string;
|
||||
description: string;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
violations: AocViolationDetail[];
|
||||
affectedDocuments: number;
|
||||
remediation?: string;
|
||||
}
|
||||
|
||||
export interface AocDocumentView {
|
||||
documentId: string;
|
||||
documentType: string;
|
||||
violations: AocViolationDetail[];
|
||||
provenance: AocProvenance;
|
||||
rawContent?: Record<string, unknown>;
|
||||
highlightedFields: string[];
|
||||
}
|
||||
|
||||
77
src/Web/StellaOps.Web/src/app/core/api/determinism.models.ts
Normal file
77
src/Web/StellaOps.Web/src/app/core/api/determinism.models.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Determinism verification models for SBOM scan details.
|
||||
*/
|
||||
|
||||
export interface DeterminismStatus {
|
||||
/** Overall determinism status */
|
||||
status: 'verified' | 'warning' | 'failed' | 'unknown';
|
||||
|
||||
/** Merkle root from _composition.json */
|
||||
merkleRoot: string | null;
|
||||
|
||||
/** Whether Merkle root matches computed hash */
|
||||
merkleConsistent: boolean;
|
||||
|
||||
/** Fragment hashes with verification status */
|
||||
fragments: DeterminismFragment[];
|
||||
|
||||
/** Composition metadata */
|
||||
composition: CompositionMeta | null;
|
||||
|
||||
/** Timestamp of verification */
|
||||
verifiedAt: string;
|
||||
|
||||
/** Any issues found */
|
||||
issues: DeterminismIssue[];
|
||||
}
|
||||
|
||||
export interface DeterminismFragment {
|
||||
/** Fragment identifier (e.g., layer digest) */
|
||||
id: string;
|
||||
|
||||
/** Fragment type */
|
||||
type: 'layer' | 'metadata' | 'attestation' | 'sbom';
|
||||
|
||||
/** Expected hash from composition */
|
||||
expectedHash: string;
|
||||
|
||||
/** Computed hash */
|
||||
computedHash: string;
|
||||
|
||||
/** Whether hashes match */
|
||||
matches: boolean;
|
||||
|
||||
/** Size in bytes */
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface CompositionMeta {
|
||||
/** Composition schema version */
|
||||
schemaVersion: string;
|
||||
|
||||
/** Scanner version that produced this */
|
||||
scannerVersion: string;
|
||||
|
||||
/** Build timestamp */
|
||||
buildTimestamp: string;
|
||||
|
||||
/** Total fragments */
|
||||
fragmentCount: number;
|
||||
|
||||
/** Composition file hash */
|
||||
compositionHash: string;
|
||||
}
|
||||
|
||||
export interface DeterminismIssue {
|
||||
/** Issue severity */
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
|
||||
/** Issue code */
|
||||
code: string;
|
||||
|
||||
/** Human-readable message */
|
||||
message: string;
|
||||
|
||||
/** Affected fragment ID if applicable */
|
||||
fragmentId?: string;
|
||||
}
|
||||
95
src/Web/StellaOps.Web/src/app/core/api/entropy.models.ts
Normal file
95
src/Web/StellaOps.Web/src/app/core/api/entropy.models.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Entropy analysis models for image security visualization.
|
||||
*/
|
||||
|
||||
export interface EntropyAnalysis {
|
||||
/** Image digest */
|
||||
imageDigest: string;
|
||||
|
||||
/** Overall entropy score (0-10, higher = more suspicious) */
|
||||
overallScore: number;
|
||||
|
||||
/** Risk level classification */
|
||||
riskLevel: 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
/** Per-layer entropy breakdown */
|
||||
layers: LayerEntropy[];
|
||||
|
||||
/** Files with high entropy (potential secrets/malware) */
|
||||
highEntropyFiles: HighEntropyFile[];
|
||||
|
||||
/** Detector hints for suspicious patterns */
|
||||
detectorHints: DetectorHint[];
|
||||
|
||||
/** Analysis timestamp */
|
||||
analyzedAt: string;
|
||||
|
||||
/** Link to raw entropy report */
|
||||
reportUrl: string;
|
||||
}
|
||||
|
||||
export interface LayerEntropy {
|
||||
/** Layer digest */
|
||||
digest: string;
|
||||
|
||||
/** Layer command (e.g., COPY, RUN) */
|
||||
command: string;
|
||||
|
||||
/** Layer size in bytes */
|
||||
size: number;
|
||||
|
||||
/** Average entropy for this layer (0-8 bits) */
|
||||
avgEntropy: number;
|
||||
|
||||
/** Percentage of opaque bytes (high entropy) */
|
||||
opaqueByteRatio: number;
|
||||
|
||||
/** Number of high-entropy files */
|
||||
highEntropyFileCount: number;
|
||||
|
||||
/** Risk contribution to overall score */
|
||||
riskContribution: number;
|
||||
}
|
||||
|
||||
export interface HighEntropyFile {
|
||||
/** File path in container */
|
||||
path: string;
|
||||
|
||||
/** Layer where file was added */
|
||||
layerDigest: string;
|
||||
|
||||
/** File size in bytes */
|
||||
size: number;
|
||||
|
||||
/** File entropy (0-8 bits) */
|
||||
entropy: number;
|
||||
|
||||
/** Classification */
|
||||
classification: 'encrypted' | 'compressed' | 'binary' | 'suspicious' | 'unknown';
|
||||
|
||||
/** Why this file is flagged */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface DetectorHint {
|
||||
/** Hint ID */
|
||||
id: string;
|
||||
|
||||
/** Severity */
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
|
||||
/** Pattern type */
|
||||
type: 'credential' | 'key' | 'token' | 'obfuscated' | 'packed' | 'crypto';
|
||||
|
||||
/** Human-readable description */
|
||||
description: string;
|
||||
|
||||
/** Affected file paths */
|
||||
affectedPaths: string[];
|
||||
|
||||
/** Confidence (0-100) */
|
||||
confidence: number;
|
||||
|
||||
/** Remediation suggestion */
|
||||
remediation: string;
|
||||
}
|
||||
163
src/Web/StellaOps.Web/src/app/core/api/policy.models.ts
Normal file
163
src/Web/StellaOps.Web/src/app/core/api/policy.models.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Policy gate models for release flow indicators.
|
||||
*/
|
||||
|
||||
export interface PolicyGateStatus {
|
||||
/** Overall gate status */
|
||||
status: 'passed' | 'failed' | 'warning' | 'pending' | 'skipped';
|
||||
|
||||
/** Policy evaluation ID */
|
||||
evaluationId: string;
|
||||
|
||||
/** Target artifact (image, SBOM, etc.) */
|
||||
targetRef: string;
|
||||
|
||||
/** Policy set that was evaluated */
|
||||
policySetId: string;
|
||||
|
||||
/** Individual gate results */
|
||||
gates: PolicyGate[];
|
||||
|
||||
/** Blocking issues preventing publish */
|
||||
blockingIssues: PolicyBlockingIssue[];
|
||||
|
||||
/** Warning-level issues */
|
||||
warnings: PolicyWarning[];
|
||||
|
||||
/** Remediation hints for failures */
|
||||
remediationHints: PolicyRemediationHint[];
|
||||
|
||||
/** Evaluation timestamp */
|
||||
evaluatedAt: string;
|
||||
|
||||
/** Can the artifact be published? */
|
||||
canPublish: boolean;
|
||||
|
||||
/** Reason if publish is blocked */
|
||||
blockReason?: string;
|
||||
}
|
||||
|
||||
export interface PolicyGate {
|
||||
/** Gate identifier */
|
||||
gateId: string;
|
||||
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
|
||||
/** Gate type */
|
||||
type: 'determinism' | 'vulnerability' | 'license' | 'signature' | 'entropy' | 'custom';
|
||||
|
||||
/** Gate result */
|
||||
result: 'passed' | 'failed' | 'warning' | 'skipped';
|
||||
|
||||
/** Is this gate required for publish? */
|
||||
required: boolean;
|
||||
|
||||
/** Gate-specific details */
|
||||
details?: Record<string, unknown>;
|
||||
|
||||
/** Evidence references */
|
||||
evidenceRefs?: string[];
|
||||
}
|
||||
|
||||
export interface PolicyBlockingIssue {
|
||||
/** Issue code */
|
||||
code: string;
|
||||
|
||||
/** Gate that produced this issue */
|
||||
gateId: string;
|
||||
|
||||
/** Issue severity */
|
||||
severity: 'critical' | 'high';
|
||||
|
||||
/** Issue description */
|
||||
message: string;
|
||||
|
||||
/** Affected resource */
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
export interface PolicyWarning {
|
||||
/** Warning code */
|
||||
code: string;
|
||||
|
||||
/** Gate that produced this warning */
|
||||
gateId: string;
|
||||
|
||||
/** Warning message */
|
||||
message: string;
|
||||
|
||||
/** Affected resource */
|
||||
resource?: string;
|
||||
}
|
||||
|
||||
export interface PolicyRemediationHint {
|
||||
/** Which gate/issue this remediates */
|
||||
forGate: string;
|
||||
|
||||
/** Which issue code */
|
||||
forCode?: string;
|
||||
|
||||
/** Hint title */
|
||||
title: string;
|
||||
|
||||
/** Step-by-step instructions */
|
||||
steps: string[];
|
||||
|
||||
/** Documentation link */
|
||||
docsUrl?: string;
|
||||
|
||||
/** CLI command to run */
|
||||
cliCommand?: string;
|
||||
|
||||
/** Estimated effort */
|
||||
effort?: 'trivial' | 'easy' | 'moderate' | 'complex';
|
||||
}
|
||||
|
||||
export interface DeterminismGateDetails {
|
||||
/** Merkle root consistency */
|
||||
merkleRootConsistent: boolean;
|
||||
|
||||
/** Expected Merkle root */
|
||||
expectedMerkleRoot?: string;
|
||||
|
||||
/** Computed Merkle root */
|
||||
computedMerkleRoot?: string;
|
||||
|
||||
/** Fragment verification results */
|
||||
fragmentResults: {
|
||||
fragmentId: string;
|
||||
expected: string;
|
||||
computed: string;
|
||||
match: boolean;
|
||||
}[];
|
||||
|
||||
/** Composition file present */
|
||||
compositionPresent: boolean;
|
||||
|
||||
/** Total fragments */
|
||||
totalFragments: number;
|
||||
|
||||
/** Matching fragments */
|
||||
matchingFragments: number;
|
||||
}
|
||||
|
||||
export interface EntropyGateDetails {
|
||||
/** Overall entropy score */
|
||||
entropyScore: number;
|
||||
|
||||
/** Score threshold for warning */
|
||||
warnThreshold: number;
|
||||
|
||||
/** Score threshold for block */
|
||||
blockThreshold: number;
|
||||
|
||||
/** Action taken based on score */
|
||||
action: 'allow' | 'warn' | 'block';
|
||||
|
||||
/** High entropy files count */
|
||||
highEntropyFileCount: number;
|
||||
|
||||
/** Suspicious patterns detected */
|
||||
suspiciousPatterns: string[];
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<div class="verify-action" [class]="'state-' + state()">
|
||||
<!-- Action Header -->
|
||||
<div class="action-header">
|
||||
<div class="action-info">
|
||||
<span class="status-icon">{{ statusIcon() }}</span>
|
||||
<div class="action-text">
|
||||
<h4 class="action-title">Verify Last {{ windowHours() }} Hours</h4>
|
||||
<p class="action-desc">{{ statusLabel() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
@if (state() === 'idle' || state() === 'completed' || state() === 'error') {
|
||||
<button class="btn-verify" (click)="runVerification()">
|
||||
@if (state() === 'idle') {
|
||||
Run Verification
|
||||
} @else {
|
||||
Re-run
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<button class="btn-cli" (click)="toggleCliGuidance()" [class.active]="showCliGuidance()">
|
||||
CLI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
@if (state() === 'running') {
|
||||
<div class="progress-section">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" [style.width]="progress() + '%'"></div>
|
||||
</div>
|
||||
<span class="progress-text">{{ progress() | number:'1.0-0' }}%</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Error State -->
|
||||
@if (state() === 'error' && error()) {
|
||||
<div class="error-banner">
|
||||
<span class="error-icon">[X]</span>
|
||||
<span class="error-message">{{ error() }}</span>
|
||||
<button class="btn-retry" (click)="runVerification()">Retry</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Results -->
|
||||
@if (state() === 'completed' && result()) {
|
||||
<div class="results-section">
|
||||
<!-- Summary Stats -->
|
||||
<div class="results-summary">
|
||||
<div class="stat-card" [class.success]="result()!.status === 'passed'">
|
||||
<span class="stat-value">{{ result()!.checkedCount | number }}</span>
|
||||
<span class="stat-label">Documents Checked</span>
|
||||
</div>
|
||||
<div class="stat-card success">
|
||||
<span class="stat-value">{{ result()!.passedCount | number }}</span>
|
||||
<span class="stat-label">Passed</span>
|
||||
</div>
|
||||
<div class="stat-card" [class.error]="result()!.failedCount > 0">
|
||||
<span class="stat-value">{{ result()!.failedCount | number }}</span>
|
||||
<span class="stat-label">Failed</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{{ resultSummary()?.passRate }}%</span>
|
||||
<span class="stat-label">Pass Rate</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Violations Preview -->
|
||||
@if (result()!.violations.length > 0) {
|
||||
<div class="violations-preview">
|
||||
<h5 class="preview-title">
|
||||
Violations Found
|
||||
<span class="violation-count">{{ result()!.violations.length }}</span>
|
||||
</h5>
|
||||
|
||||
<!-- Violation codes breakdown -->
|
||||
<div class="code-breakdown">
|
||||
@for (code of resultSummary()?.uniqueCodes || []; track code) {
|
||||
<span class="code-chip">
|
||||
{{ code }}
|
||||
<span class="code-count">
|
||||
{{ result()!.violations.filter(v => v.violationCode === code).length }}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Sample violations -->
|
||||
<ul class="violations-list">
|
||||
@for (v of result()!.violations.slice(0, 3); track v.documentId + v.violationCode) {
|
||||
<li class="violation-item">
|
||||
<button class="violation-btn" (click)="onSelectViolation(v)">
|
||||
<span class="v-code">{{ v.violationCode }}</span>
|
||||
<span class="v-doc">{{ v.documentId | slice:0:20 }}...</span>
|
||||
@if (v.field) {
|
||||
<span class="v-field">{{ v.field }}</span>
|
||||
}
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
@if (result()!.violations.length > 3) {
|
||||
<li class="more-violations">
|
||||
+ {{ result()!.violations.length - 3 }} more violations
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="no-violations">
|
||||
<span class="success-icon">[+]</span>
|
||||
<span>No violations found in the last {{ windowHours() }} hours</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Completion Info -->
|
||||
<div class="completion-info">
|
||||
<span class="verify-id">ID: {{ result()!.verificationId | slice:0:12 }}</span>
|
||||
<span class="verify-time">Completed: {{ result()!.completedAt | date:'medium' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- CLI Guidance Panel -->
|
||||
@if (showCliGuidance()) {
|
||||
<div class="cli-guidance">
|
||||
<h5 class="cli-title">CLI Parity</h5>
|
||||
<p class="cli-desc">{{ cliGuidance.description }}</p>
|
||||
|
||||
<!-- Current Command -->
|
||||
<div class="cli-command-section">
|
||||
<label class="cli-label">Equivalent Command</label>
|
||||
<div class="cli-command">
|
||||
<code>{{ getCliCommand() }}</code>
|
||||
<button class="btn-copy" (click)="copyCommand(getCliCommand())" title="Copy">
|
||||
[C]
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Flags -->
|
||||
<div class="cli-flags-section">
|
||||
<label class="cli-label">Available Flags</label>
|
||||
<table class="flags-table">
|
||||
<tbody>
|
||||
@for (flag of cliGuidance.flags; track flag.flag) {
|
||||
<tr>
|
||||
<td class="flag-name"><code>{{ flag.flag }}</code></td>
|
||||
<td class="flag-desc">{{ flag.description }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Examples -->
|
||||
<div class="cli-examples-section">
|
||||
<label class="cli-label">Examples</label>
|
||||
<div class="examples-list">
|
||||
@for (example of cliGuidance.examples; track example) {
|
||||
<div class="example-item">
|
||||
<code>{{ example }}</code>
|
||||
<button class="btn-copy" (click)="copyCommand(example)" title="Copy">
|
||||
[C]
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Install hint -->
|
||||
<div class="install-hint">
|
||||
<span class="hint-icon">[i]</span>
|
||||
<span>Install CLI: <code>npm install -g @stellaops/cli</code></span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,184 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { AocClient } from '../../core/api/aoc.client';
|
||||
import {
|
||||
AocVerificationRequest,
|
||||
AocVerificationResult,
|
||||
AocViolationDetail,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
type VerifyState = 'idle' | 'running' | 'completed' | 'error';
|
||||
|
||||
export interface CliParityGuidance {
|
||||
command: string;
|
||||
description: string;
|
||||
flags: { flag: string; description: string }[];
|
||||
examples: string[];
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-verify-action',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './verify-action.component.html',
|
||||
styleUrls: ['./verify-action.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class VerifyActionComponent {
|
||||
private readonly aocClient = inject(AocClient);
|
||||
|
||||
/** Tenant ID to verify */
|
||||
readonly tenantId = input.required<string>();
|
||||
|
||||
/** Time window in hours (default 24h) */
|
||||
readonly windowHours = input(24);
|
||||
|
||||
/** Maximum documents to check */
|
||||
readonly limit = input(10000);
|
||||
|
||||
/** Emits when verification completes */
|
||||
readonly verified = output<AocVerificationResult>();
|
||||
|
||||
/** Emits when user clicks on a violation */
|
||||
readonly selectViolation = output<AocViolationDetail>();
|
||||
|
||||
readonly state = signal<VerifyState>('idle');
|
||||
readonly result = signal<AocVerificationResult | null>(null);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly progress = signal(0);
|
||||
readonly showCliGuidance = signal(false);
|
||||
|
||||
readonly statusIcon = computed(() => {
|
||||
switch (this.state()) {
|
||||
case 'idle':
|
||||
return '[ ]';
|
||||
case 'running':
|
||||
return '[~]';
|
||||
case 'completed':
|
||||
return this.result()?.status === 'passed' ? '[+]' : '[!]';
|
||||
case 'error':
|
||||
return '[X]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
});
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
switch (this.state()) {
|
||||
case 'idle':
|
||||
return 'Ready to verify';
|
||||
case 'running':
|
||||
return 'Verification in progress...';
|
||||
case 'completed':
|
||||
const r = this.result();
|
||||
if (!r) return 'Completed';
|
||||
return r.status === 'passed'
|
||||
? 'Verification passed'
|
||||
: r.status === 'failed'
|
||||
? 'Verification failed'
|
||||
: 'Verification completed with warnings';
|
||||
case 'error':
|
||||
return 'Verification error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
readonly resultSummary = computed(() => {
|
||||
const r = this.result();
|
||||
if (!r) return null;
|
||||
return {
|
||||
passRate: ((r.passedCount / r.checkedCount) * 100).toFixed(2),
|
||||
violationCount: r.violations.length,
|
||||
uniqueCodes: [...new Set(r.violations.map((v) => v.violationCode))],
|
||||
};
|
||||
});
|
||||
|
||||
readonly cliGuidance: CliParityGuidance = {
|
||||
command: 'stella aoc verify',
|
||||
description:
|
||||
'Run the same verification from CLI for automation, CI/CD pipelines, or detailed output.',
|
||||
flags: [
|
||||
{ flag: '--tenant', description: 'Tenant ID to verify' },
|
||||
{ flag: '--since', description: 'Start time (ISO8601 or duration like "24h")' },
|
||||
{ flag: '--limit', description: 'Maximum documents to check' },
|
||||
{ flag: '--output', description: 'Output format: json, table, summary' },
|
||||
{ flag: '--fail-on-violation', description: 'Exit with code 1 if any violations found' },
|
||||
{ flag: '--verbose', description: 'Show detailed violation information' },
|
||||
],
|
||||
examples: [
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h',
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h --output json > report.json',
|
||||
'stella aoc verify --tenant $TENANT_ID --since 24h --fail-on-violation',
|
||||
],
|
||||
};
|
||||
|
||||
async runVerification(): Promise<void> {
|
||||
if (this.state() === 'running') return;
|
||||
|
||||
this.state.set('running');
|
||||
this.error.set(null);
|
||||
this.result.set(null);
|
||||
this.progress.set(0);
|
||||
|
||||
// Simulate progress updates
|
||||
const progressInterval = setInterval(() => {
|
||||
this.progress.update((p) => Math.min(p + Math.random() * 15, 90));
|
||||
}, 200);
|
||||
|
||||
const since = new Date();
|
||||
since.setHours(since.getHours() - this.windowHours());
|
||||
|
||||
const request: AocVerificationRequest = {
|
||||
tenantId: this.tenantId(),
|
||||
since: since.toISOString(),
|
||||
limit: this.limit(),
|
||||
};
|
||||
|
||||
this.aocClient.verify(request).subscribe({
|
||||
next: (result) => {
|
||||
clearInterval(progressInterval);
|
||||
this.progress.set(100);
|
||||
this.result.set(result);
|
||||
this.state.set('completed');
|
||||
this.verified.emit(result);
|
||||
},
|
||||
error: (err) => {
|
||||
clearInterval(progressInterval);
|
||||
this.state.set('error');
|
||||
this.error.set(err.message || 'Verification failed');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state.set('idle');
|
||||
this.result.set(null);
|
||||
this.error.set(null);
|
||||
this.progress.set(0);
|
||||
}
|
||||
|
||||
toggleCliGuidance(): void {
|
||||
this.showCliGuidance.update((v) => !v);
|
||||
}
|
||||
|
||||
onSelectViolation(violation: AocViolationDetail): void {
|
||||
this.selectViolation.emit(violation);
|
||||
}
|
||||
|
||||
copyCommand(command: string): void {
|
||||
navigator.clipboard.writeText(command);
|
||||
}
|
||||
|
||||
getCliCommand(): string {
|
||||
return `stella aoc verify --tenant ${this.tenantId()} --since ${this.windowHours()}h`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
<div class="violation-drilldown">
|
||||
<!-- Header with Summary -->
|
||||
<header class="drilldown-header">
|
||||
<div class="summary-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-value">{{ totalViolations() }}</span>
|
||||
<span class="stat-label">Violations</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-value">{{ totalDocuments() }}</span>
|
||||
<span class="stat-label">Documents</span>
|
||||
</div>
|
||||
<div class="severity-breakdown">
|
||||
@if (severityCounts().critical > 0) {
|
||||
<span class="severity-chip critical">{{ severityCounts().critical }} critical</span>
|
||||
}
|
||||
@if (severityCounts().high > 0) {
|
||||
<span class="severity-chip high">{{ severityCounts().high }} high</span>
|
||||
}
|
||||
@if (severityCounts().medium > 0) {
|
||||
<span class="severity-chip medium">{{ severityCounts().medium }} medium</span>
|
||||
}
|
||||
@if (severityCounts().low > 0) {
|
||||
<span class="severity-chip low">{{ severityCounts().low }} low</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'by-violation'"
|
||||
(click)="setViewMode('by-violation')"
|
||||
>
|
||||
By Violation
|
||||
</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'by-document'"
|
||||
(click)="setViewMode('by-document')"
|
||||
>
|
||||
By Document
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
class="search-input"
|
||||
placeholder="Filter violations..."
|
||||
[value]="searchFilter()"
|
||||
(input)="onSearch($event)"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- By Violation View -->
|
||||
@if (viewMode() === 'by-violation') {
|
||||
<div class="violation-list">
|
||||
@for (group of filteredGroups(); track group.code) {
|
||||
<div class="violation-group" [class]="'severity-' + group.severity">
|
||||
<button
|
||||
class="group-header"
|
||||
(click)="toggleGroup(group.code)"
|
||||
[attr.aria-expanded]="expandedCode() === group.code"
|
||||
>
|
||||
<span class="severity-icon">{{ getSeverityIcon(group.severity) }}</span>
|
||||
<div class="group-info">
|
||||
<span class="violation-code">{{ group.code }}</span>
|
||||
<span class="violation-desc">{{ group.description }}</span>
|
||||
</div>
|
||||
<span class="affected-count">{{ group.affectedDocuments }} doc(s)</span>
|
||||
<span class="expand-icon" [class.expanded]="expandedCode() === group.code">v</span>
|
||||
</button>
|
||||
|
||||
@if (expandedCode() === group.code) {
|
||||
<div class="group-details">
|
||||
@if (group.remediation) {
|
||||
<div class="remediation-hint">
|
||||
<strong>Remediation:</strong> {{ group.remediation }}
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="violations-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Document</th>
|
||||
<th>Field</th>
|
||||
<th>Expected</th>
|
||||
<th>Actual</th>
|
||||
<th>Provenance</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (v of group.violations; track v.documentId + v.field) {
|
||||
<tr class="violation-row">
|
||||
<td class="doc-cell">
|
||||
<button class="doc-link" (click)="onSelectDocument(v.documentId)">
|
||||
{{ v.documentId | slice:0:20 }}...
|
||||
</button>
|
||||
</td>
|
||||
<td class="field-cell">
|
||||
@if (v.field) {
|
||||
<code class="field-path highlighted">{{ v.field }}</code>
|
||||
} @else {
|
||||
<span class="no-field">-</span>
|
||||
}
|
||||
</td>
|
||||
<td class="expected-cell">
|
||||
@if (v.expected) {
|
||||
<code class="value expected">{{ v.expected }}</code>
|
||||
} @else {
|
||||
<span class="no-value">-</span>
|
||||
}
|
||||
</td>
|
||||
<td class="actual-cell">
|
||||
@if (v.actual) {
|
||||
<code class="value actual error">{{ v.actual }}</code>
|
||||
} @else {
|
||||
<span class="no-value">-</span>
|
||||
}
|
||||
</td>
|
||||
<td class="provenance-cell">
|
||||
@if (v.provenance) {
|
||||
<div class="provenance-info">
|
||||
<span class="source-type">{{ getSourceTypeIcon(v.provenance.sourceType) }}</span>
|
||||
<span class="source-id" [title]="v.provenance.sourceId">
|
||||
{{ v.provenance.sourceId | slice:0:15 }}
|
||||
</span>
|
||||
<span class="digest" [title]="v.provenance.digest">
|
||||
{{ formatDigest(v.provenance.digest) }}
|
||||
</span>
|
||||
</div>
|
||||
} @else {
|
||||
<span class="no-provenance">No provenance</span>
|
||||
}
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn-icon" (click)="onViewRaw(v.documentId)" title="View raw">
|
||||
{ }
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filteredGroups().length === 0) {
|
||||
<div class="empty-state">
|
||||
@if (searchFilter()) {
|
||||
<p>No violations match "{{ searchFilter() }}"</p>
|
||||
} @else {
|
||||
<p>No violations to display</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- By Document View -->
|
||||
@if (viewMode() === 'by-document') {
|
||||
<div class="document-list">
|
||||
@for (doc of filteredDocuments(); track doc.documentId) {
|
||||
<div class="document-card">
|
||||
<button
|
||||
class="doc-header"
|
||||
(click)="toggleDocument(doc.documentId)"
|
||||
[attr.aria-expanded]="expandedDocId() === doc.documentId"
|
||||
>
|
||||
<span class="doc-type-badge">{{ doc.documentType }}</span>
|
||||
<span class="doc-id">{{ doc.documentId }}</span>
|
||||
<span class="violation-count">{{ doc.violations.length }} violation(s)</span>
|
||||
<span class="expand-icon" [class.expanded]="expandedDocId() === doc.documentId">v</span>
|
||||
</button>
|
||||
|
||||
@if (expandedDocId() === doc.documentId) {
|
||||
<div class="doc-details">
|
||||
<!-- Provenance Section -->
|
||||
<div class="provenance-section">
|
||||
<h4 class="section-title">Provenance</h4>
|
||||
<dl class="provenance-grid">
|
||||
<div class="prov-item">
|
||||
<dt>Source</dt>
|
||||
<dd>
|
||||
<span class="source-type">{{ getSourceTypeIcon(doc.provenance.sourceType) }}</span>
|
||||
{{ doc.provenance.sourceId }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="prov-item">
|
||||
<dt>Digest</dt>
|
||||
<dd><code>{{ doc.provenance.digest }}</code></dd>
|
||||
</div>
|
||||
<div class="prov-item">
|
||||
<dt>Ingested</dt>
|
||||
<dd>{{ formatDate(doc.provenance.ingestedAt) }}</dd>
|
||||
</div>
|
||||
@if (doc.provenance.submitter) {
|
||||
<div class="prov-item">
|
||||
<dt>Submitter</dt>
|
||||
<dd>{{ doc.provenance.submitter }}</dd>
|
||||
</div>
|
||||
}
|
||||
@if (doc.provenance.sourceUrl) {
|
||||
<div class="prov-item">
|
||||
<dt>Source URL</dt>
|
||||
<dd class="url">{{ doc.provenance.sourceUrl }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Violations Section -->
|
||||
<div class="violations-section">
|
||||
<h4 class="section-title">Violations</h4>
|
||||
<ul class="doc-violations-list">
|
||||
@for (v of doc.violations; track v.violationCode + v.field) {
|
||||
<li class="doc-violation-item">
|
||||
<div class="violation-header">
|
||||
<code class="violation-code">{{ v.violationCode }}</code>
|
||||
@if (v.field) {
|
||||
<span class="at-field">at</span>
|
||||
<code class="field-path highlighted">{{ v.field }}</code>
|
||||
}
|
||||
</div>
|
||||
@if (v.expected || v.actual) {
|
||||
<div class="value-diff">
|
||||
<div class="expected-row">
|
||||
<span class="label">Expected:</span>
|
||||
<code class="value">{{ v.expected || 'N/A' }}</code>
|
||||
</div>
|
||||
<div class="actual-row">
|
||||
<span class="label">Actual:</span>
|
||||
<code class="value error">{{ v.actual || 'N/A' }}</code>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Raw Content Preview -->
|
||||
@if (doc.rawContent) {
|
||||
<div class="raw-content-section">
|
||||
<h4 class="section-title">
|
||||
Document Fields
|
||||
<button class="btn-link" (click)="onViewRaw(doc.documentId)">View Full</button>
|
||||
</h4>
|
||||
<div class="field-preview">
|
||||
@for (field of doc.highlightedFields; track field) {
|
||||
<div class="field-row" [class.error]="isFieldHighlighted(doc, field)">
|
||||
<span class="field-name">{{ field }}</span>
|
||||
<code class="field-value">{{ getFieldValue(doc.rawContent, field) }}</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (filteredDocuments().length === 0) {
|
||||
<div class="empty-state">
|
||||
@if (searchFilter()) {
|
||||
<p>No documents match "{{ searchFilter() }}"</p>
|
||||
} @else {
|
||||
<p>No documents to display</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,585 @@
|
||||
.violation-drilldown {
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card, white);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drilldown-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summary-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.severity-breakdown {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.severity-chip {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&.critical {
|
||||
background: var(--color-critical-bg, #fef2f2);
|
||||
color: var(--color-critical, #dc2626);
|
||||
}
|
||||
|
||||
&.high {
|
||||
background: var(--color-error-bg, #fff7ed);
|
||||
color: var(--color-error, #ea580c);
|
||||
}
|
||||
|
||||
&.medium {
|
||||
background: var(--color-warning-bg, #fffbeb);
|
||||
color: var(--color-warning, #d97706);
|
||||
}
|
||||
|
||||
&.low {
|
||||
background: var(--color-info-bg, #f0f9ff);
|
||||
color: var(--color-info, #0284c7);
|
||||
}
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--color-bg-card, white);
|
||||
border: none;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f3f4f6);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--color-primary, #2563eb);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
min-width: 200px;
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--color-primary, #2563eb);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
// Violation List (By Violation View)
|
||||
.violation-list {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.violation-group {
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.severity-critical {
|
||||
.group-header { border-left: 3px solid var(--color-critical, #dc2626); }
|
||||
.severity-icon { color: var(--color-critical, #dc2626); }
|
||||
}
|
||||
|
||||
&.severity-high {
|
||||
.group-header { border-left: 3px solid var(--color-error, #ea580c); }
|
||||
.severity-icon { color: var(--color-error, #ea580c); }
|
||||
}
|
||||
|
||||
&.severity-medium {
|
||||
.group-header { border-left: 3px solid var(--color-warning, #d97706); }
|
||||
.severity-icon { color: var(--color-warning, #d97706); }
|
||||
}
|
||||
|
||||
&.severity-low {
|
||||
.group-header { border-left: 3px solid var(--color-info, #0284c7); }
|
||||
.severity-icon { color: var(--color-info, #0284c7); }
|
||||
}
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f9fafb);
|
||||
}
|
||||
}
|
||||
|
||||
.severity-icon {
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
width: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.group-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.violation-code {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.violation-desc {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.affected-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.group-details {
|
||||
padding: 0 1rem 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
}
|
||||
|
||||
.remediation-hint {
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--color-info-bg, #f0f9ff);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.violations-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.5rem;
|
||||
vertical-align: top;
|
||||
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary, #2563eb);
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.field-path {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
|
||||
&.highlighted {
|
||||
background: var(--color-warning-bg, #fef3c7);
|
||||
color: var(--color-warning-dark, #92400e);
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
background: var(--color-bg-code, #f3f4f6);
|
||||
max-width: 150px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.expected {
|
||||
background: var(--color-success-bg, #ecfdf5);
|
||||
color: var(--color-success, #059669);
|
||||
}
|
||||
|
||||
&.actual.error {
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.no-field,
|
||||
.no-value,
|
||||
.no-provenance {
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.provenance-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.source-type {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.source-id,
|
||||
.digest {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f3f4f6);
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
}
|
||||
|
||||
// Document List (By Document View)
|
||||
.document-list {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.document-card {
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.doc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f9fafb);
|
||||
}
|
||||
}
|
||||
|
||||
.doc-type-badge {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.doc-id {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #111827);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.violation-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-error, #dc2626);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.doc-details {
|
||||
padding: 0 1rem 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
margin: 0 0 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary, #2563eb);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
font-weight: normal;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.provenance-section,
|
||||
.violations-section,
|
||||
.raw-content-section {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.provenance-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.prov-item {
|
||||
dt {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #374151);
|
||||
|
||||
code {
|
||||
font-size: 0.75rem;
|
||||
background: var(--color-bg-code, #f3f4f6);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&.url {
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.doc-violations-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.doc-violation-item {
|
||||
padding: 0.5rem;
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.violation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.at-field {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.value-diff {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.expected-row,
|
||||
.actual-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
.label {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
min-width: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
.actual-row {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.field-preview {
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
|
||||
font-size: 0.8125rem;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.error {
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
|
||||
.field-name {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.field-name {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text, #374151);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
font-style: italic;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
AocViolationDetail,
|
||||
AocViolationGroup,
|
||||
AocDocumentView,
|
||||
AocProvenance,
|
||||
} from '../../core/api/aoc.models';
|
||||
|
||||
type ViewMode = 'by-violation' | 'by-document';
|
||||
|
||||
@Component({
|
||||
selector: 'app-violation-drilldown',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './violation-drilldown.component.html',
|
||||
styleUrls: ['./violation-drilldown.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ViolationDrilldownComponent {
|
||||
/** Violation groups to display */
|
||||
readonly violationGroups = input.required<AocViolationGroup[]>();
|
||||
|
||||
/** Document views for by-document mode */
|
||||
readonly documentViews = input<AocDocumentView[]>([]);
|
||||
|
||||
/** Emits when user clicks on a document */
|
||||
readonly selectDocument = output<string>();
|
||||
|
||||
/** Emits when user wants to view raw document */
|
||||
readonly viewRawDocument = output<string>();
|
||||
|
||||
/** Current view mode */
|
||||
readonly viewMode = signal<ViewMode>('by-violation');
|
||||
|
||||
/** Currently expanded violation code */
|
||||
readonly expandedCode = signal<string | null>(null);
|
||||
|
||||
/** Currently expanded document ID */
|
||||
readonly expandedDocId = signal<string | null>(null);
|
||||
|
||||
/** Search filter */
|
||||
readonly searchFilter = signal('');
|
||||
|
||||
readonly filteredGroups = computed(() => {
|
||||
const filter = this.searchFilter().toLowerCase();
|
||||
if (!filter) return this.violationGroups();
|
||||
return this.violationGroups().filter(
|
||||
(g) =>
|
||||
g.code.toLowerCase().includes(filter) ||
|
||||
g.description.toLowerCase().includes(filter) ||
|
||||
g.violations.some(
|
||||
(v) =>
|
||||
v.documentId.toLowerCase().includes(filter) ||
|
||||
v.field?.toLowerCase().includes(filter)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
readonly filteredDocuments = computed(() => {
|
||||
const filter = this.searchFilter().toLowerCase();
|
||||
if (!filter) return this.documentViews();
|
||||
return this.documentViews().filter(
|
||||
(d) =>
|
||||
d.documentId.toLowerCase().includes(filter) ||
|
||||
d.documentType.toLowerCase().includes(filter) ||
|
||||
d.violations.some(
|
||||
(v) =>
|
||||
v.violationCode.toLowerCase().includes(filter) ||
|
||||
v.field?.toLowerCase().includes(filter)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
readonly totalViolations = computed(() =>
|
||||
this.violationGroups().reduce((sum, g) => sum + g.violations.length, 0)
|
||||
);
|
||||
|
||||
readonly totalDocuments = computed(() => {
|
||||
const docIds = new Set<string>();
|
||||
for (const group of this.violationGroups()) {
|
||||
for (const v of group.violations) {
|
||||
docIds.add(v.documentId);
|
||||
}
|
||||
}
|
||||
return docIds.size;
|
||||
});
|
||||
|
||||
readonly severityCounts = computed(() => {
|
||||
const counts = { critical: 0, high: 0, medium: 0, low: 0 };
|
||||
for (const group of this.violationGroups()) {
|
||||
counts[group.severity] += group.violations.length;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
setViewMode(mode: ViewMode): void {
|
||||
this.viewMode.set(mode);
|
||||
}
|
||||
|
||||
toggleGroup(code: string): void {
|
||||
this.expandedCode.update((current) => (current === code ? null : code));
|
||||
}
|
||||
|
||||
toggleDocument(docId: string): void {
|
||||
this.expandedDocId.update((current) => (current === docId ? null : docId));
|
||||
}
|
||||
|
||||
onSearch(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
this.searchFilter.set(input.value);
|
||||
}
|
||||
|
||||
onSelectDocument(docId: string): void {
|
||||
this.selectDocument.emit(docId);
|
||||
}
|
||||
|
||||
onViewRaw(docId: string): void {
|
||||
this.viewRawDocument.emit(docId);
|
||||
}
|
||||
|
||||
getSeverityIcon(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return '!!';
|
||||
case 'high':
|
||||
return '!';
|
||||
case 'medium':
|
||||
return '~';
|
||||
default:
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
getSourceTypeIcon(sourceType?: string): string {
|
||||
switch (sourceType) {
|
||||
case 'registry':
|
||||
return '[R]';
|
||||
case 'git':
|
||||
return '[G]';
|
||||
case 'upload':
|
||||
return '[U]';
|
||||
case 'api':
|
||||
return '[A]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
}
|
||||
|
||||
formatDigest(digest: string, length = 12): string {
|
||||
if (digest.length <= length) return digest;
|
||||
return digest.slice(0, length) + '...';
|
||||
}
|
||||
|
||||
formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
isFieldHighlighted(doc: AocDocumentView, field: string): boolean {
|
||||
return doc.highlightedFields.includes(field);
|
||||
}
|
||||
|
||||
getFieldValue(content: Record<string, unknown> | undefined, path: string): string {
|
||||
if (!content) return 'N/A';
|
||||
const parts = path.split('.');
|
||||
let current: unknown = content;
|
||||
for (const part of parts) {
|
||||
if (current == null || typeof current !== 'object') return 'N/A';
|
||||
current = (current as Record<string, unknown>)[part];
|
||||
}
|
||||
if (current == null) return 'null';
|
||||
if (typeof current === 'object') return JSON.stringify(current);
|
||||
return String(current);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
<div class="sources-dashboard">
|
||||
<header class="dashboard-header">
|
||||
<h1>Sources Dashboard</h1>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
[disabled]="verifying()"
|
||||
(click)="onVerifyLast24h()"
|
||||
>
|
||||
{{ verifying() ? 'Verifying...' : 'Verify last 24h' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary" (click)="loadMetrics()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@if (loading()) {
|
||||
<div class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading AOC metrics...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (error()) {
|
||||
<div class="error-state">
|
||||
<p class="error-message">{{ error() }}</p>
|
||||
<button class="btn btn-secondary" (click)="loadMetrics()">Retry</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (metrics(); as m) {
|
||||
<div class="metrics-grid">
|
||||
<!-- Pass/Fail Tile -->
|
||||
<div class="tile tile-pass-fail" [class]="passRateClass()">
|
||||
<h2 class="tile-title">AOC Pass/Fail</h2>
|
||||
<div class="tile-content">
|
||||
<div class="metric-large">
|
||||
<span class="value">{{ passRate() }}%</span>
|
||||
<span class="label">Pass Rate</span>
|
||||
</div>
|
||||
<div class="metric-details">
|
||||
<div class="detail">
|
||||
<span class="count pass">{{ m.passCount | number }}</span>
|
||||
<span class="label">Passed</span>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span class="count fail">{{ m.failCount | number }}</span>
|
||||
<span class="label">Failed</span>
|
||||
</div>
|
||||
<div class="detail">
|
||||
<span class="count total">{{ m.totalCount | number }}</span>
|
||||
<span class="label">Total</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Violations Tile -->
|
||||
<div class="tile tile-violations">
|
||||
<h2 class="tile-title">Recent Violations</h2>
|
||||
<div class="tile-content">
|
||||
@if (m.recentViolations.length === 0) {
|
||||
<p class="empty-state">No violations in time window</p>
|
||||
} @else {
|
||||
<ul class="violations-list">
|
||||
@for (v of m.recentViolations; track v.code) {
|
||||
<li class="violation-item" [class]="getSeverityClass(v.severity)">
|
||||
<div class="violation-header">
|
||||
<code class="violation-code">{{ v.code }}</code>
|
||||
<span class="violation-count">{{ v.count }}x</span>
|
||||
</div>
|
||||
<p class="violation-desc">{{ v.description }}</p>
|
||||
<span class="violation-time">{{ formatRelativeTime(v.lastSeen) }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ingest Throughput Tile -->
|
||||
<div class="tile tile-throughput" [class]="throughputStatus()">
|
||||
<h2 class="tile-title">Ingest Throughput</h2>
|
||||
<div class="tile-content">
|
||||
<div class="throughput-grid">
|
||||
<div class="throughput-item">
|
||||
<span class="value">{{ m.ingestThroughput.docsPerMinute | number:'1.1-1' }}</span>
|
||||
<span class="label">docs/min</span>
|
||||
</div>
|
||||
<div class="throughput-item">
|
||||
<span class="value">{{ m.ingestThroughput.avgLatencyMs }}</span>
|
||||
<span class="label">avg ms</span>
|
||||
</div>
|
||||
<div class="throughput-item">
|
||||
<span class="value">{{ m.ingestThroughput.p95LatencyMs }}</span>
|
||||
<span class="label">p95 ms</span>
|
||||
</div>
|
||||
<div class="throughput-item">
|
||||
<span class="value">{{ m.ingestThroughput.queueDepth }}</span>
|
||||
<span class="label">queue</span>
|
||||
</div>
|
||||
<div class="throughput-item">
|
||||
<span class="value">{{ m.ingestThroughput.errorRate | number:'1.2-2' }}%</span>
|
||||
<span class="label">errors</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verification Result -->
|
||||
@if (verificationResult(); as result) {
|
||||
<div class="verification-result" [class]="'status-' + result.status">
|
||||
<h3>Verification Complete</h3>
|
||||
<div class="result-summary">
|
||||
<span class="status-badge">{{ result.status | titlecase }}</span>
|
||||
<span>Checked: {{ result.checkedCount | number }}</span>
|
||||
<span>Passed: {{ result.passedCount | number }}</span>
|
||||
<span>Failed: {{ result.failedCount | number }}</span>
|
||||
</div>
|
||||
@if (result.violations.length > 0) {
|
||||
<details class="violations-details">
|
||||
<summary>View {{ result.violations.length }} violation(s)</summary>
|
||||
<ul class="violation-list">
|
||||
@for (v of result.violations; track v.documentId) {
|
||||
<li>
|
||||
<strong>{{ v.violationCode }}</strong> in {{ v.documentId }}
|
||||
@if (v.field) {
|
||||
<br>Field: {{ v.field }} (expected: {{ v.expected }}, actual: {{ v.actual }})
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
}
|
||||
<p class="cli-hint">
|
||||
CLI equivalent: <code>stella aoc verify --since=24h --tenant=default</code>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="time-window">
|
||||
Data from {{ m.timeWindow.start | date:'short' }} to {{ m.timeWindow.end | date:'short' }}
|
||||
({{ m.timeWindow.durationMinutes / 60 | number:'1.0-0' }}h window)
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,325 @@
|
||||
.sources-dashboard {
|
||||
padding: 1.5rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: background-color 0.2s, border-color 0.2s;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&-primary {
|
||||
background-color: var(--color-primary, #2563eb);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-primary-hover, #1d4ed8);
|
||||
}
|
||||
}
|
||||
|
||||
&-secondary {
|
||||
background-color: transparent;
|
||||
border-color: var(--color-border, #d1d5db);
|
||||
color: var(--color-text, #374151);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--color-bg-hover, #f3f4f6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--color-border, #e5e7eb);
|
||||
border-top-color: var(--color-primary, #2563eb);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--color-error, #dc2626);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.tile {
|
||||
background: var(--color-bg-card, white);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
overflow: hidden;
|
||||
|
||||
&-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
&-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tile-pass-fail {
|
||||
&.excellent .metric-large .value { color: var(--color-success, #059669); }
|
||||
&.good .metric-large .value { color: var(--color-success-muted, #10b981); }
|
||||
&.warning .metric-large .value { color: var(--color-warning, #d97706); }
|
||||
&.critical .metric-large .value { color: var(--color-error, #dc2626); }
|
||||
}
|
||||
|
||||
.metric-large {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-details {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.count {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
|
||||
&.pass { color: var(--color-success, #059669); }
|
||||
&.fail { color: var(--color-error, #dc2626); }
|
||||
&.total { color: var(--color-text, #374151); }
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
}
|
||||
|
||||
.violations-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.violation-item {
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
|
||||
&.severity-critical { border-left: 3px solid var(--color-error, #dc2626); }
|
||||
&.severity-high { border-left: 3px solid var(--color-warning, #d97706); }
|
||||
&.severity-medium { border-left: 3px solid var(--color-info, #2563eb); }
|
||||
&.severity-low { border-left: 3px solid var(--color-text-muted, #9ca3af); }
|
||||
}
|
||||
|
||||
.violation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.violation-code {
|
||||
font-family: monospace;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.violation-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.violation-desc {
|
||||
font-size: 0.8125rem;
|
||||
margin: 0 0 0.25rem;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.violation-time {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.throughput-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.throughput-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.tile-throughput {
|
||||
&.critical .throughput-item .value { color: var(--color-error, #dc2626); }
|
||||
&.warning .throughput-item .value { color: var(--color-warning, #d97706); }
|
||||
}
|
||||
|
||||
.verification-result {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
|
||||
&.status-passed { background: var(--color-success-bg, #ecfdf5); border-color: var(--color-success, #059669); }
|
||||
&.status-failed { background: var(--color-error-bg, #fef2f2); border-color: var(--color-error, #dc2626); }
|
||||
&.status-partial { background: var(--color-warning-bg, #fffbeb); border-color: var(--color-warning, #d97706); }
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.result-summary {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&.status-passed .status-badge { background: var(--color-success, #059669); color: white; }
|
||||
&.status-failed .status-badge { background: var(--color-error, #dc2626); color: white; }
|
||||
&.status-partial .status-badge { background: var(--color-warning, #d97706); color: white; }
|
||||
}
|
||||
|
||||
.violations-details {
|
||||
margin: 0.75rem 0;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--color-primary, #2563eb);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.violation-list {
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cli-hint {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
|
||||
code {
|
||||
background: var(--color-bg-code, #f3f4f6);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.time-window {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<div class="determinism-badge" [class]="'status-' + status().status">
|
||||
<!-- Compact Badge -->
|
||||
<button
|
||||
class="badge-trigger"
|
||||
(click)="toggleExpanded()"
|
||||
[attr.aria-expanded]="isExpanded()"
|
||||
aria-controls="determinism-details"
|
||||
>
|
||||
<span class="badge-icon">{{ statusIcon() }}</span>
|
||||
<span class="badge-label">{{ statusLabel() }}</span>
|
||||
<span class="badge-stats">
|
||||
{{ fragmentStats().matched }}/{{ fragmentStats().total }} fragments
|
||||
</span>
|
||||
<span class="badge-expand-icon" [class.expanded]="isExpanded()">▼</span>
|
||||
</button>
|
||||
|
||||
<!-- Expanded Details -->
|
||||
@if (isExpanded()) {
|
||||
<div id="determinism-details" class="badge-details" role="region" aria-label="Determinism details">
|
||||
<!-- Merkle Root Section -->
|
||||
<div class="detail-section">
|
||||
<h4 class="section-title">Merkle Root</h4>
|
||||
<div class="merkle-info">
|
||||
@if (status().merkleRoot) {
|
||||
<code class="hash" [title]="status().merkleRoot">
|
||||
{{ formatHash(status().merkleRoot!, 24) }}
|
||||
</code>
|
||||
<span class="consistency-badge" [class.consistent]="status().merkleConsistent">
|
||||
{{ status().merkleConsistent ? 'Consistent' : 'Mismatch' }}
|
||||
</span>
|
||||
} @else {
|
||||
<span class="no-data">No Merkle root available</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Composition Metadata -->
|
||||
@if (status().composition; as comp) {
|
||||
<div class="detail-section">
|
||||
<h4 class="section-title">Composition</h4>
|
||||
<dl class="composition-meta">
|
||||
<div class="meta-item">
|
||||
<dt>Schema</dt>
|
||||
<dd>{{ comp.schemaVersion }}</dd>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<dt>Scanner</dt>
|
||||
<dd>{{ comp.scannerVersion }}</dd>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<dt>Built</dt>
|
||||
<dd>{{ comp.buildTimestamp | date:'short' }}</dd>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<dt>Hash</dt>
|
||||
<dd><code [title]="comp.compositionHash">{{ formatHash(comp.compositionHash) }}</code></dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button class="btn-link" (click)="onViewComposition()">
|
||||
View _composition.json →
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Fragment Hashes -->
|
||||
<div class="detail-section">
|
||||
<h4 class="section-title">
|
||||
Fragment Hashes
|
||||
<span class="fragment-count">
|
||||
({{ fragmentStats().percentage | number:'1.0-0' }}% match)
|
||||
</span>
|
||||
</h4>
|
||||
<div class="fragments-list">
|
||||
@for (fragment of status().fragments; track fragment.id) {
|
||||
<div class="fragment-item" [class.mismatch]="!fragment.matches">
|
||||
<span class="fragment-icon">{{ getFragmentIcon(fragment) }}</span>
|
||||
<div class="fragment-info">
|
||||
<span class="fragment-id" [title]="fragment.id">
|
||||
{{ fragment.type }}: {{ formatHash(fragment.id, 16) }}
|
||||
</span>
|
||||
<span class="fragment-size">{{ formatBytes(fragment.size) }}</span>
|
||||
</div>
|
||||
<div class="fragment-hashes">
|
||||
<code class="hash expected" [title]="fragment.expectedHash">
|
||||
E: {{ formatHash(fragment.expectedHash) }}
|
||||
</code>
|
||||
<code class="hash computed" [title]="fragment.computedHash">
|
||||
C: {{ formatHash(fragment.computedHash) }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Issues -->
|
||||
@if (status().issues.length > 0) {
|
||||
<div class="detail-section issues-section">
|
||||
<h4 class="section-title">
|
||||
Issues
|
||||
@if (issuesByLevel().errors.length > 0) {
|
||||
<span class="issue-count error">{{ issuesByLevel().errors.length }} errors</span>
|
||||
}
|
||||
@if (issuesByLevel().warnings.length > 0) {
|
||||
<span class="issue-count warning">{{ issuesByLevel().warnings.length }} warnings</span>
|
||||
}
|
||||
</h4>
|
||||
<ul class="issues-list">
|
||||
@for (issue of status().issues; track issue.code) {
|
||||
<li class="issue-item" [class]="'severity-' + issue.severity">
|
||||
<span class="issue-icon">{{ getIssueIcon(issue) }}</span>
|
||||
<div class="issue-content">
|
||||
<code class="issue-code">{{ issue.code }}</code>
|
||||
<span class="issue-message">{{ issue.message }}</span>
|
||||
@if (issue.fragmentId) {
|
||||
<span class="issue-fragment">Fragment: {{ formatHash(issue.fragmentId) }}</span>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="verified-at">
|
||||
Verified {{ status().verifiedAt | date:'medium' }}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,322 @@
|
||||
.determinism-badge {
|
||||
font-size: 0.875rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-bg-card, white);
|
||||
overflow: hidden;
|
||||
|
||||
&.status-verified {
|
||||
.badge-trigger { border-left: 3px solid var(--color-success, #059669); }
|
||||
.badge-icon { color: var(--color-success, #059669); }
|
||||
}
|
||||
|
||||
&.status-warning {
|
||||
.badge-trigger { border-left: 3px solid var(--color-warning, #d97706); }
|
||||
.badge-icon { color: var(--color-warning, #d97706); }
|
||||
}
|
||||
|
||||
&.status-failed {
|
||||
.badge-trigger { border-left: 3px solid var(--color-error, #dc2626); }
|
||||
.badge-icon { color: var(--color-error, #dc2626); }
|
||||
}
|
||||
|
||||
&.status-unknown {
|
||||
.badge-trigger { border-left: 3px solid var(--color-text-muted, #9ca3af); }
|
||||
.badge-icon { color: var(--color-text-muted, #9ca3af); }
|
||||
}
|
||||
}
|
||||
|
||||
.badge-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f9fafb);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary, #2563eb);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-icon {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.badge-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.badge-stats {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
font-size: 0.75rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.badge-expand-icon {
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
font-size: 0.625rem;
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.badge-details {
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
padding: 0.75rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
margin: 0 0 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fragment-count {
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.merkle-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hash {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
background: var(--color-bg-code, #f3f4f6);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.consistency-badge {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
color: var(--color-error, #dc2626);
|
||||
|
||||
&.consistent {
|
||||
background: var(--color-success-bg, #ecfdf5);
|
||||
color: var(--color-success, #059669);
|
||||
}
|
||||
}
|
||||
|
||||
.no-data {
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.composition-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
dt {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary, #2563eb);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.fragments-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.fragment-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
|
||||
&.mismatch {
|
||||
border-color: var(--color-error, #dc2626);
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
}
|
||||
}
|
||||
|
||||
.fragment-icon {
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
color: var(--color-success, #059669);
|
||||
|
||||
.mismatch & {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.fragment-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fragment-id {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #374151);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fragment-size {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.fragment-hashes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.hash.expected {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.hash.computed {
|
||||
.mismatch & {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.issues-section {
|
||||
.section-title {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.issue-count {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
font-weight: normal;
|
||||
text-transform: none;
|
||||
|
||||
&.error {
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: var(--color-warning-bg, #fffbeb);
|
||||
color: var(--color-warning, #d97706);
|
||||
}
|
||||
}
|
||||
|
||||
.issues-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.issue-item {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.severity-error .issue-icon { color: var(--color-error, #dc2626); }
|
||||
&.severity-warning .issue-icon { color: var(--color-warning, #d97706); }
|
||||
&.severity-info .issue-icon { color: var(--color-info, #2563eb); }
|
||||
}
|
||||
|
||||
.issue-icon {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.issue-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.issue-code {
|
||||
font-family: monospace;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.issue-message {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.issue-fragment {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.verified-at {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
DeterminismStatus,
|
||||
DeterminismFragment,
|
||||
DeterminismIssue,
|
||||
} from '../../core/api/determinism.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-determinism-badge',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './determinism-badge.component.html',
|
||||
styleUrls: ['./determinism-badge.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DeterminismBadgeComponent {
|
||||
/** Determinism status data */
|
||||
readonly status = input.required<DeterminismStatus>();
|
||||
|
||||
/** Whether to show expanded details by default */
|
||||
readonly expanded = input(false);
|
||||
|
||||
/** Emits when user clicks to view full composition */
|
||||
readonly viewComposition = output<void>();
|
||||
|
||||
/** Local expanded state */
|
||||
readonly isExpanded = signal(false);
|
||||
|
||||
readonly statusIcon = computed(() => {
|
||||
switch (this.status().status) {
|
||||
case 'verified':
|
||||
return '✓';
|
||||
case 'warning':
|
||||
return '⚠';
|
||||
case 'failed':
|
||||
return '✗';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
});
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
switch (this.status().status) {
|
||||
case 'verified':
|
||||
return 'Deterministic';
|
||||
case 'warning':
|
||||
return 'Partial';
|
||||
case 'failed':
|
||||
return 'Non-deterministic';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
});
|
||||
|
||||
readonly fragmentStats = computed(() => {
|
||||
const fragments = this.status().fragments;
|
||||
const matched = fragments.filter((f) => f.matches).length;
|
||||
const total = fragments.length;
|
||||
return { matched, total, percentage: total > 0 ? (matched / total) * 100 : 0 };
|
||||
});
|
||||
|
||||
readonly issuesByLevel = computed(() => {
|
||||
const issues = this.status().issues;
|
||||
return {
|
||||
errors: issues.filter((i) => i.severity === 'error'),
|
||||
warnings: issues.filter((i) => i.severity === 'warning'),
|
||||
info: issues.filter((i) => i.severity === 'info'),
|
||||
};
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Initialize expanded state from input
|
||||
this.isExpanded.set(this.expanded());
|
||||
}
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.isExpanded.update((v) => !v);
|
||||
}
|
||||
|
||||
onViewComposition(): void {
|
||||
this.viewComposition.emit();
|
||||
}
|
||||
|
||||
formatHash(hash: string, length = 12): string {
|
||||
if (!hash) return 'N/A';
|
||||
if (hash.length <= length) return hash;
|
||||
return hash.substring(0, length) + '...';
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
getFragmentIcon(fragment: DeterminismFragment): string {
|
||||
return fragment.matches ? '✓' : '✗';
|
||||
}
|
||||
|
||||
getIssueIcon(issue: DeterminismIssue): string {
|
||||
switch (issue.severity) {
|
||||
case 'error':
|
||||
return '✗';
|
||||
case 'warning':
|
||||
return '⚠';
|
||||
default:
|
||||
return 'ℹ';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
<div class="entropy-panel" [class]="riskClass()">
|
||||
<!-- Header with Overall Score -->
|
||||
<header class="panel-header">
|
||||
<div class="score-section">
|
||||
<div class="score-ring" [attr.data-score]="analysis().overallScore">
|
||||
<svg viewBox="0 0 100 100" class="score-svg">
|
||||
<circle class="score-bg" cx="50" cy="50" r="45" />
|
||||
<circle
|
||||
class="score-fill"
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
[style.strokeDasharray]="(analysis().overallScore / 10 * 283) + ' 283'"
|
||||
/>
|
||||
</svg>
|
||||
<span class="score-value">{{ analysis().overallScore | number:'1.1-1' }}</span>
|
||||
</div>
|
||||
<div class="score-info">
|
||||
<h3 class="risk-label">{{ analysis().riskLevel | titlecase }} Risk</h3>
|
||||
<p class="score-desc">{{ scoreDescription() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-report" (click)="onViewReport()">
|
||||
View entropy.report.json →
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="panel-content">
|
||||
<!-- Layer Donut Chart -->
|
||||
<section class="section layer-section">
|
||||
<h4 class="section-title">Layer Entropy Distribution</h4>
|
||||
<div class="layer-visualization">
|
||||
<div class="donut-chart">
|
||||
<svg viewBox="-50 -50 100 100" class="donut-svg">
|
||||
@for (layer of layerDonutData(); track layer.digest) {
|
||||
<path
|
||||
class="donut-segment"
|
||||
[attr.d]="'M 0 -40 A 40 40 0 ' + (layer.percentage > 50 ? 1 : 0) + ' 1 ' + (Math.sin(layer.percentage * 3.6 * Math.PI / 180) * 40) + ' ' + (-Math.cos(layer.percentage * 3.6 * Math.PI / 180) * 40)"
|
||||
[style.stroke]="layer.color"
|
||||
[style.transform]="'rotate(' + layer.startAngle + 'deg)'"
|
||||
(click)="onSelectLayer(layer.digest)"
|
||||
/>
|
||||
}
|
||||
</svg>
|
||||
<span class="donut-center">
|
||||
{{ analysis().layers.length }} layers
|
||||
</span>
|
||||
</div>
|
||||
<div class="layer-legend">
|
||||
@for (layer of analysis().layers; track layer.digest) {
|
||||
<button
|
||||
class="legend-item"
|
||||
(click)="onSelectLayer(layer.digest)"
|
||||
>
|
||||
<span class="legend-color" [style.background]="layerDonutData()[analysis().layers.indexOf(layer)]?.color"></span>
|
||||
<span class="legend-label" [title]="layer.command">
|
||||
{{ formatPath(layer.command, 20) }}
|
||||
</span>
|
||||
<span class="legend-value">{{ layer.opaqueByteRatio | number:'1.0-0' }}% opaque</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- High Entropy Files Heatmap -->
|
||||
<section class="section files-section">
|
||||
<h4 class="section-title">
|
||||
High Entropy Files
|
||||
<span class="count-badge">{{ analysis().highEntropyFiles.length }}</span>
|
||||
</h4>
|
||||
@if (topHighEntropyFiles().length === 0) {
|
||||
<p class="empty-state">No high entropy files detected</p>
|
||||
} @else {
|
||||
<div class="files-heatmap">
|
||||
@for (file of topHighEntropyFiles(); track file.path) {
|
||||
<button
|
||||
class="file-item"
|
||||
[class]="getEntropyClass(file.entropy)"
|
||||
(click)="onSelectFile(file.path)"
|
||||
>
|
||||
<span class="file-icon">{{ getClassificationIcon(file.classification) }}</span>
|
||||
<div class="file-info">
|
||||
<span class="file-path" [title]="file.path">{{ formatPath(file.path) }}</span>
|
||||
<div class="file-meta">
|
||||
<span class="file-size">{{ formatBytes(file.size) }}</span>
|
||||
<span class="file-class">{{ file.classification }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entropy-bar-container">
|
||||
<div class="entropy-bar" [style.width]="getEntropyBarWidth(file.entropy)"></div>
|
||||
<span class="entropy-value">{{ file.entropy | number:'1.2-2' }} bits</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (analysis().highEntropyFiles.length > 10) {
|
||||
<p class="more-files">
|
||||
+ {{ analysis().highEntropyFiles.length - 10 }} more files
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
|
||||
<!-- Why Risky? Detector Hints -->
|
||||
<section class="section hints-section">
|
||||
<h4 class="section-title">Why Risky?</h4>
|
||||
@if (analysis().detectorHints.length === 0) {
|
||||
<p class="empty-state">No specific risks detected</p>
|
||||
} @else {
|
||||
<div class="hint-chips">
|
||||
@for (group of detectorHintsByType(); track group.type) {
|
||||
<button class="hint-chip" [class]="'severity-' + group.maxSeverity">
|
||||
<span class="chip-icon">{{ getHintTypeIcon(group.type) }}</span>
|
||||
<span class="chip-label">{{ group.type | titlecase }}</span>
|
||||
<span class="chip-count">{{ group.count }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<ul class="hints-list">
|
||||
@for (hint of analysis().detectorHints.slice(0, 5); track hint.id) {
|
||||
<li class="hint-item" [class]="'severity-' + hint.severity">
|
||||
<div class="hint-header">
|
||||
<span class="hint-icon">{{ getHintTypeIcon(hint.type) }}</span>
|
||||
<span class="hint-type">{{ hint.type | titlecase }}</span>
|
||||
<span class="hint-confidence">{{ hint.confidence }}% confidence</span>
|
||||
</div>
|
||||
<p class="hint-desc">{{ hint.description }}</p>
|
||||
<p class="hint-remediation">
|
||||
<strong>Fix:</strong> {{ hint.remediation }}
|
||||
</p>
|
||||
@if (hint.affectedPaths.length > 0) {
|
||||
<details class="affected-paths">
|
||||
<summary>{{ hint.affectedPaths.length }} affected file(s)</summary>
|
||||
<ul>
|
||||
@for (path of hint.affectedPaths.slice(0, 3); track path) {
|
||||
<li><code>{{ formatPath(path, 50) }}</code></li>
|
||||
}
|
||||
@if (hint.affectedPaths.length > 3) {
|
||||
<li class="more">+ {{ hint.affectedPaths.length - 3 }} more</li>
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (analysis().detectorHints.length > 5) {
|
||||
<p class="more-hints">
|
||||
+ {{ analysis().detectorHints.length - 5 }} more hints in report
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="panel-footer">
|
||||
<span class="analyzed-at">Analyzed {{ analysis().analyzedAt | date:'medium' }}</span>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -0,0 +1,431 @@
|
||||
.entropy-panel {
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card, white);
|
||||
overflow: hidden;
|
||||
|
||||
&.risk-low { --risk-color: var(--color-success, #059669); }
|
||||
&.risk-medium { --risk-color: var(--color-warning, #d97706); }
|
||||
&.risk-high { --risk-color: var(--color-error, #ea580c); }
|
||||
&.risk-critical { --risk-color: var(--color-critical, #dc2626); }
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border-bottom: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.score-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.score-ring {
|
||||
position: relative;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.score-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.score-bg {
|
||||
fill: none;
|
||||
stroke: var(--color-border, #e5e7eb);
|
||||
stroke-width: 8;
|
||||
}
|
||||
|
||||
.score-fill {
|
||||
fill: none;
|
||||
stroke: var(--risk-color);
|
||||
stroke-width: 8;
|
||||
stroke-linecap: round;
|
||||
transition: stroke-dasharray 0.5s ease;
|
||||
}
|
||||
|
||||
.score-value {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--risk-color);
|
||||
}
|
||||
|
||||
.score-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.risk-label {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--risk-color);
|
||||
}
|
||||
|
||||
.score-desc {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.btn-report {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary, #2563eb);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
margin: 0 0 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 10px;
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
color: var(--color-text, #374151);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.layer-visualization {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.donut-chart {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.donut-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.donut-segment {
|
||||
fill: none;
|
||||
stroke-width: 16;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.donut-center {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.layer-legend {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f9fafb);
|
||||
}
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text, #374151);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.legend-value {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.files-heatmap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
&.entropy-low { border-left: 3px solid var(--color-success, #059669); }
|
||||
&.entropy-medium { border-left: 3px solid var(--color-warning, #d97706); }
|
||||
&.entropy-high { border-left: 3px solid var(--color-error, #ea580c); }
|
||||
&.entropy-critical { border-left: 3px solid var(--color-critical, #dc2626); }
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
font-family: monospace;
|
||||
color: var(--color-text, #374151);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.entropy-bar-container {
|
||||
width: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.entropy-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(to right, var(--color-success, #059669), var(--color-warning, #d97706), var(--color-critical, #dc2626));
|
||||
}
|
||||
|
||||
.entropy-value {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.more-files,
|
||||
.more-hints {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
text-align: center;
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.hint-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.hint-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 16px;
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #e5e7eb);
|
||||
}
|
||||
|
||||
&.severity-critical { border-color: var(--color-critical, #dc2626); background: #fef2f2; }
|
||||
&.severity-high { border-color: var(--color-error, #ea580c); background: #fff7ed; }
|
||||
&.severity-medium { border-color: var(--color-warning, #d97706); background: #fffbeb; }
|
||||
}
|
||||
|
||||
.chip-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.chip-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.chip-count {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.hints-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hint-item {
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
|
||||
&.severity-critical { border-left: 3px solid var(--color-critical, #dc2626); }
|
||||
&.severity-high { border-left: 3px solid var(--color-error, #ea580c); }
|
||||
&.severity-medium { border-left: 3px solid var(--color-warning, #d97706); }
|
||||
&.severity-low { border-left: 3px solid var(--color-text-muted, #9ca3af); }
|
||||
}
|
||||
|
||||
.hint-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.hint-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.hint-type {
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.hint-confidence {
|
||||
margin-left: auto;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.hint-desc {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.hint-remediation {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.affected-paths {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-primary, #2563eb);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0.25rem 0 0;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: monospace;
|
||||
background: var(--color-bg-code, #f3f4f6);
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.more {
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
font-style: italic;
|
||||
list-style: none;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-footer {
|
||||
padding: 0.5rem 1rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
}
|
||||
|
||||
.analyzed-at {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
EntropyAnalysis,
|
||||
LayerEntropy,
|
||||
HighEntropyFile,
|
||||
DetectorHint,
|
||||
} from '../../core/api/entropy.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-entropy-panel',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './entropy-panel.component.html',
|
||||
styleUrls: ['./entropy-panel.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EntropyPanelComponent {
|
||||
/** Entropy analysis data */
|
||||
readonly analysis = input.required<EntropyAnalysis>();
|
||||
|
||||
/** Emits when user wants to view raw report */
|
||||
readonly viewReport = output<void>();
|
||||
|
||||
/** Emits when user clicks on a layer */
|
||||
readonly selectLayer = output<string>();
|
||||
|
||||
/** Emits when user clicks on a file */
|
||||
readonly selectFile = output<string>();
|
||||
|
||||
readonly riskClass = computed(() => 'risk-' + this.analysis().riskLevel);
|
||||
|
||||
readonly scoreDescription = computed(() => {
|
||||
const score = this.analysis().overallScore;
|
||||
if (score <= 2) return 'Minimal entropy detected';
|
||||
if (score <= 4) return 'Normal entropy levels';
|
||||
if (score <= 6) return 'Elevated entropy - review recommended';
|
||||
if (score <= 8) return 'High entropy - potential secrets detected';
|
||||
return 'Critical entropy - likely obfuscated content';
|
||||
});
|
||||
|
||||
readonly layerDonutData = computed(() => {
|
||||
const layers = this.analysis().layers;
|
||||
const total = layers.reduce((sum, l) => sum + l.riskContribution, 0);
|
||||
return layers.map((layer, index) => ({
|
||||
...layer,
|
||||
percentage: total > 0 ? (layer.riskContribution / total) * 100 : 0,
|
||||
startAngle: this.calculateStartAngle(layers, index, total),
|
||||
color: this.getLayerColor(layer.riskContribution, total / layers.length),
|
||||
}));
|
||||
});
|
||||
|
||||
readonly topHighEntropyFiles = computed(() => {
|
||||
return [...this.analysis().highEntropyFiles]
|
||||
.sort((a, b) => b.entropy - a.entropy)
|
||||
.slice(0, 10);
|
||||
});
|
||||
|
||||
readonly detectorHintsByType = computed(() => {
|
||||
const hints = this.analysis().detectorHints;
|
||||
const byType = new Map<string, DetectorHint[]>();
|
||||
for (const hint of hints) {
|
||||
const existing = byType.get(hint.type) || [];
|
||||
existing.push(hint);
|
||||
byType.set(hint.type, existing);
|
||||
}
|
||||
return Array.from(byType.entries()).map(([type, items]) => ({
|
||||
type,
|
||||
items,
|
||||
count: items.length,
|
||||
maxSeverity: this.getMaxSeverity(items),
|
||||
}));
|
||||
});
|
||||
|
||||
private calculateStartAngle(
|
||||
layers: LayerEntropy[],
|
||||
index: number,
|
||||
total: number
|
||||
): number {
|
||||
let angle = 0;
|
||||
for (let i = 0; i < index; i++) {
|
||||
angle += total > 0 ? (layers[i].riskContribution / total) * 360 : 0;
|
||||
}
|
||||
return angle;
|
||||
}
|
||||
|
||||
private getLayerColor(contribution: number, avg: number): string {
|
||||
const ratio = contribution / (avg || 1);
|
||||
if (ratio > 2) return 'var(--color-entropy-critical, #dc2626)';
|
||||
if (ratio > 1.5) return 'var(--color-entropy-high, #ea580c)';
|
||||
if (ratio > 1) return 'var(--color-entropy-medium, #d97706)';
|
||||
return 'var(--color-entropy-low, #65a30d)';
|
||||
}
|
||||
|
||||
private getMaxSeverity(hints: DetectorHint[]): string {
|
||||
const severityOrder = ['critical', 'high', 'medium', 'low'];
|
||||
for (const severity of severityOrder) {
|
||||
if (hints.some((h) => h.severity === severity)) {
|
||||
return severity;
|
||||
}
|
||||
}
|
||||
return 'low';
|
||||
}
|
||||
|
||||
getEntropyBarWidth(entropy: number): string {
|
||||
// Entropy is 0-8 bits, normalize to percentage
|
||||
return Math.min(entropy / 8 * 100, 100) + '%';
|
||||
}
|
||||
|
||||
getEntropyClass(entropy: number): string {
|
||||
if (entropy >= 7) return 'entropy-critical';
|
||||
if (entropy >= 6) return 'entropy-high';
|
||||
if (entropy >= 4.5) return 'entropy-medium';
|
||||
return 'entropy-low';
|
||||
}
|
||||
|
||||
getClassificationIcon(classification: HighEntropyFile['classification']): string {
|
||||
switch (classification) {
|
||||
case 'encrypted':
|
||||
return '🔐';
|
||||
case 'compressed':
|
||||
return '📦';
|
||||
case 'binary':
|
||||
return '⚙️';
|
||||
case 'suspicious':
|
||||
return '⚠️';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
}
|
||||
|
||||
getHintTypeIcon(type: DetectorHint['type']): string {
|
||||
switch (type) {
|
||||
case 'credential':
|
||||
return '🔑';
|
||||
case 'key':
|
||||
return '🔏';
|
||||
case 'token':
|
||||
return '🎫';
|
||||
case 'obfuscated':
|
||||
return '🎭';
|
||||
case 'packed':
|
||||
return '📦';
|
||||
case 'crypto':
|
||||
return '🔐';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
}
|
||||
|
||||
formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
formatPath(path: string, maxLength = 40): string {
|
||||
if (path.length <= maxLength) return path;
|
||||
return '...' + path.slice(-maxLength + 3);
|
||||
}
|
||||
|
||||
onViewReport(): void {
|
||||
this.viewReport.emit();
|
||||
}
|
||||
|
||||
onSelectLayer(digest: string): void {
|
||||
this.selectLayer.emit(digest);
|
||||
}
|
||||
|
||||
onSelectFile(path: string): void {
|
||||
this.selectFile.emit(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
<div class="entropy-policy-banner" [class]="bannerClass()">
|
||||
<!-- Main Banner -->
|
||||
<div class="banner-main">
|
||||
<span class="banner-icon">{{ bannerIcon() }}</span>
|
||||
|
||||
<div class="banner-content">
|
||||
<h4 class="banner-title">{{ bannerTitle() }}</h4>
|
||||
<p class="banner-message">{{ bannerMessage() }}</p>
|
||||
</div>
|
||||
|
||||
<div class="banner-actions">
|
||||
@if (config().reportUrl) {
|
||||
<button class="btn-secondary" (click)="onDownloadReport()">
|
||||
Download Report
|
||||
</button>
|
||||
}
|
||||
<button class="btn-secondary" (click)="onViewAnalysis()">
|
||||
View Analysis
|
||||
</button>
|
||||
@if (config().action !== 'allow') {
|
||||
<button
|
||||
class="btn-expand"
|
||||
(click)="toggleExpanded()"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
>
|
||||
{{ expanded() ? 'Hide' : 'Show' }} Details
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Score Visualization -->
|
||||
<div class="score-visualization">
|
||||
<div class="score-bar">
|
||||
<div class="score-track">
|
||||
<!-- Zone backgrounds -->
|
||||
<div class="zone allow" [style.width]="warnPercentage() + '%'"></div>
|
||||
<div
|
||||
class="zone warn"
|
||||
[style.left]="warnPercentage() + '%'"
|
||||
[style.width]="(blockPercentage() - warnPercentage()) + '%'"
|
||||
></div>
|
||||
<div
|
||||
class="zone block"
|
||||
[style.left]="blockPercentage() + '%'"
|
||||
[style.width]="(100 - blockPercentage()) + '%'"
|
||||
></div>
|
||||
|
||||
<!-- Threshold markers -->
|
||||
<div class="threshold-line warn" [style.left]="warnPercentage() + '%'">
|
||||
<span class="threshold-label">Warn</span>
|
||||
</div>
|
||||
<div class="threshold-line block" [style.left]="blockPercentage() + '%'">
|
||||
<span class="threshold-label">Block</span>
|
||||
</div>
|
||||
|
||||
<!-- Current score marker -->
|
||||
<div
|
||||
class="score-marker"
|
||||
[style.left]="scorePercentage() + '%'"
|
||||
[class]="'action-' + config().action"
|
||||
>
|
||||
<span class="score-value">{{ config().currentScore | number:'1.1-1' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scale-labels">
|
||||
<span>0</span>
|
||||
<span>2</span>
|
||||
<span>4</span>
|
||||
<span>6</span>
|
||||
<span>8</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="score-legend">
|
||||
<span class="legend-item allow">
|
||||
<span class="legend-dot"></span>
|
||||
Allow (< {{ config().warnThreshold }})
|
||||
</span>
|
||||
<span class="legend-item warn">
|
||||
<span class="legend-dot"></span>
|
||||
Warn ({{ config().warnThreshold }} - {{ config().blockThreshold }})
|
||||
</span>
|
||||
<span class="legend-item block">
|
||||
<span class="legend-dot"></span>
|
||||
Block (> {{ config().blockThreshold }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Details -->
|
||||
@if (expanded()) {
|
||||
<div class="banner-details">
|
||||
<!-- Policy Info -->
|
||||
<div class="policy-info">
|
||||
<h5 class="section-title">Policy Information</h5>
|
||||
<dl class="info-grid">
|
||||
<div class="info-item">
|
||||
<dt>Policy</dt>
|
||||
<dd>{{ config().policyName }}</dd>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<dt>Policy ID</dt>
|
||||
<dd><code>{{ config().policyId }}</code></dd>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<dt>Warn Threshold</dt>
|
||||
<dd>{{ config().warnThreshold }} / 10</dd>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<dt>Block Threshold</dt>
|
||||
<dd>{{ config().blockThreshold }} / 10</dd>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<dt>High Entropy Files</dt>
|
||||
<dd>{{ config().highEntropyFileCount }} files</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Threshold Explanation -->
|
||||
<div class="threshold-explanation">
|
||||
<h5 class="section-title">Understanding Entropy Thresholds</h5>
|
||||
<div class="explanation-content">
|
||||
<p>
|
||||
<strong>Entropy</strong> measures the randomness of data in files. High entropy often indicates:
|
||||
</p>
|
||||
<ul class="entropy-indicators">
|
||||
<li><span class="indicator-icon">[E]</span> Encrypted content</li>
|
||||
<li><span class="indicator-icon">[C]</span> Compressed files (zip, gz, etc.)</li>
|
||||
<li><span class="indicator-icon">[B]</span> Binary executables</li>
|
||||
<li><span class="indicator-icon">[S]</span> Potential secrets or credentials</li>
|
||||
<li><span class="indicator-icon">[O]</span> Obfuscated or packed code</li>
|
||||
</ul>
|
||||
<p class="explanation-note">
|
||||
While some high-entropy content is legitimate (fonts, images, compressed assets),
|
||||
unexpected high entropy may indicate security concerns requiring review.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mitigation Steps -->
|
||||
@if (config().action !== 'allow') {
|
||||
<div class="mitigation-section">
|
||||
<h5 class="section-title">Mitigation Steps</h5>
|
||||
<div class="mitigation-list">
|
||||
@for (step of effectiveMitigationSteps(); track step.id) {
|
||||
<div class="mitigation-card">
|
||||
<div class="mitigation-header">
|
||||
<span class="mitigation-title">{{ step.title }}</span>
|
||||
<div class="mitigation-badges">
|
||||
<span class="badge impact" [class]="'impact-' + step.impact">
|
||||
{{ getImpactLabel(step.impact) }}
|
||||
</span>
|
||||
<span class="badge effort">{{ getEffortLabel(step.effort) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mitigation-desc">{{ step.description }}</p>
|
||||
|
||||
@if (step.command) {
|
||||
<div class="mitigation-command">
|
||||
<code>{{ step.command }}</code>
|
||||
<button class="btn-run" (click)="onRunMitigation(step)">
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (step.docsUrl) {
|
||||
<a class="docs-link" [href]="step.docsUrl" target="_blank">
|
||||
Learn more ->
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Report Download -->
|
||||
@if (config().reportUrl) {
|
||||
<div class="report-section">
|
||||
<h5 class="section-title">Raw Evidence</h5>
|
||||
<div class="report-info">
|
||||
<span class="report-label">entropy.report.json</span>
|
||||
<p class="report-desc">
|
||||
Download the full entropy analysis report containing per-file entropy scores,
|
||||
detector findings, and detailed metrics.
|
||||
</p>
|
||||
<button class="btn-download" (click)="onDownloadReport()">
|
||||
Download entropy.report.json
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,489 @@
|
||||
.entropy-policy-banner {
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card, white);
|
||||
overflow: hidden;
|
||||
|
||||
&.action-allow {
|
||||
border-color: var(--color-success-border, #a7f3d0);
|
||||
|
||||
.banner-main {
|
||||
background: var(--color-success-bg, #ecfdf5);
|
||||
border-left: 4px solid var(--color-success, #059669);
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
color: var(--color-success, #059669);
|
||||
}
|
||||
}
|
||||
|
||||
&.action-warn {
|
||||
border-color: var(--color-warning-border, #fde68a);
|
||||
|
||||
.banner-main {
|
||||
background: var(--color-warning-bg, #fffbeb);
|
||||
border-left: 4px solid var(--color-warning, #d97706);
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
color: var(--color-warning, #d97706);
|
||||
}
|
||||
}
|
||||
|
||||
&.action-block {
|
||||
border-color: var(--color-error-border, #fecaca);
|
||||
|
||||
.banner-main {
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
border-left: 4px solid var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.banner-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.banner-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.banner-content {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.banner-message {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.banner-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text, #374151);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f3f4f6);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-expand {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-primary, #2563eb);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-bg, #eff6ff);
|
||||
}
|
||||
}
|
||||
|
||||
// Score Visualization
|
||||
.score-visualization {
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.score-bar {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.score-track {
|
||||
position: relative;
|
||||
height: 24px;
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
border-radius: 4px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.zone {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
|
||||
&.allow {
|
||||
background: var(--color-success-bg, #dcfce7);
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
|
||||
&.warn {
|
||||
background: var(--color-warning-bg, #fef3c7);
|
||||
}
|
||||
|
||||
&.block {
|
||||
background: var(--color-error-bg, #fee2e2);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.threshold-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: currentColor;
|
||||
transform: translateX(-1px);
|
||||
|
||||
&.warn {
|
||||
color: var(--color-warning, #d97706);
|
||||
}
|
||||
|
||||
&.block {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.threshold-label {
|
||||
position: absolute;
|
||||
top: -18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.score-marker {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
|
||||
&.action-allow {
|
||||
.score-value {
|
||||
background: var(--color-success, #059669);
|
||||
}
|
||||
}
|
||||
|
||||
&.action-warn {
|
||||
.score-value {
|
||||
background: var(--color-warning, #d97706);
|
||||
}
|
||||
}
|
||||
|
||||
&.action-block {
|
||||
.score-value {
|
||||
background: var(--color-error, #dc2626);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.score-value {
|
||||
display: block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scale-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.score-legend {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-top: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
|
||||
&.allow .legend-dot { background: var(--color-success, #059669); }
|
||||
&.warn .legend-dot { background: var(--color-warning, #d97706); }
|
||||
&.block .legend-dot { background: var(--color-error, #dc2626); }
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
// Expanded Details
|
||||
.banner-details {
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
padding: 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.policy-info,
|
||||
.threshold-explanation,
|
||||
.mitigation-section,
|
||||
.report-section {
|
||||
margin-bottom: 1.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
dt {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #374151);
|
||||
|
||||
code {
|
||||
font-size: 0.75rem;
|
||||
background: var(--color-bg-code, #f3f4f6);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.explanation-content {
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
|
||||
p {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
}
|
||||
|
||||
.entropy-indicators {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 0.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 0.25rem;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
.indicator-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.explanation-note {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
font-style: italic;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mitigation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mitigation-card {
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.mitigation-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mitigation-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.mitigation-badges {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
|
||||
&.impact {
|
||||
&.impact-high {
|
||||
background: var(--color-success-bg, #ecfdf5);
|
||||
color: var(--color-success, #059669);
|
||||
}
|
||||
|
||||
&.impact-medium {
|
||||
background: var(--color-info-bg, #f0f9ff);
|
||||
color: var(--color-info, #0284c7);
|
||||
}
|
||||
|
||||
&.impact-low {
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
}
|
||||
|
||||
&.effort {
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
}
|
||||
|
||||
.mitigation-desc {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.mitigation-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-bg-code, #1f2937);
|
||||
border-radius: 4px;
|
||||
|
||||
code {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
color: #e5e7eb;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.btn-run {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--color-primary, #2563eb);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
font-size: 0.6875rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-dark, #1d4ed8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.docs-link {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-primary, #2563eb);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.report-info {
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.report-label {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.report-desc {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.btn-download {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--color-primary, #2563eb);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-dark, #1d4ed8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
export interface EntropyPolicyConfig {
|
||||
/** Warn threshold (0-10) */
|
||||
warnThreshold: number;
|
||||
|
||||
/** Block threshold (0-10) */
|
||||
blockThreshold: number;
|
||||
|
||||
/** Current entropy score */
|
||||
currentScore: number;
|
||||
|
||||
/** Action taken */
|
||||
action: 'allow' | 'warn' | 'block';
|
||||
|
||||
/** Policy ID */
|
||||
policyId: string;
|
||||
|
||||
/** Policy name */
|
||||
policyName: string;
|
||||
|
||||
/** High entropy file count */
|
||||
highEntropyFileCount: number;
|
||||
|
||||
/** Link to entropy report */
|
||||
reportUrl?: string;
|
||||
}
|
||||
|
||||
export interface EntropyMitigationStep {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
impact: 'high' | 'medium' | 'low';
|
||||
effort: 'trivial' | 'easy' | 'moderate' | 'complex';
|
||||
command?: string;
|
||||
docsUrl?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-entropy-policy-banner',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './entropy-policy-banner.component.html',
|
||||
styleUrls: ['./entropy-policy-banner.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class EntropyPolicyBannerComponent {
|
||||
/** Policy configuration and current state */
|
||||
readonly config = input.required<EntropyPolicyConfig>();
|
||||
|
||||
/** Custom mitigation steps */
|
||||
readonly mitigationSteps = input<EntropyMitigationStep[]>([]);
|
||||
|
||||
/** Emits when user wants to download entropy report */
|
||||
readonly downloadReport = output<string>();
|
||||
|
||||
/** Emits when user runs a mitigation command */
|
||||
readonly runMitigation = output<EntropyMitigationStep>();
|
||||
|
||||
/** Emits when user wants to view detailed analysis */
|
||||
readonly viewAnalysis = output<void>();
|
||||
|
||||
/** Show expanded details */
|
||||
readonly expanded = signal(false);
|
||||
|
||||
readonly bannerClass = computed(() => 'action-' + this.config().action);
|
||||
|
||||
readonly bannerIcon = computed(() => {
|
||||
switch (this.config().action) {
|
||||
case 'allow':
|
||||
return '[OK]';
|
||||
case 'warn':
|
||||
return '[!]';
|
||||
case 'block':
|
||||
return '[X]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
});
|
||||
|
||||
readonly bannerTitle = computed(() => {
|
||||
switch (this.config().action) {
|
||||
case 'allow':
|
||||
return 'Entropy Check Passed';
|
||||
case 'warn':
|
||||
return 'Entropy Warning';
|
||||
case 'block':
|
||||
return 'Entropy Block';
|
||||
default:
|
||||
return 'Entropy Status Unknown';
|
||||
}
|
||||
});
|
||||
|
||||
readonly bannerMessage = computed(() => {
|
||||
const cfg = this.config();
|
||||
switch (cfg.action) {
|
||||
case 'allow':
|
||||
return 'Entropy score ' + cfg.currentScore.toFixed(1) + ' is within acceptable limits.';
|
||||
case 'warn':
|
||||
return 'Entropy score ' + cfg.currentScore.toFixed(1) + ' exceeds warning threshold (' + cfg.warnThreshold + '). Review recommended.';
|
||||
case 'block':
|
||||
return 'Entropy score ' + cfg.currentScore.toFixed(1) + ' exceeds block threshold (' + cfg.blockThreshold + '). Publication blocked.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
readonly scorePercentage = computed(() =>
|
||||
(this.config().currentScore / 10) * 100
|
||||
);
|
||||
|
||||
readonly warnPercentage = computed(() =>
|
||||
(this.config().warnThreshold / 10) * 100
|
||||
);
|
||||
|
||||
readonly blockPercentage = computed(() =>
|
||||
(this.config().blockThreshold / 10) * 100
|
||||
);
|
||||
|
||||
readonly defaultMitigationSteps: EntropyMitigationStep[] = [
|
||||
{
|
||||
id: 'review-files',
|
||||
title: 'Review High-Entropy Files',
|
||||
description: 'Examine files flagged as high entropy to identify false positives or legitimate concerns.',
|
||||
impact: 'high',
|
||||
effort: 'easy',
|
||||
docsUrl: '/docs/security/entropy-analysis',
|
||||
},
|
||||
{
|
||||
id: 'exclude-known',
|
||||
title: 'Exclude Known Binary Artifacts',
|
||||
description: 'Add exclusion patterns for legitimate compressed files, fonts, or compiled assets.',
|
||||
impact: 'medium',
|
||||
effort: 'trivial',
|
||||
command: 'stella policy entropy exclude --pattern "*.woff2" --pattern "*.gz"',
|
||||
},
|
||||
{
|
||||
id: 'investigate-secrets',
|
||||
title: 'Investigate Potential Secrets',
|
||||
description: 'Check if high-entropy content contains accidentally committed secrets or credentials.',
|
||||
impact: 'high',
|
||||
effort: 'moderate',
|
||||
command: 'stella scan secrets --image $IMAGE_REF',
|
||||
docsUrl: '/docs/security/secret-detection',
|
||||
},
|
||||
{
|
||||
id: 'adjust-threshold',
|
||||
title: 'Adjust Policy Thresholds',
|
||||
description: 'If false positives are common, consider adjusting warn/block thresholds for this policy.',
|
||||
impact: 'medium',
|
||||
effort: 'easy',
|
||||
command: 'stella policy entropy set-threshold --policy $POLICY_ID --warn 7.0 --block 8.5',
|
||||
},
|
||||
];
|
||||
|
||||
readonly effectiveMitigationSteps = computed(() => {
|
||||
const custom = this.mitigationSteps();
|
||||
return custom.length > 0 ? custom : this.defaultMitigationSteps;
|
||||
});
|
||||
|
||||
toggleExpanded(): void {
|
||||
this.expanded.update((v) => !v);
|
||||
}
|
||||
|
||||
onDownloadReport(): void {
|
||||
const url = this.config().reportUrl;
|
||||
if (url) {
|
||||
this.downloadReport.emit(url);
|
||||
}
|
||||
}
|
||||
|
||||
onViewAnalysis(): void {
|
||||
this.viewAnalysis.emit();
|
||||
}
|
||||
|
||||
onRunMitigation(step: EntropyMitigationStep): void {
|
||||
this.runMitigation.emit(step);
|
||||
}
|
||||
|
||||
getImpactLabel(impact: string): string {
|
||||
switch (impact) {
|
||||
case 'high':
|
||||
return 'High Impact';
|
||||
case 'medium':
|
||||
return 'Medium Impact';
|
||||
case 'low':
|
||||
return 'Low Impact';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
getEffortLabel(effort: string): string {
|
||||
switch (effort) {
|
||||
case 'trivial':
|
||||
return '< 5 min';
|
||||
case 'easy':
|
||||
return '5-15 min';
|
||||
case 'moderate':
|
||||
return '15-60 min';
|
||||
case 'complex':
|
||||
return '> 1 hour';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
<div class="policy-gate-indicator" [class]="statusClass()" [class.compact]="compact()">
|
||||
<!-- Status Banner -->
|
||||
<div class="status-banner">
|
||||
<span class="status-icon">{{ statusIcon() }}</span>
|
||||
<div class="status-info">
|
||||
<span class="status-label">{{ statusLabel() }}</span>
|
||||
<span class="gate-summary">
|
||||
{{ passedGates().length }}/{{ gateStatus().gates.length }} gates passed
|
||||
@if (warningGates().length > 0) {
|
||||
<span class="warning-count">({{ warningGates().length }} warnings)</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="status-actions">
|
||||
@if (!gateStatus().canPublish && gateStatus().remediationHints.length > 0) {
|
||||
<button class="btn-remediation" (click)="toggleRemediation()">
|
||||
{{ showRemediation() ? 'Hide' : 'Show' }} Fixes
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
class="btn-publish"
|
||||
[disabled]="!gateStatus().canPublish"
|
||||
(click)="onPublish()"
|
||||
[title]="gateStatus().blockReason || 'Publish artifact'"
|
||||
>
|
||||
@if (gateStatus().canPublish) {
|
||||
Publish
|
||||
} @else {
|
||||
Blocked
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block Reason Banner -->
|
||||
@if (!gateStatus().canPublish && gateStatus().blockReason) {
|
||||
<div class="block-banner">
|
||||
<span class="block-icon">[!]</span>
|
||||
<span class="block-message">{{ gateStatus().blockReason }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Gate List -->
|
||||
@if (!compact()) {
|
||||
<div class="gates-list">
|
||||
@for (gate of gateStatus().gates; track gate.gateId) {
|
||||
<div class="gate-item" [class]="'result-' + gate.result">
|
||||
<button
|
||||
class="gate-header"
|
||||
(click)="toggleGate(gate.gateId)"
|
||||
[attr.aria-expanded]="expandedGate() === gate.gateId"
|
||||
>
|
||||
<span class="gate-type-icon">{{ getGateIcon(gate.type) }}</span>
|
||||
<span class="result-icon">{{ getResultIcon(gate.result) }}</span>
|
||||
<div class="gate-info">
|
||||
<span class="gate-name">{{ gate.name }}</span>
|
||||
@if (gate.required) {
|
||||
<span class="required-badge">Required</span>
|
||||
}
|
||||
</div>
|
||||
<span class="expand-icon" [class.expanded]="expandedGate() === gate.gateId">v</span>
|
||||
</button>
|
||||
|
||||
@if (expandedGate() === gate.gateId) {
|
||||
<div class="gate-details">
|
||||
<!-- Determinism Gate Details -->
|
||||
@if (gate.type === 'determinism' && getDeterminismDetails(gate)) {
|
||||
@let det = getDeterminismDetails(gate)!;
|
||||
<div class="determinism-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Merkle Root</span>
|
||||
<span class="detail-value" [class.mismatch]="!det.merkleRootConsistent">
|
||||
@if (det.merkleRootConsistent) {
|
||||
<span class="match-icon">[+]</span> Consistent
|
||||
} @else {
|
||||
<span class="mismatch-icon">[x]</span> Mismatch
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (!det.merkleRootConsistent) {
|
||||
<div class="hash-comparison">
|
||||
<div class="hash-row">
|
||||
<span class="hash-label">Expected:</span>
|
||||
<code class="hash">{{ formatHash(det.expectedMerkleRoot, 24) }}</code>
|
||||
</div>
|
||||
<div class="hash-row">
|
||||
<span class="hash-label">Computed:</span>
|
||||
<code class="hash mismatch">{{ formatHash(det.computedMerkleRoot, 24) }}</code>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Fragments</span>
|
||||
<span class="detail-value">
|
||||
{{ det.matchingFragments }}/{{ det.totalFragments }} verified
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Composition File</span>
|
||||
<span class="detail-value" [class.missing]="!det.compositionPresent">
|
||||
{{ det.compositionPresent ? 'Present' : 'Missing' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@if (det.fragmentResults.length > 0) {
|
||||
<details class="fragment-details">
|
||||
<summary>Fragment Verification ({{ det.fragmentResults.length }})</summary>
|
||||
<ul class="fragment-list">
|
||||
@for (frag of det.fragmentResults.slice(0, 5); track frag.fragmentId) {
|
||||
<li [class.mismatch]="!frag.match">
|
||||
<span class="frag-id">{{ frag.fragmentId }}</span>
|
||||
<span class="frag-status">{{ frag.match ? '+' : 'x' }}</span>
|
||||
</li>
|
||||
}
|
||||
@if (det.fragmentResults.length > 5) {
|
||||
<li class="more">+ {{ det.fragmentResults.length - 5 }} more</li>
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Entropy Gate Details -->
|
||||
@if (gate.type === 'entropy' && getEntropyDetails(gate)) {
|
||||
@let ent = getEntropyDetails(gate)!;
|
||||
<div class="entropy-details">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Entropy Score</span>
|
||||
<span class="detail-value score" [class]="'action-' + ent.action">
|
||||
{{ ent.entropyScore | number:'1.1-1' }} / 10
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="threshold-bar">
|
||||
<div class="threshold-track">
|
||||
<div class="threshold-marker warn" [style.left]="(ent.warnThreshold / 10 * 100) + '%'"></div>
|
||||
<div class="threshold-marker block" [style.left]="(ent.blockThreshold / 10 * 100) + '%'"></div>
|
||||
<div class="score-marker" [style.left]="(ent.entropyScore / 10 * 100) + '%'"></div>
|
||||
</div>
|
||||
<div class="threshold-labels">
|
||||
<span>0</span>
|
||||
<span class="warn-label">Warn ({{ ent.warnThreshold }})</span>
|
||||
<span class="block-label">Block ({{ ent.blockThreshold }})</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">High Entropy Files</span>
|
||||
<span class="detail-value">{{ ent.highEntropyFileCount }}</span>
|
||||
</div>
|
||||
|
||||
@if (ent.suspiciousPatterns.length > 0) {
|
||||
<div class="suspicious-patterns">
|
||||
<span class="detail-label">Suspicious Patterns</span>
|
||||
<ul class="pattern-list">
|
||||
@for (pattern of ent.suspiciousPatterns; track pattern) {
|
||||
<li>{{ pattern }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Evidence Links -->
|
||||
@if (gate.evidenceRefs && gate.evidenceRefs.length > 0) {
|
||||
<div class="evidence-links">
|
||||
<span class="evidence-label">Evidence:</span>
|
||||
@for (ref of gate.evidenceRefs; track ref) {
|
||||
<button class="evidence-link" (click)="onViewEvidence(ref)">
|
||||
{{ ref | slice:0:20 }}...
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Gate-specific Remediation -->
|
||||
@if (gate.result === 'failed') {
|
||||
@let hints = getHintsForGate(gate.gateId);
|
||||
@if (hints.length > 0) {
|
||||
<div class="gate-remediation">
|
||||
<h5 class="remediation-title">How to Fix</h5>
|
||||
@for (hint of hints; track hint.title) {
|
||||
<div class="hint-card">
|
||||
<div class="hint-header">
|
||||
<span class="hint-title">{{ hint.title }}</span>
|
||||
@if (hint.effort) {
|
||||
<span class="effort-badge">{{ getEffortLabel(hint.effort) }}</span>
|
||||
}
|
||||
</div>
|
||||
<ol class="hint-steps">
|
||||
@for (step of hint.steps; track step) {
|
||||
<li>{{ step }}</li>
|
||||
}
|
||||
</ol>
|
||||
@if (hint.cliCommand) {
|
||||
<div class="cli-command">
|
||||
<code>{{ hint.cliCommand }}</code>
|
||||
<button class="btn-run" (click)="onRunRemediation(hint)">Run</button>
|
||||
</div>
|
||||
}
|
||||
@if (hint.docsUrl) {
|
||||
<a class="docs-link" [href]="hint.docsUrl" target="_blank">
|
||||
Documentation ->
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Blocking Issues Summary -->
|
||||
@if (gateStatus().blockingIssues.length > 0) {
|
||||
<div class="blocking-issues">
|
||||
<h4 class="issues-title">Blocking Issues ({{ gateStatus().blockingIssues.length }})</h4>
|
||||
<ul class="issues-list">
|
||||
@for (issue of gateStatus().blockingIssues; track issue.code) {
|
||||
<li class="issue-item" [class]="'severity-' + issue.severity">
|
||||
<span class="issue-code">{{ issue.code }}</span>
|
||||
<span class="issue-message">{{ issue.message }}</span>
|
||||
@if (issue.resource) {
|
||||
<span class="issue-resource">{{ issue.resource }}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Remediation Panel -->
|
||||
@if (showRemediation() && gateStatus().remediationHints.length > 0) {
|
||||
<div class="remediation-panel">
|
||||
<h4 class="remediation-panel-title">Remediation Steps</h4>
|
||||
@for (hint of gateStatus().remediationHints; track hint.title) {
|
||||
<div class="remediation-card">
|
||||
<div class="remediation-header">
|
||||
<span class="remediation-for">{{ hint.forGate }}</span>
|
||||
<span class="remediation-title">{{ hint.title }}</span>
|
||||
@if (hint.effort) {
|
||||
<span class="effort-badge">{{ getEffortLabel(hint.effort) }}</span>
|
||||
}
|
||||
</div>
|
||||
<ol class="remediation-steps">
|
||||
@for (step of hint.steps; track step) {
|
||||
<li>{{ step }}</li>
|
||||
}
|
||||
</ol>
|
||||
@if (hint.cliCommand) {
|
||||
<div class="cli-command">
|
||||
<code>{{ hint.cliCommand }}</code>
|
||||
<button class="btn-run" (click)="onRunRemediation(hint)">Run</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="indicator-footer">
|
||||
<span class="eval-id">Evaluation: {{ gateStatus().evaluationId | slice:0:12 }}</span>
|
||||
<span class="eval-time">{{ gateStatus().evaluatedAt | date:'medium' }}</span>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -0,0 +1,673 @@
|
||||
.policy-gate-indicator {
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg-card, white);
|
||||
overflow: hidden;
|
||||
|
||||
&.status-passed {
|
||||
.status-banner { border-left: 4px solid var(--color-success, #059669); }
|
||||
.status-icon { color: var(--color-success, #059669); }
|
||||
}
|
||||
|
||||
&.status-failed {
|
||||
.status-banner { border-left: 4px solid var(--color-error, #dc2626); }
|
||||
.status-icon { color: var(--color-error, #dc2626); }
|
||||
}
|
||||
|
||||
&.status-warning {
|
||||
.status-banner { border-left: 4px solid var(--color-warning, #d97706); }
|
||||
.status-icon { color: var(--color-warning, #d97706); }
|
||||
}
|
||||
|
||||
&.status-pending {
|
||||
.status-banner { border-left: 4px solid var(--color-info, #2563eb); }
|
||||
.status-icon { color: var(--color-info, #2563eb); }
|
||||
}
|
||||
|
||||
&.status-skipped {
|
||||
.status-banner { border-left: 4px solid var(--color-text-muted, #9ca3af); }
|
||||
.status-icon { color: var(--color-text-muted, #9ca3af); }
|
||||
}
|
||||
|
||||
&.compact {
|
||||
.gates-list,
|
||||
.blocking-issues,
|
||||
.remediation-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.status-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text, #111827);
|
||||
}
|
||||
|
||||
.gate-summary {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.warning-count {
|
||||
color: var(--color-warning, #d97706);
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-remediation {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text, #374151);
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f3f4f6);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-publish {
|
||||
padding: 0.375rem 1rem;
|
||||
background: var(--color-success, #059669);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--color-success-dark, #047857);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--color-text-muted, #9ca3af);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.block-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
border-bottom: 1px solid var(--color-error-border, #fecaca);
|
||||
}
|
||||
|
||||
.block-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.block-message {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.gates-list {
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.gate-item {
|
||||
border-bottom: 1px solid var(--color-border-light, #f3f4f6);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.result-passed {
|
||||
.result-icon { color: var(--color-success, #059669); }
|
||||
}
|
||||
|
||||
&.result-failed {
|
||||
.result-icon { color: var(--color-error, #dc2626); }
|
||||
.gate-header { background: var(--color-error-bg, #fef2f2); }
|
||||
}
|
||||
|
||||
&.result-warning {
|
||||
.result-icon { color: var(--color-warning, #d97706); }
|
||||
.gate-header { background: var(--color-warning-bg, #fffbeb); }
|
||||
}
|
||||
|
||||
&.result-skipped {
|
||||
.result-icon { color: var(--color-text-muted, #9ca3af); }
|
||||
.gate-name { color: var(--color-text-muted, #9ca3af); }
|
||||
}
|
||||
}
|
||||
|
||||
.gate-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-bg-hover, #f9fafb);
|
||||
}
|
||||
}
|
||||
|
||||
.gate-type-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
border-radius: 3px;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-family: monospace;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.gate-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gate-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.required-badge {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.0625rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
background: var(--color-warning-bg, #fef3c7);
|
||||
color: var(--color-warning-dark, #92400e);
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
transition: transform 0.2s;
|
||||
|
||||
&.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.gate-details {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border-top: 1px solid var(--color-border-light, #f3f4f6);
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-weight: 500;
|
||||
color: var(--color-text, #374151);
|
||||
|
||||
&.mismatch,
|
||||
&.missing {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
&.score {
|
||||
font-family: monospace;
|
||||
|
||||
&.action-allow { color: var(--color-success, #059669); }
|
||||
&.action-warn { color: var(--color-warning, #d97706); }
|
||||
&.action-block { color: var(--color-error, #dc2626); }
|
||||
}
|
||||
}
|
||||
|
||||
.match-icon {
|
||||
color: var(--color-success, #059669);
|
||||
}
|
||||
|
||||
.mismatch-icon {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.hash-comparison {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-bg-card, white);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.hash-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hash-label {
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.hash {
|
||||
font-family: monospace;
|
||||
background: var(--color-bg-code, #f3f4f6);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 2px;
|
||||
|
||||
&.mismatch {
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.fragment-details {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
summary {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-primary, #2563eb);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.fragment-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0.25rem 0 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.125rem 0;
|
||||
font-size: 0.75rem;
|
||||
|
||||
&.mismatch {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
&.more {
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.frag-id {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.frag-status {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
// Entropy Details
|
||||
.threshold-bar {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.threshold-track {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
background: linear-gradient(to right,
|
||||
var(--color-success, #059669) 0%,
|
||||
var(--color-warning, #d97706) 50%,
|
||||
var(--color-error, #dc2626) 100%
|
||||
);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.threshold-marker {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 2px;
|
||||
height: 12px;
|
||||
background: var(--color-text, #374151);
|
||||
|
||||
&.warn::after,
|
||||
&.block::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -3px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&.warn::after {
|
||||
background: var(--color-warning, #d97706);
|
||||
}
|
||||
|
||||
&.block::after {
|
||||
background: var(--color-error, #dc2626);
|
||||
}
|
||||
}
|
||||
|
||||
.score-marker {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: white;
|
||||
border: 2px solid var(--color-text, #374151);
|
||||
border-radius: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.threshold-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.625rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.warn-label {
|
||||
color: var(--color-warning, #d97706);
|
||||
}
|
||||
|
||||
.block-label {
|
||||
color: var(--color-error, #dc2626);
|
||||
}
|
||||
|
||||
.suspicious-patterns {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.pattern-list {
|
||||
list-style: disc;
|
||||
margin: 0.25rem 0 0 1rem;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-warning, #d97706);
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--color-border-light, #f3f4f6);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.evidence-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.evidence-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary, #2563eb);
|
||||
font-size: 0.75rem;
|
||||
font-family: monospace;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.gate-remediation {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border-light, #f3f4f6);
|
||||
}
|
||||
|
||||
.remediation-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.hint-card,
|
||||
.remediation-card {
|
||||
background: var(--color-bg-card, white);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hint-header,
|
||||
.remediation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hint-title,
|
||||
.remediation-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.remediation-for {
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--color-bg-subtle, #f3f4f6);
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.effort-badge {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--color-info-bg, #f0f9ff);
|
||||
color: var(--color-info, #0284c7);
|
||||
border-radius: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.hint-steps,
|
||||
.remediation-steps {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
|
||||
li {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text, #374151);
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cli-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-bg-code, #1f2937);
|
||||
border-radius: 4px;
|
||||
|
||||
code {
|
||||
flex: 1;
|
||||
font-size: 0.75rem;
|
||||
color: #e5e7eb;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.btn-run {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--color-primary, #2563eb);
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
font-size: 0.6875rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--color-primary-dark, #1d4ed8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.docs-link {
|
||||
display: inline-block;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-primary, #2563eb);
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.blocking-issues {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-error-bg, #fef2f2);
|
||||
border-top: 1px solid var(--color-error-border, #fecaca);
|
||||
}
|
||||
|
||||
.issues-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-error, #dc2626);
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.issues-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.issue-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0;
|
||||
font-size: 0.8125rem;
|
||||
border-bottom: 1px solid var(--color-error-border, #fecaca);
|
||||
flex-wrap: wrap;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&.severity-critical .issue-code {
|
||||
background: var(--color-critical, #dc2626);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.severity-high .issue-code {
|
||||
background: var(--color-error, #ea580c);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.issue-code {
|
||||
font-family: monospace;
|
||||
font-size: 0.6875rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.issue-message {
|
||||
flex: 1;
|
||||
color: var(--color-text, #374151);
|
||||
}
|
||||
|
||||
.issue-resource {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.remediation-panel {
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--color-info-bg, #f0f9ff);
|
||||
border-top: 1px solid var(--color-info-border, #bae6fd);
|
||||
}
|
||||
|
||||
.remediation-panel-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-info-dark, #0369a1);
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.indicator-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--color-bg-subtle, #f9fafb);
|
||||
border-top: 1px solid var(--color-border, #e5e7eb);
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted, #9ca3af);
|
||||
}
|
||||
|
||||
.eval-id {
|
||||
font-family: monospace;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
PolicyGateStatus,
|
||||
PolicyGate,
|
||||
PolicyRemediationHint,
|
||||
DeterminismGateDetails,
|
||||
EntropyGateDetails,
|
||||
} from '../../core/api/policy.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-gate-indicator',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './policy-gate-indicator.component.html',
|
||||
styleUrls: ['./policy-gate-indicator.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PolicyGateIndicatorComponent {
|
||||
/** Policy gate status data */
|
||||
readonly gateStatus = input.required<PolicyGateStatus>();
|
||||
|
||||
/** Show compact view */
|
||||
readonly compact = input(false);
|
||||
|
||||
/** Emits when user clicks publish (if allowed) */
|
||||
readonly publish = output<string>();
|
||||
|
||||
/** Emits when user wants to view evidence */
|
||||
readonly viewEvidence = output<string>();
|
||||
|
||||
/** Emits when user wants to run remediation */
|
||||
readonly runRemediation = output<PolicyRemediationHint>();
|
||||
|
||||
/** Currently expanded gate */
|
||||
readonly expandedGate = signal<string | null>(null);
|
||||
|
||||
/** Show remediation panel */
|
||||
readonly showRemediation = signal(false);
|
||||
|
||||
readonly statusClass = computed(() => 'status-' + this.gateStatus().status);
|
||||
|
||||
readonly statusIcon = computed(() => {
|
||||
switch (this.gateStatus().status) {
|
||||
case 'passed':
|
||||
return '[OK]';
|
||||
case 'failed':
|
||||
return '[X]';
|
||||
case 'warning':
|
||||
return '[!]';
|
||||
case 'pending':
|
||||
return '[...]';
|
||||
case 'skipped':
|
||||
return '[-]';
|
||||
default:
|
||||
return '[?]';
|
||||
}
|
||||
});
|
||||
|
||||
readonly statusLabel = computed(() => {
|
||||
switch (this.gateStatus().status) {
|
||||
case 'passed':
|
||||
return 'All Gates Passed';
|
||||
case 'failed':
|
||||
return 'Gates Failed';
|
||||
case 'warning':
|
||||
return 'Gates Passed with Warnings';
|
||||
case 'pending':
|
||||
return 'Evaluation Pending';
|
||||
case 'skipped':
|
||||
return 'Gates Skipped';
|
||||
default:
|
||||
return 'Unknown Status';
|
||||
}
|
||||
});
|
||||
|
||||
readonly passedGates = computed(() =>
|
||||
this.gateStatus().gates.filter((g) => g.result === 'passed')
|
||||
);
|
||||
|
||||
readonly failedGates = computed(() =>
|
||||
this.gateStatus().gates.filter((g) => g.result === 'failed')
|
||||
);
|
||||
|
||||
readonly warningGates = computed(() =>
|
||||
this.gateStatus().gates.filter((g) => g.result === 'warning')
|
||||
);
|
||||
|
||||
readonly determinismGate = computed(() =>
|
||||
this.gateStatus().gates.find((g) => g.type === 'determinism')
|
||||
);
|
||||
|
||||
readonly entropyGate = computed(() =>
|
||||
this.gateStatus().gates.find((g) => g.type === 'entropy')
|
||||
);
|
||||
|
||||
toggleGate(gateId: string): void {
|
||||
this.expandedGate.update((current) => (current === gateId ? null : gateId));
|
||||
}
|
||||
|
||||
toggleRemediation(): void {
|
||||
this.showRemediation.update((v) => !v);
|
||||
}
|
||||
|
||||
onPublish(): void {
|
||||
if (this.gateStatus().canPublish) {
|
||||
this.publish.emit(this.gateStatus().evaluationId);
|
||||
}
|
||||
}
|
||||
|
||||
onViewEvidence(ref: string): void {
|
||||
this.viewEvidence.emit(ref);
|
||||
}
|
||||
|
||||
onRunRemediation(hint: PolicyRemediationHint): void {
|
||||
this.runRemediation.emit(hint);
|
||||
}
|
||||
|
||||
getGateIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'determinism':
|
||||
return '#';
|
||||
case 'vulnerability':
|
||||
return '!';
|
||||
case 'license':
|
||||
return 'L';
|
||||
case 'signature':
|
||||
return 'S';
|
||||
case 'entropy':
|
||||
return 'E';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
getResultIcon(result: string): string {
|
||||
switch (result) {
|
||||
case 'passed':
|
||||
return '+';
|
||||
case 'failed':
|
||||
return 'x';
|
||||
case 'warning':
|
||||
return '!';
|
||||
case 'skipped':
|
||||
return '-';
|
||||
default:
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
getEffortLabel(effort?: string): string {
|
||||
switch (effort) {
|
||||
case 'trivial':
|
||||
return '< 5 min';
|
||||
case 'easy':
|
||||
return '5-15 min';
|
||||
case 'moderate':
|
||||
return '15-60 min';
|
||||
case 'complex':
|
||||
return '> 1 hour';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
getDeterminismDetails(gate: PolicyGate): DeterminismGateDetails | null {
|
||||
return gate.details as DeterminismGateDetails | null;
|
||||
}
|
||||
|
||||
getEntropyDetails(gate: PolicyGate): EntropyGateDetails | null {
|
||||
return gate.details as EntropyGateDetails | null;
|
||||
}
|
||||
|
||||
formatHash(hash: string | undefined, length = 12): string {
|
||||
if (!hash) return 'N/A';
|
||||
if (hash.length <= length) return hash;
|
||||
return hash.slice(0, length) + '...';
|
||||
}
|
||||
|
||||
getHintsForGate(gateId: string): PolicyRemediationHint[] {
|
||||
return this.gateStatus().remediationHints.filter((h) => h.forGate === gateId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user