Fix journey cluster defects + UX improvements across 7 clusters

P0 fixes (clean-start + route contracts):
- VexHub: fix migration 002 table name + add repair migration 003
- Gateway: add /console/admin and /api/v1/unknowns routes
- IDP: add platform.idp.admin scope to OAuth client + web config
- Risk: fix URL construction from authority to gateway base
- Unknowns: fix client path from /api/v1/scanner/unknowns to /api/v1/unknowns

P1 fixes (trust + shell integrity):
- Audit: fix module name normalization, add Authority audit source
- Stage: add persistence across web store, API contracts, DB migration 059
- Posture: add per-source error tracking + degradation banner

P2 fixes (adoption + workflow clarity):
- Rename Triage to Findings in navigation + breadcrumbs
- Command palette: show quick actions for plain text queries, fix scan routes
- Scan: add local-mode limitation messaging + queue hints
- Release: add post-seal promotion CTA with pre-filled release ID
- Welcome: rewrite around operator adoption model (Get Started + What Stella Replaces)

UX improvements:
- Status rail: convert to icon-only with color state + tooltips
- Event Stream Monitor: new page at /ops/operations/event-stream
- Sidebar: collapse Operations by default
- User menu: embed theme switcher (Day/Night/System), remove standalone toggle
- Settings: add Profile section with email editing + PUT /api/v1/platform/preferences/email endpoint
- Docs viewer: replace custom parser with ngx-markdown (marked) for proper table/code/blockquote rendering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-03-17 15:10:36 +02:00
parent 4b7d3587ca
commit b851aa8300
50 changed files with 2163 additions and 551 deletions

View File

@@ -657,6 +657,7 @@ VALUES
'export.viewer', 'export.operator', 'export.admin',
'vuln:view', 'vuln:investigate', 'vuln:operate', 'vuln:audit',
'platform.context.read', 'platform.context.write',
'platform.idp.read', 'platform.idp.admin',
'doctor:run', 'doctor:admin', 'ops.health',
'integration:read', 'integration:write', 'integration:operate', 'registry.admin',
'timeline:read', 'timeline:write',

View File

@@ -62,6 +62,7 @@
{ "Type": "Microservice", "Path": "^/api/v1/secrets(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/secrets$1" },
{ "Type": "Microservice", "Path": "^/api/v1/sources(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/sources$1" },
{ "Type": "Microservice", "Path": "^/api/v1/witnesses(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/witnesses$1" },
{ "Type": "Microservice", "Path": "^/api/v1/unknowns(.*)", "IsRegex": true, "TranslatesTo": "http://scanner.stella-ops.local/api/v1/unknowns$1" },
{ "Type": "Microservice", "Path": "^/api/v1/trust(.*)", "IsRegex": true, "TranslatesTo": "https://authority.stella-ops.local/api/v1/trust$1" },
{ "Type": "Microservice", "Path": "^/api/v1/evidence(.*)", "IsRegex": true, "TranslatesTo": "https://evidencelocker.stella-ops.local/api/v1/evidence$1" },
{ "Type": "Microservice", "Path": "^/api/v1/proofs(.*)", "IsRegex": true, "TranslatesTo": "https://evidencelocker.stella-ops.local/api/v1/proofs$1" },
@@ -140,6 +141,7 @@
{ "Type": "ReverseProxy", "Path": "/jwks", "TranslatesTo": "http://authority.stella-ops.local/jwks" },
{ "Type": "ReverseProxy", "Path": "/authority/console", "TranslatesTo": "https://authority.stella-ops.local/console" },
{ "Type": "ReverseProxy", "Path": "/authority", "TranslatesTo": "https://authority.stella-ops.local/authority" },
{ "Type": "ReverseProxy", "Path": "/console/admin", "TranslatesTo": "https://authority.stella-ops.local/console/admin" },
{ "Type": "ReverseProxy", "Path": "/console", "TranslatesTo": "https://authority.stella-ops.local/console" },
{ "Type": "ReverseProxy", "Path": "/rekor", "TranslatesTo": "http://rekor.stella-ops.local:3322", "PreserveAuthHeaders": false },
{ "Type": "ReverseProxy", "Path": "/platform/envsettings.json", "TranslatesTo": "http://platform.stella-ops.local/platform/envsettings.json" },

View File

@@ -0,0 +1,230 @@
# Sprint 20260317-003 — Journey Problem Cluster Fixes
## Topic & Scope
- Implement all P0, P1, and P2 fixes identified in the Journey Problem Clusters Action Report (`docs/qa/JOURNEY_PROBLEM_CLUSTERS_ACTION_REPORT_20260317.md`).
- Covers VexHub migration repair, gateway route fixes, scope alignment, audit normalization, stage persistence, posture error tracking, navigation vocabulary, command palette, scan UX, welcome page, and release flow clarity.
- Working directories: `src/VexHub/`, `src/Web/`, `src/Platform/`, `src/Timeline/`, `devops/compose/`.
- Expected evidence: all three C# services build clean (0 warnings), TypeScript compiles clean (no new errors), all journey cluster items addressed.
## Dependencies & Concurrency
- Depends on `docs/implplan/SPRINT_20260317_002_DOCS_journey_problem_clusters_action_report.md` (analysis).
- No upstream sprint blockers — all changes are self-contained.
## Documentation Prerequisites
- `docs/qa/JOURNEY_PROBLEM_CLUSTERS_ACTION_REPORT_20260317.md`
- `AGENTS.md`
## Delivery Tracker
### P0-1 - VexHub migration mismatch repair
Status: DONE
Dependency: none
Owners: Developer
Task description:
- Migration 002 references `vexhub.vex_sources` but 001 creates `vexhub.sources`.
- Added `003_fix_source_backoff_columns.sql` with `IF NOT EXISTS` for idempotency.
- Added `ConsecutiveFailures` and `NextEligiblePollAt` properties to `VexSource.cs`.
- Added EF column mappings in `VexHubDbContext.cs`.
Completion criteria:
- [x] Migration 003 exists and uses correct table name
- [x] EF model has backoff column mappings
- [x] VexHub service builds clean (0 warnings, 0 errors)
### P0-2 - Console-admin gateway route
Status: DONE
Dependency: none
Owners: Developer
Task description:
- Frontend calls `/console/admin/*` but gateway had no explicit route, causing requests to fall through to Platform (404).
- Added `/console/admin``authority.stella-ops.local/console/admin` route before the generic `/console` route.
Completion criteria:
- [x] Gateway config has `/console/admin` route with correct specificity ordering
### P0-3 - Unknowns path fix (client + gateway)
Status: DONE
Dependency: none
Owners: Developer
Task description:
- Web client called `/api/v1/scanner/unknowns` but scanner exposes `/api/v1/unknowns`.
- Changed client base URL to `/api/v1/unknowns`.
- Added gateway route `^/api/v1/unknowns(.*)` → scanner service.
- Updated test script references.
Completion criteria:
- [x] Client uses `/api/v1/unknowns`
- [x] Gateway has explicit unknowns route
- [x] No stale `scanner/unknowns` references in `src/Web/`
### P0-4 - Identity Providers scope fix
Status: DONE
Dependency: none
Owners: Developer
Task description:
- Backend requires `platform.idp.admin` scope but `stella-ops-ui` client didn't include it.
- Added `platform.idp.read` and `platform.idp.admin` to `allowed_scopes` in `04-authority-schema.sql`.
- Added both scopes to the OIDC `scope` string in `config.json`.
Completion criteria:
- [x] SQL seed includes IDP scopes
- [x] Web config requests IDP scopes during login
### P0-5 - Risk dashboard URL construction
Status: DONE
Dependency: none
Owners: Developer
Task description:
- Client built risk URLs from `authorityBase + '/risk'` → double-pathed `/authority/risk/risk/status`.
- Changed `app.config.ts` to use gateway base and `/api/risk`.
- Removed duplicate `/risk` prefix from all `risk-http.client.ts` endpoint paths.
Completion criteria:
- [x] `RISK_API_BASE_URL` resolves to `/api/risk` via gateway
- [x] No duplicate `/risk/risk` paths in client
### P1-1 - Audit module normalization + Authority source
Status: DONE
Dependency: none
Owners: Developer
Task description:
- `NormalizeModule` mapped "evidencelocker"→"sbom" and "notify"→"integrations" (wrong).
- Fixed to preserve original module names.
- Added `evidencelocker` and `notify` to the known modules catalog.
- Fixed hardcoded module labels in `HttpUnifiedAuditEventProvider`.
- Added Authority audit fetcher (`/console/admin/audit`) as a new source.
- Wired `AuthorityBaseUrl` config in `Program.cs`.
Completion criteria:
- [x] Module names are 1:1 with actual modules
- [x] Authority audit events are fetched
- [x] Timeline service builds clean
### P1-2 - Stage persistence full chain
Status: DONE
Dependency: none
Owners: Developer
Task description:
- Stage was tracked in web store but never sent to backend or persisted in DB.
- Added `Stage` to `PlatformContextPreferencesRequest` and `PlatformContextPreferences`.
- Added stage to SQL upsert in `PlatformContextService.cs`.
- Added EF model property and column mapping.
- Added `stage` to `buildPreferencesPayload()` in TypeScript store.
- Created migration `059_UiContextPreferencesStage.sql`.
Completion criteria:
- [x] Stage round-trips: web store → API → DB → API → web store
- [x] Platform service builds clean
- [x] Migration file exists and is embedded
### P1-3 - Security posture degraded-data tracking
Status: DONE
Dependency: none
Owners: Developer
Task description:
- `SecurityRiskOverviewComponent` used `catchError(() => of([]))` silently converting API failures to zeros.
- Added 5 per-source error signals and a `hasDegradedData` computed signal.
- Each `catchError` now sets its error signal before returning the fallback.
- Error signals are cleared on each load cycle.
- Added degradation banner in template.
Completion criteria:
- [x] Per-source error tracking in place
- [x] Degradation banner shows when any source fails
- [x] TypeScript compiles clean
### P2-1 - Rename Triage to Findings in navigation
Status: DONE
Dependency: none
Owners: Developer
Task description:
- Changed top-level nav group label from "Triage" to "Findings".
- Updated breadcrumb display text for `/triage/` segments.
- Left route paths and internal IDs unchanged.
Completion criteria:
- [x] Navigation shows "Findings" instead of "Triage"
- [x] Breadcrumbs show "Findings"
- [x] No route path changes
### P2-2 - Command palette plain scan search
Status: DONE
Dependency: none
Owners: Developer
Task description:
- Plain text "scan" returned no quick actions (only `>` prefix did).
- Added `inlineMatchedActions` signal for mixed-mode results.
- Plain text queries now show matching quick actions above search results.
- Fixed scan quick action routes: `scan` and `scan-image` now route to `/security/scan` instead of triage pages.
Completion criteria:
- [x] Typing "scan" shows quick actions + search results
- [x] Scan actions route to `/security/scan`
- [x] Keyboard navigation works across both sections
### P2-3 - Scan local-mode limitation messaging
Status: DONE
Dependency: none
Owners: Developer
Task description:
- Scan UI waited 60 polls (~3 minutes) before showing any explanation.
- Added `pollCount` signal, `scanInProgress` and `showQueueHint` computed signals.
- Immediate info banner on scan start explains local-mode queue behavior.
- After 10 polls (~30s), a queue hint banner appears with link to Jobs Engine.
Completion criteria:
- [x] Info banner visible immediately after scan submission
- [x] Queue hint appears after ~30 seconds
- [x] Both banners disappear on scan completion
### P2-4 - Post-seal promotion CTA
Status: DONE
Dependency: none
Owners: Developer
Task description:
- Sealing a release didn't explain that promotion is the next step.
- Added explanation text distinguishing sealing from deployment.
- Added primary "Request Promotion" button linking to `/releases/promotions/create` with `releaseId` pre-filled.
- Demoted secondary links (view promotions, back to versions) to outline style.
Completion criteria:
- [x] Post-seal section explains sealing vs. promotion
- [x] "Request Promotion" CTA with pre-filled release ID
- [x] Visual hierarchy: primary CTA > secondary links
### P2-5 - Welcome page operator adoption rewrite
Status: DONE
Dependency: none
Owners: Developer
Task description:
- Welcome page was brand-heavy with generic chips. Didn't explain what Stella does for operators.
- Added "Get Started" journey: Connect Registry → Scan Artifact → Governed Release → Promote with Evidence.
- Added "What Stella Replaces" section: manual scripts → policy-gated promotions, scattered scans → unified posture, trust-me deploys → verifiable evidence.
- Kept sign-in button, docs link, auth notice, and existing layout structure.
Completion criteria:
- [x] Welcome page answers "what do I stop scripting?" within 20 seconds
- [x] Four concrete first steps visible
- [x] Before/after value props visible
## Execution Log
| Date (UTC) | Update | Owner |
| --- | --- | --- |
| 2026-03-17 | Sprint created from Journey Problem Clusters Action Report. | Developer |
| 2026-03-17 | P0 items implemented in parallel (5 agents): VexHub migration, gateway routes, IDP scope, unknowns path, risk URL. All verified — 3 C# services build clean, TS compiles clean. | Developer |
| 2026-03-17 | P1 items implemented in parallel (3 agents): audit normalization + Authority source, stage persistence full chain, posture degraded-data tracking. All verified — builds clean. | Developer |
| 2026-03-17 | P2 items implemented in parallel (5 agents): Triage→Findings rename, command palette scan fix, scan local-mode messaging, post-seal promotion CTA, welcome page rewrite. All verified — TS compiles clean. | Developer |
## Decisions & Risks
- VexHub migration 003 uses `IF NOT EXISTS` for idempotency — safe on both fresh and partially-migrated databases.
- IDP scope changes only take effect on fresh DB (INSERT ON CONFLICT DO NOTHING). Existing deployments need manual `allowed_scopes` update or volume reset.
- Authority audit endpoint (`/console/admin/audit`) response shape was inferred from ConsoleAdminEndpointExtensions — may need runtime verification.
- Risk dashboard: the gateway route exists for `/api/risk/*` but some dashboard summary endpoints (`/api/risk/status`, `/api/risk/aggregated-status`) may not exist in the backend yet. The URL construction is now correct, but 404s may persist until backend endpoints are implemented.
- Welcome page content is operator-focused but may need product review for messaging alignment.
- Pre-existing TS error in `trust-score-config.component.spec.ts:234` is unrelated to this sprint.
## Next Checkpoints
- Rebuild affected Docker images (vexhub, platform, timeline, router-gateway, console).
- Reset DB volume and verify fresh-start VexHub health.
- Run full local journey re-test to confirm fixes resolve the reported issues.
- Product review of welcome page copy and Findings/Triage vocabulary decision.

View File

@@ -23,10 +23,12 @@ public sealed record PlatformContextPreferences(
IReadOnlyList<string> Regions,
IReadOnlyList<string> Environments,
string TimeWindow,
string? Stage,
DateTimeOffset UpdatedAt,
string UpdatedBy);
public sealed record PlatformContextPreferencesRequest(
IReadOnlyList<string>? Regions,
IReadOnlyList<string>? Environments,
string? TimeWindow);
string? TimeWindow,
string? Stage = null);

View File

@@ -23,6 +23,16 @@ public sealed record PlatformLanguagePreference(
public sealed record PlatformLanguagePreferenceRequest(
string Locale);
public sealed record PlatformEmailPreference(
string TenantId,
string ActorId,
string? Email,
DateTimeOffset UpdatedAt,
string? UpdatedBy);
public sealed record PlatformEmailPreferenceRequest(
string Email);
public sealed record PlatformDashboardProfile(
string ProfileId,
string Name,

View File

@@ -386,6 +386,44 @@ public static class PlatformEndpoints
}
}).RequireAuthorization(PlatformPolicies.PreferencesWrite);
preferences.MapGet("/email", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformPreferencesService service,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
var email = await service.GetEmailPreferenceAsync(requestContext!, cancellationToken).ConfigureAwait(false);
return Results.Ok(email);
}).RequireAuthorization(PlatformPolicies.PreferencesRead);
preferences.MapPut("/email", async Task<IResult> (
HttpContext context,
PlatformRequestContextResolver resolver,
PlatformPreferencesService service,
PlatformEmailPreferenceRequest request,
CancellationToken cancellationToken) =>
{
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
{
return failure!;
}
try
{
var email = await service.UpsertEmailPreferenceAsync(requestContext!, request, cancellationToken).ConfigureAwait(false);
return Results.Ok(email);
}
catch (InvalidOperationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
}).RequireAuthorization(PlatformPolicies.PreferencesWrite);
var profiles = platform.MapGroup("/dashboard/profiles").WithTags("Platform Preferences");
profiles.MapGet("/", async Task<IResult> (

View File

@@ -103,6 +103,7 @@ public sealed class PlatformContextService : IPlatformContextQuery
defaultRegions,
Array.Empty<string>(),
DefaultTimeWindow,
null,
timeProvider.GetUtcNow(),
context.ActorId);
@@ -156,6 +157,7 @@ public sealed class PlatformContextService : IPlatformContextQuery
.ToArray();
var nextTimeWindow = NormalizeTimeWindow(request.TimeWindow, current.TimeWindow);
var nextStage = NormalizeStage(request.Stage, current.Stage);
var updated = new PlatformContextPreferences(
context.TenantId,
@@ -163,6 +165,7 @@ public sealed class PlatformContextService : IPlatformContextQuery
nextRegions,
nextEnvironments,
nextTimeWindow,
nextStage,
timeProvider.GetUtcNow(),
context.ActorId);
@@ -174,6 +177,16 @@ public sealed class PlatformContextService : IPlatformContextQuery
return await store.UpsertPreferencesAsync(updated, cancellationToken).ConfigureAwait(false);
}
private static string? NormalizeStage(string? requested, string? fallback)
{
if (!string.IsNullOrWhiteSpace(requested))
{
return requested.Trim().ToLowerInvariant();
}
return string.IsNullOrWhiteSpace(fallback) ? null : fallback.Trim().ToLowerInvariant();
}
private static string NormalizeTimeWindow(string? requested, string fallback)
{
if (!string.IsNullOrWhiteSpace(requested))
@@ -266,17 +279,18 @@ public sealed class PostgresPlatformContextStore : IPlatformContextStore
// PostgreSQL-specific upsert with RETURNING for preferences.
private const string UpsertPreferencesSql = """
INSERT INTO platform.ui_context_preferences
(tenant_id, actor_id, regions, environments, time_window, updated_at, updated_by)
(tenant_id, actor_id, regions, environments, time_window, stage, updated_at, updated_by)
VALUES
(@tenant_id, @actor_id, @regions, @environments, @time_window, @updated_at, @updated_by)
(@tenant_id, @actor_id, @regions, @environments, @time_window, @stage, @updated_at, @updated_by)
ON CONFLICT (tenant_id, actor_id)
DO UPDATE SET
regions = EXCLUDED.regions,
environments = EXCLUDED.environments,
time_window = EXCLUDED.time_window,
stage = EXCLUDED.stage,
updated_at = EXCLUDED.updated_at,
updated_by = EXCLUDED.updated_by
RETURNING regions, environments, time_window, updated_at, updated_by
RETURNING regions, environments, time_window, stage, updated_at, updated_by
""";
private readonly NpgsqlDataSource dataSource;
@@ -360,6 +374,7 @@ public sealed class PostgresPlatformContextStore : IPlatformContextStore
NormalizeTextArray(entity.Regions),
NormalizeTextArray(entity.Environments),
entity.TimeWindow,
entity.Stage,
new DateTimeOffset(DateTime.SpecifyKind(entity.UpdatedAt, DateTimeKind.Utc)),
entity.UpdatedBy);
}
@@ -376,6 +391,7 @@ public sealed class PostgresPlatformContextStore : IPlatformContextStore
command.Parameters.AddWithValue("regions", preference.Regions.ToArray());
command.Parameters.AddWithValue("environments", preference.Environments.ToArray());
command.Parameters.AddWithValue("time_window", preference.TimeWindow);
command.Parameters.AddWithValue("stage", (object?)preference.Stage ?? DBNull.Value);
command.Parameters.AddWithValue("updated_at", preference.UpdatedAt);
command.Parameters.AddWithValue("updated_by", preference.UpdatedBy);
@@ -388,8 +404,9 @@ public sealed class PostgresPlatformContextStore : IPlatformContextStore
ReadTextArray(reader, 0),
ReadTextArray(reader, 1),
reader.GetString(2),
reader.GetFieldValue<DateTimeOffset>(3),
reader.GetString(4));
reader.IsDBNull(3) ? null : reader.GetString(3),
reader.GetFieldValue<DateTimeOffset>(4),
reader.GetString(5));
}
private static string[] NormalizeTextArray(string[]? values)

View File

@@ -49,6 +49,7 @@ public sealed class PlatformPreferencesService
};
private const string LocalePreferenceKey = "locale";
private const string EmailPreferenceKey = "email";
private static readonly JsonObject DefaultPreferences = new()
{
@@ -162,6 +163,61 @@ public sealed class PlatformPreferencesService
UpdatedBy: context.ActorId));
}
public Task<PlatformEmailPreference> GetEmailPreferenceAsync(
PlatformRequestContext context,
CancellationToken cancellationToken)
{
var preferences = GetOrCreatePreferences(context);
var email = preferences.Preferences[EmailPreferenceKey]?.GetValue<string>();
return Task.FromResult(new PlatformEmailPreference(
TenantId: context.TenantId,
ActorId: context.ActorId,
Email: email,
UpdatedAt: preferences.UpdatedAt,
UpdatedBy: preferences.UpdatedBy));
}
public Task<PlatformEmailPreference> UpsertEmailPreferenceAsync(
PlatformRequestContext context,
PlatformEmailPreferenceRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var trimmed = request.Email?.Trim();
if (string.IsNullOrEmpty(trimmed) || !trimmed.Contains('@'))
{
throw new InvalidOperationException("A valid email address is required.");
}
var existing = GetOrCreatePreferences(context);
var updatedPreferences = ClonePreferences(existing.Preferences);
updatedPreferences[EmailPreferenceKey] = trimmed;
var now = timeProvider.GetUtcNow();
var updated = existing with
{
Preferences = updatedPreferences,
UpdatedAt = now,
UpdatedBy = context.ActorId
};
store.Upsert(context.TenantId, context.ActorId, updated);
logger.LogInformation(
"Updated email preference for tenant {TenantId} actor {ActorId} to {Email}.",
context.TenantId,
context.ActorId,
trimmed);
return Task.FromResult(new PlatformEmailPreference(
TenantId: context.TenantId,
ActorId: context.ActorId,
Email: trimmed,
UpdatedAt: now,
UpdatedBy: context.ActorId));
}
public Task<IReadOnlyList<PlatformDashboardProfile>> GetProfilesAsync(
PlatformRequestContext context,
CancellationToken cancellationToken)

View File

@@ -62,6 +62,15 @@ namespace StellaOps.Platform.Database.EfCore.CompiledModels
timeWindow.AddAnnotation("Relational:ColumnName", "time_window");
timeWindow.AddAnnotation("Relational:DefaultValueSql", "'24h'");
var stage = runtimeEntityType.AddProperty(
"Stage",
typeof(string),
propertyInfo: typeof(UiContextPreference).GetProperty("Stage",
BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: null,
nullable: true);
stage.AddAnnotation("Relational:ColumnName", "stage");
var updatedAt = runtimeEntityType.AddProperty(
"UpdatedAt",
typeof(DateTime),

View File

@@ -107,6 +107,9 @@ public partial class PlatformDbContext : DbContext
entity.Property(e => e.TimeWindow)
.HasDefaultValueSql("'24h'")
.HasColumnName("time_window");
entity.Property(e => e.Stage)
.IsRequired(false)
.HasColumnName("stage");
entity.Property(e => e.UpdatedAt)
.HasDefaultValueSql("now()")
.HasColumnName("updated_at");

View File

@@ -14,6 +14,8 @@ public partial class UiContextPreference
public string TimeWindow { get; set; } = null!;
public string? Stage { get; set; }
public DateTime UpdatedAt { get; set; }
public string UpdatedBy { get; set; } = null!;

View File

@@ -0,0 +1,5 @@
-- Add stage column to ui_context_preferences for persisting the user's
-- selected deployment stage (dev/staging/production/all) across sessions.
ALTER TABLE platform.ui_context_preferences
ADD COLUMN IF NOT EXISTS stage TEXT NULL;

View File

@@ -36,14 +36,16 @@ public sealed class HttpUnifiedAuditEventProvider : IUnifiedAuditEventProvider
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(timeout);
var getAuthority = GetAuthorityEventsAsync(endpointOptions, timeoutSource.Token);
var getJobEngine = GetJobEngineEventsAsync(endpointOptions, timeoutSource.Token);
var getPolicy = GetPolicyEventsAsync(endpointOptions, timeoutSource.Token);
var getEvidence = GetEvidenceLockerEventsAsync(endpointOptions, timeoutSource.Token);
var getNotify = GetNotifyEventsAsync(endpointOptions, timeoutSource.Token);
await Task.WhenAll(getJobEngine, getPolicy, getEvidence, getNotify).ConfigureAwait(false);
await Task.WhenAll(getAuthority, getJobEngine, getPolicy, getEvidence, getNotify).ConfigureAwait(false);
return getJobEngine.Result
return getAuthority.Result
.Concat(getJobEngine.Result)
.Concat(getPolicy.Result)
.Concat(getEvidence.Result)
.Concat(getNotify.Result)
@@ -54,6 +56,87 @@ public sealed class HttpUnifiedAuditEventProvider : IUnifiedAuditEventProvider
.ToList();
}
private async Task<IReadOnlyList<UnifiedAuditEvent>> GetAuthorityEventsAsync(
UnifiedAuditModuleEndpointsOptions options,
CancellationToken cancellationToken)
{
var uri = BuildUri(
options.AuthorityBaseUrl,
"/console/admin/audit",
new Dictionary<string, string?> { ["limit"] = options.FetchLimitPerModule.ToString(CultureInfo.InvariantCulture) });
if (uri is null)
{
return Array.Empty<UnifiedAuditEvent>();
}
using var document = await GetJsonDocumentAsync(uri, cancellationToken).ConfigureAwait(false);
if (document is null)
{
return Array.Empty<UnifiedAuditEvent>();
}
if (!TryGetPropertyIgnoreCase(document.RootElement, "events", out var entries) ||
entries.ValueKind != JsonValueKind.Array)
{
return Array.Empty<UnifiedAuditEvent>();
}
var events = new List<UnifiedAuditEvent>();
foreach (var entry in entries.EnumerateArray())
{
var id = GetString(entry, "id") ?? GetString(entry, "eventId");
if (string.IsNullOrWhiteSpace(id))
{
continue;
}
var eventType = GetString(entry, "eventType") ?? GetString(entry, "type");
var description = GetString(entry, "description") ?? GetString(entry, "summary") ?? eventType ?? "Authority audit event";
var action = UnifiedAuditValueMapper.NormalizeAction(eventType, description);
var actorId = GetString(entry, "actorId") ?? GetString(entry, "actor") ?? "authority-system";
var actorType = UnifiedAuditValueMapper.NormalizeActorType(GetString(entry, "actorType"));
var severity = UnifiedAuditValueMapper.NormalizeSeverity(
GetString(entry, "severity"),
action,
description);
events.Add(new UnifiedAuditEvent
{
Id = id,
Timestamp = UnifiedAuditValueMapper.ParseTimestampOrDefault(
GetString(entry, "occurredAt") ?? GetString(entry, "timestamp"), TimestampFallback),
Module = "authority",
Action = action,
Severity = severity,
Actor = new UnifiedAuditActor
{
Id = actorId,
Name = GetString(entry, "actorName") ?? actorId,
Type = actorType,
IpAddress = GetString(entry, "actorIp") ?? GetString(entry, "ipAddress"),
UserAgent = GetString(entry, "userAgent")
},
Resource = new UnifiedAuditResource
{
Type = GetString(entry, "resourceType") ?? "authority_resource",
Id = GetString(entry, "resourceId") ?? id
},
Description = description,
Details = new Dictionary<string, object?>(StringComparerOrdinal)
{
["eventType"] = eventType
},
CorrelationId = GetString(entry, "correlationId"),
TenantId = GetString(entry, "tenantId"),
Tags = ["authority", action]
});
}
return events;
}
private async Task<IReadOnlyList<UnifiedAuditEvent>> GetJobEngineEventsAsync(
UnifiedAuditModuleEndpointsOptions options,
CancellationToken cancellationToken)
@@ -277,7 +360,7 @@ public sealed class HttpUnifiedAuditEventProvider : IUnifiedAuditEventProvider
{
Id = id,
Timestamp = UnifiedAuditValueMapper.ParseTimestampOrDefault(GetString(entry, "occurredAt"), TimestampFallback),
Module = "sbom",
Module = "evidencelocker",
Action = action,
Severity = UnifiedAuditValueMapper.NormalizeSeverity(GetString(entry, "severity"), action, eventType),
Actor = new UnifiedAuditActor
@@ -297,7 +380,7 @@ public sealed class HttpUnifiedAuditEventProvider : IUnifiedAuditEventProvider
["eventType"] = eventType,
["subject"] = subject
},
Tags = ["sbom", action]
Tags = ["evidencelocker", action]
});
}
@@ -345,7 +428,7 @@ public sealed class HttpUnifiedAuditEventProvider : IUnifiedAuditEventProvider
{
Id = id,
Timestamp = UnifiedAuditValueMapper.ParseTimestampOrDefault(GetString(entry, "createdAt"), TimestampFallback),
Module = "integrations",
Module = "notify",
Action = action,
Severity = UnifiedAuditValueMapper.NormalizeSeverity(GetString(entry, "severity"), action, description),
Actor = new UnifiedAuditActor
@@ -363,7 +446,7 @@ public sealed class HttpUnifiedAuditEventProvider : IUnifiedAuditEventProvider
Details = details,
CorrelationId = GetString(entry, "correlationId"),
TenantId = GetString(entry, "tenantId"),
Tags = ["integrations", action]
Tags = ["notify", action]
});
}

View File

@@ -16,6 +16,8 @@ public static class UnifiedAuditCatalog
"scanner",
"attestor",
"sbom",
"evidencelocker",
"notify",
"scheduler"
];
@@ -159,13 +161,7 @@ public static class UnifiedAuditValueMapper
public static string NormalizeModule(string rawModule)
{
var source = rawModule.Trim().ToLowerInvariant();
return source switch
{
"evidencelocker" => "sbom",
"notify" => "integrations",
_ => UnifiedAuditCatalog.Modules.Contains(source, StringComparer.Ordinal) ? source : "integrations"
};
return rawModule.Trim().ToLowerInvariant();
}
public static DateTimeOffset ParseTimestampOrDefault(string? rawTimestamp, DateTimeOffset defaultValue)
@@ -355,6 +351,7 @@ public sealed record UnifiedAuditQuery
public sealed record UnifiedAuditModuleEndpointsOptions
{
public string AuthorityBaseUrl { get; set; } = "http://authority.stella-ops.local";
public string JobEngineBaseUrl { get; set; } = "http://jobengine.stella-ops.local";
public string PolicyBaseUrl { get; set; } = "http://policy-gateway.stella-ops.local";
public string EvidenceLockerBaseUrl { get; set; } = "http://evidencelocker.stella-ops.local";

View File

@@ -18,6 +18,10 @@ builder.Services.AddSingleton(TimeProvider.System);
builder.Services.Configure<UnifiedAuditModuleEndpointsOptions>(options =>
{
options.AuthorityBaseUrl = builder.Configuration["UnifiedAudit:Sources:Authority"]
?? builder.Configuration["STELLAOPS_AUTHORITY_URL"]
?? options.AuthorityBaseUrl;
options.JobEngineBaseUrl = builder.Configuration["UnifiedAudit:Sources:JobEngine"]
?? builder.Configuration["STELLAOPS_JOBENGINE_URL"]
?? options.JobEngineBaseUrl;

View File

@@ -62,6 +62,10 @@ public partial class VexHubDbContext : DbContext
.HasDefaultValueSql("now()")
.HasColumnName("created_at");
entity.Property(e => e.UpdatedAt).HasColumnName("updated_at");
entity.Property(e => e.ConsecutiveFailures)
.HasDefaultValue(0)
.HasColumnName("consecutive_failures");
entity.Property(e => e.NextEligiblePollAt).HasColumnName("next_eligible_poll_at");
});
// ── statements ───────────────────────────────────────────────────

View File

@@ -18,4 +18,6 @@ public partial class VexSource
public string Config { get; set; } = null!;
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
public int ConsecutiveFailures { get; set; }
public DateTimeOffset? NextEligiblePollAt { get; set; }
}

View File

@@ -2,6 +2,6 @@
-- Sprint: Advisory & VEX Source Management
-- Adds failure tracking columns for exponential backoff
ALTER TABLE vexhub.vex_sources
ALTER TABLE vexhub.sources
ADD COLUMN IF NOT EXISTS consecutive_failures INT NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS next_eligible_poll_at TIMESTAMPTZ NULL;

View File

@@ -0,0 +1,9 @@
-- Migration: 003_fix_source_backoff_columns
-- Repairs migration 002 which referenced wrong table name (vexhub.vex_sources instead of vexhub.sources)
-- Uses IF NOT EXISTS so this is safe on both fresh and partially-migrated databases
ALTER TABLE vexhub.sources
ADD COLUMN IF NOT EXISTS consecutive_failures INT NOT NULL DEFAULT 0;
ALTER TABLE vexhub.sources
ADD COLUMN IF NOT EXISTS next_eligible_poll_at TIMESTAMPTZ NULL;

View File

@@ -20,8 +20,10 @@
"@angular/router": "^21.1.2",
"@viz-js/viz": "^3.24.0",
"d3": "^7.9.0",
"mermaid": "^11.12.2",
"marked": "^17.0.4",
"mermaid": "^11.13.0",
"monaco-editor": "0.52.0",
"ngx-markdown": "^21.1.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"yaml": "^2.4.2",
@@ -2863,42 +2865,42 @@
"license": "MIT"
},
"node_modules/@chevrotain/cst-dts-gen": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz",
"integrity": "sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==",
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz",
"integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/gast": "11.0.3",
"@chevrotain/types": "11.0.3",
"lodash-es": "4.17.21"
"@chevrotain/gast": "11.1.2",
"@chevrotain/types": "11.1.2",
"lodash-es": "4.17.23"
}
},
"node_modules/@chevrotain/gast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.0.3.tgz",
"integrity": "sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==",
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz",
"integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/types": "11.0.3",
"lodash-es": "4.17.21"
"@chevrotain/types": "11.1.2",
"lodash-es": "4.17.23"
}
},
"node_modules/@chevrotain/regexp-to-ast": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz",
"integrity": "sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==",
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz",
"integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/types": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.0.3.tgz",
"integrity": "sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==",
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz",
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
"license": "Apache-2.0"
},
"node_modules/@chevrotain/utils": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.0.3.tgz",
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz",
"integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==",
"license": "Apache-2.0"
},
"node_modules/@chromatic-com/storybook": {
@@ -4622,12 +4624,12 @@
]
},
"node_modules/@mermaid-js/parser": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-0.6.3.tgz",
"integrity": "sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.1.tgz",
"integrity": "sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==",
"license": "MIT",
"dependencies": {
"langium": "3.3.1"
"langium": "^4.0.0"
}
},
"node_modules/@modelcontextprotocol/sdk": {
@@ -7463,6 +7465,16 @@
"@types/node": "*"
}
},
"node_modules/@upsetjs/venn.js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz",
"integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==",
"license": "MIT",
"optionalDependencies": {
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.1"
}
},
"node_modules/@vitejs/plugin-basic-ssl": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz",
@@ -8860,17 +8872,17 @@
}
},
"node_modules/chevrotain": {
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz",
"integrity": "sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==",
"version": "11.1.2",
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz",
"integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==",
"license": "Apache-2.0",
"dependencies": {
"@chevrotain/cst-dts-gen": "11.0.3",
"@chevrotain/gast": "11.0.3",
"@chevrotain/regexp-to-ast": "11.0.3",
"@chevrotain/types": "11.0.3",
"@chevrotain/utils": "11.0.3",
"lodash-es": "4.17.21"
"@chevrotain/cst-dts-gen": "11.1.2",
"@chevrotain/gast": "11.1.2",
"@chevrotain/regexp-to-ast": "11.1.2",
"@chevrotain/types": "11.1.2",
"@chevrotain/utils": "11.1.2",
"lodash-es": "4.17.23"
}
},
"node_modules/chevrotain-allstar": {
@@ -9096,6 +9108,18 @@
"node": ">= 12"
}
},
"node_modules/clipboard": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/clipboard/-/clipboard-2.0.11.tgz",
"integrity": "sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==",
"license": "MIT",
"optional": true,
"dependencies": {
"good-listener": "^1.2.2",
"select": "^1.1.2",
"tiny-emitter": "^2.0.0"
}
},
"node_modules/cliui": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
@@ -10154,9 +10178,9 @@
}
},
"node_modules/dagre-d3-es": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz",
"integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==",
"version": "7.0.14",
"resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz",
"integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==",
"license": "MIT",
"dependencies": {
"d3": "^7.9.0",
@@ -10278,6 +10302,13 @@
"robust-predicates": "^3.0.2"
}
},
"node_modules/delegate": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
"integrity": "sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==",
"license": "MIT",
"optional": true
},
"node_modules/depd": {
"version": "2.0.0",
"dev": true,
@@ -10497,6 +10528,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/emoji-toolkit": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/emoji-toolkit/-/emoji-toolkit-10.0.0.tgz",
"integrity": "sha512-GkIAvgutEVbkqcT2HjBzV002SWvpdNaT3aP9q/YjQ6hlgDq8HhE9GcqxWkyYkRRQnLADGpwDoj1heTw9KzO9wQ==",
"license": "MIT",
"optional": true
},
"node_modules/emojis-list": {
"version": "3.0.0",
"dev": true,
@@ -11381,6 +11419,16 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/good-listener": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/good-listener/-/good-listener-1.2.2.tgz",
"integrity": "sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==",
"license": "MIT",
"optional": true,
"dependencies": {
"delegate": "^3.1.2"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"dev": true,
@@ -12400,19 +12448,20 @@
}
},
"node_modules/langium": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/langium/-/langium-3.3.1.tgz",
"integrity": "sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz",
"integrity": "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==",
"license": "MIT",
"dependencies": {
"chevrotain": "~11.0.3",
"chevrotain-allstar": "~0.3.0",
"chevrotain": "~11.1.1",
"chevrotain-allstar": "~0.3.1",
"vscode-languageserver": "~9.0.1",
"vscode-languageserver-textdocument": "~1.0.11",
"vscode-uri": "~3.0.8"
"vscode-uri": "~3.1.0"
},
"engines": {
"node": ">=16.0.0"
"node": ">=20.10.0",
"npm": ">=10.2.3"
}
},
"node_modules/launch-editor": {
@@ -12954,9 +13003,9 @@
}
},
"node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"version": "17.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
"integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
@@ -13015,33 +13064,46 @@
"license": "MIT"
},
"node_modules/mermaid": {
"version": "11.12.2",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.2.tgz",
"integrity": "sha512-n34QPDPEKmaeCG4WDMGy0OT6PSyxKCfy2pJgShP+Qow2KLrvWjclwbc3yXfSIf4BanqWEhQEpngWwNp/XhZt6w==",
"version": "11.13.0",
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.13.0.tgz",
"integrity": "sha512-fEnci+Immw6lKMFI8sqzjlATTyjLkRa6axrEgLV2yHTfv8r+h1wjFbV6xeRtd4rUV1cS4EpR9rwp3Rci7TRWDw==",
"license": "MIT",
"dependencies": {
"@braintree/sanitize-url": "^7.1.1",
"@iconify/utils": "^3.0.1",
"@mermaid-js/parser": "^0.6.3",
"@iconify/utils": "^3.0.2",
"@mermaid-js/parser": "^1.0.1",
"@types/d3": "^7.4.3",
"cytoscape": "^3.29.3",
"@upsetjs/venn.js": "^2.0.0",
"cytoscape": "^3.33.1",
"cytoscape-cose-bilkent": "^4.1.0",
"cytoscape-fcose": "^2.2.0",
"d3": "^7.9.0",
"d3-sankey": "^0.12.3",
"dagre-d3-es": "7.0.13",
"dayjs": "^1.11.18",
"dompurify": "^3.2.5",
"katex": "^0.16.22",
"dagre-d3-es": "7.0.14",
"dayjs": "^1.11.19",
"dompurify": "^3.3.1",
"katex": "^0.16.25",
"khroma": "^2.1.0",
"lodash-es": "^4.17.21",
"marked": "^16.2.1",
"lodash-es": "^4.17.23",
"marked": "^16.3.0",
"roughjs": "^4.6.6",
"stylis": "^4.3.6",
"ts-dedent": "^2.2.0",
"uuid": "^11.1.0"
}
},
"node_modules/mermaid/node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/mermaid/node_modules/uuid": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
@@ -13487,6 +13549,30 @@
"dev": true,
"license": "MIT"
},
"node_modules/ngx-markdown": {
"version": "21.1.0",
"resolved": "https://registry.npmjs.org/ngx-markdown/-/ngx-markdown-21.1.0.tgz",
"integrity": "sha512-qiyn9Je20F9yS4/q0p1Xhk2b/HW0rHWWlJNRm8DIzJKNck9Rmn/BfFxq0webmQHPPyYkg2AjNq/ZeSqDTQJbsQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.3.0"
},
"optionalDependencies": {
"clipboard": "^2.0.11",
"emoji-toolkit": ">= 8.0.0 < 11.0.0",
"katex": "^0.16.0",
"mermaid": ">= 10.6.0 < 12.0.0",
"prismjs": "^1.30.0"
},
"peerDependencies": {
"@angular/common": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"marked": "^17.0.0",
"rxjs": "^6.5.3 || ^7.4.0",
"zone.js": "~0.15.0 || ~0.16.0"
}
},
"node_modules/no-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
@@ -14549,6 +14635,16 @@
"renderkid": "^3.0.0"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=6"
}
},
"node_modules/proc-log": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
@@ -15341,6 +15437,13 @@
"ajv": "^8.8.2"
}
},
"node_modules/select": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz",
"integrity": "sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==",
"license": "MIT",
"optional": true
},
"node_modules/select-hose": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
@@ -16441,6 +16544,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/tiny-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz",
"integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==",
"license": "MIT",
"optional": true
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -17103,9 +17213,9 @@
"license": "MIT"
},
"node_modules/vscode-uri": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.0.8.tgz",
"integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
"license": "MIT"
},
"node_modules/w3c-xmlserializer": {

View File

@@ -42,8 +42,10 @@
"@angular/router": "^21.1.2",
"@viz-js/viz": "^3.24.0",
"d3": "^7.9.0",
"mermaid": "^11.12.2",
"marked": "^17.0.4",
"mermaid": "^11.13.0",
"monaco-editor": "0.52.0",
"ngx-markdown": "^21.1.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"yaml": "^2.4.2",

View File

@@ -56,7 +56,7 @@ async function collectRuntime(page, runtime) {
if (
message.type() === 'error'
&& text !== 'Failed to load resource: the server responded with a status of 500 (Internal Server Error)'
&& !text.includes('/api/v1/scanner/unknowns')
&& !text.includes('/api/v1/unknowns')
) {
runtime.consoleErrors.push(text);
}
@@ -65,7 +65,7 @@ async function collectRuntime(page, runtime) {
runtime.pageErrors.push(cleanText(error.message));
});
page.on('response', async (response) => {
if (response.status() >= 500 && !response.url().includes('/api/v1/scanner/unknowns')) {
if (response.status() >= 500 && !response.url().includes('/api/v1/unknowns')) {
runtime.responseErrors.push(`${response.status()} ${response.url()}`);
}
});
@@ -194,7 +194,7 @@ async function main() {
},
});
await page.route('**/api/v1/scanner/unknowns**', async (route) => {
await page.route('**/api/v1/unknowns**', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',

View File

@@ -2,6 +2,7 @@ import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@a
import { ApplicationConfig, inject, provideAppInitializer } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideRouter, TitleStrategy, withComponentInputBinding } from '@angular/router';
import { provideMarkdown } from 'ngx-markdown';
import { PageTitleStrategy } from './core/navigation/page-title.strategy';
import { routes } from './app.routes';
@@ -22,18 +23,18 @@ import {
NotifyApiHttpClient,
MockNotifyClient,
} from './core/api/notify.client';
import {
EXCEPTION_API,
EXCEPTION_API_BASE_URL,
ExceptionApiHttpClient,
MockExceptionApiService,
} from './core/api/exception.client';
import { VULNERABILITY_API, MockVulnerabilityApiService } from './core/api/vulnerability.client';
import {
VULNERABILITY_API_BASE_URL,
VULNERABILITY_QUERY_API_BASE_URL,
VulnerabilityHttpClient,
} from './core/api/vulnerability-http.client';
import {
EXCEPTION_API,
EXCEPTION_API_BASE_URL,
ExceptionApiHttpClient,
MockExceptionApiService,
} from './core/api/exception.client';
import { VULNERABILITY_API, MockVulnerabilityApiService } from './core/api/vulnerability.client';
import {
VULNERABILITY_API_BASE_URL,
VULNERABILITY_QUERY_API_BASE_URL,
VulnerabilityHttpClient,
} from './core/api/vulnerability-http.client';
import { RISK_API, MockRiskApi } from './core/api/risk.client';
import { RISK_API_BASE_URL, RiskHttpClient } from './core/api/risk-http.client';
import { AppConfigService } from './core/config/app-config.service';
@@ -266,8 +267,8 @@ import {
MockIdentityProviderClient,
} from './core/api/identity-provider.client';
function resolveApiBaseUrl(baseUrl: string | undefined, path: string): string {
const normalizedBase = (baseUrl ?? '').trim();
function resolveApiBaseUrl(baseUrl: string | undefined, path: string): string {
const normalizedBase = (baseUrl ?? '').trim();
if (!normalizedBase) {
return path;
@@ -285,19 +286,19 @@ function resolveApiBaseUrl(baseUrl: string | undefined, path: string): string {
: normalizedBase;
return `${baseWithoutTrailingSlash}/${path.replace(/^\/+/, '')}`;
}
}
export function resolveApiRootUrl(baseUrl: string | undefined): string {
const normalizedBase = (baseUrl ?? '').trim();
if (!normalizedBase) {
return '';
}
return normalizedBase.endsWith('/')
? normalizedBase.slice(0, -1)
: normalizedBase;
}
}
}
export function resolveApiRootUrl(baseUrl: string | undefined): string {
const normalizedBase = (baseUrl ?? '').trim();
if (!normalizedBase) {
return '';
}
return normalizedBase.endsWith('/')
? normalizedBase.slice(0, -1)
: normalizedBase;
}
export const appConfig: ApplicationConfig = {
providers: [
@@ -305,6 +306,7 @@ export const appConfig: ApplicationConfig = {
provideAnimationsAsync(),
{ provide: TitleStrategy, useClass: PageTitleStrategy },
provideHttpClient(withInterceptorsFromDi()),
provideMarkdown(),
provideAppInitializer(() => {
const initializerFn = ((configService: AppConfigService, probeService: BackendProbeService, i18nService: I18nService, openApiParamMap: OpenApiContextParamMap) => async () => {
await configService.load();
@@ -367,21 +369,21 @@ export const appConfig: ApplicationConfig = {
})(inject(AuthSessionStore), inject(TenantActivationService));
return initializerFn();
}),
{
provide: RISK_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const authorityBase = config.config.apiBaseUrls.authority;
try {
return new URL('/risk', authorityBase).toString();
} catch {
const normalized = authorityBase.endsWith('/')
? authorityBase.slice(0, -1)
: authorityBase;
return `${normalized}/risk`;
}
},
},
{
provide: RISK_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
try {
return new URL('/api/risk', gatewayBase).toString();
} catch {
const normalized = gatewayBase.endsWith('/')
? gatewayBase.slice(0, -1)
: gatewayBase;
return `${normalized}/api/risk`;
}
},
},
{
provide: AUTH_SERVICE,
useExisting: AuthorityAuthAdapterService,
@@ -405,42 +407,42 @@ export const appConfig: ApplicationConfig = {
provide: RISK_API,
useExisting: RiskHttpClient,
},
{
provide: VULNERABILITY_QUERY_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway
?? config.config.apiBaseUrls.scanner
?? config.config.apiBaseUrls.authority;
return resolveApiBaseUrl(gatewayBase, '/api/v1/vulnerabilities');
},
},
{
provide: VULNERABILITY_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
return resolveApiRootUrl(config.config.apiBaseUrls.authority);
},
},
{
provide: VULNERABILITY_QUERY_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway
?? config.config.apiBaseUrls.scanner
?? config.config.apiBaseUrls.authority;
return resolveApiBaseUrl(gatewayBase, '/api/v1/vulnerabilities');
},
},
{
provide: VULNERABILITY_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
return resolveApiRootUrl(config.config.apiBaseUrls.authority);
},
},
VulnerabilityHttpClient,
MockVulnerabilityApiService,
{
provide: VULNERABILITY_API,
useExisting: VulnerabilityHttpClient,
},
{
provide: NOTIFY_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
try {
return new URL('/api/v1/notify', gatewayBase).toString();
} catch {
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
return `${normalized}/api/v1/notify`;
}
},
},
{
provide: NOTIFY_API_BASE_URL,
deps: [AppConfigService],
useFactory: (config: AppConfigService) => {
const gatewayBase = config.config.apiBaseUrls.gateway ?? config.config.apiBaseUrls.authority;
try {
return new URL('/api/v1/notify', gatewayBase).toString();
} catch {
const normalized = gatewayBase.endsWith('/') ? gatewayBase.slice(0, -1) : gatewayBase;
return `${normalized}/api/v1/notify`;
}
},
},
{
provide: ADVISORY_AI_API_BASE_URL,
deps: [AppConfigService],

View File

@@ -44,7 +44,7 @@ export class RiskHttpClient implements RiskApi {
if (options.search) params = params.set('search', options.search);
return this.http
.get<RiskResultPage>(`${this.baseUrl}/risk`, { headers, params })
.get<RiskResultPage>(`${this.baseUrl}`, { headers, params })
.pipe(
map((page) => ({
...page,
@@ -61,7 +61,7 @@ export class RiskHttpClient implements RiskApi {
const headers = this.buildHeaders(tenant, options.projectId, traceId);
return this.http
.get<RiskStats>(`${this.baseUrl}/risk/status`, { headers })
.get<RiskStats>(`${this.baseUrl}/status`, { headers })
.pipe(
map((stats) => ({
countsBySeverity: stats.countsBySeverity,
@@ -80,7 +80,7 @@ export class RiskHttpClient implements RiskApi {
const headers = this.buildHeaders(tenant, options?.projectId, traceId);
return this.http
.get<RiskProfile>(`${this.baseUrl}/risk/${encodeURIComponent(riskId)}`, { headers })
.get<RiskProfile>(`${this.baseUrl}/${encodeURIComponent(riskId)}`, { headers })
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}
@@ -93,7 +93,7 @@ export class RiskHttpClient implements RiskApi {
const headers = this.buildHeaders(tenant, options?.projectId, traceId);
return this.http
.get<RiskExplanationUrl>(`${this.baseUrl}/risk/${encodeURIComponent(riskId)}/explanation-url`, { headers })
.get<RiskExplanationUrl>(`${this.baseUrl}/${encodeURIComponent(riskId)}/explanation-url`, { headers })
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}
@@ -105,7 +105,7 @@ export class RiskHttpClient implements RiskApi {
const headers = this.buildHeaders(tenant, options.projectId, traceId);
return this.http
.get<AggregatedRiskStatus>(`${this.baseUrl}/risk/aggregated-status`, { headers })
.get<AggregatedRiskStatus>(`${this.baseUrl}/aggregated-status`, { headers })
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}
@@ -120,7 +120,7 @@ export class RiskHttpClient implements RiskApi {
if (options.limit) params = params.set('limit', options.limit);
return this.http
.get<SeverityTransitionEvent[]>(`${this.baseUrl}/risk/transitions/recent`, { headers, params })
.get<SeverityTransitionEvent[]>(`${this.baseUrl}/transitions/recent`, { headers, params })
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}
@@ -134,7 +134,7 @@ export class RiskHttpClient implements RiskApi {
event: SeverityTransitionEvent
): Observable<{ emitted: boolean; eventId: string }> {
return this.http
.post<{ emitted: boolean; eventId: string }>(`${this.baseUrl}/risk/transitions`, event)
.post<{ emitted: boolean; eventId: string }>(`${this.baseUrl}/transitions`, event)
.pipe(catchError((err) => throwError(() => this.normalizeError(err))));
}

View File

@@ -128,7 +128,7 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
shortcut: '>scan',
description: 'Opens artifact scan dialog',
icon: 'scan',
route: '/security/triage',
route: '/security/scan',
keywords: ['scan', 'artifact', 'analyze'],
},
{
@@ -224,7 +224,7 @@ export const DEFAULT_QUICK_ACTIONS: QuickAction[] = [
shortcut: '>scan-image',
description: 'Scan a container image for vulnerabilities',
icon: 'scan',
route: '/triage/artifacts',
route: '/security/scan',
keywords: ['scan', 'image', 'container', 'vulnerability'],
},
{

View File

@@ -114,7 +114,7 @@ export interface PolicyUnknownDetailResponse {
@Injectable({ providedIn: 'root' })
export class UnknownsClient {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/api/v1/scanner/unknowns';
private readonly baseUrl = '/api/v1/unknowns';
private readonly policyBaseUrl = '/api/v1/policy/unknowns';
list(filter?: UnknownFilter, limit = 50, cursor?: string): Observable<UnknownListResponse> {

View File

@@ -51,6 +51,7 @@ interface PlatformContextPreferencesRequestPayload {
regions: string[];
environments: string[];
timeWindow: string;
stage?: string;
}
@Injectable({ providedIn: 'root' })
@@ -412,6 +413,7 @@ export class PlatformContextStore {
regions: this.selectedRegions(),
environments: this.selectedEnvironments(),
timeWindow: this.timeWindow(),
stage: this.stage(),
};
}

View File

@@ -116,11 +116,11 @@ export const NAVIGATION_GROUPS: NavGroup[] = [
},
// -------------------------------------------------------------------------
// Triage - Artifact management and risk assessment
// Findings - Artifact management and risk assessment
// -------------------------------------------------------------------------
{
id: 'triage',
label: 'Triage',
label: 'Findings',
icon: 'filter',
items: [
{

View File

@@ -50,12 +50,20 @@ import {
</section>
@if (showPostSealGuide()) {
<section class="bvd__post-seal" aria-label="Next steps">
<h3>Release sealed successfully. What's next?</h3>
<section class="bvd__post-seal" aria-label="Next steps after sealing">
<h3>Release definition sealed successfully</h3>
<p class="bvd__post-seal-explain">
Sealing locks the release definition — its components, contract inputs, and policy pin are now immutable.
To deploy this release, request a promotion to the target environment. Promotion enters the deployment
workflow where policy gates, approvals, and materialization checks are evaluated before any changes reach
an environment.
</p>
<div class="bvd__post-seal-actions">
<a [routerLink]="['/releases/promotions']" queryParamsHandling="merge" class="bvd__action-link">Promote to environment</a>
<a [routerLink]="['/releases/approvals']" queryParamsHandling="merge" class="bvd__action-link">Review approvals</a>
<a [routerLink]="['/releases/versions']" queryParamsHandling="merge" class="bvd__action-link">Back to versions</a>
<a [routerLink]="['/releases/promotions/create']"
[queryParams]="{ releaseId: bundleId(), returnTo: '/releases/bundles/' + bundleId() + '/versions/' + versionId() }"
class="bvd__action-link bvd__action-link--primary">Request Promotion</a>
<a [routerLink]="['/releases/promotions']" class="bvd__action-link">View all promotions</a>
<a [routerLink]="['/releases/versions']" class="bvd__action-link">Back to versions</a>
</div>
</section>
}
@@ -227,15 +235,23 @@ import {
}
.bvd__post-seal h3 {
margin: 0 0 0.75rem;
margin: 0 0 0.35rem;
font-size: 1rem;
font-weight: 600;
}
.bvd__post-seal-explain {
margin: 0 0 0.75rem;
font-size: 0.84rem;
line-height: 1.5;
color: var(--color-text-secondary, #555);
}
.bvd__post-seal-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
align-items: center;
}
.bvd__action-link {
@@ -244,8 +260,15 @@ import {
font-size: 0.85rem;
font-weight: 500;
text-decoration: none;
background: var(--color-surface-alt, #f3f4f6);
color: var(--color-brand-primary, #4f46e5);
border: 1px solid var(--color-border, #e5e7eb);
}
.bvd__action-link--primary {
background: var(--color-brand-primary, #6366f1);
color: #fff;
border-color: var(--color-brand-primary, #6366f1);
}
.bvd__tabs {

View File

@@ -75,6 +75,69 @@ function parseFenceStart(line: string): { language: string; inlineContent: strin
return { language: '', inlineContent: fenceContent };
}
function parseTableRow(line: string): string[] {
return line
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim());
}
function isAlignmentRow(line: string): boolean {
const cells = parseTableRow(line);
return cells.length > 0 && cells.every((cell) => /^:?-+:?$/.test(cell));
}
function parseAlignment(cell: string): 'left' | 'center' | 'right' {
const trimmed = cell.trim();
if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center';
if (trimmed.endsWith(':')) return 'right';
return 'left';
}
function renderTable(
tableLines: string[],
currentDocPath: string,
resolveDocsLink: (target: string, currentDocPath: string) => string | null,
): string {
if (tableLines.length < 2) {
return `<pre class="docs-viewer__table-fallback">${escapeHtml(tableLines.join('\n'))}</pre>`;
}
const headerCells = parseTableRow(tableLines[0]);
const hasAlignment = tableLines.length > 1 && isAlignmentRow(tableLines[1]);
const alignments = hasAlignment
? parseTableRow(tableLines[1]).map(parseAlignment)
: headerCells.map(() => 'left' as const);
const bodyStart = hasAlignment ? 2 : 1;
const thCells = headerCells
.map((cell, i) => {
const align = alignments[i] ?? 'left';
const style = align !== 'left' ? ` style="text-align:${align}"` : '';
return `<th${style}>${renderInlineMarkdown(cell, currentDocPath, resolveDocsLink)}</th>`;
})
.join('');
const bodyRows = tableLines
.slice(bodyStart)
.map((row) => {
const cells = parseTableRow(row);
const tds = headerCells
.map((_, i) => {
const align = alignments[i] ?? 'left';
const style = align !== 'left' ? ` style="text-align:${align}"` : '';
const content = cells[i] ?? '';
return `<td${style}>${renderInlineMarkdown(content, currentDocPath, resolveDocsLink)}</td>`;
})
.join('');
return `<tr>${tds}</tr>`;
})
.join('');
return `<div class="docs-viewer__table-wrap"><table><thead><tr>${thCells}</tr></thead><tbody>${bodyRows}</tbody></table></div>`;
}
function pushParagraph(parts: string[], paragraphLines: string[], currentDocPath: string, resolveDocsLink: (target: string, currentDocPath: string) => string | null): void {
if (paragraphLines.length === 0) {
return;
@@ -124,6 +187,12 @@ export function renderMarkdownDocument(
continue;
}
if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line.trim())) {
parts.push('<hr/>');
index += 1;
continue;
}
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
const level = headingMatch[1].length;
@@ -180,7 +249,7 @@ export function renderMarkdownDocument(
index += 1;
}
parts.push(`<pre class="docs-viewer__table-fallback">${escapeHtml(tableLines.join('\n'))}</pre>`);
parts.push(renderTable(tableLines, currentDocPath, resolveDocsLink));
continue;
}

View File

@@ -1,22 +1,29 @@
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, DestroyRef, inject, signal, ViewEncapsulation } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { NavigationEnd, Router } from '@angular/router';
import { filter, firstValueFrom } from 'rxjs';
import { MarkdownComponent } from 'ngx-markdown';
import {
buildDocsAssetCandidates,
normalizeDocsAnchor,
parseDocsUrl,
resolveDocsLink,
} from '../../core/navigation/docs-route';
import { DocsHeading, renderMarkdownDocument, slugifyHeading } from './docs-markdown';
import { slugifyHeading } from './docs-markdown';
interface TocHeading {
level: number;
text: string;
id: string;
}
@Component({
selector: 'app-docs-viewer',
standalone: true,
imports: [MarkdownComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<section class="docs-viewer">
<header class="docs-viewer__header">
@@ -56,7 +63,13 @@ import { DocsHeading, renderMarkdownDocument, slugifyHeading } from './docs-mark
</nav>
}
<article class="docs-viewer__content" [innerHTML]="renderedHtml()"></article>
<article class="docs-viewer__content">
<markdown
[data]="rawMarkdown()"
[disableSanitizer]="true"
(ready)="onMarkdownReady()"
></markdown>
</article>
</div>
</section>
`,
@@ -143,6 +156,7 @@ import { DocsHeading, renderMarkdownDocument, slugifyHeading } from './docs-mark
align-items: start;
}
/* ── TOC ── */
.docs-viewer__toc {
position: sticky;
top: 1rem;
@@ -166,79 +180,74 @@ import { DocsHeading, renderMarkdownDocument, slugifyHeading } from './docs-mark
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
gap: 0.25rem;
}
.docs-viewer__toc li {
margin: 0;
}
.docs-viewer__toc li { margin: 0; }
.docs-viewer__toc a {
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.82rem;
font-size: 0.8rem;
line-height: 1.4;
display: block;
padding: 0.1rem 0;
}
.docs-viewer__toc-level-3,
.docs-viewer__toc a:hover { color: var(--color-brand-primary); }
.docs-viewer__toc-level-1 { font-weight: var(--font-weight-semibold); }
.docs-viewer__toc-level-2 { padding-left: 0; }
.docs-viewer__toc-level-3 { padding-left: 0.75rem; font-size: 0.76rem; }
.docs-viewer__toc-level-4,
.docs-viewer__toc-level-5,
.docs-viewer__toc-level-6 {
padding-left: 0.75rem;
}
.docs-viewer__toc-level-6 { padding-left: 1.5rem; font-size: 0.72rem; }
/* ── Content area ── */
.docs-viewer__content {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
background: var(--color-surface-primary);
padding: 1.2rem 1.3rem;
min-width: 0;
overflow-x: auto;
}
.docs-viewer__content :is(h1, h2, h3, h4, h5, h6) {
.docs-viewer__content h1,
.docs-viewer__content h2,
.docs-viewer__content h3,
.docs-viewer__content h4,
.docs-viewer__content h5,
.docs-viewer__content h6 {
color: var(--color-text-heading);
scroll-margin-top: 1rem;
}
.docs-viewer__content h1 {
font-size: 1.7rem;
margin-top: 0;
}
.docs-viewer__content h2 {
font-size: 1.3rem;
margin-top: 1.5rem;
}
.docs-viewer__content h3 {
font-size: 1.05rem;
margin-top: 1.2rem;
}
.docs-viewer__content h1 { font-size: 1.7rem; margin-top: 0; }
.docs-viewer__content h2 { font-size: 1.3rem; margin-top: 2rem; border-bottom: 1px solid var(--color-border-primary); padding-bottom: 0.35rem; }
.docs-viewer__content h3 { font-size: 1.05rem; margin-top: 1.5rem; }
.docs-viewer__content h4 { font-size: 0.95rem; margin-top: 1.2rem; }
.docs-viewer__content p,
.docs-viewer__content li,
.docs-viewer__content blockquote {
color: var(--color-text-primary);
line-height: 1.65;
font-size: 0.95rem;
line-height: 1.7;
font-size: 0.92rem;
}
.docs-viewer__content ul,
.docs-viewer__content ol {
padding-left: 1.25rem;
}
.docs-viewer__content ol { padding-left: 1.25rem; }
.docs-viewer__content a {
color: var(--color-brand-primary);
}
.docs-viewer__content a { color: var(--color-brand-primary); }
.docs-viewer__content code {
font-family: var(--font-mono, monospace);
font-size: 0.88em;
font-size: 0.85em;
background: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
padding: 0.08rem 0.28rem;
padding: 0.1rem 0.3rem;
}
.docs-viewer__content pre {
@@ -247,30 +256,75 @@ import { DocsHeading, renderMarkdownDocument, slugifyHeading } from './docs-mark
border-radius: var(--radius-md);
background: var(--color-surface-secondary);
padding: 0.9rem;
font-family: var(--font-mono, monospace);
font-size: 0.82rem;
line-height: 1.55;
}
.docs-viewer__content blockquote {
margin: 0;
border-left: 3px solid var(--color-brand-primary);
background: var(--color-brand-primary-10);
padding: 0.2rem 0.9rem;
.docs-viewer__content pre code {
background: none;
border: none;
padding: 0;
font-size: inherit;
}
.docs-viewer__content blockquote {
margin: 1rem 0;
border-left: 3px solid var(--color-brand-primary);
background: var(--color-brand-primary-10);
padding: 0.5rem 1rem;
}
/* ── Tables ── */
.docs-viewer__content table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
line-height: 1.5;
margin: 1rem 0;
}
.docs-viewer__content th,
.docs-viewer__content td {
border: 1px solid var(--color-border-primary);
padding: 0.5rem 0.75rem;
text-align: left;
}
.docs-viewer__content th {
background: var(--color-surface-secondary);
font-weight: var(--font-weight-semibold);
font-size: 0.82rem;
color: var(--color-text-secondary);
white-space: nowrap;
}
.docs-viewer__content td { color: var(--color-text-primary); }
.docs-viewer__content tbody tr:hover {
background: color-mix(in srgb, var(--color-surface-secondary) 50%, transparent);
}
/* ── HR ── */
.docs-viewer__content hr {
border: none;
border-top: 1px solid var(--color-border-primary);
margin: 1.5rem 0;
}
/* ── Images ── */
.docs-viewer__content img {
max-width: 100%;
height: auto;
border-radius: var(--radius-md);
}
/* ── Mermaid ── */
.docs-viewer__content .mermaid { margin: 1rem 0; }
@media (max-width: 960px) {
.docs-viewer__layout {
grid-template-columns: 1fr;
}
.docs-viewer__toc {
position: static;
}
.docs-viewer__header {
flex-direction: column;
}
.docs-viewer__layout { grid-template-columns: 1fr; }
.docs-viewer__toc { position: static; }
.docs-viewer__header { flex-direction: column; }
}
`],
})
@@ -278,16 +332,16 @@ export class DocsViewerComponent {
private readonly destroyRef = inject(DestroyRef);
private readonly http = inject(HttpClient);
private readonly router = inject(Router);
private readonly sanitizer = inject(DomSanitizer);
private requestVersion = 0;
private pendingAnchor: string | null = null;
readonly title = signal('Documentation');
readonly requestedPath = signal('README.md');
readonly resolvedAssetPath = signal<string | null>(null);
readonly headings = signal<DocsHeading[]>([]);
readonly headings = signal<TocHeading[]>([]);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly renderedHtml = signal<SafeHtml | string>('');
readonly rawMarkdown = signal('');
constructor() {
this.loadFromUrl(this.router.url);
@@ -302,6 +356,14 @@ export class DocsViewerComponent {
});
}
onMarkdownReady(): void {
this.extractHeadings();
if (this.pendingAnchor) {
this.scrollToAnchor(this.pendingAnchor);
this.pendingAnchor = null;
}
}
private loadFromUrl(url: string): void {
const { path, anchor } = parseDocsUrl(url);
this.requestedPath.set(path);
@@ -314,43 +376,61 @@ export class DocsViewerComponent {
this.error.set(null);
this.resolvedAssetPath.set(null);
this.headings.set([]);
this.rawMarkdown.set('');
for (const candidate of buildDocsAssetCandidates(path)) {
try {
const markdown = await firstValueFrom(this.http.get(candidate, { responseType: 'text' }));
if (requestVersion !== this.requestVersion) {
return;
}
if (requestVersion !== this.requestVersion) return;
const rendered = renderMarkdownDocument(markdown, path, resolveDocsLink);
this.title.set(rendered.title);
this.headings.set(rendered.headings);
// Extract title from first H1
const titleMatch = markdown.match(/^#\s+(.+)$/m);
this.title.set(titleMatch?.[1]?.trim() ?? 'Documentation');
this.resolvedAssetPath.set(candidate);
this.renderedHtml.set(this.sanitizer.bypassSecurityTrustHtml(rendered.html));
this.rawMarkdown.set(markdown);
this.loading.set(false);
queueMicrotask(() => this.scrollToAnchor(anchor));
this.pendingAnchor = anchor;
return;
} catch {
continue;
}
}
if (requestVersion !== this.requestVersion) {
return;
}
if (requestVersion !== this.requestVersion) return;
this.title.set('Documentation');
this.renderedHtml.set(this.sanitizer.bypassSecurityTrustHtml(''));
this.rawMarkdown.set('');
this.error.set(`No documentation asset matched ${path}.`);
this.loading.set(false);
}
private extractHeadings(): void {
const container = document.querySelector('.docs-viewer__content');
if (!container) return;
const elements = container.querySelectorAll('h1, h2, h3, h4, h5, h6');
const tocHeadings: TocHeading[] = [];
elements.forEach((el) => {
const level = parseInt(el.tagName[1], 10);
const text = el.textContent?.trim() ?? '';
if (!text) return;
let id = el.id;
if (!id) {
id = slugifyHeading(text);
el.id = id;
}
tocHeadings.push({ level, text, id });
});
this.headings.set(tocHeadings);
}
private scrollToAnchor(anchor: string | null): void {
const normalizedAnchor = normalizeDocsAnchor(anchor);
if (!normalizedAnchor) {
return;
}
if (!normalizedAnchor) return;
const target =
document.getElementById(normalizedAnchor) ??

View File

@@ -0,0 +1,373 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { PlatformContextStore } from '../../../core/context/platform-context.store';
import { OPERATIONS_PATHS } from './operations-paths';
interface EventType {
readonly key: string;
readonly label: string;
}
@Component({
selector: 'app-event-stream-page',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<section class="event-stream">
<header class="event-stream__header">
<nav class="event-stream__breadcrumb">
<a [routerLink]="OPERATIONS_PATHS.overview">Operations</a>
<span class="separator">/</span>
<span>Event Stream</span>
</nav>
<h1>Event Stream Monitor</h1>
<p class="event-stream__description">
Real-time event bus health, connection status, and recent activity across all Stella Ops services.
</p>
</header>
<div class="event-stream__layout">
<div class="event-stream__main">
<!-- Connection status card -->
<div class="card card--status">
<div class="status-row">
<span class="status-indicator" [class.status-indicator--connected]="!hasError()" [class.status-indicator--degraded]="hasError()"></span>
<div class="status-details">
<span class="status-label">{{ hasError() ? 'Degraded' : 'Connected' }}</span>
<span class="status-transport">Transport: Valkey Pub/Sub</span>
</div>
</div>
@if (hasError()) {
<div class="status-error">{{ platformError() }}</div>
}
</div>
<!-- Metric cards -->
<div class="metric-cards">
<div class="card card--metric">
<span class="metric-label">Services Connected</span>
<span class="metric-value">&mdash;</span>
<span class="metric-note">Requires gateway health endpoint</span>
</div>
<div class="card card--metric">
<span class="metric-label">Events / min</span>
<span class="metric-value">&mdash;</span>
<span class="metric-note">Requires event replay API</span>
</div>
<div class="card card--metric">
<span class="metric-label">Last Event</span>
<span class="metric-value">&mdash;</span>
<span class="metric-note">Requires event replay API</span>
</div>
</div>
<!-- Recent events table -->
<div class="card card--table">
<h2 class="card__title">Recent Events</h2>
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Service</th>
<th>Event Type</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="4" class="empty-state">
Event stream history will appear here once the event replay API is available.
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Event types sidebar -->
<aside class="event-stream__sidebar">
<div class="card card--sidebar">
<h2 class="card__title">Event Types</h2>
<p class="sidebar-description">Known event types flowing through the Valkey pub/sub stream.</p>
<ul class="event-type-list">
@for (eventType of eventTypes; track eventType.key) {
<li class="event-type-item">
<code class="event-type-key">{{ eventType.key }}</code>
<span class="event-type-label">{{ eventType.label }}</span>
</li>
}
</ul>
</div>
</aside>
</div>
</section>
`,
styles: [`
.event-stream {
display: grid;
gap: 1rem;
}
.event-stream__header {
display: grid;
gap: 0.25rem;
}
.event-stream__breadcrumb {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.72rem;
color: var(--color-text-secondary);
}
.event-stream__breadcrumb a {
color: var(--color-brand-primary);
text-decoration: none;
}
.event-stream__breadcrumb .separator {
color: var(--color-text-tertiary, var(--color-text-secondary));
}
.event-stream__header h1 {
margin: 0;
}
.event-stream__description {
margin: 0;
font-size: 0.8rem;
color: var(--color-text-secondary);
max-width: 72ch;
}
.event-stream__layout {
display: grid;
grid-template-columns: 1fr 280px;
gap: 1rem;
align-items: start;
}
@media (max-width: 860px) {
.event-stream__layout {
grid-template-columns: 1fr;
}
}
.event-stream__main {
display: grid;
gap: 0.75rem;
}
.card {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
padding: 0.75rem;
}
.card__title {
margin: 0 0 0.5rem;
font-size: 0.82rem;
font-weight: 600;
}
/* Status card */
.card--status {
display: grid;
gap: 0.5rem;
}
.status-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.status-indicator {
width: 14px;
height: 14px;
border-radius: 50%;
flex-shrink: 0;
}
.status-indicator--connected {
background: #10b981;
box-shadow: 0 0 6px rgba(16, 185, 129, 0.45);
}
.status-indicator--degraded {
background: #f59e0b;
box-shadow: 0 0 6px rgba(245, 158, 11, 0.45);
}
.status-details {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.status-label {
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text-primary);
}
.status-transport {
font-size: 0.72rem;
color: var(--color-text-secondary);
font-family: var(--font-mono, monospace);
}
.status-error {
font-size: 0.72rem;
color: var(--color-status-error-text, #ef4444);
border: 1px solid var(--color-status-error-border, rgba(239, 68, 68, 0.3));
background: var(--color-status-error-surface, rgba(239, 68, 68, 0.08));
border-radius: var(--radius-sm);
padding: 0.3rem 0.45rem;
}
/* Metric cards */
.metric-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
@media (max-width: 640px) {
.metric-cards {
grid-template-columns: 1fr;
}
}
.card--metric {
display: flex;
flex-direction: column;
gap: 0.15rem;
text-align: center;
padding: 0.85rem 0.6rem;
}
.metric-label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-secondary);
}
.metric-value {
font-size: 1.6rem;
font-weight: 700;
font-family: var(--font-mono, monospace);
color: var(--color-text-primary);
line-height: 1.2;
}
.metric-note {
font-size: 0.66rem;
color: var(--color-text-tertiary, var(--color-text-secondary));
font-style: italic;
}
/* Table card */
.card--table {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
border-bottom: 1px solid var(--color-border-primary);
padding: 0.45rem;
font-size: 0.74rem;
white-space: nowrap;
}
th {
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-secondary);
}
.empty-state {
text-align: center;
color: var(--color-text-secondary);
font-style: italic;
padding: 2rem 1rem;
white-space: normal;
}
/* Sidebar */
.event-stream__sidebar {
position: sticky;
top: 1rem;
}
.card--sidebar {
display: grid;
gap: 0.4rem;
}
.sidebar-description {
margin: 0;
font-size: 0.72rem;
color: var(--color-text-secondary);
}
.event-type-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.event-type-item {
display: grid;
gap: 0.1rem;
padding: 0.35rem 0.45rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: var(--color-surface-secondary, var(--color-surface-primary));
}
.event-type-key {
font-size: 0.72rem;
font-family: var(--font-mono, monospace);
color: var(--color-brand-primary);
}
.event-type-label {
font-size: 0.68rem;
color: var(--color-text-secondary);
}
`],
})
export class EventStreamPageComponent {
private readonly platformContext = inject(PlatformContextStore);
readonly OPERATIONS_PATHS = OPERATIONS_PATHS;
readonly platformError = this.platformContext.error;
readonly hasError = computed(() => !!this.platformContext.error());
readonly eventTypes: readonly EventType[] = [
{ key: 'context.changed', label: 'User context updates' },
{ key: 'release.sealed', label: 'Release sealed' },
{ key: 'scan.completed', label: 'Scan finished' },
{ key: 'policy.evaluated', label: 'Policy evaluation' },
{ key: 'approval.requested', label: 'Approval workflow' },
{ key: 'evidence.signed', label: 'Evidence signing' },
{ key: 'job.completed', label: 'Job engine completion' },
{ key: 'health.changed', label: 'Service health update' },
];
}

View File

@@ -13,6 +13,7 @@ export const OPERATIONS_PATHS = {
signals: `${OPERATIONS_ROOT}/signals`,
packs: `${OPERATIONS_ROOT}/packs`,
notifications: `${OPERATIONS_ROOT}/notifications`,
eventStream: `${OPERATIONS_ROOT}/event-stream`,
status: `${OPERATIONS_ROOT}/status`,
scheduler: `${OPERATIONS_ROOT}/scheduler`,
schedulerRuns: `${OPERATIONS_ROOT}/scheduler/runs`,

View File

@@ -174,6 +174,27 @@ interface ScanStatusResponse {
</div>
</div>
@if (scanInProgress()) {
<div class="info-banner">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="16" x2="12" y2="12" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="8" x2="12.01" y2="8" stroke="currentColor" stroke-width="2"/>
</svg>
<span>Scan submitted. In local development mode, scans may take longer as scanner workers process the queue sequentially.</span>
</div>
}
@if (showQueueHint()) {
<div class="info-banner info-banner--hint">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
<polyline points="12 6 12 12 16 14" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
<span>Still processing. Check <a routerLink="/ops/operations/jobengine">Jobs Engine status</a> for queue details.</span>
</div>
}
@if (scanStatus() === 'completed') {
<div class="completion-banner">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
@@ -492,6 +513,39 @@ interface ScanStatusResponse {
margin-top: 0.25rem;
}
.info-banner {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-radius: var(--radius-sm);
font-size: 0.78rem;
line-height: 1.4;
border: 1px solid color-mix(in srgb, var(--color-brand-primary) 30%, transparent);
background: color-mix(in srgb, var(--color-brand-primary) 8%, transparent);
color: var(--color-text-secondary);
}
.info-banner svg {
flex-shrink: 0;
margin-top: 0.1rem;
color: var(--color-brand-primary);
}
.info-banner--hint {
border-color: color-mix(in srgb, var(--color-status-warning-text, #b08800) 30%, transparent);
background: color-mix(in srgb, var(--color-status-warning-text, #b08800) 8%, transparent);
}
.info-banner--hint svg {
color: var(--color-status-warning-text, #b08800);
}
.info-banner a {
color: inherit;
text-decoration: underline;
}
.findings-link {
margin-left: auto;
text-decoration: none;
@@ -523,8 +577,17 @@ export class ScanSubmitComponent implements OnDestroy {
readonly scanId = signal<string | null>(null);
readonly scanStatus = signal<string>('queued');
readonly scanTimedOut = signal(false);
readonly pollCount = signal(0);
private pollSubscription: { unsubscribe: () => void } | null = null;
/** Derived polling hints */
readonly scanInProgress = computed(() => {
const id = this.scanId();
const s = this.scanStatus();
return !!id && s !== 'completed' && s !== 'failed';
});
readonly showQueueHint = computed(() => this.scanInProgress() && this.pollCount() >= 10);
/** Derive image name (strip tag/digest for triage link) */
readonly imageName = computed(() => {
const ref = this.imageRef().trim();
@@ -595,6 +658,7 @@ export class ScanSubmitComponent implements OnDestroy {
this.scanId.set(null);
this.scanStatus.set('queued');
this.scanTimedOut.set(false);
this.pollCount.set(0);
this.submitError.set(null);
this.imageRef.set('');
this.forceRescan = false;
@@ -613,6 +677,7 @@ export class ScanSubmitComponent implements OnDestroy {
private startPolling(scanId: string): void {
this.stopPolling();
this.pollCount.set(0);
this.pollSubscription = timer(0, 3000).pipe(
take(60),
@@ -620,6 +685,7 @@ export class ScanSubmitComponent implements OnDestroy {
this.http.get<ScanStatusResponse>(`/api/v1/scans/${encodeURIComponent(scanId)}`)
),
tap((response) => {
this.pollCount.update((n) => n + 1);
this.scanStatus.set(response.status?.toLowerCase() || 'queued');
}),
takeWhile(

View File

@@ -90,6 +90,11 @@ interface PlatformListResponse<T> {
@if (error()) { <div class="banner banner--error">{{ error() }}</div> }
@if (loading()) { <div class="banner">Loading security overview...</div> }
@if (hasDegradedData()) {
<div class="banner banner--warn">
Some data sources are temporarily unavailable. Displayed values may be incomplete.
</div>
}
@if (!loading()) {
<section class="kpis">
@@ -258,6 +263,7 @@ interface PlatformListResponse<T> {
.banner{padding:.65rem;font-size:.8rem;color:var(--color-text-secondary)}
.banner--error{color:var(--color-status-error-text)}
.banner--warn{color:var(--color-status-warning-text);border-color:var(--color-status-warning-text)}
.kpis{
display:grid;
@@ -314,6 +320,17 @@ export class SecurityRiskOverviewComponent {
readonly vexSourceHealth = signal<IntegrationHealthRow[]>([]);
readonly triageStats = signal<VulnerabilityStats | null>(null);
/** Per-source error signals for degraded-data tracking. */
readonly findingsError = signal<string | null>(null);
readonly dispositionError = signal<string | null>(null);
readonly sbomError = signal<string | null>(null);
readonly feedHealthError = signal<string | null>(null);
readonly triageStatsError = signal<string | null>(null);
readonly hasDegradedData = computed(() =>
!!(this.findingsError() || this.dispositionError() || this.sbomError() || this.feedHealthError() || this.triageStatsError())
);
/** Use triage stats total when security findings API returns empty. */
readonly findingsCount = computed(() => {
const securityFindings = this.findings().length;
@@ -462,17 +479,31 @@ export class SecurityRiskOverviewComponent {
private load(): void {
this.loading.set(true);
this.error.set(null);
this.findingsError.set(null);
this.dispositionError.set(null);
this.sbomError.set(null);
this.feedHealthError.set(null);
this.triageStatsError.set(null);
const params = this.createContextParams();
const findings$ = this.http
.get<SecurityFindingsResponse>('/api/v2/security/findings', { params: params.set('pivot', 'cve') })
.pipe(map((res) => res.items ?? []), catchError(() => of([] as SecurityFindingProjection[])));
.pipe(map((res) => res.items ?? []), catchError(() => {
this.findingsError.set('Unable to load security findings');
return of([] as SecurityFindingProjection[]);
}));
const disposition$ = this.http
.get<PlatformListResponse<SecurityDispositionProjection>>('/api/v2/security/disposition', { params })
.pipe(map((res) => res.items ?? []), catchError(() => of([] as SecurityDispositionProjection[])));
.pipe(map((res) => res.items ?? []), catchError(() => {
this.dispositionError.set('Unable to load disposition data');
return of([] as SecurityDispositionProjection[]);
}));
const sbom$ = this.http
.get<SecuritySbomExplorerResponse>('/api/v2/security/sbom-explorer', { params: params.set('mode', 'table') })
.pipe(map((res) => res.table ?? []), catchError(() => of([] as SecuritySbomExplorerResponse['table'])));
.pipe(map((res) => res.table ?? []), catchError(() => {
this.sbomError.set('Unable to load SBOM data');
return of([] as SecuritySbomExplorerResponse['table']);
}));
const feedHealth$ = this.advisorySourcesApi.listSources(true).pipe(
map((items: AdvisorySourceListItemDto[]) => items.map(item => ({
sourceId: item.sourceKey,
@@ -482,11 +513,17 @@ export class SecurityRiskOverviewComponent {
freshnessMinutes: item.freshnessAgeSeconds > 0 ? Math.round(item.freshnessAgeSeconds / 60) : null,
slaMinutes: Math.round(item.freshnessSlaSeconds / 60),
} as IntegrationHealthRow))),
catchError(() => of([] as IntegrationHealthRow[]))
catchError(() => {
this.feedHealthError.set('Unable to load advisory feed health');
return of([] as IntegrationHealthRow[]);
})
);
const vexHealth$ = of([] as IntegrationHealthRow[]);
const triageStats$ = this.vulnApi.getStats().pipe(
catchError(() => of(null as VulnerabilityStats | null))
catchError(() => {
this.triageStatsError.set('Unable to load triage statistics');
return of(null as VulnerabilityStats | null);
})
);
forkJoin({ findings: findings$, disposition: disposition$, sbom: sbom$, feedHealth: feedHealth$, vexHealth: vexHealth$, triageStats: triageStats$ })

View File

@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
import { ThemeService, ThemeMode } from '../../../core/services/theme.service';
import { AUTH_SERVICE, AuthService } from '../../../core/auth';
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
import { I18nService, LocaleCatalogService, SUPPORTED_LOCALES, UserLocalePreferenceService } from '../../../core/i18n';
import { SidebarPreferenceService } from '../../../layout/app-sidebar/sidebar-preference.service';
@@ -19,6 +20,40 @@ type LocaleSaveState = 'idle' | 'saving' | 'saved' | 'syncFailed';
<h1 class="prefs__title">User Preferences</h1>
<p class="prefs__subtitle">Personalize your console experience</p>
<!-- Profile -->
<section class="prefs__card">
<div class="prefs__card-header">
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<circle cx="12" cy="8" r="4" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M4 20c0-4 4-6 8-6s8 2 8 6" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
<h2>Profile</h2>
</div>
<p class="prefs__description">Manage your account information.</p>
<label class="prefs__label" for="display-name">Display name</label>
<input id="display-name" class="prefs__input" type="text"
[value]="userName()" disabled
title="Display name is managed by your identity provider" />
<p class="prefs__hint">Managed by your identity provider</p>
<label class="prefs__label" for="email-input">Email</label>
<div class="prefs__input-row">
<input id="email-input" class="prefs__input" type="email"
[value]="emailValue()"
(input)="onEmailInput($event)"
placeholder="Enter your email address" />
<button type="button" class="prefs__save-btn"
[disabled]="!emailDirty() || emailSaving()"
(click)="saveEmail()">
{{ emailSaving() ? 'Saving...' : 'Save' }}
</button>
</div>
@if (emailStatus()) {
<p class="prefs__status">{{ emailStatus() }}</p>
}
</section>
<!-- Appearance -->
<section class="prefs__card">
<div class="prefs__card-header">
@@ -359,6 +394,52 @@ type LocaleSaveState = 'idle' | 'saving' | 'saved' | 'syncFailed';
outline-offset: 2px;
}
/* Profile inputs */
.prefs__input {
width: min(320px, 100%);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
background: var(--color-surface-primary);
color: var(--color-text-primary);
font-size: 0.875rem;
padding: 0.5rem 0.625rem;
}
.prefs__input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.prefs__input:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.prefs__input-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.prefs__save-btn {
padding: 0.5rem 1rem;
border: 1px solid var(--color-brand-primary);
border-radius: var(--radius-md);
background: var(--color-brand-primary);
color: white;
font-size: 0.8125rem;
cursor: pointer;
white-space: nowrap;
}
.prefs__save-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.prefs__save-btn:hover:not(:disabled) {
background: var(--color-brand-primary-hover);
}
.prefs__hint {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-tertiary);
}
/* AI section */
.prefs__ai-plain-language {
padding-top: 0.5rem;
@@ -372,13 +453,20 @@ type LocaleSaveState = 'idle' | 'saving' | 'saved' | 'syncFailed';
}
`],
})
export class UserPreferencesPageComponent {
export class UserPreferencesPageComponent implements OnInit {
protected readonly themeService = inject(ThemeService);
protected readonly sidebarPrefs = inject(SidebarPreferenceService);
private readonly i18n = inject(I18nService);
private readonly localeCatalog = inject(LocaleCatalogService);
private readonly localePreference = inject(UserLocalePreferenceService);
private readonly authSession = inject(AuthSessionStore);
private readonly authService = inject(AUTH_SERVICE) as AuthService;
readonly userName = computed(() => this.authService.user()?.name ?? 'User');
readonly emailValue = signal('');
readonly emailDirty = signal(false);
readonly emailSaving = signal(false);
readonly emailStatus = signal<string | null>(null);
readonly currentLocale = this.i18n.locale;
readonly sidebarCollapsed = this.sidebarPrefs.sidebarCollapsed;
@@ -422,6 +510,45 @@ export class UserPreferencesPageComponent {
void this.loadLocaleOptions();
}
ngOnInit(): void {
const email = this.authService.user()?.email?.trim() ?? '';
const lower = email.toLowerCase();
if (lower.includes('@unknown.local') || !email.includes('@')) {
this.emailValue.set('');
} else {
this.emailValue.set(email);
}
}
onEmailInput(event: Event): void {
const val = (event.target as HTMLInputElement).value;
this.emailValue.set(val);
this.emailDirty.set(true);
this.emailStatus.set(null);
}
async saveEmail(): Promise<void> {
this.emailSaving.set(true);
this.emailStatus.set(null);
try {
const resp = await fetch('/api/v1/platform/preferences/email', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: this.emailValue() }),
});
if (resp.ok) {
this.emailStatus.set('Email updated successfully.');
this.emailDirty.set(false);
} else {
this.emailStatus.set('Failed to update email. The endpoint may not be available yet.');
}
} catch {
this.emailStatus.set('Failed to update email. The endpoint may not be available yet.');
} finally {
this.emailSaving.set(false);
}
}
async onLocaleSelected(event: Event): Promise<void> {
const selected = (event.target as HTMLSelectElement | null)?.value?.trim();
if (!selected || selected === this.currentLocale()) return;

View File

@@ -54,13 +54,13 @@ declare global {
<!-- Brand typography -->
<h1 class="brand">{{ title() }}</h1>
<p class="tagline">Release Control Plane</p>
<p class="tagline">Release Control Plane for Container Estates</p>
<!-- Amber rule -->
<div class="rule" aria-hidden="true"></div>
<!-- Message -->
<p class="message">{{ message() }}</p>
<!-- Value proposition -->
<p class="message">Stop scripting promotions, chasing scan results, and hoping deploys are safe. Stella gives your team governed releases with evidence at every step.</p>
@if (authNotice(); as notice) {
<p class="auth-notice">
@@ -71,20 +71,61 @@ declare global {
</p>
}
<!-- System status indicators -->
<div class="indicators">
<span class="chip chip--1">
<i class="chip__dot"></i>
<span>Encrypted</span>
</span>
<span class="chip chip--2">
<i class="chip__dot"></i>
<span>Identity</span>
</span>
<span class="chip chip--3">
<i class="chip__dot"></i>
<span>Pipeline</span>
</span>
<!-- Get Started journey -->
<div class="journey">
<h2 class="journey__heading">Get Started</h2>
<ol class="journey__steps">
<li class="step step--1">
<span class="step__number">1</span>
<div class="step__body">
<strong class="step__title">Connect a Registry</strong>
<span class="step__desc">Link your container registry to discover images</span>
</div>
</li>
<li class="step step--2">
<span class="step__number">2</span>
<div class="step__body">
<strong class="step__title">Scan an Artifact</strong>
<span class="step__desc">Run security and compliance scans on container images</span>
</div>
</li>
<li class="step step--3">
<span class="step__number">3</span>
<div class="step__body">
<strong class="step__title">Create a Governed Release</strong>
<span class="step__desc">Bundle verified artifacts into a release definition</span>
</div>
</li>
<li class="step step--4">
<span class="step__number">4</span>
<div class="step__body">
<strong class="step__title">Promote with Evidence</strong>
<span class="step__desc">Move releases between environments with full audit trail</span>
</div>
</li>
</ol>
</div>
<!-- What Stella Replaces -->
<div class="replaces">
<h2 class="replaces__heading">What Stella Replaces</h2>
<ul class="replaces__list">
<li class="replaces__item replaces__item--1">
<span class="replaces__before">Manual promotion scripts</span>
<span class="replaces__arrow" aria-hidden="true">&rarr;</span>
<span class="replaces__after">Policy-gated environment promotions</span>
</li>
<li class="replaces__item replaces__item--2">
<span class="replaces__before">Scattered scan results</span>
<span class="replaces__arrow" aria-hidden="true">&rarr;</span>
<span class="replaces__after">Unified security posture with VEX support</span>
</li>
<li class="replaces__item replaces__item--3">
<span class="replaces__before">Trust-me deployments</span>
<span class="replaces__arrow" aria-hidden="true">&rarr;</span>
<span class="replaces__after">Verifiable evidence for every release decision</span>
</li>
</ul>
</div>
<!-- Actions -->
@@ -139,7 +180,8 @@ declare global {
align-items: center;
justify-content: center;
min-height: 100%;
overflow: hidden;
overflow-x: hidden;
overflow-y: auto;
background:
radial-gradient(ellipse 55% 45% at 50% 38%, rgba(224,154,24,0.07) 0%, transparent 70%),
radial-gradient(ellipse 40% 30% at 20% 80%, rgba(8,10,18,0.5) 0%, transparent 50%),
@@ -228,7 +270,7 @@ declare global {
flex-direction: column;
align-items: center;
text-align: center;
max-width: 380px;
max-width: 520px;
width: 100%;
padding: 2rem 1.5rem;
}
@@ -309,7 +351,7 @@ declare global {
font-weight: 400;
line-height: 1.65;
color: #7A7568;
max-width: 300px;
max-width: 420px;
animation: content-up 550ms cubic-bezier(0.22, 1, 0.36, 1) 0.5s both;
}
@@ -338,46 +380,150 @@ declare global {
}
/* ==================================================================
STATUS INDICATORS
GET STARTED JOURNEY
================================================================== */
.indicators {
display: flex;
gap: 0.625rem;
margin-bottom: 2rem;
.journey {
width: 100%;
margin-bottom: 1.5rem;
animation: content-up 550ms cubic-bezier(0.22, 1, 0.36, 1) 0.55s both;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.3125rem;
padding: 0.1875rem 0.5rem;
border-radius: 100px;
border: 1px solid rgba(224,154,24,0.10);
background: rgba(224,154,24,0.04);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.5625rem;
.journey__heading {
margin: 0 0 0.75rem;
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
font-size: 0.625rem;
font-weight: 500;
letter-spacing: 0.06em;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #7A7568;
color: #CC8810;
}
.chip--1 { animation: chip-in 400ms cubic-bezier(0.22, 1, 0.36, 1) 0.6s both; }
.chip--2 { animation: chip-in 400ms cubic-bezier(0.22, 1, 0.36, 1) 0.7s both; }
.chip--3 { animation: chip-in 400ms cubic-bezier(0.22, 1, 0.36, 1) 0.8s both; }
.journey__steps {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.chip__dot {
width: 4px;
height: 4px;
.step {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.625rem 0.75rem;
border-radius: 10px;
border: 1px solid rgba(224,154,24,0.08);
background: rgba(224,154,24,0.03);
text-align: left;
}
.step--1 { animation: chip-in 400ms cubic-bezier(0.22, 1, 0.36, 1) 0.6s both; }
.step--2 { animation: chip-in 400ms cubic-bezier(0.22, 1, 0.36, 1) 0.7s both; }
.step--3 { animation: chip-in 400ms cubic-bezier(0.22, 1, 0.36, 1) 0.8s both; }
.step--4 { animation: chip-in 400ms cubic-bezier(0.22, 1, 0.36, 1) 0.9s both; }
.step__number {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 6px rgba(34,197,94,0.4);
animation: dot-pulse 3s ease-in-out infinite;
background: rgba(224,154,24,0.12);
color: #CC8810;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.6875rem;
font-weight: 600;
line-height: 1;
margin-top: 1px;
}
.chip--1 .chip__dot { animation-delay: 0.9s; }
.chip--2 .chip__dot { animation-delay: 1.2s; }
.chip--3 .chip__dot { animation-delay: 1.5s; }
.step__body {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.step__title {
font-size: 0.8125rem;
font-weight: 600;
color: #F0EDE4;
line-height: 1.3;
}
.step__desc {
font-size: 0.75rem;
font-weight: 400;
color: #7A7568;
line-height: 1.4;
}
/* ==================================================================
WHAT STELLA REPLACES
================================================================== */
.replaces {
width: 100%;
margin-bottom: 1.75rem;
animation: content-up 550ms cubic-bezier(0.22, 1, 0.36, 1) 0.95s both;
}
.replaces__heading {
margin: 0 0 0.625rem;
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
font-size: 0.625rem;
font-weight: 500;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #CC8810;
}
.replaces__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.replaces__item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
border-radius: 8px;
border: 1px solid rgba(224,154,24,0.06);
background: rgba(224,154,24,0.02);
font-size: 0.6875rem;
line-height: 1.4;
}
.replaces__item--1 { animation: chip-in 400ms cubic-bezier(0.22, 1, 0.36, 1) 1.0s both; }
.replaces__item--2 { animation: chip-in 400ms cubic-bezier(0.22, 1, 0.36, 1) 1.1s both; }
.replaces__item--3 { animation: chip-in 400ms cubic-bezier(0.22, 1, 0.36, 1) 1.2s both; }
.replaces__before {
color: #5a564e;
text-decoration: line-through;
text-decoration-color: rgba(90,86,78,0.4);
flex: 1;
text-align: left;
}
.replaces__arrow {
flex-shrink: 0;
color: #CC8810;
font-size: 0.75rem;
}
.replaces__after {
flex: 1.4;
color: #b8b1a4;
font-weight: 500;
text-align: left;
}
/* ==================================================================
CTA BUTTON & DOCS LINK
@@ -388,7 +534,7 @@ declare global {
flex-direction: column;
align-items: center;
gap: 1rem;
animation: content-up 550ms cubic-bezier(0.22, 1, 0.36, 1) 0.85s both;
animation: content-up 550ms cubic-bezier(0.22, 1, 0.36, 1) 1.3s both;
}
.cta {
@@ -584,11 +730,6 @@ declare global {
to { opacity: 1; transform: scale(1) translateY(0); }
}
@keyframes dot-pulse {
0%, 100% { box-shadow: 0 0 4px rgba(34,197,94,0.3); }
50% { box-shadow: 0 0 8px rgba(34,197,94,0.6); }
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -100% 0; }
@@ -600,16 +741,19 @@ declare global {
@media (prefers-reduced-motion: reduce) {
.viewport, .dot-grid, .signal-svg, .scan,
.logo-img, .logo-glow, .brand, .tagline,
.rule, .message, .actions,
.chip--1, .chip--2, .chip--3,
.chip__dot, .cta::after, .docs-link,
.rule, .message, .actions, .journey, .replaces,
.step--1, .step--2, .step--3, .step--4,
.replaces__item--1, .replaces__item--2, .replaces__item--3,
.cta::after, .docs-link,
.corner, .version {
animation: none !important;
}
.dot-grid, .signal-svg, .logo-img, .logo-glow,
.brand, .tagline, .rule, .message, .actions,
.chip--1, .chip--2, .chip--3,
.journey, .replaces,
.step--1, .step--2, .step--3, .step--4,
.replaces__item--1, .replaces__item--2, .replaces__item--3,
.corner, .version {
opacity: 1;
}
@@ -624,41 +768,66 @@ declare global {
@media (max-width: 640px) {
.hero {
padding: 1.5rem 1.25rem;
max-width: 100%;
}
.logo-wrap {
width: 180px;
height: 180px;
margin-bottom: 1.25rem;
width: 140px;
height: 140px;
margin-bottom: 1rem;
}
.logo-img {
width: 140px;
height: 140px;
width: 110px;
height: 110px;
border-radius: 24px;
}
.logo-glow {
width: 240px;
height: 240px;
width: 200px;
height: 200px;
}
.brand {
font-size: 2rem;
font-size: 1.75rem;
}
.tagline {
font-size: 0.5625rem;
letter-spacing: 0.10em;
margin-bottom: 1rem;
}
.step {
padding: 0.5rem 0.625rem;
}
.step__title {
font-size: 0.75rem;
}
.step__desc {
font-size: 0.6875rem;
}
.replaces__item {
flex-wrap: wrap;
gap: 0.25rem;
font-size: 0.625rem;
letter-spacing: 0.12em;
}
.indicators {
gap: 0.375rem;
.replaces__before,
.replaces__after {
flex: 1 1 100%;
}
.chip {
font-size: 0.5rem;
padding: 0.125rem 0.375rem;
.replaces__arrow {
display: none;
}
.replaces__after::before {
content: '\\2192\\00a0 ';
color: #CC8810;
}
.corner--tl { top: 1rem; left: 1rem; }

View File

@@ -10,7 +10,7 @@ const STORAGE_KEY = 'stellaops.sidebar.preferences';
const DEFAULTS: SidebarPreferences = {
sidebarCollapsed: false,
collapsedGroups: ['audit-evidence', 'setup-admin'],
collapsedGroups: ['operations', 'audit-evidence', 'setup-admin'],
collapsedSections: [],
};

View File

@@ -18,8 +18,6 @@ import { filter } from 'rxjs/operators';
import { AuthSessionStore } from '../../core/auth/auth-session.store';
import { ConsoleSessionService } from '../../core/console/console-session.service';
import { ConsoleSessionStore } from '../../core/console/console-session.store';
import { ThemeService } from '../../core/services/theme.service';
import { GlobalSearchComponent } from '../global-search/global-search.component';
import { ContextChipsComponent } from '../context-chips/context-chips.component';
import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.component';
@@ -101,32 +99,6 @@ import { LiveEventStreamChipComponent } from '../context-chips/live-event-stream
Context
</button>
<!-- Theme toggle -->
<button
type="button"
class="topbar__theme-toggle"
[attr.aria-label]="'Theme: ' + themeService.modeLabel() + '. Click to cycle.'"
[title]="themeService.modeLabel()"
(click)="themeService.cycle()"
>
@if (themeService.mode() === 'dark') {
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
} @else if (themeService.mode() === 'system') {
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<rect x="2" y="3" width="20" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="21" x2="16" y2="21" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="17" x2="12" y2="21" stroke="currentColor" stroke-width="2"/>
</svg>
} @else {
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
</button>
<!-- User menu -->
<app-user-menu></app-user-menu>
</div>
@@ -367,32 +339,6 @@ import { LiveEventStreamChipComponent } from '../context-chips/live-event-stream
flex-shrink: 0;
}
.topbar__theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid transparent;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
transition: background-color 0.12s, color 0.12s, border-color 0.12s;
}
.topbar__theme-toggle:hover {
background: var(--color-nav-hover);
color: var(--color-text-primary);
border-color: var(--color-border-primary);
}
.topbar__theme-toggle:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: -2px;
}
.topbar__primary-action {
display: inline-flex;
align-items: center;
@@ -653,7 +599,6 @@ export class AppTopbarComponent {
private readonly consoleStore = inject(ConsoleSessionStore);
private readonly i18n = inject(I18nService);
private readonly localePreference = inject(UserLocalePreferenceService);
protected readonly themeService = inject(ThemeService);
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private readonly elementRef = inject(ElementRef<HTMLElement>);

View File

@@ -9,7 +9,7 @@ import { RouterLink } from '@angular/router';
import { AUTH_SERVICE, AuthService, StellaOpsScopes } from '../../core/auth';
/**
* EvidenceModeChipComponent - Shows whether evidence signing is enabled.
* EvidenceModeChipComponent - Icon-only chip showing whether evidence signing is enabled.
*/
@Component({
selector: 'app-evidence-mode-chip',
@@ -22,6 +22,7 @@ import { AUTH_SERVICE, AuthService, StellaOpsScopes } from '../../core/auth';
[class.chip--off]="!isEnabled()"
routerLink="/setup/trust-signing"
[attr.title]="tooltip()"
[attr.aria-label]="tooltip()"
>
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
@if (isEnabled()) {
@@ -33,32 +34,31 @@ import { AUTH_SERVICE, AuthService, StellaOpsScopes } from '../../core/auth';
<line x1="15" y1="9" x2="9" y2="15" stroke="currentColor" stroke-width="2"/>
}
</svg>
<span class="chip__label">Evidence: {{ isEnabled() ? 'ON' : 'OFF' }}</span>
</a>
`,
styles: [`
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-full);
font-size: 0.625rem;
font-weight: var(--font-weight-medium);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: opacity 0.15s;
height: 22px;
&:hover {
opacity: 0.8;
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
transition: opacity 0.15s, transform 0.15s;
position: relative;
}
.chip:hover {
opacity: 0.85;
transform: scale(1.1);
}
.chip:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.chip__icon {
flex-shrink: 0;
}
.chip--on {
@@ -70,14 +70,6 @@ import { AUTH_SERVICE, AuthService, StellaOpsScopes } from '../../core/auth';
background: var(--color-severity-none-bg);
color: var(--color-text-secondary);
}
.chip__icon {
flex-shrink: 0;
}
.chip__label {
line-height: 1;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -94,7 +86,7 @@ export class EvidenceModeChipComponent {
readonly tooltip = computed(() =>
this.isEnabled()
? 'Evidence signing scopes are active for this session.'
: 'Evidence signing scopes are not active for this session.'
? 'Evidence: ON \u2014 signing scopes active'
: 'Evidence: OFF \u2014 signing scopes inactive'
);
}

View File

@@ -9,9 +9,9 @@ import { RouterLink } from '@angular/router';
import { OfflineModeService } from '../../core/services/offline-mode.service';
/**
* FeedSnapshotChipComponent - Shows the date of the current vulnerability feed snapshot.
* FeedSnapshotChipComponent - Icon-only chip showing the vulnerability feed snapshot status.
*
* Displays staleness indicator when feed is older than threshold.
* Color indicates freshness: blue when fresh, red when stale.
*/
@Component({
selector: 'app-feed-snapshot-chip',
@@ -24,45 +24,38 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
[class.chip--stale]="isStale()"
routerLink="/ops/operations/feeds-airgap"
[attr.title]="tooltip()"
[attr.aria-label]="tooltip()"
>
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path d="M4 11a9 9 0 0 1 9 9" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M4 4a16 16 0 0 1 16 16" fill="none" stroke="currentColor" stroke-width="2"/>
<circle cx="5" cy="19" r="1" fill="currentColor"/>
</svg>
<span class="chip__label">Feed: {{ snapshotDate() }}</span>
@if (isStale()) {
<svg class="chip__warning" viewBox="0 0 24 24" width="12" height="12" aria-label="Feed is stale">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="8" x2="12" y2="12" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="16" x2="12.01" y2="16" stroke="currentColor" stroke-width="2"/>
</svg>
}
</a>
`,
styles: [`
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-full);
font-size: 0.625rem;
font-weight: var(--font-weight-medium);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: opacity 0.15s;
height: 22px;
&:hover {
opacity: 0.8;
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
transition: opacity 0.15s, transform 0.15s;
position: relative;
}
.chip:hover {
opacity: 0.85;
transform: scale(1.1);
}
.chip:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.chip__icon {
flex-shrink: 0;
}
.chip--fresh {
@@ -74,19 +67,6 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
background: var(--color-severity-high-bg);
color: var(--color-severity-high);
}
.chip__icon {
flex-shrink: 0;
}
.chip__label {
line-height: 1;
}
.chip__warning {
flex-shrink: 0;
margin-left: 0.125rem;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -116,10 +96,9 @@ export class FeedSnapshotChipComponent {
});
readonly tooltip = computed(() => {
const freshness = this.offlineMode.bundleFreshness();
if (!freshness) {
return 'Using live feed connectivity; offline snapshot metadata is unavailable.';
if (this.isStale()) {
return `Feed: ${this.snapshotDate()} \u2014 stale`;
}
return `${freshness.message} (snapshot ${freshness.bundleCreatedAt}).`;
return `Feed: ${this.snapshotDate()}`;
});
}

View File

@@ -1,58 +1,63 @@
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { PlatformContextStore } from '../../core/context/platform-context.store';
@Component({
selector: 'app-live-event-stream-chip',
standalone: true,
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<span
<a
class="chip"
[class.chip--ok]="status() === 'connected'"
[class.chip--degraded]="status() === 'degraded'"
routerLink="/ops/operations/event-stream"
[attr.title]="tooltip()"
role="status"
aria-live="polite"
aria-label="Live event stream status"
[attr.aria-label]="tooltip()"
>
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<circle cx="12" cy="12" r="8" fill="none" stroke="currentColor" stroke-width="2" />
<circle cx="12" cy="12" r="3" fill="currentColor" />
</svg>
<span class="chip__label">Events: {{ status() === 'connected' ? 'CONNECTED' : 'DEGRADED' }}</span>
</span>
</a>
`,
styles: [
`
.chip {
display: inline-flex;
align-items: center;
gap: 0.22rem;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-full);
border: 1px solid var(--color-border-primary);
background: var(--color-surface-primary);
padding: 0.14rem 0.45rem;
font-size: 0.625rem;
font-family: var(--font-family-mono);
letter-spacing: 0.04em;
text-transform: uppercase;
text-decoration: none;
cursor: pointer;
transition: opacity 0.15s, transform 0.15s;
position: relative;
}
.chip--ok {
border-color: color-mix(in srgb, var(--color-status-ok) 45%, var(--color-border-primary));
color: var(--color-status-ok);
.chip:hover {
opacity: 0.85;
transform: scale(1.1);
}
.chip--degraded {
border-color: color-mix(in srgb, var(--color-status-warn) 45%, var(--color-border-primary));
color: var(--color-status-warn);
.chip:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.chip__icon {
flex-shrink: 0;
}
.chip__label {
white-space: nowrap;
.chip--ok {
background: color-mix(in srgb, var(--color-status-ok) 15%, transparent);
color: var(--color-status-ok);
}
.chip--degraded {
background: color-mix(in srgb, var(--color-status-warn) 15%, transparent);
color: var(--color-status-warn);
}
`,
],
@@ -67,4 +72,12 @@ export class LiveEventStreamChipComponent {
readonly status = computed<'connected' | 'degraded'>(() => {
return this.context.error() ? 'degraded' : 'connected';
});
readonly tooltip = computed(() => {
if (this.status() === 'connected') {
return 'Events: Connected';
}
const error = this.context.error();
return error ? `Events: Degraded \u2014 ${error}` : 'Events: Degraded';
});
}

View File

@@ -4,11 +4,9 @@ import { RouterLink } from '@angular/router';
import { OfflineModeService } from '../../core/services/offline-mode.service';
/**
* OfflineStatusChipComponent - Shows connectivity/offline status.
* OfflineStatusChipComponent - Icon-only chip showing connectivity/offline status.
*
* Displays:
* - "Offline: OK" when fully operational
* - "Offline: DEGRADED" when some features unavailable
* Color indicates state: green when OK, amber when degraded.
*/
@Component({
selector: 'app-offline-status-chip',
@@ -21,6 +19,7 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
[class.chip--degraded]="status() === 'degraded'"
routerLink="/ops/operations/offline-kit"
[attr.title]="tooltip()"
[attr.aria-label]="tooltip()"
aria-live="polite"
>
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
@@ -33,32 +32,31 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
<line x1="12" y1="16" x2="12.01" y2="16" stroke="currentColor" stroke-width="2"/>
}
</svg>
<span class="chip__label">Offline: {{ status() === 'ok' ? 'OK' : 'DEGRADED' }}</span>
</a>
`,
styles: [`
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-full);
font-size: 0.625rem;
font-weight: var(--font-weight-medium);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: opacity 0.15s;
height: 22px;
&:hover {
opacity: 0.8;
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
transition: opacity 0.15s, transform 0.15s;
position: relative;
}
.chip:hover {
opacity: 0.85;
transform: scale(1.1);
}
.chip:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.chip__icon {
flex-shrink: 0;
}
.chip--ok {
@@ -70,14 +68,6 @@ import { OfflineModeService } from '../../core/services/offline-mode.service';
background: var(--color-severity-medium-bg);
color: var(--color-status-warning-text);
}
.chip__icon {
flex-shrink: 0;
}
.chip__label {
line-height: 1;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -98,18 +88,22 @@ export class OfflineStatusChipComponent {
});
readonly tooltip = computed(() => {
if (this.status() === 'ok') {
return 'Offline: OK \u2014 online with live connectivity';
}
if (this.offlineMode.isOffline()) {
return (
this.offlineMode.offlineBannerMessage() ??
'Offline mode is active for this tenant context.'
);
const message = this.offlineMode.offlineBannerMessage();
return message
? `Offline: Degraded \u2014 ${message}`
: 'Offline: Degraded \u2014 offline mode is active';
}
const freshness = this.offlineMode.bundleFreshness();
if (freshness) {
return freshness.message;
if (freshness?.message) {
return `Offline: Degraded \u2014 ${freshness.message}`;
}
return 'Online mode active with live backend connectivity.';
return 'Offline: Degraded';
});
}

View File

@@ -10,7 +10,7 @@ import { RouterLink } from '@angular/router';
import { PolicyPackStore } from '../../features/policy-studio/services/policy-pack.store';
/**
* PolicyBaselineChipComponent - Shows the active policy baseline version.
* PolicyBaselineChipComponent - Icon-only chip showing the active policy baseline version.
*/
@Component({
selector: 'app-policy-baseline-chip',
@@ -19,48 +19,50 @@ import { PolicyPackStore } from '../../features/policy-studio/services/policy-pa
template: `
<a
class="chip"
[class.chip--active]="hasBaseline()"
[class.chip--none]="!hasBaseline()"
routerLink="/ops/policy/packs"
[attr.title]="tooltip()"
[attr.aria-label]="tooltip()"
>
<svg class="chip__icon" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
<span class="chip__label">Policy: {{ baselineName() }}</span>
</a>
`,
styles: [`
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.5rem;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-full);
font-size: 0.625rem;
font-weight: var(--font-weight-medium);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: opacity 0.15s;
height: 22px;
background: var(--color-status-excepted-bg);
color: var(--color-status-excepted);
&:hover {
opacity: 0.8;
}
&:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
transition: opacity 0.15s, transform 0.15s;
position: relative;
}
.chip:hover {
opacity: 0.85;
transform: scale(1.1);
}
.chip:focus-visible {
outline: 2px solid var(--color-brand-primary);
outline-offset: 2px;
}
.chip__icon {
flex-shrink: 0;
}
.chip__label {
line-height: 1;
.chip--active {
background: var(--color-status-excepted-bg);
color: var(--color-status-excepted);
}
.chip--none {
background: var(--color-surface-secondary, rgba(128, 128, 128, 0.1));
color: var(--color-text-muted);
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -71,6 +73,8 @@ export class PolicyBaselineChipComponent {
initialValue: [],
});
readonly hasBaseline = computed(() => this.packs().length > 0);
readonly baselineName = computed(() => {
const packs = this.packs();
if (packs.length === 0) {
@@ -84,10 +88,10 @@ export class PolicyBaselineChipComponent {
readonly tooltip = computed(() => {
const packs = this.packs();
if (packs.length === 0) {
return 'No policy baseline is currently available.';
return 'Policy: No baseline';
}
const activePack = packs.find((pack) => pack.status === 'active') ?? packs[0];
return `Active policy baseline: ${activePack.name} ${activePack.version}. Click to manage policies.`;
return `Policy: ${activePack.name} ${activePack.version}`.trim();
});
}

View File

@@ -212,6 +212,15 @@ export const OPERATIONS_ROUTES: Routes = [
(m) => m.TopologyEnvironmentDetailPageComponent,
),
},
{
path: 'event-stream',
title: 'Event Stream',
data: { breadcrumb: 'Event Stream' },
loadComponent: () =>
import('../features/platform/ops/event-stream-page.component').then(
(m) => m.EventStreamPageComponent,
),
},
{
path: 'status',
title: 'System Status',

View File

@@ -123,7 +123,7 @@ export class BreadcrumbComponent {
'vulnerabilities': 'Vulnerabilities',
'graph': 'SBOM Graph',
'reachability': 'Reachability',
'triage': 'Triage',
'triage': 'Findings',
'artifacts': 'Artifacts',
'audit-bundles': 'Audit Bundles',
'exceptions': 'Exceptions',

View File

@@ -134,6 +134,26 @@ import { SeedClient } from '../../../core/api/seed.client';
}
</div>
} @else {
@if (inlineMatchedActions().length > 0) {
<div class="cp__section cp__section--border">
<div class="cp__section-label">Quick Actions</div>
@for (action of inlineMatchedActions(); track action.id; let i = $index) {
<button
class="cp__row"
[class.cp__row--selected]="selectedIndex() === i"
(click)="executeAction(action)"
(mouseenter)="selectedIndex.set(i)"
>
<span class="cp__row-icon">*</span>
<div class="cp__row-content">
<div class="cp__row-label">{{ action.label }}</div>
<div class="cp__row-desc">{{ action.description }}</div>
</div>
<span class="cp__row-shortcut">{{ action.shortcut }}</span>
</button>
}
</div>
}
@if (searchResponse()) {
@for (group of searchResponse()!.groups; track group.type) {
<div class="cp__section cp__section--border">
@@ -172,7 +192,7 @@ import { SeedClient } from '../../../core/api/seed.client';
}
</div>
}
@if (searchResponse()!.groups.length === 0) {
@if (searchResponse()!.groups.length === 0 && inlineMatchedActions().length === 0) {
<div class="cp__empty cp__empty--lg">
<div class="cp__empty-title">No results found</div>
<div class="cp__empty-hint">Try a different search term or use &gt; for actions</div>
@@ -501,6 +521,8 @@ export class CommandPaletteComponent implements OnInit, OnDestroy {
isActionMode = computed(() => this.query.startsWith('>'));
filteredActions = computed(() => filterQuickActions(this.query, this.quickActions));
/** Quick actions matching a plain-text (non-">" prefixed) query. */
inlineMatchedActions = signal<QuickAction[]>([]);
private flatResults: SearchResult[] = [];
@@ -552,15 +574,22 @@ export class CommandPaletteComponent implements OnInit, OnDestroy {
this.isOpen.set(true);
this.query = '';
this.searchResponse.set(null);
this.inlineMatchedActions.set([]);
this.selectedIndex.set(0);
this.recentSearches.set(getRecentSearches());
setTimeout(() => this.searchInput?.nativeElement?.focus(), 0);
}
close(): void { this.isOpen.set(false); this.query = ''; }
close(): void { this.isOpen.set(false); this.query = ''; this.inlineMatchedActions.set([]); }
onQueryChange(query: string): void {
this.selectedIndex.set(0);
// For plain-text queries (no ">" prefix), compute matching quick actions
if (!query.startsWith('>') && query.trim().length >= 2) {
this.inlineMatchedActions.set(filterQuickActions(query, this.quickActions));
} else {
this.inlineMatchedActions.set([]);
}
this.searchQuery$.next(query);
}
@@ -577,7 +606,9 @@ export class CommandPaletteComponent implements OnInit, OnDestroy {
private getMaxIndex(): number {
if (this.isActionMode()) return Math.max(0, this.filteredActions().length - 1);
if (!this.query || this.query.length < 2) return Math.max(0, this.recentSearches().length + 5 - 1);
return Math.max(0, this.flatResults.length - 1);
// In mixed mode: inline actions + search results
const inlineCount = this.inlineMatchedActions().length;
return Math.max(0, inlineCount + this.flatResults.length - 1);
}
private selectCurrentItem(): void {
@@ -589,7 +620,14 @@ export class CommandPaletteComponent implements OnInit, OnDestroy {
else { const a = this.quickActions[idx - rc]; if (a) this.executeAction(a); }
return;
}
const result = this.flatResults[idx];
// In mixed mode: inline actions come first, then search results
const inlineCount = this.inlineMatchedActions().length;
if (idx < inlineCount) {
const action = this.inlineMatchedActions()[idx];
if (action) this.executeAction(action);
return;
}
const result = this.flatResults[idx - inlineCount];
if (result) this.selectResult(result);
}
@@ -659,7 +697,8 @@ export class CommandPaletteComponent implements OnInit, OnDestroy {
clearRecent(): void { clearRecentSearches(); this.recentSearches.set([]); }
isResultSelected(group: SearchResultGroup, resultIndex: number): boolean {
let flatIdx = 0;
const inlineOffset = this.inlineMatchedActions().length;
let flatIdx = inlineOffset;
const response = this.searchResponse();
if (!response) return false;
for (const g of response.groups) {
@@ -670,7 +709,8 @@ export class CommandPaletteComponent implements OnInit, OnDestroy {
}
setSelectedResult(group: SearchResultGroup, resultIndex: number): void {
let flatIdx = 0;
const inlineOffset = this.inlineMatchedActions().length;
let flatIdx = inlineOffset;
const response = this.searchResponse();
if (!response) return;
for (const g of response.groups) {

View File

@@ -204,6 +204,68 @@
flex-shrink: 0;
}
// =============================================================================
// Theme indicator in trigger
// =============================================================================
.user-menu__theme-indicator {
display: flex;
align-items: center;
color: var(--color-text-tertiary);
opacity: 0.7;
}
// =============================================================================
// Theme switcher row in dropdown
// =============================================================================
.user-menu__theme-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
}
.user-menu__theme-label {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.user-menu__theme-buttons {
display: flex;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
overflow: hidden;
}
.user-menu__theme-btn {
padding: 0.2rem 0.5rem;
font-size: var(--font-size-xs);
background: none;
border: none;
border-right: 1px solid var(--color-border-primary);
color: var(--color-text-secondary);
cursor: pointer;
transition: background 0.1s, color 0.1s;
&:last-child {
border-right: none;
}
&:hover {
background: var(--color-dropdown-hover);
}
&--active {
background: var(--color-brand-primary);
color: var(--color-text-inverse);
&:hover {
background: var(--color-brand-primary-hover);
}
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.user-menu__trigger,

View File

@@ -4,6 +4,7 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
import { AUTH_SERVICE, AuthService } from '../../../core/auth';
import { NavigationService } from '../../../core/navigation';
import { ThemeService, ThemeMode } from '../../../core/services/theme.service';
/**
* User menu dropdown component.
@@ -34,6 +35,24 @@ import { NavigationService } from '../../../core/navigation';
</svg>
}
</div>
<span class="user-menu__theme-indicator" [title]="themeService.modeLabel()">
@if (themeService.mode() === 'dark') {
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
} @else if (themeService.mode() === 'system') {
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<rect x="2" y="3" width="20" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="21" x2="16" y2="21" stroke="currentColor" stroke-width="2"/>
<line x1="12" y1="17" x2="12" y2="21" stroke="currentColor" stroke-width="2"/>
</svg>
} @else {
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<circle cx="12" cy="12" r="5" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" fill="none" stroke="currentColor" stroke-width="2"/>
</svg>
}
</span>
<span class="user-menu__name">{{ displayName() }}</span>
<svg class="user-menu__chevron" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<polyline points="6 9 12 15 18 9" fill="none" stroke="currentColor" stroke-width="2"/>
@@ -49,6 +68,25 @@ import { NavigationService } from '../../../core/navigation';
<div class="user-menu__info-email" [title]="displayEmail()">{{ displayEmail() }}</div>
</div>
<!-- Theme Switcher -->
<div class="user-menu__theme-row">
<span class="user-menu__theme-label">Theme</span>
<div class="user-menu__theme-buttons" role="radiogroup" aria-label="Theme">
<button type="button" class="user-menu__theme-btn"
[class.user-menu__theme-btn--active]="themeService.mode() === 'light'"
(click)="themeService.setMode('light'); $event.stopPropagation()"
title="Light theme">Day</button>
<button type="button" class="user-menu__theme-btn"
[class.user-menu__theme-btn--active]="themeService.mode() === 'dark'"
(click)="themeService.setMode('dark'); $event.stopPropagation()"
title="Dark theme">Night</button>
<button type="button" class="user-menu__theme-btn"
[class.user-menu__theme-btn--active]="themeService.mode() === 'system'"
(click)="themeService.setMode('system'); $event.stopPropagation()"
title="Follow system">System</button>
</div>
</div>
<div class="user-menu__divider"></div>
<!-- Navigation Items -->
@@ -109,6 +147,7 @@ import { NavigationService } from '../../../core/navigation';
export class UserMenuComponent {
protected readonly authService = inject(AUTH_SERVICE) as AuthService;
protected readonly navService = inject(NavigationService);
protected readonly themeService = inject(ThemeService);
protected readonly menuOpen = signal(false);

View File

@@ -8,7 +8,7 @@
"redirectUri": "/auth/callback",
"silentRefreshRedirectUri": "/auth/silent-refresh",
"postLogoutRedirectUri": "/",
"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 orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit registry.admin signer:read signer:sign signer:rotate signer:admin trust:read trust:write trust:admin",
"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 orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read release:write release:publish scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit platform.idp.read platform.idp.admin registry.admin signer:read signer:sign signer:rotate signer:admin trust:read trust:write trust:admin",
"audience": "/scanner",
"dpopAlgorithms": ["ES256"],
"refreshLeewaySeconds": 60