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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user