texts fixes, search bar fixes, global menu fixes.
This commit is contained in:
@@ -138,6 +138,9 @@ docker compose -f docker-compose.stella-ops.yml up -d
|
||||
# Reverse-proxy fallback mode (no route-table edits required)
|
||||
ROUTER_GATEWAY_CONFIG=./router-gateway-local.reverseproxy.json \
|
||||
docker compose -f docker-compose.stella-ops.yml up -d
|
||||
|
||||
# Optional: mode switch helper with health recovery + header-search smoke checks
|
||||
pwsh ./scripts/router-mode-redeploy.ps1 -Mode microservice
|
||||
```
|
||||
|
||||
Validation endpoints:
|
||||
@@ -148,6 +151,9 @@ curl -k https://127.1.0.1/openapi.json
|
||||
|
||||
# Timeline API schema (through router-gateway)
|
||||
curl -k https://127.1.0.1/openapi.json | jq '.paths["/api/v1/timeline"]'
|
||||
|
||||
# Header search routing smoke (fails on missing /api/v1/search* or /api/v1/advisory-ai/search* routes)
|
||||
pwsh ./scripts/header-search-smoke.ps1
|
||||
```
|
||||
|
||||
### With Observability
|
||||
|
||||
@@ -347,10 +347,10 @@ services:
|
||||
Logging__LogLevel__Microsoft.AspNetCore.Authorization: "Debug"
|
||||
Platform__Storage__Driver: "postgres"
|
||||
Platform__Storage__PostgresConnectionString: *postgres-connection
|
||||
Platform__EnvironmentSettings__AuthorizeEndpoint: "https://127.1.0.1/connect/authorize"
|
||||
Platform__EnvironmentSettings__TokenEndpoint: "https://127.1.0.1/connect/token"
|
||||
Platform__EnvironmentSettings__RedirectUri: "https://127.1.0.1/auth/callback"
|
||||
Platform__EnvironmentSettings__PostLogoutRedirectUri: "https://127.1.0.1/"
|
||||
Platform__EnvironmentSettings__AuthorizeEndpoint: "https://stella-ops.local/connect/authorize"
|
||||
Platform__EnvironmentSettings__TokenEndpoint: "https://stella-ops.local/connect/token"
|
||||
Platform__EnvironmentSettings__RedirectUri: "https://stella-ops.local/auth/callback"
|
||||
Platform__EnvironmentSettings__PostLogoutRedirectUri: "https://stella-ops.local/"
|
||||
Platform__EnvironmentSettings__Scope: "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate timeline:read timeline:write"
|
||||
STELLAOPS_ROUTER_URL: "http://router.stella-ops.local"
|
||||
STELLAOPS_PLATFORM_URL: "http://platform.stella-ops.local"
|
||||
@@ -1190,8 +1190,8 @@ services:
|
||||
AirGap__Egress__Enabled: "false"
|
||||
volumes:
|
||||
- *cert-volume
|
||||
- taskrunner-artifacts-data:/app/artifacts
|
||||
tmpfs:
|
||||
- /app/artifacts:mode=1777
|
||||
- /app/queue:mode=1777
|
||||
- /app/state:mode=1777
|
||||
- /app/approvals:mode=1777
|
||||
@@ -2208,6 +2208,9 @@ services:
|
||||
ADVISORYAI__AdvisoryAI__Inference__Mode: "${ADVISORY_AI_INFERENCE_MODE:-Local}"
|
||||
ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "${ADVISORY_AI_REMOTE_BASEADDRESS:-}"
|
||||
ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "${ADVISORY_AI_REMOTE_APIKEY:-}"
|
||||
ADVISORYAI__KnowledgeSearch__ConnectionString: *postgres-connection
|
||||
ADVISORYAI__KnowledgeSearch__FindingsAdapterEnabled: "true"
|
||||
ADVISORYAI__KnowledgeSearch__FindingsAdapterBaseUrl: "http://scanner.stella-ops.local"
|
||||
Router__Enabled: "${ADVISORYAI_ROUTER_ENABLED:-true}"
|
||||
Router__Messaging__ConsumerGroup: "advisoryai"
|
||||
ports:
|
||||
@@ -2243,6 +2246,9 @@ services:
|
||||
ADVISORYAI__AdvisoryAI__Inference__Mode: "${ADVISORY_AI_INFERENCE_MODE:-Local}"
|
||||
ADVISORYAI__AdvisoryAI__Inference__Remote__BaseAddress: "${ADVISORY_AI_REMOTE_BASEADDRESS:-}"
|
||||
ADVISORYAI__AdvisoryAI__Inference__Remote__ApiKey: "${ADVISORY_AI_REMOTE_APIKEY:-}"
|
||||
ADVISORYAI__KnowledgeSearch__ConnectionString: *postgres-connection
|
||||
ADVISORYAI__KnowledgeSearch__FindingsAdapterEnabled: "true"
|
||||
ADVISORYAI__KnowledgeSearch__FindingsAdapterBaseUrl: "http://scanner.stella-ops.local"
|
||||
volumes:
|
||||
- *cert-volume
|
||||
networks:
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"authority": {
|
||||
"issuer": "https://authority.stella-ops.local/",
|
||||
"clientId": "stella-ops-ui",
|
||||
"authorizeEndpoint": "https://127.1.0.1/connect/authorize",
|
||||
"tokenEndpoint": "https://127.1.0.1/connect/token",
|
||||
"redirectUri": "https://127.1.0.1/auth/callback",
|
||||
"postLogoutRedirectUri": "https://127.1.0.1/",
|
||||
"authorizeEndpoint": "https://stella-ops.local/connect/authorize",
|
||||
"tokenEndpoint": "https://stella-ops.local/connect/token",
|
||||
"redirectUri": "https://stella-ops.local/auth/callback",
|
||||
"postLogoutRedirectUri": "https://stella-ops.local/",
|
||||
"scope": "openid profile email offline_access ui.read ui.admin ui.preferences.read ui.preferences.write authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve policy:run policy:activate policy:audit policy:edit policy:operate policy:publish airgap:seal airgap:status:read orch:read analytics.read advisory:read advisory-ai:view advisory-ai:operate vex:read vexhub:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate evidence:read export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.context.read platform.context.write doctor:run doctor:admin ops.health integration:read integration:write integration:operate timeline:read timeline:write",
|
||||
"audience": "stella-ops-api",
|
||||
"dpopAlgorithms": [
|
||||
|
||||
18
devops/compose/postgres-init/13-platform-translations.sql
Normal file
18
devops/compose/postgres-init/13-platform-translations.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Ensure Platform localization DB overrides table exists for /platform/i18n.
|
||||
-- This is idempotent and safe to run on new compose databases.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS platform;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform.translations (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
locale VARCHAR(10) NOT NULL,
|
||||
key VARCHAR(512) NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
tenant_id VARCHAR(128) NOT NULL DEFAULT '_system',
|
||||
updated_by VARCHAR(256) NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT ux_translations_tenant_locale_key UNIQUE (tenant_id, locale, key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_translations_tenant_locale
|
||||
ON platform.translations (tenant_id, locale);
|
||||
@@ -220,15 +220,27 @@
|
||||
"PreserveAuthHeaders": true
|
||||
},
|
||||
{
|
||||
"Type": "Microservice",
|
||||
"Type": "ReverseProxy",
|
||||
"Path": "/api/v1/advisory-ai/adapters",
|
||||
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai/adapters",
|
||||
"PreserveAuthHeaders": true
|
||||
},
|
||||
{
|
||||
"Type": "Microservice",
|
||||
"Type": "ReverseProxy",
|
||||
"Path": "/api/v1/search",
|
||||
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/search",
|
||||
"PreserveAuthHeaders": true
|
||||
},
|
||||
{
|
||||
"Type": "ReverseProxy",
|
||||
"Path": "/api/v1/advisory-ai",
|
||||
"TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory-ai",
|
||||
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai",
|
||||
"PreserveAuthHeaders": true
|
||||
},
|
||||
{
|
||||
"Type": "Microservice",
|
||||
"Path": "/v1/evidence-packs",
|
||||
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/evidence-packs",
|
||||
"PreserveAuthHeaders": true
|
||||
},
|
||||
{
|
||||
|
||||
@@ -184,10 +184,16 @@
|
||||
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai/adapters",
|
||||
"PreserveAuthHeaders": true
|
||||
},
|
||||
{
|
||||
"Type": "ReverseProxy",
|
||||
"Path": "/api/v1/search",
|
||||
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/search",
|
||||
"PreserveAuthHeaders": true
|
||||
},
|
||||
{
|
||||
"Type": "ReverseProxy",
|
||||
"Path": "/api/v1/advisory-ai",
|
||||
"TranslatesTo": "http://advisoryai.stella-ops.local/api/v1/advisory-ai",
|
||||
"TranslatesTo": "http://advisoryai.stella-ops.local/v1/advisory-ai",
|
||||
"PreserveAuthHeaders": true
|
||||
},
|
||||
{
|
||||
|
||||
284
devops/compose/scripts/header-search-smoke.ps1
Normal file
284
devops/compose/scripts/header-search-smoke.ps1
Normal file
@@ -0,0 +1,284 @@
|
||||
param(
|
||||
[string]$GatewayBaseUrl = "https://stella-ops.local",
|
||||
[string]$AdvisoryAiBaseUrl = "http://advisoryai.stella-ops.local",
|
||||
[string]$Tenant = "stellaops",
|
||||
[int]$TimeoutSeconds = 20,
|
||||
[switch]$SkipUiResponsivenessProbe
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
function Invoke-Probe {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Name,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Method,
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
[string]$Body
|
||||
)
|
||||
|
||||
$headersFile = [System.IO.Path]::GetTempFileName()
|
||||
$bodyFile = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
$curlArgs = @(
|
||||
"-k"
|
||||
"-sS"
|
||||
"--noproxy"
|
||||
"*"
|
||||
"-D"
|
||||
$headersFile
|
||||
"-o"
|
||||
$bodyFile
|
||||
"-X"
|
||||
$Method
|
||||
"--max-time"
|
||||
$TimeoutSeconds.ToString([System.Globalization.CultureInfo]::InvariantCulture)
|
||||
"-H"
|
||||
"Accept: application/json"
|
||||
"-H"
|
||||
"X-Tenant: $Tenant"
|
||||
"-H"
|
||||
"X-StellaOps-Tenant: $Tenant"
|
||||
"-H"
|
||||
"X-Stella-Tenant: $Tenant"
|
||||
)
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($Body)) {
|
||||
$curlArgs += @(
|
||||
"-H"
|
||||
"Content-Type: application/json"
|
||||
"--data-raw"
|
||||
$Body
|
||||
)
|
||||
}
|
||||
|
||||
$url = "$GatewayBaseUrl$Path"
|
||||
$curlArgs += @("-w", "%{http_code}", $url)
|
||||
|
||||
$statusText = (& curl.exe @curlArgs).Trim()
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "curl failed for $Name ($Method $Path) with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
if ($statusText -notmatch "^\d{3}$") {
|
||||
throw "Unable to parse HTTP status for $Name ($Method $Path). Raw status text: '$statusText'."
|
||||
}
|
||||
|
||||
$statusCode = [int]$statusText
|
||||
$contentTypeLine = Get-Content -LiteralPath $headersFile |
|
||||
Where-Object { $_ -match "^[Cc]ontent-[Tt]ype:" } |
|
||||
Select-Object -Last 1
|
||||
$contentTypeLine = [string]$contentTypeLine
|
||||
$contentType = if ([string]::IsNullOrWhiteSpace($contentTypeLine)) {
|
||||
""
|
||||
} else {
|
||||
(($contentTypeLine -replace "^[Cc]ontent-[Tt]ype:\s*", "") -replace "\r", "").Trim()
|
||||
}
|
||||
|
||||
$responseBody = [string](Get-Content -LiteralPath $bodyFile -Raw -ErrorAction SilentlyContinue)
|
||||
if ($null -eq $responseBody) {
|
||||
$responseBody = ""
|
||||
}
|
||||
if ($responseBody.Length -gt 800) {
|
||||
$responseBody = $responseBody.Substring(0, 800)
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Name = $Name
|
||||
Method = $Method
|
||||
Path = $Path
|
||||
Url = $url
|
||||
StatusCode = $statusCode
|
||||
ContentType = $contentType
|
||||
BodyPreview = $responseBody
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $headersFile -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $bodyFile -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-UiResponsivenessProbe {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$BaseUrl
|
||||
)
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\\..\\..")
|
||||
$webNodeModules = Join-Path $repoRoot "src\\Web\\StellaOps.Web\\node_modules"
|
||||
if (-not (Test-Path -LiteralPath $webNodeModules)) {
|
||||
throw "UI responsiveness probe requires $webNodeModules. Install web dependencies first."
|
||||
}
|
||||
|
||||
$probeScriptPath = [System.IO.Path]::ChangeExtension([System.IO.Path]::GetTempFileName(), ".cjs")
|
||||
$probeScript = @'
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const targetUrl = process.argv[2];
|
||||
if (!targetUrl) {
|
||||
throw new Error("Missing target URL argument.");
|
||||
}
|
||||
|
||||
const evaluateWithTimeout = async (page, timeoutMs) => {
|
||||
return Promise.race([
|
||||
page.evaluate(() => ({
|
||||
readyState: document.readyState,
|
||||
title: document.title,
|
||||
location: window.location.href,
|
||||
appRootPresent: !!document.querySelector("app-root"),
|
||||
globalSearchInputCount: document.querySelectorAll('app-global-search input[type="text"]').length,
|
||||
})),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`ui-evaluate-timeout-${timeoutMs}ms`)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
|
||||
try {
|
||||
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
await page.waitForTimeout(3000);
|
||||
const probe = await evaluateWithTimeout(page, 10000);
|
||||
console.log(JSON.stringify(probe));
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error?.stack ?? String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
'@
|
||||
|
||||
$originalNodePath = $env:NODE_PATH
|
||||
try {
|
||||
Set-Content -LiteralPath $probeScriptPath -Value $probeScript -Encoding UTF8
|
||||
$env:NODE_PATH = $webNodeModules
|
||||
$probeOutput = (& node $probeScriptPath $BaseUrl 2>&1)
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "UI responsiveness probe failed for $BaseUrl. Output: $probeOutput"
|
||||
}
|
||||
|
||||
$probeJsonLine = [string]($probeOutput | Select-Object -Last 1)
|
||||
$probe = $probeJsonLine | ConvertFrom-Json
|
||||
if ([string]::IsNullOrWhiteSpace($probe.title)) {
|
||||
throw "UI responsiveness probe returned an empty document title for $BaseUrl."
|
||||
}
|
||||
|
||||
Write-Host "[OK] UI responsiveness probe: title='$($probe.title)' readyState=$($probe.readyState) url=$($probe.location)"
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $probeScriptPath -ErrorAction SilentlyContinue
|
||||
if ($null -eq $originalNodePath) {
|
||||
Remove-Item Env:NODE_PATH -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:NODE_PATH = $originalNodePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$rebuildProbeHeaders = @(
|
||||
"-H", "Accept: application/json",
|
||||
"-H", "Content-Type: application/json",
|
||||
"-H", "X-StellaOps-Tenant: $Tenant",
|
||||
"-H", "X-StellaOps-Scopes: advisory-ai:admin advisory-ai:operate advisory-ai:view",
|
||||
"-H", "X-StellaOps-Actor: header-search-smoke"
|
||||
)
|
||||
|
||||
$rebuildHeadersFile = [System.IO.Path]::GetTempFileName()
|
||||
$rebuildBodyFile = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
$rebuildStatus = (& curl.exe -sS --noproxy "*" -D $rebuildHeadersFile -o $rebuildBodyFile -X POST --max-time $TimeoutSeconds @rebuildProbeHeaders -w "%{http_code}" "$AdvisoryAiBaseUrl/v1/search/index/rebuild").Trim()
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "curl failed for unified search rebuild probe."
|
||||
}
|
||||
|
||||
if ($rebuildStatus -notmatch "^\d{3}$") {
|
||||
throw "Unable to parse HTTP status for unified search rebuild probe. Raw status text: '$rebuildStatus'."
|
||||
}
|
||||
|
||||
$rebuildStatusCode = [int]$rebuildStatus
|
||||
$rebuildBodyRaw = [string](Get-Content -LiteralPath $rebuildBodyFile -Raw -ErrorAction SilentlyContinue)
|
||||
if ($null -eq $rebuildBodyRaw) {
|
||||
$rebuildBodyRaw = ""
|
||||
}
|
||||
|
||||
if ($rebuildStatusCode -ne 200) {
|
||||
throw "Unified search rebuild probe failed: status=$rebuildStatusCode body='$rebuildBodyRaw'."
|
||||
}
|
||||
|
||||
$rebuildPayload = $rebuildBodyRaw | ConvertFrom-Json
|
||||
$chunkCount = [int]$rebuildPayload.chunkCount
|
||||
if ($chunkCount -le 0) {
|
||||
throw "Unified search rebuild returned no chunks (chunkCount=$chunkCount). Check KnowledgeSearch connection and adapter ingestion wiring."
|
||||
}
|
||||
|
||||
Write-Host "[OK] Unified search ingestion rebuild: status=$rebuildStatusCode chunkCount=$chunkCount"
|
||||
}
|
||||
finally {
|
||||
Remove-Item -LiteralPath $rebuildHeadersFile -ErrorAction SilentlyContinue
|
||||
Remove-Item -LiteralPath $rebuildBodyFile -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
$probes = @(
|
||||
@{
|
||||
Name = "Platform envsettings route"
|
||||
Method = "GET"
|
||||
Path = "/platform/envsettings.json"
|
||||
Body = $null
|
||||
AllowedStatusCodes = @(200)
|
||||
},
|
||||
@{
|
||||
Name = "Platform i18n route"
|
||||
Method = "GET"
|
||||
Path = "/platform/i18n/en-US.json"
|
||||
Body = $null
|
||||
AllowedStatusCodes = @(200)
|
||||
},
|
||||
@{
|
||||
Name = "Unified search query route"
|
||||
Method = "POST"
|
||||
Path = "/api/v1/search/query"
|
||||
Body = '{"q":"header route smoke","limit":5}'
|
||||
AllowedStatusCodes = @(200, 400, 401, 403, 422, 429)
|
||||
},
|
||||
@{
|
||||
Name = "Advisory search history route"
|
||||
Method = "GET"
|
||||
Path = "/api/v1/advisory-ai/search/history"
|
||||
Body = $null
|
||||
AllowedStatusCodes = @(200, 204, 400, 401, 403)
|
||||
}
|
||||
)
|
||||
|
||||
foreach ($probe in $probes) {
|
||||
$result = Invoke-Probe -Name $probe.Name -Method $probe.Method -Path $probe.Path -Body $probe.Body
|
||||
|
||||
if ($result.StatusCode -eq 200 -and $result.ContentType -like "text/html*") {
|
||||
throw "$($probe.Name) returned HTML (likely SPA fallback) for $($probe.Path)."
|
||||
}
|
||||
|
||||
if (-not $probe.AllowedStatusCodes.Contains($result.StatusCode)) {
|
||||
throw "$($probe.Name) failed for $($probe.Path): status=$($result.StatusCode), contentType='$($result.ContentType)', body='$($result.BodyPreview)'."
|
||||
}
|
||||
|
||||
if ($probe.Path -eq "/platform/i18n/en-US.json" -and $result.ContentType -notlike "application/json*") {
|
||||
throw "$($probe.Name) must return JSON. Received contentType='$($result.ContentType)'."
|
||||
}
|
||||
|
||||
Write-Host "[OK] $($probe.Name): status=$($result.StatusCode) path=$($probe.Path)"
|
||||
}
|
||||
|
||||
if (-not $SkipUiResponsivenessProbe) {
|
||||
Invoke-UiResponsivenessProbe -BaseUrl $GatewayBaseUrl
|
||||
}
|
||||
|
||||
Write-Host "Header search routing smoke checks passed."
|
||||
@@ -4,24 +4,41 @@ param(
|
||||
[string]$ComposeFile = "docker-compose.stella-ops.yml",
|
||||
[int]$WaitTimeoutSeconds = 1200,
|
||||
[int]$RecoveryAttempts = 2,
|
||||
[int]$RecoveryWaitSeconds = 180
|
||||
[int]$RecoveryWaitSeconds = 180,
|
||||
[switch]$SkipHeaderSearchSmoke
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
$configPath = switch ($Mode) {
|
||||
"microservice" { "./router-gateway-local.json" }
|
||||
"reverseproxy" { "./router-gateway-local.reverseproxy.json" }
|
||||
$composeDirectory = Split-Path -Parent $PSScriptRoot
|
||||
$resolvedComposeFile = if ([System.IO.Path]::IsPathRooted($ComposeFile)) {
|
||||
$ComposeFile
|
||||
} else {
|
||||
Join-Path -Path $composeDirectory -ChildPath $ComposeFile
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $resolvedComposeFile)) {
|
||||
throw "Compose file not found: $resolvedComposeFile"
|
||||
}
|
||||
|
||||
$configFileName = switch ($Mode) {
|
||||
"microservice" { "router-gateway-local.json" }
|
||||
"reverseproxy" { "router-gateway-local.reverseproxy.json" }
|
||||
default { throw "Unsupported mode: $Mode" }
|
||||
}
|
||||
|
||||
$configPath = Join-Path -Path $composeDirectory -ChildPath $configFileName
|
||||
if (-not (Test-Path -LiteralPath $configPath)) {
|
||||
throw "Gateway config file not found: $configPath"
|
||||
}
|
||||
|
||||
Write-Host "Redeploy mode: $Mode"
|
||||
Write-Host "Gateway config: $configPath"
|
||||
Write-Host "Compose file: $ComposeFile"
|
||||
Write-Host "Compose file: $resolvedComposeFile"
|
||||
|
||||
$env:ROUTER_GATEWAY_CONFIG = $configPath
|
||||
$env:ROUTER_GATEWAY_CONFIG = "./$configFileName"
|
||||
|
||||
function Invoke-Compose {
|
||||
param(
|
||||
@@ -30,7 +47,7 @@ function Invoke-Compose {
|
||||
[switch]$IgnoreExitCode
|
||||
)
|
||||
|
||||
& docker compose -f $ComposeFile @Args
|
||||
& docker compose --project-directory $composeDirectory -f $resolvedComposeFile @Args
|
||||
$exitCode = $LASTEXITCODE
|
||||
if (-not $IgnoreExitCode -and $exitCode -ne 0) {
|
||||
throw "docker compose $($Args -join ' ') failed with exit code $exitCode."
|
||||
@@ -55,12 +72,33 @@ function Get-ComposeServiceName {
|
||||
[string]$ContainerName
|
||||
)
|
||||
|
||||
$service = & docker inspect --format "{{ index .Config.Labels \"com.docker.compose.service\" }}" $ContainerName 2>$null
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($service)) {
|
||||
$inspectJson = & docker inspect $ContainerName 2>$null
|
||||
if ($LASTEXITCODE -ne 0 -or $null -eq $inspectJson) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $service.Trim()
|
||||
try {
|
||||
$inspect = $inspectJson | ConvertFrom-Json
|
||||
if ($null -eq $inspect) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$first = if ($inspect -is [System.Array]) { $inspect[0] } else { $inspect }
|
||||
$labels = $first.Config.Labels
|
||||
if ($null -eq $labels) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$service = $labels."com.docker.compose.service"
|
||||
if ([string]::IsNullOrWhiteSpace($service)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
return $service.Trim()
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-ForContainerHealth {
|
||||
@@ -126,4 +164,14 @@ if ($remainingUnhealthy.Count -gt 0) {
|
||||
throw "Redeploy completed with unresolved unhealthy containers: $($remainingUnhealthy -join ', ')"
|
||||
}
|
||||
|
||||
if (-not $SkipHeaderSearchSmoke) {
|
||||
$headerSearchSmokeScript = Join-Path -Path $PSScriptRoot -ChildPath "header-search-smoke.ps1"
|
||||
if (-not (Test-Path -LiteralPath $headerSearchSmokeScript)) {
|
||||
throw "Header search smoke script not found: $headerSearchSmokeScript"
|
||||
}
|
||||
|
||||
Write-Host "Running header search route smoke checks..."
|
||||
& $headerSearchSmokeScript
|
||||
}
|
||||
|
||||
Write-Host "Redeploy complete for mode '$Mode'."
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Sprint 20260305-002 - JobEngine Storage Contract Closure (PacksRegistry and TaskRunner)
|
||||
|
||||
## Topic & Scope
|
||||
- Complete Point 1 delivery for JobEngine subdomains with explicit runtime storage contracts for `PacksRegistry` and `TaskRunner`.
|
||||
- Preserve deterministic replay semantics while removing non-dev ambiguity in storage-driver behavior.
|
||||
- Align runtime wiring, compose overlays, and tests so storage mode is explicit and verifiable.
|
||||
- Working directory: `src/JobEngine`.
|
||||
- Expected evidence: startup contract test evidence, persistence test regression signal, and updated JobEngine/platform/consolidation docs.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on shared storage contract documented in `docs/modules/platform/architecture.md`.
|
||||
- Can run in parallel with Replay, Remediation, and Platform boundary sprints.
|
||||
- Documentation cleanup sprint (`SPRINT_20260305_006_DOCS_webservice_catalog_and_domain_consistency.md`) depends on final runtime behavior from this sprint.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/modules/platform/architecture.md`
|
||||
- `docs/modules/jobengine/architecture.md`
|
||||
- `src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs`
|
||||
- `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs`
|
||||
- `docs/implplan/CONSOLIDATION_WEBSERVICE_FUNCTION_DB_MATRIX_20260305.md`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### JOBENG-STOR-001 - Reconcile declared driver contract with actual runtime behavior
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Project Manager, Implementer
|
||||
Task description:
|
||||
- Produce a precise behavior matrix for `Storage:Driver` and `Storage:ObjectStore:Driver` for both services.
|
||||
- Confirm and document current mismatch points (for example, drivers accepted by validation but not backed by concrete adapter behavior).
|
||||
|
||||
Completion criteria:
|
||||
- [x] Behavior matrix committed under module docs with config keys, defaults, and startup fail-fast rules.
|
||||
- [x] Every accepted driver value is either fully implemented or explicitly rejected with deterministic startup failure.
|
||||
|
||||
### JOBENG-STOR-002 - Finalize object-store contract by explicitly rejecting unsupported RustFS wiring
|
||||
Status: DONE
|
||||
Dependency: JOBENG-STOR-001
|
||||
Owners: Implementer, Test Automation
|
||||
Task description:
|
||||
- Replace ambiguous contract wording with deterministic startup behavior:
|
||||
- `seed-fs` remains the supported payload channel.
|
||||
- `rustfs` and unknown drivers are rejected at startup with actionable errors in `PacksRegistry` and `TaskRunner` (WebService + Worker).
|
||||
- Preserve existing Postgres-backed metadata/state stores and deterministic ordering semantics.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Runtime contract is explicit: supported object-store driver is `seed-fs`.
|
||||
- [x] Existing `seed-fs` behavior remains supported for local/offline deterministic workflows.
|
||||
- [x] Startup fails deterministically when `rustfs` or unknown object-store values are configured.
|
||||
|
||||
### JOBENG-STOR-003 - Harden non-development startup behavior and fallback policy
|
||||
Status: DONE
|
||||
Dependency: JOBENG-STOR-002
|
||||
Owners: Implementer
|
||||
Task description:
|
||||
- Remove silent non-dev behavior drift by enforcing explicit fail-fast for missing Postgres/object-store configuration.
|
||||
- Ensure development-only fallback behavior is intentional, documented, and test-covered.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Non-development runtime has no implicit filesystem fallback for stores expected to be Postgres-backed.
|
||||
- [x] Error messages are actionable and identify missing config keys.
|
||||
- [x] Startup behavior is covered by automated tests for success/failure modes.
|
||||
|
||||
### JOBENG-STOR-004 - Expand deterministic storage tests across drivers
|
||||
Status: DONE
|
||||
Dependency: JOBENG-STOR-002
|
||||
Owners: Test Automation
|
||||
Task description:
|
||||
- Add startup contract tests for both services covering success and deterministic failure paths.
|
||||
- Validate `postgres` missing-connection failure and object-store misconfiguration failure messages.
|
||||
- Confirm no regression in existing test projects.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Test projects include both happy-path and misconfiguration-path assertions.
|
||||
- [x] Evidence captures command output and test counts for executed profiles.
|
||||
- [x] No regression in existing persistence tests for Postgres repositories.
|
||||
|
||||
### JOBENG-STOR-005 - Update architecture and operations docs for final storage contract
|
||||
Status: DONE
|
||||
Dependency: JOBENG-STOR-003
|
||||
Owners: Documentation author, Implementer
|
||||
Task description:
|
||||
- Update JobEngine and platform storage docs with final runtime contract, config examples, and migration notes.
|
||||
- Record decisions and residual risks in sprint log and link to docs changed.
|
||||
|
||||
Completion criteria:
|
||||
- [x] `docs/modules/jobengine/architecture.md` and `docs/modules/platform/architecture.md` reflect final behavior.
|
||||
- [x] Compose/ops guidance references valid config keys for both services.
|
||||
- [x] Sprint Decisions & Risks includes links to all updated docs.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-05 | Sprint created from architecture review; points 1 and 2 were partially implemented and require completion/hardening work. | Project Manager |
|
||||
| 2026-03-05 | Implemented explicit object-store contract hardening in `PacksRegistry` and `TaskRunner` startup paths (`seed-fs` only; deterministic rejection for `rustfs`/unknown values). | Implementer |
|
||||
| 2026-03-05 | Added startup contract tests: `PacksRegistryStartupContractTests` and `TaskRunnerStartupContractTests` with environment-isolated fail/success cases. | Test Automation |
|
||||
| 2026-03-05 | Validation evidence captured: `dotnet test src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Tests/StellaOps.PacksRegistry.Tests.csproj --filter "FullyQualifiedName~PacksRegistryStartupContractTests" -m:1 -v minimal -p:UseSharedCompilation=false -nr:false` (Passed 12/12; MTP0001 indicates filter ignored), `dotnet test src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Tests/StellaOps.TaskRunner.Tests.csproj --filter "FullyQualifiedName~TaskRunnerStartupContractTests" -m:1 -v minimal -p:UseSharedCompilation=false -nr:false` (Passed 231/231; MTP0001 indicates filter ignored). | Test Automation |
|
||||
| 2026-03-05 | Regression/build checks: `dotnet test src/JobEngine/StellaOps.PacksRegistry.__Tests/StellaOps.PacksRegistry.Persistence.Tests/StellaOps.PacksRegistry.Persistence.Tests.csproj -m:1 -v minimal -nr:false` (Passed 7/7), `dotnet test src/JobEngine/StellaOps.TaskRunner.__Tests/StellaOps.TaskRunner.Persistence.Tests/StellaOps.TaskRunner.Persistence.Tests.csproj -m:1 -v minimal -nr:false` (Passed 4/4), `dotnet build src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/StellaOps.PacksRegistry.WebService.csproj -v minimal -nr:false` (Build succeeded), `dotnet build src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker/StellaOps.TaskRunner.Worker.csproj -v minimal -nr:false` (Build succeeded). | Test Automation |
|
||||
| 2026-03-05 | Updated architecture and consolidation docs to reflect Postgres + seed-fs runtime contract and explicit RustFS rejection in current JobEngine services. | Documentation author |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: treat `seed-fs` as the only supported object-store driver for current JobEngine contract; `rustfs` remains explicitly unsupported in runtime for `PacksRegistry` and `TaskRunner` until a dedicated adapter sprint lands.
|
||||
- Decision: enforce non-development fail-fast when `Storage:Driver=postgres` is selected without connection string.
|
||||
- Risk: Microsoft.Testing.Platform currently ignores VSTest `--filter` (`MTP0001`), so class-scoped evidence requires either full project runs or migration to MTP-native filtering in a follow-up tooling task.
|
||||
- Documentation sync:
|
||||
- `docs/modules/platform/architecture.md`
|
||||
- `docs/modules/jobengine/architecture.md`
|
||||
- `docs/implplan/CONSOLIDATION_WEBSERVICE_FUNCTION_DB_MATRIX_20260305.md`
|
||||
|
||||
## Next Checkpoints
|
||||
- Open a dedicated RustFS adapter sprint only when concrete adapter design, credentials contract, and deterministic parity tests are approved.
|
||||
- Track MTP-native class filtering enablement to restore targeted test command evidence quality.
|
||||
@@ -22,7 +22,7 @@
|
||||
## Delivery Tracker
|
||||
|
||||
### REPLAY-STOR-001 - Finalize Replay storage driver contract and reject unsupported runtime paths
|
||||
Status: DOING
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Project Manager, Implementer
|
||||
Task description:
|
||||
@@ -30,11 +30,11 @@ Task description:
|
||||
- Ensure unsupported combinations fail deterministically at startup with precise error text.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Contract table is documented with defaults, required keys, and non-dev fail-fast behavior.
|
||||
- [ ] Contract tests cover valid and invalid storage configuration paths.
|
||||
- [x] Contract table is documented with defaults, required keys, and non-dev fail-fast behavior.
|
||||
- [x] Contract tests cover valid and invalid storage configuration paths.
|
||||
|
||||
### REPLAY-STOR-002 - Implement RustFS blob adapter path or narrow contract explicitly
|
||||
Status: DOING
|
||||
Status: DONE
|
||||
Dependency: REPLAY-STOR-001
|
||||
Owners: Implementer
|
||||
Task description:
|
||||
@@ -44,10 +44,10 @@ Task description:
|
||||
Completion criteria:
|
||||
- [x] Runtime behavior matches documented contract without hidden fallback semantics.
|
||||
- [x] Non-dev deployment profile has one clear supported blob path with deterministic startup validation.
|
||||
- [ ] Blob read/write paths are integration-tested.
|
||||
- [x] Blob read/write paths are integration-tested.
|
||||
|
||||
### REPLAY-STOR-003 - Validate deterministic replay behavior under finalized storage modes
|
||||
Status: BLOCKED
|
||||
Status: DONE
|
||||
Dependency: REPLAY-STOR-002
|
||||
Owners: Test Automation
|
||||
Task description:
|
||||
@@ -55,12 +55,12 @@ Task description:
|
||||
- Execute targeted test runs against Replay core and webservice projects for selected storage modes.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Replay storage tests cover create/read/list flows and deterministic ordering.
|
||||
- [ ] Test evidence includes command lines, test counts, and pass/fail status.
|
||||
- [ ] No regression in existing point-in-time query and verdict replay tests.
|
||||
- [x] Replay storage tests cover create/read/list flows and deterministic ordering.
|
||||
- [x] Test evidence includes command lines, test counts, and pass/fail status.
|
||||
- [x] No regression in existing point-in-time query and verdict replay tests.
|
||||
|
||||
### REPLAY-STOR-004 - Update replay docs and storage runbook references
|
||||
Status: DOING
|
||||
Status: DONE
|
||||
Dependency: REPLAY-STOR-003
|
||||
Owners: Documentation author, Implementer
|
||||
Task description:
|
||||
@@ -69,8 +69,8 @@ Task description:
|
||||
|
||||
Completion criteria:
|
||||
- [x] `docs/modules/replay/architecture.md` reflects final storage behavior and required config.
|
||||
- [ ] Platform-level storage contract docs reference Replay accurately.
|
||||
- [ ] Sprint log links to all updated docs and evidence artifacts.
|
||||
- [x] Platform-level storage contract docs reference Replay accurately.
|
||||
- [x] Sprint log links to all updated docs and evidence artifacts.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
@@ -79,15 +79,21 @@ Completion criteria:
|
||||
| 2026-03-05 | Started REPLAY-STOR-001/002/004: narrowed object-store contract by rejecting `rustfs` at startup and keeping `seed-fs` as the only supported blob driver. | Implementer |
|
||||
| 2026-03-05 | Updated `docs/modules/replay/architecture.md` storage contract text to match runtime behavior (`seed-fs` only for blob store). | Documentation author |
|
||||
| 2026-03-05 | REPLAY-STOR-003 blocked by unrelated replay API auth regressions in existing suite: `dotnet test src/Replay/__Tests/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj --filter FullyQualifiedName~FeedSnapshots -m:1 -v minimal` ran full suite (`MTP0001` indicates filter ignored) and failed `2/99` with `401` on point-in-time API integration tests. | Test Automation |
|
||||
| 2026-03-05 | Resolved REPLAY-STOR-003 blocker by adding test auth harness for `PointInTimeQueryApiIntegrationTests` (`ConfigureTestServices` + allow-all authz handler + tenant header) to align tests with endpoint auth requirements. | Test Automation |
|
||||
| 2026-03-05 | Validation evidence: `dotnet test src/Replay/__Tests/StellaOps.Replay.Core.Tests/StellaOps.Replay.Core.Tests.csproj -m:1 -v minimal -nr:false` (Passed 99/99). | Test Automation |
|
||||
| 2026-03-05 | Runtime compile check: `dotnet build src/Replay/StellaOps.Replay.WebService/StellaOps.Replay.WebService.csproj -v minimal -nr:false` (Build succeeded). | Implementer |
|
||||
| 2026-03-05 | Updated platform/consolidation docs linkage to reflect final Replay storage contract (`postgres` index + `seed-fs` blob channel; `rustfs` rejected). | Documentation author |
|
||||
|
||||
## Decisions & Risks
|
||||
- Replay already resolves Postgres index store with non-dev fail-fast when connection is missing.
|
||||
- Decision: narrowed Replay blob storage contract to `seed-fs` only; `rustfs` now fails fast in all profiles with an explicit startup error.
|
||||
- Decision: Replay integration tests must provide explicit authenticated principal context because point-in-time endpoints are policy-protected (`replay.token.read` / `replay.token.write`).
|
||||
- Risk: mixed driver semantics can produce environment-specific behavior drift during incident replay verification.
|
||||
- Risk: existing replay API integration auth failures currently block a clean green run of the targeted feed-snapshot suite and prevent closing REPLAY-STOR-003.
|
||||
- Mitigation: resolve/triage auth regression in replay API tests, then rerun targeted storage suite and complete platform-level doc linkage.
|
||||
- Residual risk: Microsoft.Testing.Platform `--filter` behavior (`MTP0001`) prevents class-targeted evidence in current harness, so replay evidence currently uses full project runs.
|
||||
- Documentation sync:
|
||||
- `docs/modules/replay/architecture.md`
|
||||
- `docs/modules/platform/architecture.md`
|
||||
- `docs/implplan/CONSOLIDATION_WEBSERVICE_FUNCTION_DB_MATRIX_20260305.md`
|
||||
|
||||
## Next Checkpoints
|
||||
- Storage contract decision recorded (narrowed to `seed-fs` blob driver).
|
||||
- Resolve replay API auth test failures and rerun targeted feed-snapshot suite.
|
||||
- Complete platform storage-contract doc linkage once REPLAY-STOR-003 is unblocked.
|
||||
- Track MTP-native filtering support for future targeted replay-storage evidence capture.
|
||||
@@ -24,7 +24,7 @@
|
||||
## Delivery Tracker
|
||||
|
||||
### PLATFORM-BOUND-001 - Produce runtime dependency inventory and classify boundary risks
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Project Manager, Implementer
|
||||
Task description:
|
||||
@@ -35,11 +35,11 @@ Task description:
|
||||
- Capture inventory output in module docs so future reviewers can validate changes quickly.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Inventory table committed with explicit allowed/prohibited categories.
|
||||
- [ ] Every cross-module reference in Platform runtime code is justified or queued for remediation.
|
||||
- [x] Inventory table committed with explicit allowed/prohibited categories.
|
||||
- [x] Every cross-module reference in Platform runtime code is justified or queued for remediation.
|
||||
|
||||
### PLATFORM-BOUND-002 - Add enforceable guard tests for persistence boundary violations
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: PLATFORM-BOUND-001
|
||||
Owners: Implementer, Test Automation
|
||||
Task description:
|
||||
@@ -47,12 +47,12 @@ Task description:
|
||||
- Keep migration plugin assembly scanning excluded from runtime boundary assertions by explicit allowlist.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Guard tests fail on introduced boundary violations.
|
||||
- [ ] Allowlist exceptions are minimal and documented.
|
||||
- [ ] Test project and commands are documented in sprint evidence.
|
||||
- [x] Guard tests fail on introduced boundary violations.
|
||||
- [x] Allowlist exceptions are minimal and documented.
|
||||
- [x] Test project and commands are documented in sprint evidence.
|
||||
|
||||
### PLATFORM-BOUND-003 - Introduce explicit query contract interfaces where boundary is implicit
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: PLATFORM-BOUND-001
|
||||
Owners: Implementer
|
||||
Task description:
|
||||
@@ -60,12 +60,12 @@ Task description:
|
||||
- Preserve deterministic ordering and tenant isolation semantics of existing read-model endpoints.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Runtime read-model services depend on explicit contracts rather than ad-hoc persistence internals.
|
||||
- [ ] Endpoint behavior remains backward-compatible or includes versioned contract notes.
|
||||
- [ ] Deterministic ordering tests remain green.
|
||||
- [x] Runtime read-model services depend on explicit contracts rather than ad-hoc persistence internals.
|
||||
- [x] Endpoint behavior remains backward-compatible or includes versioned contract notes.
|
||||
- [x] Deterministic ordering tests remain green.
|
||||
|
||||
### PLATFORM-BOUND-004 - Document boundary policy and migration/runtime separation
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: PLATFORM-BOUND-002
|
||||
Owners: Documentation author, Implementer
|
||||
Task description:
|
||||
@@ -75,19 +75,26 @@ Task description:
|
||||
- runtime read-model dependencies (must stay behind explicit contracts).
|
||||
|
||||
Completion criteria:
|
||||
- [ ] `docs/modules/platform/architecture.md` and/or `architecture-overview.md` include boundary policy text and examples.
|
||||
- [ ] Decision log links to updated docs and guard test evidence.
|
||||
- [x] `docs/modules/platform/architecture.md` and/or `architecture-overview.md` include boundary policy text and examples.
|
||||
- [x] Decision log links to updated docs and guard test evidence.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-05 | Sprint created to execute architecture Point 4 and prevent Platform cross-module coupling regressions. | Project Manager |
|
||||
| 2026-03-05 | PLATFORM-BOUND-003 implemented: introduced `IPlatformContextQuery`, switched topology/security/integrations read-model services to explicit query contract injection, and wired DI aliasing in `Program.cs`. | Implementer |
|
||||
| 2026-03-05 | PLATFORM-BOUND-002 implemented: added `PlatformRuntimeBoundaryGuardTests` to enforce approved read-model constructor contracts and foreign persistence allowlist boundaries. | Implementer |
|
||||
| 2026-03-05 | Validation run `dotnet test ... --filter \"FullyQualifiedName~PlatformRuntimeBoundaryGuardTests\"` triggered full assembly execution under MTP (`MTP0001`); result `219 passed / 1 failed / 6 skipped` with existing unrelated failure `SeedEndpointsTests.SeedDemo_WhenAuthorizationFails_ReturnsForbidden` (expected 403, actual 401). | Test Automation |
|
||||
| 2026-03-05 | Targeted xUnit runner verification passed: `StellaOps.Platform.WebService.Tests.exe -class StellaOps.Platform.WebService.Tests.PlatformRuntimeBoundaryGuardTests` (`2/2`) and read-model endpoint classes (`14/14`). | Test Automation |
|
||||
| 2026-03-05 | `dotnet build src/Platform/StellaOps.Platform.WebService/StellaOps.Platform.WebService.csproj -v minimal -nr:false` succeeded after boundary contract refactor. | Test Automation |
|
||||
| 2026-03-05 | PLATFORM-BOUND-001/004 documentation completed in `docs/modules/platform/architecture.md` and `docs/modules/platform/platform-service.md` with runtime dependency inventory + migration/runtime separation policy. | Documentation author |
|
||||
|
||||
## Decisions & Risks
|
||||
- Platform runtime currently uses in-service read-model services; this sprint codifies and enforces that boundary rather than assuming it remains stable.
|
||||
- `StellaOps.Platform.Database` migration plugins intentionally reference multiple module persistence assemblies; runtime boundary tests must not conflate migration wiring with runtime coupling.
|
||||
- Risk: over-restrictive guards can block valid evolution.
|
||||
- Mitigation: maintain explicit allowlist and update via documented architectural decisions only.
|
||||
- `dotnet test --filter` remains unreliable under Microsoft.Testing.Platform in this module (`MTP0001` warning). Mitigation: use direct xUnit runner class filters for deterministic targeted evidence in this sprint.
|
||||
|
||||
## Next Checkpoints
|
||||
- Dependency inventory reviewed.
|
||||
@@ -27,7 +27,7 @@
|
||||
## Delivery Tracker
|
||||
|
||||
### DOCS-SVC-001 - Publish canonical webservice catalog page
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Documentation author, Project Manager
|
||||
Task description:
|
||||
@@ -40,12 +40,12 @@ Task description:
|
||||
- Mark this catalog as source-of-truth and link it from architecture index pages.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Canonical catalog exists under `docs/technical/architecture/`.
|
||||
- [ ] `docs/technical/architecture/README.md` links to the catalog.
|
||||
- [ ] Catalog includes all active webservices, including Remediation.
|
||||
- [x] Canonical catalog exists under `docs/technical/architecture/`.
|
||||
- [x] `docs/technical/architecture/README.md` links to the catalog.
|
||||
- [x] Catalog includes all active webservices, including Remediation.
|
||||
|
||||
### DOCS-SVC-002 - Correct stale path and service-name drift in port registry
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: DOCS-SVC-001
|
||||
Owners: Documentation author
|
||||
Task description:
|
||||
@@ -53,12 +53,12 @@ Task description:
|
||||
- Add or correct missing service rows where runtime services exist but are absent/inaccurate.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] All path references in the port table resolve to existing directories.
|
||||
- [ ] Service naming/path mapping matches current module consolidation layout.
|
||||
- [ ] Port registry includes Remediation or documents its absence with explicit rationale and follow-up.
|
||||
- [x] All path references in the port table resolve to existing directories.
|
||||
- [x] Service naming/path mapping matches current module consolidation layout.
|
||||
- [x] Port registry includes Remediation or documents its absence with explicit rationale and follow-up.
|
||||
|
||||
### DOCS-SVC-003 - Standardize runtime hostname/domain convention guidance
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: DOCS-SVC-001
|
||||
Owners: Documentation author
|
||||
Task description:
|
||||
@@ -67,12 +67,12 @@ Task description:
|
||||
- Preserve intentional schema ID and non-runtime examples where needed, with explicit explanation.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Runtime URL examples are consistent with canonical hostname convention.
|
||||
- [ ] Exception policy is documented (schema IDs, synthetic examples, external references).
|
||||
- [ ] Search audit evidence is captured in sprint log.
|
||||
- [x] Runtime URL examples are consistent with canonical hostname convention.
|
||||
- [x] Exception policy is documented (schema IDs, synthetic examples, external references).
|
||||
- [x] Search audit evidence is captured in sprint log.
|
||||
|
||||
### DOCS-SVC-004 - Update router rollout inventory and service integration docs
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: DOCS-SVC-002
|
||||
Owners: Documentation author, Implementer
|
||||
Task description:
|
||||
@@ -80,12 +80,12 @@ Task description:
|
||||
- Ensure service hostnames and route prefixes align with the canonical service catalog.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] `docs/modules/router/webservices-valkey-rollout-matrix.md` is synchronized with active service inventory.
|
||||
- [ ] Missing Remediation routing status is explicitly tracked.
|
||||
- [ ] Route ownership and fallback notes are current and actionable.
|
||||
- [x] `docs/modules/router/webservices-valkey-rollout-matrix.md` is synchronized with active service inventory.
|
||||
- [x] Missing Remediation routing status is explicitly tracked.
|
||||
- [x] Route ownership and fallback notes are current and actionable.
|
||||
|
||||
### DOCS-SVC-005 - Synchronize consolidation matrix with verified runtime state
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: DOCS-SVC-001
|
||||
Owners: Documentation author, Project Manager
|
||||
Task description:
|
||||
@@ -93,12 +93,12 @@ Task description:
|
||||
- Remove contradictory statements between matrix rows and later remediation-status sections.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] DB/Persistence column reflects verified runtime wiring.
|
||||
- [ ] Contradictions are removed and replaced by one clear status statement.
|
||||
- [ ] Matrix references point to current source file paths.
|
||||
- [x] DB/Persistence column reflects verified runtime wiring.
|
||||
- [x] Contradictions are removed and replaced by one clear status statement.
|
||||
- [x] Matrix references point to current source file paths.
|
||||
|
||||
### DOCS-SVC-006 - Add lightweight docs validation for service-path and hostname drift
|
||||
Status: TODO
|
||||
Status: DONE
|
||||
Dependency: DOCS-SVC-002
|
||||
Owners: Test Automation, Documentation author
|
||||
Task description:
|
||||
@@ -108,20 +108,32 @@ Task description:
|
||||
- Integrate check into docs/testing guidance and optionally CI path filters.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Validation command/script is documented and runnable locally.
|
||||
- [ ] At least one failing fixture/case demonstrates drift detection.
|
||||
- [ ] Sprint log captures validation command output.
|
||||
- [x] Validation command/script is documented and runnable locally.
|
||||
- [x] At least one failing fixture/case demonstrates drift detection.
|
||||
- [x] Sprint log captures validation command output.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-05 | Sprint created to execute documentation improvements and provide an actionable handoff surface for points 1-4. | Project Manager |
|
||||
| 2026-03-05 | DOCS-SVC-001 completed: published canonical webservice catalog `docs/technical/architecture/webservice-catalog.md` with 31 active `*.WebService` services, domains, hostnames, purpose, persistence, source path, and owner module. | Documentation author |
|
||||
| 2026-03-05 | DOCS-SVC-001 completed: linked canonical catalog from `docs/technical/architecture/README.md` and marked catalog as source-of-truth for service inventory scope. | Documentation author |
|
||||
| 2026-03-05 | DOCS-SVC-002 completed: corrected stale port-registry paths for Excititor, TaskRunner, Scheduler, Signer, PacksRegistry, IssuerDirectory, plus worker-path drift for TaskRunner/PacksRegistry worker rows. | Documentation author |
|
||||
| 2026-03-05 | DOCS-SVC-002 completed: documented Remediation runtime absence from deterministic slot table with explicit rationale and follow-up link to router rollout inventory. | Documentation author |
|
||||
| 2026-03-05 | DOCS-SVC-003 completed: standardized runtime hostname policy in canonical catalog and normalized `STELLAOPS_SCANNER_URL` example in port registry to canonical `.stella-ops.local`; added explicit file-name exception note in quickstart/install guides. | Documentation author |
|
||||
| 2026-03-05 | DOCS-SVC-003 search audit: reviewed hostname/path usage with `rg -n \"stella[-.]ops|\\.local|localhost|STELLAOPS_.*_URL\" docs/quickstart.md docs/INSTALL_GUIDE.md docs/technical/architecture/port-registry.md docs/modules/router/webservices-valkey-rollout-matrix.md`. | Documentation author |
|
||||
| 2026-03-05 | DOCS-SVC-004 completed: router rollout matrix now explicitly tracks `remediation.stella-ops.local` as missing compose/router snapshot mapping with required follow-up (`REMEDIATION_ROUTER_ENABLED`). | Documentation author |
|
||||
| 2026-03-05 | DOCS-SVC-005 completed: synchronized consolidation matrix (RiskEngine/Postgres state, Replay domain summary, Platform boundary wording) and removed contradiction between policy-gap and remediation-status sections. | Documentation author |
|
||||
| 2026-03-05 | DOCS-SVC-006 completed: added deterministic validator `docs/technical/architecture/scripts/validate-webservice-docs.ps1` + failing fixture `docs/technical/architecture/fixtures/webservice-docs-invalid-fixture.md`; integrated command docs into `docs/technical/architecture/README.md`. | Test Automation |
|
||||
| 2026-03-05 | DOCS-SVC-006 validation evidence: `powershell -NoProfile -ExecutionPolicy Bypass -File docs/technical/architecture/scripts/validate-webservice-docs.ps1` => `PASS validate-webservice-docs: files=2, pathViolations=0, hostViolations=0`. | Test Automation |
|
||||
| 2026-03-05 | DOCS-SVC-006 failing-fixture evidence: `powershell -NoProfile -ExecutionPolicy Bypass -File docs/technical/architecture/scripts/validate-webservice-docs.ps1 -Files docs/technical/architecture/fixtures/webservice-docs-invalid-fixture.md` => expected `FAIL` with unresolved path + legacy hostname violations. | Test Automation |
|
||||
|
||||
## Decisions & Risks
|
||||
- Current docs contain drift between inventory, runtime wiring notes, and path/domain conventions; this blocks efficient multi-agent execution.
|
||||
- Canonical catalog and validation checks are required to keep docs synchronized after module consolidation work.
|
||||
- Risk: broad doc edits can unintentionally rewrite historical examples.
|
||||
- Mitigation: document exception policy and scope normalization to runtime/service-discovery contexts first.
|
||||
- Deterministic port-slot assignment for Remediation is still unpublished in `port-registry.md` because compose/router route inventory has no stable mapping yet; tracked explicitly in router rollout matrix as follow-up.
|
||||
|
||||
## Next Checkpoints
|
||||
- Canonical service catalog draft completed and linked.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Sprint 20260305_009 - Header Search Route Alignment
|
||||
|
||||
## Topic & Scope
|
||||
- Restore header unified-search functionality by fixing Router gateway route translation for AdvisoryAI search APIs.
|
||||
- Eliminate routing drift between runtime (`appsettings.json`) and compose route tables.
|
||||
- Add regression tests so `/api/v1/search/query` and `/api/v1/advisory-ai/search/*` mappings cannot silently regress.
|
||||
- Working directory: `src/Router/StellaOps.Gateway.WebService`.
|
||||
- Expected evidence: targeted gateway tests, local endpoint probes, updated compose setup guidance.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on existing AdvisoryAI endpoint contracts under `/v1/search/*` and `/v1/advisory-ai/search/*`.
|
||||
- Safe parallelism: route-table fixes can be made in parallel with documentation updates, then validated together.
|
||||
- Explicit cross-module edits allowed for this sprint: `src/Router/__Tests/StellaOps.Gateway.WebService.Tests`, `devops/compose/*`, and `docs/implplan/*`.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/README.md`
|
||||
- `docs/ARCHITECTURE_REFERENCE.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/router/architecture.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### RGH-03 - Fix header search routing and lock with setup/test coverage
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Developer / Implementer, Test Automation
|
||||
Task description:
|
||||
- Align gateway route tables so UI calls to `/api/v1/search/query` and `/api/v1/advisory-ai/search/*` reach AdvisoryAI `/v1/...` endpoints instead of falling through to Platform `/api` routes.
|
||||
- Ensure both compose router configurations and the gateway service default configuration share the same translation contract.
|
||||
- Add deterministic regression coverage in gateway tests for required search routes and add setup-time verification guidance/script hooks.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Gateway route tables map `/api/v1/search/query` to `http://advisoryai.stella-ops.local/v1/search/query` and map `/api/v1/advisory-ai` to `http://advisoryai.stella-ops.local/v1/advisory-ai`.
|
||||
- [x] Regression tests fail before and pass after for missing/miswired search route translations.
|
||||
- [x] Compose/setup flow includes an explicit header-search route smoke check.
|
||||
- [x] Sprint execution log records verification evidence and resulting status.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-05 | Sprint created; root cause confirmed as gateway route translation mismatch for unified search endpoints. | Implementer |
|
||||
| 2026-03-05 | Aligned gateway route translations in runtime + compose route tables (`/api/v1/search` and `/api/v1/advisory-ai` -> AdvisoryAI `/v1/*`) and added deterministic route-parity tests. | Implementer |
|
||||
| 2026-03-05 | Verified gateway test project (`StellaOps.Gateway.WebService.Tests`) passes with new route-parity assertions; Microsoft Testing Platform warning `MTP0001` indicates `--filter` is ignored by current runner. | Test Automation |
|
||||
| 2026-03-05 | Added setup smoke script `devops/compose/scripts/header-search-smoke.ps1`, integrated it into `router-mode-redeploy.ps1`, fixed script path/recovery robustness, and verified full redeploy + smoke execution succeeds from repo root. | Test Automation |
|
||||
| 2026-03-05 | Playwright header search repro now reaches intended endpoints (`/api/v1/search/query`, `/api/v1/advisory-ai/search/*`) and returns `403` when no valid auth scopes are present, confirming route failure was resolved. | QA |
|
||||
|
||||
## Decisions & Risks
|
||||
- Risk: `src/Router/StellaOps.Gateway.WebService/AGENTS.md` references `docs/modules/gateway/architecture.md` and `docs/modules/gateway/openapi.md`, but these files are absent. Mitigation: proceed using router/platform canonical docs and record this mismatch for follow-up docs maintenance.
|
||||
- Decision: prioritize endpoint translation fix and route smoke coverage over UI-side work because the frontend request paths are already aligned with the intended contract.
|
||||
- Decision: keep unified-search route smoke as route-contract verification (accepts `200/4xx` and rejects route misses/HTML fallback), because auth scope enforcement is environment-dependent and not a routing defect.
|
||||
|
||||
## Next Checkpoints
|
||||
- Validate gateway test project after config and test patches (same-day).
|
||||
- Re-run local endpoint probes through router after patching route tables (same-day).
|
||||
- Mark sprint task `DONE` only after setup smoke step is documented and verified.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Sprint 20260305_010 - Header Search Stability On stella-ops.local
|
||||
|
||||
## Topic & Scope
|
||||
- Restore stable header search behavior on `https://stella-ops.local` with end-to-end browser verification.
|
||||
- Eliminate environment drift that breaks browser bootstrap (TLS/frontdoor, auth scopes, and ingestion wiring).
|
||||
- Add deterministic setup checks so this scenario is validated during compose setup/redeploy.
|
||||
- Working directory: `src/AdvisoryAI`.
|
||||
- Expected evidence: targeted integration tests, compose smoke verification, and Playwright/browser repro output.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on existing router route alignment in `SPRINT_20260305_009_Router_header_search_route_alignment.md`.
|
||||
- Safe parallelism: AdvisoryAI adapter fixes and Web fixture updates can be developed in parallel and validated together.
|
||||
- Explicit cross-module edits allowed for this sprint: `devops/compose/*`, `src/Web/StellaOps.Web/*`, `src/Router/*`, and `docs/implplan/*`.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/README.md`
|
||||
- `docs/07_HIGH_LEVEL_ARCHITECTURE.md`
|
||||
- `docs/modules/platform/architecture-overview.md`
|
||||
- `docs/modules/advisory-ai/architecture.md`
|
||||
- `docs/modules/router/architecture.md`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### AHS-01 - Stabilize header search bootstrap, auth, and ingestion path
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Developer / Implementer, QA, Test Automation
|
||||
Task description:
|
||||
- Diagnose and fix the root cause for browser load instability on `https://stella-ops.local`, then verify header search end-to-end in the same host/path.
|
||||
- Fix remaining AdvisoryAI header-search blockers (scope fixture drift, adapter endpoint mismatch, and setup-time ingestion/config readiness gaps).
|
||||
- Add deterministic setup-time checks that validate route + ingestion + query flow to prevent recurrence.
|
||||
|
||||
Completion criteria:
|
||||
- [x] `https://stella-ops.local` loads reliably without bootstrap retry loops/high CPU behavior.
|
||||
- [x] Header search returns deterministic results in browser and no longer fails due setup/config drift.
|
||||
- [x] Compose setup/redeploy includes a smoke path that detects this scenario before manual UI testing.
|
||||
- [x] Targeted tests cover adapter/fixture contracts and pass.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-05 | Sprint created and set to DOING; investigation started from `stella-ops.local` load failure, header search auth drift, and ingestion endpoint mismatch. | Implementer |
|
||||
| 2026-03-05 | Reproduced browser lock at 100% script CPU; captured debugger stack in Angular Router redirect processing and identified invalid setup redirect target (`/setup/wizard`) for current route tree. | Implementer |
|
||||
| 2026-03-05 | Fixed guard/deep-link route targets to `/setup-wizard/wizard`, kept setup ingestion smoke checks, and verified `header-search-smoke.ps1` passes including UI responsiveness probe. | Implementer |
|
||||
| 2026-03-05 | Ran targeted frontend tests: `config.guard.spec.ts` and `doctor-wizard-mapping.spec.ts` (19/19 pass). | Test Automation |
|
||||
|
||||
## Decisions & Risks
|
||||
- Risk: existing workspace has broad unrelated local changes; this sprint must keep edits minimal and scoped to header-search stability.
|
||||
- Decision: prioritize `stella-ops.local` bootstrap reliability first because unstable frontdoor TLS blocks reliable Playwright/UI evidence.
|
||||
- Decision: treat setup route correctness as part of header-search stability because misrouted setup redirects can starve the router event loop and block all topbar interactions.
|
||||
|
||||
## Next Checkpoints
|
||||
- Confirm frontdoor bootstrap fix on `https://stella-ops.local`.
|
||||
- Re-run header search browser repro and direct API probes.
|
||||
- Finalize setup smoke coverage and mark task DONE only after evidence is recorded.
|
||||
@@ -0,0 +1,144 @@
|
||||
# Sprint 20260305-007 - FE Visual QA Fixes and Global Menu Grouping
|
||||
|
||||
## Topic & Scope
|
||||
- Address QA-identified visual defects across Setup, Security Findings, Releases, Ops, and shell layout.
|
||||
- Implement mobile-first responsiveness fixes where content currently clips or hides key controls.
|
||||
- Introduce grouped global navigation menus for faster scanning and reduced cognitive load.
|
||||
- Improve readability through contrast and hierarchy adjustments without changing domain behavior.
|
||||
- Working directory: `src/Web/StellaOps.Web`.
|
||||
- Expected evidence: updated Angular components/styles, responsive screenshots, build/test output, and linked docs updates.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on current route shell and IA baseline in `src/Web/StellaOps.Web/src/app/layout/**` and `src/Web/StellaOps.Web/src/app/routes/**`.
|
||||
- Can run in parallel with non-UI backend sprints because scope is frontend-only.
|
||||
- Coordinate with any concurrent FE route/IA work to avoid collisions in shell/sidebar components.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/qa/feature-checks/FLOW.md`
|
||||
- `docs/code-of-conduct/TESTING_PRACTICES.md`
|
||||
- `docs/modules/ui/information-architecture.md`
|
||||
- `docs/ui-analysis/01_SHELL_AND_NAVIGATION.md`
|
||||
- `docs/ui-analysis/04_ADMIN_CONFIG_RELEASE_EVIDENCE_SCREENS.md`
|
||||
- `src/Web/StellaOps.Web/AGENTS.md`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-VIS-001 - Fix Findings Explorer mobile clipping and pane overflow
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Developer / Implementer, QA
|
||||
Task description:
|
||||
- Replace fixed-width compare panes with responsive behavior for tablet/mobile.
|
||||
- Ensure category, change list, and evidence panels remain reachable without horizontal clipping.
|
||||
- Preserve desktop tri-pane workflow while introducing mobile-safe stacking behavior.
|
||||
|
||||
Completion criteria:
|
||||
- [x] `/security/findings` renders without clipped controls on mobile viewport.
|
||||
- [x] No horizontal viewport overflow in compare panes at 390px width.
|
||||
- [x] Desktop layout remains functionally unchanged.
|
||||
|
||||
### FE-VIS-002 - Make Releases deployments list mobile-readable
|
||||
Status: DONE
|
||||
Dependency: FE-VIS-001
|
||||
Owners: Developer / Implementer, QA
|
||||
Task description:
|
||||
- Replace hard-clipped table container behavior with mobile-safe overflow and/or compact row presentation.
|
||||
- Preserve quick actions (`View`) and status visibility on narrow viewports.
|
||||
|
||||
Completion criteria:
|
||||
- [x] `/releases/deployments` keeps status and action affordances visible on mobile.
|
||||
- [x] Horizontal clipping is removed for small screens.
|
||||
- [x] Desktop table fidelity remains intact.
|
||||
|
||||
### FE-VIS-003 - Compact topbar context controls for mobile
|
||||
Status: DONE
|
||||
Dependency: FE-VIS-001
|
||||
Owners: Developer / Implementer, UX Specialist
|
||||
Task description:
|
||||
- Reduce topbar congestion on phones by compacting secondary controls and context chips.
|
||||
- Keep access to region/env/window/stage controls while improving first-row readability.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Topbar no longer appears crowded at 390px width.
|
||||
- [x] Context controls remain discoverable and keyboard accessible.
|
||||
- [x] Search and user menu remain consistently usable on mobile.
|
||||
|
||||
### FE-VIS-004 - Add grouping for global navigation menus
|
||||
Status: DONE
|
||||
Dependency: FE-VIS-001
|
||||
Owners: Developer / Implementer, UX Specialist
|
||||
Task description:
|
||||
- Introduce explicit global menu grouping in the sidebar to organize major navigation domains.
|
||||
- Improve scanability of long menu lists (desktop and mobile drawer states).
|
||||
|
||||
Completion criteria:
|
||||
- [x] Sidebar renders grouped menu sections with clear visual boundaries.
|
||||
- [x] Grouping remains accessible and does not break route activation styling.
|
||||
- [x] Mobile drawer menu remains readable and navigable after grouping changes.
|
||||
|
||||
### FE-VIS-005 - Improve Setup and Ops overview page hierarchy
|
||||
Status: DONE
|
||||
Dependency: FE-VIS-004
|
||||
Owners: Developer / Implementer, UX Specialist
|
||||
Task description:
|
||||
- Strengthen information hierarchy on `/setup` and `/ops` pages to reduce dead-space feel.
|
||||
- Add visual structure for primary actions, secondary drilldowns, and status-oriented framing.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Setup and Ops overview screens present clear primary/secondary action hierarchy.
|
||||
- [x] Empty vertical areas are reduced without adding placeholder noise.
|
||||
- [x] Updated layouts are responsive on desktop and mobile.
|
||||
|
||||
### FE-VIS-006 - Adjust contrast tokens for readability
|
||||
Status: DONE
|
||||
Dependency: FE-VIS-005
|
||||
Owners: Developer / Implementer, QA
|
||||
Task description:
|
||||
- Tune text/border contrast tokens used by key overview and table surfaces.
|
||||
- Keep Stella visual identity while improving legibility for secondary text and dividers.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Secondary and muted text is visibly more legible on light surfaces.
|
||||
- [x] Core border separators are easier to distinguish.
|
||||
- [x] No theme regressions in dark-mode token definitions.
|
||||
|
||||
### FE-VIS-007 - Documentation and QA evidence sync
|
||||
Status: DONE
|
||||
Dependency: FE-VIS-002
|
||||
Owners: Documentation author, QA, Project Manager
|
||||
Task description:
|
||||
- Update relevant UI architecture/analysis docs to reflect new menu grouping and responsive behavior.
|
||||
- Capture fresh Playwright screenshots for the corrected pages.
|
||||
- Record commands and outcomes in sprint execution log.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Updated docs under `docs/modules/ui/` and/or `docs/ui-analysis/`.
|
||||
- [x] New screenshot evidence captured for desktop and mobile key pages.
|
||||
- [x] Sprint Execution Log includes verification commands and outcomes.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-05 | Sprint created from QA visual review findings; FE-VIS-001 started. | Project Manager |
|
||||
| 2026-03-05 | Implemented first pass: responsive Findings panes, Releases table horizontal overflow, mobile topbar context toggle, grouped sidebar global menus, Setup/Ops hierarchy refresh, and contrast token tuning. | Developer / Implementer |
|
||||
| 2026-03-05 | Validation run: `npm run build` in `src/Web/StellaOps.Web` completed successfully (existing bundle budget warnings remain). | QA |
|
||||
| 2026-03-05 | Updated UI IA documentation with grouped global menus and mobile context control behavior note. | Documentation author |
|
||||
| 2026-03-05 | Updated `tests/e2e/nav-shell.spec.ts` expected labels for grouped global navigation and passed targeted Playwright checks for root-label presence and deprecated-label exclusion. | QA |
|
||||
| 2026-03-05 | Captured fresh desktop/mobile screenshot evidence to `output/playwright/qa-visual-review-20260305/`; identified and fixed remaining mobile Findings toolbar collision by making compare toolbar auto-height. | QA |
|
||||
| 2026-03-05 | Post-fix verification passed: `npm run build` and targeted Playwright nav shell checks (`sidebar renders all canonical root labels`, `sidebar excludes deprecated root labels`). | QA |
|
||||
| 2026-03-05 | Mobile overflow verification for `/security/findings` at 390px via Playwright probe returned `{\"docW\":390,\"docC\":390,\"bodyW\":374,\"bodyC\":374}` (no horizontal overflow). | QA |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: prioritize mobile clipping and global navigation grouping before broader aesthetic refinements.
|
||||
- Risk: shell/sidebar edits may conflict with concurrent IA work.
|
||||
- Mitigation: keep changes scoped to `src/Web/StellaOps.Web` and verify route activation behavior after grouping.
|
||||
- Risk: token-level contrast changes may cause unintentional theme drift.
|
||||
- Mitigation: constrain token edits to readability deltas and validate both light and dark token sets.
|
||||
- Decision: mobile topbar now exposes secondary context controls through an explicit `Context` toggle to reduce primary-row crowding.
|
||||
- Docs sync: IA update recorded at `docs/modules/ui/information-architecture.md` (`2026-03-05 Shell IA Update` section).
|
||||
- Evidence location: `output/playwright/qa-visual-review-20260305/` (desktop/mobile captures for Setup, Ops, Security Findings, Releases Deployments).
|
||||
|
||||
## Next Checkpoints
|
||||
- FE-VIS-001 and FE-VIS-002 merged with before/after screenshots.
|
||||
- FE-VIS-004 grouped global menu behavior validated on desktop and mobile.
|
||||
- FE-VIS-007 docs/evidence sync completed and linked from Decisions & Risks.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Sprint 20260305-008 - FE Sidebar Grouping UX Regression Fix
|
||||
|
||||
## Topic & Scope
|
||||
- Fix sidebar grouping regressions reported after the global menu grouping rollout.
|
||||
- Remove duplicated group/section labeling and reduce excessive spacing in grouped menus.
|
||||
- Restore predictable click behavior for grouped navigation entries.
|
||||
- Working directory: `src/Web/StellaOps.Web`.
|
||||
- Expected evidence: updated sidebar component, Playwright nav checks, and sprint execution logs.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on grouped sidebar implementation in `src/Web/StellaOps.Web/src/app/layout/app-sidebar/**`.
|
||||
- Safe to run in parallel with backend sprints; frontend-shell only.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/qa/feature-checks/FLOW.md`
|
||||
- `docs/code-of-conduct/TESTING_PRACTICES.md`
|
||||
- `docs/modules/ui/information-architecture.md`
|
||||
- `src/Web/StellaOps.Web/AGENTS.md`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### FE-NAV-001 - Remove duplicated menu labeling and tighten group spacing
|
||||
Status: DONE
|
||||
Dependency: none
|
||||
Owners: Developer / Implementer, UX Specialist
|
||||
Task description:
|
||||
- Refactor grouped sidebar rendering to avoid duplicate semantic labels for the same navigation section.
|
||||
- Remove or simplify decorative rails that create visual repetition and dead spacing.
|
||||
- Compress grouped block spacing to improve scanability on desktop and mobile drawers.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Group labels are rendered once per group and not repeated as decorative rails.
|
||||
- [x] Group spacing is compact and no large dead-space cards remain.
|
||||
- [x] Sidebar remains readable in both desktop and mobile drawer layouts.
|
||||
|
||||
### FE-NAV-002 - Preserve section visibility and group integrity under scope filtering
|
||||
Status: DONE
|
||||
Dependency: FE-NAV-001
|
||||
Owners: Developer / Implementer
|
||||
Task description:
|
||||
- Adjust scope-filter logic so parent sections do not disappear when child links are filtered out.
|
||||
- Ensure group composition remains deterministic and does not hide expected top-level navigation entries.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Parent sections remain visible when permitted even if all scoped children are hidden.
|
||||
- [x] Groups do not disappear unexpectedly due to child filtering.
|
||||
- [x] Existing scope restrictions still apply to hidden child links.
|
||||
|
||||
### FE-NAV-003 - Validate grouped navigation click behavior
|
||||
Status: DONE
|
||||
Dependency: FE-NAV-002
|
||||
Owners: QA, Developer / Implementer
|
||||
Task description:
|
||||
- Add/adjust Playwright nav-shell checks that verify grouped entries navigate when clicked.
|
||||
- Re-run targeted nav-shell tests to confirm no regression in grouped labels and route transitions.
|
||||
|
||||
Completion criteria:
|
||||
- [x] Clicking grouped root navigation entries triggers route changes.
|
||||
- [x] Existing grouped-label tests remain passing.
|
||||
- [x] Sprint execution log captures commands and outcomes.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-05 | Sprint created from user-reported grouped-sidebar UX regressions; FE-NAV-001 started. | Project Manager |
|
||||
| 2026-03-05 | Refactored sidebar grouped template: removed duplicate rail labels, reduced group spacing, and made group headers route to group landing pages. | Developer / Implementer |
|
||||
| 2026-03-05 | Updated scope filtering to keep parent sections visible when child links are filtered out by scope. | Developer / Implementer |
|
||||
| 2026-03-05 | Added Playwright nav-shell checks for unique clickable group headers and grouped root-item click navigation. | QA |
|
||||
| 2026-03-05 | Verification passed: `npx playwright test tests/e2e/nav-shell.spec.ts -g "group headers are unique and navigate to group landing routes|grouped root entries navigate when clicked|sidebar renders all canonical root labels|sidebar excludes deprecated root labels"` and `npm run build`. | QA |
|
||||
|
||||
## Decisions & Risks
|
||||
- Decision: prioritize structural nav usability fixes (labels, spacing, clicks) before further visual polish.
|
||||
- Risk: sidebar template changes can affect activation styling.
|
||||
- Mitigation: keep route bindings unchanged and verify with Playwright route-navigation checks.
|
||||
- Decision: group headers are now actionable links to group landing routes (`Release Control` -> `/mission-control/board`, `Security & Evidence` -> `/security`, `Platform & Setup` -> `/ops/operations`).
|
||||
|
||||
## Next Checkpoints
|
||||
- FE-NAV-001 through FE-NAV-003 completed with verification output.
|
||||
- Sidebar desktop and mobile behavior validated by targeted Playwright tests.
|
||||
@@ -74,6 +74,8 @@ The example file ships with working local-dev defaults. For production, change `
|
||||
|
||||
Stella Ops services bind to unique loopback IPs so all can use port 443 without collisions. Add the entries from `devops/compose/hosts.stellaops.local` to your hosts file:
|
||||
|
||||
Runtime URL convention remains `*.stella-ops.local`; `hosts.stellaops.local` is the template file name only.
|
||||
|
||||
- **Windows:** `C:\Windows\System32\drivers\etc\hosts` (run editor as Administrator)
|
||||
- **Linux / macOS:** `sudo sh -c 'cat devops/compose/hosts.stellaops.local >> /etc/hosts'`
|
||||
|
||||
|
||||
@@ -16,15 +16,15 @@
|
||||
| Doctor | 1 | Doctor | in-memory |
|
||||
| EvidenceLocker | 1 | EvidenceLocker | postgres |
|
||||
| ExportCenter | 1 | ExportCenter | postgres |
|
||||
| Findings | 2 | Findings.Ledger, RiskEngine | in-memory, postgres |
|
||||
| Findings | 2 | Findings.Ledger, RiskEngine | postgres (in-memory fallback in RiskEngine test profile) |
|
||||
| Integrations | 1 | Integrations | postgres |
|
||||
| JobEngine | 4 | JobEngine, PacksRegistry, Scheduler, TaskRunner | file-backed, postgres |
|
||||
| JobEngine | 4 | JobEngine, PacksRegistry, Scheduler, TaskRunner | postgres, seed-fs object-store |
|
||||
| Notifier | 1 | Notifier | postgres |
|
||||
| Notify | 1 | Notify | postgres |
|
||||
| Platform | 1 | Platform | postgres |
|
||||
| ReachGraph | 1 | ReachGraph | postgres |
|
||||
| Remediation | 1 | Remediation | postgres |
|
||||
| Replay | 1 | Replay | in-memory |
|
||||
| Replay | 1 | Replay | postgres, seed-fs object-store |
|
||||
| Router | 1 | Gateway | no-persistence |
|
||||
| Scanner | 1 | Scanner | postgres |
|
||||
| Timeline | 2 | Timeline, TimelineIndexer | postgres |
|
||||
@@ -47,18 +47,18 @@
|
||||
| EvidenceLocker | EvidenceLocker | Evidence ingest/scoring, snapshots, bundle download/portable package, verify, legal hold, plus export/verdict/evidence-thread adapters | EvidenceLockerDbContext | src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.WebService/Program.cs; src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Infrastructure/EfCore/Context/EvidenceLockerDbContext.cs |
|
||||
| ExportCenter | ExportCenter | Endpoints: Attestation, AuditBundle, ExceptionReport, ExportApi (+6 more); routes: audit-bundles, exports, incidents, lineage (+4 more) | ExportCenterDbContext | src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService/Program.cs; src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Infrastructure/EfCore/Context/ExportCenterDbContext.cs |
|
||||
| Findings | Findings.Ledger | Endpoints: Backport, EvidenceGraph, FindingSummary, ReachabilityMap (+4 more); routes: findings, scoring | FindingsLedgerDbContext | src/Findings/StellaOps.Findings.Ledger.WebService/Program.cs; src/Findings/StellaOps.Findings.Ledger/EfCore/Context/FindingsLedgerDbContext.cs |
|
||||
| Findings | RiskEngine | Endpoints: ExploitMaturity; routes: exploit-maturity | No service DB; InMemoryRiskScoreResultStore | src/Findings/StellaOps.RiskEngine.WebService/Program.cs; src/Findings/__Libraries/StellaOps.RiskEngine.Infrastructure/Stores/InMemoryRiskScoreResultStore.cs |
|
||||
| Findings | RiskEngine | Endpoints: ExploitMaturity; routes: exploit-maturity | PostgresRiskScoreResultStore (in-memory fallback retained for explicit test profile) | src/Findings/StellaOps.RiskEngine.WebService/Program.cs; src/Findings/__Libraries/StellaOps.RiskEngine.Infrastructure/Stores/PostgresRiskScoreResultStore.cs |
|
||||
| Integrations | Integrations | Endpoints: Integration; routes: integrations | IntegrationDbContext | src/Integrations/StellaOps.Integrations.WebService/Program.cs; src/Integrations/__Libraries/StellaOps.Integrations.Persistence/IntegrationDbContext.cs |
|
||||
| JobEngine | JobEngine | Endpoints: Approval, Audit, CircuitBreaker, Dag (+21 more); routes: approvals, environments, jobengine, metrics (+2 more) | JobEngineDbContext | src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService/Program.cs; src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.Infrastructure/EfCore/Context/JobEngineDbContext.cs |
|
||||
| JobEngine | PacksRegistry | Packs upload/list/content/provenance/manifest/signature, attestations, parity/lifecycle, mirrors sync, compliance summary, offline-seed export | No relational DB; filesystem repositories (packs/parity/lifecycle/audit/attestations/mirrors) | src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs; src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Infrastructure/FileSystem/FilePackRepository.cs |
|
||||
| JobEngine | PacksRegistry | Packs upload/list/content/provenance/manifest/signature, attestations, parity/lifecycle, mirrors sync, compliance summary, offline-seed export | Postgres metadata/state repositories + seed-fs blob channel (`SeedFsPacksRegistryBlobStore`) | src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs; src/JobEngine/StellaOps.PacksRegistry.__Libraries/StellaOps.PacksRegistry.Persistence/Postgres/BlobStorage/SeedFsPacksRegistryBlobStore.cs |
|
||||
| JobEngine | Scheduler | Endpoints: FailureSignature, Run, Schedule; routes: events, graphs, scheduler | SchedulerDataSource, SchedulerDbContext | src/JobEngine/StellaOps.Scheduler.WebService/Program.cs; src/JobEngine/StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Persistence/EfCore/Context/SchedulerDbContext.cs |
|
||||
| JobEngine | TaskRunner | Run simulation/execution state/logs/artifacts/approvals/cancel, attestation APIs, incident-mode APIs, SLO breach webhook | No relational DB; filesystem stores for run state/logs/approvals/artifacts | src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs; src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Infrastructure/Execution/FilePackRunStateStore.cs |
|
||||
| JobEngine | TaskRunner | Run simulation/execution state/logs/artifacts/approvals/cancel, attestation APIs, incident-mode APIs, SLO breach webhook | Postgres run state/log/approval stores + seed-fs artifact/provenance payload channel | src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs; src/JobEngine/StellaOps.TaskRunner.__Libraries/StellaOps.TaskRunner.Persistence/Postgres/Repositories/PostgresPackRunStateStore.cs |
|
||||
| Notifier | Notifier | Endpoints: Escalation, Fallback, Incident, Localization (+10 more); routes: ack, escalation-policies, escalations, fallback (+13 more) | NotifyDataSource, NotifyDbContext | src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Program.cs; src/Notify/__Libraries/StellaOps.Notify.Persistence/EfCore/Context/NotifyDbContext.cs |
|
||||
| Notify | Notify | Rules/channels/templates CRUD, deliveries history, digests, audit trail, lock APIs, internal normalize endpoints | NotifyDataSource, NotifyDbContext | src/Notify/StellaOps.Notify.WebService/Program.cs; src/Notify/__Libraries/StellaOps.Notify.Persistence/EfCore/Context/NotifyDbContext.cs |
|
||||
| Platform | Platform | Endpoints: AdministrationTrustSigningMutation, Analytics, Context, EnvironmentSettings (+19 more); routes: admin, administration, analytics, authority (+26 more) | PlatformDbContext plus read-model access to Authority/Concelier/Excititor/Scheduler/Notify/Policy contexts | src/Platform/StellaOps.Platform.WebService/Program.cs; src/Authority/__Libraries/StellaOps.Authority.Persistence/EfCore/Context/AuthorityDbContext.cs |
|
||||
| Platform | Platform | Endpoints: AdministrationTrustSigningMutation, Analytics, Context, EnvironmentSettings (+19 more); routes: admin, administration, analytics, authority (+26 more) | PlatformDbContext + module-local runtime contracts (`IReleaseControlBundleStore`, `IPlatformContextQuery`); migration-only foreign persistence references are allowlisted | src/Platform/StellaOps.Platform.WebService/Program.cs; src/Platform/StellaOps.Platform.WebService/Services/PlatformContextService.cs |
|
||||
| ReachGraph | ReachGraph | Endpoints: CveMapping, Reachability, ReachGraph | ReachGraphDataSource, ReachGraphDbContext | src/ReachGraph/StellaOps.ReachGraph.WebService/Program.cs; src/__Libraries/StellaOps.ReachGraph.Persistence/EfCore/Context/ReachGraphDbContext.cs |
|
||||
| Remediation | Remediation | Endpoints: RemediationMatch, RemediationRegistry, RemediationSource; routes: remediation | RemediationDataSource, RemediationDbContext | src/Remediation/StellaOps.Remediation.WebService/Program.cs; src/Remediation/StellaOps.Remediation.Persistence/EfCore/Context/RemediationDbContext.cs |
|
||||
| Replay | Replay | Endpoints: PointInTimeQuery, VerdictReplay; routes: pit, replay | No service DB; in-memory feed snapshot blob/index stores | src/Replay/StellaOps.Replay.WebService/Program.cs; src/Replay/StellaOps.Replay.WebService/FeedSnapshotSupport.cs |
|
||||
| Replay | Replay | Endpoints: PointInTimeQuery, VerdictReplay; routes: pit, replay | Postgres snapshot index store + seed-fs snapshot blob store | src/Replay/StellaOps.Replay.WebService/Program.cs; src/Replay/StellaOps.Replay.WebService/FeedSnapshotSupport.cs |
|
||||
| Router | Gateway | Gateway route dispatch pipeline, authz/header enforcement, transport routing, OpenAPI aggregation | No application DB; gateway routing/middleware service | src/Router/StellaOps.Gateway.WebService/Program.cs |
|
||||
| Scanner | Scanner | Endpoints: Actionables, Approval, Baseline, BatchTriage (+43 more); routes: drift, epss, github, hot-lookup (+12 more) | ScannerDbContext + ScannerSourcesDataSource + TriageDbContext (+ AuthorityDbContext path) | src/Scanner/StellaOps.Scanner.WebService/Program.cs; src/Authority/__Libraries/StellaOps.Authority.Persistence/EfCore/Context/AuthorityDbContext.cs |
|
||||
| Timeline | Timeline | Endpoints: Export, Health, Replay, Timeline (+1 more); routes: audit, timeline | EventingDataSource, EventingDbContext, TimelineCoreDataSource, TimelineCoreDbContext | src/Timeline/StellaOps.Timeline.WebService/Program.cs; src/__Libraries/StellaOps.Eventing/EfCore/Context/EventingDbContext.cs |
|
||||
@@ -76,10 +76,10 @@
|
||||
## Policy Gaps (Postgres First, RustFS for Blobs)
|
||||
| Service | Current Runtime Wiring | Compose Signal | Gap | Required Remediation |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| PacksRegistry | File repositories (`src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs` lines 29-34) | Main compose provides `ConnectionStrings__Default` (line 1769); testing compose expects `PACKSREGISTRY__STORAGE__DRIVER=postgres` (line 253) | High | Add storage driver contract; move metadata (pack/parity/lifecycle/mirror/audit) to Postgres; keep pack/provenance/attestation payloads in RustFS/seed-fs blob path. |
|
||||
| TaskRunner | File stores/readers (`src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs` lines 61,66,71,76) | Main compose provides `ConnectionStrings__Default` (line 1150); testing compose expects `TASKRUNNER__STORAGE__DRIVER=postgres` (line 271) | High | Add Postgres storage driver for run state/logs/approvals; move large artifacts to RustFS/seed-fs blob path; keep deterministic replay semantics. |
|
||||
| RiskEngine | In-memory result store (`src/Findings/StellaOps.RiskEngine.WebService/Program.cs` line 21) | Main compose provides `ConnectionStrings__Default` (line 1048) | Medium-High | Implement Postgres-backed result store with deterministic ordering/query semantics; keep in-memory only for explicit test profile. |
|
||||
| Replay | In-memory snapshot blob/index stores (`src/Replay/StellaOps.Replay.WebService/Program.cs` lines 61-62) | Main compose provides `ConnectionStrings__Default` (line 2037) | Medium-High | Persist replay snapshot index/state in Postgres; move snapshot blobs to RustFS/seed-fs object path. |
|
||||
| PacksRegistry | `Storage:Driver=postgres` plus Postgres repositories for metadata/state; `Storage:ObjectStore:Driver=seed-fs` for blob payloads; startup rejects `rustfs` and unknown object-store drivers. | Main/testing compose provide Postgres connection and service storage-driver keys. | Closed (Sprint 312 + 2026-03-05 hardening) | Keep contract seed-fs-only until a dedicated RustFS adapter sprint lands with parity tests. |
|
||||
| TaskRunner | `Storage:Driver=postgres` plus Postgres repositories for state/log/approval; `Storage:ObjectStore:Driver=seed-fs` for artifact payloads; startup rejects `rustfs` and unknown object-store drivers in WebService and Worker. | Main/testing compose provide Postgres connection and service storage-driver keys. | Closed (Sprint 312 + 2026-03-05 hardening) | Keep contract seed-fs-only until a dedicated RustFS adapter sprint lands with parity tests. |
|
||||
| RiskEngine | Postgres-backed result store (`PostgresRiskScoreResultStore`) with explicit in-memory fallback for tests. | Main compose provides `ConnectionStrings__Default` (line 1048). | Closed (Sprint 312) | Keep in-memory fallback scoped to explicit test profile only; maintain parity tests for Postgres path. |
|
||||
| Replay | `Storage:Driver=postgres` for snapshot index and `Storage:ObjectStore:Driver=seed-fs` for snapshot blobs; startup rejects `rustfs` and unknown object-store values. | Main compose provides `ConnectionStrings__Default` and storage driver keys for replay. | Closed (Sprint 312 + 2026-03-05 hardening) | Keep contract seed-fs-only until a dedicated RustFS adapter sprint lands with deterministic parity tests. |
|
||||
| OpsMemory | Postgres store exists but connection key is `ConnectionStrings:OpsMemory` with localhost fallback (`src/AdvisoryAI/StellaOps.OpsMemory.WebService/Program.cs` lines 19-20) | Main compose sets only `ConnectionStrings__Default` (line 1537) | Medium | Accept `ConnectionStrings:Default` as primary fallback or map explicit `ConnectionStrings:OpsMemory` in compose; remove localhost fallback in non-dev runtime. |
|
||||
| Scanner | Postgres + RustFS split already configured (`src/Scanner` + compose lines 652-659/720-725) | Explicitly aligned in compose | None | Use as reference implementation for storage-driver conventions. |
|
||||
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
# Sprint 20260305-002 - JobEngine Storage Completion (PacksRegistry and TaskRunner)
|
||||
|
||||
## Topic & Scope
|
||||
- Complete the remaining delivery gap for Point 1: Postgres-first metadata/state with production-ready object-store blob handling for `PacksRegistry` and `TaskRunner`.
|
||||
- Preserve deterministic replay semantics while removing non-dev ambiguity in storage-driver behavior.
|
||||
- Align runtime wiring, compose overlays, and tests so storage mode is explicit and verifiable.
|
||||
- Working directory: `src/JobEngine`.
|
||||
- Expected evidence: targeted persistence/integration test passes, compose config validation output, and updated JobEngine/platform architecture docs.
|
||||
|
||||
## Dependencies & Concurrency
|
||||
- Depends on shared storage contract documented in `docs/modules/platform/architecture.md`.
|
||||
- Can run in parallel with Replay, Remediation, and Platform boundary sprints.
|
||||
- Documentation cleanup sprint (`SPRINT_20260305_006_DOCS_webservice_catalog_and_domain_consistency.md`) depends on final runtime behavior from this sprint.
|
||||
|
||||
## Documentation Prerequisites
|
||||
- `docs/modules/platform/architecture.md`
|
||||
- `docs/modules/jobengine/architecture.md`
|
||||
- `src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService/Program.cs`
|
||||
- `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService/Program.cs`
|
||||
- `docs/implplan/CONSOLIDATION_WEBSERVICE_FUNCTION_DB_MATRIX_20260305.md`
|
||||
|
||||
## Delivery Tracker
|
||||
|
||||
### JOBENG-STOR-001 - Reconcile declared driver contract with actual runtime behavior
|
||||
Status: TODO
|
||||
Dependency: none
|
||||
Owners: Project Manager, Implementer
|
||||
Task description:
|
||||
- Produce a precise behavior matrix for `Storage:Driver` and `Storage:ObjectStore:Driver` for both services.
|
||||
- Confirm and document current mismatch points (for example, drivers accepted by validation but not backed by concrete adapter behavior).
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Behavior matrix committed under module docs with config keys, defaults, and startup fail-fast rules.
|
||||
- [ ] Every accepted driver value is either fully implemented or explicitly rejected with deterministic startup failure.
|
||||
|
||||
### JOBENG-STOR-002 - Implement production RustFS object-store adapters for blob payloads
|
||||
Status: TODO
|
||||
Dependency: JOBENG-STOR-001
|
||||
Owners: Implementer, Test Automation
|
||||
Task description:
|
||||
- Implement and wire RustFS/S3-compatible blob adapters for:
|
||||
- `PacksRegistry` pack/provenance/attestation payload channels.
|
||||
- `TaskRunner` run artifact payload channel.
|
||||
- Preserve existing Postgres-backed metadata stores and deterministic ordering semantics.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] `Storage:ObjectStore:Driver=rustfs` uses concrete RustFS adapter implementations in both services.
|
||||
- [ ] Existing `seed-fs` behavior remains supported for local/offline deterministic workflows.
|
||||
- [ ] Non-development startup fails when RustFS is configured without required endpoint/credentials settings.
|
||||
|
||||
### JOBENG-STOR-003 - Harden non-development startup behavior and fallback policy
|
||||
Status: TODO
|
||||
Dependency: JOBENG-STOR-002
|
||||
Owners: Implementer
|
||||
Task description:
|
||||
- Remove silent non-dev behavior drift by enforcing explicit fail-fast for missing Postgres/object-store configuration.
|
||||
- Ensure development-only fallback behavior is intentional, documented, and test-covered.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Non-development runtime has no implicit filesystem fallback for stores expected to be Postgres-backed.
|
||||
- [ ] Error messages are actionable and identify missing config keys.
|
||||
- [ ] Startup behavior is covered by automated tests for success/failure modes.
|
||||
|
||||
### JOBENG-STOR-004 - Expand deterministic storage tests across drivers
|
||||
Status: TODO
|
||||
Dependency: JOBENG-STOR-002
|
||||
Owners: Test Automation
|
||||
Task description:
|
||||
- Add targeted tests that validate parity across `postgres + seed-fs` and `postgres + rustfs`.
|
||||
- Include replay-critical assertions for stable ordering, digest consistency, and tenant isolation.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] Targeted test projects include both happy-path and misconfiguration-path assertions.
|
||||
- [ ] Evidence captures command output and test counts for each driver profile.
|
||||
- [ ] No regression in existing persistence tests for Postgres repositories.
|
||||
|
||||
### JOBENG-STOR-005 - Update architecture and operations docs for final storage contract
|
||||
Status: TODO
|
||||
Dependency: JOBENG-STOR-003
|
||||
Owners: Documentation author, Implementer
|
||||
Task description:
|
||||
- Update JobEngine and platform storage docs with final runtime contract, config examples, and migration notes.
|
||||
- Record decisions and residual risks in sprint log and link to docs changed.
|
||||
|
||||
Completion criteria:
|
||||
- [ ] `docs/modules/jobengine/architecture.md` and `docs/modules/platform/architecture.md` reflect final behavior.
|
||||
- [ ] Compose/ops guidance references valid config keys for both services.
|
||||
- [ ] Sprint Decisions & Risks includes links to all updated docs.
|
||||
|
||||
## Execution Log
|
||||
| Date (UTC) | Update | Owner |
|
||||
| --- | --- | --- |
|
||||
| 2026-03-05 | Sprint created from architecture review; points 1 and 2 were partially implemented and require completion/hardening work. | Project Manager |
|
||||
|
||||
## Decisions & Risks
|
||||
- Current code already wires Postgres state stores for TaskRunner and Postgres persistence extension for PacksRegistry, but remaining object-store adapter parity and fallback hardening are unresolved.
|
||||
- `PacksRegistry` currently carries an explicit RustFS-not-implemented guard in runtime contract paths; this blocks full completion of Point 1 in production modes.
|
||||
- `TaskRunner` currently accepts object-store driver values while artifact reading remains filesystem-root based; implementation parity must be enforced to avoid config drift.
|
||||
- Mitigation: complete adapter implementation and add startup contract tests before documentation sprint declares Point 1 as complete.
|
||||
|
||||
## Next Checkpoints
|
||||
- Driver matrix and gap report complete.
|
||||
- RustFS adapter PR ready with targeted test evidence.
|
||||
- Docs and compose parity review complete before marking DONE.
|
||||
@@ -173,6 +173,8 @@ The TaskRunner provides the execution substrate for Orchestrator jobs. Workers p
|
||||
- `Storage:Driver=postgres` is the production default for run state, logs, and approvals.
|
||||
- Postgres-backed stores: `PostgresPackRunStateStore`, `PostgresPackRunLogStore`, `PostgresPackRunApprovalStore` via `TaskRunnerDataSource`.
|
||||
- Artifact payload channel uses object storage path (`seed-fs` driver) configured with `TaskRunner:Storage:ObjectStore:SeedFs:RootPath`.
|
||||
- Startup fails fast when `Storage:ObjectStore:Driver` is set to `rustfs` (not implemented) or any unsupported driver value.
|
||||
- Non-development startup fails fast when `Storage:Driver=postgres` and no connection string is configured.
|
||||
- Explicit non-production overrides remain available (`filesystem`, `inmemory`) but are no longer implicit defaults.
|
||||
|
||||
### 8.3) PacksRegistry subdomain
|
||||
@@ -186,6 +188,8 @@ The PacksRegistry manages compliance/automation pack definitions, versions, and
|
||||
**Database and storage contract (Sprint 312):**
|
||||
- `Storage:Driver=postgres` is the production default for metadata/state repositories (`pack`, `parity`, `lifecycle`, `mirror`, `audit`, `attestation metadata`).
|
||||
- Blob/object payloads (`pack content`, `provenance content`, `attestation content`) are persisted through the seed-fs object-store channel (`SeedFsPacksRegistryBlobStore`).
|
||||
- Startup fails fast when `Storage:ObjectStore:Driver` is set to `rustfs` (not implemented) or any unsupported driver value.
|
||||
- Non-development startup fails fast when `Storage:Driver=postgres` and no connection string is configured.
|
||||
- PostgreSQL keeps metadata and compatibility placeholders; payload retrieval resolves from object storage first.
|
||||
- Explicit non-production overrides remain available (`filesystem`, `inmemory`) but are no longer implicit defaults.
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ This contract is the default for all stateful StellaOps webservices unless a mod
|
||||
- Production default: `postgres`.
|
||||
- `inmemory` and `filesystem` are non-production/testing-only and must be explicitly configured.
|
||||
- `Storage:ObjectStore:Driver`
|
||||
- Accepted values: `rustfs`, `seed-fs`.
|
||||
- Accepted values at platform key level: `rustfs`, `seed-fs`.
|
||||
- Module runtime contracts may narrow this set and must fail fast for unsupported values.
|
||||
- Use only for blob/object payload channels (artifacts, snapshots, package blobs).
|
||||
- `ConnectionStrings:Default`
|
||||
- Required when `Storage:Driver=postgres` unless a service-specific connection key is provided.
|
||||
@@ -40,12 +41,43 @@ Fail-fast policy:
|
||||
- Development runtime may use localhost/file defaults only when explicitly intended for local workflows.
|
||||
|
||||
Current implementation status (2026-03-05):
|
||||
- `PacksRegistry`: Postgres metadata/state + seed-fs payload channel for pack/provenance/attestation blobs.
|
||||
- `TaskRunner`: Postgres run state/log/approval + seed-fs artifact payload channel.
|
||||
- `PacksRegistry`: Postgres metadata/state + seed-fs payload channel for pack/provenance/attestation blobs; startup rejects `rustfs` and unknown object-store drivers.
|
||||
- `TaskRunner`: Postgres run state/log/approval + seed-fs artifact payload channel; startup rejects `rustfs` and unknown object-store drivers in both WebService and Worker.
|
||||
- `RiskEngine`: Postgres-backed result store (`riskengine.risk_score_results`) with explicit in-memory test fallback.
|
||||
- `Replay`: Postgres snapshot index + seed-fs snapshot blob store.
|
||||
- `Replay`: Postgres snapshot index + seed-fs snapshot blob store; startup rejects `rustfs` and unknown object-store drivers.
|
||||
- `OpsMemory`: connection precedence aligned to `ConnectionStrings:OpsMemory -> ConnectionStrings:Default`, with non-development fail-fast.
|
||||
|
||||
## Platform Runtime Read-Model Boundary Policy (Point 4 / Sprint 20260305-005)
|
||||
|
||||
Platform runtime read-model APIs are aggregation-only and must stay behind explicit query contracts. Runtime read handlers must not take direct dependencies on foreign module persistence internals.
|
||||
|
||||
Approved runtime query contracts:
|
||||
- `IReleaseControlBundleStore` (release/topology/security/integration projections over release-control bundles + runs).
|
||||
- `IPlatformContextQuery` (read-only access to region/environment context inventory).
|
||||
|
||||
Prohibited in runtime read-model services:
|
||||
- Direct constructor dependencies on foreign `StellaOps.*.Persistence*` namespaces.
|
||||
- Direct `DbContext`, `NpgsqlDataSource`, or module-specific migration runner dependencies from non-admin read endpoints.
|
||||
|
||||
Migration/admin allowlist (explicit boundary exceptions):
|
||||
- `src/Platform/StellaOps.Platform.WebService/Endpoints/SeedEndpoints.cs`
|
||||
- `src/Platform/__Libraries/StellaOps.Platform.Database/MigrationModulePlugins.cs`
|
||||
|
||||
Enforcement:
|
||||
- Guard tests in `src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformRuntimeBoundaryGuardTests.cs` fail when constructor contracts drift or foreign persistence references appear outside the allowlist above.
|
||||
|
||||
### Runtime Dependency Inventory (2026-03-05)
|
||||
|
||||
| Component | Dependency category | Classification | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `ReleaseReadModelService` | `IReleaseControlBundleStore` | Allowed runtime read-model dependency | Release projection reads only via Platform-owned bundle-store contract. |
|
||||
| `TopologyReadModelService` | `IReleaseControlBundleStore`, `IPlatformContextQuery` | Allowed runtime read-model dependency | Topology projection composes release bundles with context inventory through explicit query contracts. |
|
||||
| `SecurityReadModelService` | `IReleaseControlBundleStore`, `IPlatformContextQuery` | Allowed runtime read-model dependency | Security projection remains synthetic/read-only and does not call VEX/exception write stores directly. |
|
||||
| `IntegrationsReadModelService` | `IReleaseControlBundleStore`, `IPlatformContextQuery` | Allowed runtime read-model dependency | Integration freshness projection uses release run metadata and context inventory only. |
|
||||
| `PlatformContextService` | `IPlatformContextStore` (`InMemory`/`Postgres`) | Allowed runtime dependency (module-local persistence) | Exposes read-only `IPlatformContextQuery` plus preference write APIs; no foreign module coupling. |
|
||||
| `SeedEndpoints` | Foreign `StellaOps.*.Persistence*` migration assemblies | Migration/admin-only dependency | Allowed exception for demo seed execution only (`platform.setup.admin`). |
|
||||
| `MigrationModulePlugins` | Foreign module migration assemblies | Migration-only dependency | Allowed exception for schema migration orchestration, not part of runtime read endpoint execution path. |
|
||||
|
||||
## Advisory Commitments (2026-02-26 Batch)
|
||||
|
||||
- `SPRINT_20260226_223_Platform_score_explain_contract_and_replay_alignment` defines deterministic score/explain/replay contract behavior for CLI and Web consumers.
|
||||
|
||||
@@ -161,6 +161,14 @@ Provide a single, deterministic aggregation layer for cross-service UX workflows
|
||||
- Notifier (alert policies and delivery status)
|
||||
- Policy/Scanner/Registry/VexHub (search aggregation sources)
|
||||
|
||||
## Runtime boundary policy
|
||||
- Runtime read-model services (`/api/v2/releases`, `/api/v2/topology/*`, `/api/v2/security/*`, `/api/v2/integrations/*`) must depend only on explicit query contracts:
|
||||
- `IReleaseControlBundleStore`
|
||||
- `IPlatformContextQuery`
|
||||
- Foreign module persistence references are migration/admin-only and limited to explicit allowlist surfaces (`SeedEndpoints`, `MigrationModulePlugins`).
|
||||
- Runtime read endpoints must not inject foreign `*.Persistence*` types, `DbContext` from other modules, or migration runners directly.
|
||||
- Guard tests: `src/Platform/__Tests/StellaOps.Platform.WebService.Tests/PlatformRuntimeBoundaryGuardTests.cs`.
|
||||
|
||||
## Security and scopes
|
||||
- Health: `ops.health` (summary), `ops.admin` (metrics)
|
||||
- Quotas: `quota.read` (summary), `quota.admin` (alerts/config)
|
||||
|
||||
@@ -42,6 +42,7 @@ Legend:
|
||||
| policy-engine.stella-ops.local | policy-engine | /api/risk, /api/risk-budget, /api/v1/determinization, /policyEngine | C | Developer + Test Automation (Wave C) | Migrate API prefixes first; keep root compatibility path until control-plane verification completes. | Route type revert + `POLICY_ENGINE_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| policy-gateway.stella-ops.local | policy | /api/cvss, /api/exceptions, /api/gate, /api/policy, /api/v1/governance, /api/v1/policy, /policy, /policyGateway | C | Developer + Test Automation (Wave C) | Migrate API prefixes first; keep `/policy` and `/policyGateway` compatibility paths until final cutover. | Route type revert + `POLICY_GATEWAY_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| reachgraph.stella-ops.local | reachgraph-web | /api/v1/reachability, /reachgraph | D | Developer + Test Automation (Wave D) | Migrate API prefix first, then root compatibility path. | Route type revert + `REACHGRAPH_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| remediation.stella-ops.local | — (not in compose snapshot) | — (no ReverseProxy route in 2026-02-21 snapshot) | C | Developer + Test Automation (Wave C) | `StellaOps.Remediation.WebService` exists, but router/compose mapping is missing. Add explicit remediation API route inventory and then migrate to Microservice route type in control-plane wave. | Missing rollback key; add `REMEDIATION_ROUTER_ENABLED` once route is added. |
|
||||
| registry-token.stella-ops.local | registry-token | /registryTokenservice | A | Developer + Test Automation (Wave A) | Migrate compatibility route with token flow validation in Wave A. | Route type revert + `REGISTRY_TOKEN_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| replay.stella-ops.local | replay-web | /replay | A | Developer + Test Automation (Wave A) | Migrate compatibility route in Wave A; add API-form alias if needed. | Route type revert + `REPLAY_ROUTER_ENABLED=false` (RMW-03). |
|
||||
| riskengine.stella-ops.local | riskengine-web | /riskengine | C | Developer + Test Automation (Wave C) | Migrate compatibility route in control-plane wave; add API alias if required. | Route type revert + `RISKENGINE_ROUTER_ENABLED=false` (RMW-03). |
|
||||
|
||||
@@ -4,6 +4,18 @@
|
||||
|
||||
This document defines the information architecture (IA) for the StellaOps web interface, including navigation structure, route hierarchy, and role-based access patterns.
|
||||
|
||||
## 2026-03-05 Shell IA Update
|
||||
|
||||
The global shell navigation was updated to improve visual scanability and mobile usability:
|
||||
|
||||
- Sidebar global menus are now grouped into:
|
||||
- `Release Control`
|
||||
- `Security & Evidence`
|
||||
- `Platform & Setup`
|
||||
- Group headers act as direct links to each group's landing route for faster navigation.
|
||||
- Mobile topbar secondary controls now open through an explicit `Context` toggle instead of always occupying visible row space.
|
||||
- Findings compare panes and Releases deployment list were updated for mobile-safe layouts (no forced clipping).
|
||||
|
||||
## Navigation Structure
|
||||
|
||||
### Primary Navigation
|
||||
|
||||
@@ -112,6 +112,9 @@ After a full setup, you'll have 60+ services running locally:
|
||||
|
||||
Full service list: `devops/compose/docker-compose.stella-ops.yml`
|
||||
|
||||
Runtime URL convention: use `*.stella-ops.local` hostnames for services.
|
||||
Exception: `hosts.stellaops.local` is a file name, not a runtime host.
|
||||
|
||||
Optional Sigstore services (`rekor-v2`, `rekor-cli`, `cosign`) are enabled only with:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -11,12 +11,28 @@ Use this index to locate platform-level architecture references and per-module d
|
||||
- [Component map](component-map.md) (quick descriptions of every module under `src/`)
|
||||
|
||||
## Detailed references
|
||||
- [Canonical webservice catalog](webservice-catalog.md)
|
||||
- [Platform topology](platform-topology.md)
|
||||
- [Infrastructure dependencies](infrastructure-dependencies.md)
|
||||
- [Request and data flows](request-flows.md)
|
||||
- [Data isolation model](data-isolation.md)
|
||||
- [Security boundaries](security-boundaries.md)
|
||||
|
||||
## Docs validation
|
||||
|
||||
Use the architecture docs validation check to detect service-path and hostname drift:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File ./docs/technical/architecture/scripts/validate-webservice-docs.ps1
|
||||
```
|
||||
|
||||
Intentional failing fixture (sanity check for the validator):
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File ./docs/technical/architecture/scripts/validate-webservice-docs.ps1 `
|
||||
-Files "docs/technical/architecture/fixtures/webservice-docs-invalid-fixture.md"
|
||||
```
|
||||
|
||||
## User-centric views (NEW)
|
||||
- [User flows (UML diagrams)](user-flows.md) - End-to-end flows from user perspective
|
||||
- [Module matrix](module-matrix.md) - Complete 46-module inventory with categorization
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Validation Fixture: Expected to fail
|
||||
|
||||
This fixture intentionally includes drift so `validate-webservice-docs.ps1` can prove detection.
|
||||
|
||||
| Service | Hostname | Path |
|
||||
| --- | --- | --- |
|
||||
| DemoService | `demo.stellaops.local` | `src/DoesNotExist/Missing.WebService` |
|
||||
|
||||
Runtime URL sample: https://demo.stellaops.local/api/v1/demo
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
All Stella Ops web services are assigned deterministic HTTPS/HTTP port pairs to avoid collisions during local development and simplify service discovery configuration.
|
||||
|
||||
Service inventory source-of-truth: `docs/technical/architecture/webservice-catalog.md`.
|
||||
This page focuses on deterministic slot/port allocation and may include legacy or unassigned notes.
|
||||
|
||||
## Port Assignment Scheme
|
||||
|
||||
- **Formula**: Service at slot N → HTTPS = `10000 + N×10`, HTTP = `10000 + N×10 + 1`
|
||||
@@ -22,7 +25,7 @@ All Stella Ops web services are assigned deterministic HTTPS/HTTP port pairs to
|
||||
| 7 | 10070 | 10071 | Evidence Locker Aggregator | — | `src/EvidenceLocker/StellaOps.EvidenceLocker` | `STELLAOPS_EVIDENCELOCKER_AGGREGATOR_URL` |
|
||||
| 8 | 10080 | 10081 | Scanner | `scanner.stella-ops.local` | `src/Scanner/StellaOps.Scanner.WebService` | `STELLAOPS_SCANNER_URL` |
|
||||
| 9 | 10090 | 10091 | Concelier | `concelier.stella-ops.local` | `src/Concelier/StellaOps.Concelier.WebService` | `STELLAOPS_CONCELIER_URL` |
|
||||
| 10 | 10100 | 10101 | Excititor | `excititor.stella-ops.local` | `src/Excititor/StellaOps.Excititor.WebService` | `STELLAOPS_EXCITITOR_URL` |
|
||||
| 10 | 10100 | 10101 | Excititor | `excititor.stella-ops.local` | `src/Concelier/StellaOps.Excititor.WebService` | `STELLAOPS_EXCITITOR_URL` |
|
||||
| 11 | 10110 | 10111 | VexHub | `vexhub.stella-ops.local` | `src/VexHub/StellaOps.VexHub.WebService` | `STELLAOPS_VEXHUB_URL` |
|
||||
| 12 | 10120 | 10121 | VexLens | `vexlens.stella-ops.local` | `src/VexLens/StellaOps.VexLens.WebService` | `STELLAOPS_VEXLENS_URL` |
|
||||
| 13 | 10130 | 10131 | VulnExplorer | `vulnexplorer.stella-ops.local` | `src/Findings/StellaOps.VulnExplorer.Api` | `STELLAOPS_VULNEXPLORER_URL` |
|
||||
@@ -30,8 +33,8 @@ All Stella Ops web services are assigned deterministic HTTPS/HTTP port pairs to
|
||||
| 15 | 10150 | 10151 | Policy Gateway | `policy-gateway.stella-ops.local` | `src/Policy/StellaOps.Policy.Gateway` | `STELLAOPS_POLICY_GATEWAY_URL` |
|
||||
| 16 | 10160 | 10161 | RiskEngine | `riskengine.stella-ops.local` | `src/Findings/StellaOps.RiskEngine.WebService` | `STELLAOPS_RISKENGINE_URL` |
|
||||
| 17 | 10170 | 10171 | Orchestrator | `jobengine.stella-ops.local` | `src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService` | `STELLAOPS_JOBENGINE_URL` |
|
||||
| 18 | 10180 | 10181 | TaskRunner | `taskrunner.stella-ops.local` | `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService` | `STELLAOPS_TASKRUNNER_URL` |
|
||||
| 19 | 10190 | 10191 | Scheduler | `scheduler.stella-ops.local` | `src/Scheduler/StellaOps.Scheduler.WebService` | `STELLAOPS_SCHEDULER_URL` |
|
||||
| 18 | 10180 | 10181 | TaskRunner | `taskrunner.stella-ops.local` | `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService` | `STELLAOPS_TASKRUNNER_URL` |
|
||||
| 19 | 10190 | 10191 | Scheduler | `scheduler.stella-ops.local` | `src/JobEngine/StellaOps.Scheduler.WebService` | `STELLAOPS_SCHEDULER_URL` |
|
||||
| 20 | 10200 | 10201 | Graph API | `graph.stella-ops.local` | `src/Graph/StellaOps.Graph.Api` | `STELLAOPS_GRAPH_URL` |
|
||||
| 21 | 10210 | 10211 | Cartographer | `cartographer.stella-ops.local` | `src/Scanner/StellaOps.Scanner.Cartographer` | `STELLAOPS_CARTOGRAPHER_URL` |
|
||||
| 22 | 10220 | 10221 | ReachGraph | `reachgraph.stella-ops.local` | `src/ReachGraph/StellaOps.ReachGraph.WebService` | `STELLAOPS_REACHGRAPH_URL` |
|
||||
@@ -42,14 +45,14 @@ All Stella Ops web services are assigned deterministic HTTPS/HTTP port pairs to
|
||||
| 27 | 10270 | 10271 | OpsMemory | `opsmemory.stella-ops.local` | `src/AdvisoryAI/StellaOps.OpsMemory.WebService` | `STELLAOPS_OPSMEMORY_URL` |
|
||||
| 28 | 10280 | 10281 | Notifier | `notifier.stella-ops.local` | `src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService` | `STELLAOPS_NOTIFIER_URL` |
|
||||
| 29 | 10290 | 10291 | Notify | `notify.stella-ops.local` | `src/Notify/StellaOps.Notify.WebService` | `STELLAOPS_NOTIFY_URL` |
|
||||
| 30 | 10300 | 10301 | Signer | `signer.stella-ops.local` | `src/Signer/StellaOps.Signer/StellaOps.Signer.WebService` | `STELLAOPS_SIGNER_URL` |
|
||||
| 30 | 10300 | 10301 | Signer | `signer.stella-ops.local` | `src/Attestor/StellaOps.Signer/StellaOps.Signer.WebService` | `STELLAOPS_SIGNER_URL` |
|
||||
| 31 | 10310 | 10311 | SmRemote | `smremote.stella-ops.local` | `src/SmRemote/StellaOps.SmRemote.Service` | `STELLAOPS_SMREMOTE_URL` |
|
||||
| 32 | 10320 | 10321 | AirGap Controller | `airgap-controller.stella-ops.local` | `src/AirGap/StellaOps.AirGap.Controller` | `STELLAOPS_AIRGAP_CONTROLLER_URL` |
|
||||
| 33 | 10330 | 10331 | AirGap Time | `airgap-time.stella-ops.local` | `src/AirGap/StellaOps.AirGap.Time` | `STELLAOPS_AIRGAP_TIME_URL` |
|
||||
| 34 | 10340 | 10341 | PacksRegistry | `packsregistry.stella-ops.local` | `src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService` | `STELLAOPS_PACKSREGISTRY_URL` |
|
||||
| 34 | 10340 | 10341 | PacksRegistry | `packsregistry.stella-ops.local` | `src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService` | `STELLAOPS_PACKSREGISTRY_URL` |
|
||||
| 35 | 10350 | 10351 | Registry Token | `registry-token.stella-ops.local` | `src/Registry/StellaOps.Registry.TokenService` | `STELLAOPS_REGISTRY_TOKENSERVICE_URL` |
|
||||
| 36 | 10360 | 10361 | BinaryIndex | `binaryindex.stella-ops.local` | `src/BinaryIndex/StellaOps.BinaryIndex.WebService` | `STELLAOPS_BINARYINDEX_URL` |
|
||||
| 37 | 10370 | 10371 | IssuerDirectory | `issuerdirectory.stella-ops.local` | `src/IssuerDirectory/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService` | `STELLAOPS_ISSUERDIRECTORY_URL` |
|
||||
| 37 | 10370 | 10371 | IssuerDirectory | `issuerdirectory.stella-ops.local` | `src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService` | `STELLAOPS_ISSUERDIRECTORY_URL` |
|
||||
| 38 | 10380 | 10381 | Symbols | `symbols.stella-ops.local` | `src/BinaryIndex/StellaOps.Symbols.Server` | `STELLAOPS_SYMBOLS_URL` |
|
||||
| 39 | 10390 | 10391 | SbomService | `sbomservice.stella-ops.local` | `src/SbomService/StellaOps.SbomService` | `STELLAOPS_SBOMSERVICE_URL` |
|
||||
| 40 | 10400 | 10401 | ExportCenter | `exportcenter.stella-ops.local` | `src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService` | `STELLAOPS_EXPORTCENTER_URL` |
|
||||
@@ -62,6 +65,8 @@ All Stella Ops web services are assigned deterministic HTTPS/HTTP port pairs to
|
||||
| 90 | 10900 | 10901 | Examples.Gateway | — | `src/Router/examples/Examples.Gateway` | — |
|
||||
| 91 | 10910 | 10911 | Examples.MultiTransport | — | `src/Router/examples/Examples.MultiTransport.Gateway` | — |
|
||||
|
||||
> Remediation runtime note: `src/Remediation/StellaOps.Remediation.WebService` is active and binds `remediation.stella-ops.local`, but no deterministic slot is currently published in this table because compose/router inventory does not yet expose a stable route mapping. Track status in `docs/modules/router/webservices-valkey-rollout-matrix.md`.
|
||||
|
||||
## Worker Services
|
||||
|
||||
Worker services associated with a web service use ports offset by +2/+3 from the web service slot:
|
||||
@@ -71,17 +76,17 @@ Worker services associated with a web service use ports offset by +2/+3 from the
|
||||
| 10062 | 10063 | EvidenceLocker Worker | `src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Worker` |
|
||||
| 10162 | 10163 | RiskEngine Worker | `src/Findings/StellaOps.RiskEngine.Worker` |
|
||||
| 10172 | 10173 | Orchestrator Worker | `src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.Worker` |
|
||||
| 10182 | 10183 | TaskRunner Worker | `src/TaskRunner/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker` |
|
||||
| 10182 | 10183 | TaskRunner Worker | `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Worker` |
|
||||
| 10232 | 10233 | TimelineIndexer Worker | `src/Timeline/StellaOps.TimelineIndexer.Worker` |
|
||||
| 10282 | 10283 | Notifier Worker | `src/Notifier/StellaOps.Notifier/StellaOps.Notifier.Worker` |
|
||||
| 10342 | 10343 | PacksRegistry Worker | `src/PacksRegistry/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Worker` |
|
||||
| 10342 | 10343 | PacksRegistry Worker | `src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.Worker` |
|
||||
| 10402 | 10403 | ExportCenter Worker | `src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.Worker` |
|
||||
|
||||
## Environment Variable Convention
|
||||
|
||||
Each web service has a corresponding `STELLAOPS_{SERVICE}_URL` environment variable. The Platform service reads these at startup (Layer 1 of the 3-layer configuration) and maps them into `ApiBaseUrls` for the Angular frontend.
|
||||
|
||||
Example: `STELLAOPS_SCANNER_URL=https://scanner.internal:10080` maps to `ApiBaseUrls["scanner"]`.
|
||||
Example: `STELLAOPS_SCANNER_URL=https://scanner.stella-ops.local` maps to `ApiBaseUrls["scanner"]`.
|
||||
|
||||
See also: [3-Layer Service URL Configuration](../../modules/platform/architecture-overview.md)
|
||||
|
||||
|
||||
103
docs/technical/architecture/scripts/validate-webservice-docs.ps1
Normal file
103
docs/technical/architecture/scripts/validate-webservice-docs.ps1
Normal file
@@ -0,0 +1,103 @@
|
||||
param(
|
||||
[string[]] $Files = @(
|
||||
"docs/technical/architecture/port-registry.md",
|
||||
"docs/technical/architecture/webservice-catalog.md"
|
||||
)
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Get-RepositoryRoot {
|
||||
param([string] $Start)
|
||||
|
||||
$current = Resolve-Path $Start
|
||||
while ($null -ne $current) {
|
||||
if (Test-Path (Join-Path $current "docs")) {
|
||||
return $current
|
||||
}
|
||||
|
||||
$parent = Split-Path -Parent $current
|
||||
if ($parent -eq $current) {
|
||||
break
|
||||
}
|
||||
|
||||
$current = $parent
|
||||
}
|
||||
|
||||
throw "Could not locate repository root from '$Start'."
|
||||
}
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = Get-RepositoryRoot -Start $scriptDir
|
||||
|
||||
$pathViolations = [System.Collections.Generic.List[string]]::new()
|
||||
$hostViolations = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
$pathPattern = [regex]'`(?<path>src/[^`]+)`'
|
||||
$urlPattern = [regex]'https?://(?<host>[A-Za-z0-9.-]+)'
|
||||
$legacyHostPattern = [regex]'\b(?<host>[a-z0-9.-]+\.stellaops\.local)\b'
|
||||
|
||||
foreach ($relativeFile in $Files) {
|
||||
$normalizedRelative = $relativeFile.Replace('\\', '/').Trim()
|
||||
$absoluteFile = Join-Path $repoRoot $normalizedRelative
|
||||
|
||||
if (-not (Test-Path $absoluteFile)) {
|
||||
throw "Validation file not found: $normalizedRelative"
|
||||
}
|
||||
|
||||
$lineNumber = 0
|
||||
foreach ($line in Get-Content $absoluteFile) {
|
||||
$lineNumber++
|
||||
|
||||
foreach ($match in $pathPattern.Matches($line)) {
|
||||
$pathValue = $match.Groups['path'].Value.Trim()
|
||||
$absolutePath = Join-Path $repoRoot ($pathValue.Replace('/', [IO.Path]::DirectorySeparatorChar))
|
||||
if (-not (Test-Path $absolutePath)) {
|
||||
$pathViolations.Add("${normalizedRelative}:$lineNumber unresolved path '$pathValue'")
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($match in $urlPattern.Matches($line)) {
|
||||
$hostValue = $match.Groups['host'].Value.ToLowerInvariant()
|
||||
if ($hostValue -eq "localhost") {
|
||||
continue
|
||||
}
|
||||
|
||||
if ($hostValue.StartsWith("127.")) {
|
||||
continue
|
||||
}
|
||||
|
||||
if ($hostValue -eq "stella-ops.local" -or $hostValue.EndsWith(".stella-ops.local")) {
|
||||
continue
|
||||
}
|
||||
|
||||
$hostViolations.Add("${normalizedRelative}:$lineNumber non-canonical runtime host '$hostValue'")
|
||||
}
|
||||
|
||||
foreach ($match in $legacyHostPattern.Matches($line)) {
|
||||
$hostValue = $match.Groups['host'].Value.ToLowerInvariant()
|
||||
if ($hostValue -eq "hosts.stellaops.local") {
|
||||
continue
|
||||
}
|
||||
|
||||
$hostViolations.Add("${normalizedRelative}:$lineNumber forbidden legacy hostname '$hostValue' (expected .stella-ops.local)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($pathViolations.Count -eq 0 -and $hostViolations.Count -eq 0) {
|
||||
Write-Output "PASS validate-webservice-docs: files=$($Files.Count), pathViolations=0, hostViolations=0"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Output "FAIL validate-webservice-docs"
|
||||
foreach ($violation in $pathViolations) {
|
||||
Write-Output "PATH: $violation"
|
||||
}
|
||||
|
||||
foreach ($violation in $hostViolations) {
|
||||
Write-Output "HOST: $violation"
|
||||
}
|
||||
|
||||
exit 1
|
||||
56
docs/technical/architecture/webservice-catalog.md
Normal file
56
docs/technical/architecture/webservice-catalog.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Canonical Webservice Catalog
|
||||
|
||||
This page is the source-of-truth inventory for Stella Ops `*.WebService` runtime services.
|
||||
|
||||
## Scope and contract
|
||||
- Inventory source: `rg --files src -g "*WebService.csproj"`.
|
||||
- Includes active runtime webservices only (31 services).
|
||||
- Excludes non-`WebService` API binaries (for example `StellaOps.Policy.Engine`, `StellaOps.Policy.Gateway`, `StellaOps.Graph.Api`, `StellaOps.VulnExplorer.Api`, `StellaOps.Symbols.Server`, `StellaOps.Registry.TokenService`, `StellaOps.SmRemote.Service`) even though they may bind `*.stella-ops.local` aliases.
|
||||
- Canonical runtime hostname form: `<service>.stella-ops.local`.
|
||||
|
||||
## Runtime hostname convention and exceptions
|
||||
- Runtime service-discovery URLs in docs should use `https://<service>.stella-ops.local` (or the HTTP equivalent when TLS is intentionally not shown).
|
||||
- Permitted exceptions:
|
||||
- Infrastructure aliases (`db.stella-ops.local`, `cache.stella-ops.local`, `s3.stella-ops.local`, `rekor.stella-ops.local`, `registry.stella-ops.local`).
|
||||
- Loopback/dev diagnostics (`localhost`, `127.x.y.z`) where transport wiring is the point of the example.
|
||||
- Non-runtime identifiers/file names (for example `hosts.stellaops.local` file path).
|
||||
|
||||
## Active webservices
|
||||
| Domain | Webservice | Local hostname | Purpose | Persistence | Source path | Owner module |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| AdvisoryAI | AdvisoryAI | `advisoryai.stella-ops.local` | Advisory assistant APIs (chat, evidence-pack, knowledge search). | postgres | `src/AdvisoryAI/StellaOps.AdvisoryAI.WebService` | `src/AdvisoryAI` |
|
||||
| AdvisoryAI | OpsMemory | `opsmemory.stella-ops.local` | Operational memory/query APIs for advisory workflows. | postgres | `src/AdvisoryAI/StellaOps.OpsMemory.WebService` | `src/AdvisoryAI` |
|
||||
| Attestor | Attestor | `attestor.stella-ops.local` | Attestation, witness, and proof-chain APIs. | postgres | `src/Attestor/StellaOps.Attestor/StellaOps.Attestor.WebService` | `src/Attestor` |
|
||||
| Attestor | Signer | `signer.stella-ops.local` | Signing and key-ceremony APIs. | postgres | `src/Attestor/StellaOps.Signer/StellaOps.Signer.WebService` | `src/Attestor` |
|
||||
| Authority | IssuerDirectory | `issuerdirectory.stella-ops.local` | Issuer metadata and trust directory APIs. | postgres | `src/Authority/StellaOps.IssuerDirectory/StellaOps.IssuerDirectory.WebService` | `src/Authority` |
|
||||
| BinaryIndex | BinaryIndex | `binaryindex.stella-ops.local` | Binary index, patch coverage, and resolution APIs. | postgres | `src/BinaryIndex/StellaOps.BinaryIndex.WebService` | `src/BinaryIndex` |
|
||||
| Concelier | Concelier | `concelier.stella-ops.local` | Advisory ingestion and source-management APIs. | postgres | `src/Concelier/StellaOps.Concelier.WebService` | `src/Concelier` |
|
||||
| Concelier | Excititor | `excititor.stella-ops.local` | VEX ingest, linkset, and evidence APIs. | postgres | `src/Concelier/StellaOps.Excititor.WebService` | `src/Concelier` |
|
||||
| Doctor | Doctor | `doctor.stella-ops.local` | Health diagnostics and setup-check APIs. | in-memory (no service DB) | `src/Doctor/StellaOps.Doctor.WebService` | `src/Doctor` |
|
||||
| EvidenceLocker | EvidenceLocker | `evidencelocker.stella-ops.local` | Evidence ingest, bundle, legal hold, and verification APIs. | postgres | `src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.WebService` | `src/EvidenceLocker` |
|
||||
| ExportCenter | ExportCenter | `exportcenter.stella-ops.local` | Export/audit bundle/report APIs. | postgres | `src/ExportCenter/StellaOps.ExportCenter/StellaOps.ExportCenter.WebService` | `src/ExportCenter` |
|
||||
| Findings | Findings.Ledger | `findings.stella-ops.local` | Findings ledger, summary, and evidence graph APIs. | postgres | `src/Findings/StellaOps.Findings.Ledger.WebService` | `src/Findings` |
|
||||
| Findings | RiskEngine | `riskengine.stella-ops.local` | Exploit maturity and risk score APIs. | postgres (in-memory fallback for explicit test profile) | `src/Findings/StellaOps.RiskEngine.WebService` | `src/Findings` |
|
||||
| Integrations | Integrations | `integrations.stella-ops.local` | Integration adapters and endpoint management APIs. | postgres | `src/Integrations/StellaOps.Integrations.WebService` | `src/Integrations` |
|
||||
| JobEngine | JobEngine | `jobengine.stella-ops.local` | Release orchestration, approvals, DAG/workflow APIs. | postgres | `src/JobEngine/StellaOps.JobEngine/StellaOps.JobEngine.WebService` | `src/JobEngine` |
|
||||
| JobEngine | PacksRegistry | `packsregistry.stella-ops.local` | Pack/provenance/attestation registry APIs. | postgres + seed-fs object payloads | `src/JobEngine/StellaOps.PacksRegistry/StellaOps.PacksRegistry.WebService` | `src/JobEngine` |
|
||||
| JobEngine | Scheduler | `scheduler.stella-ops.local` | Schedule/run planning and event APIs. | postgres | `src/JobEngine/StellaOps.Scheduler.WebService` | `src/JobEngine` |
|
||||
| JobEngine | TaskRunner | `taskrunner.stella-ops.local` | Task execution, run state/log, approval, and artifact APIs. | postgres + seed-fs object payloads | `src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.WebService` | `src/JobEngine` |
|
||||
| Notifier | Notifier | `notifier.stella-ops.local` | Escalation and incident notification APIs. | postgres | `src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService` | `src/Notifier` |
|
||||
| Notify | Notify | `notify.stella-ops.local` | Notification rule/channel/template and delivery APIs. | postgres | `src/Notify/StellaOps.Notify.WebService` | `src/Notify` |
|
||||
| Platform | Platform | `platform.stella-ops.local` | Console aggregation, setup, admin, and read-model APIs. | postgres | `src/Platform/StellaOps.Platform.WebService` | `src/Platform` |
|
||||
| ReachGraph | ReachGraph | `reachgraph.stella-ops.local` | Reachability graph and CVE mapping APIs. | postgres | `src/ReachGraph/StellaOps.ReachGraph.WebService` | `src/ReachGraph` |
|
||||
| Remediation | Remediation | `remediation.stella-ops.local` | Remediation source, registry, and match APIs. | postgres | `src/Remediation/StellaOps.Remediation.WebService` | `src/Remediation` |
|
||||
| Replay | Replay | `replay.stella-ops.local` | Point-in-time query and verdict replay APIs. | postgres + seed-fs snapshot blobs | `src/Replay/StellaOps.Replay.WebService` | `src/Replay` |
|
||||
| Router | Gateway | `router.stella-ops.local` | Gateway dispatch, auth, and reverse-proxy APIs. | no-persistence | `src/Router/StellaOps.Gateway.WebService` | `src/Router` |
|
||||
| Scanner | Scanner | `scanner.stella-ops.local` | Scan submission, triage, drift, and scan data APIs. | postgres | `src/Scanner/StellaOps.Scanner.WebService` | `src/Scanner` |
|
||||
| Timeline | Timeline | `timeline.stella-ops.local` | Timeline query/export/replay APIs. | postgres | `src/Timeline/StellaOps.Timeline.WebService` | `src/Timeline` |
|
||||
| Timeline | TimelineIndexer | `timelineindexer.stella-ops.local` | Timeline indexer control/status APIs. | postgres | `src/Timeline/StellaOps.TimelineIndexer.WebService` | `src/Timeline` |
|
||||
| Unknowns | Unknowns | `unknowns.stella-ops.local` | Unknowns queue and triage APIs. | postgres | `src/Unknowns/StellaOps.Unknowns.WebService` | `src/Unknowns` |
|
||||
| VexHub | VexHub | `vexhub.stella-ops.local` | VEX ingest and distribution APIs. | postgres | `src/VexHub/StellaOps.VexHub.WebService` | `src/VexHub` |
|
||||
| VexLens | VexLens | `vexlens.stella-ops.local` | VEX lens, delta, and gate-view APIs. | postgres | `src/VexLens/StellaOps.VexLens.WebService` | `src/VexLens` |
|
||||
|
||||
## Related references
|
||||
- `docs/technical/architecture/port-registry.md`
|
||||
- `docs/modules/router/webservices-valkey-rollout-matrix.md`
|
||||
- `docs/implplan/CONSOLIDATION_WEBSERVICE_FUNCTION_DB_MATRIX_20260305.md`
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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('\\', '/');
|
||||
}
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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). |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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. |
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
BIN
src/Web/StellaOps.Web/output/playwright/header-search-repro.png
Normal file
BIN
src/Web/StellaOps.Web/output/playwright/header-search-repro.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 285 KiB |
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 & 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 & 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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": "Нямате права да извършите това действие.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "У вас нет разрешения на выполнение этого действия.",
|
||||
|
||||
@@ -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": "Ви не маєте дозволу на виконання цієї дії.",
|
||||
|
||||
@@ -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": "您没有执行此操作的权限。",
|
||||
|
||||
@@ -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": "您沒有執行此操作的權限。",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user