texts fixes, search bar fixes, global menu fixes.

This commit is contained in:
master
2026-03-05 18:10:56 +02:00
parent 8e1cb9448d
commit a918d39a61
101 changed files with 3543 additions and 534 deletions

View File

@@ -55,13 +55,13 @@ public sealed class KnowledgeSearchOptions
public List<string> OpenApiRoots { get; set; } = ["src", "devops/compose"];
public string UnifiedFindingsSnapshotPath { get; set; } =
"src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/findings.snapshot.json";
"UnifiedSearch/Snapshots/findings.snapshot.json";
public string UnifiedVexSnapshotPath { get; set; } =
"src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/vex.snapshot.json";
"UnifiedSearch/Snapshots/vex.snapshot.json";
public string UnifiedPolicySnapshotPath { get; set; } =
"src/AdvisoryAI/StellaOps.AdvisoryAI/UnifiedSearch/Snapshots/policy.snapshot.json";
"UnifiedSearch/Snapshots/policy.snapshot.json";
public bool UnifiedAutoIndexEnabled { get; set; }

View File

@@ -13,7 +13,7 @@
<InternalsVisibleTo Include="StellaOps.AdvisoryAI.WebService" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Storage\Migrations\**\*.sql" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
<EmbeddedResource Include="Storage\Migrations\**\*.sql" />
<EmbeddedResource Include="UnifiedSearch\Synthesis\synthesis-system-prompt.txt" LogicalName="synthesis-system-prompt.txt" />
</ItemGroup>
<ItemGroup>
@@ -34,6 +34,18 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<TargetPath>models/all-MiniLM-L6-v2.onnx</TargetPath>
</None>
<None Update="UnifiedSearch/Snapshots/findings.snapshot.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<TargetPath>UnifiedSearch/Snapshots/findings.snapshot.json</TargetPath>
</None>
<None Update="UnifiedSearch/Snapshots/vex.snapshot.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<TargetPath>UnifiedSearch/Snapshots/vex.snapshot.json</TargetPath>
</None>
<None Update="UnifiedSearch/Snapshots/policy.snapshot.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<TargetPath>UnifiedSearch/Snapshots/policy.snapshot.json</TargetPath>
</None>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />

View File

@@ -16,7 +16,7 @@ internal sealed class FindingsSearchAdapter : ISearchIngestionAdapter
{
private const string TenantHeader = "X-StellaOps-Tenant";
private const string HttpClientName = "scanner-internal";
private const string FindingsEndpoint = "/api/v1/scanner/security/findings";
private const string FindingsEndpoint = "/api/v1/security/findings";
private const int MaxPages = 20;
private const int PageSize = 100;

View File

@@ -12,16 +12,19 @@ namespace StellaOps.AdvisoryAI.UnifiedSearch;
internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
{
private readonly KnowledgeSearchOptions _options;
private readonly IKnowledgeSearchStore _store;
private readonly IEnumerable<ISearchIngestionAdapter> _adapters;
private readonly ILogger<UnifiedSearchIndexer> _logger;
public UnifiedSearchIndexer(
IOptions<KnowledgeSearchOptions> options,
IKnowledgeSearchStore store,
IEnumerable<ISearchIngestionAdapter> adapters,
ILogger<UnifiedSearchIndexer> logger)
{
ArgumentNullException.ThrowIfNull(options);
_options = options.Value ?? new KnowledgeSearchOptions();
_store = store ?? throw new ArgumentNullException(nameof(store));
_adapters = adapters ?? throw new ArgumentNullException(nameof(adapters));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -39,6 +42,8 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
return new UnifiedSearchIndexSummary(0, 0, 0);
}
await _store.EnsureSchemaAsync(cancellationToken).ConfigureAwait(false);
var stopwatch = Stopwatch.StartNew();
var domains = 0;
var chunks = 0;
@@ -131,6 +136,8 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
return new UnifiedSearchIndexSummary(0, 0, 0);
}
await _store.EnsureSchemaAsync(cancellationToken).ConfigureAwait(false);
var stopwatch = Stopwatch.StartNew();
var domains = 0;
var chunks = 0;
@@ -348,11 +355,17 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
UnifiedChunk chunk,
CancellationToken cancellationToken)
{
var sourceRef = ResolveSourceRef(chunk);
var sourcePath = ResolveSourcePath(chunk);
const string sql = """
INSERT INTO advisoryai.kb_doc
(doc_id, doc_type, product, version, source_ref, path, title, content_hash, metadata, indexed_at)
VALUES (@doc_id, @doc_type, @product, @version, @source_ref, @path, @title, @content_hash, '{}'::jsonb, NOW())
ON CONFLICT (doc_id) DO NOTHING;
ON CONFLICT (doc_id) DO UPDATE SET
title = EXCLUDED.title,
content_hash = EXCLUDED.content_hash,
indexed_at = NOW();
""";
await using var command = connection.CreateCommand();
@@ -362,14 +375,34 @@ internal sealed class UnifiedSearchIndexer : IUnifiedSearchIndexer
command.Parameters.AddWithValue("doc_type", chunk.Domain);
command.Parameters.AddWithValue("product", "stella-ops");
command.Parameters.AddWithValue("version", "local");
command.Parameters.AddWithValue("source_ref", chunk.Domain);
command.Parameters.AddWithValue("path", chunk.Kind);
command.Parameters.AddWithValue("source_ref", sourceRef);
command.Parameters.AddWithValue("path", sourcePath);
command.Parameters.AddWithValue("title", chunk.Title);
command.Parameters.AddWithValue("content_hash", KnowledgeSearchText.StableId(chunk.Body));
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static string ResolveSourceRef(UnifiedChunk chunk)
{
if (!string.IsNullOrWhiteSpace(chunk.EntityKey))
{
return chunk.EntityKey.Trim();
}
return chunk.DocId;
}
private static string ResolveSourcePath(UnifiedChunk chunk)
{
if (!string.IsNullOrWhiteSpace(chunk.DocId))
{
return chunk.DocId;
}
return $"{chunk.Domain}/{chunk.Kind}";
}
private static IReadOnlyList<UnifiedChunk> DeduplicateChunks(IEnumerable<UnifiedChunk> chunks)
{
var byChunkId = new SortedDictionary<string, UnifiedChunk>(StringComparer.Ordinal);

View File

@@ -62,7 +62,7 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests
handler.Requests.Should().ContainSingle();
handler.Requests[0].Tenant.Should().Be("global");
handler.Requests[0].Uri.Should().Contain("/api/v1/scanner/security/findings");
handler.Requests[0].Uri.Should().Contain("/api/v1/security/findings");
}
[Fact]
@@ -326,6 +326,7 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests
var indexer = new UnifiedSearchIndexer(
options,
store,
[
new FindingsSearchAdapter(
new SingleClientFactory(findingsHandler, "http://scanner.local"),
@@ -357,6 +358,35 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests
(await CountDomainChunksAsync(connection, "policy")).Should().Be(4);
}
[Fact]
public async Task UnifiedSearchIndexer_RebuildAllAsync_EnsuresSchema_WhenTablesAreMissing()
{
await using var fixture = await StartPostgresOrSkipAsync();
var options = Options.Create(new KnowledgeSearchOptions
{
Enabled = true,
ConnectionString = fixture.ConnectionString,
FtsLanguageConfig = "simple"
});
await using var store = new PostgresKnowledgeSearchStore(options, NullLogger<PostgresKnowledgeSearchStore>.Instance);
var adapter = new MutableAdapter("findings", [BuildFindingChunk("finding-seed", "CVE-2026-3000", "Schema bootstrap finding.")]);
var indexer = new UnifiedSearchIndexer(
options,
store,
[adapter],
NullLogger<UnifiedSearchIndexer>.Instance);
var summary = await indexer.RebuildAllAsync(CancellationToken.None);
summary.DomainCount.Should().Be(1);
summary.ChunkCount.Should().Be(1);
await using var connection = new NpgsqlConnection(fixture.ConnectionString);
await connection.OpenAsync();
(await CountDomainChunksAsync(connection, "findings")).Should().Be(1);
}
[Fact]
public async Task UnifiedSearchIndexer_IndexAll_UpsertsOnlyChangedChunks_AndFindsNewFinding()
{
@@ -378,6 +408,7 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests
var adapter = new MutableAdapter("findings", [unchangedChunk]);
var indexer = new UnifiedSearchIndexer(
options,
store,
[adapter],
NullLogger<UnifiedSearchIndexer>.Instance);
@@ -502,6 +533,7 @@ public sealed class UnifiedSearchLiveAdapterIntegrationTests
var adapter = new MutableAdapter("findings", [chunkTenantA]);
var indexer = new UnifiedSearchIndexer(
options,
store,
[adapter],
NullLogger<UnifiedSearchIndexer>.Instance);

View File

@@ -0,0 +1,70 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
namespace StellaOps.PacksRegistry.Tests;
[Collection(PacksRegistryStartupEnvironmentCollection.Name)]
public sealed class PacksRegistryStartupContractTests
{
[Fact]
public void Startup_FailsWithoutPostgresConnectionString_InProduction()
{
using var environment = PacksRegistryStartupEnvironmentScope.ProductionPostgresWithoutConnection();
using var factory = new WebApplicationFactory<Program>();
var exception = Assert.ThrowsAny<Exception>(() =>
{
using var client = factory.CreateClient();
});
Assert.Contains(
"PacksRegistry requires PostgreSQL connection settings in non-development mode.",
exception.ToString(),
StringComparison.Ordinal);
}
[Fact]
public void Startup_RejectsRustFsObjectStoreDriver()
{
using var environment = PacksRegistryStartupEnvironmentScope.ProductionWithObjectStoreDriver("rustfs");
using var factory = new WebApplicationFactory<Program>();
var exception = Assert.ThrowsAny<Exception>(() =>
{
using var client = factory.CreateClient();
});
Assert.Contains(
"RustFS object store is configured for PacksRegistry, but no RustFS adapter is implemented.",
exception.ToString(),
StringComparison.Ordinal);
}
[Fact]
public void Startup_RejectsUnsupportedObjectStoreDriver()
{
using var environment = PacksRegistryStartupEnvironmentScope.ProductionWithObjectStoreDriver("unknown-store");
using var factory = new WebApplicationFactory<Program>();
var exception = Assert.ThrowsAny<Exception>(() =>
{
using var client = factory.CreateClient();
});
Assert.Contains(
"Unsupported object store driver 'unknown-store' for PacksRegistry. Allowed values: seed-fs.",
exception.ToString(),
StringComparison.Ordinal);
}
[Fact]
public async Task Startup_AllowsSeedFsObjectStoreDriver()
{
using var environment = PacksRegistryStartupEnvironmentScope.TestingInMemorySeedFs();
using var factory = new WebApplicationFactory<Program>();
using var client = factory.CreateClient();
var response = await client.GetAsync("/healthz", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}

View File

@@ -0,0 +1,96 @@
namespace StellaOps.PacksRegistry.Tests;
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class PacksRegistryStartupEnvironmentCollection
{
public const string Name = "PacksRegistryStartupEnvironment";
}
internal sealed class PacksRegistryStartupEnvironmentScope : IDisposable
{
private static readonly string[] ManagedKeys =
[
"DOTNET_ENVIRONMENT",
"ASPNETCORE_ENVIRONMENT",
"STORAGE__DRIVER",
"PACKSREGISTRY__STORAGE__DRIVER",
"STORAGE__OBJECTSTORE__DRIVER",
"PACKSREGISTRY__STORAGE__OBJECTSTORE__DRIVER",
"STORAGE__POSTGRES__CONNECTIONSTRING",
"PACKSREGISTRY__STORAGE__POSTGRES__CONNECTIONSTRING",
"CONNECTIONSTRINGS__PACKSREGISTRY",
"CONNECTIONSTRINGS__DEFAULT"
];
private readonly Dictionary<string, string?> _originalValues = new(StringComparer.Ordinal);
private PacksRegistryStartupEnvironmentScope()
{
foreach (var key in ManagedKeys)
{
_originalValues[key] = Environment.GetEnvironmentVariable(key);
}
}
public static PacksRegistryStartupEnvironmentScope ProductionPostgresWithoutConnection()
{
var scope = new PacksRegistryStartupEnvironmentScope();
scope.Set("DOTNET_ENVIRONMENT", "Production");
scope.Set("ASPNETCORE_ENVIRONMENT", "Production");
scope.Set("STORAGE__DRIVER", "postgres");
scope.Set("PACKSREGISTRY__STORAGE__DRIVER", "postgres");
scope.Set("PACKSREGISTRY__STORAGE__OBJECTSTORE__DRIVER", "seed-fs");
scope.Set("STORAGE__OBJECTSTORE__DRIVER", "seed-fs");
scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", null);
scope.Set("PACKSREGISTRY__STORAGE__POSTGRES__CONNECTIONSTRING", null);
scope.Set("CONNECTIONSTRINGS__PACKSREGISTRY", null);
scope.Set("CONNECTIONSTRINGS__DEFAULT", null);
return scope;
}
public static PacksRegistryStartupEnvironmentScope ProductionWithObjectStoreDriver(string objectStoreDriver)
{
var scope = new PacksRegistryStartupEnvironmentScope();
scope.Set("DOTNET_ENVIRONMENT", "Production");
scope.Set("ASPNETCORE_ENVIRONMENT", "Production");
scope.Set("STORAGE__DRIVER", "postgres");
scope.Set("PACKSREGISTRY__STORAGE__DRIVER", "postgres");
scope.Set("PACKSREGISTRY__STORAGE__OBJECTSTORE__DRIVER", objectStoreDriver);
scope.Set("STORAGE__OBJECTSTORE__DRIVER", objectStoreDriver);
var connectionString = "Host=localhost;Database=stellaops_packs;Username=stellaops;Password=stellaops";
scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", connectionString);
scope.Set("PACKSREGISTRY__STORAGE__POSTGRES__CONNECTIONSTRING", connectionString);
scope.Set("CONNECTIONSTRINGS__PACKSREGISTRY", connectionString);
scope.Set("CONNECTIONSTRINGS__DEFAULT", connectionString);
return scope;
}
public static PacksRegistryStartupEnvironmentScope TestingInMemorySeedFs()
{
var scope = new PacksRegistryStartupEnvironmentScope();
scope.Set("DOTNET_ENVIRONMENT", "Testing");
scope.Set("ASPNETCORE_ENVIRONMENT", "Testing");
scope.Set("STORAGE__DRIVER", "inmemory");
scope.Set("PACKSREGISTRY__STORAGE__DRIVER", "inmemory");
scope.Set("PACKSREGISTRY__STORAGE__OBJECTSTORE__DRIVER", "seed-fs");
scope.Set("STORAGE__OBJECTSTORE__DRIVER", "seed-fs");
scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", null);
scope.Set("PACKSREGISTRY__STORAGE__POSTGRES__CONNECTIONSTRING", null);
scope.Set("CONNECTIONSTRINGS__PACKSREGISTRY", null);
scope.Set("CONNECTIONSTRINGS__DEFAULT", null);
return scope;
}
public void Dispose()
{
foreach (var entry in _originalValues)
{
Environment.SetEnvironmentVariable(entry.Key, entry.Value);
}
}
private void Set(string key, string? value)
{
Environment.SetEnvironmentVariable(key, value);
}
}

View File

@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0432-T | DONE | Revalidated 2026-01-07; test coverage audit for StellaOps.PacksRegistry.Tests. |
| AUDIT-0432-A | DONE | Waived (test project; revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260305-002 | DONE | Added `PacksRegistryStartupContractTests` covering postgres missing-connection fail-fast and seed-fs/rustfs object-store contract enforcement. |

View File

@@ -73,8 +73,6 @@ else
}
ValidateObjectStoreContract(
builder.Configuration,
builder.Environment.IsDevelopment(),
"PacksRegistry",
objectStoreDriver);
@@ -925,38 +923,21 @@ static string ResolveObjectStoreDriver(IConfiguration configuration, string serv
}
static void ValidateObjectStoreContract(
IConfiguration configuration,
bool isDevelopment,
string serviceName,
string objectStoreDriver)
{
if (!string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(objectStoreDriver, "seed-fs", StringComparison.OrdinalIgnoreCase))
if (!string.Equals(objectStoreDriver, "seed-fs", StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"RustFS object store is configured for {serviceName}, but no RustFS adapter is implemented. " +
"Use seed-fs.");
}
throw new InvalidOperationException(
$"Unsupported object store driver '{objectStoreDriver}' for {serviceName}. " +
"Allowed values: rustfs, seed-fs.");
}
if (!string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase))
{
return;
}
if (!isDevelopment)
{
throw new InvalidOperationException(
$"RustFS object store is configured for {serviceName}, but the RustFS adapter is not implemented yet. " +
"Use seed-fs until RustFS adapter support lands.");
}
var rustFsBaseUrl = FirstNonEmpty(
configuration[$"{serviceName}:Storage:ObjectStore:RustFs:BaseUrl"],
configuration["Storage:ObjectStore:RustFs:BaseUrl"]);
if (string.IsNullOrWhiteSpace(rustFsBaseUrl))
{
return;
"Allowed values: seed-fs.");
}
}

View File

@@ -10,3 +10,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0433-A | TODO | Revalidated 2026-01-07 (open findings). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-312-003 | DONE | Postgres-first storage driver migration with seed-fs payload contract wired in Program startup (pack/provenance/attestation payload channel). |
| SPRINT-20260305-002 | DONE | Finalized startup contract: seed-fs is the only accepted object-store driver; rustfs/unknown drivers fail fast with deterministic error messages. |

View File

@@ -6,3 +6,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| --- | --- | --- |
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/StellaOps.TaskRunner.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-20260305-002 | DONE | Added `TaskRunnerStartupContractTests` covering postgres non-dev fail-fast and object-store driver contract (`seed-fs` only, rustfs/unknown rejected). |

View File

@@ -0,0 +1,70 @@
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
namespace StellaOps.TaskRunner.Tests;
[Collection(TaskRunnerStartupEnvironmentCollection.Name)]
public sealed class TaskRunnerStartupContractTests
{
[Fact]
public void Startup_FailsWithoutPostgresConnectionString_InProduction()
{
using var environment = TaskRunnerStartupEnvironmentScope.ProductionPostgresWithoutConnection();
using var factory = new WebApplicationFactory<Program>();
var exception = Assert.ThrowsAny<Exception>(() =>
{
using var client = factory.CreateClient();
});
Assert.Contains(
"TaskRunner requires PostgreSQL connection settings in non-development mode.",
exception.ToString(),
StringComparison.Ordinal);
}
[Fact]
public void Startup_RejectsRustFsObjectStoreDriver()
{
using var environment = TaskRunnerStartupEnvironmentScope.ProductionWithObjectStoreDriver("rustfs");
using var factory = new WebApplicationFactory<Program>();
var exception = Assert.ThrowsAny<Exception>(() =>
{
using var client = factory.CreateClient();
});
Assert.Contains(
"RustFS object store is configured for TaskRunner, but no RustFS adapter is implemented. Use seed-fs.",
exception.ToString(),
StringComparison.Ordinal);
}
[Fact]
public void Startup_RejectsUnsupportedObjectStoreDriver()
{
using var environment = TaskRunnerStartupEnvironmentScope.ProductionWithObjectStoreDriver("unknown-store");
using var factory = new WebApplicationFactory<Program>();
var exception = Assert.ThrowsAny<Exception>(() =>
{
using var client = factory.CreateClient();
});
Assert.Contains(
"Unsupported object store driver 'unknown-store' for TaskRunner. Allowed values: seed-fs.",
exception.ToString(),
StringComparison.Ordinal);
}
[Fact]
public async Task Startup_AllowsSeedFsObjectStoreDriver()
{
using var environment = TaskRunnerStartupEnvironmentScope.TestingInMemorySeedFs();
using var factory = new WebApplicationFactory<Program>();
using var client = factory.CreateClient();
var response = await client.GetAsync("/.well-known/openapi", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
}

View File

@@ -0,0 +1,96 @@
namespace StellaOps.TaskRunner.Tests;
[CollectionDefinition(Name, DisableParallelization = true)]
public sealed class TaskRunnerStartupEnvironmentCollection
{
public const string Name = "TaskRunnerStartupEnvironment";
}
internal sealed class TaskRunnerStartupEnvironmentScope : IDisposable
{
private static readonly string[] ManagedKeys =
[
"DOTNET_ENVIRONMENT",
"ASPNETCORE_ENVIRONMENT",
"STORAGE__DRIVER",
"TASKRUNNER__STORAGE__DRIVER",
"STORAGE__OBJECTSTORE__DRIVER",
"TASKRUNNER__STORAGE__OBJECTSTORE__DRIVER",
"STORAGE__POSTGRES__CONNECTIONSTRING",
"TASKRUNNER__STORAGE__POSTGRES__CONNECTIONSTRING",
"CONNECTIONSTRINGS__TASKRUNNER",
"CONNECTIONSTRINGS__DEFAULT"
];
private readonly Dictionary<string, string?> _originalValues = new(StringComparer.Ordinal);
private TaskRunnerStartupEnvironmentScope()
{
foreach (var key in ManagedKeys)
{
_originalValues[key] = Environment.GetEnvironmentVariable(key);
}
}
public static TaskRunnerStartupEnvironmentScope ProductionPostgresWithoutConnection()
{
var scope = new TaskRunnerStartupEnvironmentScope();
scope.Set("DOTNET_ENVIRONMENT", "Production");
scope.Set("ASPNETCORE_ENVIRONMENT", "Production");
scope.Set("STORAGE__DRIVER", "postgres");
scope.Set("TASKRUNNER__STORAGE__DRIVER", "postgres");
scope.Set("TASKRUNNER__STORAGE__OBJECTSTORE__DRIVER", "seed-fs");
scope.Set("STORAGE__OBJECTSTORE__DRIVER", "seed-fs");
scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", null);
scope.Set("TASKRUNNER__STORAGE__POSTGRES__CONNECTIONSTRING", null);
scope.Set("CONNECTIONSTRINGS__TASKRUNNER", null);
scope.Set("CONNECTIONSTRINGS__DEFAULT", null);
return scope;
}
public static TaskRunnerStartupEnvironmentScope ProductionWithObjectStoreDriver(string objectStoreDriver)
{
var scope = new TaskRunnerStartupEnvironmentScope();
scope.Set("DOTNET_ENVIRONMENT", "Production");
scope.Set("ASPNETCORE_ENVIRONMENT", "Production");
scope.Set("STORAGE__DRIVER", "postgres");
scope.Set("TASKRUNNER__STORAGE__DRIVER", "postgres");
scope.Set("TASKRUNNER__STORAGE__OBJECTSTORE__DRIVER", objectStoreDriver);
scope.Set("STORAGE__OBJECTSTORE__DRIVER", objectStoreDriver);
var connectionString = "Host=localhost;Database=stellaops_taskrunner;Username=stellaops;Password=stellaops";
scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", connectionString);
scope.Set("TASKRUNNER__STORAGE__POSTGRES__CONNECTIONSTRING", connectionString);
scope.Set("CONNECTIONSTRINGS__TASKRUNNER", connectionString);
scope.Set("CONNECTIONSTRINGS__DEFAULT", connectionString);
return scope;
}
public static TaskRunnerStartupEnvironmentScope TestingInMemorySeedFs()
{
var scope = new TaskRunnerStartupEnvironmentScope();
scope.Set("DOTNET_ENVIRONMENT", "Testing");
scope.Set("ASPNETCORE_ENVIRONMENT", "Testing");
scope.Set("STORAGE__DRIVER", "inmemory");
scope.Set("TASKRUNNER__STORAGE__DRIVER", "inmemory");
scope.Set("TASKRUNNER__STORAGE__OBJECTSTORE__DRIVER", "seed-fs");
scope.Set("STORAGE__OBJECTSTORE__DRIVER", "seed-fs");
scope.Set("STORAGE__POSTGRES__CONNECTIONSTRING", null);
scope.Set("TASKRUNNER__STORAGE__POSTGRES__CONNECTIONSTRING", null);
scope.Set("CONNECTIONSTRINGS__TASKRUNNER", null);
scope.Set("CONNECTIONSTRINGS__DEFAULT", null);
return scope;
}
public void Dispose()
{
foreach (var entry in _originalValues)
{
Environment.SetEnvironmentVariable(entry.Key, entry.Value);
}
}
private void Set(string key, string? value)
{
Environment.SetEnvironmentVariable(key, value);
}
}

View File

@@ -60,7 +60,7 @@ builder.Services.AddStellaOpsTelemetry(
var storageDriver = ResolveStorageDriver(builder.Configuration, "TaskRunner");
RegisterStateStores(builder.Services, builder.Configuration, builder.Environment.IsDevelopment(), storageDriver);
ValidateObjectStoreContract(builder.Configuration, builder.Environment.IsDevelopment(), "TaskRunner");
ValidateObjectStoreContract(builder.Configuration, "TaskRunner");
builder.Services.AddSingleton<IPackRunArtifactReader>(sp =>
{
@@ -1066,27 +1066,19 @@ static string? ResolveSchemaName(IConfiguration configuration, string serviceNam
configuration[$"Postgres:{serviceName}:SchemaName"]);
}
static void ValidateObjectStoreContract(IConfiguration configuration, bool isDevelopment, string serviceName)
static void ValidateObjectStoreContract(IConfiguration configuration, string serviceName)
{
var objectStoreDriver = ResolveObjectStoreDriver(configuration, serviceName);
if (!string.Equals(objectStoreDriver, "seed-fs", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase))
if (!string.Equals(objectStoreDriver, "seed-fs", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Unsupported object store driver '{objectStoreDriver}' for {serviceName}. Allowed values: seed-fs, rustfs.");
}
if (string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase) && !isDevelopment)
{
var rustFsBaseUrl = FirstNonEmpty(
configuration[$"{serviceName}:Storage:ObjectStore:RustFs:BaseUrl"],
configuration["Storage:ObjectStore:RustFs:BaseUrl"]);
if (string.IsNullOrWhiteSpace(rustFsBaseUrl))
if (string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"RustFS object store is configured for {serviceName}, but BaseUrl is missing.");
$"RustFS object store is configured for {serviceName}, but no RustFS adapter is implemented. Use seed-fs.");
}
throw new InvalidOperationException(
$"Unsupported object store driver '{objectStoreDriver}' for {serviceName}. Allowed values: seed-fs.");
}
}
@@ -1342,5 +1334,7 @@ internal static class RunStateMapper
}
}
public partial class Program;

View File

@@ -7,3 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/StellaOps.TaskRunner.WebService.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-312-004 | DONE | Runtime storage driver migration verified: Postgres state/log/approval default plus seed-fs artifact object-store path. |
| SPRINT-20260305-002 | DONE | Startup contract hardened: non-dev postgres missing-connection fails fast; object-store accepts seed-fs only and rejects rustfs/unknown values. |

View File

@@ -58,7 +58,7 @@ builder.Services.AddStellaOpsTelemetry(
var storageDriver = ResolveStorageDriver(builder.Configuration, "TaskRunner");
RegisterStateStores(builder.Services, builder.Configuration, builder.Environment.IsDevelopment(), storageDriver);
ValidateObjectStoreContract(builder.Configuration, builder.Environment.IsDevelopment(), "TaskRunner");
ValidateObjectStoreContract(builder.Configuration, "TaskRunner");
builder.Services.AddSingleton<IPackRunArtifactUploader>(sp =>
{
@@ -179,27 +179,19 @@ static string? ResolveSchemaName(IConfiguration configuration, string serviceNam
configuration[$"Postgres:{serviceName}:SchemaName"]);
}
static void ValidateObjectStoreContract(IConfiguration configuration, bool isDevelopment, string serviceName)
static void ValidateObjectStoreContract(IConfiguration configuration, string serviceName)
{
var objectStoreDriver = ResolveObjectStoreDriver(configuration, serviceName);
if (!string.Equals(objectStoreDriver, "seed-fs", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase))
if (!string.Equals(objectStoreDriver, "seed-fs", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Unsupported object store driver '{objectStoreDriver}' for {serviceName}. Allowed values: seed-fs, rustfs.");
}
if (string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase) && !isDevelopment)
{
var rustFsBaseUrl = FirstNonEmpty(
configuration[$"{serviceName}:Storage:ObjectStore:RustFs:BaseUrl"],
configuration["Storage:ObjectStore:RustFs:BaseUrl"]);
if (string.IsNullOrWhiteSpace(rustFsBaseUrl))
if (string.Equals(objectStoreDriver, "rustfs", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"RustFS object store is configured for {serviceName}, but BaseUrl is missing.");
$"RustFS object store is configured for {serviceName}, but no RustFS adapter is implemented. Use seed-fs.");
}
throw new InvalidOperationException(
$"Unsupported object store driver '{objectStoreDriver}' for {serviceName}. Allowed values: seed-fs.");
}
}

View File

@@ -7,3 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/StellaOps.TaskRunner.Worker.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-312-004 | DONE | Worker storage wiring aligned to Postgres state/log/approval and seed-fs artifact/provenance object-store contract. |
| SPRINT-20260305-002 | DONE | Worker startup contract now rejects rustfs/unknown object-store drivers and keeps seed-fs as the deterministic supported payload channel. |

View File

@@ -903,6 +903,36 @@ static void ConfigureEndpoints(WebApplication app)
.RequireAuthorization(NotifyPolicies.Viewer)
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
apiGroup.MapGet("/delivery/stats", async (
IDeliveryRepository repository,
HttpContext context,
CancellationToken cancellationToken) =>
{
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
{
return error!;
}
var now = DateTimeOffset.UtcNow;
var stats = await repository.GetStatsAsync(tenant!, now.AddHours(-24), now, cancellationToken);
var totalCompleted = stats.Sent + stats.Delivered + stats.Failed + stats.Bounced;
var successCount = stats.Sent + stats.Delivered;
var rate = totalCompleted > 0 ? (double)successCount / totalCompleted * 100.0 : 0.0;
return Results.Ok(new
{
totalSent = stats.Sent + stats.Delivered,
totalFailed = stats.Failed + stats.Bounced,
totalPending = stats.Pending,
successRate = Math.Round(rate, 1),
windowHours = 24,
evaluatedAt = now
});
})
.WithName("NotifyDeliveryStats")
.WithDescription("Get delivery statistics for the last 24 hours")
.RequireAuthorization(NotifyPolicies.Viewer);
apiGroup.MapGet("/deliveries/{deliveryId}", async (
string deliveryId,
IDeliveryRepository repository,

View File

@@ -189,6 +189,7 @@ builder.Services.AddHttpClient("AuthorityInternal", client =>
builder.Services.AddSingleton<PlatformMetadataService>();
builder.Services.AddSingleton<PlatformContextService>();
builder.Services.AddSingleton<IPlatformContextQuery>(sp => sp.GetRequiredService<PlatformContextService>());
builder.Services.AddSingleton<TopologyReadModelService>();
builder.Services.AddSingleton<ReleaseReadModelService>();
builder.Services.AddSingleton<SecurityReadModelService>();

View File

@@ -34,14 +34,14 @@ public sealed class IntegrationsReadModelService
];
private readonly IReleaseControlBundleStore bundleStore;
private readonly PlatformContextService contextService;
private readonly IPlatformContextQuery contextQuery;
public IntegrationsReadModelService(
IReleaseControlBundleStore bundleStore,
PlatformContextService contextService)
IPlatformContextQuery contextQuery)
{
this.bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
this.contextService = contextService ?? throw new ArgumentNullException(nameof(contextService));
this.contextQuery = contextQuery ?? throw new ArgumentNullException(nameof(contextQuery));
}
public async Task<IntegrationPageResult<IntegrationFeedProjection>> ListFeedsAsync(
@@ -110,7 +110,7 @@ public sealed class IntegrationsReadModelService
PlatformRequestContext context,
CancellationToken cancellationToken)
{
var environments = await contextService.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false);
var environments = await contextQuery.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false);
var runs = await bundleStore.ListMaterializationRunsAsync(
context.TenantId,
RunScanLimit,

View File

@@ -25,7 +25,17 @@ public interface IPlatformContextStore
CancellationToken cancellationToken = default);
}
public sealed class PlatformContextService
public interface IPlatformContextQuery
{
Task<IReadOnlyList<PlatformContextRegion>> GetRegionsAsync(
CancellationToken cancellationToken = default);
Task<IReadOnlyList<PlatformContextEnvironment>> GetEnvironmentsAsync(
IReadOnlyList<string>? regionFilter,
CancellationToken cancellationToken = default);
}
public sealed class PlatformContextService : IPlatformContextQuery
{
private static readonly string[] AllowedTimeWindows = ["1h", "24h", "7d", "30d", "90d"];
private const string DefaultTimeWindow = "24h";

View File

@@ -57,16 +57,25 @@ public sealed class PostgresTranslationStore : ITranslationStore
{
ct.ThrowIfCancellationRequested();
var result = new Dictionary<string, string>(StringComparer.Ordinal);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(SelectAllSql, conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
cmd.Parameters.AddWithValue("locale", locale);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
try
{
result[reader.GetString(0)] = reader.GetString(1);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(SelectAllSql, conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
cmd.Parameters.AddWithValue("locale", locale);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
result[reader.GetString(0)] = reader.GetString(1);
}
}
catch (Npgsql.PostgresException ex) when (IsMissingTranslationsTable(ex))
{
_logger.LogWarning(
"platform.translations table is missing; returning empty translation set for tenant {TenantId} locale {Locale}",
tenantId,
locale);
}
return result;
@@ -78,17 +87,27 @@ public sealed class PostgresTranslationStore : ITranslationStore
ct.ThrowIfCancellationRequested();
var result = new Dictionary<string, string>(StringComparer.Ordinal);
var prefix = keyPrefix.EndsWith('.') ? keyPrefix : keyPrefix + ".";
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(SelectByPrefixSql, conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
cmd.Parameters.AddWithValue("locale", locale);
cmd.Parameters.AddWithValue("prefix", prefix + "%");
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
try
{
result[reader.GetString(0)] = reader.GetString(1);
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(SelectByPrefixSql, conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
cmd.Parameters.AddWithValue("locale", locale);
cmd.Parameters.AddWithValue("prefix", prefix + "%");
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
result[reader.GetString(0)] = reader.GetString(1);
}
}
catch (Npgsql.PostgresException ex) when (IsMissingTranslationsTable(ex))
{
_logger.LogWarning(
"platform.translations table is missing; returning empty translation prefix set for tenant {TenantId} locale {Locale} prefix {Prefix}",
tenantId,
locale,
keyPrefix);
}
return result;
@@ -154,17 +173,28 @@ public sealed class PostgresTranslationStore : ITranslationStore
{
ct.ThrowIfCancellationRequested();
var locales = new List<string>();
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(SelectLocalesSql, conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
try
{
locales.Add(reader.GetString(0));
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(SelectLocalesSql, conn);
cmd.Parameters.AddWithValue("tenant_id", tenantId);
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
locales.Add(reader.GetString(0));
}
}
catch (Npgsql.PostgresException ex) when (IsMissingTranslationsTable(ex))
{
_logger.LogWarning(
"platform.translations table is missing; returning no DB locales for tenant {TenantId}",
tenantId);
}
return locales;
}
private static bool IsMissingTranslationsTable(Npgsql.PostgresException ex)
=> string.Equals(ex.SqlState, "42P01", StringComparison.Ordinal);
}

View File

@@ -22,14 +22,14 @@ public sealed class SecurityReadModelService
private static readonly string[] LicenseCatalog = ["Apache-2.0", "MIT", "BUSL-1.1", "BSD-3-Clause", "MPL-2.0"];
private readonly IReleaseControlBundleStore bundleStore;
private readonly PlatformContextService contextService;
private readonly IPlatformContextQuery contextQuery;
public SecurityReadModelService(
IReleaseControlBundleStore bundleStore,
PlatformContextService contextService)
IPlatformContextQuery contextQuery)
{
this.bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
this.contextService = contextService ?? throw new ArgumentNullException(nameof(contextService));
this.contextQuery = contextQuery ?? throw new ArgumentNullException(nameof(contextQuery));
}
public async Task<SecurityFindingsPageResult> ListFindingsAsync(
@@ -204,7 +204,7 @@ public sealed class SecurityReadModelService
PlatformRequestContext context,
CancellationToken cancellationToken)
{
var environments = await contextService.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false);
var environments = await contextQuery.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false);
var environmentById = environments.ToDictionary(item => item.EnvironmentId, StringComparer.Ordinal);
var bundles = await bundleStore.ListBundlesAsync(

View File

@@ -17,14 +17,14 @@ public sealed class TopologyReadModelService
private static readonly DateTimeOffset ProjectionEpoch = DateTimeOffset.UnixEpoch;
private readonly IReleaseControlBundleStore bundleStore;
private readonly PlatformContextService contextService;
private readonly IPlatformContextQuery contextQuery;
public TopologyReadModelService(
IReleaseControlBundleStore bundleStore,
PlatformContextService contextService)
IPlatformContextQuery contextQuery)
{
this.bundleStore = bundleStore ?? throw new ArgumentNullException(nameof(bundleStore));
this.contextService = contextService ?? throw new ArgumentNullException(nameof(contextService));
this.contextQuery = contextQuery ?? throw new ArgumentNullException(nameof(contextQuery));
}
public async Task<TopologyPageResult<TopologyRegionProjection>> ListRegionsAsync(
@@ -248,8 +248,8 @@ public sealed class TopologyReadModelService
PlatformRequestContext context,
CancellationToken cancellationToken)
{
var regions = await contextService.GetRegionsAsync(cancellationToken).ConfigureAwait(false);
var environments = await contextService.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false);
var regions = await contextQuery.GetRegionsAsync(cancellationToken).ConfigureAwait(false);
var environments = await contextQuery.GetEnvironmentsAsync(null, cancellationToken).ConfigureAwait(false);
var bundles = await bundleStore.ListBundlesAsync(
context.TenantId,

View File

@@ -46,4 +46,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| SPRINT_20260224_004-LOC-308 | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: platform locale catalog endpoint (`GET /api/v1/platform/localization/locales`) is now consumed by both UI and CLI locale-selection paths. |
| SPRINT_20260226_230-LOC-001 | DONE | Sprint `docs-archived/implplan/2026-03-03-completed-sprints/SPRINT_20260226_230_Platform_locale_label_translation_corrections.md`: completed non-English translation correction across Platform/Web/shared localization bundles (`bg-BG`, `de-DE`, `es-ES`, `fr-FR`, `ru-RU`, `uk-UA`, `zh-CN`, `zh-TW`), including cleanup of placeholder/transliteration/malformed values (`Ezik`, leaked token markers, mojibake) and a context-quality pass for backend German resource bundles (`graph`, `policy`, `scanner`, `advisoryai`). |
| PLATFORM-223-001 | DONE | Sprint `docs-archived/implplan/2026-03-03-completed-sprints/SPRINT_20260226_223_Platform_score_explain_contract_and_replay_alignment.md`: shipped deterministic score explain/replay contract completion (`unknowns`, `proof_ref`, deterministic replay envelope parsing/verification differences) and updated score API/module docs with contract notes. |
| SPRINT_20260305_005-PLATFORM-BOUND-001 | DONE | Sprint `docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_005_Platform_read_model_boundary_enforcement.md`: captured Platform runtime dependency inventory with explicit allowed runtime, migration-only, and prohibited coupling categories in architecture docs. |
| SPRINT_20260305_005-PLATFORM-BOUND-003 | DONE | Sprint `docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_005_Platform_read_model_boundary_enforcement.md`: introduced `IPlatformContextQuery` and switched topology/security/integrations read-model services to explicit query contracts; DI now binds read-model contract separately from context mutation service. |
| SPRINT_20260305_005-PLATFORM-BOUND-004 | DONE | Sprint `docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_005_Platform_read_model_boundary_enforcement.md`: documented runtime boundary policy, migration/runtime separation, and allowlisted exceptions in Platform dossiers. |

View File

@@ -0,0 +1,137 @@
using StellaOps.Platform.WebService.Services;
using StellaOps.TestKit;
using System.Reflection;
namespace StellaOps.Platform.WebService.Tests;
public sealed class PlatformRuntimeBoundaryGuardTests
{
private static readonly IReadOnlyDictionary<Type, Type[]> ApprovedConstructorDependencies =
new Dictionary<Type, Type[]>
{
[typeof(ReleaseReadModelService)] = [typeof(IReleaseControlBundleStore)],
[typeof(TopologyReadModelService)] = [typeof(IReleaseControlBundleStore), typeof(IPlatformContextQuery)],
[typeof(SecurityReadModelService)] = [typeof(IReleaseControlBundleStore), typeof(IPlatformContextQuery)],
[typeof(IntegrationsReadModelService)] = [typeof(IReleaseControlBundleStore), typeof(IPlatformContextQuery)],
};
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RuntimeReadModelServices_UseOnlyApprovedConstructorContracts()
{
var violations = new List<string>();
foreach (var (serviceType, expectedDependencies) in ApprovedConstructorDependencies)
{
var constructors = serviceType
.GetConstructors(BindingFlags.Instance | BindingFlags.Public)
.Where(static ctor => !ctor.IsStatic)
.ToArray();
if (constructors.Length != 1)
{
violations.Add($"{serviceType.Name}: expected exactly one public constructor, found {constructors.Length}.");
continue;
}
var actualDependencies = constructors[0]
.GetParameters()
.Select(static parameter => parameter.ParameterType)
.ToArray();
if (actualDependencies.Length != expectedDependencies.Length)
{
violations.Add(
$"{serviceType.Name}: expected {FormatTypeList(expectedDependencies)} but found {FormatTypeList(actualDependencies)}.");
continue;
}
for (var i = 0; i < expectedDependencies.Length; i++)
{
if (actualDependencies[i] != expectedDependencies[i])
{
violations.Add(
$"{serviceType.Name}: constructor contract mismatch at position {i + 1}; expected {expectedDependencies[i].FullName}, found {actualDependencies[i].FullName}.");
}
}
}
Assert.True(
violations.Count == 0,
"Runtime read-model constructor contracts drifted from approved boundary.\n"
+ string.Join(Environment.NewLine, violations));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void RuntimeSourceFiles_DoNotReferenceForeignPersistenceOutsideAllowlist()
{
var platformRoot = FindPlatformRoot();
var scannedRoots = new[]
{
Path.Combine(platformRoot, "StellaOps.Platform.WebService"),
Path.Combine(platformRoot, "__Libraries", "StellaOps.Platform.Database")
};
var allowlistedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
NormalizePath(Path.Combine("StellaOps.Platform.WebService", "Endpoints", "SeedEndpoints.cs")),
NormalizePath(Path.Combine("__Libraries", "StellaOps.Platform.Database", "MigrationModulePlugins.cs")),
};
var violations = new List<string>();
foreach (var root in scannedRoots)
{
foreach (var file in Directory.GetFiles(root, "*.cs", SearchOption.AllDirectories))
{
var relativePath = NormalizePath(Path.GetRelativePath(platformRoot, file));
foreach (var (line, lineNumber) in File.ReadLines(file).Select((value, index) => (value, index + 1)))
{
if (!line.TrimStart().StartsWith("using StellaOps.", StringComparison.Ordinal)
|| !line.Contains(".Persistence", StringComparison.Ordinal)
|| allowlistedFiles.Contains(relativePath))
{
continue;
}
violations.Add($"{relativePath}:{lineNumber}: {line.Trim()}");
}
}
}
Assert.True(
violations.Count == 0,
"Foreign persistence references are only allowed in explicit migration/seed boundaries. Violations:\n"
+ string.Join(Environment.NewLine, violations));
}
private static string FormatTypeList(IEnumerable<Type> types)
{
return string.Join(", ", types.Select(static type => type.FullName ?? type.Name));
}
private static string FindPlatformRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
var candidate = Path.Combine(current.FullName, "src", "Platform");
if (Directory.Exists(Path.Combine(candidate, "StellaOps.Platform.WebService"))
&& Directory.Exists(Path.Combine(candidate, "__Libraries", "StellaOps.Platform.Database")))
{
return candidate;
}
current = current.Parent;
}
throw new DirectoryNotFoundException("Could not locate src/Platform root for runtime boundary guard tests.");
}
private static string NormalizePath(string path)
{
return path.Replace('\\', '/');
}
}

View File

@@ -23,3 +23,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| SPRINT_20260224_004-LOC-302-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added language preference endpoint coverage in `PreferencesEndpointsTests` (round-trip persistence + invalid locale rejection) and expanded locale catalog verification in `LocalizationEndpointsTests`. |
| SPRINT_20260224_004-LOC-305-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: extended `LocalizationEndpointsTests` to verify common-layer and `platform.*` namespace bundle availability for all supported locales. |
| SPRINT_20260224_004-LOC-307-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: extended localization and preference endpoint tests for Ukrainian rollout (`uk-UA` locale catalog/bundle assertions and alias normalization to canonical `uk-UA`). |
| SPRINT_20260305_005-PLATFORM-BOUND-002 | DONE | Sprint `docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_005_Platform_read_model_boundary_enforcement.md`: added `PlatformRuntimeBoundaryGuardTests` to enforce approved read-model constructor contracts and disallow foreign persistence references outside explicit migration/seed allowlist files. |

View File

@@ -19,7 +19,7 @@
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
- `docs/modules/platform/architecture.md`
- `docs/modules/remediation/architecture.md`
- `docs/implplan/SPRINT_20260305_004_Remediation_postgres_runtime_wiring.md`
- `docs-archived/implplan/2026-03-05-completed-sprints/SPRINT_20260305_004_Remediation_postgres_runtime_wiring.md`
## Working Agreement
- Update sprint task state (`TODO`, `DOING`, `DONE`, `BLOCKED`) in `docs/implplan/SPRINT_*.md` as work progresses.

View File

@@ -7,3 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Replay/StellaOps.Replay.WebService/StellaOps.Replay.WebService.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-312-006 | DONE | Added Postgres snapshot index + seed-fs snapshot blob stores and wired storage-driver registration in webservice startup. |
| SPRINT-20260305-003 | DONE | Replay storage contract closed: object-store narrowed to seed-fs only with deterministic rustfs/unknown-driver startup rejection and synced docs. |

View File

@@ -4,11 +4,20 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using FluentAssertions;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Replay.WebService;
using Xunit;
@@ -23,6 +32,7 @@ public sealed class PointInTimeQueryApiIntegrationTests
{
await using var factory = CreateFactory();
using var client = factory.CreateClient();
ConfigureClient(client);
var cveId = "CVE-2024-7777";
var providerId = "nvd-e2e";
@@ -112,6 +122,7 @@ public sealed class PointInTimeQueryApiIntegrationTests
{
await using var factory = CreateFactory();
using var client = factory.CreateClient();
ConfigureClient(client);
var response = await client.PostAsJsonAsync(
"/v1/pit/advisory/diff",
@@ -146,6 +157,67 @@ public sealed class PointInTimeQueryApiIntegrationTests
["Replay:Authority:RequireHttpsMetadata"] = "false",
});
});
builder.ConfigureTestServices(services =>
{
services.AddAuthentication(TestReplayAuthHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, TestReplayAuthHandler>(
TestReplayAuthHandler.SchemeName,
_ => { });
services.PostConfigureAll<AuthenticationOptions>(options =>
{
options.DefaultAuthenticateScheme = TestReplayAuthHandler.SchemeName;
options.DefaultChallengeScheme = TestReplayAuthHandler.SchemeName;
options.DefaultScheme = TestReplayAuthHandler.SchemeName;
});
services.RemoveAll<IAuthorizationHandler>();
services.AddSingleton<IAuthorizationHandler, AllowAllAuthorizationHandler>();
});
});
}
private static void ConfigureClient(HttpClient client)
{
client.DefaultRequestHeaders.TryAddWithoutValidation("X-StellaOps-Tenant", "tenant-e2e");
}
private sealed class TestReplayAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "ReplayTestScheme";
public TestReplayAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new[]
{
new Claim("scope", "vuln.operate replay.token.read replay.token.write"),
new Claim("scp", "vuln.operate replay.token.read replay.token.write")
};
var principal = new ClaimsPrincipal(new ClaimsIdentity(claims, SchemeName));
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
private sealed class AllowAllAuthorizationHandler : IAuthorizationHandler
{
public Task HandleAsync(AuthorizationHandlerContext context)
{
foreach (var requirement in context.PendingRequirements.ToList())
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
}

View File

@@ -7,3 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20260130_002_Tools_csproj_remediation_sol
| REMED-05 | TODO | Remediation checklist: docs/implplan/audits/csproj-standards/remediation/checklists/src/Replay/__Tests/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.md. |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| SPRINT-312-006 | DONE | Added `ReplayFeedSnapshotStoresTests` and validated Postgres index + seed-fs blob stores via class-targeted xUnit execution (3/3 pass). |
| SPRINT-20260305-003 | DONE | Added authenticated replay API integration test harness and revalidated Replay core suite (`dotnet test ...` passed 99/99). |

View File

@@ -11,3 +11,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| RGH-01 | DONE | 2026-02-22: Added SPA fallback handling for browser deep links on microservice route matches; API prefixes remain backend-dispatched. |
| RGH-02 | DONE | 2026-03-04: Expanded approved auth passthrough prefixes (`/authority`, `/doctor`, `/api`) to unblock authenticated gateway routes used by Audit Log UI E2E. |
| RGH-03 | DONE | 2026-03-05: Aligned `/api/v1/search*` and `/api/v1/advisory-ai*` route translations to AdvisoryAI `/v1/*`, added compose/runtime parity safeguards, and verified setup smoke coverage. |

View File

@@ -102,7 +102,8 @@
{ "Type": "ReverseProxy", "Path": "/api/v1/evidence", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/evidence" },
{ "Type": "ReverseProxy", "Path": "/api/v1/proofs", "TranslatesTo": "http://evidencelocker.stella-ops.local/api/v1/proofs" },
{ "Type": "ReverseProxy", "Path": "/api/v1/timeline", "TranslatesTo": "http://timelineindexer.stella-ops.local/api/v1/timeline" },
{ "Type": "ReverseProxy", "Path": "/api/v1/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory-ai" },
{ "Type": "ReverseProxy", "Path": "/api/v1/search", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/search" },
{ "Type": "ReverseProxy", "Path": "/api/v1/advisory-ai", "TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai" },
{ "Type": "ReverseProxy", "Path": "/api/v1/advisory", "TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory" },
{ "Type": "ReverseProxy", "Path": "/api/v1/concelier", "TranslatesTo": "http://concelier.stella-ops.local/api/v1/concelier" },
{ "Type": "ReverseProxy", "Path": "/api/v1/vulnerabilities", "TranslatesTo": "http://scanner.stella-ops.local/api/v1/vulnerabilities" },

View File

@@ -0,0 +1,74 @@
using System.Text.Json;
namespace StellaOps.Gateway.WebService.Tests.Configuration;
public sealed class GatewayRouteSearchMappingsTests
{
private static readonly (string Path, string Target, string RouteType)[] RequiredMappings =
[
("/api/v1/search", "http://advisoryai.stella-ops.local/v1/search", "ReverseProxy"),
("/api/v1/advisory-ai", "http://advisoryai.stella-ops.local/v1/advisory-ai", "ReverseProxy")
];
public static TheoryData<string> RouteConfigPaths => new()
{
"src/Router/StellaOps.Gateway.WebService/appsettings.json",
"devops/compose/router-gateway-local.json",
"devops/compose/router-gateway-local.reverseproxy.json"
};
[Theory]
[MemberData(nameof(RouteConfigPaths))]
public void RouteTable_ContainsUnifiedSearchMappingsAndKeepsThemAheadOfApiCatchAll(string configRelativePath)
{
var repoRoot = FindRepositoryRoot();
var configPath = Path.Combine(repoRoot, configRelativePath.Replace('/', Path.DirectorySeparatorChar));
Assert.True(File.Exists(configPath), $"Config file not found: {configPath}");
using var stream = File.OpenRead(configPath);
using var document = JsonDocument.Parse(stream);
var routes = document.RootElement
.GetProperty("Gateway")
.GetProperty("Routes")
.EnumerateArray()
.Select((route, index) => new RouteEntry(
index,
route.GetProperty("Type").GetString() ?? string.Empty,
route.GetProperty("Path").GetString() ?? string.Empty,
route.TryGetProperty("TranslatesTo", out var translatesTo)
? translatesTo.GetString() ?? string.Empty
: string.Empty))
.ToList();
var catchAllIndex = routes.FindIndex(route => string.Equals(route.Path, "/api", StringComparison.Ordinal));
foreach (var (requiredPath, requiredTarget, requiredType) in RequiredMappings)
{
var route = routes.FirstOrDefault(candidate => string.Equals(candidate.Path, requiredPath, StringComparison.Ordinal));
Assert.True(route is not null, $"Missing route '{requiredPath}' in {configRelativePath}.");
Assert.Equal(requiredType, route!.Type);
Assert.Equal(requiredTarget, route!.TranslatesTo);
if (catchAllIndex >= 0)
{
Assert.True(route.Index < catchAllIndex, $"{requiredPath} must appear before /api catch-all in {configRelativePath}.");
}
}
}
private static string FindRepositoryRoot()
{
for (var current = new DirectoryInfo(AppContext.BaseDirectory); current is not null; current = current.Parent)
{
if (Directory.Exists(Path.Combine(current.FullName, ".git")))
{
return current.FullName;
}
}
throw new InvalidOperationException($"Unable to locate repository root from {AppContext.BaseDirectory}.");
}
private sealed record RouteEntry(int Index, string Type, string Path, string TranslatesTo);
}

View File

@@ -10,3 +10,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
| AUDIT-0349-A | DONE | Waived (test project; revalidated 2026-01-07). |
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
| RGH-01-T | DONE | 2026-02-22: Added route-dispatch unit tests for microservice SPA fallback and API-prefix bypass behavior. |
| RGH-03-T | DONE | 2026-03-05: Added deterministic route-table parity tests for unified search mappings across gateway runtime and compose configs; verified in gateway test run. |

View File

@@ -1540,6 +1540,21 @@ app.MapPost("/internal/orchestrator/watermarks", async Task<IResult> (
.RequireAuthorization(SbomPolicies.Internal)
.RequireTenant();
// Compare baseline recommendation stub (called by UI compare feature)
app.MapGet("/api/compare/baselines/{scanDigest}", (string scanDigest) =>
Results.Ok(new
{
selectedDigest = (string?)null,
selectionReason = "No baseline recommendations available for this scan",
alternatives = Array.Empty<object>(),
autoSelectEnabled = true,
scanDigest
}))
.WithName("GetCompareBaselineRecommendation")
.WithDescription("Returns baseline scan recommendations for delta comparison. Returns empty recommendations when no previous scans are available.")
.WithTags("Compare")
.RequireAuthorization(SbomPolicies.Read);
app.TryRefreshStellaRouterEndpoints(routerEnabled);
app.Run();

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

View File

@@ -0,0 +1,101 @@
const { chromium } = require('playwright');
const session = {
subjectId: 'user-author',
tenant: 'tenant-default',
scopes: [
'ui.read',
'policy:read',
'policy:author',
'policy:simulate',
'advisory-ai:view',
'advisory-ai:operate',
'findings:read',
'vex:read',
'admin',
],
};
(async () => {
const browser = await chromium.launch({
headless: true,
args: ['--no-sandbox', '--no-proxy-server'],
});
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
const navHistory = [];
const httpErrors = [];
const failures = [];
let currentUrl = '';
page.on('framenavigated', (frame) => {
if (frame !== page.mainFrame()) {
return;
}
const entry = `${new Date().toISOString()} ${frame.url()}`;
navHistory.push(entry);
console.log('[nav]', entry);
});
page.on('response', (response) => {
if (response.status() < 400) {
return;
}
const request = response.request();
const entry = `${response.status()} ${request.method()} ${response.url()}`;
httpErrors.push(entry);
console.log('[http-error]', entry);
});
page.on('requestfailed', (request) => {
const entry = `${request.method()} ${request.url()} :: ${request.failure()?.errorText ?? 'failed'}`;
failures.push(entry);
console.log('[requestfailed]', entry);
});
page.on('console', (msg) => {
if (msg.type() === 'error') {
console.log('[console-error]', msg.text());
}
});
await page.addInitScript((stubSession) => {
window.__stellaopsTestSession = stubSession;
}, session);
const target = process.argv[2] ?? 'https://stella-ops.local/';
console.log('[goto]', target);
try {
await page.goto(target, { waitUntil: 'commit', timeout: 20000 });
} catch (error) {
console.log('[goto-error]', error.message);
}
for (let i = 0; i < 20; i += 1) {
const url = page.url();
if (url !== currentUrl) {
currentUrl = url;
console.log('[url-change]', url);
}
await page.waitForTimeout(1000);
}
const searchInputCount = await page
.evaluate(() => document.querySelectorAll('app-global-search input[type="text"]').length)
.catch(() => -1);
console.log('[final-url]', page.url());
console.log('[title]', await page.title().catch(() => '<title unavailable>'));
console.log('[search-input-count]', searchInputCount);
console.log('[nav-count]', navHistory.length);
console.log('[http-error-count]', httpErrors.length);
console.log('[failed-request-count]', failures.length);
await page.screenshot({ path: 'output/playwright/stella-ops-local-load-check-viewport.png' });
await browser.close();
})();

View File

@@ -0,0 +1,66 @@
const { chromium } = require('playwright');
const session = {
subjectId: 'user-author',
tenant: 'tenant-default',
scopes: ['ui.read', 'policy:read', 'policy:author', 'policy:simulate', 'advisory:search', 'advisory:read', 'search:read', 'findings:read', 'vex:read', 'admin'],
};
(async () => {
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
page.on('requestfailed', (request) => {
const url = request.url();
if (url.includes('/search')) {
console.log('[requestfailed]', request.method(), url, request.failure()?.errorText);
}
});
page.on('response', (response) => {
const url = response.url();
if (
url.includes('/api/v1/search/query') ||
url.includes('/api/v1/advisory-ai/search') ||
url.includes('/api/v1/advisory-ai/search/analytics')
) {
const req = response.request();
console.log('[response]', req.method(), response.status(), url);
}
});
await page.addInitScript((stubSession) => {
window.__stellaopsTestSession = stubSession;
}, session);
const url = process.argv[2] || 'https://127.1.0.1/';
console.log('[goto]', url);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForTimeout(2000);
const count = await page.evaluate(() => document.querySelectorAll('app-global-search input[type="text"]').length);
console.log('[search-input-count]', count);
if (count === 0) {
console.log('[page-url]', page.url());
console.log('[title]', await page.title());
await page.screenshot({ path: 'output/playwright/header-search-repro-no-input.png', fullPage: true });
await browser.close();
process.exit(1);
}
await page.click('app-global-search input[type="text"]', { timeout: 15000 });
await page.fill('app-global-search input[type="text"]', 'critical findings', { timeout: 15000 });
await page.waitForTimeout(3000);
const results = await page.evaluate(() => document.querySelectorAll('app-entity-card').length);
const emptyText = await page.locator('.search__empty').allTextContents();
const degradedVisible = await page.locator('.search__degraded-banner').isVisible().catch(() => false);
console.log('[entity-cards]', results);
console.log('[empty-text]', emptyText.join(' | '));
console.log('[degraded-banner]', degradedVisible);
await page.screenshot({ path: 'output/playwright/header-search-repro-live.png', fullPage: true });
await browser.close();
})();

View File

@@ -0,0 +1,66 @@
const { chromium } = require('playwright');
const session = {
subjectId: 'user-author',
tenant: 'tenant-default',
scopes: ['ui.read','policy:read','policy:author','policy:simulate','advisory:search','advisory:read','search:read','findings:read','vex:read','admin']
};
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ ignoreHTTPSErrors: true });
const page = await context.newPage();
page.on('requestfailed', (request) => {
const url = request.url();
if (url.includes('/search')) {
console.log('[requestfailed]', request.method(), url, request.failure()?.errorText);
}
});
page.on('response', (response) => {
const url = response.url();
if (
url.includes('/api/v1/search/query') ||
url.includes('/api/v1/advisory-ai/search') ||
url.includes('/api/v1/advisory-ai/search/analytics')
) {
const req = response.request();
console.log('[response]', req.method(), response.status(), url);
}
});
await page.addInitScript((stubSession) => {
window.__stellaopsTestSession = stubSession;
}, session);
const url = process.argv[2] || 'https://127.1.0.1:10000/';
console.log('[goto]', url);
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 60000 });
await page.waitForTimeout(2000);
const count = await page.evaluate(() => document.querySelectorAll('app-global-search input[type="text"]').length);
console.log('[search-input-count]', count);
if (count === 0) {
console.log('[page-url]', page.url());
console.log('[title]', await page.title());
await page.screenshot({ path: 'output/playwright/header-search-repro-no-input.png', fullPage: true });
await browser.close();
process.exit(1);
}
await page.click('app-global-search input[type="text"]', { timeout: 15000 });
await page.fill('app-global-search input[type="text"]', 'critical findings', { timeout: 15000 });
await page.waitForTimeout(3000);
const results = await page.evaluate(() => document.querySelectorAll('app-entity-card').length);
const emptyText = await page.locator('.search__empty').allTextContents();
const degradedVisible = await page.locator('.search__degraded-banner').isVisible().catch(() => false);
console.log('[entity-cards]', results);
console.log('[empty-text]', emptyText.join(' | '));
console.log('[degraded-banner]', degradedVisible);
await page.screenshot({ path: 'output/playwright/header-search-repro.png', fullPage: true });
await browser.close();
})();

View File

@@ -101,7 +101,7 @@ export class ReleaseManagementHttpClient implements ReleaseManagementApi {
private readonly http = inject(HttpClient);
private readonly context = inject(PlatformContextStore);
private readonly readBaseUrl = '/api/v2/releases';
private readonly legacyBaseUrl = '/api/release-orchestrator/releases';
private readonly legacyBaseUrl = '/api/v1/releases';
listReleases(filter?: ReleaseFilter): Observable<ReleaseListResponse> {
const page = Math.max(1, filter?.page ?? 1);

View File

@@ -61,7 +61,7 @@ describe('requireConfigGuard', () => {
expect((result as UrlTree).toString()).toBe('/setup');
});
it('should redirect to /setup/wizard when config loaded but setup is absent', () => {
it('should redirect to /setup-wizard/wizard when config loaded but setup is absent', () => {
(configService.isConfigured as jasmine.Spy).and.returnValue(true);
Object.defineProperty(configService, 'config', {
get: () => ({ ...minimalConfig, setup: undefined }),
@@ -70,10 +70,10 @@ describe('requireConfigGuard', () => {
const result = runGuard();
expect(result).toBeInstanceOf(UrlTree);
expect((result as UrlTree).toString()).toBe('/setup/wizard');
expect((result as UrlTree).toString()).toBe('/setup-wizard/wizard');
});
it('should redirect to /setup/wizard?resume=migrations when setup is a step ID', () => {
it('should redirect to /setup-wizard/wizard?resume=migrations when setup is a step ID', () => {
(configService.isConfigured as jasmine.Spy).and.returnValue(true);
Object.defineProperty(configService, 'config', {
get: () => ({ ...minimalConfig, setup: 'migrations' }),
@@ -82,7 +82,7 @@ describe('requireConfigGuard', () => {
const result = runGuard();
expect(result).toBeInstanceOf(UrlTree);
expect((result as UrlTree).toString()).toContain('/setup/wizard');
expect((result as UrlTree).toString()).toContain('/setup-wizard/wizard');
expect((result as UrlTree).queryParams['resume']).toBe('migrations');
});

View File

@@ -7,8 +7,8 @@ import { AppConfigService } from './app-config.service';
* Route guard that checks both configuration loading and setup state.
*
* - If config is not loaded → redirect to /setup
* - If config is loaded but `setup` is absent/undefined → redirect to /setup/wizard (fresh install)
* - If config is loaded and `setup` is a step ID → redirect to /setup/wizard?resume=<stepId>
* - If config is loaded but `setup` is absent/undefined → redirect to /setup-wizard/wizard (fresh install)
* - If config is loaded and `setup` is a step ID → redirect to /setup-wizard/wizard?resume=<stepId>
* - If config is loaded and `setup === "complete"` → allow navigation
*
* Place this guard **before** auth guards so unconfigured deployments
@@ -26,12 +26,12 @@ export const requireConfigGuard: CanMatchFn = () => {
if (!setup) {
// setup absent → fresh install, go to wizard
return router.createUrlTree(['/setup/wizard']);
return router.createUrlTree(['/setup-wizard/wizard']);
}
if (setup !== 'complete') {
// setup = stepId → resume wizard at that step
return router.createUrlTree(['/setup/wizard'], {
return router.createUrlTree(['/setup-wizard/wizard'], {
queryParams: { resume: setup },
});
}

View File

@@ -21,79 +21,142 @@ interface SetupCard {
template: `
<div class="admin-overview">
<header class="admin-overview__header">
<h1 class="admin-overview__title">Setup</h1>
<p class="admin-overview__subtitle">
Manage topology, identity, tenants, notifications, and system controls.
</p>
<div>
<h1 class="admin-overview__title">Setup</h1>
<p class="admin-overview__subtitle">
Manage topology, identity, tenants, notifications, and system controls.
</p>
</div>
<div class="admin-overview__meta">
<span class="admin-overview__meta-chip">7 setup domains</span>
<span class="admin-overview__meta-chip">3 operational drilldowns</span>
<span class="admin-overview__meta-chip">Offline-first safe</span>
</div>
</header>
<div class="admin-overview__grid">
@for (card of cards; track card.id) {
<a class="admin-card" [routerLink]="card.route">
<div class="admin-card__icon" aria-hidden="true">{{ card.icon }}</div>
<div class="admin-card__body">
<h2 class="admin-card__title">{{ card.title }}</h2>
<p class="admin-card__description">{{ card.description }}</p>
</div>
</a>
}
</div>
<div class="admin-overview__layout">
<div>
<div class="admin-overview__grid">
@for (card of cards; track card.id) {
<a class="admin-card" [routerLink]="card.route">
<div class="admin-card__icon" aria-hidden="true">{{ card.icon }}</div>
<div class="admin-card__body">
<h2 class="admin-card__title">{{ card.title }}</h2>
<p class="admin-card__description">{{ card.description }}</p>
</div>
</a>
}
</div>
<section class="admin-overview__drilldowns">
<h2 class="admin-overview__section-heading">Operational Drilldowns</h2>
<ul class="admin-overview__links">
<li><a routerLink="/ops/operations/quotas">Quotas &amp; Limits</a> - Ops</li>
<li><a routerLink="/ops/operations/system-health">System Health</a> - Ops</li>
<li><a routerLink="/evidence/audit-log">Audit Log</a> - Evidence</li>
</ul>
</section>
<section class="admin-overview__drilldowns">
<h2 class="admin-overview__section-heading">Operational Drilldowns</h2>
<ul class="admin-overview__links">
<li><a routerLink="/ops/operations/quotas">Quotas &amp; Limits</a> - Ops</li>
<li><a routerLink="/ops/operations/system-health">System Health</a> - Ops</li>
<li><a routerLink="/evidence/audit-log">Audit Log</a> - Evidence</li>
</ul>
</section>
</div>
<aside class="admin-overview__aside">
<section class="admin-overview__aside-section">
<h2 class="admin-overview__section-heading">Quick Actions</h2>
<div class="admin-overview__quick-actions">
<a routerLink="/setup/topology/targets">Add Target</a>
<a routerLink="/setup/integrations">Configure Integrations</a>
<a routerLink="/setup/identity-access">Review Access</a>
</div>
</section>
<section class="admin-overview__aside-section">
<h2 class="admin-overview__section-heading">Suggested Next Steps</h2>
<ul class="admin-overview__links admin-overview__links--compact">
<li>Validate environment mapping and target ownership.</li>
<li>Verify notification channels before first production promotion.</li>
<li>Confirm audit trail visibility for approvers.</li>
</ul>
</section>
</aside>
</div>
</div>
`,
styles: [
`
.admin-overview {
padding: 1.5rem;
max-width: 1200px;
max-width: 1240px;
}
.admin-overview__header {
margin-bottom: 2rem;
margin-bottom: 1.5rem;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.admin-overview__title {
font-size: 1.5rem;
font-weight: 600;
font-size: 1.9rem;
line-height: 1.05;
font-weight: 700;
margin: 0 0 0.5rem;
}
.admin-overview__subtitle {
color: var(--color-text-secondary, #666);
color: var(--color-text-secondary, #4f4b3e);
margin: 0;
max-width: 60ch;
}
.admin-overview__meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.admin-overview__meta-chip {
display: inline-flex;
align-items: center;
border: 1px solid var(--color-border-secondary, #d4c9a8);
border-radius: var(--radius-full, 999px);
padding: 0.24rem 0.55rem;
font-size: 0.65rem;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--color-text-secondary, #4f4b3e);
background: var(--color-surface-primary, #fff);
}
.admin-overview__layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 1rem;
}
.admin-overview__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 0.85rem;
margin-bottom: 1.5rem;
}
.admin-card {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1.25rem;
padding: 1rem;
background: var(--color-surface, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-md, 8px);
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
}
.admin-card:hover {
border-color: var(--color-brand-primary, #4f46e5);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
border-color: var(--color-brand-primary, #f5a623);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
}
.admin-card__icon {
@@ -104,24 +167,59 @@ interface SetupCard {
}
.admin-card__title {
font-size: 0.9375rem;
font-weight: 600;
font-size: 0.94rem;
font-weight: 700;
margin: 0 0 0.25rem;
}
.admin-card__description {
font-size: 0.8125rem;
color: var(--color-text-secondary, #666);
font-size: 0.82rem;
color: var(--color-text-secondary, #4f4b3e);
margin: 0;
}
.admin-overview__aside {
display: grid;
gap: 0.8rem;
align-content: start;
}
.admin-overview__aside-section,
.admin-overview__drilldowns {
border: 1px solid var(--color-border-primary, #e5e7eb);
background: var(--color-surface-primary, #fff);
border-radius: var(--radius-md, 8px);
padding: 0.9rem;
}
.admin-overview__section-heading {
font-size: 0.875rem;
font-weight: 600;
margin: 0 0 0.75rem;
font-size: 0.74rem;
font-weight: 700;
margin: 0 0 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary, #666);
letter-spacing: 0.08em;
color: var(--color-text-secondary, #4f4b3e);
}
.admin-overview__quick-actions {
display: grid;
gap: 0.45rem;
}
.admin-overview__quick-actions a {
display: inline-flex;
align-items: center;
border: 1px solid var(--color-border-primary, #e5e7eb);
border-radius: var(--radius-sm, 6px);
padding: 0.45rem 0.55rem;
text-decoration: none;
color: var(--color-text-primary, #1c1200);
background: var(--color-surface-secondary, #faf8f0);
}
.admin-overview__quick-actions a:hover {
border-color: var(--color-brand-primary, #f5a623);
color: var(--color-brand-primary, #f5a623);
}
.admin-overview__links {
@@ -134,18 +232,42 @@ interface SetupCard {
}
.admin-overview__links li {
font-size: 0.875rem;
color: var(--color-text-secondary, #666);
font-size: 0.84rem;
color: var(--color-text-secondary, #4f4b3e);
}
.admin-overview__links--compact li {
line-height: 1.35;
}
.admin-overview__links a {
color: var(--color-brand-primary, #4f46e5);
color: var(--color-brand-primary, #f5a623);
text-decoration: none;
}
.admin-overview__links a:hover {
text-decoration: underline;
}
@media (max-width: 1080px) {
.admin-overview__layout {
grid-template-columns: 1fr;
}
.admin-overview__header {
flex-direction: column;
}
}
@media (max-width: 575px) {
.admin-overview {
padding: 0.75rem;
}
.admin-overview__title {
font-size: 2rem;
}
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -4,13 +4,18 @@
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.compare-toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
height: auto;
min-height: 0;
padding: var(--space-2) var(--space-4);
background: var(--color-surface-secondary);
overflow: visible;
.target-selector {
display: flex;
@@ -86,6 +91,7 @@
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
.pane {
@@ -187,6 +193,120 @@
}
}
@media (max-width: 1080px) {
.compare-toolbar {
flex-wrap: wrap;
align-items: flex-start;
gap: var(--space-2);
.target-selector {
flex: 1 1 100%;
min-width: 0;
flex-wrap: wrap;
}
.toolbar-actions {
width: 100%;
justify-content: space-between;
flex-wrap: wrap;
}
}
.delta-summary {
flex-wrap: wrap;
gap: var(--space-2);
}
}
@media (max-width: 768px) {
.compare-view {
height: auto;
min-height: 100%;
}
.compare-toolbar {
display: grid;
grid-template-columns: 1fr;
align-items: stretch;
padding: var(--space-2);
gap: var(--space-2);
.target-selector {
width: 100%;
gap: var(--space-2);
align-items: flex-start;
mat-select {
min-width: 0;
width: 100%;
}
}
.toolbar-actions {
width: 100%;
display: grid;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: var(--space-2);
> button[mat-icon-button] {
justify-self: start;
}
> button[mat-stroked-button] {
justify-self: end;
}
}
.role-toggle {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-1);
}
.role-toggle-button {
min-width: 0;
width: 100%;
padding-inline: 0.2rem;
font-size: 0.74rem;
}
}
.delta-summary {
gap: var(--space-1);
padding: var(--space-2);
.summary-chip {
font-size: 0.75rem;
}
}
.panes-container {
flex-direction: column;
overflow: visible;
}
.pane {
border-right: none;
border-bottom: 1px solid var(--color-border-primary);
}
.pane:last-child {
border-bottom: none;
}
.categories-pane,
.items-pane,
.evidence-pane {
width: 100%;
}
.evidence-pane .side-by-side {
grid-template-columns: 1fr;
}
}
.empty-state {
display: flex;
flex-direction: column;

View File

@@ -31,7 +31,7 @@ interface Deployment {
</div>
</header>
<div class="table-container">
<div class="table-container table-container--desktop">
<table class="data-table">
<thead>
<tr>
@@ -76,6 +76,53 @@ interface Deployment {
</tbody>
</table>
</div>
<div class="deployment-cards" aria-label="Deployment cards">
@for (deployment of deployments(); track deployment.id) {
<article class="deployment-card">
<header class="deployment-card__header">
<a [routerLink]="['./', deployment.id]" class="deployment-link deployment-card__id">
{{ deployment.id }}
</a>
<span class="status-badge" [class]="'status-badge--' + deployment.status">
@if (deployment.status === 'running') {
<span class="spinner"></span>
}
{{ deployment.status | uppercase }}
</span>
</header>
<dl class="deployment-card__meta">
<div>
<dt>Release</dt>
<dd>
<a [routerLink]="['/releases', deployment.releaseVersion]">{{ deployment.releaseVersion }}</a>
</dd>
</div>
<div>
<dt>Environment</dt>
<dd>{{ deployment.environment }}</dd>
</div>
<div>
<dt>Started</dt>
<dd>{{ deployment.startedAt }}</dd>
</div>
<div>
<dt>Duration</dt>
<dd>{{ deployment.duration }}</dd>
</div>
<div>
<dt>Initiated By</dt>
<dd>{{ deployment.initiatedBy }}</dd>
</div>
</dl>
<div class="deployment-card__actions">
<a [routerLink]="['./', deployment.id]" class="btn btn--sm">View Deployment</a>
</div>
</article>
}
</div>
</div>
`,
styles: [`
@@ -88,9 +135,10 @@ interface Deployment {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
overflow: hidden;
overflow-x: auto;
overflow-y: hidden;
}
.data-table { width: 100%; border-collapse: collapse; }
.data-table { width: 100%; min-width: 840px; border-collapse: collapse; }
.data-table th, .data-table td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid var(--color-border-primary); }
.data-table th { background: var(--color-surface-secondary); font-size: 0.75rem; font-weight: var(--font-weight-semibold); color: var(--color-text-secondary); text-transform: uppercase; }
.data-table tbody tr:hover { background: var(--color-nav-hover); }
@@ -115,6 +163,74 @@ interface Deployment {
@keyframes spin { to { transform: rotate(360deg); } }
.btn { padding: 0.25rem 0.5rem; border-radius: var(--radius-md); font-size: 0.75rem; font-weight: var(--font-weight-medium); text-decoration: none; background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); color: var(--color-text-primary); }
.deployment-cards {
display: none;
gap: 0.6rem;
}
.deployment-card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.7rem;
display: grid;
gap: 0.6rem;
}
.deployment-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.deployment-card__id {
font-size: 0.84rem;
}
.deployment-card__meta {
margin: 0;
display: grid;
gap: 0.35rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.deployment-card__meta div {
display: grid;
gap: 0.1rem;
}
.deployment-card__meta dt {
margin: 0;
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-secondary);
}
.deployment-card__meta dd {
margin: 0;
font-size: 0.84rem;
color: var(--color-text-primary);
line-height: 1.25;
}
.deployment-card__actions {
display: flex;
justify-content: flex-end;
}
@media (max-width: 768px) {
.deployments-page { max-width: none; }
.page-title { font-size: 2rem; line-height: 1.05; }
.page-subtitle { font-size: 0.95rem; line-height: 1.35; }
.data-table th, .data-table td { padding: 0.625rem 0.75rem; }
.table-container--desktop { display: none; }
.deployment-cards { display: grid; }
.deployment-card__meta { grid-template-columns: 1fr; }
.deployment-card__actions .btn { width: 100%; text-align: center; }
}
`]
})
export class DeploymentsListPageComponent {

View File

@@ -92,5 +92,5 @@ export function getCheckIdsForStep(stepId: SetupStepId): string[] {
/** Build a deep-link URL to the setup wizard for a specific step in reconfigure mode. */
export function buildWizardDeepLink(stepId: SetupStepId): string {
return `/setup/wizard?step=${stepId}&mode=reconfigure`;
return `/setup-wizard/wizard?step=${stepId}&mode=reconfigure`;
}

View File

@@ -13,22 +13,136 @@ import { RouterLink } from '@angular/router';
<p>Unified operations workspace for platform runtime, policy governance, and integrations.</p>
</header>
<div class="doors">
<a routerLink="/ops/operations">Operations</a>
<a routerLink="/ops/integrations">Integrations</a>
<a routerLink="/ops/policy">Policy</a>
<a routerLink="/ops/platform-setup">Platform Setup</a>
<div class="ops-overview__layout">
<div class="doors">
<a routerLink="/ops/operations">
<strong>Operations</strong>
<span>Scheduler, feeds, health, quotas, and runtime controls.</span>
</a>
<a routerLink="/ops/integrations">
<strong>Integrations</strong>
<span>Connector onboarding, source registration, and delivery channels.</span>
</a>
<a routerLink="/ops/policy">
<strong>Policy</strong>
<span>Governance, simulations, waivers, and gate catalog management.</span>
</a>
<a routerLink="/ops/platform-setup">
<strong>Platform Setup</strong>
<span>Environment bootstrap, topology alignment, and baseline controls.</span>
</a>
</div>
<aside class="ops-overview__aside">
<h2>Operational Focus</h2>
<ul>
<li>Validate feed freshness and advisory import health.</li>
<li>Review scheduler backlog and failed task retries.</li>
<li>Confirm policy package rollout and waiver expiration queue.</li>
</ul>
</aside>
</div>
</section>
`,
styles: [
`
.ops-overview { display: grid; gap: 1rem; }
h1 { margin: 0; font-size: 1.35rem; }
p { margin: 0; color: var(--color-text-secondary); }
.doors { display: grid; gap: 0.5rem; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
.doors a { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md); padding: 0.65rem; text-decoration: none; color: var(--color-text-primary); background: var(--color-surface-primary); }
.doors a:hover { border-color: var(--color-brand-primary); color: var(--color-brand-primary); }
.ops-overview {
display: grid;
gap: 1rem;
}
h1 {
margin: 0;
font-size: 2rem;
line-height: 1.05;
}
p {
margin: 0.3rem 0 0;
color: var(--color-text-secondary);
max-width: 70ch;
}
.ops-overview__layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 1rem;
}
.doors {
display: grid;
gap: 0.7rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.doors a {
display: grid;
gap: 0.35rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
padding: 0.75rem;
text-decoration: none;
color: var(--color-text-primary);
background: var(--color-surface-primary);
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
}
.doors a strong {
font-size: 0.95rem;
line-height: 1.2;
}
.doors a span {
font-size: 0.78rem;
line-height: 1.35;
color: var(--color-text-secondary);
}
.doors a:hover {
border-color: var(--color-brand-primary);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
}
.ops-overview__aside {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.8rem;
}
.ops-overview__aside h2 {
margin: 0 0 0.55rem;
font-size: 0.74rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-text-secondary);
}
.ops-overview__aside ul {
margin: 0;
padding-left: 1rem;
display: grid;
gap: 0.35rem;
}
.ops-overview__aside li {
font-size: 0.82rem;
line-height: 1.35;
color: var(--color-text-secondary);
}
@media (max-width: 1080px) {
.ops-overview__layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 575px) {
h1 {
font-size: 2rem;
}
}
`,
],
})

View File

@@ -3,7 +3,7 @@
* @sprint Sprint 4: UI Wizard Core
* @description Routes for the setup wizard feature.
* The default path shows the config-missing screen (for unconfigured deployments).
* The /setup/wizard path loads the full setup wizard (when config is present).
* The /setup-wizard/wizard path loads the full setup wizard (when config is present).
*/
import { Routes } from '@angular/router';

View File

@@ -1,4 +1,4 @@
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core';
import { RouterOutlet } from '@angular/router';
@@ -6,6 +6,7 @@ import { AppTopbarComponent } from '../app-topbar/app-topbar.component';
import { AppSidebarComponent } from '../app-sidebar';
import { BreadcrumbComponent } from '../breadcrumb/breadcrumb.component';
import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
import { SidebarPreferenceService } from '../app-sidebar/sidebar-preference.service';
/**
* AppShellComponent - Main application shell with permanent left rail navigation.
@@ -29,16 +30,16 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
OverlayHostComponent
],
template: `
<div class="shell" [class.shell--mobile-open]="mobileMenuOpen()" [class.shell--collapsed]="sidebarCollapsed()">
<div class="shell" [class.shell--mobile-open]="mobileMenuOpen()" [class.shell--collapsed]="sidebarPrefs.sidebarCollapsed()">
<!-- Skip link for accessibility -->
<a class="shell__skip-link" href="#main-content">Skip to main content</a>
<!-- Sidebar -->
<app-sidebar
class="shell__sidebar"
[collapsed]="sidebarCollapsed()"
[collapsed]="sidebarPrefs.sidebarCollapsed()"
(mobileClose)="onMobileSidebarClose()"
(collapseToggle)="onSidebarCollapseToggle()"
(collapseToggle)="sidebarPrefs.toggleSidebar()"
></app-sidebar>
<!-- Main area (topbar + content) -->
@@ -212,12 +213,11 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShellComponent {
readonly sidebarPrefs = inject(SidebarPreferenceService);
/** Whether mobile menu is open */
readonly mobileMenuOpen = signal(false);
/** Whether sidebar is collapsed (icons only) */
readonly sidebarCollapsed = signal(false);
onMobileMenuToggle(): void {
this.mobileMenuOpen.update((v) => !v);
}
@@ -225,8 +225,4 @@ export class AppShellComponent {
onMobileSidebarClose(): void {
this.mobileMenuOpen.set(false);
}
onSidebarCollapseToggle(): void {
this.sidebarCollapsed.update((v) => !v);
}
}

View File

@@ -39,6 +39,25 @@ describe('AppSidebarComponent', () => {
expect(text).not.toContain('Analytics');
});
it('starts edge auto-scroll animation only when pointer enters edge zone', () => {
setScopes([StellaOpsScopes.UI_READ]);
const rafSpy = spyOn(window, 'requestAnimationFrame').and.returnValue(1);
const cancelSpy = spyOn(window, 'cancelAnimationFrame');
const fixture = createComponent();
const nav = fixture.nativeElement.querySelector('.sidebar__nav') as HTMLElement;
expect(nav).toBeTruthy();
expect(rafSpy).not.toHaveBeenCalled();
spyOn(nav, 'getBoundingClientRect').and.returnValue(new DOMRect(0, 0, 320, 480));
nav.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientY: 5 }));
expect(rafSpy).toHaveBeenCalledTimes(1);
nav.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }));
expect(cancelSpy).toHaveBeenCalledWith(1);
});
function setScopes(scopes: readonly StellaOpsScope[]): void {
const baseUser = authService.user();
if (!baseUser) {

View File

@@ -23,6 +23,7 @@ import type { ApprovalApi } from '../../core/api/approval.client';
import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
import { DoctorTrendService } from '../../core/doctor/doctor-trend.service';
import { SidebarPreferenceService } from './sidebar-preference.service';
/**
* Navigation structure for the shell.
@@ -33,6 +34,8 @@ export interface NavSection {
label: string;
icon: string;
route: string;
menuGroupId?: string;
menuGroupLabel?: string;
badge$?: () => number | null;
sparklineData$?: () => number[];
children?: NavItem[];
@@ -40,19 +43,31 @@ export interface NavSection {
requireAnyScope?: readonly StellaOpsScope[];
}
interface DisplayNavSection extends NavSection {
sectionBadge: number | null;
displayChildren: NavItem[];
}
interface NavSectionGroup {
id: string;
label: string;
sections: DisplayNavSection[];
}
/**
* AppSidebarComponent - Permanent dark left navigation rail.
*
* Design: Always-visible 240px dark sidebar. Never collapses.
* Design: Always-visible 240px dark sidebar, collapsible to 56px icon rail.
* Dark charcoal background with amber/gold accents.
* All nav groups are always expanded. Mouse-proximity auto-scroll near edges.
* Collapsible nav groups and foldable sections with smooth CSS grid animation.
* Mouse-proximity auto-scroll near edges.
*/
@Component({
selector: 'app-sidebar',
standalone: true,
imports: [
SidebarNavItemComponent
],
SidebarNavItemComponent,
],
template: `
<aside
class="sidebar"
@@ -73,65 +88,116 @@ export interface NavSection {
</svg>
</button>
<!-- Navigation - sections with vertical rail labels -->
<!-- Navigation - collapsible groups with foldable sections -->
<nav class="sidebar__nav" #sidebarNav>
@for (section of displaySections(); track section.id; let first = $first) {
@if (!section.displayChildren.length) {
<!-- Root item (no children, e.g. Dashboard) -->
@if (!first) {
<div class="nav-section__divider"></div>
@for (group of displaySectionGroups(); track group.id) {
<div
class="sb-group"
[class.sb-group--collapsed]="!collapsed && sidebarPrefs.collapsedGroups().has(group.id)"
role="group"
[attr.aria-label]="group.label"
>
@if (!collapsed) {
<button
type="button"
class="sb-group__header"
(click)="sidebarPrefs.toggleGroup(group.id)"
[attr.aria-expanded]="!sidebarPrefs.collapsedGroups().has(group.id)"
[attr.aria-controls]="'nav-grp-' + group.id"
>
<span class="sb-group__title">{{ group.label }}</span>
<svg class="sb-group__chevron" viewBox="0 0 16 16" width="10" height="10" aria-hidden="true">
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
}
<app-sidebar-nav-item
[label]="section.label"
[icon]="section.icon"
[route]="section.route"
[badge]="section.sectionBadge"
[collapsed]="collapsed"
></app-sidebar-nav-item>
} @else {
<!-- Section group with children -->
<div class="nav-section">
@if (!first) {
<div class="nav-section__divider"></div>
}
<div class="nav-section__body">
@if (!collapsed) {
<div class="nav-section__rail" aria-hidden="true">
<div class="nav-section__rail-line"></div>
<span class="nav-section__rail-label">{{ section.label }}</span>
</div>
}
<app-sidebar-nav-item
[label]="section.label"
[icon]="section.icon"
[route]="section.route"
[badge]="section.sectionBadge"
[collapsed]="collapsed"
></app-sidebar-nav-item>
@for (child of section.displayChildren; track child.id) {
<app-sidebar-nav-item
[label]="child.label"
[icon]="child.icon"
[route]="child.route"
[badge]="child.badge ?? null"
[isChild]="true"
[collapsed]="collapsed"
></app-sidebar-nav-item>
<div class="sb-group__body" [id]="'nav-grp-' + group.id">
<div class="sb-group__body-inner">
@for (section of group.sections; track section.id; let sectionFirst = $first) {
@if (!collapsed && !sectionFirst) {
<div class="sb-divider"></div>
}
@if (!collapsed && section.displayChildren.length > 0) {
<!-- Section with foldable children -->
<div class="sb-section" [class.sb-section--folded]="sidebarPrefs.collapsedSections().has(section.id)">
<div class="sb-section__head">
<app-sidebar-nav-item
[label]="section.label"
[icon]="section.icon"
[route]="section.route"
[badge]="section.sectionBadge"
[collapsed]="collapsed"
></app-sidebar-nav-item>
<button
type="button"
class="sb-section__fold"
(click)="sidebarPrefs.toggleSection(section.id)"
[attr.aria-expanded]="!sidebarPrefs.collapsedSections().has(section.id)"
[attr.aria-label]="(sidebarPrefs.collapsedSections().has(section.id) ? 'Expand ' : 'Collapse ') + section.label"
>
<svg class="sb-section__fold-icon" viewBox="0 0 16 16" width="12" height="12" aria-hidden="true">
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="sb-section__body">
<div class="sb-section__body-inner">
@for (child of section.displayChildren; track child.id) {
<app-sidebar-nav-item
[label]="child.label"
[icon]="child.icon"
[route]="child.route"
[badge]="child.badge ?? null"
[isChild]="true"
[collapsed]="collapsed"
></app-sidebar-nav-item>
}
</div>
</div>
</div>
} @else {
<!-- Section without children (or collapsed sidebar) -->
<app-sidebar-nav-item
[label]="section.label"
[icon]="section.icon"
[route]="section.route"
[badge]="section.sectionBadge"
[collapsed]="collapsed"
></app-sidebar-nav-item>
}
}
</div>
</div>
}
</div>
}
</nav>
<!-- Footer -->
<div class="sidebar__footer">
<button
type="button"
class="sidebar__collapse-btn"
(click)="collapseToggle.emit()"
[attr.title]="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
[attr.aria-label]="collapsed ? 'Expand sidebar' : 'Collapse sidebar'"
>
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
@if (collapsed) {
<polyline points="9 18 15 12 9 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
} @else {
<polyline points="15 18 9 12 15 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
}
</svg>
</button>
<div class="sidebar__footer-divider"></div>
<span class="sidebar__version">Stella Ops v1.0.0-alpha</span>
</div>
</aside>
`,
styles: [`
/* ================================================================
Sidebar shell
================================================================ */
.sidebar {
display: flex;
flex-direction: column;
@@ -149,7 +215,6 @@ export interface NavSection {
width: 56px;
}
/* Subtle inner glow along right edge */
.sidebar::after {
content: '';
position: absolute;
@@ -160,7 +225,17 @@ export interface NavSection {
background: var(--color-sidebar-border);
}
/* ---- Mobile close ---- */
/* Mobile: always full width regardless of collapsed state */
@media (max-width: 991px) {
.sidebar,
.sidebar.sidebar--collapsed {
width: 280px;
}
}
/* ================================================================
Mobile close
================================================================ */
.sidebar__close {
display: none;
position: absolute;
@@ -189,12 +264,14 @@ export interface NavSection {
}
}
/* ---- Nav ---- */
/* ================================================================
Scrollable nav area
================================================================ */
.sidebar__nav {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 0.5rem 0.5rem;
padding: 0.25rem 0.375rem;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.08) transparent;
@@ -210,71 +287,212 @@ export interface NavSection {
}
}
/* ---- Collapsed nav ---- */
.sidebar--collapsed .sidebar__nav {
padding: 0.5rem 0.25rem;
padding: 0.25rem 0.125rem;
}
/* ---- Section groups ---- */
.nav-section__divider {
/* ================================================================
Nav group (collapsible section cluster)
================================================================ */
.sb-group {
&:not(:first-child) {
margin-top: 0.125rem;
padding-top: 0.25rem;
border-top: 1px solid var(--color-sidebar-divider);
}
}
/* ---- Group header (toggle button) ---- */
.sb-group__header {
display: flex;
align-items: center;
width: calc(100% - 0.25rem);
margin: 0 0.125rem 0.125rem;
padding: 0.3125rem 0.5rem;
border: none;
border-radius: 4px;
background: transparent;
color: var(--color-sidebar-text-muted);
cursor: pointer;
font-family: inherit;
font-size: 0;
transition: color 0.15s, background 0.15s;
&:hover {
color: var(--color-sidebar-text-heading);
background: rgba(255, 255, 255, 0.03);
}
&:focus-visible {
outline: 1px solid var(--color-sidebar-active-border);
outline-offset: -1px;
}
}
.sb-group__title {
flex: 1;
font-size: 0.5625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
line-height: 1;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---- Group chevron (rotates on collapse) ---- */
.sb-group__chevron {
flex-shrink: 0;
opacity: 0.35;
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.15s;
}
.sb-group--collapsed .sb-group__chevron {
transform: rotate(-90deg);
}
.sb-group__header:hover .sb-group__chevron {
opacity: 0.7;
}
/* ---- Animated group body (CSS grid trick) ---- */
.sb-group__body {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.sb-group--collapsed .sb-group__body {
grid-template-rows: 0fr;
}
.sb-group__body-inner {
overflow: hidden;
}
/* ================================================================
Sections within a group (foldable children)
================================================================ */
.sb-divider {
height: 1px;
background: var(--color-sidebar-divider);
margin: 0.5rem 0.75rem;
margin: 0.25rem 0.75rem;
}
.nav-section__body {
/* Section head: nav-item + fold toggle */
.sb-section__head {
position: relative;
}
.nav-section__rail {
.sb-section__fold {
position: absolute;
left: 2px;
top: 0;
bottom: 0;
width: 14px;
right: 0.375rem;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 1;
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--color-sidebar-text-muted);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
z-index: 2;
&:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--color-sidebar-text-heading);
}
&:focus-visible {
opacity: 1;
outline: 1px solid var(--color-sidebar-active-border);
}
}
.nav-section__rail-line {
position: absolute;
top: 6px;
bottom: 6px;
left: 50%;
width: 1px;
background: rgba(245, 200, 120, 0.12);
transform: translateX(-50%);
/* Show fold button on hover or when section is folded */
.sb-section__head:hover .sb-section__fold,
.sb-section--folded .sb-section__fold {
opacity: 1;
}
.nav-section__rail-label {
position: relative;
z-index: 1;
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 0.5rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--color-sidebar-group-text);
white-space: nowrap;
background: var(--color-sidebar-bg);
padding: 4px 0;
line-height: 1;
.sb-section__fold-icon {
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
/* ---- Footer ---- */
.sb-section--folded .sb-section__fold-icon {
transform: rotate(-90deg);
}
/* Animated section body */
.sb-section__body {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 0.22s cubic-bezier(0.4, 0, 0.2, 1);
}
.sb-section--folded .sb-section__body {
grid-template-rows: 0fr;
}
.sb-section__body-inner {
overflow: hidden;
margin-left: 1.25rem;
border-left: 1px solid rgba(245, 166, 35, 0.12);
}
/* ================================================================
Footer with collapse toggle
================================================================ */
.sidebar__footer {
flex-shrink: 0;
padding: 0.5rem 0.75rem;
padding: 0.375rem 0.75rem 0.5rem;
}
.sidebar__collapse-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 28px;
border: 1px solid var(--color-sidebar-divider);
border-radius: 6px;
background: transparent;
color: var(--color-sidebar-text-muted);
cursor: pointer;
margin-bottom: 0.375rem;
transition: color 0.15s, background 0.15s, border-color 0.15s;
&:hover {
color: var(--color-sidebar-text-heading);
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.12);
}
&:focus-visible {
outline: 1px solid var(--color-sidebar-active-border);
outline-offset: -1px;
}
}
/* Hide collapse button on mobile (mobile uses the close X) */
@media (max-width: 991px) {
.sidebar__collapse-btn {
display: none;
}
}
.sidebar__footer-divider {
height: 1px;
background: var(--color-sidebar-divider);
margin-bottom: 0.5rem;
margin-bottom: 0.375rem;
}
.sidebar__version {
@@ -289,7 +507,7 @@ export interface NavSection {
}
.sidebar--collapsed .sidebar__footer {
padding: 0.5rem;
padding: 0.375rem 0.25rem 0.5rem;
}
.sidebar--collapsed .sidebar__version {
@@ -307,6 +525,8 @@ export class AppSidebarComponent implements AfterViewInit {
private readonly doctorTrendService = inject(DoctorTrendService);
private readonly ngZone = inject(NgZone);
readonly sidebarPrefs = inject(SidebarPreferenceService);
@Input() collapsed = false;
@Output() mobileClose = new EventEmitter<void>();
@Output() collapseToggle = new EventEmitter<void>();
@@ -324,6 +544,8 @@ export class AppSidebarComponent implements AfterViewInit {
label: 'Dashboard',
icon: 'dashboard',
route: '/mission-control/board',
menuGroupId: 'release-control',
menuGroupLabel: 'Release Control',
requireAnyScope: [
StellaOpsScopes.UI_READ,
StellaOpsScopes.RELEASE_READ,
@@ -335,6 +557,8 @@ export class AppSidebarComponent implements AfterViewInit {
label: 'Releases',
icon: 'package',
route: '/releases/deployments',
menuGroupId: 'release-control',
menuGroupLabel: 'Release Control',
requireAnyScope: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.RELEASE_WRITE,
@@ -384,6 +608,8 @@ export class AppSidebarComponent implements AfterViewInit {
label: 'Vulnerabilities',
icon: 'shield',
route: '/security',
menuGroupId: 'security-evidence',
menuGroupLabel: 'Security & Evidence',
sparklineData$: () => this.doctorTrendService.securityTrend(),
requireAnyScope: [
StellaOpsScopes.SCANNER_READ,
@@ -406,6 +632,8 @@ export class AppSidebarComponent implements AfterViewInit {
label: 'Evidence',
icon: 'file-text',
route: '/evidence/overview',
menuGroupId: 'security-evidence',
menuGroupLabel: 'Security & Evidence',
requireAnyScope: [
StellaOpsScopes.RELEASE_READ,
StellaOpsScopes.POLICY_AUDIT,
@@ -426,6 +654,8 @@ export class AppSidebarComponent implements AfterViewInit {
label: 'Operations',
icon: 'settings',
route: '/ops/operations',
menuGroupId: 'platform-setup',
menuGroupLabel: 'Platform & Setup',
sparklineData$: () => this.doctorTrendService.platformTrend(),
requireAnyScope: [
StellaOpsScopes.UI_ADMIN,
@@ -445,6 +675,8 @@ export class AppSidebarComponent implements AfterViewInit {
label: 'Setup',
icon: 'server',
route: '/setup/system',
menuGroupId: 'platform-setup',
menuGroupLabel: 'Platform & Setup',
requireAnyScope: [
StellaOpsScopes.UI_ADMIN,
StellaOpsScopes.RELEASE_READ,
@@ -469,14 +701,42 @@ export class AppSidebarComponent implements AfterViewInit {
});
/** Sections with duplicate children removed and badges resolved */
readonly displaySections = computed(() => {
return this.visibleSections().map(section => ({
readonly displaySections = computed<DisplayNavSection[]>(() => {
return this.visibleSections().map((section) => ({
...section,
sectionBadge: section.badge$?.() ?? null,
displayChildren: (section.children ?? []).filter(child => child.route !== section.route),
displayChildren: (section.children ?? []).filter((child) => child.route !== section.route),
}));
});
/** Menu groups rendered in deterministic order for scanability */
readonly displaySectionGroups = computed<NavSectionGroup[]>(() => {
const orderedGroups = new Map<string, NavSectionGroup>();
const groupOrder = ['release-control', 'security-evidence', 'platform-setup', 'misc'];
for (const groupId of groupOrder) {
orderedGroups.set(groupId, {
id: groupId,
label: this.resolveMenuGroupLabel(groupId),
sections: [],
});
}
for (const section of this.displaySections()) {
const groupId = section.menuGroupId ?? 'misc';
const group = orderedGroups.get(groupId) ?? {
id: groupId,
label: section.menuGroupLabel ?? this.resolveMenuGroupLabel(groupId),
sections: [],
};
group.sections.push(section);
orderedGroups.set(groupId, group);
}
return Array.from(orderedGroups.values()).filter((group) => group.sections.length > 0);
});
constructor() {
this.loadPendingApprovalsBadge();
this.router.events
@@ -510,10 +770,6 @@ export class AppSidebarComponent implements AfterViewInit {
.map((child) => this.filterItem(child))
.filter((child): child is NavItem => child !== null);
if (visibleChildren.length === 0) {
return null;
}
const children = visibleChildren.map((child) => this.withDynamicChildState(child));
return {
@@ -522,6 +778,23 @@ export class AppSidebarComponent implements AfterViewInit {
};
}
private resolveMenuGroupLabel(groupId: string): string {
switch (groupId) {
case 'release-control':
return 'Release Control';
case 'security-evidence':
return 'Security & Evidence';
case 'platform-setup':
return 'Platform & Setup';
default:
return 'Global Menu';
}
}
groupRoute(group: NavSectionGroup): string {
return group.sections[0]?.route ?? '/mission-control/board';
}
private withDynamicChildState(item: NavItem): NavItem {
if (item.id !== 'rel-approvals') {
return item;
@@ -579,14 +852,34 @@ export class AppSidebarComponent implements AfterViewInit {
const EDGE_ZONE = 60;
const MAX_SPEED = 8;
let scrollDirection = 0; // -1 = up, 0 = none, 1 = down
let animFrameId = 0;
let animFrameId: number | null = null;
let paused = false;
const scrollLoop = () => {
if (scrollDirection !== 0 && !paused) {
navEl.scrollTop += scrollDirection;
const stopScrollLoop = () => {
if (animFrameId === null) {
return;
}
animFrameId = requestAnimationFrame(scrollLoop);
cancelAnimationFrame(animFrameId);
animFrameId = null;
};
const runScrollLoop = () => {
if (animFrameId !== null) {
return;
}
const tick = () => {
if (scrollDirection === 0 || paused) {
animFrameId = null;
return;
}
navEl.scrollTop += scrollDirection;
animFrameId = requestAnimationFrame(tick);
};
animFrameId = requestAnimationFrame(tick);
};
const onMouseMove = (e: MouseEvent) => {
@@ -595,7 +888,7 @@ export class AppSidebarComponent implements AfterViewInit {
// Check if hovering a nav item (pause scrolling)
const target = e.target as HTMLElement;
paused = !!(target.closest('.nav-item') || target.closest('.nav-group__header'));
paused = !!(target.closest('.nav-item') || target.closest('.sb-group__header'));
const distFromTop = mouseY - rect.top;
const distFromBottom = rect.bottom - mouseY;
@@ -609,22 +902,28 @@ export class AppSidebarComponent implements AfterViewInit {
} else {
scrollDirection = 0;
}
if (scrollDirection !== 0 && !paused) {
runScrollLoop();
} else {
stopScrollLoop();
}
};
const onMouseLeave = () => {
scrollDirection = 0;
paused = false;
stopScrollLoop();
};
// Run outside Angular zone to avoid unnecessary change detection
this.ngZone.runOutsideAngular(() => {
navEl.addEventListener('mousemove', onMouseMove);
navEl.addEventListener('mouseleave', onMouseLeave);
animFrameId = requestAnimationFrame(scrollLoop);
});
this.destroyRef.onDestroy(() => {
cancelAnimationFrame(animFrameId);
stopScrollLoop();
navEl.removeEventListener('mousemove', onMouseMove);
navEl.removeEventListener('mouseleave', onMouseLeave);
});

View File

@@ -0,0 +1,82 @@
import { Injectable, signal, computed, effect } from '@angular/core';
export interface SidebarPreferences {
sidebarCollapsed: boolean;
collapsedGroups: string[];
collapsedSections: string[];
}
const STORAGE_KEY = 'stellaops.sidebar.preferences';
const DEFAULTS: SidebarPreferences = {
sidebarCollapsed: false,
collapsedGroups: [],
collapsedSections: ['ops', 'setup'],
};
@Injectable({ providedIn: 'root' })
export class SidebarPreferenceService {
private readonly _state = signal<SidebarPreferences>(this.load());
readonly sidebarCollapsed = computed(() => this._state().sidebarCollapsed);
readonly collapsedGroups = computed(() => new Set(this._state().collapsedGroups));
readonly collapsedSections = computed(() => new Set(this._state().collapsedSections));
constructor() {
effect(() => this.save(this._state()));
}
toggleSidebar(): void {
this._state.update((s) => ({ ...s, sidebarCollapsed: !s.sidebarCollapsed }));
}
toggleGroup(groupId: string): void {
this._state.update((s) => {
const set = new Set(s.collapsedGroups);
if (set.has(groupId)) set.delete(groupId);
else set.add(groupId);
return { ...s, collapsedGroups: [...set] };
});
}
toggleSection(sectionId: string): void {
this._state.update((s) => {
const set = new Set(s.collapsedSections);
if (set.has(sectionId)) set.delete(sectionId);
else set.add(sectionId);
return { ...s, collapsedSections: [...set] };
});
}
private load(): SidebarPreferences {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return {
sidebarCollapsed:
typeof parsed.sidebarCollapsed === 'boolean'
? parsed.sidebarCollapsed
: DEFAULTS.sidebarCollapsed,
collapsedGroups: Array.isArray(parsed.collapsedGroups)
? parsed.collapsedGroups
: DEFAULTS.collapsedGroups,
collapsedSections: Array.isArray(parsed.collapsedSections)
? parsed.collapsedSections
: DEFAULTS.collapsedSections,
};
}
} catch {
/* ignore parse errors */
}
return { ...DEFAULTS };
}
private save(state: SidebarPreferences): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
/* ignore quota / private-mode errors */
}
}
}

View File

@@ -78,13 +78,28 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
<a class="topbar__primary-action" [routerLink]="action.route">{{ action.label }}</a>
}
<button
type="button"
class="topbar__context-toggle"
[class.topbar__context-toggle--active]="mobileContextOpen()"
[attr.aria-expanded]="mobileContextOpen()"
aria-controls="topbar-context-row"
(click)="toggleMobileContext()"
>
Context
</button>
<!-- User menu -->
<app-user-menu></app-user-menu>
</div>
</div>
<!-- Row 2: Tenant (if multi) + Context chips + Locale -->
<div class="topbar__row topbar__row--secondary">
<div
id="topbar-context-row"
class="topbar__row topbar__row--secondary"
[class.topbar__row--secondary-open]="mobileContextOpen()"
>
<!-- Tenant selector (only shown when multiple tenants) -->
@if (showTenantSelector()) {
<div class="topbar__tenant">
@@ -198,6 +213,14 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
.topbar__row--secondary {
padding: 0 0.5rem;
display: none;
}
.topbar__row--secondary-open {
display: flex;
height: auto;
padding: 0.35rem 0.5rem;
overflow: visible;
}
}
@@ -312,6 +335,34 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
border-color: var(--color-brand-primary);
}
.topbar__context-toggle {
display: none;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
height: 28px;
padding: 0 0.45rem;
background: var(--color-surface-primary);
color: var(--color-text-secondary);
font-size: 0.625rem;
font-family: var(--font-family-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
cursor: pointer;
transition: border-color 0.12s, color 0.12s, background 0.12s;
}
.topbar__context-toggle:hover {
border-color: var(--color-brand-primary);
color: var(--color-text-primary);
background: var(--color-surface-secondary);
}
.topbar__context-toggle--active {
border-color: var(--color-brand-primary);
background: color-mix(in srgb, var(--color-brand-primary) 14%, var(--color-surface-primary));
color: var(--color-brand-primary);
}
@media (max-width: 575px) {
.topbar__right {
gap: 0.25rem;
@@ -320,6 +371,12 @@ import { I18nService, UserLocalePreferenceService } from '../../core/i18n';
.topbar__primary-action {
display: none;
}
.topbar__context-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
}
}
/* ---- Row 2 separator ---- */
@@ -495,6 +552,7 @@ export class AppTopbarComponent {
);
readonly localePreferenceSyncAttempted = signal(false);
readonly scopePanelOpen = signal(false);
readonly mobileContextOpen = signal(false);
readonly tenantPanelOpen = signal(false);
readonly tenantSwitchInFlight = signal(false);
readonly tenantBootstrapAttempted = signal(false);
@@ -551,6 +609,10 @@ export class AppTopbarComponent {
this.scopePanelOpen.update((open) => !open);
}
toggleMobileContext(): void {
this.mobileContextOpen.update((open) => !open);
}
closeScopePanel(): void {
this.scopePanelOpen.set(false);
}
@@ -654,6 +716,7 @@ export class AppTopbarComponent {
onEscape(): void {
this.closeScopePanel();
this.closeTenantPanel();
this.mobileContextOpen.set(false);
}
@HostListener('document:click', ['$event'])
@@ -666,12 +729,17 @@ export class AppTopbarComponent {
const host = this.elementRef.nativeElement;
const insideScope = host.querySelector('.topbar__scope-wrap')?.contains(target) ?? false;
const insideTenant = host.querySelector('.topbar__tenant')?.contains(target) ?? false;
const insideContextToggle = host.querySelector('.topbar__context-toggle')?.contains(target) ?? false;
const insideContextRow = host.querySelector('.topbar__row--secondary')?.contains(target) ?? false;
if (!insideScope) {
this.closeScopePanel();
}
if (!insideTenant) {
this.closeTenantPanel();
}
if (!insideContextToggle && !insideContextRow) {
this.mobileContextOpen.set(false);
}
}
private async loadTenantContextIfNeeded(): Promise<void> {

View File

@@ -381,6 +381,40 @@ interface DropdownOption {
font-size: 0.625rem;
color: var(--color-status-error-text);
}
@media (max-width: 575px) {
.ctx {
width: 100%;
align-items: flex-start;
}
.ctx__controls {
flex-wrap: wrap;
gap: 0.2rem;
}
.ctx__dropdown-btn {
height: 22px;
padding: 0 0.35rem;
}
.ctx__dropdown-key {
display: none;
}
.ctx__dropdown-val {
max-width: 82px;
font-size: 0.58rem;
}
.ctx__sep {
display: none;
}
.ctx__chips {
display: none;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})

View File

@@ -1,8 +1,9 @@
{
"_meta": {
"locale": "bg-BG",
"description": "Offline fallback bundle for StellaOps Console"
"description": "Offline fallback bundle for Stella Ops Console"
},
"ui.branding.app_name": "пакет \"Стелла\"",
"common.error.generic": "Нещо се обърка.",
"common.error.not_found": "Заявеният ресурс не беше намерен.",
"common.error.unauthorized": "Нямате права да извършите това действие.",

View File

@@ -1,8 +1,9 @@
{
"_meta": {
"locale": "de-DE",
"description": "Offline fallback bundle for StellaOps Console"
"description": "Offline fallback bundle for Stella Ops Console"
},
"ui.branding.app_name": "Stella Ops",
"common.error.generic": "Etwas ist schiefgelaufen.",
"common.error.not_found": "Die angeforderte Ressource wurde nicht gefunden.",
"common.error.unauthorized": "Sie haben keine Berechtigung, diese Aktion auszuführen.",

View File

@@ -1,8 +1,9 @@
{
"_meta": {
"locale": "en-US",
"description": "Offline fallback bundle for StellaOps Console"
"description": "Offline fallback bundle for Stella Ops Console"
},
"ui.branding.app_name": "Stella Ops",
"common.error.generic": "Something went wrong.",
"common.error.not_found": "The requested resource was not found.",
"common.error.unauthorized": "You do not have permission to perform this action.",

View File

@@ -1,8 +1,9 @@
{
"_meta": {
"locale": "es-ES",
"description": "Offline fallback bundle for StellaOps Console"
"description": "Offline fallback bundle for Stella Ops Console"
},
"ui.branding.app_name": "Stella Ops",
"common.error.generic": "Algo salió mal.",
"common.error.not_found": "No se encontró el recurso solicitado.",
"common.error.unauthorized": "No tienes permiso para realizar esta acción.",

View File

@@ -1,8 +1,9 @@
{
"_meta": {
"locale": "fr-FR",
"description": "Offline fallback bundle for StellaOps Console"
"description": "Offline fallback bundle for Stella Ops Console"
},
"ui.branding.app_name": "Stella Ops",
"common.error.generic": "Quelque chose s'est mal passé.",
"common.error.not_found": "La ressource demandée n'a pas été trouvée.",
"common.error.unauthorized": "Vous n'êtes pas autorisé à effectuer cette action.",

View File

@@ -3,7 +3,7 @@
"_meta": {
"version": "1.0",
"locale": "en-US",
"description": "Micro-interaction copy for StellaOps Console (MI9)"
"description": "Micro-interaction copy for Stella Ops Console (MI9)"
},
"loading": {
"skeleton": "Loading...",

View File

@@ -1,8 +1,9 @@
{
"_meta": {
"locale": "ru-RU",
"description": "Offline fallback bundle for StellaOps Console"
"description": "Offline fallback bundle for Stella Ops Console"
},
"ui.branding.app_name": "пакет \"Стелла\"",
"common.error.generic": "Что-то пошло не так.",
"common.error.not_found": "Запрошенный ресурс не найден.",
"common.error.unauthorized": "У вас нет разрешения на выполнение этого действия.",

View File

@@ -1,8 +1,9 @@
{
"_meta": {
"locale": "uk-UA",
"description": "Offline fallback bundle for StellaOps Console"
"description": "Offline fallback bundle for Stella Ops Console"
},
"ui.branding.app_name": "пакет \"Стелла\"",
"common.error.generic": "Щось пішло не так.",
"common.error.not_found": "Потрібний ресурс не знайдено.",
"common.error.unauthorized": "Ви не маєте дозволу на виконання цієї дії.",

View File

@@ -1,8 +1,9 @@
{
"_meta": {
"locale": "zh-CN",
"description": "Offline fallback bundle for StellaOps Console"
"description": "Offline fallback bundle for Stella Ops Console"
},
"ui.branding.app_name": "Stella Ops",
"common.error.generic": "出了点问题。",
"common.error.not_found": "未找到请求的资源。",
"common.error.unauthorized": "您没有执行此操作的权限。",

View File

@@ -1,8 +1,9 @@
{
"_meta": {
"locale": "zh-TW",
"description": "Offline fallback bundle for StellaOps Console"
"description": "Offline fallback bundle for Stella Ops Console"
},
"ui.branding.app_name": "Stella Ops",
"common.error.generic": "出了點問題。",
"common.error.not_found": "未找到請求的資源。",
"common.error.unauthorized": "您沒有執行此操作的權限。",

View File

@@ -1,6 +1,6 @@
{
"name": "Stella Ops",
"short_name": "StellaOps",
"short_name": "Stella Ops",
"description": "Release control plane for container estates",
"start_url": "/",
"display": "standalone",

View File

@@ -30,8 +30,8 @@
// ---------------------------------------------------------------------------
--color-text-primary: #3D2E0A;
--color-text-heading: #1C1200;
--color-text-secondary: #6B5A2E;
--color-text-muted: #9A8F78;
--color-text-secondary: #5A4A24;
--color-text-muted: #756949;
--color-text-inverse: #F5F0E6;
--color-text-link: #D4920A;
--color-text-link-hover: #F5A623;
@@ -39,8 +39,8 @@
// ---------------------------------------------------------------------------
// Border Colors
// ---------------------------------------------------------------------------
--color-border-primary: rgba(212, 201, 168, 0.3);
--color-border-secondary: rgba(212, 201, 168, 0.5);
--color-border-primary: rgba(176, 160, 118, 0.52);
--color-border-secondary: rgba(176, 160, 118, 0.72);
--color-border-focus: #F5A623;
--color-border-error: #ef4444;

View File

@@ -92,15 +92,15 @@ describe('DoctorWizardMapping', () => {
describe('buildWizardDeepLink', () => {
it('should build correct deep link for database', () => {
expect(buildWizardDeepLink('database')).toBe('/setup/wizard?step=database&mode=reconfigure');
expect(buildWizardDeepLink('database')).toBe('/setup-wizard/wizard?step=database&mode=reconfigure');
});
it('should build correct deep link for authority', () => {
expect(buildWizardDeepLink('authority')).toBe('/setup/wizard?step=authority&mode=reconfigure');
expect(buildWizardDeepLink('authority')).toBe('/setup-wizard/wizard?step=authority&mode=reconfigure');
});
it('should build correct deep link for telemetry', () => {
expect(buildWizardDeepLink('telemetry')).toBe('/setup/wizard?step=telemetry&mode=reconfigure');
expect(buildWizardDeepLink('telemetry')).toBe('/setup-wizard/wizard?step=telemetry&mode=reconfigure');
});
});
});

View File

@@ -172,13 +172,14 @@ test.describe('Nav shell canonical domains', () => {
const navText = (await page.locator('aside.sidebar').textContent()) ?? '';
const labels = [
'Mission Board',
'Mission Alerts',
'Mission Activity',
'Release Control',
'Security & Evidence',
'Platform & Setup',
'Dashboard',
'Releases',
'Security',
'Vulnerabilities',
'Evidence',
'Ops',
'Operations',
'Setup',
];
@@ -198,6 +199,45 @@ test.describe('Nav shell canonical domains', () => {
expect(navText).not.toContain('Administration');
expect(navText).not.toContain('Policy Studio');
});
test('group headers are unique and navigate to group landing routes', async ({ page }) => {
await go(page, '/mission-control/board');
await ensureShell(page);
const releaseGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Release Control' });
const securityGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Security & Evidence' });
const platformGroup = page.locator('aside.sidebar .nav-group__label', { hasText: 'Platform & Setup' });
await expect(releaseGroup).toHaveCount(1);
await expect(securityGroup).toHaveCount(1);
await expect(platformGroup).toHaveCount(1);
await releaseGroup.click();
await expect(page).toHaveURL(/\/mission-control\/board$/);
await securityGroup.click();
await expect(page).toHaveURL(/\/security(\/|$)/);
await platformGroup.click();
await expect(page).toHaveURL(/\/ops(\/|$)/);
});
test('grouped root entries navigate when clicked', async ({ page }) => {
await go(page, '/mission-control/board');
await ensureShell(page);
await page.locator('aside.sidebar a.nav-item', { hasText: 'Releases' }).first().click();
await expect(page).toHaveURL(/\/releases\/deployments$/);
await page.locator('aside.sidebar a.nav-item', { hasText: 'Vulnerabilities' }).first().click();
await expect(page).toHaveURL(/\/security(\/|$)/);
await page.locator('aside.sidebar a.nav-item', { hasText: 'Evidence' }).first().click();
await expect(page).toHaveURL(/\/evidence\/overview$/);
await page.locator('aside.sidebar a.nav-item', { hasText: 'Operations' }).first().click();
await expect(page).toHaveURL(/\/ops\/operations$/);
});
});
test.describe('Nav shell breadcrumbs and stability', () => {

View File

@@ -21,7 +21,7 @@ export const mockConfig = {
redirectUri: 'http://127.0.0.1:4400/auth/callback',
postLogoutRedirectUri: 'http://127.0.0.1:4400/',
scope:
'openid profile email ui.read advisory:search advisory:read search:read findings:read vex:read policy:read health:read',
'openid profile email ui.read advisory:read advisory-ai:view advisory-ai:operate findings:read vex:read policy:read health:read',
audience: 'https://scanner.local',
dpopAlgorithms: ['ES256'],
refreshLeewaySeconds: 60,
@@ -55,9 +55,9 @@ export const shellSession = {
...policyAuthorSession.scopes,
'ui.read',
'admin',
'advisory:search',
'advisory:read',
'search:read',
'advisory-ai:view',
'advisory-ai:operate',
'findings:read',
'vex:read',
'policy:read',