Fix journey cluster defects + UX improvements across 7 clusters

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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