feat: Implement NotifyPanelComponent with unit tests and mock API service
- Added NotifyPanelComponent for managing notification channels and rules. - Implemented reactive forms for channel and rule management. - Created unit tests for NotifyPanelComponent to validate functionality. - Developed MockNotifyApiService to simulate API interactions for testing. - Added mock data for channels, rules, and deliveries to facilitate testing. - Introduced RuntimeEventFactoryTests to ensure correct event creation with build ID.
This commit is contained in:
26
EXECPLAN.md
26
EXECPLAN.md
@@ -76,14 +76,14 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
|
|||||||
- Team Team Excititor Connectors – Ubuntu: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.Ubuntu.CSAF/TASKS.md`. Focus on EXCITITOR-CONN-UBUNTU-01-003 (TODO). Confirm prerequisites (internal: EXCITITOR-CONN-UBUNTU-01-002 (Wave 0); external: EXCITITOR-POLICY-01-001) before starting and report status in module TASKS.md.
|
- Team Team Excititor Connectors – Ubuntu: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.Ubuntu.CSAF/TASKS.md`. Focus on EXCITITOR-CONN-UBUNTU-01-003 (TODO). Confirm prerequisites (internal: EXCITITOR-CONN-UBUNTU-01-002 (Wave 0); external: EXCITITOR-POLICY-01-001) before starting and report status in module TASKS.md.
|
||||||
- Team Team Excititor Export: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Export/TASKS.md`. Focus on EXCITITOR-EXPORT-01-006 (DONE 2025-10-21). Confirm prerequisites (internal: EXCITITOR-EXPORT-01-005 (Wave 0), POLICY-CORE-09-005 (Wave 0)) before starting and report status in module TASKS.md.
|
- Team Team Excititor Export: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Export/TASKS.md`. Focus on EXCITITOR-EXPORT-01-006 (DONE 2025-10-21). Confirm prerequisites (internal: EXCITITOR-EXPORT-01-005 (Wave 0), POLICY-CORE-09-005 (Wave 0)) before starting and report status in module TASKS.md.
|
||||||
- Team Team Excititor Worker: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Worker/TASKS.md`. Focus on EXCITITOR-WORKER-01-003 (TODO). Confirm prerequisites (internal: EXCITITOR-ATTEST-01-003 (Wave 0); external: EXCITITOR-EXPORT-01-002, EXCITITOR-WORKER-01-001) before starting and report status in module TASKS.md.
|
- Team Team Excititor Worker: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Excititor.Worker/TASKS.md`. Focus on EXCITITOR-WORKER-01-003 (TODO). Confirm prerequisites (internal: EXCITITOR-ATTEST-01-003 (Wave 0); external: EXCITITOR-EXPORT-01-002, EXCITITOR-WORKER-01-001) before starting and report status in module TASKS.md.
|
||||||
- Team UI Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.UI/TASKS.md`. Focus on UI-ATTEST-11-005 (DONE 2025-10-23), UI-VEX-13-003 (TODO), UI-POLICY-13-007 (TODO), UI-ADMIN-13-004 (TODO), UI-AUTH-13-001 (DONE 2025-10-23), UI-SCANS-13-002 (TODO), UI-NOTIFY-13-006 (DOING 2025-10-19), UI-SCHED-13-005 (TODO). Confirm prerequisites (internal: ATTESTOR-API-11-201 (Wave 0), AUTH-DPOP-11-001 (Wave 0), AUTH-MTLS-11-002 (Wave 0), EXCITITOR-EXPORT-01-005 (Wave 0), NOTIFY-WEB-15-101 (Wave 0), POLICY-CORE-09-006 (Wave 0), SCHED-WEB-16-101 (Wave 0), SIGNER-API-11-101 (Wave 0); external: EXCITITOR-CORE-02-001, SCANNER-WEB-09-102, SCANNER-WEB-09-103) before starting and report status in module TASKS.md.
|
- Team UI Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.UI/TASKS.md`. Focus on UI-ATTEST-11-005 (DONE 2025-10-23), UI-VEX-13-003 (TODO), UI-POLICY-13-007 (TODO), UI-ADMIN-13-004 (TODO), UI-AUTH-13-001 (DONE 2025-10-23), UI-SCANS-13-002 (TODO), UI-NOTIFY-13-006 (DONE 2025-10-25), UI-SCHED-13-005 (TODO). Confirm prerequisites (internal: ATTESTOR-API-11-201 (Wave 0), AUTH-DPOP-11-001 (Wave 0), AUTH-MTLS-11-002 (Wave 0), EXCITITOR-EXPORT-01-005 (Wave 0), NOTIFY-WEB-15-101 (Wave 0), POLICY-CORE-09-006 (Wave 0), SCHED-WEB-16-101 (Wave 0), SIGNER-API-11-101 (Wave 0); external: EXCITITOR-CORE-02-001, SCANNER-WEB-09-102, SCANNER-WEB-09-103) before starting and report status in module TASKS.md.
|
||||||
- Team Zastava Observer Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Zastava.Observer/TASKS.md`. Focus on ZASTAVA-OBS-12-001 (DONE 2025-10-24). Confirm prerequisites (internal: ZASTAVA-CORE-12-201 (Wave 0)) before starting and report status in module TASKS.md.
|
- Team Zastava Observer Guild: read EXECPLAN.md Wave 1 and SPRINTS.md rows for `src/StellaOps.Zastava.Observer/TASKS.md`. Focus on ZASTAVA-OBS-12-001 (DONE 2025-10-24). Confirm prerequisites (internal: ZASTAVA-CORE-12-201 (Wave 0)) before starting and report status in module TASKS.md.
|
||||||
|
|
||||||
### Wave 2
|
### Wave 2
|
||||||
- Team Bench Guild, Notify Team: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `bench/TASKS.md`. Focus on BENCH-NOTIFY-15-001 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-301 (Wave 1)) before starting and report status in module TASKS.md.
|
- Team Bench Guild, Notify Team: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `bench/TASKS.md`. Focus on BENCH-NOTIFY-15-001 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-301 (Wave 1)) before starting and report status in module TASKS.md.
|
||||||
- Team Bench Guild, Scheduler Team: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `bench/TASKS.md`. Focus on BENCH-IMPACT-16-001 (TODO). Confirm prerequisites (internal: SCHED-IMPACT-16-301 (Wave 1)) before starting and report status in module TASKS.md.
|
- Team Bench Guild, Scheduler Team: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `bench/TASKS.md`. Focus on BENCH-IMPACT-16-001 (TODO). Confirm prerequisites (internal: SCHED-IMPACT-16-301 (Wave 1)) before starting and report status in module TASKS.md.
|
||||||
- Team Deployment Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/deployment/TASKS.md`. Focus on DEVOPS-OPS-14-003 (TODO). Confirm prerequisites (internal: DEVOPS-REL-14-001 (Wave 1)) before starting and report status in module TASKS.md.
|
- Team Deployment Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/deployment/TASKS.md`. Focus on DEVOPS-OPS-14-003 (TODO). Confirm prerequisites (internal: DEVOPS-REL-14-001 (Wave 1)) before starting and report status in module TASKS.md.
|
||||||
- Team DevOps Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-MIRROR-08-001 (DONE 2025-10-19), DEVOPS-PERF-10-002 (TODO), DEVOPS-REL-14-004 (TODO), DEVOPS-REL-17-002 (TODO), DEVOPS-NUGET-13-001 (DOING 2025-10-24), and DEVOPS-UI-13-006 (TODO). Confirm prerequisites (internal: BENCH-SCANNER-10-002 (Wave 1), DEVOPS-REL-14-001 (Wave 1), SCANNER-EMIT-17-701 (Wave 1), UI-AUTH-13-001 (Wave 1)) before starting and report status in module TASKS.md.
|
- Team DevOps Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-MIRROR-08-001 (DONE 2025-10-19), DEVOPS-PERF-10-002 (TODO), DEVOPS-REL-14-004 (TODO), DEVOPS-REL-17-002 (TODO), DEVOPS-NUGET-13-001 (DONE 2025-10-25), and DEVOPS-UI-13-006 (TODO). Confirm prerequisites (internal: BENCH-SCANNER-10-002 (Wave 1), DEVOPS-REL-14-001 (Wave 1), SCANNER-EMIT-17-701 (Wave 1), UI-AUTH-13-001 (Wave 1)) before starting and report status in module TASKS.md.
|
||||||
- Team DevOps Guild, Notify Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-SCANNER-09-205 (TODO). Confirm prerequisites (internal: DEVOPS-SCANNER-09-204 (Wave 1)) before starting and report status in module TASKS.md.
|
- Team DevOps Guild, Notify Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `ops/devops/TASKS.md`. Focus on DEVOPS-SCANNER-09-205 (TODO). Confirm prerequisites (internal: DEVOPS-SCANNER-09-204 (Wave 1)) before starting and report status in module TASKS.md.
|
||||||
- Team Notify Engine Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Notify.Engine/TASKS.md`. Focus on NOTIFY-ENGINE-15-302 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-301 (Wave 1)) before starting and report status in module TASKS.md.
|
- Team Notify Engine Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Notify.Engine/TASKS.md`. Focus on NOTIFY-ENGINE-15-302 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-301 (Wave 1)) before starting and report status in module TASKS.md.
|
||||||
- Team Notify Queue Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Notify.Queue/TASKS.md`. Focus on NOTIFY-QUEUE-15-403 (DONE 2025-10-23), NOTIFY-QUEUE-15-402 (DONE 2025-10-23). Confirm prerequisites (internal: NOTIFY-QUEUE-15-401 (Wave 1)) before starting and report status in module TASKS.md.
|
- Team Notify Queue Guild: read EXECPLAN.md Wave 2 and SPRINTS.md rows for `src/StellaOps.Notify.Queue/TASKS.md`. Focus on NOTIFY-QUEUE-15-403 (DONE 2025-10-23), NOTIFY-QUEUE-15-402 (DONE 2025-10-23). Confirm prerequisites (internal: NOTIFY-QUEUE-15-401 (Wave 1)) before starting and report status in module TASKS.md.
|
||||||
@@ -108,7 +108,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
|
|||||||
- Team Notify Worker Guild: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Notify.Worker/TASKS.md`. Focus on NOTIFY-WORKER-15-203 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-302 (Wave 2)) before starting and report status in module TASKS.md.
|
- Team Notify Worker Guild: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Notify.Worker/TASKS.md`. Focus on NOTIFY-WORKER-15-203 (TODO). Confirm prerequisites (internal: NOTIFY-ENGINE-15-302 (Wave 2)) before starting and report status in module TASKS.md.
|
||||||
- Team Scheduler Worker Guild: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Scheduler.Worker/TASKS.md`. Focus on SCHED-WORKER-16-203 (TODO). Confirm prerequisites (internal: SCHED-WORKER-16-202 (Wave 2)) before starting and report status in module TASKS.md.
|
- Team Scheduler Worker Guild: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Scheduler.Worker/TASKS.md`. Focus on SCHED-WORKER-16-203 (TODO). Confirm prerequisites (internal: SCHED-WORKER-16-202 (Wave 2)) before starting and report status in module TASKS.md.
|
||||||
- Team TBD: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang.DotNet/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Node/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Python/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Rust/TASKS.md`. SCANNER-ANALYZERS-LANG-10-305C/304C/309N/303C/306C are all DONE (latest 2025-10-22); remaining Wave 3 attention shifts to 10-307* helper consolidation and subsequent benchmarking tickets. Confirm prerequisites (internal: SCANNER-ANALYZERS-LANG-10-303B (Wave 2), SCANNER-ANALYZERS-LANG-10-304B (Wave 2), SCANNER-ANALYZERS-LANG-10-305B (Wave 2), SCANNER-ANALYZERS-LANG-10-306B (Wave 2), SCANNER-ANALYZERS-LANG-10-308N (Wave 2)) before scheduling new work and report status in module TASKS.md.
|
- Team TBD: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang.DotNet/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Node/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Python/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Rust/TASKS.md`. SCANNER-ANALYZERS-LANG-10-305C/304C/309N/303C/306C are all DONE (latest 2025-10-22); remaining Wave 3 attention shifts to 10-307* helper consolidation and subsequent benchmarking tickets. Confirm prerequisites (internal: SCANNER-ANALYZERS-LANG-10-303B (Wave 2), SCANNER-ANALYZERS-LANG-10-304B (Wave 2), SCANNER-ANALYZERS-LANG-10-305B (Wave 2), SCANNER-ANALYZERS-LANG-10-306B (Wave 2), SCANNER-ANALYZERS-LANG-10-308N (Wave 2)) before scheduling new work and report status in module TASKS.md.
|
||||||
- Team Zastava Observer Guild: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Zastava.Observer/TASKS.md`. ZASTAVA-OBS-12-003 closed (DONE 2025-10-24); ZASTAVA-OBS-12-004 (DONE 2025-10-24) delivered disk-backed batching. Remaining focus shifts to ZASTAVA-OBS-17-005 (DOING 2025-10-24). Confirm prerequisites (internal: ZASTAVA-OBS-12-002 (Wave 2)) before starting and keep TASKS.md in sync.
|
- Team Zastava Observer Guild: read EXECPLAN.md Wave 3 and SPRINTS.md rows for `src/StellaOps.Zastava.Observer/TASKS.md`. ZASTAVA-OBS-17-005 closed (DONE 2025-10-25); observers now emit buildIds for ELF workloads. Monitor backlog for new reachability/doc tasks and keep TASKS.md in sync.
|
||||||
|
|
||||||
### Wave 4
|
### Wave 4
|
||||||
- Team DevEx/CLI: read EXECPLAN.md Wave 4 and SPRINTS.md rows for `src/StellaOps.Cli/TASKS.md`. Focus on CLI-PLUGIN-13-007 (DONE 2025-10-22). Confirm prerequisites (internal: CLI-OFFLINE-13-006 (Wave 3), CLI-RUNTIME-13-005 (Wave 0)) before starting and report status in module TASKS.md.
|
- Team DevEx/CLI: read EXECPLAN.md Wave 4 and SPRINTS.md rows for `src/StellaOps.Cli/TASKS.md`. Focus on CLI-PLUGIN-13-007 (DONE 2025-10-22). Confirm prerequisites (internal: CLI-OFFLINE-13-006 (Wave 3), CLI-RUNTIME-13-005 (Wave 0)) before starting and report status in module TASKS.md.
|
||||||
@@ -124,7 +124,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
|
|||||||
### Wave 5
|
### Wave 5
|
||||||
- Team Excititor Connectors – Stella: read EXECPLAN.md Wave 5 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.StellaOpsMirror/TASKS.md`. Focus on EXCITITOR-CONN-STELLA-07-003 (TODO). Confirm prerequisites (internal: EXCITITOR-CONN-STELLA-07-002 (Wave 4)) before starting and report status in module TASKS.md.
|
- Team Excititor Connectors – Stella: read EXECPLAN.md Wave 5 and SPRINTS.md rows for `src/StellaOps.Excititor.Connectors.StellaOpsMirror/TASKS.md`. Focus on EXCITITOR-CONN-STELLA-07-003 (TODO). Confirm prerequisites (internal: EXCITITOR-CONN-STELLA-07-002 (Wave 4)) before starting and report status in module TASKS.md.
|
||||||
- Team Notify Connectors Guild: read EXECPLAN.md Wave 5 and SPRINTS.md rows for `src/StellaOps.Notify.Connectors.Email/TASKS.md`, `src/StellaOps.Notify.Connectors.Slack/TASKS.md`, `src/StellaOps.Notify.Connectors.Teams/TASKS.md`, `src/StellaOps.Notify.Connectors.Webhook/TASKS.md`. Focus on NOTIFY-CONN-SLACK-15-502 (DONE), NOTIFY-CONN-TEAMS-15-602 (DONE), NOTIFY-CONN-EMAIL-15-702 (BLOCKED 2025-10-20), NOTIFY-CONN-WEBHOOK-15-802 (BLOCKED 2025-10-20). Confirm prerequisites (internal: NOTIFY-CONN-EMAIL-15-701 (Wave 4), NOTIFY-CONN-SLACK-15-501 (Wave 4), NOTIFY-CONN-TEAMS-15-601 (Wave 4), NOTIFY-CONN-WEBHOOK-15-801 (Wave 4)) before starting and report status in module TASKS.md.
|
- Team Notify Connectors Guild: read EXECPLAN.md Wave 5 and SPRINTS.md rows for `src/StellaOps.Notify.Connectors.Email/TASKS.md`, `src/StellaOps.Notify.Connectors.Slack/TASKS.md`, `src/StellaOps.Notify.Connectors.Teams/TASKS.md`, `src/StellaOps.Notify.Connectors.Webhook/TASKS.md`. Focus on NOTIFY-CONN-SLACK-15-502 (DONE), NOTIFY-CONN-TEAMS-15-602 (DONE), NOTIFY-CONN-EMAIL-15-702 (BLOCKED 2025-10-20), NOTIFY-CONN-WEBHOOK-15-802 (BLOCKED 2025-10-20). Confirm prerequisites (internal: NOTIFY-CONN-EMAIL-15-701 (Wave 4), NOTIFY-CONN-SLACK-15-501 (Wave 4), NOTIFY-CONN-TEAMS-15-601 (Wave 4), NOTIFY-CONN-WEBHOOK-15-801 (Wave 4)) before starting and report status in module TASKS.md.
|
||||||
- Team Scanner WebService Guild: read EXECPLAN.md Wave 5 and SPRINTS.md rows for `src/StellaOps.Scanner.WebService/TASKS.md`. Focus on SCANNER-RUNTIME-17-401 (DOING 2025-10-24). Confirm prerequisites (internal: POLICY-RUNTIME-17-201 (Wave 4), SCANNER-EMIT-17-701 (Wave 1), SCANNER-RUNTIME-12-301 (Wave 1), ZASTAVA-OBS-17-005 (Wave 3, DOING 2025-10-24)) before starting and report status in module TASKS.md.
|
- Team Scanner WebService Guild: read EXECPLAN.md Wave 5 and SPRINTS.md rows for `src/StellaOps.Scanner.WebService/TASKS.md`. SCANNER-RUNTIME-17-401 closed 2025-10-25 with build-id persistence + policy/CLI exposure; monitor downstream dependencies (POLICY-RUNTIME-17-201, DEVOPS-REL-17-002) for reachability/debug-store follow-ups.
|
||||||
- Team TBD: read EXECPLAN.md Wave 5 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang.DotNet/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Python/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Rust/TASKS.md`. SCANNER-ANALYZERS-LANG-10-308D/G/P completed (2025-10-23/2025-10-22/2025-10-23); pending items are SCANNER-ANALYZERS-LANG-10-308R (TODO). Confirm prerequisites (internal: SCANNER-ANALYZERS-LANG-10-307D (Wave 4), SCANNER-ANALYZERS-LANG-10-307G (Wave 4), SCANNER-ANALYZERS-LANG-10-307P (Wave 4), SCANNER-ANALYZERS-LANG-10-307R (Wave 4)) before starting and report status in module TASKS.md.
|
- Team TBD: read EXECPLAN.md Wave 5 and SPRINTS.md rows for `src/StellaOps.Scanner.Analyzers.Lang.DotNet/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Go/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Python/TASKS.md`, `src/StellaOps.Scanner.Analyzers.Lang.Rust/TASKS.md`. SCANNER-ANALYZERS-LANG-10-308D/G/P completed (2025-10-23/2025-10-22/2025-10-23); pending items are SCANNER-ANALYZERS-LANG-10-308R (TODO). Confirm prerequisites (internal: SCANNER-ANALYZERS-LANG-10-307D (Wave 4), SCANNER-ANALYZERS-LANG-10-307G (Wave 4), SCANNER-ANALYZERS-LANG-10-307P (Wave 4), SCANNER-ANALYZERS-LANG-10-307R (Wave 4)) before starting and report status in module TASKS.md.
|
||||||
|
|
||||||
### Wave 6
|
### Wave 6
|
||||||
@@ -644,7 +644,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
|
|||||||
5. [TODO] UI-SCANS-13-002 — Build scans module (list/detail/SBOM/diff/attestation) with performance + accessibility targets.
|
5. [TODO] UI-SCANS-13-002 — Build scans module (list/detail/SBOM/diff/attestation) with performance + accessibility targets.
|
||||||
• Prereqs: SCANNER-WEB-09-102 (external/completed), SIGNER-API-11-101 (Wave 0)
|
• Prereqs: SCANNER-WEB-09-102 (external/completed), SIGNER-API-11-101 (Wave 0)
|
||||||
• Current: TODO
|
• Current: TODO
|
||||||
6. [DOING] UI-NOTIFY-13-006 — Notify panel: channels/rules CRUD, deliveries view, test send integration.
|
6. [DONE 2025-10-25] UI-NOTIFY-13-006 — Notify panel: channels/rules CRUD, deliveries view, test send integration.
|
||||||
• Prereqs: NOTIFY-WEB-15-101 (Wave 0)
|
• Prereqs: NOTIFY-WEB-15-101 (Wave 0)
|
||||||
• Current: TODO
|
• Current: TODO
|
||||||
7. [TODO] UI-SCHED-13-005 — Scheduler panel: schedules CRUD, run history, dry-run preview using API/mocks.
|
7. [TODO] UI-SCHED-13-005 — Scheduler panel: schedules CRUD, run history, dry-run preview using API/mocks.
|
||||||
@@ -653,7 +653,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
|
|||||||
- **Sprint 13** · Platform Reliability
|
- **Sprint 13** · Platform Reliability
|
||||||
- Team: DevOps Guild, Platform Leads
|
- Team: DevOps Guild, Platform Leads
|
||||||
- Path: `ops/devops/TASKS.md`
|
- Path: `ops/devops/TASKS.md`
|
||||||
1. [DOING 2025-10-24] DEVOPS-NUGET-13-001 — Add .NET 10 preview feeds/local mirrors so `dotnet restore` succeeds offline; document updated NuGet bootstrap.
|
1. [DONE 2025-10-25] DEVOPS-NUGET-13-001 — Add .NET 10 preview feeds/local mirrors so `dotnet restore` succeeds offline; document updated NuGet bootstrap.
|
||||||
• Prereqs: DEVOPS-REL-14-001 (Wave 1)
|
• Prereqs: DEVOPS-REL-14-001 (Wave 1)
|
||||||
• Current: DOING – Mirror preview packages into Offline Kit/allowlisted feeds, update NuGet.config mapping, and refresh restore documentation.
|
• Current: DOING – Mirror preview packages into Offline Kit/allowlisted feeds, update NuGet.config mapping, and refresh restore documentation.
|
||||||
2. [TODO] DEVOPS-UI-13-006 — Add Playwright-based UI auth smoke job to CI/offline pipelines, wiring sample `/config.json` provisioning and reporting.
|
2. [TODO] DEVOPS-UI-13-006 — Add Playwright-based UI auth smoke job to CI/offline pipelines, wiring sample `/config.json` provisioning and reporting.
|
||||||
@@ -930,9 +930,9 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
|
|||||||
- **Sprint 17** · Symbol Intelligence & Forensics
|
- **Sprint 17** · Symbol Intelligence & Forensics
|
||||||
- Team: Zastava Observer Guild
|
- Team: Zastava Observer Guild
|
||||||
- Path: `src/StellaOps.Zastava.Observer/TASKS.md`
|
- Path: `src/StellaOps.Zastava.Observer/TASKS.md`
|
||||||
1. [DOING (2025-10-24)] ZASTAVA-OBS-17-005 — Collect GNU build-id for ELF processes and attach it to emitted runtime events to enable symbol lookup + debug-store correlation.
|
1. [DONE (2025-10-25)] ZASTAVA-OBS-17-005 — Collect GNU build-id for ELF processes and attach it to emitted runtime events to enable symbol lookup + debug-store correlation.
|
||||||
• Prereqs: ZASTAVA-OBS-12-002 (Wave 2)
|
• Prereqs: ZASTAVA-OBS-12-002 (Wave 2)
|
||||||
• Current: TODO
|
• Current: DONE — Build-id capture wired through RuntimeProcessCollector + RuntimeEventFactory; docs/runbook updated with debug-store workflow.
|
||||||
|
|
||||||
## Wave 4 — 15 task(s) ready after Wave 3
|
## Wave 4 — 15 task(s) ready after Wave 3
|
||||||
- **Sprint 7** · Contextual Truth Foundations
|
- **Sprint 7** · Contextual Truth Foundations
|
||||||
@@ -945,7 +945,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
|
|||||||
- Team: Policy Guild, Scanner WebService Guild
|
- Team: Policy Guild, Scanner WebService Guild
|
||||||
- Path: `src/StellaOps.Policy/TASKS.md`
|
- Path: `src/StellaOps.Policy/TASKS.md`
|
||||||
1. [TODO] POLICY-RUNTIME-17-201 — Define runtime reachability feed contract and alignment plan for `SCANNER-RUNTIME-17-401` once Zastava endpoints land; document policy expectations for reachability tags.
|
1. [TODO] POLICY-RUNTIME-17-201 — Define runtime reachability feed contract and alignment plan for `SCANNER-RUNTIME-17-401` once Zastava endpoints land; document policy expectations for reachability tags.
|
||||||
• Prereqs: ZASTAVA-OBS-17-005 (Wave 3 — DOING 2025-10-24)
|
• Prereqs: ZASTAVA-OBS-17-005 (Wave 3 — DONE 2025-10-25)
|
||||||
• Current: TODO
|
• Current: TODO
|
||||||
- **Sprint 10** · Backlog
|
- **Sprint 10** · Backlog
|
||||||
- Team: TBD
|
- Team: TBD
|
||||||
@@ -1009,7 +1009,7 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
|
|||||||
- Team: Docs Guild
|
- Team: Docs Guild
|
||||||
- Path: `docs/TASKS.md`
|
- Path: `docs/TASKS.md`
|
||||||
1. [TODO] DOCS-RUNTIME-17-004 — Document build-id workflows: SBOM exposure, runtime event payloads, debug-store layout, and operator guidance for symbol retrieval.
|
1. [TODO] DOCS-RUNTIME-17-004 — Document build-id workflows: SBOM exposure, runtime event payloads, debug-store layout, and operator guidance for symbol retrieval.
|
||||||
• Prereqs: SCANNER-EMIT-17-701 (Wave 1), ZASTAVA-OBS-17-005 (Wave 3 — DOING 2025-10-24), DEVOPS-REL-17-002 (Wave 2)
|
• Prereqs: SCANNER-EMIT-17-701 (Wave 1), ZASTAVA-OBS-17-005 (Wave 3 — DONE 2025-10-25), DEVOPS-REL-17-002 (Wave 2)
|
||||||
• Current: TODO
|
• Current: TODO
|
||||||
|
|
||||||
## Wave 5 — 10 task(s) ready after Wave 4
|
## Wave 5 — 10 task(s) ready after Wave 4
|
||||||
@@ -1052,9 +1052,9 @@ Generated from SPRINTS.md and module TASKS.md files on 2025-10-19. Waves cluster
|
|||||||
- **Sprint 17** · Symbol Intelligence & Forensics
|
- **Sprint 17** · Symbol Intelligence & Forensics
|
||||||
- Team: Scanner WebService Guild
|
- Team: Scanner WebService Guild
|
||||||
- Path: `src/StellaOps.Scanner.WebService/TASKS.md`
|
- Path: `src/StellaOps.Scanner.WebService/TASKS.md`
|
||||||
1. [TODO] SCANNER-RUNTIME-17-401 — Persist runtime build-id observations and expose them via `/runtime/events` + policy joins for debug-symbol correlation.
|
1. [DONE 2025-10-25] SCANNER-RUNTIME-17-401 — Persist runtime build-id observations and expose them via `/runtime/events` + policy joins for debug-symbol correlation.
|
||||||
• Prereqs: SCANNER-RUNTIME-12-301 (Wave 1), ZASTAVA-OBS-17-005 (Wave 3 — DOING 2025-10-24), SCANNER-EMIT-17-701 (Wave 1), POLICY-RUNTIME-17-201 (Wave 4)
|
• Prereqs: SCANNER-RUNTIME-12-301 (Wave 1), ZASTAVA-OBS-17-005 (Wave 3 — DONE 2025-10-25), SCANNER-EMIT-17-701 (Wave 1), POLICY-RUNTIME-17-201 (Wave 4)
|
||||||
• Current: TODO
|
• Current: DONE — runtime events normalize digests/build IDs, policy responses/CLI emit `buildIds`, docs/tests updated for debug-store workflows.
|
||||||
|
|
||||||
## Wave 6 — 8 task(s) ready after Wave 5
|
## Wave 6 — 8 task(s) ready after Wave 5
|
||||||
- **Sprint 10** · Backlog
|
- **Sprint 10** · Backlog
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<packageSources>
|
<packageSources>
|
||||||
<clear />
|
<clear />
|
||||||
<add key="local" value="local-nuget" />
|
<add key="local" value="local-nuget" />
|
||||||
|
<add key="dotnet-public" value="https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json" />
|
||||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||||
</packageSources>
|
</packageSources>
|
||||||
<packageSourceMapping>
|
<packageSourceMapping>
|
||||||
@@ -30,6 +31,11 @@
|
|||||||
<package pattern="System.Memory" />
|
<package pattern="System.Memory" />
|
||||||
<package pattern="System.Runtime.CompilerServices.Unsafe" />
|
<package pattern="System.Runtime.CompilerServices.Unsafe" />
|
||||||
</packageSource>
|
</packageSource>
|
||||||
|
<packageSource key="dotnet-public">
|
||||||
|
<package pattern="Microsoft.Extensions.*" />
|
||||||
|
<package pattern="Microsoft.AspNetCore.*" />
|
||||||
|
<package pattern="Microsoft.Data.Sqlite" />
|
||||||
|
</packageSource>
|
||||||
<packageSource key="nuget.org">
|
<packageSource key="nuget.org">
|
||||||
<package pattern="*" />
|
<package pattern="*" />
|
||||||
</packageSource>
|
</packageSource>
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
|
|||||||
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-VEX-13-003 | Implement VEX explorer + policy editor with preview integration. |
|
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-VEX-13-003 | Implement VEX explorer + policy editor with preview integration. |
|
||||||
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-ADMIN-13-004 | Deliver admin area (tenants/clients/quotas/licensing) with RBAC + audit hooks. |
|
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-ADMIN-13-004 | Deliver admin area (tenants/clients/quotas/licensing) with RBAC + audit hooks. |
|
||||||
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-SCHED-13-005 | Scheduler panel: schedules CRUD, run history, dry-run preview. |
|
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | TODO | UI Guild | UI-SCHED-13-005 | Scheduler panel: schedules CRUD, run history, dry-run preview. |
|
||||||
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | DOING (2025-10-19) | UI Guild | UI-NOTIFY-13-006 | Notify panel: channels/rules CRUD, deliveries view, test send. |
|
| Sprint 13 | UX & CLI Experience | src/StellaOps.UI/TASKS.md | DONE (2025-10-25) | UI Guild | UI-NOTIFY-13-006 | Notify panel: channels/rules CRUD, deliveries view, test send. |
|
||||||
| Sprint 13 | UX & CLI Experience | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI | CLI-RUNTIME-13-005 | Add runtime policy test verbs that consume `/policy/runtime` and display verdicts. |
|
| Sprint 13 | UX & CLI Experience | src/StellaOps.Cli/TASKS.md | TODO | DevEx/CLI | CLI-RUNTIME-13-005 | Add runtime policy test verbs that consume `/policy/runtime` and display verdicts. |
|
||||||
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | DOING (2025-10-24) | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-001 | Wire up .NET 10 preview feeds/local mirrors so `dotnet restore` succeeds offline; document updated NuGet bootstrap. |
|
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | DONE (2025-10-25) | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-001 | Wire up .NET 10 preview feeds/local mirrors so `dotnet restore` succeeds offline; document updated NuGet bootstrap. |
|
||||||
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-NUGET-13-002 | Ensure all solutions/projects prioritize `local-nuget` before public feeds and add restore-order validation. |
|
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-NUGET-13-002 | Ensure all solutions/projects prioritize `local-nuget` before public feeds and add restore-order validation. |
|
||||||
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | TODO | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-003 | Upgrade `Microsoft.*` dependencies pinned to 8.* to their latest .NET 10 (or 9.x) releases and refresh guidance. |
|
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | TODO | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-003 | Upgrade `Microsoft.*` dependencies pinned to 8.* to their latest .NET 10 (or 9.x) releases and refresh guidance. |
|
||||||
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | TODO | DevOps Guild, UI Guild | DEVOPS-UI-13-006 | Add Playwright-based UI auth smoke job to CI/offline pipelines, wiring sample `/config.json` provisioning and reporting. |
|
| Sprint 13 | Platform Reliability | ops/devops/TASKS.md | TODO | DevOps Guild, UI Guild | DEVOPS-UI-13-006 | Add Playwright-based UI auth smoke job to CI/offline pipelines, wiring sample `/config.json` provisioning and reporting. |
|
||||||
@@ -86,8 +86,8 @@ This file describe implementation of Stella Ops (docs/README.md). Implementation
|
|||||||
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-16-205 | Metrics/telemetry for Scheduler planners/runners. |
|
| Sprint 16 | Scheduler Intelligence | src/StellaOps.Scheduler.Worker/TASKS.md | TODO | Scheduler Worker Guild | SCHED-WORKER-16-205 | Metrics/telemetry for Scheduler planners/runners. |
|
||||||
| Sprint 16 | Benchmarks | bench/TASKS.md | TODO | Bench Guild, Scheduler Team | BENCH-IMPACT-16-001 | ImpactIndex throughput bench + RAM profile. |
|
| Sprint 16 | Benchmarks | bench/TASKS.md | TODO | Bench Guild, Scheduler Team | BENCH-IMPACT-16-001 | ImpactIndex throughput bench + RAM profile. |
|
||||||
| Sprint 17 | Symbol Intelligence & Forensics | src/StellaOps.Scanner.Emit/TASKS.md | TODO | Emit Guild | SCANNER-EMIT-17-701 | Record GNU build-id for ELF components and surface it in SBOM/diff outputs. |
|
| Sprint 17 | Symbol Intelligence & Forensics | src/StellaOps.Scanner.Emit/TASKS.md | TODO | Emit Guild | SCANNER-EMIT-17-701 | Record GNU build-id for ELF components and surface it in SBOM/diff outputs. |
|
||||||
| Sprint 17 | Symbol Intelligence & Forensics | src/StellaOps.Zastava.Observer/TASKS.md | DOING (2025-10-24) | Zastava Observer Guild | ZASTAVA-OBS-17-005 | Collect GNU build-id during runtime observation and attach it to emitted events. |
|
| Sprint 17 | Symbol Intelligence & Forensics | src/StellaOps.Zastava.Observer/TASKS.md | DONE (2025-10-25) | Zastava Observer Guild | ZASTAVA-OBS-17-005 | Collect GNU build-id during runtime observation and attach it to emitted events. |
|
||||||
| Sprint 17 | Symbol Intelligence & Forensics | src/StellaOps.Scanner.WebService/TASKS.md | DOING (2025-10-24) | Scanner WebService Guild | SCANNER-RUNTIME-17-401 | Persist runtime build-id observations and expose them for debug-symbol correlation. |
|
| Sprint 17 | Symbol Intelligence & Forensics | src/StellaOps.Scanner.WebService/TASKS.md | DONE (2025-10-25) | Scanner WebService Guild | SCANNER-RUNTIME-17-401 | Persist runtime build-id observations and expose them for debug-symbol correlation. |
|
||||||
| Sprint 17 | Symbol Intelligence & Forensics | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-REL-17-002 | Ship stripped debug artifacts organised by build-id within release/offline kits. |
|
| Sprint 17 | Symbol Intelligence & Forensics | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-REL-17-002 | Ship stripped debug artifacts organised by build-id within release/offline kits. |
|
||||||
| Sprint 17 | Symbol Intelligence & Forensics | docs/TASKS.md | TODO | Docs Guild | DOCS-RUNTIME-17-004 | Document build-id workflows for SBOMs, runtime events, and debug-store usage. |
|
| Sprint 17 | Symbol Intelligence & Forensics | docs/TASKS.md | TODO | Docs Guild | DOCS-RUNTIME-17-004 | Document build-id workflows for SBOMs, runtime events, and debug-store usage. |
|
||||||
| Sprint 18 | Launch Readiness | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-LAUNCH-18-001 | Production launch cutover rehearsal and runbook publication (blocked on implementation sign-off and environment setup). |
|
| Sprint 18 | Launch Readiness | ops/devops/TASKS.md | TODO | DevOps Guild | DEVOPS-LAUNCH-18-001 | Production launch cutover rehearsal and runbook publication (blocked on implementation sign-off and environment setup). |
|
||||||
|
|||||||
17
TODOS.md
17
TODOS.md
@@ -1,12 +1,5 @@
|
|||||||
# Current Focus – FEEDCONN-CERTCC
|
1. Concelier must never merge, just aggregate. The advisories must be linked, but their severity will be considered through policies from the Web part of the scanner.
|
||||||
| Task | Status | Notes |
|
2. Same for Excititor
|
||||||
|---|---|---|
|
3. Why Web and UI?
|
||||||
|FEEDCONN-CERTCC-02-005 Deterministic fixtures/tests|DONE (2025-10-11)|Snapshot regression for summary/detail fetch landed; fixtures regenerate via `UPDATE_CERTCC_FIXTURES`.|
|
4. Consider all of functionality there is. Is the Cli for all of it? Is there a Web for all of it? Do we have Policy editor? Does the policy support VEX application rules? Advisories application Rules? Do have SBOM graphs explorer? Do we have good SBOM vulnerability explorer? Do we have advisory using AI?
|
||||||
|FEEDCONN-CERTCC-02-008 Snapshot coverage handoff|DONE (2025-10-11)|Fixtures + README guidance shipped; QA can rerun with `UPDATE_CERTCC_FIXTURES=1` and share recorded-request diff with Merge.|
|
5. Do we have build docker containers that are downloadable? page describign how to download and install?
|
||||||
|FEEDCONN-CERTCC-02-007 Connector test harness remediation|DONE (2025-10-11)|Harness now resets time provider, wires Source.Common, and verifies VINCE canned responses across fetch→parse→map.|
|
|
||||||
|FEEDCONN-CERTCC-02-009 Detail/map reintegration plan|DONE (2025-10-11)|Plan published in `src/StellaOps.Concelier.Connector.CertCc/FEEDCONN-CERTCC-02-009_PLAN.md`; outlines staged enablement + rollback.|
|
|
||||||
|
|
||||||
# Connector Apple Status
|
|
||||||
| Task | Status | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
|FEEDCONN-APPLE-02-003 Telemetry & documentation|DONE (2025-10-11)|Apple connector meter registered with WebService OpenTelemetry metrics; README and fixtures highlight normalizedVersions coverage for conflict sprint handoff.|
|
|
||||||
@@ -565,7 +565,7 @@ Content-Type: application/json
|
|||||||
"containerId": "containerd://bead5...",
|
"containerId": "containerd://bead5...",
|
||||||
"imageRef": "ghcr.io/acme/api@sha256:deadbeef"
|
"imageRef": "ghcr.io/acme/api@sha256:deadbeef"
|
||||||
},
|
},
|
||||||
"process": { "pid": 12345, "entrypoint": ["/start.sh", "--serve"] },
|
"process": { "pid": 12345, "entrypoint": ["/start.sh", "--serve"], "buildId": "5f0c7c3c..." },
|
||||||
"loadedLibs": [
|
"loadedLibs": [
|
||||||
{ "path": "/lib/x86_64-linux-gnu/libssl.so.3", "inode": 123456, "sha256": "abc123..." }
|
{ "path": "/lib/x86_64-linux-gnu/libssl.so.3", "inode": 123456, "sha256": "abc123..." }
|
||||||
],
|
],
|
||||||
@@ -627,7 +627,7 @@ See `docs/dev/32_AUTH_CLIENT_GUIDE.md` for recommended profiles (online vs. air-
|
|||||||
| `stellaops-cli offline kit import` | Upload an offline kit bundle to the backend | `<bundle.tgz>` (argument)<br>`--manifest <path>`<br>`--bundle-signature <path>`<br>`--manifest-signature <path>` | Validates digests when metadata is present, then posts multipart payloads to `POST /api/offline-kit/import`; logs the submitted import ID/status for air-gapped rollout tracking. |
|
| `stellaops-cli offline kit import` | Upload an offline kit bundle to the backend | `<bundle.tgz>` (argument)<br>`--manifest <path>`<br>`--bundle-signature <path>`<br>`--manifest-signature <path>` | Validates digests when metadata is present, then posts multipart payloads to `POST /api/offline-kit/import`; logs the submitted import ID/status for air-gapped rollout tracking. |
|
||||||
| `stellaops-cli offline kit status` | Display imported offline kit details | `--json` | Shows bundle id/kind, captured/imported timestamps, digests, and component versions; `--json` emits machine-readable output for scripting. |
|
| `stellaops-cli offline kit status` | Display imported offline kit details | `--json` | Shows bundle id/kind, captured/imported timestamps, digests, and component versions; `--json` emits machine-readable output for scripting. |
|
||||||
| `stellaops-cli config show` | Display resolved configuration | — | Masks secret values; helpful for air‑gapped installs |
|
| `stellaops-cli config show` | Display resolved configuration | — | Masks secret values; helpful for air‑gapped installs |
|
||||||
| `stellaops-cli runtime policy test` | Ask Scanner.WebService for runtime verdicts (Webhook parity) | `--image/-i <digest>` (repeatable, comma/space lists supported)<br>`--file/-f <path>`<br>`--namespace/--ns <name>`<br>`--label/-l key=value` (repeatable)<br>`--json` | Posts to `POST /api/v1/scanner/policy/runtime`, deduplicates image digests, and prints TTL/policy revision plus per-image columns for signed state, SBOM referrers, quieted-by metadata, confidence, and Rekor attestation (uuid + verified flag). Accepts newline/whitespace-delimited stdin when piped; `--json` emits the raw response without additional logging. |
|
| `stellaops-cli runtime policy test` | Ask Scanner.WebService for runtime verdicts (Webhook parity) | `--image/-i <digest>` (repeatable, comma/space lists supported)<br>`--file/-f <path>`<br>`--namespace/--ns <name>`<br>`--label/-l key=value` (repeatable)<br>`--json` | Posts to `POST /api/v1/scanner/policy/runtime`, deduplicates image digests, and prints TTL/policy revision plus per-image columns for signed state, SBOM referrers, quieted-by metadata, confidence, Rekor attestation (uuid + verified flag), and recently observed build IDs (shortened for readability). Accepts newline/whitespace-delimited stdin when piped; `--json` emits the raw response without additional logging. |
|
||||||
|
|
||||||
`POST /api/v1/scanner/policy/runtime` responds with one entry per digest. Each result now includes:
|
`POST /api/v1/scanner/policy/runtime` responds with one entry per digest. Each result now includes:
|
||||||
|
|
||||||
@@ -635,6 +635,7 @@ See `docs/dev/32_AUTH_CLIENT_GUIDE.md` for recommended profiles (online vs. air-
|
|||||||
- `confidence` (0-1 double) derived from canonical `PolicyPreviewService` evaluation and `quieted`/`quietedBy` flags for muted findings.
|
- `confidence` (0-1 double) derived from canonical `PolicyPreviewService` evaluation and `quieted`/`quietedBy` flags for muted findings.
|
||||||
- `rekor` block carrying `uuid`, `url`, and the attestor-backed `verified` boolean when Rekor inclusion proofs have been confirmed.
|
- `rekor` block carrying `uuid`, `url`, and the attestor-backed `verified` boolean when Rekor inclusion proofs have been confirmed.
|
||||||
- `metadata` (stringified JSON) capturing runtime heuristics, policy issues, evaluated findings, and timestamps for downstream audit.
|
- `metadata` (stringified JSON) capturing runtime heuristics, policy issues, evaluated findings, and timestamps for downstream audit.
|
||||||
|
- `buildIds` (array) lists up to three distinct GNU build-id hashes recently observed for that digest so debuggers can derive `/usr/lib/debug/.build-id/<aa>/<rest>.debug` paths for symbol stores.
|
||||||
|
|
||||||
When running on an interactive terminal without explicit override flags, the CLI uses Spectre.Console prompts to let you choose per-run ORAS/offline bundle behaviour.
|
When running on an interactive terminal without explicit override flags, the CLI uses Spectre.Console prompts to let you choose per-run ORAS/offline bundle behaviour.
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,18 @@ Lists discovered UI plugins; each can inject routes/panels. Toggle on/off withou
|
|||||||
* **Generate Offline Token** – admin‑only button → POST `/token/offline` (UI wraps the API).
|
* **Generate Offline Token** – admin‑only button → POST `/token/offline` (UI wraps the API).
|
||||||
* Upload new token file for manual refresh.
|
* Upload new token file for manual refresh.
|
||||||
|
|
||||||
|
### 3.7 Notifications Panel (new)
|
||||||
|
|
||||||
|
Route: **`/notify`** (header shortcut “Notify”). The panel now exposes every Notify control-plane primitive without depending on the backend being online.
|
||||||
|
|
||||||
|
| Area | What you can do |
|
||||||
|
| --- | --- |
|
||||||
|
| **Channels** | Create/edit Slack/Teams/Email/Webhook channels, toggle enablement, maintain labels/metadata, and execute **test send** previews. Channel health cards show mocked status + trace IDs so ops can validate wiring before Notify.WebService is reachable. |
|
||||||
|
| **Rules** | Manage routing rules (matchers, severity gates, throttles/digests, locale hints). A single-action form keeps Signal-style configuration quick while mirroring Notify schema (`match`, `actions[]`). |
|
||||||
|
| **Deliveries** | Browsable ledger with status filter (All/Sent/Failed/Throttled/…), showing targets, kinds, and timestamps so operators confirm noise controls. |
|
||||||
|
|
||||||
|
The component leans on the mocked Notify API service in `src/app/testing/mock-notify-api.service.ts`, meaning Offline Kit demos run instantly yet the view stays API-shaped (same DTOs + tenant header expectations).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4 i18n & l10n
|
## 4 i18n & l10n
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
| PLATFORM-EVENTS-09-401 | DONE (2025-10-21) | Platform Events Guild | DOCS-EVENTS-09-003 | Embed canonical event samples into contract/integration tests and ensure CI validates payloads against published schemas. | Notify models tests now run schema validation against `docs/events/*.json`, event schemas allow optional `attributes`, and docs capture the new validation workflow. |
|
| PLATFORM-EVENTS-09-401 | DONE (2025-10-21) | Platform Events Guild | DOCS-EVENTS-09-003 | Embed canonical event samples into contract/integration tests and ensure CI validates payloads against published schemas. | Notify models tests now run schema validation against `docs/events/*.json`, event schemas allow optional `attributes`, and docs capture the new validation workflow. |
|
||||||
| RUNTIME-GUILD-09-402 | DONE (2025-10-19) | Runtime Guild | SCANNER-POLICY-09-107 | Confirm Scanner WebService surfaces `quietedFindingCount` and progress hints to runtime consumers; document readiness checklist. | Runtime verification run captures enriched payload; checklist/doc updates merged; stakeholders acknowledge availability. |
|
| RUNTIME-GUILD-09-402 | DONE (2025-10-19) | Runtime Guild | SCANNER-POLICY-09-107 | Confirm Scanner WebService surfaces `quietedFindingCount` and progress hints to runtime consumers; document readiness checklist. | Runtime verification run captures enriched payload; checklist/doc updates merged; stakeholders acknowledge availability. |
|
||||||
| DOCS-CONCELIER-07-201 | DONE (2025-10-22) | Docs Guild, Concelier WebService | FEEDWEB-DOCS-01-001 | Final editorial review and publish pass for Concelier authority toggle documentation (Quickstart + operator guide). | Review feedback resolved, publish PR merged, release notes updated with documentation pointer. |
|
| DOCS-CONCELIER-07-201 | DONE (2025-10-22) | Docs Guild, Concelier WebService | FEEDWEB-DOCS-01-001 | Final editorial review and publish pass for Concelier authority toggle documentation (Quickstart + operator guide). | Review feedback resolved, publish PR merged, release notes updated with documentation pointer. |
|
||||||
| DOCS-RUNTIME-17-004 | TODO | Docs Guild, Runtime Guild | SCANNER-EMIT-17-701, ZASTAVA-OBS-17-005, DEVOPS-REL-17-002 | Document build-id workflows: SBOM exposure, runtime event payloads, debug-store layout, and operator guidance for symbol retrieval. | Architecture + operator docs updated with build-id sections, examples show `readelf` output + debuginfod usage, references linked from Offline Kit/Release guides. |
|
| DOCS-RUNTIME-17-004 | TODO | Docs Guild, Runtime Guild | SCANNER-EMIT-17-701, ZASTAVA-OBS-17-005, DEVOPS-REL-17-002 | Document build-id workflows: SBOM exposure, runtime event payloads (`process.buildId`), Scanner `/policy/runtime` response (`buildIds` list), debug-store layout, and operator guidance for symbol retrieval. | Architecture + operator docs updated with build-id sections (Observer, Scanner, CLI), examples show `readelf` output + debuginfod usage, references linked from Offline Kit/Release guides + CLI help. |
|
||||||
|
|
||||||
> Update statuses (TODO/DOING/REVIEW/DONE/BLOCKED) as progress changes. Keep guides in sync with configuration samples under `etc/`.
|
> Update statuses (TODO/DOING/REVIEW/DONE/BLOCKED) as progress changes. Keep guides in sync with configuration samples under `etc/`.
|
||||||
|
|
||||||
|
|||||||
49
docs/ops/nuget-preview-bootstrap.md
Normal file
49
docs/ops/nuget-preview-bootstrap.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# NuGet Preview Bootstrap (Offline-Friendly)
|
||||||
|
|
||||||
|
The StellaOps build relies on .NET 10 preview packages (Microsoft.Extensions.*, JwtBearer 10.0 RC).
|
||||||
|
`NuGet.config` now wires three sources:
|
||||||
|
|
||||||
|
1. `local` → `./local-nuget` (preferred, air-gapped mirror)
|
||||||
|
2. `dotnet-public` → `https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/index.json`
|
||||||
|
3. `nuget.org` → fallback for everything else
|
||||||
|
|
||||||
|
Follow the steps below whenever you refresh the repo or roll a new Offline Kit drop.
|
||||||
|
|
||||||
|
## 1. Mirror the preview packages
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./ops/devops/sync-preview-nuget.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
* Reads `ops/devops/nuget-preview-packages.csv`. Each line specifies the package, version, expected SHA-256 hash, and (optionally) the flat-container base URL (we pin to `dotnet-public`).
|
||||||
|
* Downloads the `.nupkg` straight into `./local-nuget/` and re-verifies the checksum. Existing files are skipped when hashes already match.
|
||||||
|
* Use `NUGET_V2_BASE` if you need to temporarily point at a different mirror.
|
||||||
|
|
||||||
|
💡 The script never mutates packages in place—if a checksum changes you will see a “SHA mismatch … refreshing” message.
|
||||||
|
|
||||||
|
## 2. Restore using the shared `NuGet.config`
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOTNET_NOLOGO=1 dotnet restore src/StellaOps.Excititor.Connectors.Abstractions/StellaOps.Excititor.Connectors.Abstractions.csproj \
|
||||||
|
--configfile NuGet.config
|
||||||
|
```
|
||||||
|
|
||||||
|
The `packageSourceMapping` section keeps `Microsoft.Extensions.*`, `Microsoft.AspNetCore.*`, and `Microsoft.Data.Sqlite` bound to `local`/`dotnet-public`, so `dotnet restore` never has to reach out to nuget.org when mirrors are populated.
|
||||||
|
|
||||||
|
If you run fully air-gapped, remember to clear the cache between SDK upgrades:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dotnet nuget locals all --clear
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Fix |
|
||||||
|
| --- | --- |
|
||||||
|
| `dotnet restore` still hits nuget.org for preview packages | Re-run `sync-preview-nuget.sh` to ensure the `.nupkg` exists locally, then delete `~/.nuget/packages/microsoft.extensions.*` so the resolver picks up the mirrored copy. |
|
||||||
|
| SHA mismatch in the manifest | Update `ops/devops/nuget-preview-packages.csv` with the new version + checksum (from the feed) and re-run the sync script. |
|
||||||
|
| Azure DevOps feed throttling | Set `DOTNET_PUBLIC_FLAT_BASE` env var and point it at your own mirrored flat-container, then add the URL to the 4th column of the manifest. |
|
||||||
|
|
||||||
|
Keep this doc alongside Offline Kit instructions so air-gapped operators know exactly how to refresh the mirror and verify packages before restore.
|
||||||
@@ -129,3 +129,32 @@ It aligns with `Sprint 12 – Runtime Guardrails` and assumes components consume
|
|||||||
- Grafana dashboard JSON: `docs/ops/zastava-runtime-grafana-dashboard.json`.
|
- Grafana dashboard JSON: `docs/ops/zastava-runtime-grafana-dashboard.json`.
|
||||||
- Add both to the monitoring repo (`ops/monitoring/zastava`) and reference them in
|
- Add both to the monitoring repo (`ops/monitoring/zastava`) and reference them in
|
||||||
the Offline Kit manifest.
|
the Offline Kit manifest.
|
||||||
|
|
||||||
|
## 7. Build-id correlation & symbol retrieval
|
||||||
|
|
||||||
|
Runtime events emitted by Observer now include `process.buildId` (from the ELF
|
||||||
|
`NT_GNU_BUILD_ID` note) and Scanner `/policy/runtime` surfaces the most recent
|
||||||
|
`buildIds` list per digest. Operators can use these hashes to locate debug
|
||||||
|
artifacts during incident response:
|
||||||
|
|
||||||
|
1. Capture the hash from CLI/webhook/Scanner API (example:
|
||||||
|
`5f0c7c3cb4d9f8a4f1c1d5c6b7e8f90123456789`).
|
||||||
|
2. Derive the path: `<hash[0:2]>/<hash[2:]>` under the debug store, e.g.
|
||||||
|
`/var/opt/debug/.build-id/5f/0c7c3cb4d9f8a4f1c1d5c6b7e8f90123456789.debug`.
|
||||||
|
3. If the file is missing, rehydrate it from Offline Kit bundles or the
|
||||||
|
`debug-store` object bucket (mirror of release artefacts). Use:
|
||||||
|
```sh
|
||||||
|
oras cp oci://registry.internal/debug-store:latest . --include \
|
||||||
|
"5f/0c7c3cb4d9f8a4f1c1d5c6b7e8f90123456789.debug"
|
||||||
|
```
|
||||||
|
4. Attach the `.debug` file in `gdb`/`lldb` or feed it to `eu-unstrip` when
|
||||||
|
preparing symbolized traces.
|
||||||
|
5. For musl-based images, expect shorter build-id footprints. Missing hashes in
|
||||||
|
runtime events indicate stripped binaries without the GNU note—schedule a
|
||||||
|
rebuild with `-Wl,--build-id` enabled or add the binary to the debug-store
|
||||||
|
allowlist so the scanner can surface a fallback symbol package.
|
||||||
|
|
||||||
|
Monitor `scanner.policy.runtime` responses for the `buildIds` field; absence of
|
||||||
|
data after ZASTAVA-OBS-17-005 implies containers launched before the Observer
|
||||||
|
upgrade or non-ELF entrypoints (static scripts). Re-run the workload or restart
|
||||||
|
Observer to trigger a fresh capture if symbol parity is required.
|
||||||
|
|||||||
@@ -39,3 +39,16 @@ As part of **DEVOPS-UI-13-006** the pipelines will execute the UI auth smoke
|
|||||||
tests (`npm run test:e2e`) after building the Angular bundle. See
|
tests (`npm run test:e2e`) after building the Angular bundle. See
|
||||||
`docs/ops/ui-auth-smoke.md` for the job design, environment stubs, and
|
`docs/ops/ui-auth-smoke.md` for the job design, environment stubs, and
|
||||||
offline runner considerations.
|
offline runner considerations.
|
||||||
|
|
||||||
|
## NuGet preview bootstrap
|
||||||
|
|
||||||
|
`.NET 10` preview packages (Microsoft.Extensions.*, JwtBearer 10.0 RC, Sqlite 9 RC)
|
||||||
|
ship from the public `dotnet-public` Azure DevOps feed. We mirror them into
|
||||||
|
`./local-nuget` so restores succeed inside Offline Kit.
|
||||||
|
|
||||||
|
1. Run `./ops/devops/sync-preview-nuget.sh` whenever you update the manifest.
|
||||||
|
2. The script now understands the optional `SourceBase` column (V3 flat container)
|
||||||
|
and writes packages alongside their SHA-256 checks.
|
||||||
|
3. `NuGet.config` registers the mirror (`local`), dotnet-public, and nuget.org.
|
||||||
|
|
||||||
|
Detailed operator instructions live in `docs/ops/nuget-preview-bootstrap.md`.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
| DEVOPS-LAUNCH-18-100 | TODO | DevOps Guild | - | Finalise production environment footprint (clusters, secrets, network overlays) for full-platform go-live. | IaC/compose overlays committed, secrets placeholders documented, dry-run deploy succeeds in staging. |
|
| DEVOPS-LAUNCH-18-100 | TODO | DevOps Guild | - | Finalise production environment footprint (clusters, secrets, network overlays) for full-platform go-live. | IaC/compose overlays committed, secrets placeholders documented, dry-run deploy succeeds in staging. |
|
||||||
| DEVOPS-LAUNCH-18-900 | TODO | DevOps Guild, Module Leads | Wave 0 completion | Collect “full implementation” sign-off from module owners and consolidate launch readiness checklist. | Sign-off record stored under `docs/ops/launch-readiness.md`; outstanding gaps triaged; checklist approved. |
|
| DEVOPS-LAUNCH-18-900 | TODO | DevOps Guild, Module Leads | Wave 0 completion | Collect “full implementation” sign-off from module owners and consolidate launch readiness checklist. | Sign-off record stored under `docs/ops/launch-readiness.md`; outstanding gaps triaged; checklist approved. |
|
||||||
| DEVOPS-LAUNCH-18-001 | TODO | DevOps Guild | DEVOPS-LAUNCH-18-100, DEVOPS-LAUNCH-18-900 | Production launch cutover rehearsal and runbook publication. | `docs/ops/launch-cutover.md` drafted, rehearsal executed with rollback drill, approvals captured. |
|
| DEVOPS-LAUNCH-18-001 | TODO | DevOps Guild | DEVOPS-LAUNCH-18-100, DEVOPS-LAUNCH-18-900 | Production launch cutover rehearsal and runbook publication. | `docs/ops/launch-cutover.md` drafted, rehearsal executed with rollback drill, approvals captured. |
|
||||||
| DEVOPS-NUGET-13-001 | DOING (2025-10-24) | DevOps Guild, Platform Leads | DEVOPS-REL-14-001 | Add .NET 10 preview feeds / local mirrors so `Microsoft.Extensions.*` 10.0 preview packages restore offline; refresh restore docs. | NuGet.config maps preview feeds (or local mirrored packages), `dotnet restore` succeeds for Excititor/Concelier solutions without ad-hoc feed edits, docs updated for offline bootstrap. |
|
| DEVOPS-NUGET-13-001 | DONE (2025-10-25) | DevOps Guild, Platform Leads | DEVOPS-REL-14-001 | Add .NET 10 preview feeds / local mirrors so `Microsoft.Extensions.*` 10.0 preview packages restore offline; refresh restore docs. | NuGet.config maps preview feeds (or local mirrored packages), `dotnet restore` succeeds for Excititor/Concelier solutions without ad-hoc feed edits, docs updated for offline bootstrap. |
|
||||||
| DEVOPS-NUGET-13-002 | TODO | DevOps Guild | DEVOPS-NUGET-13-001 | Ensure all solutions/projects prefer `local-nuget` before public sources and document restore order validation. | `NuGet.config` and solution-level configs resolve from `local-nuget` first; automated check verifies priority; docs updated for restore ordering. |
|
| DEVOPS-NUGET-13-002 | TODO | DevOps Guild | DEVOPS-NUGET-13-001 | Ensure all solutions/projects prefer `local-nuget` before public sources and document restore order validation. | `NuGet.config` and solution-level configs resolve from `local-nuget` first; automated check verifies priority; docs updated for restore ordering. |
|
||||||
| DEVOPS-NUGET-13-003 | TODO | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-002 | Sweep `Microsoft.*` NuGet dependencies pinned to 8.* and upgrade to latest .NET 10 equivalents (or .NET 9 when 10 unavailable), updating restore guidance. | Dependency audit shows no 8.* `Microsoft.*` packages remaining; CI builds green; changelog/doc sections capture upgrade rationale. |
|
| DEVOPS-NUGET-13-003 | TODO | DevOps Guild, Platform Leads | DEVOPS-NUGET-13-002 | Sweep `Microsoft.*` NuGet dependencies pinned to 8.* and upgrade to latest .NET 10 equivalents (or .NET 9 when 10 unavailable), updating restore guidance. | Dependency audit shows no 8.* `Microsoft.*` packages remaining; CI builds green; changelog/doc sections capture upgrade rationale. |
|
||||||
| DEVOPS-UI-13-006 | TODO | DevOps Guild, UI Guild | UI-AUTH-13-001 | Add Playwright-based UI auth smoke job to CI/offline pipelines, wiring sample `/config.json` provisioning and reporting. | CI + Offline Kit run Playwright auth smoke (headless Chromium) post-build; job reuses stub config artifact, exports junit + trace on failure, docs updated under `docs/ops/ui-auth-smoke.md`. |
|
| DEVOPS-UI-13-006 | TODO | DevOps Guild, UI Guild | UI-AUTH-13-001 | Add Playwright-based UI auth smoke job to CI/offline pipelines, wiring sample `/config.json` provisioning and reporting. | CI + Offline Kit run Playwright auth smoke (headless Chromium) post-build; job reuses stub config artifact, exports junit + trace on failure, docs updated under `docs/ops/ui-auth-smoke.md`. |
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
# Package,Version,SHA256
|
# Package,Version,SHA256,SourceBase(optional)
|
||||||
Microsoft.Extensions.Caching.Memory,10.0.0-preview.7.25380.108,8721fd1420fea6e828963c8343cd83605902b663385e8c9060098374139f9b2f
|
# DotNetPublicFlat=https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2
|
||||||
Microsoft.Extensions.Configuration,10.0.0-preview.7.25380.108,5a17ba4ba47f920a04ae51d80560833da82a0926d1e462af0d11c16b5da969f4
|
Microsoft.Extensions.Caching.Memory,10.0.0-preview.7.25380.108,8721fd1420fea6e828963c8343cd83605902b663385e8c9060098374139f9b2f,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2
|
||||||
Microsoft.Extensions.Configuration.Binder,10.0.0-preview.7.25380.108,5a3af17729241e205fe8fbb1d458470e9603935ab2eb67cbbb06ce51265ff68f
|
Microsoft.Extensions.Configuration,10.0.0-preview.7.25380.108,5a17ba4ba47f920a04ae51d80560833da82a0926d1e462af0d11c16b5da969f4,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2
|
||||||
Microsoft.Extensions.DependencyInjection.Abstractions,10.0.0-preview.7.25380.108,1e9cd330d7833a3a850a7a42bbe0c729906c60bf1c359ad30a8622b50da4399b
|
Microsoft.Extensions.Configuration.Binder,10.0.0-preview.7.25380.108,5a3af17729241e205fe8fbb1d458470e9603935ab2eb67cbbb06ce51265ff68f,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2
|
||||||
Microsoft.Extensions.Hosting,10.0.0-preview.7.25380.108,3123bb019bbc0182cf7ac27f30018ca620929f8027e137bd5bdfb952037c7d29
|
Microsoft.Extensions.DependencyInjection.Abstractions,10.0.0-preview.7.25380.108,1e9cd330d7833a3a850a7a42bbe0c729906c60bf1c359ad30a8622b50da4399b,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2
|
||||||
Microsoft.Extensions.Hosting.Abstractions,10.0.0-preview.7.25380.108,b57625436c9eb53e3aa27445b680bb93285d0d2c91007bbc221b0c378ab016a3
|
Microsoft.Extensions.Hosting,10.0.0-preview.7.25380.108,3123bb019bbc0182cf7ac27f30018ca620929f8027e137bd5bdfb952037c7d29,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2
|
||||||
Microsoft.Extensions.Http,10.0.0-preview.7.25380.108,daec142b7c7bd09ec1f2a86bfc3d7fe009825f5b653d310bc9e959c0a98a0f19
|
Microsoft.Extensions.Hosting.Abstractions,10.0.0-preview.7.25380.108,b57625436c9eb53e3aa27445b680bb93285d0d2c91007bbc221b0c378ab016a3,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2
|
||||||
Microsoft.Extensions.Logging.Abstractions,10.0.0-preview.7.25380.108,87a495fa0b7054e134a5cf44ec8b071fe2bc3ddfb27e9aefc6375701dca2a33a
|
Microsoft.Extensions.Http,10.0.0-preview.7.25380.108,daec142b7c7bd09ec1f2a86bfc3d7fe009825f5b653d310bc9e959c0a98a0f19,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2
|
||||||
Microsoft.Extensions.Options,10.0.0-preview.7.25380.108,c0657c2be3b7b894024586cf6e46a2ebc0e710db64d2645c4655b893b8487d8a
|
Microsoft.Extensions.Logging.Abstractions,10.0.0-preview.7.25380.108,87a495fa0b7054e134a5cf44ec8b071fe2bc3ddfb27e9aefc6375701dca2a33a,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2
|
||||||
|
Microsoft.Extensions.Options,10.0.0-preview.7.25380.108,c0657c2be3b7b894024586cf6e46a2ebc0e710db64d2645c4655b893b8487d8a,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2
|
||||||
Microsoft.Extensions.DependencyInjection.Abstractions,9.0.0,0a7715c24299e42b081b63b4f8e33da97b985e1de9e941b2b9e4c748b0d52fe7
|
Microsoft.Extensions.DependencyInjection.Abstractions,9.0.0,0a7715c24299e42b081b63b4f8e33da97b985e1de9e941b2b9e4c748b0d52fe7
|
||||||
Microsoft.Extensions.Logging.Abstractions,9.0.0,8814ecf6dc2359715e111b78084ae42087282595358eb775456088f15e63eca5
|
Microsoft.Extensions.Logging.Abstractions,9.0.0,8814ecf6dc2359715e111b78084ae42087282595358eb775456088f15e63eca5
|
||||||
Microsoft.Extensions.Options,9.0.0,0d3e5eb80418fc8b41e4b3c8f16229e839ddd254af0513f7e6f1643970baf1c9
|
Microsoft.Extensions.Options,9.0.0,0d3e5eb80418fc8b41e4b3c8f16229e839ddd254af0513f7e6f1643970baf1c9
|
||||||
Microsoft.Extensions.Options.ConfigurationExtensions,9.0.0,af5677b04552223787d942a3f8a323f3a85aafaf20ff3c9b4aaa128c44817280
|
Microsoft.Extensions.Options.ConfigurationExtensions,9.0.0,af5677b04552223787d942a3f8a323f3a85aafaf20ff3c9b4aaa128c44817280
|
||||||
Microsoft.Data.Sqlite,9.0.0-rc.1.24451.1,770b637317e1e924f1b13587b31af0787c8c668b1d9f53f2fccae8ee8704e167
|
Microsoft.Data.Sqlite,9.0.0-rc.1.24451.1,770b637317e1e924f1b13587b31af0787c8c668b1d9f53f2fccae8ee8704e167
|
||||||
Microsoft.AspNetCore.Authentication.JwtBearer,10.0.0-rc.1.25451.107,05f168c2db7ba79230e3fd77e84f6912bc73721c6656494df0b227867a6c2d3c
|
Microsoft.AspNetCore.Authentication.JwtBearer,10.0.0-rc.1.25451.107,05f168c2db7ba79230e3fd77e84f6912bc73721c6656494df0b227867a6c2d3c,https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2
|
||||||
|
|||||||
|
@@ -3,12 +3,14 @@
|
|||||||
# Sync preview NuGet packages into the local offline feed.
|
# Sync preview NuGet packages into the local offline feed.
|
||||||
# Reads package metadata from ops/devops/nuget-preview-packages.csv
|
# Reads package metadata from ops/devops/nuget-preview-packages.csv
|
||||||
# and ensures ./local-nuget holds the expected artefacts (with SHA-256 verification).
|
# and ensures ./local-nuget holds the expected artefacts (with SHA-256 verification).
|
||||||
|
# Optional 4th CSV column can override the download base (e.g. dotnet-public flat container).
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
repo_root="$(git -C "${BASH_SOURCE%/*}/.." rev-parse --show-toplevel 2>/dev/null || pwd)"
|
repo_root="$(git -C "${BASH_SOURCE%/*}/.." rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||||
manifest="${repo_root}/ops/devops/nuget-preview-packages.csv"
|
manifest="${repo_root}/ops/devops/nuget-preview-packages.csv"
|
||||||
dest="${repo_root}/local-nuget"
|
dest="${repo_root}/local-nuget"
|
||||||
|
nuget_v2_base="${NUGET_V2_BASE:-https://www.nuget.org/api/v2/package}"
|
||||||
|
|
||||||
if [[ ! -f "$manifest" ]]; then
|
if [[ ! -f "$manifest" ]]; then
|
||||||
echo "Manifest not found: $manifest" >&2
|
echo "Manifest not found: $manifest" >&2
|
||||||
@@ -21,8 +23,17 @@ fetch_package() {
|
|||||||
local package="$1"
|
local package="$1"
|
||||||
local version="$2"
|
local version="$2"
|
||||||
local expected_sha="$3"
|
local expected_sha="$3"
|
||||||
|
local source_base="$4"
|
||||||
local target="$dest/${package}.${version}.nupkg"
|
local target="$dest/${package}.${version}.nupkg"
|
||||||
local url="https://www.nuget.org/api/v2/package/${package}/${version}"
|
local url
|
||||||
|
|
||||||
|
if [[ -n "$source_base" ]]; then
|
||||||
|
local package_lower
|
||||||
|
package_lower="${package,,}"
|
||||||
|
url="${source_base%/}/${package_lower}/${version}/${package_lower}.${version}.nupkg"
|
||||||
|
else
|
||||||
|
url="${nuget_v2_base%/}/${package}/${version}"
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[sync-nuget] Fetching ${package} ${version}"
|
echo "[sync-nuget] Fetching ${package} ${version}"
|
||||||
local tmp
|
local tmp
|
||||||
@@ -41,7 +52,7 @@ fetch_package() {
|
|||||||
trap - RETURN
|
trap - RETURN
|
||||||
}
|
}
|
||||||
|
|
||||||
while IFS=',' read -r package version sha; do
|
while IFS=',' read -r package version sha source_base; do
|
||||||
[[ -z "$package" || "$package" == \#* ]] && continue
|
[[ -z "$package" || "$package" == \#* ]] && continue
|
||||||
|
|
||||||
local_path="$dest/${package}.${version}.nupkg"
|
local_path="$dest/${package}.${version}.nupkg"
|
||||||
@@ -56,5 +67,5 @@ while IFS=',' read -r package version sha; do
|
|||||||
echo "[sync-nuget] Missing ${package} ${version}"
|
echo "[sync-nuget] Missing ${package} ${version}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
fetch_package "$package" "$version" "$sha"
|
fetch_package "$package" "$version" "$sha" "${source_base:-}"
|
||||||
done < "$manifest"
|
done < "$manifest"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
| POLICY-CORE-09-004 | DONE (2025-10-19) | Policy Guild | POLICY-CORE-09-001 | Versioned scoring config (weights, trust table, reachability buckets) with schema validation, binder, and golden fixtures. | Config serialized with semantic version, binder loads defaults, fixtures assert deterministic hash. |
|
| POLICY-CORE-09-004 | DONE (2025-10-19) | Policy Guild | POLICY-CORE-09-001 | Versioned scoring config (weights, trust table, reachability buckets) with schema validation, binder, and golden fixtures. | Config serialized with semantic version, binder loads defaults, fixtures assert deterministic hash. |
|
||||||
| POLICY-CORE-09-005 | DONE (2025-10-19) | Policy Guild | POLICY-CORE-09-004, POLICY-CORE-09-002 | Implement scoring/quiet engine: compute score from config, enforce VEX-only quiet rules, emit inputs + `quietedBy` metadata in policy verdicts. | `/reports` policy result includes score, inputs, configVersion, quiet provenance; unit/integration tests prove reproducibility. |
|
| POLICY-CORE-09-005 | DONE (2025-10-19) | Policy Guild | POLICY-CORE-09-004, POLICY-CORE-09-002 | Implement scoring/quiet engine: compute score from config, enforce VEX-only quiet rules, emit inputs + `quietedBy` metadata in policy verdicts. | `/reports` policy result includes score, inputs, configVersion, quiet provenance; unit/integration tests prove reproducibility. |
|
||||||
| POLICY-CORE-09-006 | DONE (2025-10-19) | Policy Guild | POLICY-CORE-09-005, FEEDCORE-ENGINE-07-003 | Track unknown states with deterministic confidence bands that decay over time; expose state in policy outputs and docs. | Unknown flags + confidence band persisted, decay job deterministic, preview/report APIs show state with tests covering decay math. |
|
| POLICY-CORE-09-006 | DONE (2025-10-19) | Policy Guild | POLICY-CORE-09-005, FEEDCORE-ENGINE-07-003 | Track unknown states with deterministic confidence bands that decay over time; expose state in policy outputs and docs. | Unknown flags + confidence band persisted, decay job deterministic, preview/report APIs show state with tests covering decay math. |
|
||||||
| POLICY-RUNTIME-17-201 | TODO | Policy Guild, Scanner WebService Guild | ZASTAVA-OBS-17-005 | Define runtime reachability feed contract and alignment plan for `SCANNER-RUNTIME-17-401` once Zastava endpoints land; document policy expectations for reachability tags. | Contract note published, sample payload agreed with Scanner team, dependencies captured in scanner/runtime task boards. |
|
| POLICY-RUNTIME-17-201 | TODO | Policy Guild, Scanner WebService Guild | ZASTAVA-OBS-17-005 | Define runtime reachability feed contract and alignment plan for `SCANNER-RUNTIME-17-401` once Zastava endpoints land; roll `buildIds` + reachability hints into policy metadata so CLI/Webhook consumers know how to look up symbol/debug-store artifacts. | Contract note published (fields: `buildIds`, reachability tags, TTL guidance), sample payload agreed with Scanner team, doc cross-links captured in scanner/runtime task boards. |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- 2025-10-18: POLICY-CORE-09-001 completed. Binder + diagnostics + CLI scaffolding landed with tests; schema embedded at `src/StellaOps.Policy/Schemas/policy-schema@1.json` and referenced by docs/11_DATA_SCHEMAS.md.
|
- 2025-10-18: POLICY-CORE-09-001 completed. Binder + diagnostics + CLI scaffolding landed with tests; schema embedded at `src/StellaOps.Policy/Schemas/policy-schema@1.json` and referenced by docs/11_DATA_SCHEMAS.md.
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ public sealed class RuntimeEventDocument
|
|||||||
[BsonElement("imageRef")]
|
[BsonElement("imageRef")]
|
||||||
public string? ImageRef { get; set; }
|
public string? ImageRef { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("imageDigest")]
|
||||||
|
public string? ImageDigest { get; set; }
|
||||||
|
|
||||||
[BsonElement("engine")]
|
[BsonElement("engine")]
|
||||||
public string? Engine { get; set; }
|
public string? Engine { get; set; }
|
||||||
|
|
||||||
@@ -78,6 +81,9 @@ public sealed class RuntimeEventDocument
|
|||||||
[BsonElement("sbomReferrer")]
|
[BsonElement("sbomReferrer")]
|
||||||
public string? SbomReferrer { get; set; }
|
public string? SbomReferrer { get; set; }
|
||||||
|
|
||||||
|
[BsonElement("buildId")]
|
||||||
|
public string? BuildId { get; set; }
|
||||||
|
|
||||||
[BsonElement("payload")]
|
[BsonElement("payload")]
|
||||||
public BsonDocument Payload { get; set; } = new();
|
public BsonDocument Payload { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,16 @@ public sealed class MongoBootstrapper
|
|||||||
.Ascending(x => x.Node)
|
.Ascending(x => x.Node)
|
||||||
.Ascending(x => x.When),
|
.Ascending(x => x.When),
|
||||||
new CreateIndexOptions { Name = "runtime_event_tenant_node_when" }),
|
new CreateIndexOptions { Name = "runtime_event_tenant_node_when" }),
|
||||||
|
new(
|
||||||
|
Builders<RuntimeEventDocument>.IndexKeys
|
||||||
|
.Ascending(x => x.ImageDigest)
|
||||||
|
.Descending(x => x.When),
|
||||||
|
new CreateIndexOptions { Name = "runtime_event_imageDigest_when" }),
|
||||||
|
new(
|
||||||
|
Builders<RuntimeEventDocument>.IndexKeys
|
||||||
|
.Ascending(x => x.BuildId)
|
||||||
|
.Descending(x => x.When),
|
||||||
|
new CreateIndexOptions { Name = "runtime_event_buildId_when" }),
|
||||||
new(
|
new(
|
||||||
Builders<RuntimeEventDocument>.IndexKeys.Ascending(x => x.ExpiresAt),
|
Builders<RuntimeEventDocument>.IndexKeys.Ascending(x => x.ExpiresAt),
|
||||||
new CreateIndexOptions
|
new CreateIndexOptions
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
using StellaOps.Scanner.Storage.Catalog;
|
using StellaOps.Scanner.Storage.Catalog;
|
||||||
using StellaOps.Scanner.Storage.Mongo;
|
using StellaOps.Scanner.Storage.Mongo;
|
||||||
@@ -48,9 +50,83 @@ public sealed class RuntimeEventRepository
|
|||||||
return new RuntimeEventInsertResult(inserted, duplicates);
|
return new RuntimeEventInsertResult(inserted, duplicates);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyDictionary<string, RuntimeBuildIdObservation>> GetRecentBuildIdsAsync(
|
||||||
|
IReadOnlyCollection<string> imageDigests,
|
||||||
|
int maxPerImage,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(imageDigests);
|
||||||
|
if (imageDigests.Count == 0 || maxPerImage <= 0)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, RuntimeBuildIdObservation>(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalized = imageDigests
|
||||||
|
.Where(digest => !string.IsNullOrWhiteSpace(digest))
|
||||||
|
.Select(digest => digest.Trim().ToLowerInvariant())
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (normalized.Length == 0)
|
||||||
|
{
|
||||||
|
return new Dictionary<string, RuntimeBuildIdObservation>(StringComparer.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = new Dictionary<string, RuntimeBuildIdObservation>(StringComparer.Ordinal);
|
||||||
|
var limit = Math.Max(1, maxPerImage);
|
||||||
|
|
||||||
|
foreach (var digest in normalized)
|
||||||
|
{
|
||||||
|
var filter = Builders<RuntimeEventDocument>.Filter.And(
|
||||||
|
Builders<RuntimeEventDocument>.Filter.Eq(doc => doc.ImageDigest, digest),
|
||||||
|
Builders<RuntimeEventDocument>.Filter.Ne(doc => doc.BuildId, null),
|
||||||
|
Builders<RuntimeEventDocument>.Filter.Ne(doc => doc.BuildId, string.Empty));
|
||||||
|
|
||||||
|
var documents = await _collections.RuntimeEvents
|
||||||
|
.Find(filter)
|
||||||
|
.SortByDescending(doc => doc.When)
|
||||||
|
.Limit(limit * 4)
|
||||||
|
.Project(doc => new { doc.BuildId, doc.When })
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (documents.Count == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var buildIds = documents
|
||||||
|
.Select(doc => doc.BuildId)
|
||||||
|
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Take(limit)
|
||||||
|
.Select(id => id!.Trim().ToLowerInvariant())
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (buildIds.Length == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var observedAt = documents
|
||||||
|
.Where(doc => !string.IsNullOrWhiteSpace(doc.BuildId))
|
||||||
|
.Select(doc => doc.When)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
results[digest] = new RuntimeBuildIdObservation(digest, buildIds, observedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public readonly record struct RuntimeEventInsertResult(int InsertedCount, int DuplicateCount)
|
public readonly record struct RuntimeEventInsertResult(int InsertedCount, int DuplicateCount)
|
||||||
{
|
{
|
||||||
public static RuntimeEventInsertResult Empty => new(0, 0);
|
public static RuntimeEventInsertResult Empty => new(0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record RuntimeBuildIdObservation(
|
||||||
|
string ImageDigest,
|
||||||
|
IReadOnlyList<string> BuildIds,
|
||||||
|
DateTime ObservedAtUtc);
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ public sealed class RuntimeEndpointsTests
|
|||||||
BatchId = "batch-1",
|
BatchId = "batch-1",
|
||||||
Events = new[]
|
Events = new[]
|
||||||
{
|
{
|
||||||
CreateEnvelope("evt-001"),
|
CreateEnvelope("evt-001", buildId: "ABCDEF1234567890ABCDEF1234567890ABCDEF12"),
|
||||||
CreateEnvelope("evt-002")
|
CreateEnvelope("evt-002", buildId: "abcdef1234567890abcdef1234567890abcdef12")
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,6 +50,8 @@ public sealed class RuntimeEndpointsTests
|
|||||||
{
|
{
|
||||||
Assert.Equal("tenant-alpha", doc.Tenant);
|
Assert.Equal("tenant-alpha", doc.Tenant);
|
||||||
Assert.True(doc.ExpiresAt > doc.ReceivedAt);
|
Assert.True(doc.ExpiresAt > doc.ReceivedAt);
|
||||||
|
Assert.Equal("sha256:deadbeef", doc.ImageDigest);
|
||||||
|
Assert.Equal("abcdef1234567890abcdef1234567890abcdef12", doc.BuildId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +186,17 @@ rules:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ingestRequest = new RuntimeEventsIngestRequestDto
|
||||||
|
{
|
||||||
|
Events = new[]
|
||||||
|
{
|
||||||
|
CreateEnvelope("evt-210", imageDigest: imageDigest, buildId: "1122aabbccddeeff00112233445566778899aabb"),
|
||||||
|
CreateEnvelope("evt-211", imageDigest: imageDigest, buildId: "1122AABBCCDDEEFF00112233445566778899AABB")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var ingestResponse = await client.PostAsJsonAsync("/api/v1/runtime/events", ingestRequest);
|
||||||
|
Assert.Equal(HttpStatusCode.Accepted, ingestResponse.StatusCode);
|
||||||
|
|
||||||
var request = new RuntimePolicyRequestDto
|
var request = new RuntimePolicyRequestDto
|
||||||
{
|
{
|
||||||
Namespace = "payments",
|
Namespace = "payments",
|
||||||
@@ -215,6 +228,8 @@ rules:
|
|||||||
Assert.InRange(decision.Confidence!.Value, 0.0, 1.0);
|
Assert.InRange(decision.Confidence!.Value, 0.0, 1.0);
|
||||||
Assert.False(decision.Quieted.GetValueOrDefault());
|
Assert.False(decision.Quieted.GetValueOrDefault());
|
||||||
Assert.Null(decision.QuietedBy);
|
Assert.Null(decision.QuietedBy);
|
||||||
|
Assert.NotNull(decision.BuildIds);
|
||||||
|
Assert.Contains("1122aabbccddeeff00112233445566778899aabb", decision.BuildIds!);
|
||||||
var metadataString = decision.Metadata;
|
var metadataString = decision.Metadata;
|
||||||
Console.WriteLine($"Runtime policy metadata: {metadataString ?? "<null>"}");
|
Console.WriteLine($"Runtime policy metadata: {metadataString ?? "<null>"}");
|
||||||
Assert.False(string.IsNullOrWhiteSpace(metadataString));
|
Assert.False(string.IsNullOrWhiteSpace(metadataString));
|
||||||
@@ -293,8 +308,13 @@ rules: []
|
|||||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RuntimeEventEnvelope CreateEnvelope(string eventId, string? schemaVersion = null)
|
private static RuntimeEventEnvelope CreateEnvelope(
|
||||||
|
string eventId,
|
||||||
|
string? schemaVersion = null,
|
||||||
|
string? imageDigest = null,
|
||||||
|
string? buildId = null)
|
||||||
{
|
{
|
||||||
|
var digest = string.IsNullOrWhiteSpace(imageDigest) ? "sha256:deadbeef" : imageDigest;
|
||||||
var runtimeEvent = new RuntimeEvent
|
var runtimeEvent = new RuntimeEvent
|
||||||
{
|
{
|
||||||
EventId = eventId,
|
EventId = eventId,
|
||||||
@@ -314,7 +334,18 @@ rules: []
|
|||||||
Pod = "api-123",
|
Pod = "api-123",
|
||||||
Container = "api",
|
Container = "api",
|
||||||
ContainerId = "containerd://abc",
|
ContainerId = "containerd://abc",
|
||||||
ImageRef = "ghcr.io/example/api@sha256:deadbeef"
|
ImageRef = $"ghcr.io/example/api@{digest}"
|
||||||
|
},
|
||||||
|
Delta = new RuntimeDelta
|
||||||
|
{
|
||||||
|
BaselineImageDigest = digest
|
||||||
|
},
|
||||||
|
Process = new RuntimeProcess
|
||||||
|
{
|
||||||
|
Pid = 123,
|
||||||
|
Entrypoint = new[] { "/bin/start" },
|
||||||
|
EntryTrace = Array.Empty<RuntimeEntryTrace>(),
|
||||||
|
BuildId = buildId
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ public sealed record RuntimePolicyImageResponseDto
|
|||||||
[JsonPropertyName("metadata")]
|
[JsonPropertyName("metadata")]
|
||||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? Metadata { get; init; }
|
public string? Metadata { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("buildIds")]
|
||||||
|
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||||
|
public IReadOnlyList<string>? BuildIds { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record RuntimePolicyRekorDto
|
public sealed record RuntimePolicyRekorDto
|
||||||
|
|||||||
@@ -311,7 +311,8 @@ internal static class PolicyEndpoints
|
|||||||
Confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero),
|
Confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero),
|
||||||
Quieted = decision.Quieted,
|
Quieted = decision.Quieted,
|
||||||
QuietedBy = decision.QuietedBy,
|
QuietedBy = decision.QuietedBy,
|
||||||
Metadata = metadata
|
Metadata = metadata,
|
||||||
|
BuildIds = decision.BuildIds is { Count: > 0 } ? decision.BuildIds.ToArray() : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,8 @@ internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionServi
|
|||||||
|
|
||||||
var payloadDocument = BsonDocument.Parse(Encoding.UTF8.GetString(payloadBytes));
|
var payloadDocument = BsonDocument.Parse(Encoding.UTF8.GetString(payloadBytes));
|
||||||
var runtimeEvent = envelope.Event;
|
var runtimeEvent = envelope.Event;
|
||||||
|
var normalizedDigest = ExtractImageDigest(runtimeEvent);
|
||||||
|
var normalizedBuildId = NormalizeBuildId(runtimeEvent.Process?.BuildId);
|
||||||
|
|
||||||
var document = new RuntimeEventDocument
|
var document = new RuntimeEventDocument
|
||||||
{
|
{
|
||||||
@@ -104,11 +106,13 @@ internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionServi
|
|||||||
Container = runtimeEvent.Workload.Container,
|
Container = runtimeEvent.Workload.Container,
|
||||||
ContainerId = runtimeEvent.Workload.ContainerId,
|
ContainerId = runtimeEvent.Workload.ContainerId,
|
||||||
ImageRef = runtimeEvent.Workload.ImageRef,
|
ImageRef = runtimeEvent.Workload.ImageRef,
|
||||||
|
ImageDigest = normalizedDigest,
|
||||||
Engine = runtimeEvent.Runtime.Engine,
|
Engine = runtimeEvent.Runtime.Engine,
|
||||||
EngineVersion = runtimeEvent.Runtime.Version,
|
EngineVersion = runtimeEvent.Runtime.Version,
|
||||||
BaselineDigest = runtimeEvent.Delta?.BaselineImageDigest,
|
BaselineDigest = runtimeEvent.Delta?.BaselineImageDigest,
|
||||||
ImageSigned = runtimeEvent.Posture?.ImageSigned,
|
ImageSigned = runtimeEvent.Posture?.ImageSigned,
|
||||||
SbomReferrer = runtimeEvent.Posture?.SbomReferrer,
|
SbomReferrer = runtimeEvent.Posture?.SbomReferrer,
|
||||||
|
BuildId = normalizedBuildId,
|
||||||
Payload = payloadDocument
|
Payload = payloadDocument
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -125,6 +129,66 @@ internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionServi
|
|||||||
|
|
||||||
return RuntimeEventIngestionResult.Success(insertResult.InsertedCount, insertResult.DuplicateCount, totalPayloadBytes);
|
return RuntimeEventIngestionResult.Success(insertResult.InsertedCount, insertResult.DuplicateCount, totalPayloadBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? ExtractImageDigest(RuntimeEvent runtimeEvent)
|
||||||
|
{
|
||||||
|
var digest = NormalizeDigest(runtimeEvent.Delta?.BaselineImageDigest);
|
||||||
|
if (!string.IsNullOrWhiteSpace(digest))
|
||||||
|
{
|
||||||
|
return digest;
|
||||||
|
}
|
||||||
|
|
||||||
|
var imageRef = runtimeEvent.Workload.ImageRef;
|
||||||
|
if (string.IsNullOrWhiteSpace(imageRef))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = imageRef.Trim();
|
||||||
|
var atIndex = trimmed.LastIndexOf('@');
|
||||||
|
if (atIndex >= 0 && atIndex < trimmed.Length - 1)
|
||||||
|
{
|
||||||
|
var candidate = trimmed[(atIndex + 1)..];
|
||||||
|
var parsed = NormalizeDigest(candidate);
|
||||||
|
if (!string.IsNullOrWhiteSpace(parsed))
|
||||||
|
{
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return NormalizeDigest(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeDigest(string? candidate)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(candidate))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trimmed = candidate.Trim();
|
||||||
|
if (!trimmed.Contains(':', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.ToLowerInvariant();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeBuildId(string? buildId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(buildId))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildId.Trim().ToLowerInvariant();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal readonly record struct RuntimeEventIngestionResult(
|
internal readonly record struct RuntimeEventIngestionResult(
|
||||||
|
|||||||
@@ -26,12 +26,15 @@ internal interface IRuntimePolicyService
|
|||||||
|
|
||||||
internal sealed class RuntimePolicyService : IRuntimePolicyService
|
internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||||
{
|
{
|
||||||
|
private const int MaxBuildIdsPerImage = 3;
|
||||||
|
|
||||||
private static readonly Meter PolicyMeter = new("StellaOps.Scanner.RuntimePolicy", "1.0.0");
|
private static readonly Meter PolicyMeter = new("StellaOps.Scanner.RuntimePolicy", "1.0.0");
|
||||||
private static readonly Counter<long> PolicyEvaluations = PolicyMeter.CreateCounter<long>("scanner.runtime.policy.requests", unit: "1", description: "Total runtime policy evaluation requests processed.");
|
private static readonly Counter<long> PolicyEvaluations = PolicyMeter.CreateCounter<long>("scanner.runtime.policy.requests", unit: "1", description: "Total runtime policy evaluation requests processed.");
|
||||||
private static readonly Histogram<double> PolicyEvaluationLatencyMs = PolicyMeter.CreateHistogram<double>("scanner.runtime.policy.latency.ms", unit: "ms", description: "Latency for runtime policy evaluations.");
|
private static readonly Histogram<double> PolicyEvaluationLatencyMs = PolicyMeter.CreateHistogram<double>("scanner.runtime.policy.latency.ms", unit: "ms", description: "Latency for runtime policy evaluations.");
|
||||||
|
|
||||||
private readonly LinkRepository _linkRepository;
|
private readonly LinkRepository _linkRepository;
|
||||||
private readonly ArtifactRepository _artifactRepository;
|
private readonly ArtifactRepository _artifactRepository;
|
||||||
|
private readonly RuntimeEventRepository _runtimeEventRepository;
|
||||||
private readonly PolicySnapshotStore _policySnapshotStore;
|
private readonly PolicySnapshotStore _policySnapshotStore;
|
||||||
private readonly PolicyPreviewService _policyPreviewService;
|
private readonly PolicyPreviewService _policyPreviewService;
|
||||||
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
|
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
|
||||||
@@ -42,6 +45,7 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
|||||||
public RuntimePolicyService(
|
public RuntimePolicyService(
|
||||||
LinkRepository linkRepository,
|
LinkRepository linkRepository,
|
||||||
ArtifactRepository artifactRepository,
|
ArtifactRepository artifactRepository,
|
||||||
|
RuntimeEventRepository runtimeEventRepository,
|
||||||
PolicySnapshotStore policySnapshotStore,
|
PolicySnapshotStore policySnapshotStore,
|
||||||
PolicyPreviewService policyPreviewService,
|
PolicyPreviewService policyPreviewService,
|
||||||
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
|
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
|
||||||
@@ -51,6 +55,7 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
|||||||
{
|
{
|
||||||
_linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository));
|
_linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository));
|
||||||
_artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
|
_artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
|
||||||
|
_runtimeEventRepository = runtimeEventRepository ?? throw new ArgumentNullException(nameof(runtimeEventRepository));
|
||||||
_policySnapshotStore = policySnapshotStore ?? throw new ArgumentNullException(nameof(policySnapshotStore));
|
_policySnapshotStore = policySnapshotStore ?? throw new ArgumentNullException(nameof(policySnapshotStore));
|
||||||
_policyPreviewService = policyPreviewService ?? throw new ArgumentNullException(nameof(policyPreviewService));
|
_policyPreviewService = policyPreviewService ?? throw new ArgumentNullException(nameof(policyPreviewService));
|
||||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||||
@@ -82,6 +87,10 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
|||||||
new("namespace", request.Namespace ?? "unspecified")
|
new("namespace", request.Namespace ?? "unspecified")
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var buildIdObservations = await _runtimeEventRepository
|
||||||
|
.GetRecentBuildIdsAsync(request.Images, MaxBuildIdsPerImage, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var evaluated = new HashSet<string>(StringComparer.Ordinal);
|
var evaluated = new HashSet<string>(StringComparer.Ordinal);
|
||||||
@@ -126,6 +135,9 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
|||||||
_logger.LogWarning(ex, "Runtime policy preview failed for image {ImageDigest}; falling back to heuristic evaluation.", image);
|
_logger.LogWarning(ex, "Runtime policy preview failed for image {ImageDigest}; falling back to heuristic evaluation.", image);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var normalizedImage = image.Trim().ToLowerInvariant();
|
||||||
|
buildIdObservations.TryGetValue(normalizedImage, out var buildIdObservation);
|
||||||
|
|
||||||
var decision = await BuildDecisionAsync(
|
var decision = await BuildDecisionAsync(
|
||||||
image,
|
image,
|
||||||
metadata,
|
metadata,
|
||||||
@@ -133,6 +145,7 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
|||||||
projectedVerdicts,
|
projectedVerdicts,
|
||||||
issues,
|
issues,
|
||||||
policyDigest,
|
policyDigest,
|
||||||
|
buildIdObservation?.BuildIds,
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
results[image] = decision;
|
results[image] = decision;
|
||||||
@@ -260,6 +273,7 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
|||||||
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
|
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
|
||||||
ImmutableArray<PolicyIssue> issues,
|
ImmutableArray<PolicyIssue> issues,
|
||||||
string? policyDigest,
|
string? policyDigest,
|
||||||
|
IReadOnlyList<string>? buildIds,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var reasons = new List<string>(heuristicReasons);
|
var reasons = new List<string>(heuristicReasons);
|
||||||
@@ -315,7 +329,8 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
|||||||
metadataPayload,
|
metadataPayload,
|
||||||
confidence,
|
confidence,
|
||||||
quieted,
|
quieted,
|
||||||
quietedBy);
|
quietedBy,
|
||||||
|
buildIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
private RuntimePolicyVerdict MapVerdict(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, IReadOnlyList<string> heuristicReasons)
|
private RuntimePolicyVerdict MapVerdict(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, IReadOnlyList<string> heuristicReasons)
|
||||||
@@ -485,7 +500,8 @@ internal sealed record RuntimePolicyImageDecision(
|
|||||||
IDictionary<string, object?>? Metadata,
|
IDictionary<string, object?>? Metadata,
|
||||||
double Confidence,
|
double Confidence,
|
||||||
bool Quieted,
|
bool Quieted,
|
||||||
string? QuietedBy);
|
string? QuietedBy,
|
||||||
|
IReadOnlyList<string>? BuildIds);
|
||||||
|
|
||||||
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
|
internal sealed record RuntimePolicyRekorReference(string? Uuid, string? Url, bool? Verified);
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
| SCANNER-RUNTIME-12-305 | DONE (2025-10-24) | Scanner WebService Guild | SCANNER-RUNTIME-12-301, SCANNER-RUNTIME-12-302 | Promote shared fixtures with Zastava/CLI and add end-to-end automation for `/runtime/events` + `/policy/runtime`. | Runtime policy integration test + CLI-aligned fixture assert confidence, metadata JSON, and Rekor verification; docs note shared contract. |
|
| SCANNER-RUNTIME-12-305 | DONE (2025-10-24) | Scanner WebService Guild | SCANNER-RUNTIME-12-301, SCANNER-RUNTIME-12-302 | Promote shared fixtures with Zastava/CLI and add end-to-end automation for `/runtime/events` + `/policy/runtime`. | Runtime policy integration test + CLI-aligned fixture assert confidence, metadata JSON, and Rekor verification; docs note shared contract. |
|
||||||
| SCANNER-EVENTS-15-201 | DONE (2025-10-20) | Scanner WebService Guild | NOTIFY-QUEUE-15-401 | Emit `scanner.report.ready` and `scanner.scan.completed` events (bus adapters + tests). | Event envelopes published to queue with schemas; fixtures committed; Notify consumption test passes. |
|
| SCANNER-EVENTS-15-201 | DONE (2025-10-20) | Scanner WebService Guild | NOTIFY-QUEUE-15-401 | Emit `scanner.report.ready` and `scanner.scan.completed` events (bus adapters + tests). | Event envelopes published to queue with schemas; fixtures committed; Notify consumption test passes. |
|
||||||
| SCANNER-EVENTS-16-301 | BLOCKED (2025-10-20) | Scanner WebService Guild | NOTIFY-QUEUE-15-401 | Integrate Redis publisher end-to-end once Notify queue abstraction ships; replace in-memory recorder with real stream assertions. | Notify Queue adapter available; integration test exercises Redis stream length/fields via test harness; docs updated with ops validation checklist. |
|
| SCANNER-EVENTS-16-301 | BLOCKED (2025-10-20) | Scanner WebService Guild | NOTIFY-QUEUE-15-401 | Integrate Redis publisher end-to-end once Notify queue abstraction ships; replace in-memory recorder with real stream assertions. | Notify Queue adapter available; integration test exercises Redis stream length/fields via test harness; docs updated with ops validation checklist. |
|
||||||
| SCANNER-RUNTIME-17-401 | DOING (2025-10-24) | Scanner WebService Guild | SCANNER-RUNTIME-12-301, ZASTAVA-OBS-17-005, SCANNER-EMIT-17-701, POLICY-RUNTIME-17-201 | Persist runtime build-id observations and expose them via `/runtime/events` + policy joins for debug-symbol correlation. | Mongo schema stores optional `buildId`, API/SDK responses document field, integration test resolves debug-store path using stored build-id, docs updated accordingly. |
|
| SCANNER-RUNTIME-17-401 | DONE (2025-10-25) | Scanner WebService Guild | SCANNER-RUNTIME-12-301, ZASTAVA-OBS-17-005, SCANNER-EMIT-17-701, POLICY-RUNTIME-17-201 | Persist runtime build-id observations and expose them via `/runtime/events` + policy joins for debug-symbol correlation. | Runtime events store normalized digests + build IDs with supporting indexes, runtime policy responses surface `buildIds`, tests/docs updated, and CLI/API consumers can derive debug-store paths deterministically. |
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
- 2025-10-19: Sprint 9 streaming + policy endpoints (SCANNER-WEB-09-103, SCANNER-POLICY-09-105/106/107) landed with SSE/JSONL, OpenAPI, signed report coverage documented in `docs/09_API_CLI_REFERENCE.md`.
|
- 2025-10-19: Sprint 9 streaming + policy endpoints (SCANNER-WEB-09-103, SCANNER-POLICY-09-105/106/107) landed with SSE/JSONL, OpenAPI, signed report coverage documented in `docs/09_API_CLI_REFERENCE.md`.
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
| UI-ADMIN-13-004 | TODO | UI Guild | AUTH-MTLS-11-002 | Deliver admin area (tenants/clients/quotas/licensing) with RBAC + audit hooks. | Admin e2e tests pass; unauthorized access blocked; telemetry wired. |
|
| UI-ADMIN-13-004 | TODO | UI Guild | AUTH-MTLS-11-002 | Deliver admin area (tenants/clients/quotas/licensing) with RBAC + audit hooks. | Admin e2e tests pass; unauthorized access blocked; telemetry wired. |
|
||||||
| UI-ATTEST-11-005 | DONE (2025-10-23) | UI Guild | SIGNER-API-11-101, ATTESTOR-API-11-201 | Attestation visibility (Rekor id, status) on Scan Detail. | UI shows Rekor UUID/status; mock attestation fixtures displayed; tests cover success/failure. |
|
| UI-ATTEST-11-005 | DONE (2025-10-23) | UI Guild | SIGNER-API-11-101, ATTESTOR-API-11-201 | Attestation visibility (Rekor id, status) on Scan Detail. | UI shows Rekor UUID/status; mock attestation fixtures displayed; tests cover success/failure. |
|
||||||
| UI-SCHED-13-005 | TODO | UI Guild | SCHED-WEB-16-101 | Scheduler panel: schedules CRUD, run history, dry-run preview using API/mocks. | Panel functional with mocked endpoints; UX signoff; integration tests added. |
|
| UI-SCHED-13-005 | TODO | UI Guild | SCHED-WEB-16-101 | Scheduler panel: schedules CRUD, run history, dry-run preview using API/mocks. | Panel functional with mocked endpoints; UX signoff; integration tests added. |
|
||||||
| UI-NOTIFY-13-006 | DOING (2025-10-19) | UI Guild | NOTIFY-WEB-15-101 | Notify panel: channels/rules CRUD, deliveries view, test send integration. | Panel interacts with mocked Notify API; tests cover rule lifecycle; docs updated. |
|
| UI-NOTIFY-13-006 | DONE (2025-10-25) | UI Guild | NOTIFY-WEB-15-101 | Notify panel: channels/rules CRUD, deliveries view, test send integration. | Panel interacts with mocked Notify API; tests cover rule lifecycle; docs updated. |
|
||||||
| UI-POLICY-13-007 | TODO | UI Guild | POLICY-CORE-09-006, SCANNER-WEB-09-103 | Surface policy confidence metadata (band, age, quiet provenance) on preview and report views. | UI renders new columns/tooltips, accessibility and responsive checks pass, Cypress regression updated with confidence fixtures. |
|
| UI-POLICY-13-007 | TODO | UI Guild | POLICY-CORE-09-006, SCANNER-WEB-09-103 | Surface policy confidence metadata (band, age, quiet provenance) on preview and report views. | UI renders new columns/tooltips, accessibility and responsive checks pass, Cypress regression updated with confidence fixtures. |
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
<a routerLink="/scans/scan-verified-001" routerLinkActive="active">
|
<a routerLink="/scans/scan-verified-001" routerLinkActive="active">
|
||||||
Scan Detail
|
Scan Detail
|
||||||
</a>
|
</a>
|
||||||
|
<a routerLink="/notify" routerLinkActive="active">
|
||||||
|
Notify
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="app-auth">
|
<div class="app-auth">
|
||||||
<ng-container *ngIf="isAuthenticated(); else signIn">
|
<ng-container *ngIf="isAuthenticated(); else signIn">
|
||||||
|
|||||||
@@ -4,8 +4,14 @@ import { provideRouter } from '@angular/router';
|
|||||||
|
|
||||||
import { routes } from './app.routes';
|
import { routes } from './app.routes';
|
||||||
import { CONCELIER_EXPORTER_API_BASE_URL } from './core/api/concelier-exporter.client';
|
import { CONCELIER_EXPORTER_API_BASE_URL } from './core/api/concelier-exporter.client';
|
||||||
|
import {
|
||||||
|
NOTIFY_API,
|
||||||
|
NOTIFY_API_BASE_URL,
|
||||||
|
NOTIFY_TENANT_ID,
|
||||||
|
} from './core/api/notify.client';
|
||||||
import { AppConfigService } from './core/config/app-config.service';
|
import { AppConfigService } from './core/config/app-config.service';
|
||||||
import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
|
import { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
|
||||||
|
import { MockNotifyApiService } from './testing/mock-notify-api.service';
|
||||||
|
|
||||||
export const appConfig: ApplicationConfig = {
|
export const appConfig: ApplicationConfig = {
|
||||||
providers: [
|
providers: [
|
||||||
@@ -27,5 +33,18 @@ export const appConfig: ApplicationConfig = {
|
|||||||
provide: CONCELIER_EXPORTER_API_BASE_URL,
|
provide: CONCELIER_EXPORTER_API_BASE_URL,
|
||||||
useValue: '/api/v1/concelier/exporters/trivy-db',
|
useValue: '/api/v1/concelier/exporters/trivy-db',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: NOTIFY_API_BASE_URL,
|
||||||
|
useValue: '/api/v1/notify',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: NOTIFY_TENANT_ID,
|
||||||
|
useValue: 'tenant-dev',
|
||||||
|
},
|
||||||
|
MockNotifyApiService,
|
||||||
|
{
|
||||||
|
provide: NOTIFY_API,
|
||||||
|
useExisting: MockNotifyApiService,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export const routes: Routes = [
|
|||||||
(m) => m.ScanDetailPageComponent
|
(m) => m.ScanDetailPageComponent
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'notify',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/notify/notify-panel.component').then(
|
||||||
|
(m) => m.NotifyPanelComponent
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'auth/callback',
|
path: 'auth/callback',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|||||||
142
src/StellaOps.Web/src/app/core/api/notify.client.ts
Normal file
142
src/StellaOps.Web/src/app/core/api/notify.client.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
|
||||||
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
InjectionToken,
|
||||||
|
Optional,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChannelHealthResponse,
|
||||||
|
ChannelTestSendRequest,
|
||||||
|
ChannelTestSendResponse,
|
||||||
|
NotifyChannel,
|
||||||
|
NotifyDeliveriesQueryOptions,
|
||||||
|
NotifyDeliveriesResponse,
|
||||||
|
NotifyRule,
|
||||||
|
} from './notify.models';
|
||||||
|
|
||||||
|
export interface NotifyApi {
|
||||||
|
listChannels(): Observable<NotifyChannel[]>;
|
||||||
|
saveChannel(channel: NotifyChannel): Observable<NotifyChannel>;
|
||||||
|
deleteChannel(channelId: string): Observable<void>;
|
||||||
|
getChannelHealth(channelId: string): Observable<ChannelHealthResponse>;
|
||||||
|
testChannel(
|
||||||
|
channelId: string,
|
||||||
|
payload: ChannelTestSendRequest
|
||||||
|
): Observable<ChannelTestSendResponse>;
|
||||||
|
listRules(): Observable<NotifyRule[]>;
|
||||||
|
saveRule(rule: NotifyRule): Observable<NotifyRule>;
|
||||||
|
deleteRule(ruleId: string): Observable<void>;
|
||||||
|
listDeliveries(
|
||||||
|
options?: NotifyDeliveriesQueryOptions
|
||||||
|
): Observable<NotifyDeliveriesResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NOTIFY_API = new InjectionToken<NotifyApi>('NOTIFY_API');
|
||||||
|
|
||||||
|
export const NOTIFY_API_BASE_URL = new InjectionToken<string>(
|
||||||
|
'NOTIFY_API_BASE_URL'
|
||||||
|
);
|
||||||
|
|
||||||
|
export const NOTIFY_TENANT_ID = new InjectionToken<string>('NOTIFY_TENANT_ID');
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class NotifyApiHttpClient implements NotifyApi {
|
||||||
|
constructor(
|
||||||
|
private readonly http: HttpClient,
|
||||||
|
@Inject(NOTIFY_API_BASE_URL) private readonly baseUrl: string,
|
||||||
|
@Optional() @Inject(NOTIFY_TENANT_ID) private readonly tenantId: string | null
|
||||||
|
) {}
|
||||||
|
|
||||||
|
listChannels(): Observable<NotifyChannel[]> {
|
||||||
|
return this.http.get<NotifyChannel[]>(`${this.baseUrl}/channels`, {
|
||||||
|
headers: this.buildHeaders(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveChannel(channel: NotifyChannel): Observable<NotifyChannel> {
|
||||||
|
return this.http.post<NotifyChannel>(`${this.baseUrl}/channels`, channel, {
|
||||||
|
headers: this.buildHeaders(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteChannel(channelId: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.baseUrl}/channels/${channelId}`, {
|
||||||
|
headers: this.buildHeaders(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getChannelHealth(channelId: string): Observable<ChannelHealthResponse> {
|
||||||
|
return this.http.get<ChannelHealthResponse>(
|
||||||
|
`${this.baseUrl}/channels/${channelId}/health`,
|
||||||
|
{
|
||||||
|
headers: this.buildHeaders(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
testChannel(
|
||||||
|
channelId: string,
|
||||||
|
payload: ChannelTestSendRequest
|
||||||
|
): Observable<ChannelTestSendResponse> {
|
||||||
|
return this.http.post<ChannelTestSendResponse>(
|
||||||
|
`${this.baseUrl}/channels/${channelId}/test`,
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
headers: this.buildHeaders(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
listRules(): Observable<NotifyRule[]> {
|
||||||
|
return this.http.get<NotifyRule[]>(`${this.baseUrl}/rules`, {
|
||||||
|
headers: this.buildHeaders(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
saveRule(rule: NotifyRule): Observable<NotifyRule> {
|
||||||
|
return this.http.post<NotifyRule>(`${this.baseUrl}/rules`, rule, {
|
||||||
|
headers: this.buildHeaders(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRule(ruleId: string): Observable<void> {
|
||||||
|
return this.http.delete<void>(`${this.baseUrl}/rules/${ruleId}`, {
|
||||||
|
headers: this.buildHeaders(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listDeliveries(
|
||||||
|
options?: NotifyDeliveriesQueryOptions
|
||||||
|
): Observable<NotifyDeliveriesResponse> {
|
||||||
|
let params = new HttpParams();
|
||||||
|
if (options?.status) {
|
||||||
|
params = params.set('status', options.status);
|
||||||
|
}
|
||||||
|
if (options?.since) {
|
||||||
|
params = params.set('since', options.since);
|
||||||
|
}
|
||||||
|
if (options?.limit) {
|
||||||
|
params = params.set('limit', options.limit);
|
||||||
|
}
|
||||||
|
if (options?.continuationToken) {
|
||||||
|
params = params.set('continuationToken', options.continuationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.http.get<NotifyDeliveriesResponse>(`${this.baseUrl}/deliveries`, {
|
||||||
|
headers: this.buildHeaders(),
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildHeaders(): HttpHeaders {
|
||||||
|
if (!this.tenantId) {
|
||||||
|
return new HttpHeaders();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HttpHeaders({ 'X-StellaOps-Tenant': this.tenantId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
194
src/StellaOps.Web/src/app/core/api/notify.models.ts
Normal file
194
src/StellaOps.Web/src/app/core/api/notify.models.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
export type NotifyChannelType =
|
||||||
|
| 'Slack'
|
||||||
|
| 'Teams'
|
||||||
|
| 'Email'
|
||||||
|
| 'Webhook'
|
||||||
|
| 'Custom';
|
||||||
|
|
||||||
|
export type ChannelHealthStatus = 'Healthy' | 'Degraded' | 'Unhealthy';
|
||||||
|
|
||||||
|
export type NotifyDeliveryStatus =
|
||||||
|
| 'Pending'
|
||||||
|
| 'Sent'
|
||||||
|
| 'Failed'
|
||||||
|
| 'Throttled'
|
||||||
|
| 'Digested'
|
||||||
|
| 'Dropped';
|
||||||
|
|
||||||
|
export type NotifyDeliveryAttemptStatus =
|
||||||
|
| 'Enqueued'
|
||||||
|
| 'Sending'
|
||||||
|
| 'Succeeded'
|
||||||
|
| 'Failed'
|
||||||
|
| 'Throttled'
|
||||||
|
| 'Skipped';
|
||||||
|
|
||||||
|
export type NotifyDeliveryFormat =
|
||||||
|
| 'Slack'
|
||||||
|
| 'Teams'
|
||||||
|
| 'Email'
|
||||||
|
| 'Webhook'
|
||||||
|
| 'Json';
|
||||||
|
|
||||||
|
export interface NotifyChannelLimits {
|
||||||
|
readonly concurrency?: number | null;
|
||||||
|
readonly requestsPerMinute?: number | null;
|
||||||
|
readonly timeout?: string | null;
|
||||||
|
readonly maxBatchSize?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyChannelConfig {
|
||||||
|
readonly secretRef: string;
|
||||||
|
readonly target?: string;
|
||||||
|
readonly endpoint?: string;
|
||||||
|
readonly properties?: Record<string, string>;
|
||||||
|
readonly limits?: NotifyChannelLimits | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyChannel {
|
||||||
|
readonly schemaVersion?: string;
|
||||||
|
readonly channelId: string;
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly displayName?: string;
|
||||||
|
readonly description?: string;
|
||||||
|
readonly type: NotifyChannelType;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly config: NotifyChannelConfig;
|
||||||
|
readonly labels?: Record<string, string>;
|
||||||
|
readonly metadata?: Record<string, string>;
|
||||||
|
readonly createdBy?: string;
|
||||||
|
readonly createdAt?: string;
|
||||||
|
readonly updatedBy?: string;
|
||||||
|
readonly updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyRuleMatchVex {
|
||||||
|
readonly includeAcceptedJustifications?: boolean;
|
||||||
|
readonly includeRejectedJustifications?: boolean;
|
||||||
|
readonly includeUnknownJustifications?: boolean;
|
||||||
|
readonly justificationKinds?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyRuleMatch {
|
||||||
|
readonly eventKinds?: readonly string[];
|
||||||
|
readonly namespaces?: readonly string[];
|
||||||
|
readonly repositories?: readonly string[];
|
||||||
|
readonly digests?: readonly string[];
|
||||||
|
readonly labels?: readonly string[];
|
||||||
|
readonly componentPurls?: readonly string[];
|
||||||
|
readonly minSeverity?: string | null;
|
||||||
|
readonly verdicts?: readonly string[];
|
||||||
|
readonly kevOnly?: boolean | null;
|
||||||
|
readonly vex?: NotifyRuleMatchVex | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyRuleAction {
|
||||||
|
readonly actionId: string;
|
||||||
|
readonly channel: string;
|
||||||
|
readonly template?: string;
|
||||||
|
readonly digest?: string;
|
||||||
|
readonly throttle?: string | null;
|
||||||
|
readonly locale?: string;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly metadata?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyRule {
|
||||||
|
readonly schemaVersion?: string;
|
||||||
|
readonly ruleId: string;
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description?: string;
|
||||||
|
readonly enabled: boolean;
|
||||||
|
readonly match: NotifyRuleMatch;
|
||||||
|
readonly actions: readonly NotifyRuleAction[];
|
||||||
|
readonly labels?: Record<string, string>;
|
||||||
|
readonly metadata?: Record<string, string>;
|
||||||
|
readonly createdBy?: string;
|
||||||
|
readonly createdAt?: string;
|
||||||
|
readonly updatedBy?: string;
|
||||||
|
readonly updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyDeliveryAttempt {
|
||||||
|
readonly timestamp: string;
|
||||||
|
readonly status: NotifyDeliveryAttemptStatus;
|
||||||
|
readonly statusCode?: number;
|
||||||
|
readonly reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyDeliveryRendered {
|
||||||
|
readonly channelType: NotifyChannelType;
|
||||||
|
readonly format: NotifyDeliveryFormat;
|
||||||
|
readonly target: string;
|
||||||
|
readonly title: string;
|
||||||
|
readonly body: string;
|
||||||
|
readonly summary?: string;
|
||||||
|
readonly textBody?: string;
|
||||||
|
readonly locale?: string;
|
||||||
|
readonly bodyHash?: string;
|
||||||
|
readonly attachments?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyDelivery {
|
||||||
|
readonly deliveryId: string;
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly ruleId: string;
|
||||||
|
readonly actionId: string;
|
||||||
|
readonly eventId: string;
|
||||||
|
readonly kind: string;
|
||||||
|
readonly status: NotifyDeliveryStatus;
|
||||||
|
readonly statusReason?: string;
|
||||||
|
readonly rendered?: NotifyDeliveryRendered;
|
||||||
|
readonly attempts?: readonly NotifyDeliveryAttempt[];
|
||||||
|
readonly metadata?: Record<string, string>;
|
||||||
|
readonly createdAt: string;
|
||||||
|
readonly sentAt?: string;
|
||||||
|
readonly completedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyDeliveriesQueryOptions {
|
||||||
|
readonly status?: NotifyDeliveryStatus;
|
||||||
|
readonly since?: string;
|
||||||
|
readonly limit?: number;
|
||||||
|
readonly continuationToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyDeliveriesResponse {
|
||||||
|
readonly items: readonly NotifyDelivery[];
|
||||||
|
readonly continuationToken?: string | null;
|
||||||
|
readonly count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelHealthResponse {
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly channelId: string;
|
||||||
|
readonly status: ChannelHealthStatus;
|
||||||
|
readonly message?: string | null;
|
||||||
|
readonly checkedAt: string;
|
||||||
|
readonly traceId: string;
|
||||||
|
readonly metadata?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelTestSendRequest {
|
||||||
|
readonly target?: string;
|
||||||
|
readonly templateId?: string;
|
||||||
|
readonly title?: string;
|
||||||
|
readonly summary?: string;
|
||||||
|
readonly body?: string;
|
||||||
|
readonly textBody?: string;
|
||||||
|
readonly locale?: string;
|
||||||
|
readonly metadata?: Record<string, string>;
|
||||||
|
readonly attachments?: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelTestSendResponse {
|
||||||
|
readonly tenantId: string;
|
||||||
|
readonly channelId: string;
|
||||||
|
readonly preview: NotifyDeliveryRendered;
|
||||||
|
readonly queuedAt: string;
|
||||||
|
readonly traceId: string;
|
||||||
|
readonly metadata?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<section class="notify-panel" aria-live="polite">
|
||||||
|
<header class="notify-panel__header">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Notifications</p>
|
||||||
|
<h1>Notify control plane</h1>
|
||||||
|
<p>Manage channels, routing rules, deliveries, and preview payloads offline.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ghost-button"
|
||||||
|
(click)="refreshAll()"
|
||||||
|
[disabled]="channelLoading() || ruleLoading() || deliveriesLoading()"
|
||||||
|
>
|
||||||
|
Refresh data
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="notify-grid">
|
||||||
|
<article class="notify-card">
|
||||||
|
<header class="notify-card__header">
|
||||||
|
<div>
|
||||||
|
<h2>Channels</h2>
|
||||||
|
<p>Destinations for Slack, Teams, Email, or Webhook notifications.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ghost-button" (click)="createChannelDraft()">New channel</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p *ngIf="channelMessage()" class="notify-message" role="status">
|
||||||
|
{{ channelMessage() }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="channel-list" role="list">
|
||||||
|
<li *ngFor="let channel of channels(); trackBy: trackByChannel">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="channel-item"
|
||||||
|
data-testid="channel-item"
|
||||||
|
[class.active]="selectedChannelId() === channel.channelId"
|
||||||
|
(click)="selectChannel(channel.channelId)"
|
||||||
|
>
|
||||||
|
<span class="channel-name">{{ channel.displayName || channel.name }}</span>
|
||||||
|
<span class="channel-meta">{{ channel.type }}</span>
|
||||||
|
<span class="channel-status" [class.channel-status--enabled]="channel.enabled">
|
||||||
|
{{ channel.enabled ? 'Enabled' : 'Disabled' }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="channel-form"
|
||||||
|
[formGroup]="channelForm"
|
||||||
|
(ngSubmit)="saveChannel()"
|
||||||
|
novalidate
|
||||||
|
>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input formControlName="name" type="text" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Display name</span>
|
||||||
|
<input formControlName="displayName" type="text" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Type</span>
|
||||||
|
<select formControlName="type">
|
||||||
|
<option *ngFor="let type of channelTypes" [value]="type">
|
||||||
|
{{ type }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Secret reference</span>
|
||||||
|
<input formControlName="secretRef" type="text" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Target</span>
|
||||||
|
<input formControlName="target" type="text" placeholder="#alerts or email" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Endpoint</span>
|
||||||
|
<input formControlName="endpoint" type="text" placeholder="https://example" />
|
||||||
|
</label>
|
||||||
|
<label class="full-width">
|
||||||
|
<span>Description</span>
|
||||||
|
<textarea formControlName="description" rows="2"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" formControlName="enabled" />
|
||||||
|
<span>Channel enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>Labels (key=value)</span>
|
||||||
|
<textarea formControlName="labelsText" rows="2" placeholder="tier=critical"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Metadata (key=value)</span>
|
||||||
|
<textarea formControlName="metadataText" rows="2" placeholder="workspace=stellaops"></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notify-actions">
|
||||||
|
<button type="button" class="ghost-button" (click)="createChannelDraft()">
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ghost-button"
|
||||||
|
(click)="deleteChannel()"
|
||||||
|
[disabled]="channelLoading() || !selectedChannelId()"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button type="submit" [disabled]="channelLoading()">
|
||||||
|
Save channel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section *ngIf="channelHealth() as health" class="channel-health" aria-live="polite">
|
||||||
|
<div class="status-pill" [class.status-pill--healthy]="health.status === 'Healthy'" [class.status-pill--warning]="health.status === 'Degraded'" [class.status-pill--error]="health.status === 'Unhealthy'">
|
||||||
|
{{ health.status }}
|
||||||
|
</div>
|
||||||
|
<div class="channel-health__details">
|
||||||
|
<p>{{ health.message }}</p>
|
||||||
|
<small>Last checked {{ health.checkedAt | date: 'medium' }} • Trace {{ health.traceId }}</small>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form class="test-form" [formGroup]="testForm" (ngSubmit)="sendTestPreview()" novalidate>
|
||||||
|
<h3>Test send</h3>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>Preview title</span>
|
||||||
|
<input formControlName="title" type="text" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Summary</span>
|
||||||
|
<input formControlName="summary" type="text" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Override target</span>
|
||||||
|
<input formControlName="target" type="text" placeholder="#alerts or user@org" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
<span>Body</span>
|
||||||
|
<textarea formControlName="body" rows="3"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="notify-actions">
|
||||||
|
<button type="submit" [disabled]="testSending()">
|
||||||
|
{{ testSending() ? 'Sending…' : 'Send test' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section *ngIf="testPreview() as preview" class="test-preview" data-testid="test-preview">
|
||||||
|
<header>
|
||||||
|
<strong>Preview queued</strong>
|
||||||
|
<span>{{ preview.queuedAt | date: 'short' }}</span>
|
||||||
|
</header>
|
||||||
|
<p><span>Target:</span> {{ preview.preview.target }}</p>
|
||||||
|
<p><span>Title:</span> {{ preview.preview.title }}</p>
|
||||||
|
<p><span>Summary:</span> {{ preview.preview.summary || 'n/a' }}</p>
|
||||||
|
<p class="preview-body">{{ preview.preview.body }}</p>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="notify-card">
|
||||||
|
<header class="notify-card__header">
|
||||||
|
<div>
|
||||||
|
<h2>Rules</h2>
|
||||||
|
<p>Define routing logic and throttles per channel.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ghost-button" (click)="createRuleDraft()">New rule</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p *ngIf="ruleMessage()" class="notify-message" role="status">
|
||||||
|
{{ ruleMessage() }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="rule-list" role="list">
|
||||||
|
<li *ngFor="let rule of rules(); trackBy: trackByRule">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rule-item"
|
||||||
|
data-testid="rule-item"
|
||||||
|
[class.active]="selectedRuleId() === rule.ruleId"
|
||||||
|
(click)="selectRule(rule.ruleId)"
|
||||||
|
>
|
||||||
|
<span class="rule-name">{{ rule.name }}</span>
|
||||||
|
<span class="rule-meta">{{ rule.match?.minSeverity || 'any' }}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form class="rule-form" [formGroup]="ruleForm" (ngSubmit)="saveRule()" novalidate>
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>
|
||||||
|
<span>Name</span>
|
||||||
|
<input formControlName="name" type="text" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Minimum severity</span>
|
||||||
|
<select formControlName="minSeverity">
|
||||||
|
<option value="">Any</option>
|
||||||
|
<option *ngFor="let sev of severityOptions" [value]="sev">
|
||||||
|
{{ sev }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Channel</span>
|
||||||
|
<select formControlName="channel" required>
|
||||||
|
<option *ngFor="let channel of channels()" [value]="channel.channelId">
|
||||||
|
{{ channel.displayName || channel.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Digest</span>
|
||||||
|
<input formControlName="digest" type="text" placeholder="instant or 1h" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Template</span>
|
||||||
|
<input formControlName="template" type="text" placeholder="tmpl-critical" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Locale</span>
|
||||||
|
<input formControlName="locale" type="text" placeholder="en-US" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Throttle (seconds)</span>
|
||||||
|
<input formControlName="throttleSeconds" type="number" min="0" />
|
||||||
|
</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" formControlName="enabled" />
|
||||||
|
<span>Rule enabled</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>Event kinds (comma or newline)</span>
|
||||||
|
<textarea formControlName="eventKindsText" rows="2"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Labels filter</span>
|
||||||
|
<textarea formControlName="labelsText" rows="2" placeholder="kev,critical"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Description</span>
|
||||||
|
<textarea formControlName="description" rows="2"></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="notify-actions">
|
||||||
|
<button type="button" class="ghost-button" (click)="createRuleDraft()">
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ghost-button"
|
||||||
|
(click)="deleteRule()"
|
||||||
|
[disabled]="ruleLoading() || !selectedRuleId()"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button type="submit" [disabled]="ruleLoading()">
|
||||||
|
Save rule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="notify-card notify-card--deliveries">
|
||||||
|
<header class="notify-card__header">
|
||||||
|
<div>
|
||||||
|
<h2>Deliveries</h2>
|
||||||
|
<p>Recent delivery attempts, statuses, and preview traces.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="ghost-button" (click)="refreshDeliveries()" [disabled]="deliveriesLoading()">Refresh</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="deliveries-controls">
|
||||||
|
<label>
|
||||||
|
<span>Status filter</span>
|
||||||
|
<select [value]="deliveryFilter()" (change)="onDeliveryFilterChange($any($event.target).value)">
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="sent">Sent</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="throttled">Throttled</option>
|
||||||
|
<option value="digested">Digested</option>
|
||||||
|
<option value="dropped">Dropped</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p *ngIf="deliveriesMessage()" class="notify-message" role="status">
|
||||||
|
{{ deliveriesMessage() }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="deliveries-table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">Status</th>
|
||||||
|
<th scope="col">Target</th>
|
||||||
|
<th scope="col">Kind</th>
|
||||||
|
<th scope="col">Created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
*ngFor="let delivery of filteredDeliveries(); trackBy: trackByDelivery"
|
||||||
|
data-testid="delivery-row"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<span class="status-badge" [class.status-badge--sent]="delivery.status === 'Sent'" [class.status-badge--failed]="delivery.status === 'Failed'" [class.status-badge--throttled]="delivery.status === 'Throttled'">
|
||||||
|
{{ delivery.status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ delivery.rendered?.target || 'n/a' }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ delivery.kind }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{ delivery.createdAt | date: 'short' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr *ngIf="!deliveriesLoading() && !filteredDeliveries().length">
|
||||||
|
<td colspan="4" class="empty-row">No deliveries match this filter.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-panel {
|
||||||
|
background: #0f172a;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-panel__header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: #cbd5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 1.5rem;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-card {
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost-button {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
background: transparent;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.35rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
border-color: #38bdf8;
|
||||||
|
background: rgba(56, 189, 248, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-message {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(59, 130, 246, 0.15);
|
||||||
|
color: #e0f2fe;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-list,
|
||||||
|
.rule-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-item,
|
||||||
|
.rule-item {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
background: #0f172a;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, transform 0.2s ease;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
border-color: #38bdf8;
|
||||||
|
background: rgba(56, 189, 248, 0.1);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-meta,
|
||||||
|
.rule-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-status {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(248, 250, 252, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-status--enabled {
|
||||||
|
border-color: #34d399;
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label span {
|
||||||
|
color: #cbd5f5;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0.6rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid #38bdf8;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-actions button {
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.45rem 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
background: linear-gradient(120deg, #38bdf8, #8b5cf6);
|
||||||
|
color: #0f172a;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-actions .ghost-button {
|
||||||
|
background: transparent;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-health {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #0b1220;
|
||||||
|
border: 1px solid #1d2a44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
border: 1px solid rgba(248, 250, 252, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--healthy {
|
||||||
|
border-color: #34d399;
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--warning {
|
||||||
|
border-color: #facc15;
|
||||||
|
color: #facc15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill--error {
|
||||||
|
border-color: #f87171;
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-health__details p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.channel-health__details small {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-form h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #cbd5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-preview {
|
||||||
|
border: 1px solid #1f2937;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #0b1220;
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #cbd5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-body {
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
background: #0f172a;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deliveries-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deliveries-controls label {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deliveries-table {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
color: #cbd5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody td {
|
||||||
|
padding: 0.6rem 0.25rem;
|
||||||
|
border-top: 1px solid #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-row {
|
||||||
|
text-align: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--sent {
|
||||||
|
border-color: #34d399;
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--failed {
|
||||||
|
border-color: #f87171;
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge--throttled {
|
||||||
|
border-color: #facc15;
|
||||||
|
color: #facc15;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.notify-panel {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-panel__header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { NOTIFY_API } from '../../core/api/notify.client';
|
||||||
|
import { MockNotifyApiService } from '../../testing/mock-notify-api.service';
|
||||||
|
import { NotifyPanelComponent } from './notify-panel.component';
|
||||||
|
|
||||||
|
describe('NotifyPanelComponent', () => {
|
||||||
|
let fixture: ComponentFixture<NotifyPanelComponent>;
|
||||||
|
let component: NotifyPanelComponent;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [NotifyPanelComponent],
|
||||||
|
providers: [
|
||||||
|
MockNotifyApiService,
|
||||||
|
{ provide: NOTIFY_API, useExisting: MockNotifyApiService },
|
||||||
|
],
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(NotifyPanelComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders channels from the mocked API', async () => {
|
||||||
|
await component.refreshAll();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const items: NodeListOf<HTMLButtonElement> =
|
||||||
|
fixture.nativeElement.querySelectorAll('[data-testid="channel-item"]');
|
||||||
|
expect(items.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists a new rule via the mocked API', async () => {
|
||||||
|
await component.refreshAll();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
component.createRuleDraft();
|
||||||
|
component.ruleForm.patchValue({
|
||||||
|
name: 'Notify preview rule',
|
||||||
|
channel: component.channels()[0]?.channelId ?? '',
|
||||||
|
eventKindsText: 'scanner.report.ready',
|
||||||
|
labelsText: 'kev',
|
||||||
|
});
|
||||||
|
|
||||||
|
await component.saveRule();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const ruleButtons: HTMLElement[] = Array.from(
|
||||||
|
fixture.nativeElement.querySelectorAll('[data-testid="rule-item"]')
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
ruleButtons.some((el) => el.textContent?.includes('Notify preview rule'))
|
||||||
|
).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a test preview after sending', async () => {
|
||||||
|
await component.refreshAll();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
await component.sendTestPreview();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
const preview = fixture.nativeElement.querySelector('[data-testid="test-preview"]');
|
||||||
|
expect(preview).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,642 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
OnInit,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
NonNullableFormBuilder,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
Validators,
|
||||||
|
} from '@angular/forms';
|
||||||
|
import { firstValueFrom } from 'rxjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
NOTIFY_API,
|
||||||
|
NotifyApi,
|
||||||
|
} from '../../core/api/notify.client';
|
||||||
|
import {
|
||||||
|
ChannelHealthResponse,
|
||||||
|
ChannelTestSendResponse,
|
||||||
|
NotifyChannel,
|
||||||
|
NotifyDelivery,
|
||||||
|
NotifyDeliveriesQueryOptions,
|
||||||
|
NotifyDeliveryStatus,
|
||||||
|
NotifyRule,
|
||||||
|
NotifyRuleAction,
|
||||||
|
} from '../../core/api/notify.models';
|
||||||
|
|
||||||
|
type DeliveryFilter =
|
||||||
|
| 'all'
|
||||||
|
| 'pending'
|
||||||
|
| 'sent'
|
||||||
|
| 'failed'
|
||||||
|
| 'throttled'
|
||||||
|
| 'digested'
|
||||||
|
| 'dropped';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-notify-panel',
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, ReactiveFormsModule],
|
||||||
|
templateUrl: './notify-panel.component.html',
|
||||||
|
styleUrls: ['./notify-panel.component.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class NotifyPanelComponent implements OnInit {
|
||||||
|
private readonly api = inject<NotifyApi>(NOTIFY_API);
|
||||||
|
private readonly formBuilder = inject(NonNullableFormBuilder);
|
||||||
|
|
||||||
|
private readonly tenantId = signal<string>('tenant-dev');
|
||||||
|
|
||||||
|
readonly channelTypes: readonly NotifyChannel['type'][] = [
|
||||||
|
'Slack',
|
||||||
|
'Teams',
|
||||||
|
'Email',
|
||||||
|
'Webhook',
|
||||||
|
'Custom',
|
||||||
|
];
|
||||||
|
|
||||||
|
readonly severityOptions = ['critical', 'high', 'medium', 'low'];
|
||||||
|
|
||||||
|
readonly channels = signal<NotifyChannel[]>([]);
|
||||||
|
readonly selectedChannelId = signal<string | null>(null);
|
||||||
|
readonly channelLoading = signal(false);
|
||||||
|
readonly channelMessage = signal<string | null>(null);
|
||||||
|
readonly channelHealth = signal<ChannelHealthResponse | null>(null);
|
||||||
|
readonly testPreview = signal<ChannelTestSendResponse | null>(null);
|
||||||
|
readonly testSending = signal(false);
|
||||||
|
|
||||||
|
readonly rules = signal<NotifyRule[]>([]);
|
||||||
|
readonly selectedRuleId = signal<string | null>(null);
|
||||||
|
readonly ruleLoading = signal(false);
|
||||||
|
readonly ruleMessage = signal<string | null>(null);
|
||||||
|
|
||||||
|
readonly deliveries = signal<NotifyDelivery[]>([]);
|
||||||
|
readonly deliveriesLoading = signal(false);
|
||||||
|
readonly deliveriesMessage = signal<string | null>(null);
|
||||||
|
readonly deliveryFilter = signal<DeliveryFilter>('all');
|
||||||
|
|
||||||
|
readonly filteredDeliveries = computed(() => {
|
||||||
|
const filter = this.deliveryFilter();
|
||||||
|
const items = this.deliveries();
|
||||||
|
if (filter === 'all') {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
return items.filter((item) =>
|
||||||
|
item.status.toLowerCase() === filter
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly channelForm = this.formBuilder.group({
|
||||||
|
channelId: this.formBuilder.control(''),
|
||||||
|
name: this.formBuilder.control('', {
|
||||||
|
validators: [Validators.required],
|
||||||
|
}),
|
||||||
|
displayName: this.formBuilder.control(''),
|
||||||
|
description: this.formBuilder.control(''),
|
||||||
|
type: this.formBuilder.control<NotifyChannel['type']>('Slack'),
|
||||||
|
target: this.formBuilder.control(''),
|
||||||
|
endpoint: this.formBuilder.control(''),
|
||||||
|
secretRef: this.formBuilder.control('', {
|
||||||
|
validators: [Validators.required],
|
||||||
|
}),
|
||||||
|
enabled: this.formBuilder.control(true),
|
||||||
|
labelsText: this.formBuilder.control(''),
|
||||||
|
metadataText: this.formBuilder.control(''),
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly ruleForm = this.formBuilder.group({
|
||||||
|
ruleId: this.formBuilder.control(''),
|
||||||
|
name: this.formBuilder.control('', {
|
||||||
|
validators: [Validators.required],
|
||||||
|
}),
|
||||||
|
description: this.formBuilder.control(''),
|
||||||
|
enabled: this.formBuilder.control(true),
|
||||||
|
minSeverity: this.formBuilder.control('critical'),
|
||||||
|
eventKindsText: this.formBuilder.control('scanner.report.ready'),
|
||||||
|
labelsText: this.formBuilder.control('kev,critical'),
|
||||||
|
channel: this.formBuilder.control('', {
|
||||||
|
validators: [Validators.required],
|
||||||
|
}),
|
||||||
|
digest: this.formBuilder.control('instant'),
|
||||||
|
template: this.formBuilder.control('tmpl-critical'),
|
||||||
|
locale: this.formBuilder.control('en-US'),
|
||||||
|
throttleSeconds: this.formBuilder.control(300),
|
||||||
|
});
|
||||||
|
|
||||||
|
readonly testForm = this.formBuilder.group({
|
||||||
|
title: this.formBuilder.control('Policy verdict update'),
|
||||||
|
summary: this.formBuilder.control('Mock preview of Notify payload.'),
|
||||||
|
body: this.formBuilder.control(
|
||||||
|
'Sample preview body rendered by the mocked Notify API service.'
|
||||||
|
),
|
||||||
|
textBody: this.formBuilder.control(''),
|
||||||
|
target: this.formBuilder.control(''),
|
||||||
|
});
|
||||||
|
|
||||||
|
async ngOnInit(): Promise<void> {
|
||||||
|
await this.refreshAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshAll(): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
this.loadChannels(),
|
||||||
|
this.loadRules(),
|
||||||
|
this.loadDeliveries(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadChannels(): Promise<void> {
|
||||||
|
this.channelLoading.set(true);
|
||||||
|
this.channelMessage.set(null);
|
||||||
|
try {
|
||||||
|
const channels = await firstValueFrom(this.api.listChannels());
|
||||||
|
this.channels.set(channels);
|
||||||
|
if (channels.length) {
|
||||||
|
this.tenantId.set(channels[0].tenantId);
|
||||||
|
}
|
||||||
|
if (!this.selectedChannelId() && channels.length) {
|
||||||
|
this.selectChannel(channels[0].channelId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.channelMessage.set(this.toErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
this.channelLoading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadRules(): Promise<void> {
|
||||||
|
this.ruleLoading.set(true);
|
||||||
|
this.ruleMessage.set(null);
|
||||||
|
try {
|
||||||
|
const rules = await firstValueFrom(this.api.listRules());
|
||||||
|
this.rules.set(rules);
|
||||||
|
if (!this.selectedRuleId() && rules.length) {
|
||||||
|
this.selectRule(rules[0].ruleId);
|
||||||
|
}
|
||||||
|
if (!this.ruleForm.controls.channel.value && this.channels().length) {
|
||||||
|
this.ruleForm.patchValue({ channel: this.channels()[0].channelId });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.ruleMessage.set(this.toErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
this.ruleLoading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadDeliveries(): Promise<void> {
|
||||||
|
this.deliveriesLoading.set(true);
|
||||||
|
this.deliveriesMessage.set(null);
|
||||||
|
try {
|
||||||
|
const options: NotifyDeliveriesQueryOptions = {
|
||||||
|
status: this.mapFilterToStatus(this.deliveryFilter()),
|
||||||
|
limit: 15,
|
||||||
|
};
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.api.listDeliveries(options)
|
||||||
|
);
|
||||||
|
this.deliveries.set([...(response.items ?? [])]);
|
||||||
|
} catch (error) {
|
||||||
|
this.deliveriesMessage.set(this.toErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
this.deliveriesLoading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectChannel(channelId: string): void {
|
||||||
|
const channel = this.channels().find((c) => c.channelId === channelId);
|
||||||
|
if (!channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.selectedChannelId.set(channelId);
|
||||||
|
this.channelForm.patchValue({
|
||||||
|
channelId: channel.channelId,
|
||||||
|
name: channel.name,
|
||||||
|
displayName: channel.displayName ?? '',
|
||||||
|
description: channel.description ?? '',
|
||||||
|
type: channel.type,
|
||||||
|
target: channel.config.target ?? '',
|
||||||
|
endpoint: channel.config.endpoint ?? '',
|
||||||
|
secretRef: channel.config.secretRef,
|
||||||
|
enabled: channel.enabled,
|
||||||
|
labelsText: this.formatKeyValueMap(channel.labels),
|
||||||
|
metadataText: this.formatKeyValueMap(channel.metadata),
|
||||||
|
});
|
||||||
|
this.testPreview.set(null);
|
||||||
|
void this.loadChannelHealth(channelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
selectRule(ruleId: string): void {
|
||||||
|
const rule = this.rules().find((r) => r.ruleId === ruleId);
|
||||||
|
if (!rule) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.selectedRuleId.set(ruleId);
|
||||||
|
const action = rule.actions?.[0];
|
||||||
|
this.ruleForm.patchValue({
|
||||||
|
ruleId: rule.ruleId,
|
||||||
|
name: rule.name,
|
||||||
|
description: rule.description ?? '',
|
||||||
|
enabled: rule.enabled,
|
||||||
|
minSeverity: rule.match?.minSeverity ?? '',
|
||||||
|
eventKindsText: this.formatList(rule.match?.eventKinds ?? []),
|
||||||
|
labelsText: this.formatList(rule.match?.labels ?? []),
|
||||||
|
channel: action?.channel ?? this.channels()[0]?.channelId ?? '',
|
||||||
|
digest: action?.digest ?? '',
|
||||||
|
template: action?.template ?? '',
|
||||||
|
locale: action?.locale ?? '',
|
||||||
|
throttleSeconds: this.parseDuration(action?.throttle),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createChannelDraft(): void {
|
||||||
|
this.selectedChannelId.set(null);
|
||||||
|
this.channelForm.reset({
|
||||||
|
channelId: '',
|
||||||
|
name: '',
|
||||||
|
displayName: '',
|
||||||
|
description: '',
|
||||||
|
type: 'Slack',
|
||||||
|
target: '',
|
||||||
|
endpoint: '',
|
||||||
|
secretRef: '',
|
||||||
|
enabled: true,
|
||||||
|
labelsText: '',
|
||||||
|
metadataText: '',
|
||||||
|
});
|
||||||
|
this.channelHealth.set(null);
|
||||||
|
this.testPreview.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
createRuleDraft(): void {
|
||||||
|
this.selectedRuleId.set(null);
|
||||||
|
this.ruleForm.reset({
|
||||||
|
ruleId: '',
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
enabled: true,
|
||||||
|
minSeverity: 'high',
|
||||||
|
eventKindsText: 'scanner.report.ready',
|
||||||
|
labelsText: '',
|
||||||
|
channel: this.channels()[0]?.channelId ?? '',
|
||||||
|
digest: 'instant',
|
||||||
|
template: '',
|
||||||
|
locale: 'en-US',
|
||||||
|
throttleSeconds: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveChannel(): Promise<void> {
|
||||||
|
if (this.channelForm.invalid) {
|
||||||
|
this.channelForm.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.channelLoading.set(true);
|
||||||
|
this.channelMessage.set(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = this.buildChannelPayload();
|
||||||
|
const saved = await firstValueFrom(this.api.saveChannel(payload));
|
||||||
|
await this.loadChannels();
|
||||||
|
this.selectChannel(saved.channelId);
|
||||||
|
this.channelMessage.set('Channel saved successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
this.channelMessage.set(this.toErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
this.channelLoading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteChannel(): Promise<void> {
|
||||||
|
const channelId = this.selectedChannelId();
|
||||||
|
if (!channelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.channelLoading.set(true);
|
||||||
|
this.channelMessage.set(null);
|
||||||
|
try {
|
||||||
|
await firstValueFrom(this.api.deleteChannel(channelId));
|
||||||
|
await this.loadChannels();
|
||||||
|
if (this.channels().length) {
|
||||||
|
this.selectChannel(this.channels()[0].channelId);
|
||||||
|
} else {
|
||||||
|
this.createChannelDraft();
|
||||||
|
}
|
||||||
|
this.channelMessage.set('Channel deleted.');
|
||||||
|
} catch (error) {
|
||||||
|
this.channelMessage.set(this.toErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
this.channelLoading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveRule(): Promise<void> {
|
||||||
|
if (this.ruleForm.invalid) {
|
||||||
|
this.ruleForm.markAllAsTouched();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ruleLoading.set(true);
|
||||||
|
this.ruleMessage.set(null);
|
||||||
|
try {
|
||||||
|
const payload = this.buildRulePayload();
|
||||||
|
const saved = await firstValueFrom(this.api.saveRule(payload));
|
||||||
|
await this.loadRules();
|
||||||
|
this.selectRule(saved.ruleId);
|
||||||
|
this.ruleMessage.set('Rule saved successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
this.ruleMessage.set(this.toErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
this.ruleLoading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRule(): Promise<void> {
|
||||||
|
const ruleId = this.selectedRuleId();
|
||||||
|
if (!ruleId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.ruleLoading.set(true);
|
||||||
|
this.ruleMessage.set(null);
|
||||||
|
try {
|
||||||
|
await firstValueFrom(this.api.deleteRule(ruleId));
|
||||||
|
await this.loadRules();
|
||||||
|
if (this.rules().length) {
|
||||||
|
this.selectRule(this.rules()[0].ruleId);
|
||||||
|
} else {
|
||||||
|
this.createRuleDraft();
|
||||||
|
}
|
||||||
|
this.ruleMessage.set('Rule deleted.');
|
||||||
|
} catch (error) {
|
||||||
|
this.ruleMessage.set(this.toErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
this.ruleLoading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTestPreview(): Promise<void> {
|
||||||
|
const channelId = this.selectedChannelId();
|
||||||
|
if (!channelId) {
|
||||||
|
this.channelMessage.set('Select a channel before running a test send.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.testSending.set(true);
|
||||||
|
this.channelMessage.set(null);
|
||||||
|
try {
|
||||||
|
const payload = this.testForm.getRawValue();
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.api.testChannel(channelId, {
|
||||||
|
target: payload.target || undefined,
|
||||||
|
title: payload.title || undefined,
|
||||||
|
summary: payload.summary || undefined,
|
||||||
|
body: payload.body || undefined,
|
||||||
|
textBody: payload.textBody || undefined,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
this.testPreview.set(response);
|
||||||
|
this.channelMessage.set('Test send queued successfully.');
|
||||||
|
await this.loadDeliveries();
|
||||||
|
} catch (error) {
|
||||||
|
this.channelMessage.set(this.toErrorMessage(error));
|
||||||
|
} finally {
|
||||||
|
this.testSending.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshDeliveries(): Promise<void> {
|
||||||
|
await this.loadDeliveries();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeliveryFilterChange(rawValue: string): void {
|
||||||
|
const filter = this.isDeliveryFilter(rawValue) ? rawValue : 'all';
|
||||||
|
this.deliveryFilter.set(filter);
|
||||||
|
void this.loadDeliveries();
|
||||||
|
}
|
||||||
|
|
||||||
|
trackByChannel = (_: number, item: NotifyChannel) => item.channelId;
|
||||||
|
trackByRule = (_: number, item: NotifyRule) => item.ruleId;
|
||||||
|
trackByDelivery = (_: number, item: NotifyDelivery) => item.deliveryId;
|
||||||
|
|
||||||
|
private async loadChannelHealth(channelId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await firstValueFrom(
|
||||||
|
this.api.getChannelHealth(channelId)
|
||||||
|
);
|
||||||
|
this.channelHealth.set(response);
|
||||||
|
} catch {
|
||||||
|
this.channelHealth.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildChannelPayload(): NotifyChannel {
|
||||||
|
const raw = this.channelForm.getRawValue();
|
||||||
|
const existing = this.channels().find((c) => c.channelId === raw.channelId);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const channelId = raw.channelId?.trim() || this.generateId('chn');
|
||||||
|
const tenantId = existing?.tenantId ?? this.tenantId();
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: existing?.schemaVersion ?? '1.0',
|
||||||
|
channelId,
|
||||||
|
tenantId,
|
||||||
|
name: raw.name.trim(),
|
||||||
|
displayName: raw.displayName?.trim() || undefined,
|
||||||
|
description: raw.description?.trim() || undefined,
|
||||||
|
type: raw.type,
|
||||||
|
enabled: raw.enabled,
|
||||||
|
config: {
|
||||||
|
secretRef: raw.secretRef.trim(),
|
||||||
|
target: raw.target?.trim() || undefined,
|
||||||
|
endpoint: raw.endpoint?.trim() || undefined,
|
||||||
|
properties: existing?.config.properties ?? {},
|
||||||
|
limits: existing?.config.limits,
|
||||||
|
},
|
||||||
|
labels: this.parseKeyValueText(raw.labelsText),
|
||||||
|
metadata: this.parseKeyValueText(raw.metadataText),
|
||||||
|
createdBy: existing?.createdBy ?? 'ui@stella-ops.local',
|
||||||
|
createdAt: existing?.createdAt ?? now,
|
||||||
|
updatedBy: 'ui@stella-ops.local',
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRulePayload(): NotifyRule {
|
||||||
|
const raw = this.ruleForm.getRawValue();
|
||||||
|
const existing = this.rules().find((r) => r.ruleId === raw.ruleId);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const ruleId = raw.ruleId?.trim() || this.generateId('rule');
|
||||||
|
|
||||||
|
const action: NotifyRuleAction = {
|
||||||
|
actionId: existing?.actions?.[0]?.actionId ?? this.generateId('act'),
|
||||||
|
channel: raw.channel ?? this.channels()[0]?.channelId ?? '',
|
||||||
|
template: raw.template?.trim() || undefined,
|
||||||
|
digest: raw.digest?.trim() || undefined,
|
||||||
|
locale: raw.locale?.trim() || undefined,
|
||||||
|
throttle:
|
||||||
|
raw.throttleSeconds && raw.throttleSeconds > 0
|
||||||
|
? this.formatDuration(raw.throttleSeconds)
|
||||||
|
: null,
|
||||||
|
enabled: true,
|
||||||
|
metadata: existing?.actions?.[0]?.metadata ?? {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: existing?.schemaVersion ?? '1.0',
|
||||||
|
ruleId,
|
||||||
|
tenantId: existing?.tenantId ?? this.tenantId(),
|
||||||
|
name: raw.name.trim(),
|
||||||
|
description: raw.description?.trim() || undefined,
|
||||||
|
enabled: raw.enabled,
|
||||||
|
match: {
|
||||||
|
eventKinds: this.parseList(raw.eventKindsText),
|
||||||
|
labels: this.parseList(raw.labelsText),
|
||||||
|
minSeverity: raw.minSeverity?.trim() || null,
|
||||||
|
},
|
||||||
|
actions: [action],
|
||||||
|
labels: existing?.labels ?? {},
|
||||||
|
metadata: existing?.metadata ?? {},
|
||||||
|
createdBy: existing?.createdBy ?? 'ui@stella-ops.local',
|
||||||
|
createdAt: existing?.createdAt ?? now,
|
||||||
|
updatedBy: 'ui@stella-ops.local',
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseKeyValueText(value?: string | null): Record<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
if (!value) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
value
|
||||||
|
.split(/\r?\n|,/)
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((entry) => {
|
||||||
|
const [key, ...rest] = entry.split('=');
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result[key.trim()] = rest.join('=').trim();
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatKeyValueMap(
|
||||||
|
map?: Record<string, string> | null
|
||||||
|
): string {
|
||||||
|
if (!map) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return Object.entries(map)
|
||||||
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseList(value?: string | null): string[] {
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
.split(/\r?\n|,/)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatList(items: readonly string[]): string {
|
||||||
|
if (!items?.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return items.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDuration(duration?: string | null): number {
|
||||||
|
if (!duration) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (duration.startsWith('PT')) {
|
||||||
|
const hours = extractNumber(duration, /([0-9]+)H/);
|
||||||
|
const minutes = extractNumber(duration, /([0-9]+)M/);
|
||||||
|
const seconds = extractNumber(duration, /([0-9]+)S/);
|
||||||
|
return hours * 3600 + minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
const parts = duration.split(':').map((p) => Number.parseInt(p, 10));
|
||||||
|
if (parts.length === 3) {
|
||||||
|
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
|
}
|
||||||
|
return Number.parseInt(duration, 10) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatDuration(seconds: number): string {
|
||||||
|
const clamped = Math.max(0, Math.floor(seconds));
|
||||||
|
const hrs = Math.floor(clamped / 3600);
|
||||||
|
const mins = Math.floor((clamped % 3600) / 60);
|
||||||
|
const secs = clamped % 60;
|
||||||
|
let result = 'PT';
|
||||||
|
if (hrs) {
|
||||||
|
result += `${hrs}H`;
|
||||||
|
}
|
||||||
|
if (mins) {
|
||||||
|
result += `${mins}M`;
|
||||||
|
}
|
||||||
|
if (secs || result === 'PT') {
|
||||||
|
result += `${secs}S`;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapFilterToStatus(
|
||||||
|
filter: DeliveryFilter
|
||||||
|
): NotifyDeliveryStatus | undefined {
|
||||||
|
switch (filter) {
|
||||||
|
case 'pending':
|
||||||
|
return 'Pending';
|
||||||
|
case 'sent':
|
||||||
|
return 'Sent';
|
||||||
|
case 'failed':
|
||||||
|
return 'Failed';
|
||||||
|
case 'throttled':
|
||||||
|
return 'Throttled';
|
||||||
|
case 'digested':
|
||||||
|
return 'Digested';
|
||||||
|
case 'dropped':
|
||||||
|
return 'Dropped';
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isDeliveryFilter(value: string): value is DeliveryFilter {
|
||||||
|
return (
|
||||||
|
value === 'all' ||
|
||||||
|
value === 'pending' ||
|
||||||
|
value === 'sent' ||
|
||||||
|
value === 'failed' ||
|
||||||
|
value === 'throttled' ||
|
||||||
|
value === 'digested' ||
|
||||||
|
value === 'dropped'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
if (typeof error === 'string') {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
return 'Operation failed. Please retry.';
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateId(prefix: string): string {
|
||||||
|
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractNumber(source: string, pattern: RegExp): number {
|
||||||
|
const match = source.match(pattern);
|
||||||
|
return match ? Number.parseInt(match[1], 10) : 0;
|
||||||
|
}
|
||||||
290
src/StellaOps.Web/src/app/testing/mock-notify-api.service.ts
Normal file
290
src/StellaOps.Web/src/app/testing/mock-notify-api.service.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import { Injectable, signal } from '@angular/core';
|
||||||
|
import { defer, Observable, of } from 'rxjs';
|
||||||
|
import { delay } from 'rxjs/operators';
|
||||||
|
|
||||||
|
import { NotifyApi } from '../core/api/notify.client';
|
||||||
|
import {
|
||||||
|
ChannelHealthResponse,
|
||||||
|
ChannelTestSendRequest,
|
||||||
|
ChannelTestSendResponse,
|
||||||
|
ChannelHealthStatus,
|
||||||
|
NotifyChannel,
|
||||||
|
NotifyDeliveriesQueryOptions,
|
||||||
|
NotifyDeliveriesResponse,
|
||||||
|
NotifyDelivery,
|
||||||
|
NotifyDeliveryRendered,
|
||||||
|
NotifyRule,
|
||||||
|
} from '../core/api/notify.models';
|
||||||
|
import {
|
||||||
|
inferHealthStatus,
|
||||||
|
mockNotifyChannels,
|
||||||
|
mockNotifyDeliveries,
|
||||||
|
mockNotifyRules,
|
||||||
|
mockNotifyTenant,
|
||||||
|
} from './notify-fixtures';
|
||||||
|
|
||||||
|
const LATENCY_MS = 140;
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class MockNotifyApiService implements NotifyApi {
|
||||||
|
private readonly channels = signal<NotifyChannel[]>(
|
||||||
|
clone(mockNotifyChannels)
|
||||||
|
);
|
||||||
|
private readonly rules = signal<NotifyRule[]>(clone(mockNotifyRules));
|
||||||
|
private readonly deliveries = signal<NotifyDelivery[]>(
|
||||||
|
clone(mockNotifyDeliveries)
|
||||||
|
);
|
||||||
|
|
||||||
|
listChannels(): Observable<NotifyChannel[]> {
|
||||||
|
return this.simulate(() => this.channels());
|
||||||
|
}
|
||||||
|
|
||||||
|
saveChannel(channel: NotifyChannel): Observable<NotifyChannel> {
|
||||||
|
const next = this.enrichChannel(channel);
|
||||||
|
this.channels.update((items) => upsertById(items, next, (c) => c.channelId));
|
||||||
|
return this.simulate(() => next);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteChannel(channelId: string): Observable<void> {
|
||||||
|
this.channels.update((items) => items.filter((c) => c.channelId !== channelId));
|
||||||
|
return this.simulate(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
getChannelHealth(channelId: string): Observable<ChannelHealthResponse> {
|
||||||
|
const channel = this.channels().find((c) => c.channelId === channelId);
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const status: ChannelHealthStatus = channel
|
||||||
|
? inferHealthStatus(channel.enabled, !!channel.config.target)
|
||||||
|
: 'Unhealthy';
|
||||||
|
|
||||||
|
const response: ChannelHealthResponse = {
|
||||||
|
tenantId: mockNotifyTenant,
|
||||||
|
channelId,
|
||||||
|
status,
|
||||||
|
message:
|
||||||
|
status === 'Healthy'
|
||||||
|
? 'Channel configuration validated.'
|
||||||
|
: status === 'Degraded'
|
||||||
|
? 'Channel disabled. Enable to resume deliveries.'
|
||||||
|
: 'Channel is missing a destination target or endpoint.',
|
||||||
|
checkedAt: now,
|
||||||
|
traceId: this.traceId(),
|
||||||
|
metadata: channel?.metadata ?? {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return this.simulate(() => response, 90);
|
||||||
|
}
|
||||||
|
|
||||||
|
testChannel(
|
||||||
|
channelId: string,
|
||||||
|
payload: ChannelTestSendRequest
|
||||||
|
): Observable<ChannelTestSendResponse> {
|
||||||
|
const channel = this.channels().find((c) => c.channelId === channelId);
|
||||||
|
const preview: NotifyDeliveryRendered = {
|
||||||
|
channelType: channel?.type ?? 'Slack',
|
||||||
|
format: channel?.type === 'Email' ? 'Email' : 'Slack',
|
||||||
|
target:
|
||||||
|
payload.target ?? channel?.config.target ?? channel?.config.endpoint ?? 'demo@stella-ops.org',
|
||||||
|
title: payload.title ?? 'Notify preview — policy verdict change',
|
||||||
|
body:
|
||||||
|
payload.body ??
|
||||||
|
'Sample preview payload emitted by the mocked Notify API integration.',
|
||||||
|
summary: payload.summary ?? 'Mock delivery queued.',
|
||||||
|
textBody: payload.textBody,
|
||||||
|
locale: payload.locale ?? 'en-US',
|
||||||
|
attachments: payload.attachments ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const response: ChannelTestSendResponse = {
|
||||||
|
tenantId: mockNotifyTenant,
|
||||||
|
channelId,
|
||||||
|
preview,
|
||||||
|
queuedAt: new Date().toISOString(),
|
||||||
|
traceId: this.traceId(),
|
||||||
|
metadata: {
|
||||||
|
source: 'mock-service',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.appendDeliveryFromPreview(channelId, preview);
|
||||||
|
|
||||||
|
return this.simulate(() => response, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
listRules(): Observable<NotifyRule[]> {
|
||||||
|
return this.simulate(() => this.rules());
|
||||||
|
}
|
||||||
|
|
||||||
|
saveRule(rule: NotifyRule): Observable<NotifyRule> {
|
||||||
|
const next = this.enrichRule(rule);
|
||||||
|
this.rules.update((items) => upsertById(items, next, (r) => r.ruleId));
|
||||||
|
return this.simulate(() => next);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRule(ruleId: string): Observable<void> {
|
||||||
|
this.rules.update((items) => items.filter((rule) => rule.ruleId !== ruleId));
|
||||||
|
return this.simulate(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
listDeliveries(
|
||||||
|
options?: NotifyDeliveriesQueryOptions
|
||||||
|
): Observable<NotifyDeliveriesResponse> {
|
||||||
|
const filtered = this.filterDeliveries(options);
|
||||||
|
const payload: NotifyDeliveriesResponse = {
|
||||||
|
items: filtered,
|
||||||
|
continuationToken: null,
|
||||||
|
count: filtered.length,
|
||||||
|
};
|
||||||
|
return this.simulate(() => payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private enrichChannel(channel: NotifyChannel): NotifyChannel {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const current = this.channels().find((c) => c.channelId === channel.channelId);
|
||||||
|
return {
|
||||||
|
schemaVersion: channel.schemaVersion ?? current?.schemaVersion ?? '1.0',
|
||||||
|
channelId: channel.channelId || this.randomId('chn'),
|
||||||
|
tenantId: channel.tenantId || mockNotifyTenant,
|
||||||
|
name: channel.name,
|
||||||
|
displayName: channel.displayName,
|
||||||
|
description: channel.description,
|
||||||
|
type: channel.type,
|
||||||
|
enabled: channel.enabled,
|
||||||
|
config: {
|
||||||
|
...channel.config,
|
||||||
|
properties: channel.config.properties ?? current?.config.properties ?? {},
|
||||||
|
},
|
||||||
|
labels: channel.labels ?? current?.labels ?? {},
|
||||||
|
metadata: channel.metadata ?? current?.metadata ?? {},
|
||||||
|
createdBy: current?.createdBy ?? 'ui@stella-ops.org',
|
||||||
|
createdAt: current?.createdAt ?? now,
|
||||||
|
updatedBy: 'ui@stella-ops.org',
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private enrichRule(rule: NotifyRule): NotifyRule {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const current = this.rules().find((r) => r.ruleId === rule.ruleId);
|
||||||
|
return {
|
||||||
|
schemaVersion: rule.schemaVersion ?? current?.schemaVersion ?? '1.0',
|
||||||
|
ruleId: rule.ruleId || this.randomId('rule'),
|
||||||
|
tenantId: rule.tenantId || mockNotifyTenant,
|
||||||
|
name: rule.name,
|
||||||
|
description: rule.description,
|
||||||
|
enabled: rule.enabled,
|
||||||
|
match: rule.match,
|
||||||
|
actions: rule.actions?.length
|
||||||
|
? rule.actions
|
||||||
|
: current?.actions ?? [],
|
||||||
|
labels: rule.labels ?? current?.labels ?? {},
|
||||||
|
metadata: rule.metadata ?? current?.metadata ?? {},
|
||||||
|
createdBy: current?.createdBy ?? 'ui@stella-ops.org',
|
||||||
|
createdAt: current?.createdAt ?? now,
|
||||||
|
updatedBy: 'ui@stella-ops.org',
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendDeliveryFromPreview(
|
||||||
|
channelId: string,
|
||||||
|
preview: NotifyDeliveryRendered
|
||||||
|
): void {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const delivery: NotifyDelivery = {
|
||||||
|
deliveryId: this.randomId('dlv'),
|
||||||
|
tenantId: mockNotifyTenant,
|
||||||
|
ruleId: 'rule-critical-soc',
|
||||||
|
actionId: 'act-slack-critical',
|
||||||
|
eventId: cryptoRandomUuid(),
|
||||||
|
kind: 'notify.preview',
|
||||||
|
status: 'Sent',
|
||||||
|
statusReason: 'Preview enqueued (mock)',
|
||||||
|
rendered: preview,
|
||||||
|
attempts: [
|
||||||
|
{
|
||||||
|
timestamp: now,
|
||||||
|
status: 'Enqueued',
|
||||||
|
statusCode: 202,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: now,
|
||||||
|
status: 'Succeeded',
|
||||||
|
statusCode: 200,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
previewChannel: channelId,
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
sentAt: now,
|
||||||
|
completedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.deliveries.update((items) => [delivery, ...items].slice(0, 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterDeliveries(
|
||||||
|
options?: NotifyDeliveriesQueryOptions
|
||||||
|
): NotifyDelivery[] {
|
||||||
|
const source = this.deliveries();
|
||||||
|
const since = options?.since ? Date.parse(options.since) : null;
|
||||||
|
const status = options?.status;
|
||||||
|
|
||||||
|
return source
|
||||||
|
.filter((item) => {
|
||||||
|
const matchStatus = status ? item.status === status : true;
|
||||||
|
const matchSince = since ? Date.parse(item.createdAt) >= since : true;
|
||||||
|
return matchStatus && matchSince;
|
||||||
|
})
|
||||||
|
.slice(0, options?.limit ?? 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
private simulate<T>(factory: () => T, ms: number = LATENCY_MS): Observable<T> {
|
||||||
|
return defer(() => of(clone(factory()))).pipe(delay(ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
private randomId(prefix: string): string {
|
||||||
|
const raw = cryptoRandomUuid().replace(/-/g, '').slice(0, 12);
|
||||||
|
return `${prefix}-${raw}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private traceId(): string {
|
||||||
|
return `trace-${cryptoRandomUuid()}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertById<T>(
|
||||||
|
collection: readonly T[],
|
||||||
|
entity: T,
|
||||||
|
selector: (item: T) => string
|
||||||
|
): T[] {
|
||||||
|
const id = selector(entity);
|
||||||
|
const next = [...collection];
|
||||||
|
const index = next.findIndex((item) => selector(item) === id);
|
||||||
|
if (index >= 0) {
|
||||||
|
next[index] = entity;
|
||||||
|
} else {
|
||||||
|
next.unshift(entity);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clone<T>(value: T): T {
|
||||||
|
if (typeof structuredClone === 'function') {
|
||||||
|
return structuredClone(value);
|
||||||
|
}
|
||||||
|
return JSON.parse(JSON.stringify(value)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cryptoRandomUuid(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
const template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx';
|
||||||
|
return template.replace(/[xy]/g, (c) => {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
257
src/StellaOps.Web/src/app/testing/notify-fixtures.ts
Normal file
257
src/StellaOps.Web/src/app/testing/notify-fixtures.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import {
|
||||||
|
ChannelHealthStatus,
|
||||||
|
NotifyChannel,
|
||||||
|
NotifyDelivery,
|
||||||
|
NotifyDeliveryAttemptStatus,
|
||||||
|
NotifyDeliveryStatus,
|
||||||
|
NotifyRule,
|
||||||
|
} from '../core/api/notify.models';
|
||||||
|
|
||||||
|
export const mockNotifyTenant = 'tenant-dev';
|
||||||
|
|
||||||
|
export const mockNotifyChannels: NotifyChannel[] = [
|
||||||
|
{
|
||||||
|
channelId: 'chn-slack-soc',
|
||||||
|
tenantId: mockNotifyTenant,
|
||||||
|
name: 'slack-soc',
|
||||||
|
displayName: 'Slack · SOC',
|
||||||
|
description: 'Critical scanner verdicts routed to the SOC war room.',
|
||||||
|
type: 'Slack',
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
secretRef: 'ref://notify/slack/soc-token',
|
||||||
|
target: '#stellaops-soc',
|
||||||
|
properties: {
|
||||||
|
emoji: ':rotating_light:',
|
||||||
|
unfurl: 'false',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
tier: 'critical',
|
||||||
|
region: 'global',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
workspace: 'stellaops',
|
||||||
|
},
|
||||||
|
createdBy: 'ops@stella-ops.org',
|
||||||
|
createdAt: '2025-10-10T08:12:00Z',
|
||||||
|
updatedBy: 'ops@stella-ops.org',
|
||||||
|
updatedAt: '2025-10-23T11:05:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channelId: 'chn-email-comms',
|
||||||
|
tenantId: mockNotifyTenant,
|
||||||
|
name: 'email-compliance',
|
||||||
|
displayName: 'Email · Compliance Digest',
|
||||||
|
description: 'Hourly compliance digest for licensing/secrets alerts.',
|
||||||
|
type: 'Email',
|
||||||
|
enabled: true,
|
||||||
|
config: {
|
||||||
|
secretRef: 'ref://notify/smtp/compliance',
|
||||||
|
target: 'compliance@stella-ops.org',
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
cadence: 'hourly',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
smtpProfile: 'smtp.internal',
|
||||||
|
},
|
||||||
|
createdBy: 'legal@stella-ops.org',
|
||||||
|
createdAt: '2025-10-08T14:31:00Z',
|
||||||
|
updatedBy: 'legal@stella-ops.org',
|
||||||
|
updatedAt: '2025-10-20T09:44:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
channelId: 'chn-webhook-intake',
|
||||||
|
tenantId: mockNotifyTenant,
|
||||||
|
name: 'webhook-opsbridge',
|
||||||
|
displayName: 'Webhook · OpsBridge',
|
||||||
|
description: 'Bridges Notify events into OpsBridge for automation.',
|
||||||
|
type: 'Webhook',
|
||||||
|
enabled: false,
|
||||||
|
config: {
|
||||||
|
secretRef: 'ref://notify/webhook/signing',
|
||||||
|
endpoint: 'https://opsbridge.internal/hooks/notify',
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
env: 'staging',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
signature: 'ed25519',
|
||||||
|
},
|
||||||
|
createdBy: 'platform@stella-ops.org',
|
||||||
|
createdAt: '2025-10-05T12:01:00Z',
|
||||||
|
updatedBy: 'platform@stella-ops.org',
|
||||||
|
updatedAt: '2025-10-18T17:22:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockNotifyRules: NotifyRule[] = [
|
||||||
|
{
|
||||||
|
ruleId: 'rule-critical-soc',
|
||||||
|
tenantId: mockNotifyTenant,
|
||||||
|
name: 'Critical scanner verdicts',
|
||||||
|
description:
|
||||||
|
'Route KEV-tagged critical findings to SOC Slack with zero delay.',
|
||||||
|
enabled: true,
|
||||||
|
match: {
|
||||||
|
eventKinds: ['scanner.report.ready'],
|
||||||
|
labels: ['kev', 'critical'],
|
||||||
|
minSeverity: 'critical',
|
||||||
|
verdicts: ['block', 'escalate'],
|
||||||
|
kevOnly: true,
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
actionId: 'act-slack-critical',
|
||||||
|
channel: 'chn-slack-soc',
|
||||||
|
template: 'tmpl-critical',
|
||||||
|
digest: 'instant',
|
||||||
|
throttle: 'PT300S',
|
||||||
|
locale: 'en-US',
|
||||||
|
enabled: true,
|
||||||
|
metadata: {
|
||||||
|
priority: 'p1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
labels: {
|
||||||
|
owner: 'soc',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
revision: '12',
|
||||||
|
},
|
||||||
|
createdBy: 'soc@stella-ops.org',
|
||||||
|
createdAt: '2025-10-12T10:02:00Z',
|
||||||
|
updatedBy: 'soc@stella-ops.org',
|
||||||
|
updatedAt: '2025-10-23T15:44:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ruleId: 'rule-digest-compliance',
|
||||||
|
tenantId: mockNotifyTenant,
|
||||||
|
name: 'Compliance hourly digest',
|
||||||
|
description: 'Summarise licensing + secret alerts once per hour.',
|
||||||
|
enabled: true,
|
||||||
|
match: {
|
||||||
|
eventKinds: ['scanner.scan.completed', 'scanner.report.ready'],
|
||||||
|
labels: ['compliance'],
|
||||||
|
minSeverity: 'medium',
|
||||||
|
kevOnly: false,
|
||||||
|
vex: {
|
||||||
|
includeAcceptedJustifications: true,
|
||||||
|
includeRejectedJustifications: false,
|
||||||
|
includeUnknownJustifications: true,
|
||||||
|
justificationKinds: ['exploitable', 'component_not_present'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
actionId: 'act-email-compliance',
|
||||||
|
channel: 'chn-email-comms',
|
||||||
|
digest: '1h',
|
||||||
|
throttle: 'PT1H',
|
||||||
|
enabled: true,
|
||||||
|
metadata: {
|
||||||
|
layout: 'digest',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
labels: {
|
||||||
|
owner: 'compliance',
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
frequency: 'hourly',
|
||||||
|
},
|
||||||
|
createdBy: 'compliance@stella-ops.org',
|
||||||
|
createdAt: '2025-10-09T06:15:00Z',
|
||||||
|
updatedBy: 'compliance@stella-ops.org',
|
||||||
|
updatedAt: '2025-10-21T19:45:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const deliveryStatuses: NotifyDeliveryStatus[] = [
|
||||||
|
'Sent',
|
||||||
|
'Failed',
|
||||||
|
'Throttled',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockNotifyDeliveries: NotifyDelivery[] = deliveryStatuses.map(
|
||||||
|
(status, index) => {
|
||||||
|
const now = new Date('2025-10-24T12:00:00Z').getTime();
|
||||||
|
const created = new Date(now - index * 20 * 60 * 1000).toISOString();
|
||||||
|
const attemptsStatus: NotifyDeliveryAttemptStatus =
|
||||||
|
status === 'Sent' ? 'Succeeded' : status === 'Failed' ? 'Failed' : 'Throttled';
|
||||||
|
|
||||||
|
return {
|
||||||
|
deliveryId: `dlv-${index + 1}`,
|
||||||
|
tenantId: mockNotifyTenant,
|
||||||
|
ruleId: index === 0 ? 'rule-critical-soc' : 'rule-digest-compliance',
|
||||||
|
actionId: index === 0 ? 'act-slack-critical' : 'act-email-compliance',
|
||||||
|
eventId: `00000000-0000-0000-0000-${(index + 1)
|
||||||
|
.toString()
|
||||||
|
.padStart(12, '0')}`,
|
||||||
|
kind: index === 0 ? 'scanner.report.ready' : 'scanner.scan.completed',
|
||||||
|
status,
|
||||||
|
statusReason:
|
||||||
|
status === 'Sent'
|
||||||
|
? 'Delivered'
|
||||||
|
: status === 'Failed'
|
||||||
|
? 'Channel timeout (Slack API)'
|
||||||
|
: 'Rule throttled (digest window).',
|
||||||
|
rendered: {
|
||||||
|
channelType: index === 0 ? 'Slack' : 'Email',
|
||||||
|
format: index === 0 ? 'Slack' : 'Email',
|
||||||
|
target: index === 0 ? '#stellaops-soc' : 'compliance@stella-ops.org',
|
||||||
|
title:
|
||||||
|
index === 0
|
||||||
|
? 'Critical CVE flagged for registry.git.stella-ops.org'
|
||||||
|
: 'Hourly compliance digest (#23)',
|
||||||
|
body:
|
||||||
|
index === 0
|
||||||
|
? 'KEV CVE-2025-1234 detected in ubuntu:24.04. Rescan triggered.'
|
||||||
|
: '3 findings require compliance review. See attached report.',
|
||||||
|
summary: index === 0 ? 'Immediate attention required.' : 'Digest only.',
|
||||||
|
locale: 'en-US',
|
||||||
|
attachments: index === 0 ? [] : ['https://scanner.local/reports/digest-23'],
|
||||||
|
},
|
||||||
|
attempts: [
|
||||||
|
{
|
||||||
|
timestamp: created,
|
||||||
|
status: 'Sending',
|
||||||
|
statusCode: 202,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: created,
|
||||||
|
status: attemptsStatus,
|
||||||
|
statusCode: status === 'Sent' ? 200 : 429,
|
||||||
|
reason:
|
||||||
|
status === 'Failed'
|
||||||
|
? 'Slack API returned 504'
|
||||||
|
: status === 'Throttled'
|
||||||
|
? 'Digest window open'
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
batch: `window-${index + 1}`,
|
||||||
|
},
|
||||||
|
createdAt: created,
|
||||||
|
sentAt: created,
|
||||||
|
completedAt: created,
|
||||||
|
} satisfies NotifyDelivery;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export function inferHealthStatus(
|
||||||
|
enabled: boolean,
|
||||||
|
hasTarget: boolean
|
||||||
|
): ChannelHealthStatus {
|
||||||
|
if (!hasTarget) {
|
||||||
|
return 'Unhealthy';
|
||||||
|
}
|
||||||
|
if (!enabled) {
|
||||||
|
return 'Degraded';
|
||||||
|
}
|
||||||
|
return 'Healthy';
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using StellaOps.Zastava.Core.Contracts;
|
||||||
|
using StellaOps.Zastava.Observer.Configuration;
|
||||||
|
using StellaOps.Zastava.Observer.ContainerRuntime;
|
||||||
|
using StellaOps.Zastava.Observer.ContainerRuntime.Cri;
|
||||||
|
using StellaOps.Zastava.Observer.Runtime;
|
||||||
|
using StellaOps.Zastava.Observer.Worker;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace StellaOps.Zastava.Observer.Tests.Worker;
|
||||||
|
|
||||||
|
public sealed class RuntimeEventFactoryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Create_AttachesBuildIdFromProcessCapture()
|
||||||
|
{
|
||||||
|
var timestamp = DateTimeOffset.UtcNow;
|
||||||
|
var snapshot = new CriContainerInfo(
|
||||||
|
Id: "container-a",
|
||||||
|
PodSandboxId: "sandbox-a",
|
||||||
|
Name: "api",
|
||||||
|
Attempt: 1,
|
||||||
|
Image: "ghcr.io/example/api:1.0",
|
||||||
|
ImageRef: "ghcr.io/example/api@sha256:deadbeef",
|
||||||
|
Labels: new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[CriLabelKeys.PodName] = "api-abc",
|
||||||
|
[CriLabelKeys.PodNamespace] = "payments",
|
||||||
|
[CriLabelKeys.ContainerName] = "api"
|
||||||
|
},
|
||||||
|
Annotations: new Dictionary<string, string>(),
|
||||||
|
CreatedAt: timestamp,
|
||||||
|
StartedAt: timestamp,
|
||||||
|
FinishedAt: null,
|
||||||
|
ExitCode: null,
|
||||||
|
Reason: null,
|
||||||
|
Message: null,
|
||||||
|
Pid: 4321);
|
||||||
|
|
||||||
|
var lifecycleEvent = new ContainerLifecycleEvent(ContainerLifecycleEventKind.Start, timestamp, snapshot);
|
||||||
|
var endpoint = new ContainerRuntimeEndpointOptions
|
||||||
|
{
|
||||||
|
Engine = ContainerRuntimeEngine.Containerd,
|
||||||
|
Endpoint = "unix:///run/containerd/containerd.sock",
|
||||||
|
Name = "containerd"
|
||||||
|
};
|
||||||
|
var identity = new CriRuntimeIdentity("containerd", "1.7.19", "v1");
|
||||||
|
var process = new RuntimeProcess
|
||||||
|
{
|
||||||
|
Pid = 4321,
|
||||||
|
Entrypoint = new[] { "/entrypoint.sh" },
|
||||||
|
EntryTrace = Array.Empty<RuntimeEntryTrace>(),
|
||||||
|
BuildId = "5f0c7c3cb4d9f8a4"
|
||||||
|
};
|
||||||
|
var capture = new RuntimeProcessCapture(
|
||||||
|
process,
|
||||||
|
Array.Empty<RuntimeLoadedLibrary>(),
|
||||||
|
new List<RuntimeEvidence>());
|
||||||
|
|
||||||
|
var envelope = RuntimeEventFactory.Create(
|
||||||
|
lifecycleEvent,
|
||||||
|
endpoint,
|
||||||
|
identity,
|
||||||
|
tenant: "tenant-alpha",
|
||||||
|
nodeName: "node-1",
|
||||||
|
capture: capture,
|
||||||
|
posture: null,
|
||||||
|
additionalEvidence: null);
|
||||||
|
|
||||||
|
Assert.NotNull(envelope.Event.Process);
|
||||||
|
Assert.Equal("5f0c7c3cb4d9f8a4", envelope.Event.Process!.BuildId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,6 @@
|
|||||||
| ZASTAVA-OBS-12-002 | DONE (2025-10-24) | Zastava Observer Guild | ZASTAVA-OBS-12-001 | Capture entrypoint traces and loaded libraries, hashing binaries and correlating to SBOM baseline per architecture sections 2.1 and 10. | EntryTrace parser covers shell/python/node launchers, loaded library hashes recorded, fixtures assert linkage to SBOM usage view. |
|
| ZASTAVA-OBS-12-002 | DONE (2025-10-24) | Zastava Observer Guild | ZASTAVA-OBS-12-001 | Capture entrypoint traces and loaded libraries, hashing binaries and correlating to SBOM baseline per architecture sections 2.1 and 10. | EntryTrace parser covers shell/python/node launchers, loaded library hashes recorded, fixtures assert linkage to SBOM usage view. |
|
||||||
| ZASTAVA-OBS-12-003 | DONE (2025-10-24) | Zastava Observer Guild | ZASTAVA-OBS-12-002 | Implement runtime posture checks (signature/SBOM/attestation presence) with offline caching and warning surfaces. | Observer marks posture status, caches refresh across restarts, integration tests prove offline tolerance. |
|
| ZASTAVA-OBS-12-003 | DONE (2025-10-24) | Zastava Observer Guild | ZASTAVA-OBS-12-002 | Implement runtime posture checks (signature/SBOM/attestation presence) with offline caching and warning surfaces. | Observer marks posture status, caches refresh across restarts, integration tests prove offline tolerance. |
|
||||||
| ZASTAVA-OBS-12-004 | DONE (2025-10-24) | Zastava Observer Guild | ZASTAVA-OBS-12-002 | Batch `/runtime/events` submissions with disk-backed buffer, rate limits, and deterministic envelopes. | Buffered submissions survive restart, rate-limits enforced in tests, JSON envelopes match schema in docs/events. |
|
| ZASTAVA-OBS-12-004 | DONE (2025-10-24) | Zastava Observer Guild | ZASTAVA-OBS-12-002 | Batch `/runtime/events` submissions with disk-backed buffer, rate limits, and deterministic envelopes. | Buffered submissions survive restart, rate-limits enforced in tests, JSON envelopes match schema in docs/events. |
|
||||||
| ZASTAVA-OBS-17-005 | DOING (2025-10-24) | Zastava Observer Guild | ZASTAVA-OBS-12-002 | Collect GNU build-id for ELF processes and attach it to emitted runtime events to enable symbol lookup + debug-store correlation. | Observer reads build-id via `/proc/<pid>/exe`/notes without pausing workloads, runtime events include `buildId` field, fixtures cover glibc/musl images, docs updated with retrieval notes. |
|
| ZASTAVA-OBS-17-005 | DONE (2025-10-25) | Zastava Observer Guild | ZASTAVA-OBS-12-002 | Collect GNU build-id for ELF processes and attach it to emitted runtime events to enable symbol lookup + debug-store correlation. | Build-id extraction feeds RuntimeEvent envelopes plus Scanner policy downstream; unit tests cover capture + envelope wiring, and ops runbook documents retrieval + debug-store mapping. |
|
||||||
|
|
||||||
> 2025-10-24: Observer unit tests pending; `dotnet restore` requires offline copies of `Google.Protobuf`, `Grpc.Net.Client`, `Grpc.Tools` in `local-nuget` before execution can be verified.
|
> 2025-10-24: Observer unit tests pending; `dotnet restore` requires offline copies of `Google.Protobuf`, `Grpc.Net.Client`, `Grpc.Tools` in `local-nuget` before execution can be verified.
|
||||||
|
|||||||
Reference in New Issue
Block a user