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:
@@ -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-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-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
|
||||
- 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")]
|
||||
public string? ImageRef { get; set; }
|
||||
|
||||
[BsonElement("imageDigest")]
|
||||
public string? ImageDigest { get; set; }
|
||||
|
||||
[BsonElement("engine")]
|
||||
public string? Engine { get; set; }
|
||||
|
||||
@@ -78,6 +81,9 @@ public sealed class RuntimeEventDocument
|
||||
[BsonElement("sbomReferrer")]
|
||||
public string? SbomReferrer { get; set; }
|
||||
|
||||
[BsonElement("buildId")]
|
||||
public string? BuildId { get; set; }
|
||||
|
||||
[BsonElement("payload")]
|
||||
public BsonDocument Payload { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -195,6 +195,16 @@ public sealed class MongoBootstrapper
|
||||
.Ascending(x => x.Node)
|
||||
.Ascending(x => x.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(
|
||||
Builders<RuntimeEventDocument>.IndexKeys.Ascending(x => x.ExpiresAt),
|
||||
new CreateIndexOptions
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MongoDB.Driver;
|
||||
using StellaOps.Scanner.Storage.Catalog;
|
||||
using StellaOps.Scanner.Storage.Mongo;
|
||||
@@ -48,9 +50,83 @@ public sealed class RuntimeEventRepository
|
||||
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 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",
|
||||
Events = new[]
|
||||
{
|
||||
CreateEnvelope("evt-001"),
|
||||
CreateEnvelope("evt-002")
|
||||
CreateEnvelope("evt-001", buildId: "ABCDEF1234567890ABCDEF1234567890ABCDEF12"),
|
||||
CreateEnvelope("evt-002", buildId: "abcdef1234567890abcdef1234567890abcdef12")
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,6 +50,8 @@ public sealed class RuntimeEndpointsTests
|
||||
{
|
||||
Assert.Equal("tenant-alpha", doc.Tenant);
|
||||
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
|
||||
{
|
||||
Namespace = "payments",
|
||||
@@ -215,6 +228,8 @@ rules:
|
||||
Assert.InRange(decision.Confidence!.Value, 0.0, 1.0);
|
||||
Assert.False(decision.Quieted.GetValueOrDefault());
|
||||
Assert.Null(decision.QuietedBy);
|
||||
Assert.NotNull(decision.BuildIds);
|
||||
Assert.Contains("1122aabbccddeeff00112233445566778899aabb", decision.BuildIds!);
|
||||
var metadataString = decision.Metadata;
|
||||
Console.WriteLine($"Runtime policy metadata: {metadataString ?? "<null>"}");
|
||||
Assert.False(string.IsNullOrWhiteSpace(metadataString));
|
||||
@@ -293,8 +308,13 @@ rules: []
|
||||
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
|
||||
{
|
||||
EventId = eventId,
|
||||
@@ -314,7 +334,18 @@ rules: []
|
||||
Pod = "api-123",
|
||||
Container = "api",
|
||||
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")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Metadata { get; init; }
|
||||
|
||||
[JsonPropertyName("buildIds")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public IReadOnlyList<string>? BuildIds { get; init; }
|
||||
}
|
||||
|
||||
public sealed record RuntimePolicyRekorDto
|
||||
|
||||
@@ -311,7 +311,8 @@ internal static class PolicyEndpoints
|
||||
Confidence = Math.Round(decision.Confidence, 6, MidpointRounding.AwayFromZero),
|
||||
Quieted = decision.Quieted,
|
||||
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 runtimeEvent = envelope.Event;
|
||||
var normalizedDigest = ExtractImageDigest(runtimeEvent);
|
||||
var normalizedBuildId = NormalizeBuildId(runtimeEvent.Process?.BuildId);
|
||||
|
||||
var document = new RuntimeEventDocument
|
||||
{
|
||||
@@ -104,11 +106,13 @@ internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionServi
|
||||
Container = runtimeEvent.Workload.Container,
|
||||
ContainerId = runtimeEvent.Workload.ContainerId,
|
||||
ImageRef = runtimeEvent.Workload.ImageRef,
|
||||
ImageDigest = normalizedDigest,
|
||||
Engine = runtimeEvent.Runtime.Engine,
|
||||
EngineVersion = runtimeEvent.Runtime.Version,
|
||||
BaselineDigest = runtimeEvent.Delta?.BaselineImageDigest,
|
||||
ImageSigned = runtimeEvent.Posture?.ImageSigned,
|
||||
SbomReferrer = runtimeEvent.Posture?.SbomReferrer,
|
||||
BuildId = normalizedBuildId,
|
||||
Payload = payloadDocument
|
||||
};
|
||||
|
||||
@@ -125,6 +129,66 @@ internal sealed class RuntimeEventIngestionService : IRuntimeEventIngestionServi
|
||||
|
||||
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(
|
||||
|
||||
@@ -26,12 +26,15 @@ internal interface 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 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 readonly LinkRepository _linkRepository;
|
||||
private readonly ArtifactRepository _artifactRepository;
|
||||
private readonly RuntimeEventRepository _runtimeEventRepository;
|
||||
private readonly PolicySnapshotStore _policySnapshotStore;
|
||||
private readonly PolicyPreviewService _policyPreviewService;
|
||||
private readonly IOptionsMonitor<ScannerWebServiceOptions> _optionsMonitor;
|
||||
@@ -42,6 +45,7 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
public RuntimePolicyService(
|
||||
LinkRepository linkRepository,
|
||||
ArtifactRepository artifactRepository,
|
||||
RuntimeEventRepository runtimeEventRepository,
|
||||
PolicySnapshotStore policySnapshotStore,
|
||||
PolicyPreviewService policyPreviewService,
|
||||
IOptionsMonitor<ScannerWebServiceOptions> optionsMonitor,
|
||||
@@ -51,6 +55,7 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
{
|
||||
_linkRepository = linkRepository ?? throw new ArgumentNullException(nameof(linkRepository));
|
||||
_artifactRepository = artifactRepository ?? throw new ArgumentNullException(nameof(artifactRepository));
|
||||
_runtimeEventRepository = runtimeEventRepository ?? throw new ArgumentNullException(nameof(runtimeEventRepository));
|
||||
_policySnapshotStore = policySnapshotStore ?? throw new ArgumentNullException(nameof(policySnapshotStore));
|
||||
_policyPreviewService = policyPreviewService ?? throw new ArgumentNullException(nameof(policyPreviewService));
|
||||
_optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
@@ -82,6 +87,10 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
new("namespace", request.Namespace ?? "unspecified")
|
||||
};
|
||||
|
||||
var buildIdObservations = await _runtimeEventRepository
|
||||
.GetRecentBuildIdsAsync(request.Images, MaxBuildIdsPerImage, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
var normalizedImage = image.Trim().ToLowerInvariant();
|
||||
buildIdObservations.TryGetValue(normalizedImage, out var buildIdObservation);
|
||||
|
||||
var decision = await BuildDecisionAsync(
|
||||
image,
|
||||
metadata,
|
||||
@@ -133,6 +145,7 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
projectedVerdicts,
|
||||
issues,
|
||||
policyDigest,
|
||||
buildIdObservation?.BuildIds,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
results[image] = decision;
|
||||
@@ -260,6 +273,7 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts,
|
||||
ImmutableArray<PolicyIssue> issues,
|
||||
string? policyDigest,
|
||||
IReadOnlyList<string>? buildIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var reasons = new List<string>(heuristicReasons);
|
||||
@@ -315,7 +329,8 @@ internal sealed class RuntimePolicyService : IRuntimePolicyService
|
||||
metadataPayload,
|
||||
confidence,
|
||||
quieted,
|
||||
quietedBy);
|
||||
quietedBy,
|
||||
buildIds);
|
||||
}
|
||||
|
||||
private RuntimePolicyVerdict MapVerdict(ImmutableArray<CanonicalPolicyVerdict> projectedVerdicts, IReadOnlyList<string> heuristicReasons)
|
||||
@@ -485,7 +500,8 @@ internal sealed record RuntimePolicyImageDecision(
|
||||
IDictionary<string, object?>? Metadata,
|
||||
double Confidence,
|
||||
bool Quieted,
|
||||
string? QuietedBy);
|
||||
string? QuietedBy,
|
||||
IReadOnlyList<string>? BuildIds);
|
||||
|
||||
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-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-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
|
||||
- 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-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-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. |
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<a routerLink="/scans/scan-verified-001" routerLinkActive="active">
|
||||
Scan Detail
|
||||
</a>
|
||||
<a routerLink="/notify" routerLinkActive="active">
|
||||
Notify
|
||||
</a>
|
||||
</nav>
|
||||
<div class="app-auth">
|
||||
<ng-container *ngIf="isAuthenticated(); else signIn">
|
||||
|
||||
@@ -4,8 +4,14 @@ import { provideRouter } from '@angular/router';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
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 { AuthHttpInterceptor } from './core/auth/auth-http.interceptor';
|
||||
import { MockNotifyApiService } from './testing/mock-notify-api.service';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
@@ -27,5 +33,18 @@ export const appConfig: ApplicationConfig = {
|
||||
provide: CONCELIER_EXPORTER_API_BASE_URL,
|
||||
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
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'notify',
|
||||
loadComponent: () =>
|
||||
import('./features/notify/notify-panel.component').then(
|
||||
(m) => m.NotifyPanelComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'auth/callback',
|
||||
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-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-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.
|
||||
|
||||
Reference in New Issue
Block a user