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:
2025-10-25 19:11:38 +03:00
parent b51037a9b8
commit 1e41ba7ffa
37 changed files with 2814 additions and 67 deletions

View File

@@ -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.

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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
}
};

View File

@@ -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

View File

@@ -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
};
}

View File

@@ -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(

View File

@@ -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);

View File

@@ -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`.

View File

@@ -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. |

View File

@@ -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">

View File

@@ -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,
},
],
};

View File

@@ -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: () =>

View 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 });
}
}

View 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>;
}

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -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();
});
});

View File

@@ -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;
}

View 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);
});
}

View 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';
}

View File

@@ -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);
}
}

View File

@@ -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.