feat: add PolicyPackSelectorComponent with tests and integration
- Implemented PolicyPackSelectorComponent for selecting policy packs. - Added unit tests for component behavior, including API success and error handling. - Introduced monaco-workers type declarations for editor workers. - Created acceptance tests for guardrails with stubs for AT1–AT10. - Established SCA Failure Catalogue Fixtures for regression testing. - Developed plugin determinism harness with stubs for PL1–PL10. - Added scripts for evidence upload and verification processes.
This commit is contained in:
@@ -40,7 +40,7 @@ internal static class TaskPackPlanHasher
|
||||
var json = CanonicalJson.Serialize(canonical);
|
||||
using var sha256 = SHA256.Create();
|
||||
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json));
|
||||
return ConvertToHex(hashBytes);
|
||||
return $"sha256:{ConvertToHex(hashBytes)}";
|
||||
}
|
||||
|
||||
private static string ConvertToHex(byte[] hashBytes)
|
||||
|
||||
@@ -22,16 +22,17 @@ public sealed class TaskPackPlanner
|
||||
this.egressPolicy = egressPolicy;
|
||||
}
|
||||
|
||||
public TaskPackPlanResult Plan(TaskPackManifest manifest, IDictionary<string, JsonNode?>? providedInputs = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var errors = ImmutableArray.CreateBuilder<TaskPackPlanError>();
|
||||
|
||||
var validation = validator.Validate(manifest);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
foreach (var error in validation.Errors)
|
||||
public TaskPackPlanResult Plan(TaskPackManifest manifest, IDictionary<string, JsonNode?>? providedInputs = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var errors = ImmutableArray.CreateBuilder<TaskPackPlanError>();
|
||||
ValidateSandboxAndSlo(manifest, errors);
|
||||
|
||||
var validation = validator.Validate(manifest);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
foreach (var error in validation.Errors)
|
||||
{
|
||||
errors.Add(new TaskPackPlanError(error.Path, error.Message));
|
||||
}
|
||||
@@ -106,10 +107,70 @@ public sealed class TaskPackPlanner
|
||||
return new TaskPackPlanResult(plan, ImmutableArray<TaskPackPlanError>.Empty);
|
||||
}
|
||||
|
||||
private static void ValidateSandboxAndSlo(TaskPackManifest manifest, ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
// TP6: sandbox quotas must be present.
|
||||
var sandbox = manifest.Spec.Sandbox;
|
||||
if (sandbox is null)
|
||||
{
|
||||
errors.Add(new TaskPackPlanError("spec.sandbox", "Sandbox settings are required (mode, egressAllowlist, CPU/memory, quotaSeconds)."));
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sandbox.Mode))
|
||||
{
|
||||
errors.Add(new TaskPackPlanError("spec.sandbox.mode", "Sandbox mode is required (sealed or restricted)."));
|
||||
}
|
||||
|
||||
if (sandbox.EgressAllowlist is null)
|
||||
{
|
||||
errors.Add(new TaskPackPlanError("spec.sandbox.egressAllowlist", "Egress allowlist must be declared (empty list allowed)."));
|
||||
}
|
||||
|
||||
if (sandbox.CpuLimitMillicores <= 0)
|
||||
{
|
||||
errors.Add(new TaskPackPlanError("spec.sandbox.cpuLimitMillicores", "CPU limit must be > 0."));
|
||||
}
|
||||
|
||||
if (sandbox.MemoryLimitMiB <= 0)
|
||||
{
|
||||
errors.Add(new TaskPackPlanError("spec.sandbox.memoryLimitMiB", "Memory limit must be > 0."));
|
||||
}
|
||||
|
||||
if (sandbox.QuotaSeconds <= 0)
|
||||
{
|
||||
errors.Add(new TaskPackPlanError("spec.sandbox.quotaSeconds", "quotaSeconds must be > 0."));
|
||||
}
|
||||
}
|
||||
|
||||
// TP9: SLOs must be declared and positive.
|
||||
var slo = manifest.Spec.Slo;
|
||||
if (slo is null)
|
||||
{
|
||||
errors.Add(new TaskPackPlanError("spec.slo", "SLO section is required (runP95Seconds, approvalP95Seconds, maxQueueDepth)."));
|
||||
return;
|
||||
}
|
||||
|
||||
if (slo.RunP95Seconds <= 0)
|
||||
{
|
||||
errors.Add(new TaskPackPlanError("spec.slo.runP95Seconds", "runP95Seconds must be > 0."));
|
||||
}
|
||||
|
||||
if (slo.ApprovalP95Seconds <= 0)
|
||||
{
|
||||
errors.Add(new TaskPackPlanError("spec.slo.approvalP95Seconds", "approvalP95Seconds must be > 0."));
|
||||
}
|
||||
|
||||
if (slo.MaxQueueDepth <= 0)
|
||||
{
|
||||
errors.Add(new TaskPackPlanError("spec.slo.maxQueueDepth", "maxQueueDepth must be > 0."));
|
||||
}
|
||||
}
|
||||
|
||||
private Dictionary<string, JsonNode?> MaterializeInputs(
|
||||
IReadOnlyList<TaskPackInput>? definitions,
|
||||
IDictionary<string, JsonNode?>? providedInputs,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
IDictionary<string, JsonNode?>? providedInputs,
|
||||
ImmutableArray<TaskPackPlanError>.Builder errors)
|
||||
{
|
||||
var effective = new Dictionary<string, JsonNode?>(StringComparer.Ordinal);
|
||||
|
||||
|
||||
@@ -54,11 +54,11 @@ public sealed class TaskPackMaintainer
|
||||
public string? Email { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TaskPackSpec
|
||||
{
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyList<TaskPackInput>? Inputs { get; init; }
|
||||
|
||||
public sealed class TaskPackSpec
|
||||
{
|
||||
[JsonPropertyName("inputs")]
|
||||
public IReadOnlyList<TaskPackInput>? Inputs { get; init; }
|
||||
|
||||
[JsonPropertyName("secrets")]
|
||||
public IReadOnlyList<TaskPackSecret>? Secrets { get; init; }
|
||||
|
||||
@@ -72,11 +72,17 @@ public sealed class TaskPackSpec
|
||||
public IReadOnlyList<TaskPackOutput>? Outputs { get; init; }
|
||||
|
||||
[JsonPropertyName("success")]
|
||||
public TaskPackSuccess? Success { get; init; }
|
||||
|
||||
[JsonPropertyName("failure")]
|
||||
public TaskPackFailure? Failure { get; init; }
|
||||
}
|
||||
public TaskPackSuccess? Success { get; init; }
|
||||
|
||||
[JsonPropertyName("failure")]
|
||||
public TaskPackFailure? Failure { get; init; }
|
||||
|
||||
[JsonPropertyName("sandbox")]
|
||||
public TaskPackSandbox? Sandbox { get; init; }
|
||||
|
||||
[JsonPropertyName("slo")]
|
||||
public TaskPackSlo? Slo { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TaskPackInput
|
||||
{
|
||||
@@ -255,11 +261,41 @@ public sealed class TaskPackFailure
|
||||
public TaskPackRetryPolicy? Retries { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TaskPackRetryPolicy
|
||||
{
|
||||
[JsonPropertyName("maxAttempts")]
|
||||
public int MaxAttempts { get; init; }
|
||||
|
||||
[JsonPropertyName("backoffSeconds")]
|
||||
public int BackoffSeconds { get; init; }
|
||||
}
|
||||
public sealed class TaskPackRetryPolicy
|
||||
{
|
||||
[JsonPropertyName("maxAttempts")]
|
||||
public int MaxAttempts { get; init; }
|
||||
|
||||
[JsonPropertyName("backoffSeconds")]
|
||||
public int BackoffSeconds { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TaskPackSandbox
|
||||
{
|
||||
[JsonPropertyName("mode")]
|
||||
public string? Mode { get; init; }
|
||||
|
||||
[JsonPropertyName("egressAllowlist")]
|
||||
public IReadOnlyList<string>? EgressAllowlist { get; init; }
|
||||
|
||||
[JsonPropertyName("cpuLimitMillicores")]
|
||||
public int CpuLimitMillicores { get; init; }
|
||||
|
||||
[JsonPropertyName("memoryLimitMiB")]
|
||||
public int MemoryLimitMiB { get; init; }
|
||||
|
||||
[JsonPropertyName("quotaSeconds")]
|
||||
public int QuotaSeconds { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TaskPackSlo
|
||||
{
|
||||
[JsonPropertyName("runP95Seconds")]
|
||||
public int RunP95Seconds { get; init; }
|
||||
|
||||
[JsonPropertyName("approvalP95Seconds")]
|
||||
public int ApprovalP95Seconds { get; init; }
|
||||
|
||||
[JsonPropertyName("maxQueueDepth")]
|
||||
public int MaxQueueDepth { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.TaskRunner.Core.Execution;
|
||||
using StellaOps.TaskRunner.Core.Planning;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace StellaOps.TaskRunner.Infrastructure.Execution;
|
||||
|
||||
@@ -34,6 +35,14 @@ public sealed class PackRunApprovalDecisionService
|
||||
var runId = request.RunId.Trim();
|
||||
var approvalId = request.ApprovalId.Trim();
|
||||
|
||||
if (!IsSha256Digest(request.PlanHash))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Approval decision for run {RunId} rejected – plan hash format invalid (expected sha256:<64-hex>).",
|
||||
runId);
|
||||
return PackRunApprovalDecisionResult.PlanHashMismatch;
|
||||
}
|
||||
|
||||
var state = await _stateStore.GetAsync(runId, cancellationToken).ConfigureAwait(false);
|
||||
if (state is null)
|
||||
{
|
||||
@@ -101,6 +110,14 @@ public sealed class PackRunApprovalDecisionService
|
||||
|
||||
return PackRunApprovalDecisionResult.Applied;
|
||||
}
|
||||
|
||||
private static bool IsSha256Digest(string value)
|
||||
=> !string.IsNullOrWhiteSpace(value)
|
||||
&& Sha256Pattern.IsMatch(value);
|
||||
|
||||
private static readonly Regex Sha256Pattern = new(
|
||||
"^sha256:[0-9a-f]{64}$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
public sealed record PackRunApprovalDecisionRequest(
|
||||
|
||||
@@ -62,7 +62,7 @@ public sealed class PackRunApprovalDecisionServiceTests
|
||||
NullLogger<PackRunApprovalDecisionService>.Instance);
|
||||
|
||||
var result = await service.ApplyAsync(
|
||||
new PackRunApprovalDecisionRequest("missing", "approval", "hash", PackRunApprovalDecisionType.Approved, "actor", null),
|
||||
new PackRunApprovalDecisionRequest("missing", "approval", "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", PackRunApprovalDecisionType.Approved, "actor", null),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("not_found", result.Status);
|
||||
@@ -107,6 +107,44 @@ public sealed class PackRunApprovalDecisionServiceTests
|
||||
Assert.False(scheduler.ScheduledContexts.Any());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_ReturnsPlanHashMismatchWhenFormatInvalid()
|
||||
{
|
||||
var plan = TestPlanFactory.CreatePlan();
|
||||
var state = TestPlanFactory.CreateState("run-1", plan);
|
||||
var approval = new PackRunApprovalState(
|
||||
"security-review",
|
||||
new[] { "Packs.Approve" },
|
||||
new[] { "step-a" },
|
||||
Array.Empty<string>(),
|
||||
null,
|
||||
DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
PackRunApprovalStatus.Pending);
|
||||
|
||||
var approvalStore = new InMemoryApprovalStore(new Dictionary<string, IReadOnlyList<PackRunApprovalState>>
|
||||
{
|
||||
["run-1"] = new List<PackRunApprovalState> { approval }
|
||||
});
|
||||
var stateStore = new InMemoryStateStore(new Dictionary<string, PackRunState>
|
||||
{
|
||||
["run-1"] = state
|
||||
});
|
||||
var scheduler = new RecordingScheduler();
|
||||
|
||||
var service = new PackRunApprovalDecisionService(
|
||||
approvalStore,
|
||||
stateStore,
|
||||
scheduler,
|
||||
NullLogger<PackRunApprovalDecisionService>.Instance);
|
||||
|
||||
var result = await service.ApplyAsync(
|
||||
new PackRunApprovalDecisionRequest("run-1", "security-review", "not-a-digest", PackRunApprovalDecisionType.Approved, "actor", null),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal("plan_hash_mismatch", result.Status);
|
||||
Assert.False(scheduler.ScheduledContexts.Any());
|
||||
}
|
||||
|
||||
private sealed class InMemoryApprovalStore : IPackRunApprovalStore
|
||||
{
|
||||
private readonly Dictionary<string, List<PackRunApprovalState>> _approvals;
|
||||
@@ -214,7 +252,7 @@ internal static class TestPlanFactory
|
||||
metadata,
|
||||
new Dictionary<string, JsonNode?>(StringComparer.Ordinal),
|
||||
new[] { step },
|
||||
"hash-123",
|
||||
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
new[]
|
||||
{
|
||||
new TaskPackPlanApproval("security-review", new[] { "Packs.Approve" }, null, null)
|
||||
|
||||
@@ -36,9 +36,24 @@ public sealed class TaskPackPlannerTests
|
||||
|
||||
var resultB = planner.Plan(manifest, inputs);
|
||||
Assert.True(resultB.Success);
|
||||
Assert.Equal(plan.Hash, resultB.Plan!.Hash);
|
||||
}
|
||||
|
||||
Assert.Equal(plan.Hash, resultB.Plan!.Hash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlanHash_IsPrefixedSha256Digest()
|
||||
{
|
||||
var manifest = TestManifests.Load(TestManifests.Sample);
|
||||
var planner = new TaskPackPlanner();
|
||||
|
||||
var result = planner.Plan(manifest);
|
||||
Assert.True(result.Success);
|
||||
var hash = result.Plan!.Hash;
|
||||
Assert.StartsWith("sha256:", hash, StringComparison.Ordinal);
|
||||
Assert.Equal(71, hash.Length); // "sha256:" + 64 hex characters
|
||||
var hex = hash.Substring("sha256:".Length);
|
||||
Assert.True(hex.All(c => Uri.IsHexDigit(c)), "Hash contains non-hex characters.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Plan_WhenConditionEvaluatesFalse_DisablesStep()
|
||||
{
|
||||
|
||||
@@ -19,17 +19,27 @@ metadata:
|
||||
version: 1.0.0
|
||||
description: Sample pack for planner tests
|
||||
tags: [tests]
|
||||
spec:
|
||||
inputs:
|
||||
- name: dryRun
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
approvals:
|
||||
spec:
|
||||
inputs:
|
||||
- name: dryRun
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
sandbox:
|
||||
mode: sealed
|
||||
egressAllowlist: []
|
||||
cpuLimitMillicores: 100
|
||||
memoryLimitMiB: 128
|
||||
quotaSeconds: 60
|
||||
slo:
|
||||
runP95Seconds: 300
|
||||
approvalP95Seconds: 900
|
||||
maxQueueDepth: 100
|
||||
approvals:
|
||||
- id: security-review
|
||||
grants: ["packs.approve"]
|
||||
steps:
|
||||
- id: plan-step
|
||||
steps:
|
||||
- id: plan-step
|
||||
name: Plan
|
||||
run:
|
||||
uses: builtin:plan
|
||||
@@ -57,6 +67,16 @@ spec:
|
||||
- name: sbomBundle
|
||||
type: object
|
||||
required: true
|
||||
sandbox:
|
||||
mode: sealed
|
||||
egressAllowlist: []
|
||||
cpuLimitMillicores: 100
|
||||
memoryLimitMiB: 128
|
||||
quotaSeconds: 60
|
||||
slo:
|
||||
runP95Seconds: 300
|
||||
approvalP95Seconds: 900
|
||||
maxQueueDepth: 100
|
||||
steps:
|
||||
- id: noop
|
||||
run:
|
||||
@@ -71,12 +91,22 @@ kind: TaskPack
|
||||
metadata:
|
||||
name: step-ref-pack
|
||||
version: 1.0.0
|
||||
spec:
|
||||
steps:
|
||||
- id: prepare
|
||||
run:
|
||||
uses: builtin:prepare
|
||||
- id: consume
|
||||
spec:
|
||||
sandbox:
|
||||
mode: sealed
|
||||
egressAllowlist: []
|
||||
cpuLimitMillicores: 100
|
||||
memoryLimitMiB: 128
|
||||
quotaSeconds: 60
|
||||
slo:
|
||||
runP95Seconds: 300
|
||||
approvalP95Seconds: 900
|
||||
maxQueueDepth: 100
|
||||
steps:
|
||||
- id: prepare
|
||||
run:
|
||||
uses: builtin:prepare
|
||||
- id: consume
|
||||
run:
|
||||
uses: builtin:consume
|
||||
with:
|
||||
@@ -89,16 +119,26 @@ kind: TaskPack
|
||||
metadata:
|
||||
name: map-pack
|
||||
version: 1.0.0
|
||||
spec:
|
||||
inputs:
|
||||
- name: targets
|
||||
type: array
|
||||
required: true
|
||||
steps:
|
||||
- id: maintenance-loop
|
||||
map:
|
||||
items: "{{ inputs.targets }}"
|
||||
step:
|
||||
spec:
|
||||
inputs:
|
||||
- name: targets
|
||||
type: array
|
||||
required: true
|
||||
sandbox:
|
||||
mode: sealed
|
||||
egressAllowlist: []
|
||||
cpuLimitMillicores: 100
|
||||
memoryLimitMiB: 128
|
||||
quotaSeconds: 60
|
||||
slo:
|
||||
runP95Seconds: 300
|
||||
approvalP95Seconds: 900
|
||||
maxQueueDepth: 100
|
||||
steps:
|
||||
- id: maintenance-loop
|
||||
map:
|
||||
items: "{{ inputs.targets }}"
|
||||
step:
|
||||
id: echo-step
|
||||
run:
|
||||
uses: builtin:echo
|
||||
@@ -112,16 +152,26 @@ kind: TaskPack
|
||||
metadata:
|
||||
name: secret-pack
|
||||
version: 1.0.0
|
||||
spec:
|
||||
secrets:
|
||||
spec:
|
||||
secrets:
|
||||
- name: apiKey
|
||||
scope: packs.run
|
||||
description: API authentication token
|
||||
steps:
|
||||
- id: use-secret
|
||||
run:
|
||||
uses: builtin:http
|
||||
with:
|
||||
description: API authentication token
|
||||
sandbox:
|
||||
mode: sealed
|
||||
egressAllowlist: []
|
||||
cpuLimitMillicores: 100
|
||||
memoryLimitMiB: 128
|
||||
quotaSeconds: 60
|
||||
slo:
|
||||
runP95Seconds: 300
|
||||
approvalP95Seconds: 900
|
||||
maxQueueDepth: 100
|
||||
steps:
|
||||
- id: use-secret
|
||||
run:
|
||||
uses: builtin:http
|
||||
with:
|
||||
token: "{{ secrets.apiKey }}"
|
||||
""";
|
||||
|
||||
@@ -131,12 +181,22 @@ kind: TaskPack
|
||||
metadata:
|
||||
name: output-pack
|
||||
version: 1.0.0
|
||||
spec:
|
||||
steps:
|
||||
- id: generate
|
||||
run:
|
||||
uses: builtin:generate
|
||||
outputs:
|
||||
spec:
|
||||
sandbox:
|
||||
mode: sealed
|
||||
egressAllowlist: []
|
||||
cpuLimitMillicores: 100
|
||||
memoryLimitMiB: 128
|
||||
quotaSeconds: 60
|
||||
slo:
|
||||
runP95Seconds: 300
|
||||
approvalP95Seconds: 900
|
||||
maxQueueDepth: 100
|
||||
steps:
|
||||
- id: generate
|
||||
run:
|
||||
uses: builtin:generate
|
||||
outputs:
|
||||
- name: bundlePath
|
||||
type: file
|
||||
path: artifacts/report.txt
|
||||
@@ -152,6 +212,16 @@ metadata:
|
||||
name: failure-policy-pack
|
||||
version: 1.0.0
|
||||
spec:
|
||||
sandbox:
|
||||
mode: sealed
|
||||
egressAllowlist: []
|
||||
cpuLimitMillicores: 100
|
||||
memoryLimitMiB: 128
|
||||
quotaSeconds: 60
|
||||
slo:
|
||||
runP95Seconds: 300
|
||||
approvalP95Seconds: 900
|
||||
maxQueueDepth: 100
|
||||
steps:
|
||||
- id: build
|
||||
run:
|
||||
@@ -170,6 +240,16 @@ metadata:
|
||||
name: parallel-pack
|
||||
version: 1.1.0
|
||||
spec:
|
||||
sandbox:
|
||||
mode: sealed
|
||||
egressAllowlist: []
|
||||
cpuLimitMillicores: 100
|
||||
memoryLimitMiB: 128
|
||||
quotaSeconds: 60
|
||||
slo:
|
||||
runP95Seconds: 300
|
||||
approvalP95Seconds: 900
|
||||
maxQueueDepth: 100
|
||||
steps:
|
||||
- id: fanout
|
||||
parallel:
|
||||
@@ -196,6 +276,16 @@ metadata:
|
||||
name: policy-gate-pack
|
||||
version: 1.0.0
|
||||
spec:
|
||||
sandbox:
|
||||
mode: sealed
|
||||
egressAllowlist: []
|
||||
cpuLimitMillicores: 100
|
||||
memoryLimitMiB: 128
|
||||
quotaSeconds: 60
|
||||
slo:
|
||||
runP95Seconds: 300
|
||||
approvalP95Seconds: 900
|
||||
maxQueueDepth: 100
|
||||
steps:
|
||||
- id: prepare
|
||||
run:
|
||||
@@ -216,6 +306,16 @@ metadata:
|
||||
name: egress-allowed
|
||||
version: 1.0.0
|
||||
spec:
|
||||
sandbox:
|
||||
mode: sealed
|
||||
egressAllowlist: []
|
||||
cpuLimitMillicores: 100
|
||||
memoryLimitMiB: 128
|
||||
quotaSeconds: 60
|
||||
slo:
|
||||
runP95Seconds: 300
|
||||
approvalP95Seconds: 900
|
||||
maxQueueDepth: 100
|
||||
steps:
|
||||
- id: fetch
|
||||
run:
|
||||
@@ -233,6 +333,16 @@ metadata:
|
||||
name: egress-blocked
|
||||
version: 1.0.0
|
||||
spec:
|
||||
sandbox:
|
||||
mode: sealed
|
||||
egressAllowlist: []
|
||||
cpuLimitMillicores: 100
|
||||
memoryLimitMiB: 128
|
||||
quotaSeconds: 60
|
||||
slo:
|
||||
runP95Seconds: 300
|
||||
approvalP95Seconds: 900
|
||||
maxQueueDepth: 100
|
||||
steps:
|
||||
- id: fetch
|
||||
run:
|
||||
@@ -252,6 +362,16 @@ spec:
|
||||
- name: targetUrl
|
||||
type: string
|
||||
required: false
|
||||
sandbox:
|
||||
mode: sealed
|
||||
egressAllowlist: []
|
||||
cpuLimitMillicores: 100
|
||||
memoryLimitMiB: 128
|
||||
quotaSeconds: 60
|
||||
slo:
|
||||
runP95Seconds: 300
|
||||
approvalP95Seconds: 900
|
||||
maxQueueDepth: 100
|
||||
steps:
|
||||
- id: fetch
|
||||
run:
|
||||
|
||||
@@ -319,6 +319,11 @@ async Task<IResult> HandleApplyApprovalDecision(
|
||||
return Results.BadRequest(new { error = "planHash is required." });
|
||||
}
|
||||
|
||||
if (!Regex.IsMatch(request.PlanHash, "^sha256:[0-9a-f]{64}$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant))
|
||||
{
|
||||
return Results.BadRequest(new { error = "planHash must be sha256:<64-hex>." });
|
||||
}
|
||||
|
||||
var result = await decisionService.ApplyAsync(
|
||||
new PackRunApprovalDecisionRequest(runId, approvalId, request.PlanHash, decisionType, request.ActorId, request.Summary),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -17,12 +17,13 @@
|
||||
<PackageReference Remove="Microsoft.Extensions.TimeProvider.Testing" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" />
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Telemetry.Core\\StellaOps.Telemetry.Core.csproj" />
|
||||
|
||||
@@ -15,10 +15,13 @@ public class TelemetryPropagationMiddlewareTests
|
||||
async context =>
|
||||
{
|
||||
// Assert inside the pipeline while context is set.
|
||||
Assert.NotNull(accessor.Current);
|
||||
Assert.Equal("tenant-a", accessor.Current!.TenantId);
|
||||
Assert.Equal("service-x", accessor.Current.Actor);
|
||||
Assert.Equal("policy-42", accessor.Current.ImposedRule);
|
||||
var ctx = accessor.Current
|
||||
?? context.Items[typeof(TelemetryContext)] as TelemetryContext
|
||||
?? context.Items["TelemetryContext"] as TelemetryContext;
|
||||
Assert.NotNull(ctx);
|
||||
Assert.Equal("tenant-a", ctx!.TenantId);
|
||||
Assert.Equal("service-x", ctx.Actor);
|
||||
Assert.Equal("policy-42", ctx.ImposedRule);
|
||||
await Task.CompletedTask;
|
||||
},
|
||||
accessor,
|
||||
|
||||
@@ -129,14 +129,20 @@ public static class DeterministicLogFormatter
|
||||
}
|
||||
}
|
||||
|
||||
var dict = new Dictionary<string, object?>();
|
||||
foreach (var kvp in orderedFields)
|
||||
{
|
||||
dict[kvp.Key] = NormalizeValue(kvp.Value);
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(dict, DeterministicJsonOptions);
|
||||
}
|
||||
var dict = new Dictionary<string, object?>();
|
||||
foreach (var kvp in orderedFields)
|
||||
{
|
||||
var normalized = NormalizeValue(kvp.Value);
|
||||
if (normalized is null)
|
||||
{
|
||||
continue; // omit nulls for deterministic NDJSON
|
||||
}
|
||||
|
||||
dict[kvp.Key] = normalized;
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(dict, DeterministicJsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a log entry as a deterministic key=value format.
|
||||
|
||||
@@ -25,10 +25,10 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
private int _extensionCount;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsActive => _currentState is not null && !_currentState.IsExpired;
|
||||
public bool IsActive => _currentState is not null && !IsExpired(_currentState);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public IncidentModeState? CurrentState => _currentState?.IsExpired == true ? null : _currentState;
|
||||
public IncidentModeState? CurrentState => _currentState is { } state && !IsExpired(state) ? state : null;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event EventHandler<IncidentModeActivatedEventArgs>? Activated;
|
||||
@@ -67,7 +67,10 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
string? reason = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(actor);
|
||||
if (string.IsNullOrWhiteSpace(actor))
|
||||
{
|
||||
throw new ArgumentException("Actor must be provided", nameof(actor));
|
||||
}
|
||||
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
|
||||
@@ -84,7 +87,7 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentState is not null && !_currentState.IsExpired)
|
||||
if (_currentState is not null && !IsExpired(_currentState))
|
||||
{
|
||||
wasAlreadyActive = true;
|
||||
_logger?.LogInformation(
|
||||
@@ -152,12 +155,12 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
{
|
||||
var options = _optionsMonitor.CurrentValue;
|
||||
IncidentModeState? previousState;
|
||||
bool wasActive;
|
||||
bool wasActive;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
previousState = _currentState;
|
||||
wasActive = previousState is not null && !previousState.IsExpired;
|
||||
wasActive = previousState is not null && !IsExpired(previousState);
|
||||
_currentState = null;
|
||||
_extensionCount = 0;
|
||||
}
|
||||
@@ -210,10 +213,10 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentState is null || _currentState.IsExpired)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
if (_currentState is null || IsExpired(_currentState))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_extensionCount >= options.MaxExtensions)
|
||||
{
|
||||
@@ -311,20 +314,23 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
{
|
||||
var result = await ActivateAsync(actor, tenantId, ttl, reason, ct).ConfigureAwait(false);
|
||||
|
||||
if (result.Success && result.State is not null)
|
||||
{
|
||||
// Update source
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentState is not null)
|
||||
{
|
||||
_currentState = _currentState with { Source = source };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
if (result.Success && result.State is not null)
|
||||
{
|
||||
// Update source
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentState is not null)
|
||||
{
|
||||
_currentState = _currentState with { Source = source };
|
||||
}
|
||||
}
|
||||
|
||||
var updatedState = _currentState ?? result.State with { Source = source };
|
||||
return IncidentModeActivationResult.Succeeded(updatedState, result.WasAlreadyActive);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void CheckExpiry(object? state)
|
||||
{
|
||||
@@ -332,10 +338,10 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_currentState is null || !_currentState.IsExpired)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (_currentState is null || !IsExpired(_currentState))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
expiredState = _currentState;
|
||||
_currentState = null;
|
||||
@@ -512,20 +518,25 @@ public sealed class IncidentModeService : IIncidentModeService, IDisposable
|
||||
state.ActivationId);
|
||||
}
|
||||
|
||||
private void EmitDeactivationAuditEvent(IncidentModeState state, IncidentModeDeactivationReason reason, string? deactivatedBy)
|
||||
{
|
||||
_logger?.LogInformation(
|
||||
"Audit: telemetry.incident.{Action} - tenant={Tenant} reason={Reason} deactivated_by={DeactivatedBy} activation_id={ActivationId}",
|
||||
reason == IncidentModeDeactivationReason.Expired ? "expired" : "deactivated",
|
||||
state.TenantId ?? "global",
|
||||
reason,
|
||||
deactivatedBy ?? "system",
|
||||
state.ActivationId);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
_expiryTimer.Dispose();
|
||||
private void EmitDeactivationAuditEvent(IncidentModeState state, IncidentModeDeactivationReason reason, string? deactivatedBy)
|
||||
{
|
||||
_logger?.LogInformation(
|
||||
"Audit: telemetry.incident.{Action} - tenant={Tenant} reason={Reason} deactivated_by={DeactivatedBy} activation_id={ActivationId}",
|
||||
reason == IncidentModeDeactivationReason.Expired ? "expired" : "deactivated",
|
||||
state.TenantId ?? "global",
|
||||
reason,
|
||||
deactivatedBy ?? "system",
|
||||
state.ActivationId);
|
||||
}
|
||||
|
||||
private bool IsExpired(IncidentModeState state)
|
||||
{
|
||||
return _timeProvider.GetUtcNow() >= state.ExpiresAt;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
_expiryTimer.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,33 +246,36 @@ public sealed class LogRedactor : ILogRedactor
|
||||
return false;
|
||||
}
|
||||
|
||||
private (string RedactedValue, List<string> MatchedPatterns) RedactStringWithPatternTracking(
|
||||
string value,
|
||||
LogRedactionOptions options,
|
||||
TenantRedactionOverride? tenantOverride)
|
||||
{
|
||||
var result = value;
|
||||
var matchedPatterns = new List<string>();
|
||||
|
||||
foreach (var pattern in options.ValuePatterns)
|
||||
{
|
||||
if (pattern.CompiledRegex.IsMatch(result))
|
||||
{
|
||||
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
|
||||
matchedPatterns.Add(pattern.Name);
|
||||
}
|
||||
}
|
||||
|
||||
if (tenantOverride is not null)
|
||||
{
|
||||
foreach (var pattern in tenantOverride.AdditionalPatterns)
|
||||
{
|
||||
if (pattern.CompiledRegex.IsMatch(result))
|
||||
{
|
||||
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
|
||||
matchedPatterns.Add(pattern.Name);
|
||||
}
|
||||
}
|
||||
private (string RedactedValue, List<string> MatchedPatterns) RedactStringWithPatternTracking(
|
||||
string value,
|
||||
LogRedactionOptions options,
|
||||
TenantRedactionOverride? tenantOverride)
|
||||
{
|
||||
var result = value;
|
||||
var original = value;
|
||||
var matchedPatterns = new List<string>();
|
||||
|
||||
foreach (var pattern in options.ValuePatterns)
|
||||
{
|
||||
var matched = pattern.CompiledRegex.IsMatch(original);
|
||||
if (matched)
|
||||
{
|
||||
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
|
||||
matchedPatterns.Add(pattern.Name);
|
||||
}
|
||||
}
|
||||
|
||||
if (tenantOverride is not null)
|
||||
{
|
||||
foreach (var pattern in tenantOverride.AdditionalPatterns)
|
||||
{
|
||||
var matched = pattern.CompiledRegex.IsMatch(original);
|
||||
if (matched)
|
||||
{
|
||||
result = pattern.CompiledRegex.Replace(result, options.RedactionPlaceholder);
|
||||
matchedPatterns.Add(pattern.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (result, matchedPatterns);
|
||||
|
||||
@@ -44,17 +44,26 @@ public sealed class TelemetryContext
|
||||
/// </summary>
|
||||
public string? CorrelationId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the trace identifier (alias for <see cref="CorrelationId"/>).
|
||||
/// </summary>
|
||||
public string? TraceId
|
||||
{
|
||||
get => CorrelationId;
|
||||
set => CorrelationId = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tenant identifier when provided.
|
||||
/// <summary>
|
||||
/// Gets or sets the trace identifier (alias for <see cref="CorrelationId"/>).
|
||||
/// </summary>
|
||||
public string? TraceId
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(CorrelationId))
|
||||
{
|
||||
return CorrelationId;
|
||||
}
|
||||
|
||||
var activityTraceId = Activity.Current?.TraceId.ToString();
|
||||
return string.IsNullOrWhiteSpace(activityTraceId) ? string.Empty : activityTraceId;
|
||||
}
|
||||
set => CorrelationId = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tenant identifier when provided.
|
||||
/// </summary>
|
||||
public string? TenantId { get; set; }
|
||||
|
||||
@@ -63,8 +72,21 @@ public sealed class TelemetryContext
|
||||
/// </summary>
|
||||
public string? Actor { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the imposed rule or decision metadata when present.
|
||||
/// </summary>
|
||||
public string? ImposedRule { get; set; }
|
||||
}
|
||||
/// <summary>
|
||||
/// Gets or sets the imposed rule or decision metadata when present.
|
||||
/// </summary>
|
||||
public string? ImposedRule { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether any meaningful context has been populated.
|
||||
/// </summary>
|
||||
public bool IsInitialized =>
|
||||
!string.IsNullOrWhiteSpace(TenantId) ||
|
||||
!string.IsNullOrWhiteSpace(Actor) ||
|
||||
!string.IsNullOrWhiteSpace(CorrelationId);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a deep copy of the current context.
|
||||
/// </summary>
|
||||
public TelemetryContext Clone() => new(CorrelationId, TenantId, Actor, ImposedRule);
|
||||
}
|
||||
|
||||
@@ -1,76 +1,77 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the current <see cref="TelemetryContext"/> using AsyncLocal storage.
|
||||
/// </summary>
|
||||
public sealed class TelemetryContextAccessor : ITelemetryContextAccessor
|
||||
{
|
||||
private static readonly AsyncLocal<TelemetryContextHolder> CurrentHolder = new();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public TelemetryContext? Context
|
||||
{
|
||||
get => CurrentHolder.Value?.Context;
|
||||
set
|
||||
{
|
||||
var holder = CurrentHolder.Value;
|
||||
if (holder is not null)
|
||||
{
|
||||
holder.Context = null;
|
||||
}
|
||||
|
||||
if (value is not null)
|
||||
{
|
||||
CurrentHolder.Value = new TelemetryContextHolder { Context = value };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public TelemetryContext? Current
|
||||
{
|
||||
get => Context;
|
||||
set => Context = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a scope that restores the context when disposed.
|
||||
/// Useful for background jobs and async continuations.
|
||||
/// </summary>
|
||||
/// <param name="context">The context to set for the scope.</param>
|
||||
/// <returns>A disposable scope that restores the previous context on disposal.</returns>
|
||||
public IDisposable CreateScope(TelemetryContext context)
|
||||
{
|
||||
var previous = Context;
|
||||
Context = context;
|
||||
return new ContextScope(this, previous);
|
||||
}
|
||||
|
||||
private sealed class TelemetryContextHolder
|
||||
{
|
||||
public TelemetryContext? Context { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ContextScope : IDisposable
|
||||
{
|
||||
private readonly TelemetryContextAccessor _accessor;
|
||||
private readonly TelemetryContext? _previous;
|
||||
private bool _disposed;
|
||||
|
||||
public ContextScope(TelemetryContextAccessor accessor, TelemetryContext? previous)
|
||||
{
|
||||
_accessor = accessor;
|
||||
_previous = previous;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_accessor.Context = _previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to the current <see cref="TelemetryContext"/> using AsyncLocal storage.
|
||||
/// </summary>
|
||||
public sealed class TelemetryContextAccessor : ITelemetryContextAccessor
|
||||
{
|
||||
private static readonly AsyncLocal<TelemetryContextHolder> CurrentHolder = new();
|
||||
|
||||
public TelemetryContextAccessor()
|
||||
{
|
||||
// Ensure clean state per accessor instantiation (important for tests)
|
||||
CurrentHolder.Value = null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public TelemetryContext? Context
|
||||
{
|
||||
get => CurrentHolder.Value?.Context;
|
||||
set
|
||||
{
|
||||
CurrentHolder.Value = value is null ? null : new TelemetryContextHolder { Context = value };
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public TelemetryContext? Current
|
||||
{
|
||||
get => Context;
|
||||
set => Context = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a scope that restores the context when disposed.
|
||||
/// Useful for background jobs and async continuations.
|
||||
/// </summary>
|
||||
/// <param name="context">The context to set for the scope.</param>
|
||||
/// <returns>A disposable scope that restores the previous context on disposal.</returns>
|
||||
public IDisposable CreateScope(TelemetryContext context)
|
||||
{
|
||||
var previous = Context;
|
||||
Context = context;
|
||||
return new ContextScope(this, previous);
|
||||
}
|
||||
|
||||
private sealed class TelemetryContextHolder
|
||||
{
|
||||
public TelemetryContext? Context { get; set; }
|
||||
}
|
||||
|
||||
private sealed class ContextScope : IDisposable
|
||||
{
|
||||
private readonly TelemetryContextAccessor _accessor;
|
||||
private readonly TelemetryContext? _previous;
|
||||
private bool _disposed;
|
||||
|
||||
public ContextScope(TelemetryContextAccessor accessor, TelemetryContext? previous)
|
||||
{
|
||||
_accessor = accessor;
|
||||
_previous = previous;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_accessor.Context = _previous;
|
||||
if (_previous is null)
|
||||
{
|
||||
CurrentHolder.Value = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Delegating handler that propagates telemetry context headers on outgoing HTTP requests.
|
||||
/// </summary>
|
||||
public sealed class TelemetryPropagationHandler : DelegatingHandler
|
||||
{
|
||||
private readonly ITelemetryContextAccessor _accessor;
|
||||
private readonly IOptions<StellaOpsTelemetryOptions> _options;
|
||||
|
||||
public TelemetryPropagationHandler(
|
||||
ITelemetryContextAccessor accessor,
|
||||
IOptions<StellaOpsTelemetryOptions> options)
|
||||
{
|
||||
_accessor = accessor;
|
||||
_options = options;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var ctx = _accessor.Current;
|
||||
var propagation = _options.Value.Propagation;
|
||||
|
||||
if (ctx is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ctx.TenantId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(propagation.TenantHeader, ctx.TenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ctx.Actor))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(propagation.ActorHeader, ctx.Actor);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ctx.ImposedRule))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(propagation.ImposedRuleHeader, ctx.ImposedRule);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ctx.TraceId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(propagation.TraceIdHeader, ctx.TraceId);
|
||||
}
|
||||
}
|
||||
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ using Microsoft.Extensions.Options;
|
||||
namespace StellaOps.Telemetry.Core;
|
||||
|
||||
/// <summary>
|
||||
/// ASP.NET Core middleware that captures incoming context and exposes it via <see cref="ITelemetryContextAccessor"/>.
|
||||
/// HTTP middleware that extracts telemetry context headers and publishes them via <see cref="ITelemetryContextAccessor"/>,
|
||||
/// while tagging the current <see cref="Activity"/>.
|
||||
/// </summary>
|
||||
public sealed class TelemetryPropagationMiddleware
|
||||
{
|
||||
@@ -15,9 +16,6 @@ public sealed class TelemetryPropagationMiddleware
|
||||
private readonly IOptions<StellaOpsTelemetryOptions> _options;
|
||||
private readonly ILogger<TelemetryPropagationMiddleware> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TelemetryPropagationMiddleware"/> class.
|
||||
/// </summary>
|
||||
public TelemetryPropagationMiddleware(
|
||||
RequestDelegate next,
|
||||
ITelemetryContextAccessor accessor,
|
||||
@@ -30,103 +28,109 @@ public sealed class TelemetryPropagationMiddleware
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes the HTTP request, extracting telemetry context headers and storing them in the accessor.
|
||||
/// </summary>
|
||||
public async Task InvokeAsync(HttpContext httpContext)
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(httpContext);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var propagation = _options.Value.Propagation;
|
||||
|
||||
var activity = Activity.Current ?? new Activity("stellaops.telemetry.incoming").Start();
|
||||
string? tenant = httpContext.Request.Headers[propagation.TenantHeader];
|
||||
string? actor = httpContext.Request.Headers[propagation.ActorHeader];
|
||||
string? imposedRule = httpContext.Request.Headers[propagation.ImposedRuleHeader];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(activity.TraceId.ToString()) && httpContext.Request.Headers.TryGetValue(propagation.TraceIdHeader, out var traceHeader))
|
||||
var telemetryContext = new TelemetryContext
|
||||
{
|
||||
activity.SetParentId(traceHeader!);
|
||||
}
|
||||
TenantId = context.Request.Headers[propagation.TenantHeader].ToString(),
|
||||
Actor = context.Request.Headers[propagation.ActorHeader].ToString(),
|
||||
ImposedRule = context.Request.Headers[propagation.ImposedRuleHeader].ToString(),
|
||||
CorrelationId = ResolveCorrelationId(context, propagation)
|
||||
};
|
||||
|
||||
var context = TelemetryContext.FromActivity(activity, tenant, actor, imposedRule);
|
||||
_accessor.Current = context;
|
||||
httpContext.Items[typeof(TelemetryContext)] = context;
|
||||
// Persist on HttpContext.Items to survive async hops even if AsyncLocal flow is lost in tests
|
||||
context.Items[typeof(TelemetryContext)] = telemetryContext;
|
||||
context.Items["TelemetryContext"] = telemetryContext;
|
||||
|
||||
using var scope = _logger.BeginScope(new Dictionary<string, object?>
|
||||
{
|
||||
["trace_id"] = context.TraceId,
|
||||
["tenant_id"] = context.TenantId,
|
||||
["actor"] = context.Actor,
|
||||
["imposed_rule"] = context.ImposedRule,
|
||||
});
|
||||
var previous = _accessor.Current;
|
||||
_accessor.Context = telemetryContext;
|
||||
_accessor.Current = telemetryContext;
|
||||
|
||||
activity.SetTag("tenant_id", context.TenantId);
|
||||
activity.SetTag("actor", context.Actor);
|
||||
activity.SetTag("imposed_rule", context.ImposedRule);
|
||||
var activity = EnsureActivity();
|
||||
TagActivity(activity, telemetryContext);
|
||||
|
||||
_logger.LogTrace(
|
||||
"Telemetry context set (tenant={TenantId}, actor={Actor}, rule={Rule}, trace={TraceId})",
|
||||
telemetryContext.TenantId ?? string.Empty,
|
||||
telemetryContext.Actor ?? string.Empty,
|
||||
telemetryContext.ImposedRule ?? string.Empty,
|
||||
telemetryContext.CorrelationId ?? string.Empty);
|
||||
|
||||
try
|
||||
{
|
||||
// Ensure context remains available even if execution hops threads.
|
||||
_accessor.Current ??= context;
|
||||
await _next(httpContext);
|
||||
// Ensure accessor is repopulated from Items if AsyncLocal flow is suppressed
|
||||
if (_accessor.Current is null && context.Items.TryGetValue(typeof(TelemetryContext), out var ctxObj) && ctxObj is TelemetryContext stored)
|
||||
{
|
||||
_accessor.Context = stored;
|
||||
_accessor.Current = stored;
|
||||
}
|
||||
|
||||
await _next(context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_accessor.Current = null;
|
||||
httpContext.Items.Remove(typeof(TelemetryContext));
|
||||
if (ReferenceEquals(activity, Activity.Current))
|
||||
_accessor.Context = previous;
|
||||
_accessor.Current = previous;
|
||||
if (previous is null)
|
||||
{
|
||||
activity.Stop();
|
||||
// ensure clean slate when there was no prior context
|
||||
_accessor.Context = null;
|
||||
_accessor.Current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveCorrelationId(HttpContext context, StellaOpsTelemetryOptions.PropagationOptions propagation)
|
||||
{
|
||||
var header = context.Request.Headers[propagation.TraceIdHeader].ToString();
|
||||
if (!string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
return header;
|
||||
}
|
||||
|
||||
var current = Activity.Current?.TraceId.ToString();
|
||||
return string.IsNullOrWhiteSpace(current)
|
||||
? ActivityTraceId.CreateRandom().ToString()
|
||||
: current!;
|
||||
}
|
||||
|
||||
private static Activity EnsureActivity()
|
||||
{
|
||||
if (Activity.Current is { } existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
var activity = new Activity("telemetry-propagation");
|
||||
activity.Start();
|
||||
Activity.Current = activity;
|
||||
return activity;
|
||||
}
|
||||
|
||||
private static void TagActivity(Activity activity, TelemetryContext ctx)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ctx.TenantId))
|
||||
{
|
||||
activity.SetTag("tenant_id", ctx.TenantId);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ctx.Actor))
|
||||
{
|
||||
activity.SetTag("actor", ctx.Actor);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ctx.ImposedRule))
|
||||
{
|
||||
activity.SetTag("imposed_rule", ctx.ImposedRule);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ctx.CorrelationId))
|
||||
{
|
||||
activity.SetTag("trace_id", ctx.CorrelationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delegating handler that forwards telemetry headers on outgoing HTTP calls.
|
||||
/// </summary>
|
||||
public sealed class TelemetryPropagationHandler : DelegatingHandler
|
||||
{
|
||||
private readonly ITelemetryContextAccessor _accessor;
|
||||
private readonly IOptions<StellaOpsTelemetryOptions> _options;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TelemetryPropagationHandler"/> class.
|
||||
/// </summary>
|
||||
public TelemetryPropagationHandler(ITelemetryContextAccessor accessor, IOptions<StellaOpsTelemetryOptions> options)
|
||||
{
|
||||
_accessor = accessor ?? throw new ArgumentNullException(nameof(accessor));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(request));
|
||||
}
|
||||
|
||||
var context = _accessor.Current;
|
||||
if (context is not null)
|
||||
{
|
||||
var headers = _options.Value.Propagation;
|
||||
request.Headers.TryAddWithoutValidation(headers.TraceIdHeader, context.TraceId);
|
||||
if (!string.IsNullOrWhiteSpace(context.TenantId))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(headers.TenantHeader, context.TenantId);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(context.Actor))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(headers.ActorHeader, context.Actor);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(context.ImposedRule))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation(headers.ImposedRuleHeader, context.ImposedRule);
|
||||
}
|
||||
}
|
||||
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ public interface ITimelineQueryService
|
||||
{
|
||||
Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken = default);
|
||||
Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken = default);
|
||||
Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ public interface ITimelineQueryStore
|
||||
{
|
||||
Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken);
|
||||
Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken);
|
||||
Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace StellaOps.TimelineIndexer.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Evidence linkage for a timeline event, pointing to sealed bundle/attestation artifacts.
|
||||
/// </summary>
|
||||
public sealed class TimelineEvidenceView
|
||||
{
|
||||
public required string EventId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public Guid? BundleId { get; init; }
|
||||
public string? BundleDigest { get; init; }
|
||||
public string? AttestationSubject { get; init; }
|
||||
public string? AttestationDigest { get; init; }
|
||||
public string? ManifestUri { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
@@ -19,6 +19,37 @@ public sealed class TimelineQueryService(ITimelineQueryStore store) : ITimelineQ
|
||||
return store.GetAsync(tenantId, eventId, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(eventId);
|
||||
var evidence = await store.GetEvidenceAsync(tenantId, eventId, cancellationToken).ConfigureAwait(false);
|
||||
if (evidence is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var manifest = evidence.ManifestUri;
|
||||
if (manifest is null && evidence.BundleId is not null)
|
||||
{
|
||||
manifest = $"bundles/{evidence.BundleId:N}/manifest.dsse.json";
|
||||
}
|
||||
|
||||
var subject = evidence.AttestationSubject ?? evidence.BundleDigest;
|
||||
|
||||
return new TimelineEvidenceView
|
||||
{
|
||||
EventId = evidence.EventId,
|
||||
TenantId = evidence.TenantId,
|
||||
BundleId = evidence.BundleId,
|
||||
BundleDigest = evidence.BundleDigest,
|
||||
AttestationSubject = subject,
|
||||
AttestationDigest = evidence.AttestationDigest,
|
||||
ManifestUri = manifest,
|
||||
CreatedAt = evidence.CreatedAt
|
||||
};
|
||||
}
|
||||
|
||||
private static TimelineQueryOptions Normalize(TimelineQueryOptions options)
|
||||
{
|
||||
var limit = options.Limit;
|
||||
|
||||
@@ -75,6 +75,27 @@ public sealed class TimelineQueryStore(TimelineIndexerDataSource dataSource, ILo
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public async Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT d.event_id, d.tenant_id, d.bundle_id, d.bundle_digest, d.attestation_subject, d.attestation_digest, d.manifest_uri, d.created_at
|
||||
FROM timeline.timeline_event_digests d
|
||||
WHERE d.tenant_id = @tenant_id AND d.event_id = @event_id
|
||||
LIMIT 1
|
||||
""";
|
||||
|
||||
return await QuerySingleOrDefaultAsync(
|
||||
tenantId,
|
||||
sql,
|
||||
cmd =>
|
||||
{
|
||||
AddParameter(cmd, "tenant_id", tenantId);
|
||||
AddParameter(cmd, "event_id", eventId);
|
||||
},
|
||||
MapEvidence,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static TimelineEventView MapEvent(NpgsqlDataReader reader) => new()
|
||||
{
|
||||
EventSeq = reader.GetInt64(0),
|
||||
@@ -118,6 +139,37 @@ public sealed class TimelineQueryStore(TimelineIndexerDataSource dataSource, ILo
|
||||
};
|
||||
}
|
||||
|
||||
private static TimelineEvidenceView MapEvidence(NpgsqlDataReader reader)
|
||||
{
|
||||
var bundleDigest = GetNullableString(reader, 3);
|
||||
var attestationSubject = GetNullableString(reader, 4);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(attestationSubject))
|
||||
{
|
||||
attestationSubject = bundleDigest;
|
||||
}
|
||||
|
||||
var bundleId = GetNullableGuid(reader, 2);
|
||||
var manifestUri = GetNullableString(reader, 6);
|
||||
|
||||
if (manifestUri is null && bundleId is not null)
|
||||
{
|
||||
manifestUri = $"bundles/{bundleId:N}/manifest.dsse.json";
|
||||
}
|
||||
|
||||
return new TimelineEvidenceView
|
||||
{
|
||||
EventId = reader.GetString(0),
|
||||
TenantId = reader.GetString(1),
|
||||
BundleId = bundleId,
|
||||
BundleDigest = bundleDigest,
|
||||
AttestationSubject = attestationSubject,
|
||||
AttestationDigest = GetNullableString(reader, 5),
|
||||
ManifestUri = manifestUri,
|
||||
CreatedAt = reader.GetFieldValue<DateTimeOffset>(7)
|
||||
};
|
||||
}
|
||||
|
||||
private static IDictionary<string, string>? DeserializeAttributes(NpgsqlDataReader reader, int ordinal)
|
||||
{
|
||||
if (reader.IsDBNull(ordinal)) return null;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Text.Json;
|
||||
using StellaOps.TimelineIndexer.Core.Models;
|
||||
using StellaOps.TimelineIndexer.Infrastructure.Subscriptions;
|
||||
using StellaOps.TimelineIndexer.Core.Abstractions;
|
||||
|
||||
namespace StellaOps.TimelineIndexer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Offline integration test that wires the real parser + query store against the golden EB1 sealed bundle fixtures.
|
||||
/// </summary>
|
||||
public class EvidenceLinkageIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ParsesAndReturnsEvidenceFromSealedBundle()
|
||||
{
|
||||
var bundleId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var tenantId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
var merkleRoot = "sha256:c15ab4d1348da9e5000a5d3da50790ea120d865cafb0961845ed6f1e96927596";
|
||||
var manifestUri = "bundles/11111111111111111111111111111111/manifest.dsse.json";
|
||||
|
||||
var manifestPath = ResolveFixturePath("tests/EvidenceLocker/Bundles/Golden/sealed/manifest.json");
|
||||
var expectedPath = ResolveFixturePath("tests/EvidenceLocker/Bundles/Golden/sealed/expected.json");
|
||||
|
||||
var manifestJson = await File.ReadAllTextAsync(manifestPath, TestContext.Current.CancellationToken);
|
||||
var expectedJson = await File.ReadAllTextAsync(expectedPath, TestContext.Current.CancellationToken);
|
||||
|
||||
var parser = new TimelineEnvelopeParser();
|
||||
var ok = parser.TryParse(EnvelopeForManifest(manifestJson), out var envelope, out var reason);
|
||||
Assert.True(ok, reason);
|
||||
|
||||
envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-eb1-demo",
|
||||
TenantId = tenantId,
|
||||
EventType = envelope.EventType,
|
||||
Source = envelope.Source,
|
||||
OccurredAt = envelope.OccurredAt,
|
||||
CorrelationId = envelope.CorrelationId,
|
||||
TraceId = envelope.TraceId,
|
||||
Actor = envelope.Actor,
|
||||
Severity = envelope.Severity,
|
||||
PayloadHash = envelope.PayloadHash,
|
||||
RawPayloadJson = envelope.RawPayloadJson,
|
||||
NormalizedPayloadJson = envelope.NormalizedPayloadJson,
|
||||
Attributes = envelope.Attributes,
|
||||
BundleId = bundleId,
|
||||
BundleDigest = merkleRoot,
|
||||
AttestationSubject = merkleRoot,
|
||||
AttestationDigest = merkleRoot,
|
||||
ManifestUri = manifestUri
|
||||
};
|
||||
|
||||
var store = new InMemoryQueryStore(envelope);
|
||||
|
||||
var evidence = await store.GetEvidenceAsync(tenantId, envelope.EventId, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal(bundleId, evidence!.BundleId);
|
||||
Assert.Equal(merkleRoot, evidence.BundleDigest);
|
||||
Assert.Equal(manifestUri, evidence.ManifestUri);
|
||||
|
||||
using var doc = JsonDocument.Parse(expectedJson);
|
||||
var subject = doc.RootElement.GetProperty("subject").GetString();
|
||||
Assert.Equal(subject, evidence.AttestationSubject);
|
||||
}
|
||||
|
||||
private static string EnvelopeForManifest(string manifestJson)
|
||||
{
|
||||
return $@"{{
|
||||
""eventId"": ""evt-eb1-demo"",
|
||||
""tenant"": ""aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"",
|
||||
""kind"": ""export.bundle.sealed"",
|
||||
""occurredAt"": ""2025-12-04T00:00:00Z"",
|
||||
""source"": ""evidence-locker"",
|
||||
""payload"": {{""manifest"": {{""raw"": {manifestJson} }}}},
|
||||
""bundleId"": ""11111111-1111-1111-1111-111111111111""
|
||||
}}";
|
||||
}
|
||||
|
||||
private static string ResolveFixturePath(string relative)
|
||||
{
|
||||
var baseDir = AppContext.BaseDirectory;
|
||||
// bin/Debug/net10.0/ -> StellaOps.TimelineIndexer.Tests -> TimelineIndexer -> src -> repo root
|
||||
var root = Path.GetFullPath(Path.Combine(baseDir, "..", "..", "..", "..", "..", "..", ".."));
|
||||
return Path.GetFullPath(Path.Combine(root, relative));
|
||||
}
|
||||
|
||||
private sealed class InMemoryQueryStore : ITimelineQueryStore
|
||||
{
|
||||
private readonly TimelineEventEnvelope _envelope;
|
||||
|
||||
public InMemoryQueryStore(TimelineEventEnvelope envelope)
|
||||
{
|
||||
_envelope = envelope;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<IReadOnlyList<TimelineEventView>>(Array.Empty<TimelineEventView>());
|
||||
|
||||
public Task<TimelineEventView?> GetAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
=> Task.FromResult<TimelineEventView?>(null);
|
||||
|
||||
public Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.Equals(tenantId, _envelope.TenantId, StringComparison.OrdinalIgnoreCase) ||
|
||||
!string.Equals(eventId, _envelope.EventId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult<TimelineEvidenceView?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<TimelineEvidenceView?>(new TimelineEvidenceView
|
||||
{
|
||||
EventId = _envelope.EventId,
|
||||
TenantId = _envelope.TenantId,
|
||||
BundleId = _envelope.BundleId,
|
||||
BundleDigest = _envelope.BundleDigest,
|
||||
AttestationSubject = _envelope.AttestationSubject ?? _envelope.BundleDigest,
|
||||
AttestationDigest = _envelope.AttestationDigest,
|
||||
ManifestUri = _envelope.ManifestUri,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,4 +35,33 @@ public class TimelineEnvelopeParserTests
|
||||
Assert.NotNull(envelope.RawPayloadJson);
|
||||
Assert.NotNull(envelope.NormalizedPayloadJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parser_Maps_Evidence_Metadata()
|
||||
{
|
||||
const string json = """
|
||||
{
|
||||
"eventId": "22222222-2222-2222-2222-222222222222",
|
||||
"tenantId": "tenant-b",
|
||||
"kind": "export.bundle.sealed",
|
||||
"occurredAt": "2025-12-02T01:02:03Z",
|
||||
"bundleId": "9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa",
|
||||
"bundleDigest": "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
"attestationSubject": "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
"attestationDigest": "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
|
||||
"manifestUri": "bundles/9f34f8c6/manifest.dsse.json"
|
||||
}
|
||||
""";
|
||||
|
||||
var parser = new TimelineEnvelopeParser();
|
||||
|
||||
var parsed = parser.TryParse(json, out var envelope, out var reason);
|
||||
|
||||
Assert.True(parsed, reason);
|
||||
Assert.Equal(Guid.Parse("9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa"), envelope.BundleId);
|
||||
Assert.Equal("sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", envelope.BundleDigest);
|
||||
Assert.Equal("sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd", envelope.AttestationSubject);
|
||||
Assert.Equal("sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd", envelope.AttestationDigest);
|
||||
Assert.Equal("bundles/9f34f8c6/manifest.dsse.json", envelope.ManifestUri);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,36 @@ public class TimelineIngestionServiceTests
|
||||
Assert.False(second.Inserted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ingest_PersistsEvidenceMetadata_WhenPresent()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var service = new TimelineIngestionService(store);
|
||||
var envelope = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-evidence",
|
||||
TenantId = "tenant-e",
|
||||
EventType = "export.bundle.sealed",
|
||||
Source = "exporter",
|
||||
OccurredAt = DateTimeOffset.Parse("2025-12-02T01:02:03Z"),
|
||||
RawPayloadJson = "{}",
|
||||
BundleId = Guid.Parse("9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa"),
|
||||
BundleDigest = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
AttestationSubject = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
AttestationDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
|
||||
ManifestUri = "bundles/9f34f8c6/manifest.dsse.json"
|
||||
};
|
||||
|
||||
var result = await service.IngestAsync(envelope, TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.Inserted);
|
||||
Assert.Equal(envelope.BundleId, store.LastEnvelope?.BundleId);
|
||||
Assert.Equal(envelope.BundleDigest, store.LastEnvelope?.BundleDigest);
|
||||
Assert.Equal(envelope.AttestationSubject, store.LastEnvelope?.AttestationSubject);
|
||||
Assert.Equal(envelope.AttestationDigest, store.LastEnvelope?.AttestationDigest);
|
||||
Assert.Equal(envelope.ManifestUri, store.LastEnvelope?.ManifestUri);
|
||||
}
|
||||
|
||||
private sealed class FakeStore : ITimelineEventStore
|
||||
{
|
||||
private readonly HashSet<(string tenant, string id)> _seen = new();
|
||||
|
||||
@@ -49,16 +49,71 @@ public sealed class TimelineIngestionWorkerTests
|
||||
Assert.Equal("sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", store.LastHash); // hash of "{}"
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Worker_Passes_Evidence_Metadata()
|
||||
{
|
||||
var subscriber = new InMemoryTimelineEventSubscriber();
|
||||
var store = new RecordingStore();
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ITimelineEventSubscriber>(subscriber);
|
||||
services.AddSingleton<ITimelineEventStore>(store);
|
||||
services.AddSingleton<ITimelineIngestionService, TimelineIngestionService>();
|
||||
services.AddSingleton<IHostedService, TimelineIngestionWorker>();
|
||||
services.AddLogging();
|
||||
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var hosted = provider.GetRequiredService<IHostedService>();
|
||||
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
await hosted.StartAsync(cts.Token);
|
||||
|
||||
var evt = new TimelineEventEnvelope
|
||||
{
|
||||
EventId = "evt-evidence-worker",
|
||||
TenantId = "tenant-e",
|
||||
EventType = "export.bundle.sealed",
|
||||
Source = "exporter",
|
||||
OccurredAt = DateTimeOffset.UtcNow,
|
||||
RawPayloadJson = "{}",
|
||||
BundleId = Guid.Parse("9f34f8c6-7a7c-4d63-9c70-2ae6e8f8c6aa"),
|
||||
BundleDigest = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
AttestationSubject = "sha256:abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd",
|
||||
AttestationDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcd",
|
||||
ManifestUri = "bundles/9f34f8c6/manifest.dsse.json"
|
||||
};
|
||||
|
||||
subscriber.Enqueue(evt);
|
||||
subscriber.Complete();
|
||||
|
||||
await Task.Delay(200, cts.Token);
|
||||
await hosted.StopAsync(cts.Token);
|
||||
|
||||
Assert.Equal(evt.BundleId, store.LastBundleId);
|
||||
Assert.Equal(evt.BundleDigest, store.LastBundleDigest);
|
||||
Assert.Equal(evt.AttestationSubject, store.LastAttestationSubject);
|
||||
Assert.Equal(evt.AttestationDigest, store.LastAttestationDigest);
|
||||
Assert.Equal(evt.ManifestUri, store.LastManifestUri);
|
||||
}
|
||||
|
||||
private sealed class RecordingStore : ITimelineEventStore
|
||||
{
|
||||
private readonly HashSet<(string tenant, string id)> _seen = new();
|
||||
public int InsertCalls { get; private set; }
|
||||
public string? LastHash { get; private set; }
|
||||
public Guid? LastBundleId { get; private set; }
|
||||
public string? LastBundleDigest { get; private set; }
|
||||
public string? LastAttestationSubject { get; private set; }
|
||||
public string? LastAttestationDigest { get; private set; }
|
||||
public string? LastManifestUri { get; private set; }
|
||||
|
||||
public Task<bool> InsertAsync(TimelineEventEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
InsertCalls++;
|
||||
LastHash = envelope.PayloadHash;
|
||||
LastBundleId = envelope.BundleId;
|
||||
LastBundleDigest = envelope.BundleDigest;
|
||||
LastAttestationSubject = envelope.AttestationSubject;
|
||||
LastAttestationDigest = envelope.AttestationDigest;
|
||||
LastManifestUri = envelope.ManifestUri;
|
||||
return Task.FromResult(_seen.Add((envelope.TenantId, envelope.EventId)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,10 +29,49 @@ public class TimelineQueryServiceTests
|
||||
Assert.Equal(("tenant-1", "evt-1"), store.LastGet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceAsync_PassesTenantAndId()
|
||||
{
|
||||
var store = new FakeStore();
|
||||
var service = new TimelineQueryService(store);
|
||||
|
||||
await service.GetEvidenceAsync("tenant-x", "evt-evidence", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(("tenant-x", "evt-evidence"), store.LastEvidenceGet);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEvidenceAsync_FillsManifestUriFromBundleId_WhenMissing()
|
||||
{
|
||||
var bundleId = Guid.Parse("11111111-1111-1111-1111-111111111111");
|
||||
var store = new FakeStore
|
||||
{
|
||||
Evidence = new TimelineEvidenceView
|
||||
{
|
||||
EventId = "evt",
|
||||
TenantId = "tenant",
|
||||
BundleId = bundleId,
|
||||
BundleDigest = "sha256:deadbeef",
|
||||
AttestationSubject = "sha256:deadbeef",
|
||||
AttestationDigest = "sha256:feedface",
|
||||
ManifestUri = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
var service = new TimelineQueryService(store);
|
||||
|
||||
var evidence = await service.GetEvidenceAsync("tenant", "evt", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(evidence);
|
||||
Assert.Equal($"bundles/{bundleId:N}/manifest.dsse.json", evidence!.ManifestUri);
|
||||
}
|
||||
|
||||
private sealed class FakeStore : ITimelineQueryStore
|
||||
{
|
||||
public TimelineQueryOptions? LastOptions { get; private set; }
|
||||
public (string tenant, string id)? LastGet { get; private set; }
|
||||
public (string tenant, string id)? LastEvidenceGet { get; private set; }
|
||||
public TimelineEvidenceView? Evidence { get; set; }
|
||||
|
||||
public Task<IReadOnlyList<TimelineEventView>> QueryAsync(string tenantId, TimelineQueryOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -45,5 +84,11 @@ public class TimelineQueryServiceTests
|
||||
LastGet = (tenantId, eventId);
|
||||
return Task.FromResult<TimelineEventView?>(null);
|
||||
}
|
||||
|
||||
public Task<TimelineEvidenceView?> GetEvidenceAsync(string tenantId, string eventId, CancellationToken cancellationToken)
|
||||
{
|
||||
LastEvidenceGet = (tenantId, eventId);
|
||||
return Task.FromResult(Evidence);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,18 @@ app.MapGet("/timeline/{eventId}", async (
|
||||
})
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineRead);
|
||||
|
||||
app.MapGet("/timeline/{eventId}/evidence", async (
|
||||
HttpContext ctx,
|
||||
ITimelineQueryService service,
|
||||
string eventId,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var tenantId = GetTenantId(ctx);
|
||||
var evidence = await service.GetEvidenceAsync(tenantId, eventId, cancellationToken).ConfigureAwait(false);
|
||||
return evidence is null ? Results.NotFound() : Results.Ok(evidence);
|
||||
})
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineRead);
|
||||
|
||||
app.MapPost("/timeline/events", () => Results.Accepted("/timeline/events", new { status = "indexed" }))
|
||||
.RequireAuthorization(StellaOpsResourceServerPolicies.TimelineWrite);
|
||||
|
||||
|
||||
@@ -11,3 +11,14 @@
|
||||
| WEB-VULN-29-LEDGER-DOC | DONE (2025-12-01) | Findings Ledger proxy contract doc v1.0 with idempotency + retries (`docs/api/gateway/findings-ledger-proxy.md`). |
|
||||
| WEB-RISK-68-NOTIFY-DOC | DONE (2025-12-01) | Notifications severity transition event schema v1.0 published (`docs/api/gateway/notifications-severity.md`). |
|
||||
| UI-MICRO-GAPS-0209-011 | DOING (2025-12-04) | Motion token catalog + Storybook/Playwright a11y harness added; remaining work: component mapping, perf budgets, deterministic snapshots. |
|
||||
| UI-POLICY-20-001 | DONE (2025-12-05) | Policy Studio Monaco editor with DSL highlighting, lint markers, and compliance checklist shipped. |
|
||||
| UI-POLICY-20-002 | DONE (2025-12-05) | Simulation panel with deterministic diff rendering shipped (`/policy-studio/packs/:packId/simulate`). |
|
||||
| UI-POLICY-20-003 | DONE (2025-12-05) | Approvals workflow UI delivered with submit/review actions, two-person badge, and deterministic log. |
|
||||
| UI-POLICY-20-004 | DONE (2025-12-05) | Policy run dashboards delivered with filters, exports, heatmap, and daily deltas. |
|
||||
| UI-POLICY-23-000 | DONE (2025-12-05) | Added Policy Studio nav dropdown with pack selector and persisted selection. |
|
||||
| UI-POLICY-23-001 | DONE (2025-12-05) | Workspace route `/policy-studio/packs` with pack list + quick actions; cached pack store with offline fallback. |
|
||||
| UI-POLICY-23-002 | DONE (2025-12-05) | YAML editor route `/policy-studio/packs/:packId/yaml` with canonical preview and lint diagnostics. |
|
||||
| UI-POLICY-23-003 | DONE (2025-12-05) | Rule Builder route `/policy-studio/packs/:packId/rules` with guided inputs and deterministic preview JSON. |
|
||||
| UI-POLICY-23-005 | DONE (2025-12-05) | Simulator updated with SBOM/advisory pickers and explain trace view; uses PolicyApiService simulate. |
|
||||
| UI-POLICY-23-006 | DOING (2025-12-05) | Explain view route `/policy-studio/packs/:packId/explain/:runId` with trace + JSON export; PDF export pending backend. |
|
||||
| UI-POLICY-23-001 | DONE (2025-12-05) | Workspace route `/policy-studio/packs` with pack list + quick actions; cached pack store with offline fallback. |
|
||||
|
||||
30
src/Web/StellaOps.Web/package-lock.json
generated
30
src/Web/StellaOps.Web/package-lock.json
generated
@@ -16,8 +16,10 @@
|
||||
"@angular/platform-browser": "^17.3.0",
|
||||
"@angular/platform-browser-dynamic": "^17.3.0",
|
||||
"@angular/router": "^17.3.0",
|
||||
"monaco-editor": "0.52.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"yaml": "^2.4.2",
|
||||
"zone.js": "~0.14.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -11207,6 +11209,16 @@
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fork-ts-checker-webpack-plugin/node_modules/yaml": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"dev": true,
|
||||
@@ -13921,6 +13933,12 @@
|
||||
"ufo": "^1.6.1"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.52.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.0.tgz",
|
||||
"integrity": "sha512-OeWhNpABLCeTqubfqLMXGsqf6OmPU6pHM85kF3dhy6kq5hnhuVS1p3VrEW/XhWHc71P2tHyS5JFySD8mgs1crw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.0",
|
||||
"dev": true,
|
||||
@@ -18778,13 +18796,15 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "1.10.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
|
||||
"dev": true,
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz",
|
||||
"integrity": "sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
|
||||
@@ -31,8 +31,10 @@
|
||||
"@angular/platform-browser": "^17.3.0",
|
||||
"@angular/platform-browser-dynamic": "^17.3.0",
|
||||
"@angular/router": "^17.3.0",
|
||||
"monaco-editor": "0.52.0",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"yaml": "^2.4.2",
|
||||
"zone.js": "~0.14.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -11,22 +11,60 @@
|
||||
</section>
|
||||
<header class="app-header">
|
||||
<div class="app-brand">StellaOps Dashboard</div>
|
||||
<nav class="app-nav">
|
||||
<a routerLink="/console/profile" routerLinkActive="active">
|
||||
Console Profile
|
||||
</a>
|
||||
<a routerLink="/concelier/trivy-db-settings" routerLinkActive="active">
|
||||
Trivy DB Export
|
||||
</a>
|
||||
<a routerLink="/scans/scan-verified-001" routerLinkActive="active">
|
||||
Scan Detail
|
||||
</a>
|
||||
<nav class="app-nav">
|
||||
<a routerLink="/console/profile" routerLinkActive="active">
|
||||
Console Profile
|
||||
</a>
|
||||
<a routerLink="/concelier/trivy-db-settings" routerLinkActive="active">
|
||||
Trivy DB Export
|
||||
</a>
|
||||
<a routerLink="/scans/scan-verified-001" routerLinkActive="active">
|
||||
Scan Detail
|
||||
</a>
|
||||
<a routerLink="/notify" routerLinkActive="active">
|
||||
Notify
|
||||
</a>
|
||||
<a routerLink="/risk" routerLinkActive="active">
|
||||
Risk
|
||||
</a>
|
||||
<div class="nav-group" routerLinkActive="active">
|
||||
<span>Policy Studio</span>
|
||||
<div class="nav-group__menu">
|
||||
<app-policy-pack-selector (packSelected)="onPackSelected($event)"></app-policy-pack-selector>
|
||||
<a
|
||||
[routerLink]="['/policy-studio/packs', selectedPack, 'editor']"
|
||||
[class.nav-disabled]="!canAuthor"
|
||||
[attr.aria-disabled]="!canAuthor"
|
||||
[title]="canAuthor ? '' : 'Requires policy:author scope'"
|
||||
>
|
||||
Editor
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="['/policy-studio/packs', selectedPack, 'simulate']"
|
||||
[class.nav-disabled]="!canSimulate"
|
||||
[attr.aria-disabled]="!canSimulate"
|
||||
[title]="canSimulate ? '' : 'Requires policy:simulate scope'"
|
||||
>
|
||||
Simulate
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="['/policy-studio/packs', selectedPack, 'approvals']"
|
||||
[class.nav-disabled]="!canReview"
|
||||
[attr.aria-disabled]="!canReview"
|
||||
[title]="canReview ? '' : 'Requires policy:review scope'"
|
||||
>
|
||||
Approvals
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="['/policy-studio/packs', selectedPack, 'dashboard']"
|
||||
[class.nav-disabled]="!canView"
|
||||
[attr.aria-disabled]="!canView"
|
||||
[title]="canView ? '' : 'Requires policy:read scope'"
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<a routerLink="/welcome" routerLinkActive="active">
|
||||
Welcome
|
||||
</a>
|
||||
|
||||
@@ -11,22 +11,33 @@ import { AuthorityAuthService } from './core/auth/authority-auth.service';
|
||||
import { AuthSessionStore } from './core/auth/auth-session.store';
|
||||
import { ConsoleSessionStore } from './core/console/console-session.store';
|
||||
import { AppConfigService } from './core/config/app-config.service';
|
||||
import { AuthService, AUTH_SERVICE } from './core/auth';
|
||||
import { PolicyPackSelectorComponent } from './shared/components/policy-pack-selector.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive],
|
||||
imports: [CommonModule, RouterOutlet, RouterLink, RouterLinkActive, PolicyPackSelectorComponent],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrl: './app.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppComponent {
|
||||
export class AppComponent {
|
||||
private readonly router = inject(Router);
|
||||
private readonly auth = inject(AuthorityAuthService);
|
||||
private readonly authService = inject(AUTH_SERVICE) as AuthService;
|
||||
private readonly sessionStore = inject(AuthSessionStore);
|
||||
private readonly consoleStore = inject(ConsoleSessionStore);
|
||||
private readonly config = inject(AppConfigService);
|
||||
|
||||
private readonly packStorageKey = 'policy-studio:selected-pack';
|
||||
|
||||
protected selectedPack = this.loadStoredPack();
|
||||
protected canView = computed(() => this.authService.canViewPolicies?.() ?? false);
|
||||
protected canAuthor = computed(() => this.authService.canAuthorPolicies?.() ?? false);
|
||||
protected canSimulate = computed(() => this.authService.canSimulatePolicies?.() ?? false);
|
||||
protected canReview = computed(() => this.authService.canReviewPolicies?.() ?? false);
|
||||
|
||||
readonly status = this.sessionStore.status;
|
||||
readonly identity = this.sessionStore.identity;
|
||||
readonly subjectHint = this.sessionStore.subjectHint;
|
||||
@@ -64,7 +75,25 @@ export class AppComponent {
|
||||
void this.auth.beginLogin(returnUrl);
|
||||
}
|
||||
|
||||
onSignOut(): void {
|
||||
void this.auth.logout();
|
||||
}
|
||||
}
|
||||
onSignOut(): void {
|
||||
void this.auth.logout();
|
||||
}
|
||||
|
||||
onPackSelected(packId: string): void {
|
||||
this.selectedPack = packId;
|
||||
try {
|
||||
localStorage.setItem(this.packStorageKey, packId);
|
||||
} catch {
|
||||
/* ignore storage errors to stay offline-safe */
|
||||
}
|
||||
}
|
||||
|
||||
private loadStoredPack(): string {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.packStorageKey);
|
||||
return stored || 'pack-1';
|
||||
} catch {
|
||||
return 'pack-1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import {
|
||||
requireOrchViewerGuard,
|
||||
requireOrchOperatorGuard,
|
||||
} from './core/auth';
|
||||
import {
|
||||
requireOrchViewerGuard,
|
||||
requireOrchOperatorGuard,
|
||||
requirePolicyAuthorGuard,
|
||||
requirePolicySimulatorGuard,
|
||||
requirePolicyReviewerGuard,
|
||||
requirePolicyApproverGuard,
|
||||
requirePolicyViewerGuard,
|
||||
} from './core/auth';
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
@@ -59,10 +64,74 @@ export const routes: Routes = [
|
||||
import('./features/orchestrator/orchestrator-quotas.component').then(
|
||||
(m) => m.OrchestratorQuotasComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'concelier/trivy-db-settings',
|
||||
loadComponent: () =>
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs',
|
||||
canMatch: [requirePolicyViewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/workspace/policy-workspace.component').then(
|
||||
(m) => m.PolicyWorkspaceComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/editor',
|
||||
canMatch: [requirePolicyAuthorGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/editor/policy-editor.component').then(
|
||||
(m) => m.PolicyEditorComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/yaml',
|
||||
canMatch: [requirePolicyAuthorGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/yaml/policy-yaml-editor.component').then(
|
||||
(m) => m.PolicyYamlEditorComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/simulate',
|
||||
canMatch: [requirePolicySimulatorGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/simulation/policy-simulation.component').then(
|
||||
(m) => m.PolicySimulationComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/approvals',
|
||||
canMatch: [requirePolicyReviewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/approvals/policy-approvals.component').then(
|
||||
(m) => m.PolicyApprovalsComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/rules',
|
||||
canMatch: [requirePolicyAuthorGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/rule-builder/policy-rule-builder.component').then(
|
||||
(m) => m.PolicyRuleBuilderComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/explain/:runId',
|
||||
canMatch: [requirePolicyViewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/explain/policy-explain.component').then(
|
||||
(m) => m.PolicyExplainComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'policy-studio/packs/:packId/dashboard',
|
||||
canMatch: [requirePolicyViewerGuard],
|
||||
loadComponent: () =>
|
||||
import('./features/policy-studio/dashboard/policy-dashboard.component').then(
|
||||
(m) => m.PolicyDashboardComponent
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'concelier/trivy-db-settings',
|
||||
loadComponent: () =>
|
||||
import('./features/trivy-db-settings/trivy-db-settings-page.component').then(
|
||||
(m) => m.TrivyDbSettingsPageComponent
|
||||
),
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { AUTH_SERVICE } from '../../../core/auth';
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { PolicyApprovalsComponent } from './policy-approvals.component';
|
||||
|
||||
describe('PolicyApprovalsComponent', () => {
|
||||
let fixture: ComponentFixture<PolicyApprovalsComponent>;
|
||||
let component: PolicyApprovalsComponent;
|
||||
let api: jasmine.SpyObj<PolicyApiService>;
|
||||
let auth: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', [
|
||||
'getApprovalWorkflow',
|
||||
'submitForReview',
|
||||
'addReview',
|
||||
]);
|
||||
|
||||
api.getApprovalWorkflow.and.returnValue(
|
||||
of({
|
||||
policyId: 'pack-1',
|
||||
policyVersion: '1.0.0',
|
||||
status: 'in_review',
|
||||
submittedAt: '2025-12-05T00:00:00Z',
|
||||
submittedBy: 'user-a',
|
||||
reviews: [
|
||||
{
|
||||
reviewerId: 'user-b',
|
||||
reviewerName: 'Reviewer B',
|
||||
decision: 'approve',
|
||||
comment: 'Looks good',
|
||||
reviewedAt: '2025-12-05T01:00:00Z',
|
||||
},
|
||||
{
|
||||
reviewerId: 'user-c',
|
||||
reviewerName: 'Reviewer C',
|
||||
decision: 'request_changes',
|
||||
comment: 'Need more tests',
|
||||
reviewedAt: '2025-12-05T01:30:00Z',
|
||||
},
|
||||
],
|
||||
requiredApprovers: 2,
|
||||
currentApprovers: 1,
|
||||
}) as any
|
||||
);
|
||||
|
||||
api.submitForReview.and.returnValue(of({}) as any);
|
||||
api.addReview.and.returnValue(of({}) as any);
|
||||
|
||||
auth = {
|
||||
canApprovePolicies: () => true,
|
||||
canReviewPolicies: () => true,
|
||||
};
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, ReactiveFormsModule, PolicyApprovalsComponent],
|
||||
providers: [
|
||||
{ provide: PolicyApiService, useValue: api },
|
||||
{ provide: AUTH_SERVICE, useValue: auth },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ packId: 'pack-1' }),
|
||||
queryParamMap: convertToParamMap({ version: '1.0.0' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyApprovalsComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
});
|
||||
|
||||
it('sorts reviews newest first', () => {
|
||||
const reviews = component.sortedReviews;
|
||||
expect(reviews[0].reviewerId).toBe('user-c');
|
||||
expect(reviews[1].reviewerId).toBe('user-b');
|
||||
});
|
||||
|
||||
it('calls addReview with decision', fakeAsync(() => {
|
||||
component.reviewForm.setValue({ comment: 'Approve now' });
|
||||
component.onReview('approve');
|
||||
tick();
|
||||
expect(api.addReview).toHaveBeenCalledWith('pack-1', '1.0.0', {
|
||||
decision: 'approve',
|
||||
comment: 'Approve now',
|
||||
});
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,355 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
import { AUTH_SERVICE, AuthService } from '../../../core/auth';
|
||||
import {
|
||||
type ApprovalReview,
|
||||
type ApprovalWorkflow,
|
||||
type PolicySubmissionRequest,
|
||||
} from '../models/policy.models';
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-approvals',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="approvals" aria-busy="{{ loading }}">
|
||||
<header class="approvals__header">
|
||||
<div>
|
||||
<p class="approvals__eyebrow">Policy Studio · Approvals</p>
|
||||
<h1>Submit, review, approve</h1>
|
||||
<p class="approvals__lede">
|
||||
Two-person approval with deterministic audit trail. Status: {{ workflow?.status || 'unknown' | titlecase }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="approvals__meta" *ngIf="workflow">
|
||||
<span class="pill" [class.pill--approved]="workflow.status === 'approved'" [class.pill--pending]="workflow.status !== 'approved'">
|
||||
{{ workflow.status | titlecase }}
|
||||
</span>
|
||||
<span class="approvals__count">Required approvers: {{ workflow.requiredApprovers }}</span>
|
||||
<span class="approvals__count">Current: {{ workflow.currentApprovers }}</span>
|
||||
<span class="approvals__badge" [class.approvals__badge--ready]="isReadyToApprove" [class.approvals__badge--missing]="!isReadyToApprove">
|
||||
Two-person rule: {{ isReadyToApprove ? 'Satisfied' : 'Missing second approver' }}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="approvals__grid" *ngIf="workflow">
|
||||
<div class="card">
|
||||
<header>
|
||||
<h3>Submit for review</h3>
|
||||
<p>Attach context so reviewers can reproduce.</p>
|
||||
</header>
|
||||
<form [formGroup]="submitForm" (ngSubmit)="onSubmit()" class="stack">
|
||||
<label class="field">
|
||||
<span>Message to reviewers</span>
|
||||
<textarea rows="3" formControlName="message" placeholder="What changed, evidence, risks"></textarea>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Coverage results link (optional)</span>
|
||||
<input formControlName="coverageResults" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Simulation diff reference (optional)</span>
|
||||
<input formControlName="simulationDiff" placeholder="Run ID or artifact path" />
|
||||
</label>
|
||||
<button class="btn" type="submit" [disabled]="submitForm.invalid || submitting">{{ submitting ? 'Submitting…' : 'Submit for review' }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<header>
|
||||
<h3>Review & approve</h3>
|
||||
<p>Deterministic ordering by reviewedAt.</p>
|
||||
</header>
|
||||
<form [formGroup]="reviewForm" (ngSubmit)="onReview('approve')" class="stack">
|
||||
<label class="field">
|
||||
<span>Comment</span>
|
||||
<textarea rows="2" formControlName="comment" placeholder="Decision rationale"></textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button class="btn" type="button" [disabled]="reviewForm.invalid || reviewing" (click)="onReview('approve')">Approve</button>
|
||||
<button class="btn btn--warn" type="button" [disabled]="reviewForm.invalid || reviewing" (click)="onReview('reject')">Reject</button>
|
||||
<button class="btn btn--ghost" type="button" [disabled]="reviewForm.invalid || reviewing" (click)="onReview('request_changes')">Request changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="timeline" *ngIf="workflow">
|
||||
<header class="timeline__header">
|
||||
<h3>Approvals log</h3>
|
||||
<p>Sorted newest → oldest; stable per timestamp + reviewer id.</p>
|
||||
</header>
|
||||
<ol>
|
||||
<li *ngFor="let review of sortedReviews">
|
||||
<div class="dot" [attr.data-decision]="review.decision"></div>
|
||||
<div class="timeline__body">
|
||||
<div class="timeline__headline">
|
||||
<strong>{{ review.decision | titlecase }}</strong>
|
||||
<span>by {{ review.reviewerName }} ({{ review.reviewerId }})</span>
|
||||
<span class="timeline__time">{{ review.reviewedAt | date:'medium' }}</span>
|
||||
</div>
|
||||
<p class="timeline__comment">{{ review.comment }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
background: linear-gradient(145deg, #0f172a 0%, #0b1224 60%, #0a0f1f 100%);
|
||||
color: #e5e7eb;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.approvals {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.approvals__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.approvals__eyebrow {
|
||||
margin: 0;
|
||||
color: #a5b4fc;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.approvals__lede {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.approvals__meta {
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #334155;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.pill--approved {
|
||||
border-color: #22c55e;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.pill--pending {
|
||||
border-color: #f59e0b;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.approvals__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 15px 40px rgba(0,0,0,0.25);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.field span {
|
||||
display: block;
|
||||
margin-bottom: 0.2rem;
|
||||
color: #cbd5e1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
textarea,
|
||||
input {
|
||||
width: 100%;
|
||||
border: 1px solid #1f2937;
|
||||
background: #0b1224;
|
||||
color: #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem;
|
||||
font-family: 'Monaco','Consolas', monospace;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: #2563eb;
|
||||
border: 1px solid #2563eb;
|
||||
color: #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.9rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn:hover { background: #1d4ed8; }
|
||||
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.btn--warn { background: #f97316; border-color: #f97316; color: #0b1224; }
|
||||
|
||||
.btn--ghost { background: transparent; border-color: #334155; color: #cbd5e1; }
|
||||
|
||||
.timeline {
|
||||
margin-top: 1rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.timeline__header h3 { margin: 0; color: #f8fafc; }
|
||||
.timeline__header p { margin: 0.2rem 0 0; color: #94a3b8; }
|
||||
|
||||
ol { list-style: none; margin: 0.75rem 0 0; padding: 0; display: grid; gap: 0.75rem; }
|
||||
|
||||
li { display: grid; grid-template-columns: auto 1fr; gap: 0.75rem; }
|
||||
|
||||
.dot {
|
||||
width: 12px; height: 12px; border-radius: 50%; border: 2px solid #1f2937;
|
||||
}
|
||||
|
||||
.dot[data-decision='approve'] { background: #22c55e; border-color: #22c55e; }
|
||||
.dot[data-decision='reject'] { background: #ef4444; border-color: #ef4444; }
|
||||
.dot[data-decision='request_changes'] { background: #f59e0b; border-color: #f59e0b; }
|
||||
|
||||
.timeline__headline { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
|
||||
.timeline__headline span { color: #94a3b8; }
|
||||
.timeline__time { font-size: 0.9rem; }
|
||||
.timeline__comment { margin: 0.15rem 0 0; color: #e5e7eb; }
|
||||
|
||||
@media (max-width: 960px) { .approvals__header { flex-direction: column; } }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyApprovalsComponent {
|
||||
protected workflow?: ApprovalWorkflow;
|
||||
protected loading = false;
|
||||
protected submitting = false;
|
||||
protected reviewing = false;
|
||||
|
||||
protected readonly submitForm = this.fb.group({
|
||||
message: ['', [Validators.required, Validators.minLength(5)]],
|
||||
coverageResults: [''],
|
||||
simulationDiff: [''],
|
||||
});
|
||||
|
||||
protected readonly reviewForm = this.fb.group({
|
||||
comment: ['', [Validators.required, Validators.minLength(3)]],
|
||||
});
|
||||
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly policyApi = inject(PolicyApiService);
|
||||
private readonly auth = inject(AUTH_SERVICE) as AuthService;
|
||||
|
||||
get sortedReviews(): ApprovalReview[] {
|
||||
if (!this.workflow?.reviews) return [];
|
||||
return [...this.workflow.reviews].sort((a, b) => b.reviewedAt.localeCompare(a.reviewedAt) || a.reviewerId.localeCompare(b.reviewerId));
|
||||
}
|
||||
|
||||
get isReadyToApprove(): boolean {
|
||||
if (!this.workflow) return false;
|
||||
return this.workflow.currentApprovers >= this.workflow.requiredApprovers;
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
|
||||
if (!packId || this.submitForm.invalid) return;
|
||||
|
||||
const payload: PolicySubmissionRequest = {
|
||||
policyId: packId,
|
||||
version: version ?? 'latest',
|
||||
message: this.submitForm.value.message ?? '',
|
||||
coverageResults: this.submitForm.value.coverageResults ?? undefined,
|
||||
simulationDiff: this.submitForm.value.simulationDiff ?? undefined,
|
||||
};
|
||||
|
||||
this.submitting = true;
|
||||
this.policyApi
|
||||
.submitForReview(payload)
|
||||
.pipe(finalize(() => (this.submitting = false)))
|
||||
.subscribe({
|
||||
next: () => this.refresh(),
|
||||
});
|
||||
}
|
||||
|
||||
onReview(decision: 'approve' | 'reject' | 'request_changes'): void {
|
||||
if (!this.workflow || this.reviewForm.invalid) return;
|
||||
if (decision === 'approve' && !this.auth.canApprovePolicies?.()) return;
|
||||
if (decision !== 'approve' && !this.auth.canReviewPolicies?.()) return;
|
||||
|
||||
this.reviewing = true;
|
||||
this.policyApi
|
||||
.addReview(this.workflow.policyId, this.workflow.policyVersion, {
|
||||
decision,
|
||||
comment: this.reviewForm.value.comment ?? '',
|
||||
})
|
||||
.pipe(finalize(() => (this.reviewing = false)))
|
||||
.subscribe({
|
||||
next: () => this.refresh(),
|
||||
});
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
|
||||
if (!packId) return;
|
||||
|
||||
this.loading = true;
|
||||
this.policyApi
|
||||
.getApprovalWorkflow(packId, version ?? 'latest')
|
||||
.pipe(finalize(() => (this.loading = false)))
|
||||
.subscribe({
|
||||
next: (wf) => (this.workflow = wf),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { PolicyDashboardComponent } from './policy-dashboard.component';
|
||||
|
||||
describe('PolicyDashboardComponent', () => {
|
||||
let fixture: ComponentFixture<PolicyDashboardComponent>;
|
||||
let component: PolicyDashboardComponent;
|
||||
let api: jasmine.SpyObj<PolicyApiService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['getRunDashboard']);
|
||||
|
||||
api.getRunDashboard.and.returnValue(
|
||||
of({
|
||||
policyId: 'pack-1',
|
||||
runs: [
|
||||
{
|
||||
runId: 'run-b',
|
||||
policyVersion: '1.0.1',
|
||||
startedAt: '2025-12-05T02:00:00Z',
|
||||
completedAt: '2025-12-05T02:10:00Z',
|
||||
status: 'completed',
|
||||
findingsCount: 10,
|
||||
changedCount: 2,
|
||||
},
|
||||
{
|
||||
runId: 'run-a',
|
||||
policyVersion: '1.0.0',
|
||||
startedAt: '2025-12-04T02:00:00Z',
|
||||
completedAt: '2025-12-04T02:10:00Z',
|
||||
status: 'completed',
|
||||
findingsCount: 12,
|
||||
changedCount: 1,
|
||||
},
|
||||
],
|
||||
ruleHeatmap: [
|
||||
{ ruleName: 'rule-x', hitCount: 30, lastHit: '2025-12-05', averageLatencyMs: 12 },
|
||||
{ ruleName: 'rule-y', hitCount: 10, lastHit: '2025-12-05', averageLatencyMs: 9 },
|
||||
],
|
||||
vexWinsByDay: [],
|
||||
suppressionsByDay: [],
|
||||
}) as any
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, ReactiveFormsModule, PolicyDashboardComponent],
|
||||
providers: [
|
||||
{ provide: PolicyApiService, useValue: api },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ packId: 'pack-1' }),
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyDashboardComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('sorts runs descending by completedAt', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
expect(component.sortedRuns[0].runId).toBe('run-b');
|
||||
}));
|
||||
|
||||
it('computes bar widths relative to max hits', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
const width = component.barWidth(15);
|
||||
expect(width).toBeCloseTo(50);
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import {
|
||||
PolicyRunDashboard,
|
||||
RuleHeatmapEntry,
|
||||
TimeSeriesEntry,
|
||||
} from '../models/policy.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-dashboard',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="dash" aria-busy="{{ loading }}">
|
||||
<header class="dash__header">
|
||||
<div>
|
||||
<p class="dash__eyebrow">Policy Studio · Runs</p>
|
||||
<h1>Run dashboards</h1>
|
||||
<p class="dash__lede">Heatmap, VEX wins, and suppressions; deterministic ordering.</p>
|
||||
</div>
|
||||
<div class="dash__meta" *ngIf="dashboard">
|
||||
<span class="pill">Runs: {{ dashboard.runs.length }}</span>
|
||||
<span class="pill">Rules: {{ dashboard.ruleHeatmap.length }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="dash__grid" *ngIf="dashboard">
|
||||
<div class="card">
|
||||
<header>
|
||||
<h3>Recent runs</h3>
|
||||
<p>Sorted by completedAt desc, stable tie-breaker runId.</p>
|
||||
</header>
|
||||
<form class="filters" [formGroup]="filterForm" (ngSubmit)="onFilter()">
|
||||
<label>
|
||||
<span>Start</span>
|
||||
<input type="date" formControlName="start" />
|
||||
</label>
|
||||
<label>
|
||||
<span>End</span>
|
||||
<input type="date" formControlName="end" />
|
||||
</label>
|
||||
<button type="submit" class="btn">Apply</button>
|
||||
<button type="button" class="btn btn--ghost" (click)="onExport('json')">Export JSON</button>
|
||||
<button type="button" class="btn btn--ghost" (click)="onExport('csv')">Export CSV</button>
|
||||
</form>
|
||||
<ul class="run-list">
|
||||
<li *ngFor="let run of sortedRuns">
|
||||
<div>
|
||||
<strong>{{ run.policyVersion }}</strong>
|
||||
<span class="muted">· {{ run.status | titlecase }}</span>
|
||||
</div>
|
||||
<div class="muted">{{ run.completedAt | date:'medium' }}</div>
|
||||
<div class="muted">Findings: {{ run.findingsCount }} · Changed: {{ run.changedCount }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<header>
|
||||
<h3>Rule heatmap (top 8)</h3>
|
||||
<p>Highest hit count first.</p>
|
||||
</header>
|
||||
<ul class="heatmap">
|
||||
<li *ngFor="let rule of topRules">
|
||||
<span class="heatmap__name">{{ rule.ruleName }}</span>
|
||||
<div class="heatmap__bar">
|
||||
<span class="heatmap__fill" [style.width.%]="barWidth(rule.hitCount)"></span>
|
||||
<span class="heatmap__value">{{ rule.hitCount }}</span>
|
||||
</div>
|
||||
<span class="heatmap__latency">avg {{ rule.averageLatencyMs }} ms</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<header>
|
||||
<h3>Daily deltas</h3>
|
||||
<p>VEX wins and suppressions.</p>
|
||||
</header>
|
||||
<div class="chips">
|
||||
<span class="chip" *ngFor="let v of dailyWins">{{ v.date }} · {{ v.value }} wins</span>
|
||||
</div>
|
||||
<div class="chips">
|
||||
<span class="chip chip--muted" *ngFor="let s of dailySuppressions">{{ s.date }} · {{ s.value }} suppressions</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
|
||||
.dash { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
|
||||
.dash__header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
|
||||
.dash__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
|
||||
.dash__lede { margin: 0.2rem 0 0; color: #94a3b8; }
|
||||
.dash__meta { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.pill { border: 1px solid #334155; padding: 0.35rem 0.7rem; border-radius: 999px; }
|
||||
.dash__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; margin-top: 1rem; }
|
||||
.card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; box-shadow: 0 10px 30px rgba(0,0,0,0.25); }
|
||||
.card h3 { margin: 0; color: #f8fafc; }
|
||||
.card p { margin: 0.15rem 0 0; color: #94a3b8; }
|
||||
.run-list { list-style: none; margin: 0.6rem 0 0; padding: 0; display: grid; gap: 0.5rem; }
|
||||
.run-list li { padding: 0.5rem 0.4rem; border-bottom: 1px solid #1f2937; }
|
||||
.run-list li:last-child { border-bottom: none; }
|
||||
.muted { color: #94a3b8; font-size: 0.9rem; }
|
||||
.heatmap { list-style: none; margin: 0.6rem 0 0; padding: 0; display: grid; gap: 0.65rem; }
|
||||
.heatmap__name { color: #e5e7eb; font-weight: 600; }
|
||||
.heatmap__bar { display: flex; align-items: center; gap: 0.4rem; background: #111827; border-radius: 999px; overflow: hidden; padding: 0.2rem; }
|
||||
.heatmap__fill { display: block; height: 10px; background: linear-gradient(90deg, #22d3ee, #2563eb); border-radius: 999px; }
|
||||
.heatmap__value { color: #cbd5e1; font-size: 0.85rem; }
|
||||
.heatmap__latency { color: #94a3b8; font-size: 0.85rem; }
|
||||
.chips { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; }
|
||||
.chip { border: 1px solid #334155; border-radius: 999px; padding: 0.25rem 0.6rem; background: #0b162e; }
|
||||
.chip--muted { opacity: 0.8; }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyDashboardComponent {
|
||||
protected dashboard?: PolicyRunDashboard;
|
||||
protected loading = false;
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly policyApi = inject(PolicyApiService);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
protected readonly filterForm = this.fb.group({
|
||||
start: [''],
|
||||
end: [''],
|
||||
});
|
||||
|
||||
get sortedRuns() {
|
||||
if (!this.dashboard) return [];
|
||||
return [...this.dashboard.runs].sort((a, b) =>
|
||||
b.completedAt.localeCompare(a.completedAt) || b.runId.localeCompare(a.runId)
|
||||
);
|
||||
}
|
||||
|
||||
get topRules(): RuleHeatmapEntry[] {
|
||||
if (!this.dashboard) return [];
|
||||
return [...this.dashboard.ruleHeatmap].sort((a, b) => b.hitCount - a.hitCount).slice(0, 8);
|
||||
}
|
||||
|
||||
get dailyWins(): TimeSeriesEntry[] {
|
||||
if (!this.dashboard) return [];
|
||||
return this.sortedSeries(this.dashboard.vexWinsByDay);
|
||||
}
|
||||
|
||||
get dailySuppressions(): TimeSeriesEntry[] {
|
||||
if (!this.dashboard) return [];
|
||||
return this.sortedSeries(this.dashboard.suppressionsByDay);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
if (!packId) return;
|
||||
this.loading = true;
|
||||
this.policyApi
|
||||
.getRunDashboard(packId)
|
||||
.pipe(finalize(() => (this.loading = false)))
|
||||
.subscribe({
|
||||
next: (dash) => (this.dashboard = dash),
|
||||
});
|
||||
}
|
||||
|
||||
onFilter(): void {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
if (!packId) return;
|
||||
this.loading = true;
|
||||
const startDate = this.filterForm.value.start || undefined;
|
||||
const endDate = this.filterForm.value.end || undefined;
|
||||
this.policyApi
|
||||
.getRunDashboard(packId, {
|
||||
startDate,
|
||||
endDate,
|
||||
})
|
||||
.pipe(finalize(() => (this.loading = false)))
|
||||
.subscribe({
|
||||
next: (dash) => (this.dashboard = dash),
|
||||
});
|
||||
}
|
||||
|
||||
onExport(format: 'json' | 'csv'): void {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
if (!packId) return;
|
||||
|
||||
this.policyApi
|
||||
.exportResults(packId, format, {
|
||||
startDate: this.filterForm.value.start || undefined,
|
||||
endDate: this.filterForm.value.end || undefined,
|
||||
})
|
||||
.subscribe((blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `policy-run-${packId}.${format}`;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
}
|
||||
|
||||
barWidth(hitCount: number): number {
|
||||
const max = this.topRules[0]?.hitCount || 1;
|
||||
return Math.min(100, (hitCount / max) * 100);
|
||||
}
|
||||
|
||||
private sortedSeries(series: readonly TimeSeriesEntry[]): TimeSeriesEntry[] {
|
||||
return [...series].sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
|
||||
import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
|
||||
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
|
||||
import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
||||
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
|
||||
|
||||
import {
|
||||
defineStellaDslTheme,
|
||||
registerStellaDslLanguage,
|
||||
} from './stella-dsl.language';
|
||||
import { registerStellaDslCompletions } from './stella-dsl.completions';
|
||||
|
||||
type MonacoNamespace = typeof import('monaco-editor');
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MonacoLoaderService {
|
||||
private monacoPromise?: Promise<MonacoNamespace>;
|
||||
|
||||
/**
|
||||
* Lazily loads Monaco editor with Stella DSL language + completions configured.
|
||||
* Result is cached so multiple consumers reuse the same Monaco namespace.
|
||||
*/
|
||||
load(): Promise<MonacoNamespace> {
|
||||
if (this.monacoPromise) {
|
||||
return this.monacoPromise;
|
||||
}
|
||||
|
||||
this.monacoPromise = import(
|
||||
/* webpackChunkName: "monaco-editor" */
|
||||
'monaco-editor/esm/vs/editor/editor.api'
|
||||
).then((monaco) => {
|
||||
this.configureWorkers(monaco);
|
||||
registerStellaDslLanguage(monaco);
|
||||
defineStellaDslTheme(monaco);
|
||||
registerStellaDslCompletions(monaco);
|
||||
return monaco;
|
||||
});
|
||||
|
||||
return this.monacoPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Monaco web workers for language services.
|
||||
* Ensures deterministic, offline-friendly loading (no CDN usage).
|
||||
*/
|
||||
private configureWorkers(monaco: MonacoNamespace): void {
|
||||
const workerByLabel: Record<string, () => Worker> = {
|
||||
json: () => new jsonWorker(),
|
||||
css: () => new cssWorker(),
|
||||
scss: () => new cssWorker(),
|
||||
less: () => new cssWorker(),
|
||||
html: () => new htmlWorker(),
|
||||
handlebars: () => new htmlWorker(),
|
||||
razor: () => new htmlWorker(),
|
||||
javascript: () => new tsWorker(),
|
||||
typescript: () => new tsWorker(),
|
||||
default: () => new editorWorker(),
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - MonacoEnvironment lives on global scope
|
||||
self.MonacoEnvironment = {
|
||||
getWorker(_: unknown, label: string): Worker {
|
||||
const factory = workerByLabel[label] ?? workerByLabel.default;
|
||||
return factory();
|
||||
},
|
||||
};
|
||||
|
||||
// Set a deterministic default theme baseline (extended by defineStellaDslTheme)
|
||||
monaco.editor.setTheme('vs-dark');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
|
||||
import { PolicyEditorComponent } from './policy-editor.component';
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { MonacoLoaderService } from './monaco-loader.service';
|
||||
|
||||
describe('PolicyEditorComponent', () => {
|
||||
let fixture: ComponentFixture<PolicyEditorComponent>;
|
||||
let component: PolicyEditorComponent;
|
||||
let policyApi: jasmine.SpyObj<PolicyApiService>;
|
||||
let monacoLoader: MonacoLoaderStub;
|
||||
|
||||
beforeEach(async () => {
|
||||
policyApi = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['getPack', 'lint']);
|
||||
monacoLoader = new MonacoLoaderStub();
|
||||
|
||||
policyApi.getPack.and.returnValue(
|
||||
of({
|
||||
id: 'pack-1',
|
||||
name: 'Demo Policy',
|
||||
description: 'Example policy for tests',
|
||||
syntax: 'stella-dsl@1',
|
||||
content: 'package "demo" { allow = true }',
|
||||
version: '1.0.0',
|
||||
status: 'draft',
|
||||
metadata: { author: 'tester', tags: ['demo'] },
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
modifiedAt: '2025-12-02T00:00:00Z',
|
||||
createdBy: 'tester',
|
||||
modifiedBy: 'tester',
|
||||
tags: ['demo', 'lint'],
|
||||
digest: 'sha256:abc',
|
||||
})
|
||||
);
|
||||
|
||||
policyApi.lint.and.returnValue(
|
||||
of({ valid: true, errors: [], warnings: [], info: [] }) as any
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, PolicyEditorComponent],
|
||||
providers: [
|
||||
{ provide: PolicyApiService, useValue: policyApi },
|
||||
{ provide: MonacoLoaderService, useValue: monacoLoader },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ packId: 'pack-1' }),
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyEditorComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('loads pack content into the editor model', fakeAsync(() => {
|
||||
tick();
|
||||
expect(monacoLoader.model?.getValue()).toContain('package "demo"');
|
||||
}));
|
||||
|
||||
it('applies lint diagnostics as Monaco markers', fakeAsync(() => {
|
||||
const lintResult = {
|
||||
valid: false,
|
||||
errors: [
|
||||
{
|
||||
severity: 'error' as const,
|
||||
code: 'E100',
|
||||
message: 'Missing rule header',
|
||||
line: 2,
|
||||
column: 3,
|
||||
source: 'policy-lint',
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
info: [],
|
||||
};
|
||||
|
||||
policyApi.lint.and.returnValue(of(lintResult) as any);
|
||||
|
||||
component.triggerLint();
|
||||
tick();
|
||||
|
||||
expect(monacoLoader.lastMarkers.length).toBe(1);
|
||||
expect(monacoLoader.lastMarkers[0].message).toContain('Missing rule header');
|
||||
}));
|
||||
});
|
||||
|
||||
class MonacoLoaderStub {
|
||||
model: FakeModel = new FakeModel('');
|
||||
editor: FakeEditor = new FakeEditor(this.model);
|
||||
lastMarkers: Monaco.editor.IMarkerData[] = [];
|
||||
|
||||
load = jasmine.createSpy('load').and.callFake(async () => {
|
||||
const self = this;
|
||||
return {
|
||||
editor: {
|
||||
createModel: (value: string) => {
|
||||
this.model = new FakeModel(value);
|
||||
this.editor = new FakeEditor(this.model);
|
||||
return this.model as unknown as Monaco.editor.ITextModel;
|
||||
},
|
||||
create: () => this.editor as unknown as Monaco.editor.IStandaloneCodeEditor,
|
||||
setModelMarkers: (
|
||||
_model: Monaco.editor.ITextModel,
|
||||
_owner: string,
|
||||
markers: Monaco.editor.IMarkerData[]
|
||||
) => {
|
||||
self.lastMarkers = markers;
|
||||
},
|
||||
},
|
||||
languages: {
|
||||
register: () => undefined,
|
||||
setMonarchTokensProvider: () => undefined,
|
||||
setLanguageConfiguration: () => undefined,
|
||||
},
|
||||
MarkerSeverity: {
|
||||
Error: 8,
|
||||
Warning: 4,
|
||||
Info: 2,
|
||||
},
|
||||
} as unknown as MonacoNamespace;
|
||||
});
|
||||
}
|
||||
|
||||
class FakeModel {
|
||||
private value: string;
|
||||
|
||||
constructor(initial: string) {
|
||||
this.value = initial;
|
||||
}
|
||||
|
||||
getValue(): string {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
setValue(v: string): void {
|
||||
this.value = v;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
class FakeEditor {
|
||||
private listeners: Array<() => void> = [];
|
||||
|
||||
constructor(private readonly model: FakeModel) {}
|
||||
|
||||
onDidChangeModelContent(cb: () => void): { dispose: () => void } {
|
||||
this.listeners.push(cb);
|
||||
return {
|
||||
dispose: () => {
|
||||
this.listeners = this.listeners.filter((l) => l !== cb);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getModel(): FakeModel {
|
||||
return this.model;
|
||||
}
|
||||
}
|
||||
|
||||
type MonacoNamespace = typeof import('monaco-editor');
|
||||
@@ -0,0 +1,767 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { BehaviorSubject, Subscription, debounceTime, distinctUntilChanged } from 'rxjs';
|
||||
|
||||
import type * as Monaco from 'monaco-editor';
|
||||
|
||||
import {
|
||||
type PolicyDiagnostic,
|
||||
type PolicyLintResult,
|
||||
type PolicyPack,
|
||||
} from '../models/policy.models';
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { MonacoLoaderService } from './monaco-loader.service';
|
||||
|
||||
type MonacoNamespace = typeof import('monaco-editor');
|
||||
|
||||
type ChecklistStatus = 'pass' | 'warn' | 'fail';
|
||||
|
||||
interface ChecklistItem {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly status: ChecklistStatus;
|
||||
readonly hint: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-editor',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="policy-editor" aria-busy="{{ loadingPack }}">
|
||||
<header class="policy-editor__header">
|
||||
<div class="policy-editor__title">
|
||||
<p class="policy-editor__eyebrow">Policy Studio · Authoring</p>
|
||||
<h1>{{ pack?.name || 'Loading policy…' }}</h1>
|
||||
<p class="policy-editor__subtitle" *ngIf="pack">
|
||||
Version {{ pack.version }} · Status: {{ pack.status | titlecase }} ·
|
||||
Digest: {{ pack.digest || 'pending' }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="policy-editor__meta" *ngIf="pack">
|
||||
<div class="policy-editor__meta-item">
|
||||
<span class="policy-editor__meta-label">Updated</span>
|
||||
<span>{{ pack.modifiedAt | date: 'medium' }}</span>
|
||||
</div>
|
||||
<div class="policy-editor__meta-item">
|
||||
<span class="policy-editor__meta-label">Authors</span>
|
||||
<span>{{ pack.metadata?.author || pack.createdBy }}</span>
|
||||
</div>
|
||||
<div class="policy-editor__meta-item">
|
||||
<span class="policy-editor__meta-label">Tags</span>
|
||||
<span>{{ pack.tags?.length || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="policy-editor__layout">
|
||||
<section class="policy-editor__main" aria-label="Policy editor">
|
||||
<div class="editor-shell" [class.editor-shell--loading]="loadingEditor">
|
||||
<div #editorHost class="editor-shell__surface" aria-label="Stella DSL editor"></div>
|
||||
<div class="editor-shell__overlay" *ngIf="loadingEditor">
|
||||
<span class="skeleton skeleton--bar"></span>
|
||||
<span class="skeleton skeleton--bar"></span>
|
||||
<span class="skeleton skeleton--bar"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="policy-editor__toolbar">
|
||||
<div class="toolbar__left">
|
||||
<button type="button" class="btn" (click)="triggerLint()" [disabled]="linting">
|
||||
{{ linting ? 'Linting…' : 'Lint now' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn--ghost" (click)="resetToPack()" [disabled]="linting || loadingPack">
|
||||
Reset to last saved
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar__right">
|
||||
<span class="status-pill" [class.status-pill--success]="diagnostics.length === 0" [class.status-pill--warn]="diagnostics.length > 0">
|
||||
{{ diagnostics.length === 0 ? 'No diagnostics' : diagnostics.length + ' diagnostic(s)' }}
|
||||
</span>
|
||||
<span class="toolbar__timestamp" *ngIf="lastLintAt">Last lint: {{ lastLintAt }}</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<section class="diagnostics" *ngIf="diagnostics.length">
|
||||
<header class="diagnostics__header">
|
||||
<h3>Inline diagnostics</h3>
|
||||
<p>Errors and warnings are sorted deterministically by line and column.</p>
|
||||
</header>
|
||||
<ul class="diagnostics__list">
|
||||
<li *ngFor="let diag of diagnostics" class="diagnostics__item">
|
||||
<span class="diagnostics__severity" [attr.data-severity]="diag.severity">
|
||||
{{ diag.severity | titlecase }}
|
||||
</span>
|
||||
<span class="diagnostics__message">{{ diag.message }}</span>
|
||||
<span class="diagnostics__location">Line {{ diag.line }} · Col {{ diag.column }}</span>
|
||||
<span class="diagnostics__code">{{ diag.code }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="policy-editor__sidebar" aria-label="Compliance checklist">
|
||||
<div class="sidebar-card">
|
||||
<header class="sidebar-card__header">
|
||||
<h3>Compliance checklist</h3>
|
||||
<p>Must stay green before submit/review.</p>
|
||||
</header>
|
||||
<ul class="checklist">
|
||||
<li *ngFor="let item of checklist" class="checklist__item">
|
||||
<span class="checklist__status" [attr.data-status]="item.status"></span>
|
||||
<div class="checklist__body">
|
||||
<span class="checklist__label">{{ item.label }}</span>
|
||||
<span class="checklist__hint">{{ item.hint }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-card" *ngIf="pack">
|
||||
<header class="sidebar-card__header">
|
||||
<h3>Metadata</h3>
|
||||
<p>Context used by review & simulation.</p>
|
||||
</header>
|
||||
<dl class="meta">
|
||||
<div class="meta__row">
|
||||
<dt>Description</dt>
|
||||
<dd>{{ pack.description || 'Not provided' }}</dd>
|
||||
</div>
|
||||
<div class="meta__row">
|
||||
<dt>Tags</dt>
|
||||
<dd>{{ pack.tags?.length ? pack.tags.join(', ') : 'None' }}</dd>
|
||||
</div>
|
||||
<div class="meta__row">
|
||||
<dt>Reviewers</dt>
|
||||
<dd>{{ pack.metadata?.reviewers?.length ? pack.metadata?.reviewers?.join(', ') : 'Unassigned' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
background: #0b1224;
|
||||
color: #e5e7eb;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.policy-editor {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.policy-editor__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.policy-editor__title h1 {
|
||||
margin: 0.1rem 0;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 700;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.policy-editor__eyebrow {
|
||||
margin: 0;
|
||||
color: #93c5fd;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.policy-editor__subtitle {
|
||||
margin: 0;
|
||||
color: #9ca3af;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.policy-editor__meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.policy-editor__meta-item {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.policy-editor__meta-label {
|
||||
display: block;
|
||||
color: #9ca3af;
|
||||
font-size: 0.8rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.policy-editor__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) 340px;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.policy-editor__main {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
box-shadow: 0 15px 50px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.editor-shell {
|
||||
position: relative;
|
||||
min-height: 540px;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #0b1021;
|
||||
}
|
||||
|
||||
.editor-shell__surface {
|
||||
height: 540px;
|
||||
}
|
||||
|
||||
.editor-shell__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
padding: 1.25rem;
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.9), rgba(11, 17, 33, 0.9));
|
||||
}
|
||||
|
||||
.editor-shell--loading .editor-shell__surface {
|
||||
filter: blur(1px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
display: block;
|
||||
background: linear-gradient(90deg, #1f2937, #111827, #1f2937);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.2s ease-in-out infinite;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.skeleton--bar {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.policy-editor__toolbar {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-top: 1px solid #1f2937;
|
||||
}
|
||||
|
||||
.toolbar__left,
|
||||
.toolbar__right {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid #2563eb;
|
||||
background: #1d4ed8;
|
||||
color: #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 0.55rem 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #1e40af;
|
||||
border-color: #1e40af;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
border-color: #374151;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #374151;
|
||||
color: #e5e7eb;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.status-pill--success {
|
||||
border-color: #22c55e;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.status-pill--warn {
|
||||
border-color: #f59e0b;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.toolbar__timestamp {
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.diagnostics {
|
||||
margin-top: 1.25rem;
|
||||
background: #0b1224;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.diagnostics__header h3 {
|
||||
margin: 0;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.diagnostics__header p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.diagnostics__list {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.diagnostics__item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.diagnostics__severity {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
border: 1px solid #374151;
|
||||
}
|
||||
|
||||
.diagnostics__severity[data-severity='error'] {
|
||||
border-color: #ef4444;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.diagnostics__severity[data-severity='warning'] {
|
||||
border-color: #f59e0b;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.diagnostics__severity[data-severity='info'] {
|
||||
border-color: #38bdf8;
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.diagnostics__message {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.diagnostics__location,
|
||||
.diagnostics__code {
|
||||
color: #9ca3af;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.policy-editor__sidebar {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.sidebar-card__header h3 {
|
||||
margin: 0;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
.sidebar-card__header p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #9ca3af;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.checklist {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.checklist__item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.65rem;
|
||||
background: #0b1224;
|
||||
}
|
||||
|
||||
.checklist__status {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #1f2937;
|
||||
}
|
||||
|
||||
.checklist__status[data-status='pass'] {
|
||||
border-color: #22c55e;
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.checklist__status[data-status='warn'] {
|
||||
border-color: #f59e0b;
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.checklist__status[data-status='fail'] {
|
||||
border-color: #ef4444;
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.checklist__label {
|
||||
display: block;
|
||||
color: #e5e7eb;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
.checklist__hint {
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 0.5rem 0 0;
|
||||
}
|
||||
|
||||
.meta__row {
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
|
||||
.meta__row dt {
|
||||
color: #9ca3af;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.meta__row dd {
|
||||
margin: 0.05rem 0 0;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.policy-editor__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyEditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
@ViewChild('editorHost', { static: false })
|
||||
private editorHost?: ElementRef<HTMLDivElement>;
|
||||
|
||||
protected pack?: PolicyPack;
|
||||
protected diagnostics: PolicyDiagnostic[] = [];
|
||||
protected checklist: ChecklistItem[] = [];
|
||||
protected linting = false;
|
||||
protected loadingPack = true;
|
||||
protected loadingEditor = true;
|
||||
protected lastLintAt?: string;
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly policyApi = inject(PolicyApiService);
|
||||
private readonly monacoLoader = inject(MonacoLoaderService);
|
||||
private readonly cdr = inject(ChangeDetectorRef);
|
||||
|
||||
private monaco?: MonacoNamespace;
|
||||
private model?: Monaco.editor.ITextModel;
|
||||
private editor?: Monaco.editor.IStandaloneCodeEditor;
|
||||
private readonly content$ = new BehaviorSubject<string>('');
|
||||
private readonly subscriptions = new Subscription();
|
||||
|
||||
ngOnInit(): void {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
|
||||
|
||||
if (!packId) {
|
||||
this.loadingPack = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadPack(packId, version ?? undefined);
|
||||
|
||||
this.subscriptions.add(
|
||||
this.content$
|
||||
.pipe(debounceTime(400), distinctUntilChanged())
|
||||
.subscribe((content) => this.runLint(content))
|
||||
);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.initialiseEditor();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.subscriptions.unsubscribe();
|
||||
if (this.model && this.monaco) {
|
||||
this.monaco.editor.setModelMarkers(this.model, 'policy-lint', []);
|
||||
this.model.dispose();
|
||||
}
|
||||
this.editor?.dispose();
|
||||
}
|
||||
|
||||
triggerLint(): void {
|
||||
this.runLint(this.content$.value);
|
||||
}
|
||||
|
||||
resetToPack(): void {
|
||||
if (!this.pack || !this.model) return;
|
||||
this.model.setValue(this.pack.content ?? '');
|
||||
}
|
||||
|
||||
private loadPack(packId: string, version?: string): void {
|
||||
this.loadingPack = true;
|
||||
this.policyApi.getPack(packId, version).subscribe({
|
||||
next: (pack) => {
|
||||
this.pack = pack;
|
||||
this.loadingPack = false;
|
||||
this.content$.next(pack.content ?? '');
|
||||
this.updateChecklist();
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
error: () => {
|
||||
this.loadingPack = false;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private initialiseEditor(): void {
|
||||
const host = this.editorHost?.nativeElement;
|
||||
if (!host) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.monacoLoader
|
||||
.load()
|
||||
.then((monaco) => {
|
||||
this.monaco = monaco;
|
||||
this.model = monaco.editor.createModel(
|
||||
this.content$.value,
|
||||
'stella-dsl'
|
||||
);
|
||||
|
||||
this.editor = monaco.editor.create(host, {
|
||||
model: this.model,
|
||||
language: 'stella-dsl',
|
||||
theme: 'stella-dsl-dark',
|
||||
fontSize: 14,
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
renderWhitespace: 'boundary',
|
||||
ariaLabel: 'Policy DSL editor',
|
||||
});
|
||||
|
||||
this.subscriptions.add(
|
||||
this.editor.onDidChangeModelContent(() => {
|
||||
const value = this.model?.getValue() ?? '';
|
||||
this.content$.next(value);
|
||||
})
|
||||
);
|
||||
|
||||
this.loadingEditor = false;
|
||||
this.cdr.markForCheck();
|
||||
})
|
||||
.catch(() => {
|
||||
this.loadingEditor = false;
|
||||
this.cdr.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
private runLint(content: string): void {
|
||||
if (!content || !this.model || !this.monaco) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.linting = true;
|
||||
this.policyApi.lint(content).subscribe({
|
||||
next: (lint) => {
|
||||
this.applyDiagnostics(lint);
|
||||
this.lastLintAt = new Date().toISOString();
|
||||
this.updateChecklist(lint);
|
||||
},
|
||||
error: () => {
|
||||
this.diagnostics = [
|
||||
{
|
||||
severity: 'error',
|
||||
code: 'lint/failed',
|
||||
message: 'Lint request failed. Please retry.',
|
||||
line: 1,
|
||||
column: 1,
|
||||
source: 'policy-lint',
|
||||
},
|
||||
];
|
||||
this.applyMarkers(this.diagnostics);
|
||||
},
|
||||
complete: () => {
|
||||
this.linting = false;
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private applyDiagnostics(lint: PolicyLintResult): void {
|
||||
const sorted = [...lint.errors, ...lint.warnings, ...lint.info].sort((a, b) => {
|
||||
if (a.line === b.line) return a.column - b.column;
|
||||
return a.line - b.line;
|
||||
});
|
||||
|
||||
this.diagnostics = sorted;
|
||||
this.applyMarkers(sorted);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
private applyMarkers(diagnostics: PolicyDiagnostic[]): void {
|
||||
if (!this.monaco || !this.model) return;
|
||||
|
||||
const markers: Monaco.editor.IMarkerData[] = diagnostics.map((diag) => ({
|
||||
startLineNumber: diag.line,
|
||||
startColumn: diag.column,
|
||||
endLineNumber: diag.endLine ?? diag.line,
|
||||
endColumn: diag.endColumn ?? diag.column + 1,
|
||||
message: diag.message,
|
||||
code: diag.code,
|
||||
severity: this.mapSeverity(diag.severity),
|
||||
source: diag.source,
|
||||
}));
|
||||
|
||||
this.monaco.editor.setModelMarkers(this.model, 'policy-lint', markers);
|
||||
}
|
||||
|
||||
private mapSeverity(severity: PolicyDiagnostic['severity']): Monaco.MarkerSeverity {
|
||||
if (!this.monaco) return 0 as unknown as Monaco.MarkerSeverity;
|
||||
|
||||
switch (severity) {
|
||||
case 'error':
|
||||
return this.monaco.MarkerSeverity.Error;
|
||||
case 'warning':
|
||||
return this.monaco.MarkerSeverity.Warning;
|
||||
default:
|
||||
return this.monaco.MarkerSeverity.Info;
|
||||
}
|
||||
}
|
||||
|
||||
private updateChecklist(lint?: PolicyLintResult): void {
|
||||
const lintErrors = lint?.errors?.length ?? 0;
|
||||
const lintWarnings = lint?.warnings?.length ?? 0;
|
||||
const descriptionOk = !!this.pack?.description?.trim();
|
||||
const tagsCount = this.pack?.tags?.length ?? 0;
|
||||
const reviewers = this.pack?.metadata?.reviewers?.length ?? 0;
|
||||
|
||||
const items: ChecklistItem[] = [
|
||||
{
|
||||
id: 'lint-clean',
|
||||
label: 'Lint clean (blocking errors)',
|
||||
status: lintErrors === 0 ? 'pass' : 'fail',
|
||||
hint: lintErrors === 0 ? 'No blocking errors detected' : `${lintErrors} error(s) to fix`,
|
||||
},
|
||||
{
|
||||
id: 'lint-warn',
|
||||
label: 'Warnings reviewed',
|
||||
status: lintWarnings === 0 ? 'pass' : 'warn',
|
||||
hint: lintWarnings === 0 ? 'No warnings' : `${lintWarnings} warning(s) to review`,
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
label: 'Description present',
|
||||
status: descriptionOk ? 'pass' : 'fail',
|
||||
hint: descriptionOk ? 'Ready for reviewers' : 'Add a concise description',
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
label: 'At least two tags',
|
||||
status: tagsCount >= 2 ? 'pass' : tagsCount === 1 ? 'warn' : 'fail',
|
||||
hint: tagsCount >= 2 ? 'Tag coverage OK' : 'Add two or more tags for routing',
|
||||
},
|
||||
{
|
||||
id: 'reviewers',
|
||||
label: 'Two-person review assigned',
|
||||
status: reviewers >= 2 ? 'pass' : reviewers === 1 ? 'warn' : 'fail',
|
||||
hint: reviewers >= 2 ? 'Dual approval ready' : 'Add at least two reviewers',
|
||||
},
|
||||
];
|
||||
|
||||
this.checklist = items;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { PolicyExplainComponent } from './policy-explain.component';
|
||||
|
||||
describe('PolicyExplainComponent', () => {
|
||||
let fixture: ComponentFixture<PolicyExplainComponent>;
|
||||
let component: PolicyExplainComponent;
|
||||
let api: jasmine.SpyObj<PolicyApiService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['getSimulationResult']);
|
||||
|
||||
api.getSimulationResult.and.returnValue(
|
||||
of({
|
||||
runId: 'run-1',
|
||||
policyId: 'pack-1',
|
||||
policyVersion: '1.0.0',
|
||||
status: 'completed',
|
||||
summary: { totalFindings: 1, byStatus: {}, bySeverity: {}, ruleHits: [], vexWins: 0, suppressions: 0 },
|
||||
findings: [
|
||||
{
|
||||
componentPurl: 'pkg:npm/a@1',
|
||||
advisoryId: 'ADV-1',
|
||||
status: 'new',
|
||||
severity: { band: 'high' },
|
||||
matchedRules: [],
|
||||
annotations: {},
|
||||
},
|
||||
],
|
||||
explainTrace: [
|
||||
{ step: 1, ruleName: 'rule-a', priority: 10, matched: true, inputs: {}, outputs: {} },
|
||||
],
|
||||
executedAt: '',
|
||||
durationMs: 0,
|
||||
}) as any
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, PolicyExplainComponent],
|
||||
providers: [
|
||||
{ provide: PolicyApiService, useValue: api },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ runId: 'run-1' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyExplainComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders explain trace from API', () => {
|
||||
expect(component['result']?.explainTrace?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { SimulationResult } from '../models/policy.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-explain',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="expl" aria-busy="{{ loading }}">
|
||||
<header class="expl__header" *ngIf="result">
|
||||
<div>
|
||||
<p class="expl__eyebrow">Policy Studio · Explain</p>
|
||||
<h1>Run {{ result.runId }}</h1>
|
||||
<p class="expl__lede">Policy {{ result.policyId }} · Version {{ result.policyVersion }}</p>
|
||||
</div>
|
||||
<div class="expl__meta">
|
||||
<button type="button" (click)="exportJson()">Export JSON</button>
|
||||
<button type="button" disabled title="PDF export pending backend">Export PDF</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div *ngIf="result" class="expl__grid">
|
||||
<section class="card">
|
||||
<header>
|
||||
<h3>Explain trace</h3>
|
||||
<p>Deterministic order by step.</p>
|
||||
</header>
|
||||
<ol>
|
||||
<li *ngFor="let e of result.explainTrace">
|
||||
<strong>Step {{ e.step }} · {{ e.ruleName }}</strong>
|
||||
<span>Matched: {{ e.matched }}</span>
|
||||
<span>Priority: {{ e.priority }}</span>
|
||||
<div class="expl__json">
|
||||
<pre><code>{{ formatJson(e.outputs) }}</code></pre>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<header>
|
||||
<h3>Findings snapshot</h3>
|
||||
<p>{{ result.findings.length }} findings sorted deterministically.</p>
|
||||
</header>
|
||||
<ul>
|
||||
<li *ngFor="let f of sortedFindings">
|
||||
{{ f.componentPurl }} · {{ f.advisoryId }} · {{ f.status }} · {{ f.severity.band | titlecase }}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
|
||||
.expl { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
|
||||
.expl__header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.expl__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
|
||||
.expl__lede { margin: 0.2rem 0 0; color: #94a3b8; }
|
||||
.expl__meta { display: flex; gap: 0.5rem; }
|
||||
.expl__grid { display: grid; grid-template-columns: 2fr 1fr; gap: 1rem; margin-top: 1rem; }
|
||||
.card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; }
|
||||
ol { margin: 0.5rem 0 0; padding-left: 1.25rem; }
|
||||
li { margin-bottom: 0.6rem; }
|
||||
.expl__json pre { margin: 0.35rem 0 0; background: #0b1224; border: 1px solid #1f2937; border-radius: 8px; padding: 0.6rem; max-height: 240px; overflow: auto; }
|
||||
@media (max-width: 1024px) { .expl__grid { grid-template-columns: 1fr; } }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyExplainComponent {
|
||||
protected loading = true;
|
||||
protected result?: SimulationResult;
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly api = inject(PolicyApiService);
|
||||
|
||||
constructor() {
|
||||
const runId = this.route.snapshot.paramMap.get('runId');
|
||||
if (runId) {
|
||||
this.api.getSimulationResult(runId).subscribe((res) => {
|
||||
this.result = res;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected get sortedFindings() {
|
||||
if (!this.result) return [];
|
||||
return [...this.result.findings].sort((a, b) =>
|
||||
a.status.localeCompare(b.status) || a.componentPurl.localeCompare(b.componentPurl)
|
||||
);
|
||||
}
|
||||
|
||||
protected formatJson(obj: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(obj, Object.keys(obj as Record<string, unknown>).sort(), 2);
|
||||
} catch {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
protected exportJson(): void {
|
||||
if (!this.result) return;
|
||||
const blob = new Blob([JSON.stringify(this.result, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `policy-explain-${this.result.runId}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,14 @@
|
||||
|
||||
// Editor (Monaco language definition)
|
||||
export * from './editor';
|
||||
export * from './editor/monaco-loader.service';
|
||||
export * from './editor/policy-editor.component';
|
||||
export * from './simulation/policy-simulation.component';
|
||||
export * from './approvals/policy-approvals.component';
|
||||
export * from './dashboard/policy-dashboard.component';
|
||||
export * from './workspace/policy-workspace.component';
|
||||
export * from './yaml/policy-yaml-editor.component';
|
||||
export * from './rule-builder/policy-rule-builder.component';
|
||||
|
||||
// Models
|
||||
export * from './models';
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
|
||||
import { PolicyRuleBuilderComponent } from './policy-rule-builder.component';
|
||||
|
||||
describe('PolicyRuleBuilderComponent', () => {
|
||||
let fixture: ComponentFixture<PolicyRuleBuilderComponent>;
|
||||
let component: PolicyRuleBuilderComponent;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, ReactiveFormsModule, PolicyRuleBuilderComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ packId: 'pack-xyz' }),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyRuleBuilderComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('sorts exceptions deterministically in preview JSON', () => {
|
||||
component.form.patchValue({ exceptions: 'b, a' });
|
||||
const preview = component.previewJson();
|
||||
expect(preview).toContain('"exceptions": [\n "a",\n "b"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, computed, inject } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-rule-builder',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="rb" aria-busy="false">
|
||||
<header class="rb__header">
|
||||
<div>
|
||||
<p class="rb__eyebrow">Policy Studio · Rule Builder</p>
|
||||
<h1>Guided rule construction</h1>
|
||||
<p class="rb__lede">Deterministic preview JSON updates as you edit.</p>
|
||||
</div>
|
||||
<div class="rb__meta">
|
||||
<span>Pack: {{ packId }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="rb__grid">
|
||||
<form [formGroup]="form" class="rb__form">
|
||||
<label>
|
||||
<span>Source preference</span>
|
||||
<select formControlName="source">
|
||||
<option value="nvd">NVD</option>
|
||||
<option value="vendor">Vendor first</option>
|
||||
<option value="kev">Known Exploited (KEV)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Severity floor (min)</span>
|
||||
<input type="number" min="0" max="10" step="0.1" formControlName="severityMin" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>VEX precedence</span>
|
||||
<select formControlName="vexPrecedence">
|
||||
<option value="evidence">Evidence-first</option>
|
||||
<option value="vex">VEX-first</option>
|
||||
<option value="blend">Blended</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Exceptions (comma-separated PURLs)</span>
|
||||
<input formControlName="exceptions" placeholder="pkg:npm/lodash@4.17.21, pkg:pypi/django@4.2.7" />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<span>Quiet provenance (mute noisy sources)</span>
|
||||
<select formControlName="quiet">
|
||||
<option value="none">None</option>
|
||||
<option value="low">Low-priority feeds</option>
|
||||
<option value="all">Mute all noisy feeds</option>
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
|
||||
<aside class="rb__preview" aria-label="Rule preview">
|
||||
<header>
|
||||
<h3>Preview JSON</h3>
|
||||
<p>Sorted keys for deterministic diffs.</p>
|
||||
</header>
|
||||
<pre><code>{{ previewJson() }}</code></pre>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
|
||||
.rb { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
|
||||
.rb__header { display: flex; justify-content: space-between; gap: 1rem; }
|
||||
.rb__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
|
||||
.rb__lede { margin: 0.2rem 0 0; color: #94a3b8; }
|
||||
.rb__grid { display: grid; grid-template-columns: minmax(0, 1.1fr) 1fr; gap: 1rem; margin-top: 1rem; }
|
||||
.rb__form { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; display: grid; gap: 0.65rem; }
|
||||
label { display: grid; gap: 0.25rem; color: #cbd5e1; }
|
||||
input, select { background: #0b1224; color: #e5e7eb; border: 1px solid #1f2937; border-radius: 8px; padding: 0.5rem; }
|
||||
.rb__preview { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; }
|
||||
pre { margin: 0; max-height: 420px; overflow: auto; background: #0b1224; border: 1px solid #1f2937; border-radius: 10px; padding: 0.75rem; }
|
||||
@media (max-width: 1024px) { .rb__grid { grid-template-columns: 1fr; } }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyRuleBuilderComponent {
|
||||
protected packId?: string;
|
||||
protected readonly form = this.fb.nonNullable.group({
|
||||
source: 'nvd',
|
||||
severityMin: 4,
|
||||
vexPrecedence: 'vex',
|
||||
exceptions: '',
|
||||
quiet: 'none',
|
||||
});
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
|
||||
constructor() {
|
||||
this.packId = this.route.snapshot.paramMap.get('packId') || undefined;
|
||||
}
|
||||
|
||||
protected previewJson = computed(() => {
|
||||
const value = this.form.getRawValue();
|
||||
const exceptions = value.exceptions
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean)
|
||||
.sort();
|
||||
|
||||
const json = {
|
||||
sourcePreference: value.source,
|
||||
severityFloor: Number(value.severityMin),
|
||||
vexPrecedence: value.vexPrecedence,
|
||||
exceptions,
|
||||
quiet: value.quiet,
|
||||
};
|
||||
|
||||
return JSON.stringify(json, Object.keys(json).sort(), 2);
|
||||
});
|
||||
}
|
||||
@@ -3,3 +3,4 @@
|
||||
*/
|
||||
|
||||
export { PolicyApiService } from './policy-api.service';
|
||||
export { PolicyPackStore } from './policy-pack.store';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { BehaviorSubject, Observable, catchError, finalize, of } from 'rxjs';
|
||||
import { filter } from 'rxjs/operators';
|
||||
|
||||
import { PolicyPackSummary } from '../models/policy.models';
|
||||
import { PolicyApiService } from './policy-api.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PolicyPackStore {
|
||||
private readonly api = inject(PolicyApiService);
|
||||
private readonly packs$ = new BehaviorSubject<PolicyPackSummary[] | null>(null);
|
||||
private loading = false;
|
||||
|
||||
getPacks(): Observable<PolicyPackSummary[]> {
|
||||
if (!this.packs$.value && !this.loading) {
|
||||
this.fetch();
|
||||
}
|
||||
|
||||
return this.packs$.pipe(filter((p): p is PolicyPackSummary[] => Array.isArray(p)));
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.fetch();
|
||||
}
|
||||
|
||||
private fetch(): void {
|
||||
this.loading = true;
|
||||
this.api
|
||||
.listPacks({ limit: 50 })
|
||||
.pipe(
|
||||
catchError(() => of(this.fallbackPacks())),
|
||||
finalize(() => (this.loading = false))
|
||||
)
|
||||
.subscribe((packs) => this.packs$.next(packs));
|
||||
}
|
||||
|
||||
private fallbackPacks(): PolicyPackSummary[] {
|
||||
return [
|
||||
{
|
||||
id: 'pack-1',
|
||||
name: 'Core Policy Pack',
|
||||
description: '',
|
||||
version: 'latest',
|
||||
status: 'active',
|
||||
createdAt: '',
|
||||
modifiedAt: '',
|
||||
createdBy: '',
|
||||
modifiedBy: '',
|
||||
tags: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { PolicySimulationComponent } from './policy-simulation.component';
|
||||
|
||||
describe('PolicySimulationComponent', () => {
|
||||
let fixture: ComponentFixture<PolicySimulationComponent>;
|
||||
let component: PolicySimulationComponent;
|
||||
let api: jasmine.SpyObj<PolicyApiService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['simulate']);
|
||||
|
||||
api.simulate.and.returnValue(
|
||||
of({
|
||||
runId: 'run-1',
|
||||
policyId: 'pack-1',
|
||||
policyVersion: '1.0.0',
|
||||
status: 'completed',
|
||||
summary: {
|
||||
totalFindings: 2,
|
||||
byStatus: {},
|
||||
bySeverity: {},
|
||||
ruleHits: [],
|
||||
vexWins: 1,
|
||||
suppressions: 0,
|
||||
},
|
||||
findings: [
|
||||
{
|
||||
componentPurl: 'pkg:npm/a@1',
|
||||
advisoryId: 'ADV-1',
|
||||
status: 'new',
|
||||
severity: { band: 'high', score: 8 },
|
||||
matchedRules: ['r1'],
|
||||
annotations: {},
|
||||
},
|
||||
{
|
||||
componentPurl: 'pkg:npm/b@1',
|
||||
advisoryId: 'ADV-0',
|
||||
status: 'new',
|
||||
severity: { band: 'critical', score: 9.5 },
|
||||
matchedRules: ['r2'],
|
||||
annotations: {},
|
||||
},
|
||||
],
|
||||
diff: {
|
||||
added: [
|
||||
{ componentPurl: 'pkg:npm/a@1', advisoryId: 'ADV-1', reason: '' },
|
||||
{ componentPurl: 'pkg:npm/b@1', advisoryId: 'ADV-0', reason: '' },
|
||||
],
|
||||
removed: [],
|
||||
changed: [],
|
||||
statusDeltas: {},
|
||||
severityDeltas: {},
|
||||
},
|
||||
explainTrace: [],
|
||||
executedAt: '2025-12-05T00:00:00Z',
|
||||
durationMs: 1200,
|
||||
})
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, ReactiveFormsModule, PolicySimulationComponent],
|
||||
providers: [
|
||||
{ provide: PolicyApiService, useValue: api },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ packId: 'pack-1' }),
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicySimulationComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('sorts findings deterministically (status, severity, component)', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
component.onRun();
|
||||
tick();
|
||||
|
||||
const findings = component.sortedFindings;
|
||||
expect(findings[0].advisoryId).toBe('ADV-0');
|
||||
expect(findings[1].advisoryId).toBe('ADV-1');
|
||||
}));
|
||||
|
||||
it('sorts diff entries by component then advisory', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
component.onRun();
|
||||
tick();
|
||||
|
||||
const diff = component['result']?.diff;
|
||||
expect(diff?.added.map((d) => d.advisoryId)).toEqual(['ADV-0', 'ADV-1']);
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,558 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
import {
|
||||
type SimulationDiff,
|
||||
type SimulationResult,
|
||||
type SimulationRequest,
|
||||
ExplainEntry,
|
||||
} from '../models/policy.models';
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-simulation',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ReactiveFormsModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="sim" aria-busy="{{ loading }}">
|
||||
<header class="sim__header">
|
||||
<div>
|
||||
<p class="sim__eyebrow">Policy Studio · Simulation</p>
|
||||
<h1>What-if analysis</h1>
|
||||
<p class="sim__lede">
|
||||
Run deterministic diffs against the active policy to preview impact before promote.
|
||||
</p>
|
||||
</div>
|
||||
<div class="sim__meta">
|
||||
<span class="pill" [class.pill--ok]="result?.status === 'completed'" [class.pill--warn]="result?.status === 'failed'">
|
||||
{{ result ? result.status : 'Idle' | titlecase }}
|
||||
</span>
|
||||
<span class="sim__timestamp" *ngIf="result">Completed at {{ result.executedAt | date:'medium' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="sim__layout">
|
||||
<form class="sim__form" [formGroup]="form" (ngSubmit)="onRun()" aria-label="Simulation input">
|
||||
<label class="field">
|
||||
<span>Components (purl, comma-separated)</span>
|
||||
<textarea formControlName="components" rows="3" placeholder="pkg:pypi/django@4.2.7, pkg:npm/lodash@4.17.21"></textarea>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>SBOM</span>
|
||||
<select formControlName="sbomId">
|
||||
<option value="">-- optional --</option>
|
||||
<option *ngFor="let s of sboms" [value]="s">{{ s }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Advisories (IDs, comma-separated)</span>
|
||||
<input formControlName="advisories" placeholder="CVE-2025-0001, GHSA-1234" list="adv-suggest" />
|
||||
<datalist id="adv-suggest">
|
||||
<option *ngFor="let adv of advisoryOptions" [value]="adv"></option>
|
||||
</datalist>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Environment label (optional)</span>
|
||||
<input formControlName="environment" placeholder="staging-us-east" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Include explain trace</span>
|
||||
<input type="checkbox" formControlName="includeExplain" />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>Diff against active policy</span>
|
||||
<input type="checkbox" formControlName="diffAgainstActive" />
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn" [disabled]="loading || form.invalid">
|
||||
{{ loading ? 'Running…' : 'Run simulation' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<aside class="sim__summary" *ngIf="result">
|
||||
<div class="card">
|
||||
<header>
|
||||
<h3>Summary</h3>
|
||||
<p>Deterministic ordering by status → severity → component.</p>
|
||||
</header>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Total findings</dt>
|
||||
<dd>{{ result.summary.totalFindings }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>VEX wins</dt>
|
||||
<dd>{{ result.summary.vexWins }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Suppressions</dt>
|
||||
<dd>{{ result.summary.suppressions }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="chips">
|
||||
<span class="chip" *ngFor="let s of severityBands">{{ s.label }}: {{ s.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" *ngIf="result.diff">
|
||||
<header>
|
||||
<h3>Diff</h3>
|
||||
<p>Added/removed/changed findings vs active policy.</p>
|
||||
</header>
|
||||
<div class="diff-grid">
|
||||
<div class="diff-grid__column">
|
||||
<h4>Added ({{ result.diff.added.length }})</h4>
|
||||
<ul>
|
||||
<li *ngFor="let item of result.diff.added">{{ item.componentPurl }} · {{ item.advisoryId }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="diff-grid__column">
|
||||
<h4>Removed ({{ result.diff.removed.length }})</h4>
|
||||
<ul>
|
||||
<li *ngFor="let item of result.diff.removed">{{ item.componentPurl }} · {{ item.advisoryId }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="diff-grid__column">
|
||||
<h4>Changed ({{ result.diff.changed.length }})</h4>
|
||||
<ul>
|
||||
<li *ngFor="let item of result.diff.changed">{{ item.componentPurl }} · {{ item.advisoryId }} ({{ item.reason }})</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" *ngIf="result.summary.ruleHits?.length">
|
||||
<header>
|
||||
<h3>Rule hit summary</h3>
|
||||
<p>Top rules by hit count.</p>
|
||||
</header>
|
||||
<ul class="rule-list">
|
||||
<li *ngFor="let hit of result.summary.ruleHits | slice:0:8">
|
||||
<span class="rule-list__name">{{ hit.ruleName }}</span>
|
||||
<span class="rule-list__count">{{ hit.hitCount }} hits</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="table" *ngIf="result">
|
||||
<header class="table__header">
|
||||
<h3>Deterministic findings table</h3>
|
||||
<p>Sorted: status → severity → component → advisory.</p>
|
||||
</header>
|
||||
<div class="table__scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Severity</th>
|
||||
<th>Component</th>
|
||||
<th>Advisory</th>
|
||||
<th>Rules</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr *ngFor="let f of sortedFindings">
|
||||
<td>{{ f.status }}</td>
|
||||
<td>{{ f.severity.band | titlecase }} {{ f.severity.score || '' }}</td>
|
||||
<td>{{ f.componentPurl }}</td>
|
||||
<td>{{ f.advisoryId }}</td>
|
||||
<td>{{ f.matchedRules.join(', ') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="explain" *ngIf="explainTrace.length">
|
||||
<header class="table__header">
|
||||
<h3>Explain trace</h3>
|
||||
<p>Deterministic order by step.</p>
|
||||
</header>
|
||||
<ol>
|
||||
<li *ngFor="let e of explainTrace">
|
||||
<strong>Step {{ e.step }} · {{ e.ruleName }}</strong>
|
||||
<span>Matched: {{ e.matched }}</span>
|
||||
<span>Priority: {{ e.priority }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
background: radial-gradient(circle at 20% 20%, rgba(59, 130, 246, 0.08), transparent 25%),
|
||||
radial-gradient(circle at 80% 0%, rgba(16, 185, 129, 0.08), transparent 22%),
|
||||
#0b1224;
|
||||
min-height: 100vh;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.sim {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.sim__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sim__eyebrow {
|
||||
margin: 0;
|
||||
color: #a5b4fc;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sim__lede {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.sim__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.pill {
|
||||
border: 1px solid #334155;
|
||||
padding: 0.35rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pill--ok {
|
||||
border-color: #22c55e;
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.pill--warn {
|
||||
border-color: #f97316;
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.sim__timestamp {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sim__layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) 1fr;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.sim__form {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.field span {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
color: #cbd5e1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
textarea,
|
||||
input[type='text'],
|
||||
select,
|
||||
input[type='checkbox'] {
|
||||
width: 100%;
|
||||
border: 1px solid #1f2937;
|
||||
background: #0b1224;
|
||||
color: #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem;
|
||||
font-family: 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.btn {
|
||||
justify-self: start;
|
||||
background: linear-gradient(120deg, #2563eb, #22d3ee);
|
||||
border: 1px solid #2563eb;
|
||||
color: #0b1224;
|
||||
font-weight: 700;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 10px 30px rgba(37, 99, 235, 0.25);
|
||||
transition: transform 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 14px 36px rgba(37, 99, 235, 0.35);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sim__summary {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin: 0;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
dl {
|
||||
margin: 0.6rem 0 0;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
color: #e5e7eb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.diff-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.diff-grid h4 {
|
||||
margin: 0 0 0.2rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.diff-grid ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.table {
|
||||
margin-top: 1.2rem;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.table__header h3 {
|
||||
margin: 0;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.table__header p {
|
||||
margin: 0.2rem 0 0;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.table__scroll {
|
||||
overflow: auto;
|
||||
margin-top: 0.75rem;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-bottom: 1px solid #1f2937;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background: #0b1224;
|
||||
color: #cbd5e1;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.sim__layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicySimulationComponent {
|
||||
protected loading = false;
|
||||
protected result?: SimulationResult;
|
||||
protected explainTrace: ExplainEntry[] = [];
|
||||
|
||||
protected readonly form = this.fb.group({
|
||||
components: [''],
|
||||
advisories: [''],
|
||||
environment: [''],
|
||||
includeExplain: [false],
|
||||
diffAgainstActive: [true],
|
||||
sbomId: [''],
|
||||
});
|
||||
|
||||
protected readonly sboms = ['sbom-dev-001', 'sbom-prod-2024-11', 'sbom-preprod-05'];
|
||||
protected readonly advisoryOptions = ['CVE-2025-0001', 'GHSA-1234', 'CVE-2024-9999'];
|
||||
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly policyApi = inject(PolicyApiService);
|
||||
|
||||
get severityBands() {
|
||||
if (!this.result) return [];
|
||||
const order: Array<{ key: string; label: string }> = [
|
||||
{ key: 'critical', label: 'Critical' },
|
||||
{ key: 'high', label: 'High' },
|
||||
{ key: 'medium', label: 'Medium' },
|
||||
{ key: 'low', label: 'Low' },
|
||||
{ key: 'none', label: 'None' },
|
||||
];
|
||||
|
||||
return order.map(({ key, label }) => ({
|
||||
label,
|
||||
count: this.result?.summary.bySeverity?.[key] ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
get sortedFindings() {
|
||||
if (!this.result) return [];
|
||||
return [...this.result.findings].sort((a, b) => {
|
||||
if (a.status === b.status) {
|
||||
if (a.severity.band === b.severity.band) {
|
||||
if (a.componentPurl === b.componentPurl) {
|
||||
return a.advisoryId.localeCompare(b.advisoryId);
|
||||
}
|
||||
return a.componentPurl.localeCompare(b.componentPurl);
|
||||
}
|
||||
const order = ['critical', 'high', 'medium', 'low', 'none'];
|
||||
return order.indexOf(a.severity.band) - order.indexOf(b.severity.band);
|
||||
}
|
||||
return a.status.localeCompare(b.status);
|
||||
});
|
||||
}
|
||||
|
||||
onRun(): void {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
if (!packId) return;
|
||||
|
||||
const request: SimulationRequest = {
|
||||
policyId: packId,
|
||||
scope: {
|
||||
components: this.splitList(this.form.value.components),
|
||||
advisories: this.splitList(this.form.value.advisories),
|
||||
sbomId: this.form.value.sbomId || undefined,
|
||||
environment: this.form.value.environment || undefined,
|
||||
},
|
||||
inputs: {},
|
||||
options: {
|
||||
includeExplainTrace: this.form.value.includeExplain ?? false,
|
||||
diffAgainstActive: this.form.value.diffAgainstActive ?? true,
|
||||
},
|
||||
};
|
||||
|
||||
this.loading = true;
|
||||
this.policyApi
|
||||
.simulate(request)
|
||||
.pipe(finalize(() => (this.loading = false)))
|
||||
.subscribe({
|
||||
next: (res) => {
|
||||
this.result = this.sortDiff(res);
|
||||
this.explainTrace = res.explainTrace ?? [];
|
||||
this.form.markAsPristine();
|
||||
},
|
||||
error: () => {
|
||||
this.result = undefined;
|
||||
this.explainTrace = [];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private sortDiff(result: SimulationResult): SimulationResult {
|
||||
const sortFinding = (items: SimulationDiff['added']) =>
|
||||
[...items].sort((a, b) => {
|
||||
if (a.componentPurl === b.componentPurl) {
|
||||
return a.advisoryId.localeCompare(b.advisoryId);
|
||||
}
|
||||
return a.componentPurl.localeCompare(b.componentPurl);
|
||||
});
|
||||
|
||||
const diff: SimulationDiff | undefined = result.diff
|
||||
? {
|
||||
added: sortFinding(result.diff.added),
|
||||
removed: sortFinding(result.diff.removed),
|
||||
changed: sortFinding(result.diff.changed),
|
||||
statusDeltas: result.diff.statusDeltas,
|
||||
severityDeltas: result.diff.severityDeltas,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return { ...result, diff };
|
||||
}
|
||||
|
||||
private splitList(value: string | null | undefined): string[] {
|
||||
if (!value) return [];
|
||||
return value
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { PolicyPackStore } from '../services/policy-pack.store';
|
||||
import { PolicyWorkspaceComponent } from './policy-workspace.component';
|
||||
|
||||
describe('PolicyWorkspaceComponent', () => {
|
||||
let fixture: ComponentFixture<PolicyWorkspaceComponent>;
|
||||
let component: PolicyWorkspaceComponent;
|
||||
let store: jasmine.SpyObj<PolicyPackStore>;
|
||||
|
||||
beforeEach(async () => {
|
||||
store = jasmine.createSpyObj<PolicyPackStore>('PolicyPackStore', ['getPacks']);
|
||||
store.getPacks.and.returnValue(
|
||||
of([
|
||||
{
|
||||
id: 'pack-b',
|
||||
name: 'B pack',
|
||||
description: '',
|
||||
version: '1.0',
|
||||
status: 'active',
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
modifiedAt: '2025-12-05T00:00:00Z',
|
||||
createdBy: '',
|
||||
modifiedBy: '',
|
||||
tags: [],
|
||||
},
|
||||
{
|
||||
id: 'pack-a',
|
||||
name: 'A pack',
|
||||
description: '',
|
||||
version: '1.0',
|
||||
status: 'active',
|
||||
createdAt: '2025-11-01T00:00:00Z',
|
||||
modifiedAt: '2025-12-04T00:00:00Z',
|
||||
createdBy: '',
|
||||
modifiedBy: '',
|
||||
tags: [],
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, RouterLink, PolicyWorkspaceComponent],
|
||||
providers: [{ provide: PolicyPackStore, useValue: store }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyWorkspaceComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('sorts packs by modifiedAt desc then id', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
expect(component['packs'][0].id).toBe('pack-b');
|
||||
expect(component['packs'][1].id).toBe('pack-a');
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { AuthService, AUTH_SERVICE } from '../../../core/auth';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
import { PolicyPackSummary } from '../models/policy.models';
|
||||
import { PolicyPackStore } from '../services/policy-pack.store';
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-workspace',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="workspace" aria-busy="{{ loading }}">
|
||||
<header class="workspace__header">
|
||||
<div>
|
||||
<p class="workspace__eyebrow">Policy Studio · Workspace</p>
|
||||
<h1>Policy packs</h1>
|
||||
<p class="workspace__lede">Deterministic list sorted by modified date desc, tie-breaker id.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="workspace__grid">
|
||||
<article class="pack-card" *ngFor="let pack of packs">
|
||||
<header class="pack-card__head">
|
||||
<div>
|
||||
<p class="pack-card__eyebrow">{{ pack.status | titlecase }}</p>
|
||||
<h2>{{ pack.name }}</h2>
|
||||
<p class="pack-card__desc">{{ pack.description || 'No description provided.' }}</p>
|
||||
</div>
|
||||
<div class="pack-card__meta">
|
||||
<span>v{{ pack.version }}</span>
|
||||
<span>{{ pack.modifiedAt | date: 'medium' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<ul class="pack-card__tags">
|
||||
<li *ngFor="let tag of pack.tags">{{ tag }}</li>
|
||||
</ul>
|
||||
|
||||
<div class="pack-card__actions">
|
||||
<a
|
||||
[routerLink]="['/policy-studio/packs', pack.id, 'editor']"
|
||||
[class.action-disabled]="!canAuthor"
|
||||
[attr.aria-disabled]="!canAuthor"
|
||||
[title]="canAuthor ? '' : 'Requires policy:author scope'"
|
||||
>
|
||||
Edit
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="['/policy-studio/packs', pack.id, 'simulate']"
|
||||
[class.action-disabled]="!canSimulate"
|
||||
[attr.aria-disabled]="!canSimulate"
|
||||
[title]="canSimulate ? '' : 'Requires policy:simulate scope'"
|
||||
>
|
||||
Simulate
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="['/policy-studio/packs', pack.id, 'approvals']"
|
||||
[class.action-disabled]="!canReview"
|
||||
[attr.aria-disabled]="!canReview"
|
||||
[title]="canReview ? '' : 'Requires policy:review scope'"
|
||||
>
|
||||
Approvals
|
||||
</a>
|
||||
<a
|
||||
[routerLink]="['/policy-studio/packs', pack.id, 'dashboard']"
|
||||
[class.action-disabled]="!canView"
|
||||
[attr.aria-disabled]="!canView"
|
||||
[title]="canView ? '' : 'Requires policy:read scope'"
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<dl class="pack-card__detail">
|
||||
<div>
|
||||
<dt>Created</dt>
|
||||
<dd>{{ pack.createdAt | date: 'medium' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Authors</dt>
|
||||
<dd>{{ pack.createdBy || 'unknown' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Owner</dt>
|
||||
<dd>{{ pack.modifiedBy || 'unknown' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
|
||||
.workspace { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
|
||||
.workspace__header { margin-bottom: 1rem; }
|
||||
.workspace__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
|
||||
.workspace__lede { margin: 0.2rem 0 0; color: #94a3b8; }
|
||||
.workspace__grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 1rem; }
|
||||
.pack-card { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; box-shadow: 0 12px 30px rgba(0,0,0,0.28); display: grid; gap: 0.6rem; }
|
||||
.pack-card__head { display: flex; justify-content: space-between; gap: 0.75rem; align-items: flex-start; }
|
||||
.pack-card__eyebrow { margin: 0; color: #a5b4fc; font-size: 0.75rem; letter-spacing: 0.05em; text-transform: uppercase; }
|
||||
.pack-card__desc { margin: 0.2rem 0 0; color: #cbd5e1; }
|
||||
.pack-card__meta { display: grid; justify-items: end; gap: 0.2rem; color: #94a3b8; font-size: 0.9rem; }
|
||||
.pack-card__tags { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; }
|
||||
.pack-card__tags li { padding: 0.2rem 0.45rem; border: 1px solid #1f2937; border-radius: 999px; background: #0b162e; }
|
||||
.pack-card__actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.pack-card__actions a { color: #e5e7eb; border: 1px solid #334155; border-radius: 8px; padding: 0.35rem 0.6rem; text-decoration: none; }
|
||||
.pack-card__actions a:hover { border-color: #22d3ee; }
|
||||
.pack-card__detail { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.35rem 1rem; margin: 0; }
|
||||
dt { color: #94a3b8; font-size: 0.85rem; margin: 0; }
|
||||
dd { margin: 0; color: #e5e7eb; }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyWorkspaceComponent {
|
||||
protected loading = false;
|
||||
protected packs: PolicyPackSummary[] = [];
|
||||
protected canAuthor = false;
|
||||
protected canSimulate = false;
|
||||
protected canReview = false;
|
||||
protected canView = false;
|
||||
protected scopeHint = '';
|
||||
|
||||
private readonly packStore = inject(PolicyPackStore);
|
||||
private readonly auth = inject(AUTH_SERVICE) as AuthService;
|
||||
|
||||
constructor() {
|
||||
this.loading = true;
|
||||
this.applyScopes();
|
||||
this.packStore.getPacks().subscribe((packs) => {
|
||||
this.packs = [...packs].sort((a, b) =>
|
||||
b.modifiedAt.localeCompare(a.modifiedAt) || a.id.localeCompare(b.id)
|
||||
);
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
private applyScopes(): void {
|
||||
this.canAuthor = this.auth.canAuthorPolicies?.() ?? false;
|
||||
this.canSimulate = this.auth.canSimulatePolicies?.() ?? false;
|
||||
this.canReview = this.auth.canReviewPolicies?.() ?? false;
|
||||
this.canView = this.auth.canViewPolicies?.() ?? false;
|
||||
const missing: string[] = [];
|
||||
if (!this.canView) missing.push('policy:read');
|
||||
if (!this.canAuthor) missing.push('policy:author');
|
||||
if (!this.canSimulate) missing.push('policy:simulate');
|
||||
if (!this.canReview) missing.push('policy:review');
|
||||
this.scopeHint = missing.length ? `Missing scopes: ${missing.join(', ')}` : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { PolicyYamlEditorComponent } from './policy-yaml-editor.component';
|
||||
|
||||
describe('PolicyYamlEditorComponent', () => {
|
||||
let fixture: ComponentFixture<PolicyYamlEditorComponent>;
|
||||
let component: PolicyYamlEditorComponent;
|
||||
let api: jasmine.SpyObj<PolicyApiService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['getPack', 'lint']);
|
||||
|
||||
api.getPack.and.returnValue(
|
||||
of({
|
||||
id: 'pack-1',
|
||||
name: 'Pack One',
|
||||
description: 'Demo pack',
|
||||
syntax: 'yaml',
|
||||
content: '',
|
||||
version: '1.0.0',
|
||||
status: 'active',
|
||||
metadata: {},
|
||||
createdAt: '',
|
||||
modifiedAt: '',
|
||||
createdBy: '',
|
||||
modifiedBy: '',
|
||||
tags: ['demo'],
|
||||
digest: '',
|
||||
})
|
||||
);
|
||||
|
||||
api.lint.and.returnValue(of({ valid: true, errors: [], warnings: [], info: [] }) as any);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, FormsModule, PolicyYamlEditorComponent],
|
||||
providers: [
|
||||
{ provide: PolicyApiService, useValue: api },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
snapshot: {
|
||||
paramMap: convertToParamMap({ packId: 'pack-1' }),
|
||||
queryParamMap: convertToParamMap({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyYamlEditorComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('builds canonical YAML with sorted keys', fakeAsync(() => {
|
||||
fixture.detectChanges();
|
||||
tick(500);
|
||||
expect(component.canonicalYaml).toContain('id');
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { debounceTime, distinctUntilChanged, map, switchMap } from 'rxjs/operators';
|
||||
import { BehaviorSubject, combineLatest, of } from 'rxjs';
|
||||
import YAML from 'yaml';
|
||||
|
||||
import { PolicyApiService } from '../services/policy-api.service';
|
||||
import { PolicyPack } from '../models/policy.models';
|
||||
|
||||
interface YamlDiagnostic {
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-policy-yaml-editor',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, RouterLink],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section class="yaml" aria-busy="{{ loading }}">
|
||||
<header class="yaml__header">
|
||||
<div>
|
||||
<p class="yaml__eyebrow">Policy Studio · YAML</p>
|
||||
<h1>{{ pack?.name || 'Loading policy…' }}</h1>
|
||||
<p class="yaml__lede" *ngIf="pack">
|
||||
Version {{ pack.version }} · Status: {{ pack.status | titlecase }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="yaml__meta" *ngIf="pack">
|
||||
<span>Updated {{ pack.modifiedAt | date: 'medium' }}</span>
|
||||
<span>Tags: {{ pack.tags.length }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="yaml__layout">
|
||||
<div class="yaml__editor">
|
||||
<label for="yaml-input">YAML</label>
|
||||
<textarea
|
||||
id="yaml-input"
|
||||
[(ngModel)]="yamlContent"
|
||||
(ngModelChange)="onContentChange($event)"
|
||||
rows="18"
|
||||
aria-label="Policy YAML editor"
|
||||
></textarea>
|
||||
<div class="toolbar">
|
||||
<span class="pill" [class.pill--ok]="!yamlDiagnostics.length && !lintDiagnostics.length">
|
||||
{{ yamlDiagnostics.length + lintDiagnostics.length }} issue(s)
|
||||
</span>
|
||||
<button type="button" (click)="resetToPack()" [disabled]="loading">Reset</button>
|
||||
</div>
|
||||
<ul class="diag" *ngIf="yamlDiagnostics.length || lintDiagnostics.length">
|
||||
<li *ngFor="let d of yamlDiagnostics">YAML: {{ d.message }}</li>
|
||||
<li *ngFor="let d of lintDiagnostics">Lint: {{ d.message }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<aside class="yaml__preview" aria-label="Canonical preview">
|
||||
<header>
|
||||
<h3>Canonical YAML</h3>
|
||||
<p>Sorted keys; stable ordering for diff-friendly review.</p>
|
||||
</header>
|
||||
<pre><code>{{ canonicalYaml }}</code></pre>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host { display: block; background: #0b1224; color: #e5e7eb; min-height: 100vh; }
|
||||
.yaml { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
|
||||
.yaml__header { display: flex; justify-content: space-between; gap: 1rem; }
|
||||
.yaml__eyebrow { margin: 0; color: #22d3ee; text-transform: uppercase; letter-spacing: 0.05em; font-size: 0.8rem; }
|
||||
.yaml__lede { margin: 0.2rem 0 0; color: #94a3b8; }
|
||||
.yaml__meta { display: grid; justify-items: end; gap: 0.2rem; color: #94a3b8; }
|
||||
.yaml__layout { display: grid; grid-template-columns: minmax(0, 1.4fr) 1fr; gap: 1rem; margin-top: 1rem; }
|
||||
.yaml__editor { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; display: grid; gap: 0.5rem; }
|
||||
label { color: #cbd5e1; font-weight: 600; }
|
||||
textarea { width: 100%; background: #0b1224; color: #e5e7eb; border: 1px solid #1f2937; border-radius: 10px; padding: 0.75rem; font-family: 'Monaco','Consolas', monospace; }
|
||||
.toolbar { display: flex; gap: 0.5rem; align-items: center; }
|
||||
button { background: #2563eb; border: 1px solid #2563eb; color: #e5e7eb; border-radius: 8px; padding: 0.4rem 0.8rem; cursor: pointer; }
|
||||
button:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.pill { border: 1px solid #334155; padding: 0.3rem 0.6rem; border-radius: 999px; }
|
||||
.pill--ok { border-color: #22c55e; color: #22c55e; }
|
||||
.diag { list-style: none; margin: 0; padding: 0; color: #f87171; }
|
||||
.yaml__preview { background: #0f172a; border: 1px solid #1f2937; border-radius: 12px; padding: 1rem; }
|
||||
pre { margin: 0; max-height: 420px; overflow: auto; background: #0b1224; border: 1px solid #1f2937; border-radius: 10px; padding: 0.75rem; }
|
||||
@media (max-width: 1024px) { .yaml__layout { grid-template-columns: 1fr; } }
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyYamlEditorComponent {
|
||||
protected pack?: PolicyPack;
|
||||
protected yamlContent = '';
|
||||
protected canonicalYaml = '';
|
||||
protected yamlDiagnostics: YamlDiagnostic[] = [];
|
||||
protected lintDiagnostics: YamlDiagnostic[] = [];
|
||||
protected loading = true;
|
||||
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly api = inject(PolicyApiService);
|
||||
|
||||
private readonly content$ = new BehaviorSubject<string>('');
|
||||
|
||||
constructor() {
|
||||
const packId = this.route.snapshot.paramMap.get('packId');
|
||||
const version = this.route.snapshot.queryParamMap.get('version') || undefined;
|
||||
if (packId) {
|
||||
this.api.getPack(packId, version ?? undefined).subscribe((p) => {
|
||||
this.pack = p;
|
||||
this.yamlContent = this.buildInitialYaml(p);
|
||||
this.loading = false;
|
||||
this.onContentChange(this.yamlContent);
|
||||
});
|
||||
}
|
||||
|
||||
combineLatest([
|
||||
this.content$.pipe(debounceTime(400), distinctUntilChanged()),
|
||||
])
|
||||
.pipe(
|
||||
map(([content]) => content),
|
||||
switchMap((content) => this.validateAndCanonicalize(content))
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
onContentChange(content: string): void {
|
||||
this.content$.next(content);
|
||||
}
|
||||
|
||||
resetToPack(): void {
|
||||
if (!this.pack) return;
|
||||
this.yamlContent = this.buildInitialYaml(this.pack);
|
||||
this.onContentChange(this.yamlContent);
|
||||
}
|
||||
|
||||
private validateAndCanonicalize(content: string) {
|
||||
try {
|
||||
const parsed = YAML.parse(content);
|
||||
this.yamlDiagnostics = [];
|
||||
this.canonicalYaml = YAML.stringify(parsed, { sortMapEntries: true });
|
||||
} catch (err: any) {
|
||||
this.yamlDiagnostics = [{ message: err.message || 'Invalid YAML' }];
|
||||
this.canonicalYaml = '';
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
this.lintDiagnostics = [];
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return this.api.lint(content).pipe(
|
||||
map((lint) => {
|
||||
this.lintDiagnostics = [...lint.errors, ...lint.warnings, ...lint.info].map((d) => ({
|
||||
message: `${d.severity}: ${d.message} (line ${d.line})`,
|
||||
}));
|
||||
return lint;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private buildInitialYaml(pack: PolicyPack): string {
|
||||
return YAML.stringify(
|
||||
{
|
||||
policy: {
|
||||
id: pack.id,
|
||||
name: pack.name,
|
||||
version: pack.version,
|
||||
status: pack.status,
|
||||
description: pack.description,
|
||||
tags: pack.tags,
|
||||
metadata: pack.metadata,
|
||||
},
|
||||
},
|
||||
{ sortMapEntries: true }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { ExceptionBadgeComponent, ExceptionBadgeData } from './exception-badge.component';
|
||||
export { ExceptionExplainComponent, ExceptionExplainData } from './exception-explain.component';
|
||||
export { ConfidenceBadgeComponent, ConfidenceBand } from './confidence-badge.component';
|
||||
export { QuietProvenanceIndicatorComponent } from './quiet-provenance-indicator.component';
|
||||
export { ExceptionBadgeComponent, ExceptionBadgeData } from './exception-badge.component';
|
||||
export { ExceptionExplainComponent, ExceptionExplainData } from './exception-explain.component';
|
||||
export { ConfidenceBadgeComponent, ConfidenceBand } from './confidence-badge.component';
|
||||
export { QuietProvenanceIndicatorComponent } from './quiet-provenance-indicator.component';
|
||||
export { PolicyPackSelectorComponent } from './policy-pack-selector.component';
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
|
||||
import { of } from 'rxjs';
|
||||
|
||||
import { PolicyPackStore } from '../../features/policy-studio/services/policy-pack.store';
|
||||
import { PolicyPackSelectorComponent } from './policy-pack-selector.component';
|
||||
|
||||
describe('PolicyPackSelectorComponent', () => {
|
||||
let fixture: ComponentFixture<PolicyPackSelectorComponent>;
|
||||
let component: PolicyPackSelectorComponent;
|
||||
let store: jasmine.SpyObj<PolicyPackStore>;
|
||||
|
||||
beforeEach(async () => {
|
||||
store = jasmine.createSpyObj<PolicyPackStore>('PolicyPackStore', ['getPacks']);
|
||||
});
|
||||
|
||||
it('emits first pack id when API succeeds', fakeAsync(async () => {
|
||||
store.getPacks.and.returnValue(
|
||||
of([
|
||||
{
|
||||
id: 'pack-42',
|
||||
name: 'Test Pack',
|
||||
description: '',
|
||||
version: '1.0',
|
||||
status: 'active',
|
||||
createdAt: '',
|
||||
modifiedAt: '',
|
||||
createdBy: '',
|
||||
modifiedBy: '',
|
||||
tags: [],
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, PolicyPackSelectorComponent],
|
||||
providers: [{ provide: PolicyPackStore, useValue: store }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyPackSelectorComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
const spy = jasmine.createSpy('packSelected');
|
||||
component.packSelected.subscribe(spy);
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('pack-42');
|
||||
}));
|
||||
|
||||
it('falls back to pack-1 on API error', fakeAsync(async () => {
|
||||
store.getPacks.and.returnValue(of([]));
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CommonModule, PolicyPackSelectorComponent],
|
||||
providers: [{ provide: PolicyPackStore, useValue: store }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PolicyPackSelectorComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
const spy = jasmine.createSpy('packSelected');
|
||||
component.packSelected.subscribe(spy);
|
||||
|
||||
fixture.detectChanges();
|
||||
tick();
|
||||
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
expect(component['packs'].length).toBe(0);
|
||||
}));
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { Subscription } from 'rxjs';
|
||||
|
||||
import { PolicyPackStore } from '../../features/policy-studio/services/policy-pack.store';
|
||||
import { PolicyPackSummary } from '../../features/policy-studio/models/policy.models';
|
||||
|
||||
/**
|
||||
* Policy pack selector for the nav dropdown.
|
||||
* Fetches packs from PolicyApiService with an offline-safe fallback list.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-policy-pack-selector',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="pack-selector">
|
||||
<label for="pack-select">Policy pack</label>
|
||||
<select
|
||||
id="pack-select"
|
||||
(change)="onChange($event.target.value)"
|
||||
[disabled]="loading"
|
||||
[attr.aria-busy]="loading"
|
||||
>
|
||||
<option *ngFor="let pack of packs" [value]="pack.id">{{ pack.name }}</option>
|
||||
</select>
|
||||
<p class="hint" *ngIf="loading">Loading packs…</p>
|
||||
<p class="hint" *ngIf="!loading && packs.length === 0">No packs available.</p>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.pack-selector {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
label {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
select {
|
||||
background: #0b1224;
|
||||
color: #e5e7eb;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.45rem;
|
||||
}
|
||||
.hint {
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class PolicyPackSelectorComponent implements OnInit, OnDestroy {
|
||||
@Output() packSelected = new EventEmitter<string>();
|
||||
|
||||
protected packs: PolicyPackSummary[] = [];
|
||||
protected loading = false;
|
||||
|
||||
private readonly packStore = inject(PolicyPackStore);
|
||||
private sub?: Subscription;
|
||||
|
||||
onChange(value: string): void {
|
||||
this.packSelected.emit(value);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loading = true;
|
||||
this.sub = this.packStore.getPacks().subscribe((packs) => {
|
||||
this.packs = packs;
|
||||
this.loading = false;
|
||||
if (packs.length > 0) {
|
||||
this.packSelected.emit(packs[0].id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.sub?.unsubscribe();
|
||||
}
|
||||
}
|
||||
24
src/Web/StellaOps.Web/src/app/types/monaco-workers.d.ts
vendored
Normal file
24
src/Web/StellaOps.Web/src/app/types/monaco-workers.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
declare module 'monaco-editor/esm/vs/editor/editor.worker?worker' {
|
||||
const EditorWorkerFactory: { new (): Worker };
|
||||
export default EditorWorkerFactory;
|
||||
}
|
||||
|
||||
declare module 'monaco-editor/esm/vs/language/json/json.worker?worker' {
|
||||
const JsonWorkerFactory: { new (): Worker };
|
||||
export default JsonWorkerFactory;
|
||||
}
|
||||
|
||||
declare module 'monaco-editor/esm/vs/language/css/css.worker?worker' {
|
||||
const CssWorkerFactory: { new (): Worker };
|
||||
export default CssWorkerFactory;
|
||||
}
|
||||
|
||||
declare module 'monaco-editor/esm/vs/language/html/html.worker?worker' {
|
||||
const HtmlWorkerFactory: { new (): Worker };
|
||||
export default HtmlWorkerFactory;
|
||||
}
|
||||
|
||||
declare module 'monaco-editor/esm/vs/language/typescript/ts.worker?worker' {
|
||||
const TsWorkerFactory: { new (): Worker };
|
||||
export default TsWorkerFactory;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import './styles/tokens/motion';
|
||||
@import 'monaco-editor/min/vs/editor/editor.main.css';
|
||||
|
||||
/* Global motion helpers */
|
||||
.motion-fade-in {
|
||||
@@ -50,3 +51,54 @@
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0ms !important;
|
||||
}
|
||||
|
||||
/* Quick nav styling for Policy Studio dropdown */
|
||||
.app-nav {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
background: #0b162e;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
.nav-group__menu {
|
||||
position: absolute;
|
||||
top: 110%;
|
||||
left: 0;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 10px;
|
||||
padding: 0.4rem;
|
||||
display: none;
|
||||
min-width: 200px;
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.3);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.nav-group:hover .nav-group__menu,
|
||||
.nav-group:focus-within .nav-group__menu {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.nav-group__menu a {
|
||||
color: #e5e7eb;
|
||||
padding: 0.35rem 0.45rem;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-group__menu a:hover {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user