From b851aa8300b7febfb2e661443c8e39df13fa4e22 Mon Sep 17 00:00:00 2001 From: master <> Date: Tue, 17 Mar 2026 15:10:36 +0200 Subject: [PATCH] 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) --- .../postgres-init/04-authority-schema.sql | 1 + devops/compose/router-gateway-local.json | 2 + ...T_20260317_003_FE_journey_cluster_fixes.md | 230 +++++++++++ .../Contracts/ContextModels.cs | 4 +- .../Contracts/PreferenceModels.cs | 10 + .../Endpoints/PlatformEndpoints.cs | 38 ++ .../Services/PlatformContextService.cs | 27 +- .../Services/PlatformPreferencesService.cs | 56 +++ .../UiContextPreferenceEntityType.cs | 9 + .../EfCore/Context/PlatformDbContext.cs | 3 + .../EfCore/Models/UiContextPreference.cs | 2 + .../Release/059_UiContextPreferencesStage.sql | 5 + .../Audit/HttpUnifiedAuditEventProvider.cs | 95 ++++- .../Audit/UnifiedAuditContracts.cs | 11 +- .../StellaOps.Timeline.WebService/Program.cs | 4 + .../EfCore/Context/VexHubDbContext.cs | 4 + .../EfCore/Models/VexSource.cs | 2 + .../002_add_source_backoff_columns.sql | 2 +- .../003_fix_source_backoff_columns.sql | 9 + src/Web/StellaOps.Web/package-lock.json | 234 ++++++++--- src/Web/StellaOps.Web/package.json | 4 +- ...time-user-reporting-truthfulness-check.mjs | 6 +- src/Web/StellaOps.Web/src/app/app.config.ts | 146 +++---- .../src/app/core/api/risk-http.client.ts | 14 +- .../src/app/core/api/search.models.ts | 4 +- .../src/app/core/api/unknowns.client.ts | 2 +- .../core/context/platform-context.store.ts | 2 + .../app/core/navigation/navigation.config.ts | 4 +- .../bundle-version-detail.component.ts | 35 +- .../src/app/features/docs/docs-markdown.ts | 71 +++- .../features/docs/docs-viewer.component.ts | 230 +++++++---- .../ops/event-stream-page.component.ts | 373 ++++++++++++++++++ .../features/platform/ops/operations-paths.ts | 1 + .../features/scanner/scan-submit.component.ts | 66 ++++ .../security-risk-overview.component.ts | 47 ++- .../user-preferences-page.component.ts | 131 +++++- .../welcome/welcome-page.component.ts | 315 +++++++++++---- .../app-sidebar/sidebar-preference.service.ts | 2 +- .../layout/app-topbar/app-topbar.component.ts | 55 --- .../evidence-mode-chip.component.ts | 48 +-- .../feed-snapshot-chip.component.ts | 65 ++- .../live-event-stream-chip.component.ts | 59 +-- .../offline-status-chip.component.ts | 66 ++-- .../policy-baseline-chip.component.ts | 54 +-- .../src/app/routes/operations.routes.ts | 9 + .../breadcrumb/breadcrumb.component.ts | 2 +- .../command-palette.component.ts | 52 ++- .../user-menu/user-menu.component.scss | 62 +++ .../user-menu/user-menu.component.ts | 39 ++ src/Web/StellaOps.Web/src/config/config.json | 2 +- 50 files changed, 2163 insertions(+), 551 deletions(-) create mode 100644 docs/implplan/SPRINT_20260317_003_FE_journey_cluster_fixes.md create mode 100644 src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/059_UiContextPreferencesStage.sql create mode 100644 src/VexHub/__Libraries/StellaOps.VexHub.Persistence/Migrations/003_fix_source_backoff_columns.sql create mode 100644 src/Web/StellaOps.Web/src/app/features/platform/ops/event-stream-page.component.ts diff --git a/devops/compose/postgres-init/04-authority-schema.sql b/devops/compose/postgres-init/04-authority-schema.sql index 101da66f6..a6a322000 100644 --- a/devops/compose/postgres-init/04-authority-schema.sql +++ b/devops/compose/postgres-init/04-authority-schema.sql @@ -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', diff --git a/devops/compose/router-gateway-local.json b/devops/compose/router-gateway-local.json index 12897791a..1de8f235f 100644 --- a/devops/compose/router-gateway-local.json +++ b/devops/compose/router-gateway-local.json @@ -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" }, diff --git a/docs/implplan/SPRINT_20260317_003_FE_journey_cluster_fixes.md b/docs/implplan/SPRINT_20260317_003_FE_journey_cluster_fixes.md new file mode 100644 index 000000000..f60bdb0ad --- /dev/null +++ b/docs/implplan/SPRINT_20260317_003_FE_journey_cluster_fixes.md @@ -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. diff --git a/src/Platform/StellaOps.Platform.WebService/Contracts/ContextModels.cs b/src/Platform/StellaOps.Platform.WebService/Contracts/ContextModels.cs index 59bde93f9..47ae0cda3 100644 --- a/src/Platform/StellaOps.Platform.WebService/Contracts/ContextModels.cs +++ b/src/Platform/StellaOps.Platform.WebService/Contracts/ContextModels.cs @@ -23,10 +23,12 @@ public sealed record PlatformContextPreferences( IReadOnlyList Regions, IReadOnlyList Environments, string TimeWindow, + string? Stage, DateTimeOffset UpdatedAt, string UpdatedBy); public sealed record PlatformContextPreferencesRequest( IReadOnlyList? Regions, IReadOnlyList? Environments, - string? TimeWindow); + string? TimeWindow, + string? Stage = null); diff --git a/src/Platform/StellaOps.Platform.WebService/Contracts/PreferenceModels.cs b/src/Platform/StellaOps.Platform.WebService/Contracts/PreferenceModels.cs index 016469500..fe22fd62b 100644 --- a/src/Platform/StellaOps.Platform.WebService/Contracts/PreferenceModels.cs +++ b/src/Platform/StellaOps.Platform.WebService/Contracts/PreferenceModels.cs @@ -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, diff --git a/src/Platform/StellaOps.Platform.WebService/Endpoints/PlatformEndpoints.cs b/src/Platform/StellaOps.Platform.WebService/Endpoints/PlatformEndpoints.cs index 52c0e6891..fac898430 100644 --- a/src/Platform/StellaOps.Platform.WebService/Endpoints/PlatformEndpoints.cs +++ b/src/Platform/StellaOps.Platform.WebService/Endpoints/PlatformEndpoints.cs @@ -386,6 +386,44 @@ public static class PlatformEndpoints } }).RequireAuthorization(PlatformPolicies.PreferencesWrite); + preferences.MapGet("/email", async Task ( + 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 ( + 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 ( diff --git a/src/Platform/StellaOps.Platform.WebService/Services/PlatformContextService.cs b/src/Platform/StellaOps.Platform.WebService/Services/PlatformContextService.cs index b68c75c2d..90da69223 100644 --- a/src/Platform/StellaOps.Platform.WebService/Services/PlatformContextService.cs +++ b/src/Platform/StellaOps.Platform.WebService/Services/PlatformContextService.cs @@ -103,6 +103,7 @@ public sealed class PlatformContextService : IPlatformContextQuery defaultRegions, Array.Empty(), 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(3), - reader.GetString(4)); + reader.IsDBNull(3) ? null : reader.GetString(3), + reader.GetFieldValue(4), + reader.GetString(5)); } private static string[] NormalizeTextArray(string[]? values) diff --git a/src/Platform/StellaOps.Platform.WebService/Services/PlatformPreferencesService.cs b/src/Platform/StellaOps.Platform.WebService/Services/PlatformPreferencesService.cs index c516363a3..e005df6c0 100644 --- a/src/Platform/StellaOps.Platform.WebService/Services/PlatformPreferencesService.cs +++ b/src/Platform/StellaOps.Platform.WebService/Services/PlatformPreferencesService.cs @@ -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 GetEmailPreferenceAsync( + PlatformRequestContext context, + CancellationToken cancellationToken) + { + var preferences = GetOrCreatePreferences(context); + var email = preferences.Preferences[EmailPreferenceKey]?.GetValue(); + + return Task.FromResult(new PlatformEmailPreference( + TenantId: context.TenantId, + ActorId: context.ActorId, + Email: email, + UpdatedAt: preferences.UpdatedAt, + UpdatedBy: preferences.UpdatedBy)); + } + + public Task 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> GetProfilesAsync( PlatformRequestContext context, CancellationToken cancellationToken) diff --git a/src/Platform/__Libraries/StellaOps.Platform.Database/EfCore/CompiledModels/UiContextPreferenceEntityType.cs b/src/Platform/__Libraries/StellaOps.Platform.Database/EfCore/CompiledModels/UiContextPreferenceEntityType.cs index c405997fa..5446b2a8d 100644 --- a/src/Platform/__Libraries/StellaOps.Platform.Database/EfCore/CompiledModels/UiContextPreferenceEntityType.cs +++ b/src/Platform/__Libraries/StellaOps.Platform.Database/EfCore/CompiledModels/UiContextPreferenceEntityType.cs @@ -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), diff --git a/src/Platform/__Libraries/StellaOps.Platform.Database/EfCore/Context/PlatformDbContext.cs b/src/Platform/__Libraries/StellaOps.Platform.Database/EfCore/Context/PlatformDbContext.cs index 12698e6d7..b78bcb14a 100644 --- a/src/Platform/__Libraries/StellaOps.Platform.Database/EfCore/Context/PlatformDbContext.cs +++ b/src/Platform/__Libraries/StellaOps.Platform.Database/EfCore/Context/PlatformDbContext.cs @@ -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"); diff --git a/src/Platform/__Libraries/StellaOps.Platform.Database/EfCore/Models/UiContextPreference.cs b/src/Platform/__Libraries/StellaOps.Platform.Database/EfCore/Models/UiContextPreference.cs index bb21d6153..a15de5c6e 100644 --- a/src/Platform/__Libraries/StellaOps.Platform.Database/EfCore/Models/UiContextPreference.cs +++ b/src/Platform/__Libraries/StellaOps.Platform.Database/EfCore/Models/UiContextPreference.cs @@ -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!; diff --git a/src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/059_UiContextPreferencesStage.sql b/src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/059_UiContextPreferencesStage.sql new file mode 100644 index 000000000..0e80e8daf --- /dev/null +++ b/src/Platform/__Libraries/StellaOps.Platform.Database/Migrations/Release/059_UiContextPreferencesStage.sql @@ -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; diff --git a/src/Timeline/StellaOps.Timeline.WebService/Audit/HttpUnifiedAuditEventProvider.cs b/src/Timeline/StellaOps.Timeline.WebService/Audit/HttpUnifiedAuditEventProvider.cs index 405d86d92..6c0be7c06 100644 --- a/src/Timeline/StellaOps.Timeline.WebService/Audit/HttpUnifiedAuditEventProvider.cs +++ b/src/Timeline/StellaOps.Timeline.WebService/Audit/HttpUnifiedAuditEventProvider.cs @@ -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> GetAuthorityEventsAsync( + UnifiedAuditModuleEndpointsOptions options, + CancellationToken cancellationToken) + { + var uri = BuildUri( + options.AuthorityBaseUrl, + "/console/admin/audit", + new Dictionary { ["limit"] = options.FetchLimitPerModule.ToString(CultureInfo.InvariantCulture) }); + + if (uri is null) + { + return Array.Empty(); + } + + using var document = await GetJsonDocumentAsync(uri, cancellationToken).ConfigureAwait(false); + if (document is null) + { + return Array.Empty(); + } + + if (!TryGetPropertyIgnoreCase(document.RootElement, "events", out var entries) || + entries.ValueKind != JsonValueKind.Array) + { + return Array.Empty(); + } + + var events = new List(); + 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(StringComparerOrdinal) + { + ["eventType"] = eventType + }, + CorrelationId = GetString(entry, "correlationId"), + TenantId = GetString(entry, "tenantId"), + Tags = ["authority", action] + }); + } + + return events; + } + private async Task> 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] }); } diff --git a/src/Timeline/StellaOps.Timeline.WebService/Audit/UnifiedAuditContracts.cs b/src/Timeline/StellaOps.Timeline.WebService/Audit/UnifiedAuditContracts.cs index d199b3ba4..69119e4e9 100644 --- a/src/Timeline/StellaOps.Timeline.WebService/Audit/UnifiedAuditContracts.cs +++ b/src/Timeline/StellaOps.Timeline.WebService/Audit/UnifiedAuditContracts.cs @@ -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"; diff --git a/src/Timeline/StellaOps.Timeline.WebService/Program.cs b/src/Timeline/StellaOps.Timeline.WebService/Program.cs index 3283cdfcb..7d6a7c601 100644 --- a/src/Timeline/StellaOps.Timeline.WebService/Program.cs +++ b/src/Timeline/StellaOps.Timeline.WebService/Program.cs @@ -18,6 +18,10 @@ builder.Services.AddSingleton(TimeProvider.System); builder.Services.Configure(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; diff --git a/src/VexHub/__Libraries/StellaOps.VexHub.Persistence/EfCore/Context/VexHubDbContext.cs b/src/VexHub/__Libraries/StellaOps.VexHub.Persistence/EfCore/Context/VexHubDbContext.cs index 1cf8f393c..a3e43d1e4 100644 --- a/src/VexHub/__Libraries/StellaOps.VexHub.Persistence/EfCore/Context/VexHubDbContext.cs +++ b/src/VexHub/__Libraries/StellaOps.VexHub.Persistence/EfCore/Context/VexHubDbContext.cs @@ -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 ─────────────────────────────────────────────────── diff --git a/src/VexHub/__Libraries/StellaOps.VexHub.Persistence/EfCore/Models/VexSource.cs b/src/VexHub/__Libraries/StellaOps.VexHub.Persistence/EfCore/Models/VexSource.cs index dc6e8ded8..470671ac3 100644 --- a/src/VexHub/__Libraries/StellaOps.VexHub.Persistence/EfCore/Models/VexSource.cs +++ b/src/VexHub/__Libraries/StellaOps.VexHub.Persistence/EfCore/Models/VexSource.cs @@ -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; } } diff --git a/src/VexHub/__Libraries/StellaOps.VexHub.Persistence/Migrations/002_add_source_backoff_columns.sql b/src/VexHub/__Libraries/StellaOps.VexHub.Persistence/Migrations/002_add_source_backoff_columns.sql index 96b14a481..d3d021819 100644 --- a/src/VexHub/__Libraries/StellaOps.VexHub.Persistence/Migrations/002_add_source_backoff_columns.sql +++ b/src/VexHub/__Libraries/StellaOps.VexHub.Persistence/Migrations/002_add_source_backoff_columns.sql @@ -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; diff --git a/src/VexHub/__Libraries/StellaOps.VexHub.Persistence/Migrations/003_fix_source_backoff_columns.sql b/src/VexHub/__Libraries/StellaOps.VexHub.Persistence/Migrations/003_fix_source_backoff_columns.sql new file mode 100644 index 000000000..1734fb8b7 --- /dev/null +++ b/src/VexHub/__Libraries/StellaOps.VexHub.Persistence/Migrations/003_fix_source_backoff_columns.sql @@ -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; diff --git a/src/Web/StellaOps.Web/package-lock.json b/src/Web/StellaOps.Web/package-lock.json index ea82331c6..a6985da3c 100644 --- a/src/Web/StellaOps.Web/package-lock.json +++ b/src/Web/StellaOps.Web/package-lock.json @@ -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": { diff --git a/src/Web/StellaOps.Web/package.json b/src/Web/StellaOps.Web/package.json index 0aea38b3d..2410963cb 100644 --- a/src/Web/StellaOps.Web/package.json +++ b/src/Web/StellaOps.Web/package.json @@ -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", diff --git a/src/Web/StellaOps.Web/scripts/live-first-time-user-reporting-truthfulness-check.mjs b/src/Web/StellaOps.Web/scripts/live-first-time-user-reporting-truthfulness-check.mjs index e07e4241b..89d1fa194 100644 --- a/src/Web/StellaOps.Web/scripts/live-first-time-user-reporting-truthfulness-check.mjs +++ b/src/Web/StellaOps.Web/scripts/live-first-time-user-reporting-truthfulness-check.mjs @@ -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', diff --git a/src/Web/StellaOps.Web/src/app/app.config.ts b/src/Web/StellaOps.Web/src/app/app.config.ts index 4c2e9deac..8c3c2b6e4 100644 --- a/src/Web/StellaOps.Web/src/app/app.config.ts +++ b/src/Web/StellaOps.Web/src/app/app.config.ts @@ -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], diff --git a/src/Web/StellaOps.Web/src/app/core/api/risk-http.client.ts b/src/Web/StellaOps.Web/src/app/core/api/risk-http.client.ts index f9eab5714..57b745a5c 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/risk-http.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/risk-http.client.ts @@ -44,7 +44,7 @@ export class RiskHttpClient implements RiskApi { if (options.search) params = params.set('search', options.search); return this.http - .get(`${this.baseUrl}/risk`, { headers, params }) + .get(`${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(`${this.baseUrl}/risk/status`, { headers }) + .get(`${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(`${this.baseUrl}/risk/${encodeURIComponent(riskId)}`, { headers }) + .get(`${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(`${this.baseUrl}/risk/${encodeURIComponent(riskId)}/explanation-url`, { headers }) + .get(`${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(`${this.baseUrl}/risk/aggregated-status`, { headers }) + .get(`${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(`${this.baseUrl}/risk/transitions/recent`, { headers, params }) + .get(`${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)))); } diff --git a/src/Web/StellaOps.Web/src/app/core/api/search.models.ts b/src/Web/StellaOps.Web/src/app/core/api/search.models.ts index c07a70fd1..c15f9f992 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/search.models.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/search.models.ts @@ -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'], }, { diff --git a/src/Web/StellaOps.Web/src/app/core/api/unknowns.client.ts b/src/Web/StellaOps.Web/src/app/core/api/unknowns.client.ts index 1cb3b1431..b452fdefa 100644 --- a/src/Web/StellaOps.Web/src/app/core/api/unknowns.client.ts +++ b/src/Web/StellaOps.Web/src/app/core/api/unknowns.client.ts @@ -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 { diff --git a/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts b/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts index ed8ac1c50..9276eb0a3 100644 --- a/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts +++ b/src/Web/StellaOps.Web/src/app/core/context/platform-context.store.ts @@ -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(), }; } diff --git a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts index 7b82f8f4c..f4a8b877d 100644 --- a/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts +++ b/src/Web/StellaOps.Web/src/app/core/navigation/navigation.config.ts @@ -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: [ { diff --git a/src/Web/StellaOps.Web/src/app/features/bundles/bundle-version-detail.component.ts b/src/Web/StellaOps.Web/src/app/features/bundles/bundle-version-detail.component.ts index aef0c62c1..75a6beea4 100644 --- a/src/Web/StellaOps.Web/src/app/features/bundles/bundle-version-detail.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/bundles/bundle-version-detail.component.ts @@ -50,12 +50,20 @@ import { @if (showPostSealGuide()) { -
-

Release sealed successfully. What's next?

+
+

Release definition sealed successfully

+

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

- Promote to environment - Review approvals - Back to versions + Request Promotion + View all promotions + Back to versions
} @@ -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 { diff --git a/src/Web/StellaOps.Web/src/app/features/docs/docs-markdown.ts b/src/Web/StellaOps.Web/src/app/features/docs/docs-markdown.ts index 8a83c55c9..fed6be02e 100644 --- a/src/Web/StellaOps.Web/src/app/features/docs/docs-markdown.ts +++ b/src/Web/StellaOps.Web/src/app/features/docs/docs-markdown.ts @@ -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 `
${escapeHtml(tableLines.join('\n'))}
`; + } + + 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 `${renderInlineMarkdown(cell, currentDocPath, resolveDocsLink)}`; + }) + .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 `${renderInlineMarkdown(content, currentDocPath, resolveDocsLink)}`; + }) + .join(''); + return `${tds}`; + }) + .join(''); + + return `
${thCells}${bodyRows}
`; +} + 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('
'); + 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(`
${escapeHtml(tableLines.join('\n'))}
`); + parts.push(renderTable(tableLines, currentDocPath, resolveDocsLink)); continue; } diff --git a/src/Web/StellaOps.Web/src/app/features/docs/docs-viewer.component.ts b/src/Web/StellaOps.Web/src/app/features/docs/docs-viewer.component.ts index 0319df7e5..1de7a2069 100644 --- a/src/Web/StellaOps.Web/src/app/features/docs/docs-viewer.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/docs/docs-viewer.component.ts @@ -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: `
@@ -56,7 +63,13 @@ import { DocsHeading, renderMarkdownDocument, slugifyHeading } from './docs-mark } -
+
+ +
`, @@ -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(null); - readonly headings = signal([]); + readonly headings = signal([]); readonly loading = signal(true); readonly error = signal(null); - readonly renderedHtml = signal(''); + 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) ?? diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/event-stream-page.component.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/event-stream-page.component.ts new file mode 100644 index 000000000..a7005a55d --- /dev/null +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/event-stream-page.component.ts @@ -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: ` +
+
+ +

Event Stream Monitor

+

+ Real-time event bus health, connection status, and recent activity across all Stella Ops services. +

+
+ +
+
+ +
+
+ +
+ {{ hasError() ? 'Degraded' : 'Connected' }} + Transport: Valkey Pub/Sub +
+
+ @if (hasError()) { +
{{ platformError() }}
+ } +
+ + +
+
+ Services Connected + + Requires gateway health endpoint +
+
+ Events / min + + Requires event replay API +
+
+ Last Event + + Requires event replay API +
+
+ + +
+

Recent Events

+ + + + + + + + + + + + + + +
TimestampServiceEvent TypeDetails
+ Event stream history will appear here once the event replay API is available. +
+
+
+ + + +
+
+ `, + 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' }, + ]; +} diff --git a/src/Web/StellaOps.Web/src/app/features/platform/ops/operations-paths.ts b/src/Web/StellaOps.Web/src/app/features/platform/ops/operations-paths.ts index 79fdbff74..fd8f09a9f 100644 --- a/src/Web/StellaOps.Web/src/app/features/platform/ops/operations-paths.ts +++ b/src/Web/StellaOps.Web/src/app/features/platform/ops/operations-paths.ts @@ -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`, diff --git a/src/Web/StellaOps.Web/src/app/features/scanner/scan-submit.component.ts b/src/Web/StellaOps.Web/src/app/features/scanner/scan-submit.component.ts index 394849385..64ed949ed 100644 --- a/src/Web/StellaOps.Web/src/app/features/scanner/scan-submit.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/scanner/scan-submit.component.ts @@ -174,6 +174,27 @@ interface ScanStatusResponse { + @if (scanInProgress()) { +
+ + Scan submitted. In local development mode, scans may take longer as scanner workers process the queue sequentially. +
+ } + + @if (showQueueHint()) { +
+ + Still processing. Check Jobs Engine status for queue details. +
+ } + @if (scanStatus() === 'completed') {
} @if (loading()) { } + @if (hasDegradedData()) { + + } @if (!loading()) {
@@ -258,6 +263,7 @@ interface PlatformListResponse { .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([]); readonly triageStats = signal(null); + /** Per-source error signals for degraded-data tracking. */ + readonly findingsError = signal(null); + readonly dispositionError = signal(null); + readonly sbomError = signal(null); + readonly feedHealthError = signal(null); + readonly triageStatsError = signal(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('/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>('/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('/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$ }) diff --git a/src/Web/StellaOps.Web/src/app/features/settings/user-preferences/user-preferences-page.component.ts b/src/Web/StellaOps.Web/src/app/features/settings/user-preferences/user-preferences-page.component.ts index 8b1ec8998..cd13c03e7 100644 --- a/src/Web/StellaOps.Web/src/app/features/settings/user-preferences/user-preferences-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/settings/user-preferences/user-preferences-page.component.ts @@ -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';

User Preferences

Personalize your console experience

+ +
+
+ +

Profile

+
+

Manage your account information.

+ + + +

Managed by your identity provider

+ + +
+ + +
+ @if (emailStatus()) { +

{{ emailStatus() }}

+ } +
+
@@ -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(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 { + 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 { const selected = (event.target as HTMLSelectElement | null)?.value?.trim(); if (!selected || selected === this.currentLocale()) return; diff --git a/src/Web/StellaOps.Web/src/app/features/welcome/welcome-page.component.ts b/src/Web/StellaOps.Web/src/app/features/welcome/welcome-page.component.ts index 17b1eb912..0ffa94b3b 100644 --- a/src/Web/StellaOps.Web/src/app/features/welcome/welcome-page.component.ts +++ b/src/Web/StellaOps.Web/src/app/features/welcome/welcome-page.component.ts @@ -54,13 +54,13 @@ declare global {

{{ title() }}

-

Release Control Plane

+

Release Control Plane for Container Estates

- -

{{ message() }}

+ +

Stop scripting promotions, chasing scan results, and hoping deploys are safe. Stella gives your team governed releases with evidence at every step.

@if (authNotice(); as notice) {

@@ -71,20 +71,61 @@ declare global {

} - -
- - - Encrypted - - - - Identity - - - - Pipeline - + +
+

Get Started

+
    +
  1. + 1 +
    + Connect a Registry + Link your container registry to discover images +
    +
  2. +
  3. + 2 +
    + Scan an Artifact + Run security and compliance scans on container images +
    +
  4. +
  5. + 3 +
    + Create a Governed Release + Bundle verified artifacts into a release definition +
    +
  6. +
  7. + 4 +
    + Promote with Evidence + Move releases between environments with full audit trail +
    +
  8. +
+
+ + +
+

What Stella Replaces

+
    +
  • + Manual promotion scripts + + Policy-gated environment promotions +
  • +
  • + Scattered scan results + + Unified security posture with VEX support +
  • +
  • + Trust-me deployments + + Verifiable evidence for every release decision +
  • +
@@ -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; } diff --git a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts index 10bfc64ae..f2e878fcd 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-sidebar/sidebar-preference.service.ts @@ -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: [], }; diff --git a/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts b/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts index acaf2297e..3e2b3938e 100644 --- a/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/app-topbar/app-topbar.component.ts @@ -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 - - -
@@ -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); diff --git a/src/Web/StellaOps.Web/src/app/layout/context-chips/evidence-mode-chip.component.ts b/src/Web/StellaOps.Web/src/app/layout/context-chips/evidence-mode-chip.component.ts index 2132e3528..bfd105e20 100644 --- a/src/Web/StellaOps.Web/src/app/layout/context-chips/evidence-mode-chip.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/context-chips/evidence-mode-chip.component.ts @@ -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()" > - Evidence: {{ isEnabled() ? 'ON' : 'OFF' }} `, 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' ); } diff --git a/src/Web/StellaOps.Web/src/app/layout/context-chips/feed-snapshot-chip.component.ts b/src/Web/StellaOps.Web/src/app/layout/context-chips/feed-snapshot-chip.component.ts index 72b150cac..6d4188507 100644 --- a/src/Web/StellaOps.Web/src/app/layout/context-chips/feed-snapshot-chip.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/context-chips/feed-snapshot-chip.component.ts @@ -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()" > - Feed: {{ snapshotDate() }} - @if (isStale()) { - - - - - - } `, 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()}`; }); } diff --git a/src/Web/StellaOps.Web/src/app/layout/context-chips/live-event-stream-chip.component.ts b/src/Web/StellaOps.Web/src/app/layout/context-chips/live-event-stream-chip.component.ts index 75234c5ff..af069b836 100644 --- a/src/Web/StellaOps.Web/src/app/layout/context-chips/live-event-stream-chip.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/context-chips/live-event-stream-chip.component.ts @@ -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: ` - - Events: {{ status() === 'connected' ? 'CONNECTED' : 'DEGRADED' }} - + `, 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'; + }); } diff --git a/src/Web/StellaOps.Web/src/app/layout/context-chips/offline-status-chip.component.ts b/src/Web/StellaOps.Web/src/app/layout/context-chips/offline-status-chip.component.ts index 93286f350..5e2ff42ea 100644 --- a/src/Web/StellaOps.Web/src/app/layout/context-chips/offline-status-chip.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/context-chips/offline-status-chip.component.ts @@ -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" > - Offline: {{ status() === 'ok' ? 'OK' : 'DEGRADED' }} `, 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'; }); } diff --git a/src/Web/StellaOps.Web/src/app/layout/context-chips/policy-baseline-chip.component.ts b/src/Web/StellaOps.Web/src/app/layout/context-chips/policy-baseline-chip.component.ts index 296f82e1d..2850e7dc6 100644 --- a/src/Web/StellaOps.Web/src/app/layout/context-chips/policy-baseline-chip.component.ts +++ b/src/Web/StellaOps.Web/src/app/layout/context-chips/policy-baseline-chip.component.ts @@ -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: ` - Policy: {{ baselineName() }} `, 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(); }); } diff --git a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts index eb1cf25cb..71b36b3da 100644 --- a/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts +++ b/src/Web/StellaOps.Web/src/app/routes/operations.routes.ts @@ -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', diff --git a/src/Web/StellaOps.Web/src/app/shared/components/breadcrumb/breadcrumb.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/breadcrumb/breadcrumb.component.ts index d0d668a03..e1b7fd360 100644 --- a/src/Web/StellaOps.Web/src/app/shared/components/breadcrumb/breadcrumb.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/components/breadcrumb/breadcrumb.component.ts @@ -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', diff --git a/src/Web/StellaOps.Web/src/app/shared/components/command-palette/command-palette.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/command-palette/command-palette.component.ts index 84ee067d0..356513281 100644 --- a/src/Web/StellaOps.Web/src/app/shared/components/command-palette/command-palette.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/components/command-palette/command-palette.component.ts @@ -134,6 +134,26 @@ import { SeedClient } from '../../../core/api/seed.client'; }
} @else { + @if (inlineMatchedActions().length > 0) { +
+ + @for (action of inlineMatchedActions(); track action.id; let i = $index) { + + } +
+ } @if (searchResponse()) { @for (group of searchResponse()!.groups; track group.type) {
@@ -172,7 +192,7 @@ import { SeedClient } from '../../../core/api/seed.client'; }
} - @if (searchResponse()!.groups.length === 0) { + @if (searchResponse()!.groups.length === 0 && inlineMatchedActions().length === 0) {
No results found
Try a different search term or use > for actions
@@ -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([]); 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) { diff --git a/src/Web/StellaOps.Web/src/app/shared/components/user-menu/user-menu.component.scss b/src/Web/StellaOps.Web/src/app/shared/components/user-menu/user-menu.component.scss index 0b9194e3a..6dd3c5ce9 100644 --- a/src/Web/StellaOps.Web/src/app/shared/components/user-menu/user-menu.component.scss +++ b/src/Web/StellaOps.Web/src/app/shared/components/user-menu/user-menu.component.scss @@ -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, diff --git a/src/Web/StellaOps.Web/src/app/shared/components/user-menu/user-menu.component.ts b/src/Web/StellaOps.Web/src/app/shared/components/user-menu/user-menu.component.ts index 05e16d8a9..a86c54dc0 100644 --- a/src/Web/StellaOps.Web/src/app/shared/components/user-menu/user-menu.component.ts +++ b/src/Web/StellaOps.Web/src/app/shared/components/user-menu/user-menu.component.ts @@ -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'; }
+ + @if (themeService.mode() === 'dark') { + + } @else if (themeService.mode() === 'system') { + + } @else { + + } + {{ displayName() }}
{{ displayEmail() }}
+ +
+ Theme +
+ + + +
+
+
@@ -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); diff --git a/src/Web/StellaOps.Web/src/config/config.json b/src/Web/StellaOps.Web/src/config/config.json index 5515bbd5d..85a4f500d 100644 --- a/src/Web/StellaOps.Web/src/config/config.json +++ b/src/Web/StellaOps.Web/src/config/config.json @@ -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