search and ai stabilization work, localization stablized.
This commit is contained in:
@@ -49,4 +49,8 @@ public static class PlatformPolicies
|
||||
public const string TrustRead = "platform.trust.read";
|
||||
public const string TrustWrite = "platform.trust.write";
|
||||
public const string TrustAdmin = "platform.trust.admin";
|
||||
|
||||
// Identity provider management policies (SPRINT_20260224_100)
|
||||
public const string IdentityProviderRead = "platform.idp.read";
|
||||
public const string IdentityProviderAdmin = "platform.idp.admin";
|
||||
}
|
||||
|
||||
@@ -50,4 +50,8 @@ public static class PlatformScopes
|
||||
public const string TrustRead = StellaOpsScopes.TrustRead;
|
||||
public const string TrustWrite = StellaOpsScopes.TrustWrite;
|
||||
public const string TrustAdmin = StellaOpsScopes.TrustAdmin;
|
||||
|
||||
// Identity provider management scopes (SPRINT_20260224_100)
|
||||
public const string IdentityProviderRead = "platform.idp.read";
|
||||
public const string IdentityProviderAdmin = "platform.idp.admin";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
public sealed record IdentityProviderConfigDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Type,
|
||||
bool Enabled,
|
||||
Dictionary<string, string?> Configuration,
|
||||
string? Description,
|
||||
string? HealthStatus,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? CreatedBy,
|
||||
string? UpdatedBy);
|
||||
|
||||
public sealed record CreateIdentityProviderRequest(
|
||||
string Name,
|
||||
string Type,
|
||||
bool Enabled,
|
||||
Dictionary<string, string?> Configuration,
|
||||
string? Description);
|
||||
|
||||
public sealed record UpdateIdentityProviderRequest(
|
||||
bool? Enabled,
|
||||
Dictionary<string, string?>? Configuration,
|
||||
string? Description);
|
||||
|
||||
public sealed record TestConnectionRequest(
|
||||
string Type,
|
||||
Dictionary<string, string?> Configuration);
|
||||
|
||||
public sealed record TestConnectionResult(
|
||||
bool Success,
|
||||
string Message,
|
||||
long? LatencyMs);
|
||||
|
||||
public sealed record IdentityProviderTypeSchema(
|
||||
string Type,
|
||||
string DisplayName,
|
||||
IReadOnlyList<IdentityProviderFieldSchema> RequiredFields,
|
||||
IReadOnlyList<IdentityProviderFieldSchema> OptionalFields);
|
||||
|
||||
public sealed record IdentityProviderFieldSchema(
|
||||
string Name,
|
||||
string DisplayName,
|
||||
string FieldType,
|
||||
string? DefaultValue,
|
||||
string? Description);
|
||||
@@ -13,6 +13,16 @@ public sealed record PlatformDashboardPreferences(
|
||||
public sealed record PlatformDashboardPreferencesRequest(
|
||||
JsonObject Preferences);
|
||||
|
||||
public sealed record PlatformLanguagePreference(
|
||||
string TenantId,
|
||||
string ActorId,
|
||||
string? Locale,
|
||||
DateTimeOffset UpdatedAt,
|
||||
string? UpdatedBy);
|
||||
|
||||
public sealed record PlatformLanguagePreferenceRequest(
|
||||
string Locale);
|
||||
|
||||
public sealed record PlatformDashboardProfile(
|
||||
string ProfileId,
|
||||
string Name,
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
public static class IdentityProviderEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapIdentityProviderEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/platform/identity-providers")
|
||||
.WithTags("Identity Providers")
|
||||
.RequireAuthorization(PlatformPolicies.IdentityProviderAdmin)
|
||||
.RequireTenant();
|
||||
|
||||
group.MapGet("/", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IdentityProviderManagementService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
var items = await service.ListAsync(requestContext!.TenantId, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(items);
|
||||
});
|
||||
|
||||
group.MapGet("/{id:guid}", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IdentityProviderManagementService service,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
var item = await service.GetAsync(requestContext!.TenantId, id, cancellationToken).ConfigureAwait(false);
|
||||
return item is null ? Results.NotFound() : Results.Ok(item);
|
||||
});
|
||||
|
||||
group.MapPost("/", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IdentityProviderManagementService service,
|
||||
CreateIdentityProviderRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
try
|
||||
{
|
||||
var created = await service.CreateAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Results.Created($"/api/v1/platform/identity-providers/{created.Id}", created);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
});
|
||||
|
||||
group.MapPut("/{id:guid}", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IdentityProviderManagementService service,
|
||||
Guid id,
|
||||
UpdateIdentityProviderRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await service.UpdateAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
id,
|
||||
request,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return updated is null ? Results.NotFound() : Results.Ok(updated);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:guid}", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IdentityProviderManagementService service,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
var deleted = await service.DeleteAsync(requestContext!.TenantId, id, cancellationToken).ConfigureAwait(false);
|
||||
return deleted ? Results.NoContent() : Results.NotFound();
|
||||
});
|
||||
|
||||
group.MapPost("/{id:guid}/enable", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IdentityProviderManagementService service,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
var result = await service.SetEnabledAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
id,
|
||||
true,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||
});
|
||||
|
||||
group.MapPost("/{id:guid}/disable", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IdentityProviderManagementService service,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
var result = await service.SetEnabledAsync(
|
||||
requestContext!.TenantId,
|
||||
requestContext.ActorId,
|
||||
id,
|
||||
false,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||
});
|
||||
|
||||
group.MapPost("/test-connection", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IdentityProviderManagementService service,
|
||||
TestConnectionRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
return failure!;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await service.TestConnectionAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
});
|
||||
|
||||
group.MapGet("/{id:guid}/health", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IdentityProviderManagementService service,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
var result = await service.GetHealthAsync(requestContext!.TenantId, id, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(result);
|
||||
});
|
||||
|
||||
group.MapPost("/{id:guid}/apply", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IdentityProviderManagementService service,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<IdentityProviderManagementService> logger,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
return failure!;
|
||||
|
||||
var item = await service.GetAsync(requestContext!.TenantId, id, cancellationToken).ConfigureAwait(false);
|
||||
if (item is null)
|
||||
return Results.NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
var client = httpClientFactory.CreateClient("AuthorityInternal");
|
||||
using var response = await client.PostAsync("internal/plugins/reload", null, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Authority plugin reload triggered for provider '{ProviderName}' ({ProviderId}).",
|
||||
item.Name,
|
||||
id);
|
||||
|
||||
return Results.Ok(new { applied = true, providerId = id, providerName = item.Name });
|
||||
}
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
logger.LogWarning(
|
||||
"Authority plugin reload returned {StatusCode} for provider '{ProviderName}': {Body}",
|
||||
(int)response.StatusCode,
|
||||
item.Name,
|
||||
body);
|
||||
|
||||
return Results.Ok(new { applied = false, providerId = id, providerName = item.Name, error = $"Authority returned {(int)response.StatusCode}" });
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Failed to reach Authority reload endpoint for provider '{ProviderName}'. Config saved but not applied.",
|
||||
item.Name);
|
||||
|
||||
return Results.Ok(new { applied = false, providerId = id, providerName = item.Name, error = "Authority unreachable; config saved but not applied." });
|
||||
}
|
||||
});
|
||||
|
||||
group.MapGet("/types", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
IdentityProviderManagementService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out _, out var failure))
|
||||
return failure!;
|
||||
|
||||
var types = await service.GetTypeSchemasAsync(cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(types);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static bool TryResolveContext(
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
out PlatformRequestContext? requestContext,
|
||||
out IResult? failure)
|
||||
{
|
||||
if (resolver.TryResolve(context, out requestContext, out var error))
|
||||
{
|
||||
failure = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
failure = Results.BadRequest(new { error = error ?? "tenant_missing" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Localization;
|
||||
using StellaOps.Platform.WebService.Constants;
|
||||
using StellaOps.Platform.WebService.Services;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Endpoints;
|
||||
|
||||
/// <summary>
|
||||
/// REST API endpoints for translation management and serving UI bundles.
|
||||
/// </summary>
|
||||
public static class LocalizationEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapLocalizationEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
// Anonymous UI bundle endpoint (same pattern as /platform/envsettings.json)
|
||||
app.MapGet("/platform/i18n/{locale}.json", async (
|
||||
string locale,
|
||||
HttpContext context,
|
||||
PlatformTranslationService translationService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
|
||||
var bundle = await translationService.GetMergedBundleAsync(tenantId, locale, ct);
|
||||
|
||||
// Cache for 5 minutes — translations don't change often
|
||||
context.Response.Headers.CacheControl = "public, max-age=300";
|
||||
return Results.Ok(bundle);
|
||||
})
|
||||
.WithTags("Localization")
|
||||
.WithName("GetUiTranslationBundle")
|
||||
.WithSummary("Gets the merged translation bundle for the UI")
|
||||
.WithDescription(
|
||||
"Returns all translations for the specified locale, merging embedded defaults with DB overrides. " +
|
||||
"Anonymous access, cacheable. Used by the Angular frontend at boot time.")
|
||||
.AllowAnonymous();
|
||||
|
||||
// Authenticated API group
|
||||
var group = app.MapGroup("/api/v1/platform/localization")
|
||||
.WithTags("Localization")
|
||||
.RequireAuthorization(PlatformPolicies.PreferencesRead)
|
||||
.RequireTenant();
|
||||
|
||||
// Get all translations for a locale
|
||||
group.MapGet("/bundles/{locale}", async (
|
||||
string locale,
|
||||
HttpContext context,
|
||||
PlatformTranslationService translationService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
|
||||
var bundle = await translationService.GetMergedBundleAsync(tenantId, locale, ct);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
locale,
|
||||
strings = bundle,
|
||||
count = bundle.Count
|
||||
});
|
||||
})
|
||||
.WithName("GetTranslationBundle")
|
||||
.WithSummary("Gets all translations for a locale")
|
||||
.WithDescription(
|
||||
"Returns the merged set of all translations for the specified locale, " +
|
||||
"combining embedded defaults with system and tenant DB overrides in priority order.");
|
||||
|
||||
// Get translations filtered by namespace
|
||||
group.MapGet("/bundles/{locale}/{ns}", async (
|
||||
string locale,
|
||||
string ns,
|
||||
HttpContext context,
|
||||
PlatformTranslationService translationService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
|
||||
var bundle = await translationService.GetMergedBundleAsync(tenantId, locale, ns, ct);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
locale,
|
||||
@namespace = ns,
|
||||
strings = bundle,
|
||||
count = bundle.Count
|
||||
});
|
||||
})
|
||||
.WithName("GetTranslationBundleByNamespace")
|
||||
.WithSummary("Gets translations for a locale filtered by namespace prefix");
|
||||
|
||||
// Get available locales
|
||||
group.MapGet("/locales", async (
|
||||
HttpContext context,
|
||||
PlatformTranslationService translationService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
|
||||
var locales = await translationService.GetAllLocalesAsync(tenantId, ct);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
locales,
|
||||
count = locales.Count
|
||||
});
|
||||
})
|
||||
.WithName("GetAvailableLocales")
|
||||
.WithSummary("Gets all available locales");
|
||||
|
||||
// Upsert translations (admin)
|
||||
group.MapPut("/bundles", async (
|
||||
UpsertTranslationsRequest request,
|
||||
HttpContext context,
|
||||
ITranslationStore store,
|
||||
TranslationRegistry registry,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
|
||||
var actor = context.Request.Headers["X-Actor"].FirstOrDefault() ?? "system";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Locale))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Locale is required." });
|
||||
}
|
||||
|
||||
if (request.Strings is null || request.Strings.Count == 0)
|
||||
{
|
||||
return Results.BadRequest(new { error = "At least one translation string is required." });
|
||||
}
|
||||
|
||||
await store.UpsertBatchAsync(tenantId, request.Locale, request.Strings, actor, ct);
|
||||
|
||||
// Also merge into the in-memory registry for immediate effect
|
||||
registry.MergeBundles(request.Locale, request.Strings);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
locale = request.Locale,
|
||||
upserted = request.Strings.Count,
|
||||
message = "Translations updated successfully."
|
||||
});
|
||||
})
|
||||
.WithName("UpsertTranslations")
|
||||
.WithSummary("Creates or updates translation strings")
|
||||
.WithDescription(
|
||||
"Upserts translation key-value pairs for a locale. DB values override embedded defaults. " +
|
||||
"Changes take immediate effect in the in-memory registry.")
|
||||
.RequireAuthorization(PlatformPolicies.PreferencesWrite);
|
||||
|
||||
// Delete a translation (admin)
|
||||
group.MapDelete("/strings/{locale}/{key}", async (
|
||||
string locale,
|
||||
string key,
|
||||
HttpContext context,
|
||||
ITranslationStore store,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault() ?? "_system";
|
||||
|
||||
var deleted = await store.DeleteAsync(tenantId, locale, key, ct);
|
||||
if (!deleted)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Translation '{key}' not found for locale '{locale}'." });
|
||||
}
|
||||
|
||||
return Results.Ok(new { message = $"Translation '{key}' deleted for locale '{locale}'." });
|
||||
})
|
||||
.WithName("DeleteTranslation")
|
||||
.WithSummary("Deletes a translation override")
|
||||
.RequireAuthorization(PlatformPolicies.PreferencesWrite);
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to upsert translations.
|
||||
/// </summary>
|
||||
public sealed record UpsertTranslationsRequest
|
||||
{
|
||||
/// <summary>Target locale (e.g., "en-US", "de-DE").</summary>
|
||||
public required string Locale { get; init; }
|
||||
|
||||
/// <summary>Key-value pairs to upsert.</summary>
|
||||
public required Dictionary<string, string> Strings { get; init; }
|
||||
}
|
||||
@@ -348,6 +348,44 @@ public static class PlatformEndpoints
|
||||
}
|
||||
}).RequireAuthorization(PlatformPolicies.PreferencesWrite);
|
||||
|
||||
preferences.MapGet("/language", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformPreferencesService service,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
var locale = await service.GetLanguagePreferenceAsync(requestContext!, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(locale);
|
||||
}).RequireAuthorization(PlatformPolicies.PreferencesRead);
|
||||
|
||||
preferences.MapPut("/language", async Task<IResult> (
|
||||
HttpContext context,
|
||||
PlatformRequestContextResolver resolver,
|
||||
PlatformPreferencesService service,
|
||||
PlatformLanguagePreferenceRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveContext(context, resolver, out var requestContext, out var failure))
|
||||
{
|
||||
return failure!;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var locale = await service.UpsertLanguagePreferenceAsync(requestContext!, request, cancellationToken).ConfigureAwait(false);
|
||||
return Results.Ok(locale);
|
||||
}
|
||||
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> (
|
||||
@@ -420,6 +458,8 @@ public static class PlatformEndpoints
|
||||
return failure!;
|
||||
}
|
||||
|
||||
ApplyLegacySearchDeprecationHeaders(context.Response.Headers);
|
||||
|
||||
var sources = query.Sources
|
||||
?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.ToArray();
|
||||
@@ -452,6 +492,14 @@ public static class PlatformEndpoints
|
||||
.RequireAuthorization(PlatformPolicies.SearchRead);
|
||||
}
|
||||
|
||||
private static void ApplyLegacySearchDeprecationHeaders(IHeaderDictionary headers)
|
||||
{
|
||||
headers["Deprecation"] = "true";
|
||||
headers["Sunset"] = "2026-04-30T00:00:00Z";
|
||||
headers["Link"] = "</api/v1/search/query>; rel=\"successor-version\"";
|
||||
headers["Warning"] = "299 - Legacy platform search is deprecated; migrate to /api/v1/search/query";
|
||||
}
|
||||
|
||||
private static void MapMetadataEndpoints(IEndpointRouteBuilder platform)
|
||||
{
|
||||
platform.MapGet("/metadata", async Task<IResult> (
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.Extensions.Options;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Auth.ServerIntegration.Tenancy;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Localization;
|
||||
using StellaOps.Messaging.DependencyInjection;
|
||||
using StellaOps.Platform.Analytics;
|
||||
using StellaOps.Platform.WebService.Configuration;
|
||||
@@ -151,6 +152,8 @@ builder.Services.AddAuthorization(options =>
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.ReleaseControlOperate, PlatformScopes.OrchOperate);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.FederationRead, PlatformScopes.FederationRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.FederationManage, PlatformScopes.FederationManage);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.IdentityProviderRead, PlatformScopes.IdentityProviderRead);
|
||||
options.AddStellaOpsScopePolicy(PlatformPolicies.IdentityProviderAdmin, PlatformScopes.IdentityProviderAdmin);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<PlatformRequestContextResolver>();
|
||||
@@ -171,6 +174,19 @@ builder.Services.AddSingleton<PlatformDashboardProfileStore>();
|
||||
builder.Services.AddSingleton<PlatformPreferencesService>();
|
||||
|
||||
builder.Services.AddSingleton<PlatformSearchService>();
|
||||
builder.Services.AddSingleton<IdentityProviderManagementService>();
|
||||
|
||||
builder.Services.AddHttpClient("AuthorityInternal", client =>
|
||||
{
|
||||
var authorityUrl = builder.Configuration["STELLAOPS_AUTHORITY_URL"]
|
||||
?? builder.Configuration["Authority:InternalUrl"]
|
||||
?? "https://authority.stella-ops.local";
|
||||
client.BaseAddress = new Uri(authorityUrl.TrimEnd('/') + "/");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Bootstrap-Key",
|
||||
builder.Configuration["STELLAOPS_BOOTSTRAP_KEY"] ?? builder.Configuration["Authority:BootstrapKey"] ?? "");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<PlatformMetadataService>();
|
||||
builder.Services.AddSingleton<PlatformContextService>();
|
||||
builder.Services.AddSingleton<TopologyReadModelService>();
|
||||
@@ -208,6 +224,7 @@ if (!string.IsNullOrWhiteSpace(bootstrapOptions.Storage.PostgresConnectionString
|
||||
builder.Services.AddSingleton<IReleaseControlBundleStore, PostgresReleaseControlBundleStore>();
|
||||
builder.Services.AddSingleton<IAdministrationTrustSigningStore, PostgresAdministrationTrustSigningStore>();
|
||||
builder.Services.AddSingleton<IPlatformContextStore, PostgresPlatformContextStore>();
|
||||
builder.Services.AddSingleton<ITranslationStore, PostgresTranslationStore>();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -216,8 +233,15 @@ else
|
||||
builder.Services.AddSingleton<IReleaseControlBundleStore, InMemoryReleaseControlBundleStore>();
|
||||
builder.Services.AddSingleton<IAdministrationTrustSigningStore, InMemoryAdministrationTrustSigningStore>();
|
||||
builder.Services.AddSingleton<IPlatformContextStore, InMemoryPlatformContextStore>();
|
||||
builder.Services.AddSingleton<ITranslationStore, InMemoryTranslationStore>();
|
||||
}
|
||||
|
||||
// Localization: common base + platform-specific embedded bundles + DB overrides
|
||||
builder.Services.AddStellaOpsLocalization(builder.Configuration);
|
||||
builder.Services.AddTranslationBundle(System.Reflection.Assembly.GetExecutingAssembly());
|
||||
builder.Services.AddSingleton<PlatformTranslationService>();
|
||||
builder.Services.AddSingleton<ITranslationBundleProvider>(sp => sp.GetRequiredService<PlatformTranslationService>());
|
||||
|
||||
// Environment settings composer (3-layer merge: env vars -> YAML -> DB)
|
||||
builder.Services.AddSingleton<EnvironmentSettingsComposer>();
|
||||
builder.Services.AddSingleton<SetupStateDetector>();
|
||||
@@ -254,6 +278,7 @@ if (!string.Equals(bootstrapOptions.Storage.Driver, "memory", StringComparison.O
|
||||
}
|
||||
|
||||
app.UseStellaOpsCors();
|
||||
app.UseStellaOpsLocalization();
|
||||
app.UseStellaOpsTelemetryContext();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
@@ -270,10 +295,14 @@ app.Use(async (context, next) =>
|
||||
}
|
||||
});
|
||||
|
||||
await app.LoadTranslationsAsync();
|
||||
|
||||
app.MapLocalizationEndpoints();
|
||||
app.MapEnvironmentSettingsEndpoints();
|
||||
app.MapEnvironmentSettingsAdminEndpoints();
|
||||
app.MapContextEndpoints();
|
||||
app.MapPlatformEndpoints();
|
||||
app.MapIdentityProviderEndpoints();
|
||||
app.MapSetupEndpoints();
|
||||
app.MapAnalyticsEndpoints();
|
||||
app.MapScoreEndpoints();
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Persistence for platform translations (DB layer).
|
||||
/// </summary>
|
||||
public interface ITranslationStore
|
||||
{
|
||||
/// <summary>Get all translations for a tenant and locale.</summary>
|
||||
Task<IReadOnlyDictionary<string, string>> GetAllAsync(
|
||||
string tenantId, string locale, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Get translations filtered by key prefix for a tenant and locale.</summary>
|
||||
Task<IReadOnlyDictionary<string, string>> GetByPrefixAsync(
|
||||
string tenantId, string locale, string keyPrefix, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Upsert a single translation.</summary>
|
||||
Task UpsertAsync(string tenantId, string locale, string key, string value, string actor,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>Upsert a batch of translations.</summary>
|
||||
Task UpsertBatchAsync(string tenantId, string locale, IReadOnlyDictionary<string, string> strings,
|
||||
string actor, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Delete a single translation.</summary>
|
||||
Task<bool> DeleteAsync(string tenantId, string locale, string key, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Get all locales that have at least one translation for a tenant.</summary>
|
||||
Task<IReadOnlyList<string>> GetAvailableLocalesAsync(string tenantId, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public sealed class IdentityProviderManagementService
|
||||
{
|
||||
private static readonly HashSet<string> ValidTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"standard", "ldap", "saml", "oidc"
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string[]> RequiredFieldsByType = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ldap"] = ["host", "port", "bindDn", "bindPassword", "searchBase"],
|
||||
["saml"] = ["spEntityId", "idpEntityId"],
|
||||
["oidc"] = ["authority", "clientId"],
|
||||
["standard"] = []
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string[]> OptionalFieldsByType = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ldap"] = ["useSsl", "usernameAttribute", "groupAttribute", "groupToRoleMapping", "timeoutSeconds"],
|
||||
["saml"] = ["idpSsoUrl", "idpMetadataUrl", "idpSigningCertificate", "assertionConsumerServiceUrl", "attributeMappings", "roleMappings"],
|
||||
["oidc"] = ["clientSecret", "audience", "scopes", "claimMappings", "roleMappings", "requireHttpsMetadata"],
|
||||
["standard"] = []
|
||||
};
|
||||
|
||||
private readonly IHttpClientFactory? _httpClientFactory;
|
||||
private readonly ILogger<IdentityProviderManagementService> _logger;
|
||||
|
||||
// In-memory store keyed by (tenantId, id)
|
||||
private readonly Dictionary<Guid, IdentityProviderConfigEntry> _store = new();
|
||||
private readonly object _lock = new();
|
||||
|
||||
public IdentityProviderManagementService(
|
||||
ILogger<IdentityProviderManagementService> logger,
|
||||
IHttpClientFactory? httpClientFactory = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IdentityProviderConfigDto>> ListAsync(string tenantId, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var items = _store.Values
|
||||
.Where(e => string.Equals(e.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(e => e.Name)
|
||||
.Select(MapToDto)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<IdentityProviderConfigDto>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IdentityProviderConfigDto?> GetAsync(string tenantId, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_store.TryGetValue(id, out var entry) &&
|
||||
string.Equals(entry.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult<IdentityProviderConfigDto?>(MapToDto(entry));
|
||||
}
|
||||
|
||||
return Task.FromResult<IdentityProviderConfigDto?>(null);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IdentityProviderConfigDto> CreateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
CreateIdentityProviderRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateType(request.Type);
|
||||
ValidateRequiredFields(request.Type, request.Configuration);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
throw new InvalidOperationException("Name is required.");
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var exists = _store.Values.Any(e =>
|
||||
string.Equals(e.TenantId, tenantId, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(e.Name, request.Name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (exists)
|
||||
throw new InvalidOperationException($"An identity provider with name '{request.Name}' already exists for this tenant.");
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var entry = new IdentityProviderConfigEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
TenantId = tenantId,
|
||||
Name = request.Name.Trim(),
|
||||
Type = request.Type.Trim().ToLowerInvariant(),
|
||||
Enabled = request.Enabled,
|
||||
Configuration = new Dictionary<string, string?>(request.Configuration, StringComparer.OrdinalIgnoreCase),
|
||||
Description = request.Description,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
CreatedBy = actorId,
|
||||
UpdatedBy = actorId
|
||||
};
|
||||
|
||||
_store[entry.Id] = entry;
|
||||
_logger.LogInformation("Created identity provider '{Name}' (type={Type}) for tenant '{TenantId}'",
|
||||
entry.Name, entry.Type, tenantId);
|
||||
|
||||
return Task.FromResult(MapToDto(entry));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IdentityProviderConfigDto?> UpdateAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid id,
|
||||
UpdateIdentityProviderRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_store.TryGetValue(id, out var entry) ||
|
||||
!string.Equals(entry.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult<IdentityProviderConfigDto?>(null);
|
||||
}
|
||||
|
||||
if (request.Enabled.HasValue)
|
||||
entry.Enabled = request.Enabled.Value;
|
||||
|
||||
if (request.Configuration is not null)
|
||||
{
|
||||
ValidateRequiredFields(entry.Type, request.Configuration);
|
||||
entry.Configuration = new Dictionary<string, string?>(request.Configuration, StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
if (request.Description is not null)
|
||||
entry.Description = request.Description;
|
||||
|
||||
entry.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
entry.UpdatedBy = actorId;
|
||||
|
||||
_logger.LogInformation("Updated identity provider '{Name}' ({Id}) for tenant '{TenantId}'",
|
||||
entry.Name, id, tenantId);
|
||||
|
||||
return Task.FromResult<IdentityProviderConfigDto?>(MapToDto(entry));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_store.TryGetValue(id, out var entry) &&
|
||||
string.Equals(entry.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_store.Remove(id);
|
||||
_logger.LogInformation("Deleted identity provider '{Name}' ({Id}) for tenant '{TenantId}'",
|
||||
entry.Name, id, tenantId);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IdentityProviderConfigDto?> SetEnabledAsync(
|
||||
string tenantId,
|
||||
string actorId,
|
||||
Guid id,
|
||||
bool enabled,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_store.TryGetValue(id, out var entry) ||
|
||||
!string.Equals(entry.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult<IdentityProviderConfigDto?>(null);
|
||||
}
|
||||
|
||||
entry.Enabled = enabled;
|
||||
entry.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
entry.UpdatedBy = actorId;
|
||||
|
||||
return Task.FromResult<IdentityProviderConfigDto?>(MapToDto(entry));
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<TestConnectionResult> TestConnectionAsync(
|
||||
TestConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ValidateType(request.Type);
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
return request.Type.ToLowerInvariant() switch
|
||||
{
|
||||
"ldap" => await TestLdapConnectionAsync(request.Configuration, cancellationToken).ConfigureAwait(false),
|
||||
"saml" => await TestSamlConnectionAsync(request.Configuration, cancellationToken).ConfigureAwait(false),
|
||||
"oidc" => await TestOidcConnectionAsync(request.Configuration, cancellationToken).ConfigureAwait(false),
|
||||
"standard" => new TestConnectionResult(true, "Standard provider requires no external connection.", sw.ElapsedMilliseconds),
|
||||
_ => new TestConnectionResult(false, $"Unknown provider type: {request.Type}", sw.ElapsedMilliseconds)
|
||||
};
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Connection test failed for type '{Type}'", request.Type);
|
||||
return new TestConnectionResult(false, $"Connection test failed: {ex.Message}", sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<TestConnectionResult> GetHealthAsync(
|
||||
string tenantId,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_store.TryGetValue(id, out var entry) ||
|
||||
!string.Equals(entry.TenantId, tenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Task.FromResult(new TestConnectionResult(false, "Provider not found.", null));
|
||||
}
|
||||
|
||||
if (!entry.Enabled)
|
||||
{
|
||||
return Task.FromResult(new TestConnectionResult(false, "Provider is disabled.", null));
|
||||
}
|
||||
|
||||
return Task.FromResult(new TestConnectionResult(true, "Provider is active.", null));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<IdentityProviderTypeSchema>> GetTypeSchemasAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var schemas = new List<IdentityProviderTypeSchema>
|
||||
{
|
||||
BuildTypeSchema("standard", "Standard (Built-in)"),
|
||||
BuildTypeSchema("ldap", "LDAP / Active Directory"),
|
||||
BuildTypeSchema("saml", "SAML 2.0"),
|
||||
BuildTypeSchema("oidc", "OpenID Connect")
|
||||
};
|
||||
|
||||
return Task.FromResult<IReadOnlyList<IdentityProviderTypeSchema>>(schemas);
|
||||
}
|
||||
|
||||
private static IdentityProviderTypeSchema BuildTypeSchema(string type, string displayName)
|
||||
{
|
||||
var required = (RequiredFieldsByType.TryGetValue(type, out var reqFields) ? reqFields : [])
|
||||
.Select(f => new IdentityProviderFieldSchema(f, FormatDisplayName(f), InferFieldType(f), null, null))
|
||||
.ToList();
|
||||
|
||||
var optional = (OptionalFieldsByType.TryGetValue(type, out var optFields) ? optFields : [])
|
||||
.Select(f => new IdentityProviderFieldSchema(f, FormatDisplayName(f), InferFieldType(f), InferDefault(f), null))
|
||||
.ToList();
|
||||
|
||||
return new IdentityProviderTypeSchema(type, displayName, required, optional);
|
||||
}
|
||||
|
||||
private static string FormatDisplayName(string fieldName)
|
||||
{
|
||||
// Convert camelCase to Title Case
|
||||
var chars = new List<char>();
|
||||
for (var i = 0; i < fieldName.Length; i++)
|
||||
{
|
||||
if (i == 0)
|
||||
{
|
||||
chars.Add(char.ToUpperInvariant(fieldName[i]));
|
||||
}
|
||||
else if (char.IsUpper(fieldName[i]))
|
||||
{
|
||||
chars.Add(' ');
|
||||
chars.Add(fieldName[i]);
|
||||
}
|
||||
else
|
||||
{
|
||||
chars.Add(fieldName[i]);
|
||||
}
|
||||
}
|
||||
return new string(chars.ToArray());
|
||||
}
|
||||
|
||||
private static string InferFieldType(string fieldName)
|
||||
{
|
||||
if (fieldName.Contains("password", StringComparison.OrdinalIgnoreCase) ||
|
||||
fieldName.Contains("secret", StringComparison.OrdinalIgnoreCase))
|
||||
return "secret";
|
||||
|
||||
if (fieldName.Contains("port", StringComparison.OrdinalIgnoreCase) ||
|
||||
fieldName.Contains("timeout", StringComparison.OrdinalIgnoreCase))
|
||||
return "number";
|
||||
|
||||
if (fieldName.StartsWith("use", StringComparison.OrdinalIgnoreCase) ||
|
||||
fieldName.StartsWith("require", StringComparison.OrdinalIgnoreCase))
|
||||
return "boolean";
|
||||
|
||||
if (fieldName.Contains("url", StringComparison.OrdinalIgnoreCase) ||
|
||||
fieldName.Contains("authority", StringComparison.OrdinalIgnoreCase))
|
||||
return "url";
|
||||
|
||||
if (fieldName.Contains("certificate", StringComparison.OrdinalIgnoreCase) ||
|
||||
fieldName.Contains("Mapping", StringComparison.OrdinalIgnoreCase))
|
||||
return "textarea";
|
||||
|
||||
return "text";
|
||||
}
|
||||
|
||||
private static string? InferDefault(string fieldName)
|
||||
{
|
||||
return fieldName switch
|
||||
{
|
||||
"useSsl" => "false",
|
||||
"usernameAttribute" => "uid",
|
||||
"groupAttribute" => "memberOf",
|
||||
"scopes" => "openid profile email",
|
||||
"requireHttpsMetadata" => "true",
|
||||
"timeoutSeconds" => "30",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static void ValidateType(string type)
|
||||
{
|
||||
if (!ValidTypes.Contains(type))
|
||||
throw new InvalidOperationException($"Invalid provider type '{type}'. Valid types: {string.Join(", ", ValidTypes)}.");
|
||||
}
|
||||
|
||||
private static void ValidateRequiredFields(string type, Dictionary<string, string?> config)
|
||||
{
|
||||
if (!RequiredFieldsByType.TryGetValue(type, out var required))
|
||||
return;
|
||||
|
||||
var missing = required
|
||||
.Where(field => !config.ContainsKey(field) || string.IsNullOrWhiteSpace(config[field]))
|
||||
.ToList();
|
||||
|
||||
if (missing.Count > 0)
|
||||
throw new InvalidOperationException($"Missing required fields for type '{type}': {string.Join(", ", missing)}.");
|
||||
}
|
||||
|
||||
private async Task<TestConnectionResult> TestLdapConnectionAsync(
|
||||
Dictionary<string, string?> config,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var host = config.GetValueOrDefault("host") ?? throw new InvalidOperationException("LDAP host is required.");
|
||||
var portStr = config.GetValueOrDefault("port") ?? "389";
|
||||
if (!int.TryParse(portStr, out var port))
|
||||
throw new InvalidOperationException("LDAP port must be a valid integer.");
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
using var tcp = new TcpClient();
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
|
||||
await tcp.ConnectAsync(host, port, cts.Token).ConfigureAwait(false);
|
||||
sw.Stop();
|
||||
|
||||
return new TestConnectionResult(true, $"TCP connection to {host}:{port} succeeded.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
private async Task<TestConnectionResult> TestSamlConnectionAsync(
|
||||
Dictionary<string, string?> config,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var metadataUrl = config.GetValueOrDefault("idpMetadataUrl");
|
||||
if (string.IsNullOrWhiteSpace(metadataUrl))
|
||||
{
|
||||
// If no metadata URL, just validate that required fields exist
|
||||
var entityId = config.GetValueOrDefault("idpEntityId");
|
||||
if (string.IsNullOrWhiteSpace(entityId))
|
||||
throw new InvalidOperationException("Either idpMetadataUrl or idpEntityId is required for SAML.");
|
||||
|
||||
return new TestConnectionResult(true, "SAML configuration validated (no metadata URL to test).", null);
|
||||
}
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var httpClient = _httpClientFactory?.CreateClient("idp-test") ?? new HttpClient();
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
|
||||
var response = await httpClient.GetAsync(metadataUrl, cts.Token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var content = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
if (!content.Contains("EntityDescriptor", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TestConnectionResult(false, "SAML metadata URL responded but content does not appear to be valid SAML metadata.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(true, $"SAML metadata fetched successfully from {metadataUrl}.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_httpClientFactory is null)
|
||||
httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<TestConnectionResult> TestOidcConnectionAsync(
|
||||
Dictionary<string, string?> config,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var authority = config.GetValueOrDefault("authority") ?? throw new InvalidOperationException("OIDC authority is required.");
|
||||
var discoveryUrl = authority.TrimEnd('/') + "/.well-known/openid-configuration";
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
var httpClient = _httpClientFactory?.CreateClient("idp-test") ?? new HttpClient();
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(15));
|
||||
|
||||
var response = await httpClient.GetAsync(discoveryUrl, cts.Token).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var content = await response.Content.ReadAsStringAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
// Basic validation: should contain issuer field
|
||||
if (!content.Contains("issuer", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new TestConnectionResult(false, "OIDC discovery endpoint responded but content does not appear to be a valid OpenID configuration.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return new TestConnectionResult(true, $"OIDC discovery document fetched successfully from {discoveryUrl}.", sw.ElapsedMilliseconds);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_httpClientFactory is null)
|
||||
httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static IdentityProviderConfigDto MapToDto(IdentityProviderConfigEntry entry)
|
||||
{
|
||||
return new IdentityProviderConfigDto(
|
||||
entry.Id,
|
||||
entry.Name,
|
||||
entry.Type,
|
||||
entry.Enabled,
|
||||
new Dictionary<string, string?>(entry.Configuration),
|
||||
entry.Description,
|
||||
entry.Enabled ? "healthy" : "disabled",
|
||||
entry.CreatedAt,
|
||||
entry.UpdatedAt,
|
||||
entry.CreatedBy,
|
||||
entry.UpdatedBy);
|
||||
}
|
||||
|
||||
private sealed class IdentityProviderConfigEntry
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string TenantId { get; set; } = null!;
|
||||
public string Name { get; set; } = null!;
|
||||
public string Type { get; set; } = null!;
|
||||
public bool Enabled { get; set; }
|
||||
public Dictionary<string, string?> Configuration { get; set; } = new();
|
||||
public string? Description { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public string CreatedBy { get; set; } = null!;
|
||||
public string UpdatedBy { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory implementation of <see cref="ITranslationStore"/> for development/testing.
|
||||
/// </summary>
|
||||
public sealed class InMemoryTranslationStore : ITranslationStore
|
||||
{
|
||||
// Key: "{tenantId}:{locale}:{key}" -> value
|
||||
private readonly ConcurrentDictionary<string, string> _store = new(StringComparer.Ordinal);
|
||||
|
||||
public Task<IReadOnlyDictionary<string, string>> GetAllAsync(
|
||||
string tenantId, string locale, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var prefix = $"{tenantId}:{locale}:";
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var (compositeKey, value) in _store)
|
||||
{
|
||||
if (compositeKey.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
var key = compositeKey[prefix.Length..];
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyDictionary<string, string>>(result);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyDictionary<string, string>> GetByPrefixAsync(
|
||||
string tenantId, string locale, string keyPrefix, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var storePrefix = $"{tenantId}:{locale}:";
|
||||
var dotPrefix = keyPrefix.EndsWith('.') ? keyPrefix : keyPrefix + ".";
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var (compositeKey, value) in _store)
|
||||
{
|
||||
if (compositeKey.StartsWith(storePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
var key = compositeKey[storePrefix.Length..];
|
||||
if (key.StartsWith(dotPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyDictionary<string, string>>(result);
|
||||
}
|
||||
|
||||
public Task UpsertAsync(string tenantId, string locale, string key, string value, string actor,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
_store[$"{tenantId}:{locale}:{key}"] = value;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task UpsertBatchAsync(string tenantId, string locale, IReadOnlyDictionary<string, string> strings,
|
||||
string actor, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
foreach (var (key, value) in strings)
|
||||
{
|
||||
_store[$"{tenantId}:{locale}:{key}"] = value;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string tenantId, string locale, string key, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
return Task.FromResult(_store.TryRemove($"{tenantId}:{locale}:{key}", out _));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetAvailableLocalesAsync(string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var prefix = $"{tenantId}:";
|
||||
var locales = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var compositeKey in _store.Keys)
|
||||
{
|
||||
if (compositeKey.StartsWith(prefix, StringComparison.Ordinal))
|
||||
{
|
||||
var afterTenant = compositeKey[prefix.Length..];
|
||||
var colonIdx = afterTenant.IndexOf(':');
|
||||
if (colonIdx > 0)
|
||||
{
|
||||
locales.Add(afterTenant[..colonIdx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<string>>(locales.OrderBy(l => l).ToList());
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,43 @@ namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
public sealed class PlatformPreferencesService
|
||||
{
|
||||
private static readonly Dictionary<string, string> SupportedLocaleMap = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["en-US"] = "en-US",
|
||||
["en_US"] = "en-US",
|
||||
["en"] = "en-US",
|
||||
["de-DE"] = "de-DE",
|
||||
["de_DE"] = "de-DE",
|
||||
["de"] = "de-DE",
|
||||
["bg-BG"] = "bg-BG",
|
||||
["bg_BG"] = "bg-BG",
|
||||
["bg"] = "bg-BG",
|
||||
["ru-RU"] = "ru-RU",
|
||||
["ru_RU"] = "ru-RU",
|
||||
["ru"] = "ru-RU",
|
||||
["es-ES"] = "es-ES",
|
||||
["es_ES"] = "es-ES",
|
||||
["es"] = "es-ES",
|
||||
["fr-FR"] = "fr-FR",
|
||||
["fr_FR"] = "fr-FR",
|
||||
["fr"] = "fr-FR",
|
||||
["uk-UA"] = "uk-UA",
|
||||
["uk_UA"] = "uk-UA",
|
||||
["uk"] = "uk-UA",
|
||||
["ua"] = "uk-UA",
|
||||
["zh-TW"] = "zh-TW",
|
||||
["zh_TW"] = "zh-TW",
|
||||
["zh-Hant"] = "zh-TW",
|
||||
["zh_Hant"] = "zh-TW",
|
||||
["zh-CN"] = "zh-CN",
|
||||
["zh_CN"] = "zh-CN",
|
||||
["zh-Hans"] = "zh-CN",
|
||||
["zh_Hans"] = "zh-CN",
|
||||
["zh"] = "zh-CN",
|
||||
};
|
||||
|
||||
private const string LocalePreferenceKey = "locale";
|
||||
|
||||
private static readonly JsonObject DefaultPreferences = new()
|
||||
{
|
||||
["layout"] = "default",
|
||||
@@ -41,16 +78,7 @@ public sealed class PlatformPreferencesService
|
||||
PlatformRequestContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var preferences = store.GetOrCreate(context.TenantId, context.ActorId, () =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return new PlatformDashboardPreferences(
|
||||
TenantId: context.TenantId,
|
||||
ActorId: context.ActorId,
|
||||
Preferences: ClonePreferences(DefaultPreferences),
|
||||
UpdatedAt: now,
|
||||
UpdatedBy: context.ActorId);
|
||||
});
|
||||
var preferences = GetOrCreatePreferences(context);
|
||||
|
||||
return Task.FromResult(preferences with { Preferences = ClonePreferences(preferences.Preferences) });
|
||||
}
|
||||
@@ -62,11 +90,19 @@ public sealed class PlatformPreferencesService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var existing = GetOrCreatePreferences(context);
|
||||
var updatedPreferences = ClonePreferences(request.Preferences);
|
||||
if (!updatedPreferences.ContainsKey(LocalePreferenceKey) &&
|
||||
existing.Preferences.TryGetPropertyValue(LocalePreferenceKey, out var existingLocaleValue))
|
||||
{
|
||||
updatedPreferences[LocalePreferenceKey] = existingLocaleValue?.DeepClone();
|
||||
}
|
||||
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var preferences = new PlatformDashboardPreferences(
|
||||
TenantId: context.TenantId,
|
||||
ActorId: context.ActorId,
|
||||
Preferences: ClonePreferences(request.Preferences),
|
||||
Preferences: updatedPreferences,
|
||||
UpdatedAt: now,
|
||||
UpdatedBy: context.ActorId);
|
||||
|
||||
@@ -76,6 +112,56 @@ public sealed class PlatformPreferencesService
|
||||
return Task.FromResult(preferences with { Preferences = ClonePreferences(preferences.Preferences) });
|
||||
}
|
||||
|
||||
public Task<PlatformLanguagePreference> GetLanguagePreferenceAsync(
|
||||
PlatformRequestContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var preferences = GetOrCreatePreferences(context);
|
||||
var locale = TryNormalizeLocale(preferences.Preferences[LocalePreferenceKey]?.GetValue<string>());
|
||||
|
||||
return Task.FromResult(new PlatformLanguagePreference(
|
||||
TenantId: context.TenantId,
|
||||
ActorId: context.ActorId,
|
||||
Locale: locale,
|
||||
UpdatedAt: preferences.UpdatedAt,
|
||||
UpdatedBy: preferences.UpdatedBy));
|
||||
}
|
||||
|
||||
public Task<PlatformLanguagePreference> UpsertLanguagePreferenceAsync(
|
||||
PlatformRequestContext context,
|
||||
PlatformLanguagePreferenceRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var normalizedLocale = NormalizeLocaleOrThrow(request.Locale);
|
||||
var existing = GetOrCreatePreferences(context);
|
||||
var updatedPreferences = ClonePreferences(existing.Preferences);
|
||||
updatedPreferences[LocalePreferenceKey] = normalizedLocale;
|
||||
|
||||
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 locale preference for tenant {TenantId} actor {ActorId} to {Locale}.",
|
||||
context.TenantId,
|
||||
context.ActorId,
|
||||
normalizedLocale);
|
||||
|
||||
return Task.FromResult(new PlatformLanguagePreference(
|
||||
TenantId: context.TenantId,
|
||||
ActorId: context.ActorId,
|
||||
Locale: normalizedLocale,
|
||||
UpdatedAt: now,
|
||||
UpdatedBy: context.ActorId));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<PlatformDashboardProfile>> GetProfilesAsync(
|
||||
PlatformRequestContext context,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -135,6 +221,44 @@ public sealed class PlatformPreferencesService
|
||||
return Task.FromResult(profile with { Preferences = ClonePreferences(profile.Preferences) });
|
||||
}
|
||||
|
||||
private PlatformDashboardPreferences GetOrCreatePreferences(PlatformRequestContext context)
|
||||
{
|
||||
return store.GetOrCreate(context.TenantId, context.ActorId, () =>
|
||||
{
|
||||
var now = timeProvider.GetUtcNow();
|
||||
return new PlatformDashboardPreferences(
|
||||
TenantId: context.TenantId,
|
||||
ActorId: context.ActorId,
|
||||
Preferences: ClonePreferences(DefaultPreferences),
|
||||
UpdatedAt: now,
|
||||
UpdatedBy: context.ActorId);
|
||||
});
|
||||
}
|
||||
|
||||
private static string NormalizeLocaleOrThrow(string? locale)
|
||||
{
|
||||
var normalized = TryNormalizeLocale(locale);
|
||||
if (normalized is null)
|
||||
{
|
||||
throw new InvalidOperationException("locale is required and must be one of: en-US, de-DE, bg-BG, ru-RU, es-ES, fr-FR, uk-UA, zh-TW, zh-CN.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string? TryNormalizeLocale(string? locale)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(locale))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = locale.Trim();
|
||||
return SupportedLocaleMap.TryGetValue(trimmed, out var normalized)
|
||||
? normalized
|
||||
: null;
|
||||
}
|
||||
|
||||
private static JsonObject ClonePreferences(JsonObject? source)
|
||||
{
|
||||
if (source is null)
|
||||
|
||||
@@ -24,7 +24,6 @@ public sealed class PlatformSearchService
|
||||
|
||||
private readonly PlatformCache cache;
|
||||
private readonly PlatformAggregationMetrics metrics;
|
||||
private readonly TimeProvider timeProvider;
|
||||
private readonly PlatformSearchOptions searchOptions;
|
||||
private readonly PlatformCacheOptions cacheOptions;
|
||||
private readonly ILogger<PlatformSearchService> logger;
|
||||
@@ -32,13 +31,11 @@ public sealed class PlatformSearchService
|
||||
public PlatformSearchService(
|
||||
PlatformCache cache,
|
||||
PlatformAggregationMetrics metrics,
|
||||
TimeProvider timeProvider,
|
||||
IOptions<PlatformServiceOptions> options,
|
||||
ILogger<PlatformSearchService> logger)
|
||||
{
|
||||
this.cache = cache ?? throw new ArgumentNullException(nameof(cache));
|
||||
this.metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
this.searchOptions = options?.Value.Search ?? throw new ArgumentNullException(nameof(options));
|
||||
this.cacheOptions = options.Value.Cache;
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
@@ -95,11 +92,9 @@ public sealed class PlatformSearchService
|
||||
.ToArray();
|
||||
|
||||
var total = ordered.Length;
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var items = ordered
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.Select(item => item with { UpdatedAt = now })
|
||||
.ToArray();
|
||||
|
||||
return new PlatformSearchResult(items, total, limit, offset, query);
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Localization;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Merges embedded translations from the <see cref="TranslationRegistry"/> with DB overrides
|
||||
/// from the <see cref="ITranslationStore"/>. DB values take precedence.
|
||||
/// Also acts as the <see cref="ITranslationBundleProvider"/> for DB translations (priority 100).
|
||||
/// </summary>
|
||||
public sealed class PlatformTranslationService : ITranslationBundleProvider
|
||||
{
|
||||
private readonly ITranslationStore _store;
|
||||
private readonly TranslationRegistry _registry;
|
||||
private readonly ILogger<PlatformTranslationService> _logger;
|
||||
|
||||
public int Priority => 100;
|
||||
|
||||
public PlatformTranslationService(
|
||||
ITranslationStore store,
|
||||
TranslationRegistry registry,
|
||||
ILogger<PlatformTranslationService> logger)
|
||||
{
|
||||
_store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
_registry = registry ?? throw new ArgumentNullException(nameof(registry));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the fully merged bundle for a locale: embedded base + DB overrides.
|
||||
/// Used to serve the frontend via <c>/platform/i18n/{locale}.json</c>.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyDictionary<string, string>> GetMergedBundleAsync(
|
||||
string tenantId, string locale, CancellationToken ct = default)
|
||||
{
|
||||
// Start with all embedded translations for the locale
|
||||
var baseBundle = _registry.GetBundle(locale);
|
||||
var merged = new Dictionary<string, string>(baseBundle, StringComparer.Ordinal);
|
||||
|
||||
// Overlay DB translations (system-level first, then tenant-specific)
|
||||
var systemOverrides = await _store.GetAllAsync("_system", locale, ct).ConfigureAwait(false);
|
||||
foreach (var (key, value) in systemOverrides)
|
||||
{
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
if (!string.Equals(tenantId, "_system", StringComparison.Ordinal))
|
||||
{
|
||||
var tenantOverrides = await _store.GetAllAsync(tenantId, locale, ct).ConfigureAwait(false);
|
||||
foreach (var (key, value) in tenantOverrides)
|
||||
{
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the merged bundle filtered by namespace prefix.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyDictionary<string, string>> GetMergedBundleAsync(
|
||||
string tenantId, string locale, string namespacePrefix, CancellationToken ct = default)
|
||||
{
|
||||
var prefix = namespacePrefix.EndsWith('.') ? namespacePrefix : namespacePrefix + ".";
|
||||
|
||||
// Start with embedded translations filtered by prefix
|
||||
var baseBundle = _registry.GetBundle(locale, namespacePrefix);
|
||||
var merged = new Dictionary<string, string>(baseBundle, StringComparer.Ordinal);
|
||||
|
||||
// Overlay DB translations
|
||||
var systemOverrides = await _store.GetByPrefixAsync("_system", locale, namespacePrefix, ct)
|
||||
.ConfigureAwait(false);
|
||||
foreach (var (key, value) in systemOverrides)
|
||||
{
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
if (!string.Equals(tenantId, "_system", StringComparison.Ordinal))
|
||||
{
|
||||
var tenantOverrides = await _store.GetByPrefixAsync(tenantId, locale, namespacePrefix, ct)
|
||||
.ConfigureAwait(false);
|
||||
foreach (var (key, value) in tenantOverrides)
|
||||
{
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all locales that have translations (embedded + DB).
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> GetAllLocalesAsync(
|
||||
string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
var locales = new HashSet<string>(_registry.GetLoadedLocales(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var dbLocales = await _store.GetAvailableLocalesAsync("_system", ct).ConfigureAwait(false);
|
||||
foreach (var locale in dbLocales)
|
||||
{
|
||||
locales.Add(locale);
|
||||
}
|
||||
|
||||
if (!string.Equals(tenantId, "_system", StringComparison.Ordinal))
|
||||
{
|
||||
var tenantLocales = await _store.GetAvailableLocalesAsync(tenantId, ct).ConfigureAwait(false);
|
||||
foreach (var locale in tenantLocales)
|
||||
{
|
||||
locales.Add(locale);
|
||||
}
|
||||
}
|
||||
|
||||
return locales.OrderBy(l => l).ToList();
|
||||
}
|
||||
|
||||
// ITranslationBundleProvider — used during LoadTranslationsAsync() to merge DB into the registry
|
||||
public async Task<IReadOnlyDictionary<string, string>> LoadAsync(string locale, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var systemTranslations = await _store.GetAllAsync("_system", locale, ct).ConfigureAwait(false);
|
||||
if (systemTranslations.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Loaded {Count} DB translations for locale {Locale}", systemTranslations.Count, locale);
|
||||
}
|
||||
|
||||
return systemTranslations;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load DB translations for locale {Locale}", locale);
|
||||
return new Dictionary<string, string>();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetAvailableLocalesAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _store.GetAvailableLocalesAsync("_system", ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get available locales from DB");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,20 +46,31 @@ public sealed class PostgresEnvironmentSettingsStore : IEnvironmentSettingsStore
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = PlatformDbContextFactory.Create(
|
||||
connection, DefaultCommandTimeoutSeconds, PlatformDbContextFactory.DefaultSchemaName);
|
||||
|
||||
var entities = await dbContext.EnvironmentSettings
|
||||
.AsNoTracking()
|
||||
.OrderBy(e => e.Key)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entity in entities)
|
||||
Dictionary<string, string> dict;
|
||||
try
|
||||
{
|
||||
dict[entity.Key] = entity.Value;
|
||||
await using var connection = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var dbContext = PlatformDbContextFactory.Create(
|
||||
connection, DefaultCommandTimeoutSeconds, PlatformDbContextFactory.DefaultSchemaName);
|
||||
|
||||
var entities = await dbContext.EnvironmentSettings
|
||||
.AsNoTracking()
|
||||
.OrderBy(e => e.Key)
|
||||
.ToListAsync(ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
dict[entity.Key] = entity.Value;
|
||||
}
|
||||
}
|
||||
catch (Npgsql.PostgresException ex) when (ex.SqlState == "42P01")
|
||||
{
|
||||
// Table does not exist yet (migration not applied). Return empty so
|
||||
// Layer 1 (env vars) and Layer 2 (config) still compose a valid response.
|
||||
_logger.LogWarning("platform.environment_settings table does not exist yet; returning empty DB layer");
|
||||
dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
lock (_cacheLock)
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL implementation of <see cref="ITranslationStore"/>.
|
||||
/// </summary>
|
||||
public sealed class PostgresTranslationStore : ITranslationStore
|
||||
{
|
||||
private readonly NpgsqlDataSource _dataSource;
|
||||
private readonly ILogger<PostgresTranslationStore> _logger;
|
||||
|
||||
private const string SelectAllSql = """
|
||||
SELECT key, value
|
||||
FROM platform.translations
|
||||
WHERE tenant_id = @tenant_id AND locale = @locale
|
||||
ORDER BY key
|
||||
""";
|
||||
|
||||
private const string SelectByPrefixSql = """
|
||||
SELECT key, value
|
||||
FROM platform.translations
|
||||
WHERE tenant_id = @tenant_id AND locale = @locale AND key LIKE @prefix
|
||||
ORDER BY key
|
||||
""";
|
||||
|
||||
private const string UpsertSql = """
|
||||
INSERT INTO platform.translations (tenant_id, locale, key, value, updated_by, updated_at)
|
||||
VALUES (@tenant_id, @locale, @key, @value, @updated_by, now())
|
||||
ON CONFLICT (tenant_id, locale, key) DO UPDATE
|
||||
SET value = EXCLUDED.value, updated_by = EXCLUDED.updated_by, updated_at = now()
|
||||
""";
|
||||
|
||||
private const string DeleteSql = """
|
||||
DELETE FROM platform.translations
|
||||
WHERE tenant_id = @tenant_id AND locale = @locale AND key = @key
|
||||
""";
|
||||
|
||||
private const string SelectLocalesSql = """
|
||||
SELECT DISTINCT locale
|
||||
FROM platform.translations
|
||||
WHERE tenant_id = @tenant_id
|
||||
ORDER BY locale
|
||||
""";
|
||||
|
||||
public PostgresTranslationStore(
|
||||
NpgsqlDataSource dataSource,
|
||||
ILogger<PostgresTranslationStore>? logger = null)
|
||||
{
|
||||
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<PostgresTranslationStore>.Instance;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, string>> GetAllAsync(
|
||||
string tenantId, string locale, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(SelectAllSql, conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("locale", locale);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
result[reader.GetString(0)] = reader.GetString(1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, string>> GetByPrefixAsync(
|
||||
string tenantId, string locale, string keyPrefix, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var prefix = keyPrefix.EndsWith('.') ? keyPrefix : keyPrefix + ".";
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(SelectByPrefixSql, conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("locale", locale);
|
||||
cmd.Parameters.AddWithValue("prefix", prefix + "%");
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
result[reader.GetString(0)] = reader.GetString(1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(string tenantId, string locale, string key, string value, string actor,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(UpsertSql, conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("locale", locale);
|
||||
cmd.Parameters.AddWithValue("key", key);
|
||||
cmd.Parameters.AddWithValue("value", value);
|
||||
cmd.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
_logger.LogDebug("Upserted translation {Key} for {Locale} tenant {TenantId}", key, locale, tenantId);
|
||||
}
|
||||
|
||||
public async Task UpsertBatchAsync(string tenantId, string locale, IReadOnlyDictionary<string, string> strings,
|
||||
string actor, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var transaction = await conn.BeginTransactionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
foreach (var (key, value) in strings)
|
||||
{
|
||||
await using var cmd = new NpgsqlCommand(UpsertSql, conn, transaction);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("locale", locale);
|
||||
cmd.Parameters.AddWithValue("key", key);
|
||||
cmd.Parameters.AddWithValue("value", value);
|
||||
cmd.Parameters.AddWithValue("updated_by", actor);
|
||||
|
||||
await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(ct).ConfigureAwait(false);
|
||||
_logger.LogDebug("Upserted {Count} translations for {Locale} tenant {TenantId}",
|
||||
strings.Count, locale, tenantId);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(string tenantId, string locale, string key, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(DeleteSql, conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
cmd.Parameters.AddWithValue("locale", locale);
|
||||
cmd.Parameters.AddWithValue("key", key);
|
||||
|
||||
var affected = await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
|
||||
return affected > 0;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> GetAvailableLocalesAsync(string tenantId, CancellationToken ct = default)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var locales = new List<string>();
|
||||
|
||||
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
|
||||
await using var cmd = new NpgsqlCommand(SelectLocalesSql, conn);
|
||||
cmd.Parameters.AddWithValue("tenant_id", tenantId);
|
||||
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
locales.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return locales;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,11 @@
|
||||
<ProjectReference Include="..\..\Policy\__Libraries\StellaOps.Policy.Persistence\StellaOps.Policy.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\Notify\__Libraries\StellaOps.Notify.Persistence\StellaOps.Notify.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\Excititor\__Libraries\StellaOps.Excititor.Persistence\StellaOps.Excititor.Persistence.csproj" />
|
||||
<ProjectReference Include="..\..\__Libraries\StellaOps.Localization\StellaOps.Localization.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Translations\*.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Label="StellaOpsReleaseVersion">
|
||||
|
||||
@@ -36,4 +36,12 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| TASK-030-017 | BLOCKED | Stored procedures delivered; validation blocked pending ingestion datasets. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| PLATFORM-EF-03-WS | DONE | Sprint `docs/implplan/SPRINT_20260222_096_Platform_dal_to_efcore.md`: converted `PostgresEnvironmentSettingsStore` and `PostgresPlatformContextStore` to EF Core LINQ reads with `AsNoTracking()`, raw SQL upserts. Added `Microsoft.EntityFrameworkCore` package reference. |
|
||||
| SPRINT_20260224_001-LOC-002 | DONE | Sprint `docs/implplan/SPRINT_20260224_001_Platform_unified_translation_gap_closure.md`: added `057_PlatformTranslations.sql` migration and localization endpoint verification coverage. |
|
||||
| SPRINT_20260224_002-LOC-102 | DONE | Sprint `docs/implplan/SPRINT_20260224_002_Platform_translation_rollout_phase3_phase4.md`: added/aligned Platform `de-DE.ui.json` locale assets (including locale selector keys) for phase-4 second-locale rollout. |
|
||||
| SPRINT_20260224_004-LOC-301 | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added locale bundles for `bg-BG`, `ru-RU`, `es-ES`, `fr-FR`, `zh-TW`, `zh-CN` and locale label key coverage in Platform translation assets. |
|
||||
| SPRINT_20260224_004-LOC-302 | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added persisted user locale preference APIs (`GET/PUT /api/v1/platform/preferences/language`) and preference service normalization/persistence wiring. |
|
||||
| SPRINT_20260224_004-LOC-305 | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: completed locale storage parity by adding `*.platform.json` bundles for all supported locales and full `common` locale coverage in `StellaOps.Localization` translation assets; localization tests now assert common/platform key availability across supported locales. |
|
||||
| SPRINT_20260224_004-LOC-306 | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added dedicated `/settings/language` UX wiring that reuses Platform persisted language preference API for authenticated users. |
|
||||
| SPRINT_20260224_004-LOC-307 | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added Ukrainian locale support (`uk-UA`) across Platform translation assets and preference normalization aliases (`uk-UA`/`uk_UA`/`uk`/`ua`). |
|
||||
| SPRINT_20260224_004-LOC-308 | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: platform locale catalog endpoint (`GET /api/v1/platform/localization/locales`) is now consumed by both UI and CLI locale-selection paths. |
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"_meta": { "locale": "bg-BG", "namespace": "platform", "version": "1.0" },
|
||||
|
||||
"platform.health.status_healthy": "Vsichki sistemi rabotyat normalno.",
|
||||
"platform.health.status_degraded": "Nyakoi uslugi imat problemi.",
|
||||
"platform.health.status_unavailable": "Platformata v momenta ne e dostupna.",
|
||||
|
||||
"platform.quota.limit_exceeded": "Limitat na kvotata za {0} e nadhvurlen.",
|
||||
"platform.quota.usage_warning": "Izpolzvaneto e {0}% ot limita na kvotata.",
|
||||
"platform.quota.reset_at": "Kvotata se nulyava v {0}.",
|
||||
|
||||
"platform.onboarding.welcome": "Dobro doshli v StellaOps.",
|
||||
"platform.onboarding.step_authority": "Konfiguriraite dostavchik na identichnost.",
|
||||
"platform.onboarding.step_registry": "Svurzhete container registry.",
|
||||
"platform.onboarding.step_environments": "Definiraite tselevi sredi.",
|
||||
"platform.onboarding.step_complete": "Nastroikata e zavarshena. Gotovo za rabota.",
|
||||
|
||||
"platform.setup.required": "Predi izpolzvane na platformata e nuzhna nachalna nastroika.",
|
||||
"platform.setup.in_progress": "Nastroikata e v proces.",
|
||||
"platform.setup.complete": "Nastroikata e zavarshena.",
|
||||
|
||||
"platform.context.region_not_found": "Region {0} ne e nameren.",
|
||||
"platform.context.environment_not_found": "Sreda {0} ne e namerena.",
|
||||
|
||||
"platform.migration.started": "Migratsiyata zapochna.",
|
||||
"platform.migration.completed": "Migratsiyata priklyuchi uspeshno.",
|
||||
"platform.migration.failed": "Migratsiyata se provali: {0}."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"_meta": { "locale": "bg-BG", "namespace": "ui", "version": "1.0" },
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Ezik",
|
||||
"ui.locale.en_us": "Angliiski (USA)",
|
||||
"ui.locale.de_de": "Nemski (Germania)",
|
||||
"ui.locale.bg_bg": "Balgarski (Balgaria)",
|
||||
"ui.locale.ru_ru": "Ruski (Rusia)",
|
||||
"ui.locale.es_es": "Ispanski (Ispania)",
|
||||
"ui.locale.fr_fr": "Frenski (Francia)",
|
||||
"ui.locale.zh_tw": "Kitaiski tradicionen (Taiwan)",
|
||||
"ui.locale.zh_cn": "Kitaiski oprosten (Kitai)",
|
||||
"ui.locale.uk_ua": "Ukrainian (Ukraine)",
|
||||
"ui.settings.language.title": "Ezik",
|
||||
"ui.settings.language.subtitle": "Zadadeite predpochtaniya ezik na konzolata.",
|
||||
"ui.settings.language.description": "Promenite se prilagat vednaga v UI.",
|
||||
"ui.settings.language.selector_label": "Predpochtan ezik",
|
||||
"ui.settings.language.persisted": "Zapazeno za vashiya akaunt i preizpolzvano ot CLI.",
|
||||
"ui.settings.language.persisted_error": "Zapazeno lokalno, no sinkhronizatsiyata na akaunta se provali.",
|
||||
"ui.settings.language.sign_in_hint": "Vlezte v sistema, za da sinkhronizirate tazi nastroika s CLI.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Waiting for first signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Failed to load signal.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"_meta": { "locale": "de-DE", "namespace": "platform", "version": "1.0" },
|
||||
|
||||
"platform.health.status_healthy": "Alle Systeme betriebsbereit.",
|
||||
"platform.health.status_degraded": "Einige Dienste haben Probleme.",
|
||||
"platform.health.status_unavailable": "Die Plattform ist derzeit nicht verfuegbar.",
|
||||
|
||||
"platform.quota.limit_exceeded": "Kontingentgrenze fuer {0} ueberschritten.",
|
||||
"platform.quota.usage_warning": "Die Nutzung liegt bei {0}% des Kontingents.",
|
||||
"platform.quota.reset_at": "Kontingent wird um {0} zurueckgesetzt.",
|
||||
|
||||
"platform.onboarding.welcome": "Willkommen bei StellaOps.",
|
||||
"platform.onboarding.step_authority": "Identitaetsanbieter konfigurieren.",
|
||||
"platform.onboarding.step_registry": "Container-Registry verbinden.",
|
||||
"platform.onboarding.step_environments": "Zielumgebungen definieren.",
|
||||
"platform.onboarding.step_complete": "Einrichtung abgeschlossen. Bereit.",
|
||||
|
||||
"platform.setup.required": "Eine Ersteinrichtung ist vor der Nutzung erforderlich.",
|
||||
"platform.setup.in_progress": "Einrichtung laeuft.",
|
||||
"platform.setup.complete": "Einrichtung abgeschlossen.",
|
||||
|
||||
"platform.context.region_not_found": "Region {0} wurde nicht gefunden.",
|
||||
"platform.context.environment_not_found": "Umgebung {0} wurde nicht gefunden.",
|
||||
|
||||
"platform.migration.started": "Migration gestartet.",
|
||||
"platform.migration.completed": "Migration erfolgreich abgeschlossen.",
|
||||
"platform.migration.failed": "Migration fehlgeschlagen: {0}."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"_meta": { "locale": "de-DE", "namespace": "ui", "version": "1.0" },
|
||||
|
||||
"ui.loading.skeleton": "Wird geladen...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Etwas ist schiefgelaufen.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Speichern",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Abbrechen",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Erneut versuchen",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Frische Anmeldung: Aktiv",
|
||||
"ui.auth.fresh_stale": "Frische Anmeldung: Veraltet",
|
||||
"ui.locale.label": "Sprache",
|
||||
"ui.locale.en_us": "Englisch (USA)",
|
||||
"ui.locale.de_de": "Deutsch (Deutschland)",
|
||||
"ui.locale.bg_bg": "Bulgarisch (Bulgarien)",
|
||||
"ui.locale.ru_ru": "Russisch (Russland)",
|
||||
"ui.locale.es_es": "Spanisch (Spanien)",
|
||||
"ui.locale.fr_fr": "Franzoesisch (Frankreich)",
|
||||
"ui.locale.zh_tw": "Chinesisch (Traditionell, Taiwan)",
|
||||
"ui.locale.zh_cn": "Chinesisch (Vereinfacht, China)",
|
||||
"ui.locale.uk_ua": "Ukrainian (Ukraine)",
|
||||
"ui.settings.language.title": "Sprache",
|
||||
"ui.settings.language.subtitle": "Legen Sie Ihre bevorzugte Konsolensprache fest.",
|
||||
"ui.settings.language.description": "Aenderungen werden sofort in der UI angewendet.",
|
||||
"ui.settings.language.selector_label": "Bevorzugte Sprache",
|
||||
"ui.settings.language.persisted": "Fuer Ihr Konto gespeichert und im CLI wiederverwendet.",
|
||||
"ui.settings.language.persisted_error": "Lokal gespeichert, aber Kontosynchronisierung fehlgeschlagen.",
|
||||
"ui.settings.language.sign_in_hint": "Melden Sie sich an, um diese Einstellung mit dem CLI zu synchronisieren.",
|
||||
|
||||
"ui.first_signal.label": "Erstes Signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Warten auf erstes Signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Signal konnte nicht geladen werden.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"_meta": { "locale": "en-US", "namespace": "platform", "version": "1.0" },
|
||||
|
||||
"platform.health.status_healthy": "All systems operational.",
|
||||
"platform.health.status_degraded": "Some services are experiencing issues.",
|
||||
"platform.health.status_unavailable": "Platform is currently unavailable.",
|
||||
|
||||
"platform.quota.limit_exceeded": "Quota limit exceeded for {0}.",
|
||||
"platform.quota.usage_warning": "Usage is at {0}% of the quota limit.",
|
||||
"platform.quota.reset_at": "Quota resets at {0}.",
|
||||
|
||||
"platform.onboarding.welcome": "Welcome to StellaOps.",
|
||||
"platform.onboarding.step_authority": "Configure identity provider.",
|
||||
"platform.onboarding.step_registry": "Connect container registry.",
|
||||
"platform.onboarding.step_environments": "Define target environments.",
|
||||
"platform.onboarding.step_complete": "Setup complete. Ready to go.",
|
||||
|
||||
"platform.setup.required": "Initial setup is required before using the platform.",
|
||||
"platform.setup.in_progress": "Setup is in progress.",
|
||||
"platform.setup.complete": "Setup is complete.",
|
||||
|
||||
"platform.context.region_not_found": "Region {0} not found.",
|
||||
"platform.context.environment_not_found": "Environment {0} not found.",
|
||||
|
||||
"platform.migration.started": "Migration started.",
|
||||
"platform.migration.completed": "Migration completed successfully.",
|
||||
"platform.migration.failed": "Migration failed: {0}."
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"_meta": { "locale": "en-US", "namespace": "ui", "version": "1.0" },
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Locale",
|
||||
"ui.locale.en_us": "English (US)",
|
||||
"ui.locale.de_de": "German (Germany)",
|
||||
"ui.locale.bg_bg": "Bulgarian (Bulgaria)",
|
||||
"ui.locale.ru_ru": "Russian (Russia)",
|
||||
"ui.locale.es_es": "Spanish (Spain)",
|
||||
"ui.locale.fr_fr": "French (France)",
|
||||
"ui.locale.zh_tw": "Chinese (Traditional, Taiwan)",
|
||||
"ui.locale.zh_cn": "Chinese (Simplified, China)",
|
||||
"ui.locale.uk_ua": "Ukrainian (Ukraine)",
|
||||
"ui.settings.language.title": "Language",
|
||||
"ui.settings.language.subtitle": "Set your preferred console language.",
|
||||
"ui.settings.language.description": "Changes apply immediately in the UI.",
|
||||
"ui.settings.language.selector_label": "Preferred language",
|
||||
"ui.settings.language.persisted": "Saved for your account and reused by CLI.",
|
||||
"ui.settings.language.persisted_error": "Saved locally, but account sync failed.",
|
||||
"ui.settings.language.sign_in_hint": "Sign in to sync this preference with CLI.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Waiting for first signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Failed to load signal.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"_meta": { "locale": "es-ES", "namespace": "platform", "version": "1.0" },
|
||||
|
||||
"platform.health.status_healthy": "Todos los sistemas operan con normalidad.",
|
||||
"platform.health.status_degraded": "Algunos servicios presentan incidencias.",
|
||||
"platform.health.status_unavailable": "La plataforma no esta disponible en este momento.",
|
||||
|
||||
"platform.quota.limit_exceeded": "Se supero el limite de cuota para {0}.",
|
||||
"platform.quota.usage_warning": "El uso esta en {0}% del limite de cuota.",
|
||||
"platform.quota.reset_at": "La cuota se restablece en {0}.",
|
||||
|
||||
"platform.onboarding.welcome": "Bienvenido a StellaOps.",
|
||||
"platform.onboarding.step_authority": "Configura el proveedor de identidad.",
|
||||
"platform.onboarding.step_registry": "Conecta el registro de contenedores.",
|
||||
"platform.onboarding.step_environments": "Define los entornos de destino.",
|
||||
"platform.onboarding.step_complete": "Configuracion completada. Todo listo.",
|
||||
|
||||
"platform.setup.required": "Se requiere una configuracion inicial antes de usar la plataforma.",
|
||||
"platform.setup.in_progress": "La configuracion esta en curso.",
|
||||
"platform.setup.complete": "La configuracion esta completa.",
|
||||
|
||||
"platform.context.region_not_found": "No se encontro la region {0}.",
|
||||
"platform.context.environment_not_found": "No se encontro el entorno {0}.",
|
||||
|
||||
"platform.migration.started": "Migracion iniciada.",
|
||||
"platform.migration.completed": "Migracion completada correctamente.",
|
||||
"platform.migration.failed": "La migracion fallo: {0}."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"_meta": { "locale": "es-ES", "namespace": "ui", "version": "1.0" },
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Idioma",
|
||||
"ui.locale.en_us": "Ingles (EE. UU.)",
|
||||
"ui.locale.de_de": "Aleman (Alemania)",
|
||||
"ui.locale.bg_bg": "Bulgaro (Bulgaria)",
|
||||
"ui.locale.ru_ru": "Ruso (Rusia)",
|
||||
"ui.locale.es_es": "Espanol (Espana)",
|
||||
"ui.locale.fr_fr": "Frances (Francia)",
|
||||
"ui.locale.zh_tw": "Chino tradicional (Taiwan)",
|
||||
"ui.locale.zh_cn": "Chino simplificado (China)",
|
||||
"ui.locale.uk_ua": "Ukrainian (Ukraine)",
|
||||
"ui.settings.language.title": "Idioma",
|
||||
"ui.settings.language.subtitle": "Define tu idioma preferido de la consola.",
|
||||
"ui.settings.language.description": "Los cambios se aplican de inmediato en la UI.",
|
||||
"ui.settings.language.selector_label": "Idioma preferido",
|
||||
"ui.settings.language.persisted": "Guardado para tu cuenta y reutilizado por CLI.",
|
||||
"ui.settings.language.persisted_error": "Guardado localmente, pero fallo la sincronizacion de la cuenta.",
|
||||
"ui.settings.language.sign_in_hint": "Inicia sesion para sincronizar esta preferencia con CLI.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Waiting for first signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Failed to load signal.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"_meta": { "locale": "fr-FR", "namespace": "platform", "version": "1.0" },
|
||||
|
||||
"platform.health.status_healthy": "Tous les systemes sont operationnels.",
|
||||
"platform.health.status_degraded": "Certains services rencontrent des problemes.",
|
||||
"platform.health.status_unavailable": "La plateforme est actuellement indisponible.",
|
||||
|
||||
"platform.quota.limit_exceeded": "La limite de quota est depassee pour {0}.",
|
||||
"platform.quota.usage_warning": "L utilisation est a {0}% de la limite de quota.",
|
||||
"platform.quota.reset_at": "Le quota sera reinitialise a {0}.",
|
||||
|
||||
"platform.onboarding.welcome": "Bienvenue sur StellaOps.",
|
||||
"platform.onboarding.step_authority": "Configurez le fournisseur d identite.",
|
||||
"platform.onboarding.step_registry": "Connectez le registre de conteneurs.",
|
||||
"platform.onboarding.step_environments": "Definissez les environnements cibles.",
|
||||
"platform.onboarding.step_complete": "Configuration terminee. Pret a demarrer.",
|
||||
|
||||
"platform.setup.required": "Une configuration initiale est requise avant utilisation.",
|
||||
"platform.setup.in_progress": "La configuration est en cours.",
|
||||
"platform.setup.complete": "La configuration est terminee.",
|
||||
|
||||
"platform.context.region_not_found": "Region {0} introuvable.",
|
||||
"platform.context.environment_not_found": "Environnement {0} introuvable.",
|
||||
|
||||
"platform.migration.started": "Migration demarree.",
|
||||
"platform.migration.completed": "Migration terminee avec succes.",
|
||||
"platform.migration.failed": "Echec de la migration: {0}."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"_meta": { "locale": "fr-FR", "namespace": "ui", "version": "1.0" },
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Langue",
|
||||
"ui.locale.en_us": "Anglais (Etats-Unis)",
|
||||
"ui.locale.de_de": "Allemand (Allemagne)",
|
||||
"ui.locale.bg_bg": "Bulgare (Bulgarie)",
|
||||
"ui.locale.ru_ru": "Russe (Russie)",
|
||||
"ui.locale.es_es": "Espagnol (Espagne)",
|
||||
"ui.locale.fr_fr": "Francais (France)",
|
||||
"ui.locale.zh_tw": "Chinois traditionnel (Taiwan)",
|
||||
"ui.locale.zh_cn": "Chinois simplifie (Chine)",
|
||||
"ui.locale.uk_ua": "Ukrainian (Ukraine)",
|
||||
"ui.settings.language.title": "Langue",
|
||||
"ui.settings.language.subtitle": "Definissez votre langue de console preferee.",
|
||||
"ui.settings.language.description": "Les changements sont appliques immediatement dans l UI.",
|
||||
"ui.settings.language.selector_label": "Langue preferee",
|
||||
"ui.settings.language.persisted": "Enregistre pour votre compte et reutilise par le CLI.",
|
||||
"ui.settings.language.persisted_error": "Enregistre localement, mais la synchronisation du compte a echoue.",
|
||||
"ui.settings.language.sign_in_hint": "Connectez-vous pour synchroniser cette preference avec le CLI.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Waiting for first signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Failed to load signal.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"_meta": { "locale": "ru-RU", "namespace": "platform", "version": "1.0" },
|
||||
|
||||
"platform.health.status_healthy": "Vse sistemy rabotayut normalno.",
|
||||
"platform.health.status_degraded": "U nekotoryh servisov est problemy.",
|
||||
"platform.health.status_unavailable": "Platforma vremenno nedostupna.",
|
||||
|
||||
"platform.quota.limit_exceeded": "Limit kvoty dlya {0} prevyshen.",
|
||||
"platform.quota.usage_warning": "Ispolzovanie sostavlyaet {0}% ot limita kvoty.",
|
||||
"platform.quota.reset_at": "Kvota budet sbroshena v {0}.",
|
||||
|
||||
"platform.onboarding.welcome": "Dobro pozhalovat v StellaOps.",
|
||||
"platform.onboarding.step_authority": "Nastroyte postavshchika identichnosti.",
|
||||
"platform.onboarding.step_registry": "Podklyuchite registry konteynerov.",
|
||||
"platform.onboarding.step_environments": "Opredelite tselevye sredy.",
|
||||
"platform.onboarding.step_complete": "Nastroyka zavershena. Mozhno nachinat rabotu.",
|
||||
|
||||
"platform.setup.required": "Pered ispolzovaniem platformy trebuetsya nachalnaya nastroyka.",
|
||||
"platform.setup.in_progress": "Nastroyka vypolnyaetsya.",
|
||||
"platform.setup.complete": "Nastroyka zavershena.",
|
||||
|
||||
"platform.context.region_not_found": "Region {0} ne naiden.",
|
||||
"platform.context.environment_not_found": "Sreda {0} ne naidena.",
|
||||
|
||||
"platform.migration.started": "Migratsiya zapushchena.",
|
||||
"platform.migration.completed": "Migratsiya uspeshno zavershena.",
|
||||
"platform.migration.failed": "Migratsiya zavershilas oshibkoy: {0}."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"_meta": { "locale": "ru-RU", "namespace": "ui", "version": "1.0" },
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Yazyk",
|
||||
"ui.locale.en_us": "Angliyskiy (USA)",
|
||||
"ui.locale.de_de": "Nemetskiy (Germaniya)",
|
||||
"ui.locale.bg_bg": "Bolgarskiy (Bolgariya)",
|
||||
"ui.locale.ru_ru": "Russkiy (Rossiya)",
|
||||
"ui.locale.es_es": "Ispanskiy (Ispaniya)",
|
||||
"ui.locale.fr_fr": "Frantsuzskiy (Frantsiya)",
|
||||
"ui.locale.zh_tw": "Kitayskiy tradicionnyy (Taiwan)",
|
||||
"ui.locale.zh_cn": "Kitayskiy uproshchennyy (Kitay)",
|
||||
"ui.locale.uk_ua": "Ukrainian (Ukraine)",
|
||||
"ui.settings.language.title": "Yazyk",
|
||||
"ui.settings.language.subtitle": "Vyberite predpochtitelnyy yazyk konsoli.",
|
||||
"ui.settings.language.description": "Izmeneniya primenyayutsya srazu v UI.",
|
||||
"ui.settings.language.selector_label": "Predpochtitelnyy yazyk",
|
||||
"ui.settings.language.persisted": "Sohraneno dlya vashego akkaunta i ispolzuetsya v CLI.",
|
||||
"ui.settings.language.persisted_error": "Lokalno sohraneno, no sinkhronizatsiya akkaunta ne udalas.",
|
||||
"ui.settings.language.sign_in_hint": "Vypolnite vkhod, chtoby sinkhronizirovat etu nastroiku s CLI.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Waiting for first signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Failed to load signal.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"_meta": { "locale": "uk-UA", "namespace": "platform", "version": "1.0" },
|
||||
|
||||
"platform.health.status_healthy": "Usi systemy pratsiuiut normalno.",
|
||||
"platform.health.status_degraded": "Deiaki servisy maiut problemy.",
|
||||
"platform.health.status_unavailable": "Platforma tymchasovo nedostupna.",
|
||||
|
||||
"platform.quota.limit_exceeded": "Perevyshcheno limit kvoty dlia {0}.",
|
||||
"platform.quota.usage_warning": "Vykorystannia stanovyt {0}% vid limitu kvoty.",
|
||||
"platform.quota.reset_at": "Kvota bude skynuta o {0}.",
|
||||
|
||||
"platform.onboarding.welcome": "Laskavo prosymo do StellaOps.",
|
||||
"platform.onboarding.step_authority": "Nalashtuite providera identyfikatsii.",
|
||||
"platform.onboarding.step_registry": "Pidkliuchit reiestr kontejneriv.",
|
||||
"platform.onboarding.step_environments": "Vyznachit cilovi seredovyshcha.",
|
||||
"platform.onboarding.step_complete": "Nalashtuvannia zaversheno. Mozhna pochaty robotu.",
|
||||
|
||||
"platform.setup.required": "Pered vykorystanniam platformy potribne pochatkove nalashtuvannia.",
|
||||
"platform.setup.in_progress": "Nalashtuvannia vykonuietsia.",
|
||||
"platform.setup.complete": "Nalashtuvannia zaversheno.",
|
||||
|
||||
"platform.context.region_not_found": "Region {0} ne znahdeno.",
|
||||
"platform.context.environment_not_found": "Seredovyshche {0} ne znahdeno.",
|
||||
|
||||
"platform.migration.started": "Mihratsiiu zapushcheno.",
|
||||
"platform.migration.completed": "Mihratsiiu uspishno zaversheno.",
|
||||
"platform.migration.failed": "Pomylka mihratsii: {0}."
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"_meta": { "locale": "uk-UA", "namespace": "ui", "version": "1.0" },
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Mova",
|
||||
"ui.locale.en_us": "Angliiska (SSHA)",
|
||||
"ui.locale.de_de": "Nimetska (Nimechchyna)",
|
||||
"ui.locale.bg_bg": "Bolgarska (Bolhariia)",
|
||||
"ui.locale.ru_ru": "Rosiiska (Rosiia)",
|
||||
"ui.locale.es_es": "Ispanska (Ispaniia)",
|
||||
"ui.locale.fr_fr": "Frantsuzka (Frantsiia)",
|
||||
"ui.locale.zh_tw": "Kytaiska tradytsiina (Taivan)",
|
||||
"ui.locale.zh_cn": "Kytaiska sproshchena (Kytai)",
|
||||
"ui.locale.uk_ua": "Ukrainska (Ukraina)",
|
||||
"ui.settings.language.title": "Mova",
|
||||
"ui.settings.language.subtitle": "Vstanovit bazhanu movu konsoli.",
|
||||
"ui.settings.language.description": "Zminy zastosovuiutsia v UI odrazu.",
|
||||
"ui.settings.language.selector_label": "Bazhana mova",
|
||||
"ui.settings.language.persisted": "Zberezheno dlia vashoho oblikovoho zapysu ta povtorno vykorystovuietsia v CLI.",
|
||||
"ui.settings.language.persisted_error": "Lokalno zberezheno, ale synkhronizatsiia oblikovoho zapysu ne vdlasia.",
|
||||
"ui.settings.language.sign_in_hint": "Uvijdit, shchob synkhronizuvaty tsiu nalashtunku z CLI.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Waiting for first signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Failed to load signal.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"_meta": { "locale": "zh-CN", "namespace": "platform", "version": "1.0" },
|
||||
|
||||
"platform.health.status_healthy": "Suoyou xitong jun yunxing zhengchang.",
|
||||
"platform.health.status_degraded": "Bufen fuwu cunzai wenti.",
|
||||
"platform.health.status_unavailable": "Dangqian pingtai zan bu ke yong.",
|
||||
|
||||
"platform.quota.limit_exceeded": "{0} de peie xianzhi yi chaoguo.",
|
||||
"platform.quota.usage_warning": "Shiyongliang yi daoda peie shangxian de {0}%.",
|
||||
"platform.quota.reset_at": "Peie jiang zai {0} chongzhi.",
|
||||
|
||||
"platform.onboarding.welcome": "Huanying shiyong StellaOps.",
|
||||
"platform.onboarding.step_authority": "Qing peizhi shenfen tigongfang.",
|
||||
"platform.onboarding.step_registry": "Qing lianjie rongqi cangku.",
|
||||
"platform.onboarding.step_environments": "Qing dingyi mubiao huanjing.",
|
||||
"platform.onboarding.step_complete": "Shezhi yi wancheng. Keyi kaishi.",
|
||||
|
||||
"platform.setup.required": "Shiyong pingtai qian bixu xian wancheng chushi shezhi.",
|
||||
"platform.setup.in_progress": "Shezhi zhengzai jinxing.",
|
||||
"platform.setup.complete": "Shezhi yi wancheng.",
|
||||
|
||||
"platform.context.region_not_found": "Wei zhaodao quyu {0}.",
|
||||
"platform.context.environment_not_found": "Wei zhaodao huanjing {0}.",
|
||||
|
||||
"platform.migration.started": "Qianyi yi qidong.",
|
||||
"platform.migration.completed": "Qianyi chenggong wancheng.",
|
||||
"platform.migration.failed": "Qianyi shibai: {0}."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"_meta": { "locale": "zh-CN", "namespace": "ui", "version": "1.0" },
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Language",
|
||||
"ui.locale.en_us": "English (US)",
|
||||
"ui.locale.de_de": "German (Germany)",
|
||||
"ui.locale.bg_bg": "Bulgarian (Bulgaria)",
|
||||
"ui.locale.ru_ru": "Russian (Russia)",
|
||||
"ui.locale.es_es": "Spanish (Spain)",
|
||||
"ui.locale.fr_fr": "French (France)",
|
||||
"ui.locale.zh_tw": "Chinese Traditional (Taiwan)",
|
||||
"ui.locale.zh_cn": "Chinese Simplified (China)",
|
||||
"ui.locale.uk_ua": "Ukrainian (Ukraine)",
|
||||
"ui.settings.language.title": "Yuyan",
|
||||
"ui.settings.language.subtitle": "Shezhi nin shouxuan de kongzhi tai yuyan.",
|
||||
"ui.settings.language.description": "Genggai hui liji yingyong dao UI.",
|
||||
"ui.settings.language.selector_label": "Shouxuan yuyan",
|
||||
"ui.settings.language.persisted": "Yi baocun dao nin de zhanghu bing zai CLI zhong chongyong.",
|
||||
"ui.settings.language.persisted_error": "Yi ben di baocun, dan zhanghu tongbu shibai.",
|
||||
"ui.settings.language.sign_in_hint": "Qing denglu yi jiang ci pianhao tongbu dao CLI.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Waiting for first signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Failed to load signal.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"_meta": { "locale": "zh-TW", "namespace": "platform", "version": "1.0" },
|
||||
|
||||
"platform.health.status_healthy": "Suoyou xitong jun yunxing zhengchang.",
|
||||
"platform.health.status_degraded": "Bufen fuwu zhengzai fasheng wenti.",
|
||||
"platform.health.status_unavailable": "Muqian pingtai zan shi wufa shiyong.",
|
||||
|
||||
"platform.quota.limit_exceeded": "{0} de peie xianzhi yi chaochu.",
|
||||
"platform.quota.usage_warning": "Shiyongliang yi daoda peie shangxian de {0}%.",
|
||||
"platform.quota.reset_at": "Peie jiang zai {0} chongzhi.",
|
||||
|
||||
"platform.onboarding.welcome": "Huanying shiyong StellaOps.",
|
||||
"platform.onboarding.step_authority": "Qing shezhi shenfen tigongzhe.",
|
||||
"platform.onboarding.step_registry": "Qing lianjie rongqi cangku.",
|
||||
"platform.onboarding.step_environments": "Qing dingyi mubiao huanjing.",
|
||||
"platform.onboarding.step_complete": "Shezhi yi wancheng. Keyi kaishi.",
|
||||
|
||||
"platform.setup.required": "Shiyong pingtai qian bixu xian wancheng chushi shezhi.",
|
||||
"platform.setup.in_progress": "Shezhi jinxing zhong.",
|
||||
"platform.setup.complete": "Shezhi yi wancheng.",
|
||||
|
||||
"platform.context.region_not_found": "Zhao bu dao quyu {0}.",
|
||||
"platform.context.environment_not_found": "Zhao bu dao huanjing {0}.",
|
||||
|
||||
"platform.migration.started": "Qianyi yi kaishi.",
|
||||
"platform.migration.completed": "Qianyi chenggong wancheng.",
|
||||
"platform.migration.failed": "Qianyi shi bai: {0}."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
{
|
||||
"_meta": { "locale": "zh-TW", "namespace": "ui", "version": "1.0" },
|
||||
|
||||
"ui.loading.skeleton": "Loading...",
|
||||
"ui.loading.spinner": "Please wait...",
|
||||
"ui.loading.slow": "This is taking longer than expected...",
|
||||
|
||||
"ui.error.generic": "Something went wrong.",
|
||||
"ui.error.network": "Network error. Check your connection.",
|
||||
"ui.error.timeout": "Request timed out. Please try again.",
|
||||
"ui.error.not_found": "The requested resource was not found.",
|
||||
"ui.error.unauthorized": "You don't have permission to view this.",
|
||||
"ui.error.server_error": "Server error. Please try again later.",
|
||||
"ui.error.try_again": "Try again",
|
||||
"ui.error.go_back": "Go back",
|
||||
|
||||
"ui.offline.banner": "You're offline.",
|
||||
"ui.offline.description": "Some features may be unavailable.",
|
||||
"ui.offline.reconnecting": "Reconnecting...",
|
||||
"ui.offline.reconnected": "Back online.",
|
||||
|
||||
"ui.toast.success": "Success",
|
||||
"ui.toast.info": "Info",
|
||||
"ui.toast.warning": "Warning",
|
||||
"ui.toast.error": "Error",
|
||||
"ui.toast.dismiss": "Dismiss",
|
||||
"ui.toast.undo": "Undo",
|
||||
|
||||
"ui.actions.save": "Save",
|
||||
"ui.actions.saving": "Saving...",
|
||||
"ui.actions.saved": "Saved",
|
||||
"ui.actions.cancel": "Cancel",
|
||||
"ui.actions.confirm": "Confirm",
|
||||
"ui.actions.delete": "Delete",
|
||||
"ui.actions.deleting": "Deleting...",
|
||||
"ui.actions.deleted": "Deleted",
|
||||
"ui.actions.submit": "Submit",
|
||||
"ui.actions.submitting": "Submitting...",
|
||||
"ui.actions.submitted": "Submitted",
|
||||
"ui.actions.close": "Close",
|
||||
"ui.actions.expand": "Expand",
|
||||
"ui.actions.collapse": "Collapse",
|
||||
"ui.actions.show_more": "Show more",
|
||||
"ui.actions.show_less": "Show less",
|
||||
"ui.actions.retry": "Retry",
|
||||
"ui.actions.refresh": "Refresh",
|
||||
"ui.actions.export": "Export",
|
||||
"ui.actions.search": "Search",
|
||||
"ui.actions.clear": "Clear",
|
||||
"ui.actions.view": "View",
|
||||
"ui.actions.dismiss": "Dismiss",
|
||||
"ui.actions.show": "Show",
|
||||
"ui.actions.hide": "Hide",
|
||||
"ui.actions.sign_in": "Sign in",
|
||||
"ui.actions.back_to_list": "Back to list",
|
||||
"ui.actions.load_more": "Load more",
|
||||
|
||||
"ui.labels.all": "All",
|
||||
"ui.labels.title": "Title",
|
||||
"ui.labels.description": "Description",
|
||||
"ui.labels.status": "Status",
|
||||
"ui.labels.score": "Score",
|
||||
"ui.labels.severity": "Severity",
|
||||
"ui.labels.details": "Details",
|
||||
"ui.labels.actions": "Actions",
|
||||
"ui.labels.type": "Type",
|
||||
"ui.labels.tags": "Tags",
|
||||
"ui.labels.filters": "Filters",
|
||||
"ui.labels.updated": "Updated",
|
||||
"ui.labels.showing": "Showing",
|
||||
"ui.labels.of": "of",
|
||||
"ui.labels.total": "Total",
|
||||
"ui.labels.not_applicable": "n/a",
|
||||
"ui.labels.selected": "selected",
|
||||
"ui.labels.last_updated": "Last updated:",
|
||||
"ui.labels.expires": "Expires",
|
||||
|
||||
"ui.validation.required": "This field is required.",
|
||||
"ui.validation.invalid": "Invalid value.",
|
||||
"ui.validation.too_long": "Maximum {max} characters allowed.",
|
||||
"ui.validation.too_short": "Minimum {min} characters required.",
|
||||
"ui.validation.invalid_email": "Please enter a valid email address.",
|
||||
"ui.validation.invalid_url": "Please enter a valid URL.",
|
||||
|
||||
"ui.a11y.loading": "Content is loading.",
|
||||
"ui.a11y.loaded": "Content loaded.",
|
||||
"ui.a11y.error": "An error occurred.",
|
||||
"ui.a11y.expanded": "Expanded",
|
||||
"ui.a11y.collapsed": "Collapsed",
|
||||
"ui.a11y.selected": "Selected",
|
||||
"ui.a11y.deselected": "Deselected",
|
||||
"ui.a11y.required": "Required field",
|
||||
"ui.a11y.optional": "Optional",
|
||||
|
||||
"ui.motion.reduced": "Animations reduced.",
|
||||
"ui.motion.enabled": "Animations enabled.",
|
||||
|
||||
"ui.auth.fresh_active": "Fresh auth: Active",
|
||||
"ui.auth.fresh_stale": "Fresh auth: Stale",
|
||||
"ui.locale.label": "Language",
|
||||
"ui.locale.en_us": "English (US)",
|
||||
"ui.locale.de_de": "German (Germany)",
|
||||
"ui.locale.bg_bg": "Bulgarian (Bulgaria)",
|
||||
"ui.locale.ru_ru": "Russian (Russia)",
|
||||
"ui.locale.es_es": "Spanish (Spain)",
|
||||
"ui.locale.fr_fr": "French (France)",
|
||||
"ui.locale.zh_tw": "Chinese Traditional (Taiwan)",
|
||||
"ui.locale.zh_cn": "Chinese Simplified (China)",
|
||||
"ui.locale.uk_ua": "Ukrainian (Ukraine)",
|
||||
"ui.settings.language.title": "Yuyan",
|
||||
"ui.settings.language.subtitle": "Shezhi nin pianhao de kongzhi tai yuyan.",
|
||||
"ui.settings.language.description": "Biangeng hui liji shengxiao zai UI.",
|
||||
"ui.settings.language.selector_label": "Pianhao yuyan",
|
||||
"ui.settings.language.persisted": "Yijing baocun dao zhanghu bing gong CLI chongyong.",
|
||||
"ui.settings.language.persisted_error": "Yijing benji baocun, dan zhanghu tongbu shibai.",
|
||||
"ui.settings.language.sign_in_hint": "Qing dengru yi jiang ci pianhao tongbu dao CLI.",
|
||||
|
||||
"ui.first_signal.label": "First signal",
|
||||
"ui.first_signal.run_prefix": "Run:",
|
||||
"ui.first_signal.live": "Live",
|
||||
"ui.first_signal.polling": "Polling",
|
||||
"ui.first_signal.range_prefix": "Range",
|
||||
"ui.first_signal.range_separator": "\u2013",
|
||||
"ui.first_signal.stage_separator": " \u00b7 ",
|
||||
"ui.first_signal.waiting": "Waiting for first signal\u2026",
|
||||
"ui.first_signal.not_available": "Signal not available yet.",
|
||||
"ui.first_signal.offline": "Offline. Last known signal may be stale.",
|
||||
"ui.first_signal.failed": "Failed to load signal.",
|
||||
"ui.first_signal.retry": "Retry",
|
||||
"ui.first_signal.try_again": "Try again",
|
||||
"ui.first_signal.kind.queued": "Queued",
|
||||
"ui.first_signal.kind.started": "Started",
|
||||
"ui.first_signal.kind.phase": "In progress",
|
||||
"ui.first_signal.kind.blocked": "Blocked",
|
||||
"ui.first_signal.kind.failed": "Failed",
|
||||
"ui.first_signal.kind.succeeded": "Succeeded",
|
||||
"ui.first_signal.kind.canceled": "Canceled",
|
||||
"ui.first_signal.kind.unavailable": "Unavailable",
|
||||
"ui.first_signal.kind.unknown": "Signal",
|
||||
"ui.first_signal.stage.resolve": "Resolving",
|
||||
"ui.first_signal.stage.fetch": "Fetching",
|
||||
"ui.first_signal.stage.restore": "Restoring",
|
||||
"ui.first_signal.stage.analyze": "Analyzing",
|
||||
"ui.first_signal.stage.policy": "Evaluating policy",
|
||||
"ui.first_signal.stage.report": "Generating report",
|
||||
"ui.first_signal.stage.unknown": "Processing",
|
||||
"ui.first_signal.aria.card_label": "First signal status",
|
||||
|
||||
"ui.severity.critical": "Critical",
|
||||
"ui.severity.high": "High",
|
||||
"ui.severity.medium": "Medium",
|
||||
"ui.severity.low": "Low",
|
||||
"ui.severity.info": "Info",
|
||||
"ui.severity.none": "None",
|
||||
|
||||
"ui.release_orchestrator.title": "Release Orchestrator",
|
||||
"ui.release_orchestrator.subtitle": "Pipeline overview and release management",
|
||||
"ui.release_orchestrator.pipeline_runs": "Pipeline Runs",
|
||||
"ui.release_orchestrator.refresh_dashboard": "Refresh dashboard",
|
||||
|
||||
"ui.risk_dashboard.eyebrow": "Gateway \u00b7 Risk",
|
||||
"ui.risk_dashboard.title": "Risk Profiles",
|
||||
"ui.risk_dashboard.subtitle": "Tenant-scoped risk posture with deterministic ordering.",
|
||||
"ui.risk_dashboard.up_to_date": "Up to date",
|
||||
"ui.risk_dashboard.last_computation": "Last Computation",
|
||||
"ui.risk_dashboard.search_placeholder": "Title contains",
|
||||
"ui.risk_dashboard.evaluated": "Evaluated",
|
||||
"ui.risk_dashboard.risks_suffix": "risks.",
|
||||
"ui.risk_dashboard.error_unable_to_load": "Unable to load risk profiles.",
|
||||
"ui.risk_dashboard.no_risks_found": "No risks found for current filters.",
|
||||
"ui.risk_dashboard.loading_risks": "Loading risks\u2026",
|
||||
|
||||
"ui.findings.title": "Findings",
|
||||
"ui.findings.search_placeholder": "Search findings...",
|
||||
"ui.findings.clear_filters": "Clear Filters",
|
||||
"ui.findings.bulk_triage": "Bulk Triage",
|
||||
"ui.findings.export_all": "Export all findings",
|
||||
"ui.findings.export_selected": "Export selected findings",
|
||||
"ui.findings.select_all": "Select all findings",
|
||||
"ui.findings.trust": "Trust",
|
||||
"ui.findings.advisory": "Advisory",
|
||||
"ui.findings.package": "Package",
|
||||
"ui.findings.flags": "Flags",
|
||||
"ui.findings.why": "Why",
|
||||
"ui.findings.select": "Select",
|
||||
"ui.findings.no_findings": "No findings to display.",
|
||||
"ui.findings.no_match": "No findings match the current filters.",
|
||||
|
||||
"ui.sources_dashboard.title": "Sources Dashboard",
|
||||
"ui.sources_dashboard.verifying": "Verifying...",
|
||||
"ui.sources_dashboard.verify_24h": "Verify last 24h",
|
||||
"ui.sources_dashboard.loading_aoc": "Loading AOC metrics...",
|
||||
"ui.sources_dashboard.pass_fail_title": "AOC Pass/Fail",
|
||||
"ui.sources_dashboard.pass_rate": "Pass Rate",
|
||||
"ui.sources_dashboard.passed": "Passed",
|
||||
"ui.sources_dashboard.failed": "Failed",
|
||||
"ui.sources_dashboard.recent_violations": "Recent Violations",
|
||||
"ui.sources_dashboard.no_violations": "No violations in time window",
|
||||
"ui.sources_dashboard.throughput_title": "Ingest Throughput",
|
||||
"ui.sources_dashboard.docs_per_min": "docs/min",
|
||||
"ui.sources_dashboard.avg_ms": "avg ms",
|
||||
"ui.sources_dashboard.p95_ms": "p95 ms",
|
||||
"ui.sources_dashboard.queue": "queue",
|
||||
"ui.sources_dashboard.errors": "errors",
|
||||
"ui.sources_dashboard.verification_complete": "Verification Complete",
|
||||
"ui.sources_dashboard.checked": "Checked:",
|
||||
"ui.sources_dashboard.violations": "violation(s)",
|
||||
"ui.sources_dashboard.field": "Field:",
|
||||
"ui.sources_dashboard.expected": "expected:",
|
||||
"ui.sources_dashboard.actual": "actual:",
|
||||
"ui.sources_dashboard.cli_equivalent": "CLI equivalent:",
|
||||
"ui.sources_dashboard.data_from": "Data from",
|
||||
"ui.sources_dashboard.to": "to",
|
||||
"ui.sources_dashboard.hour_window": "h window",
|
||||
|
||||
"ui.timeline.title": "Timeline",
|
||||
"ui.timeline.event_timeline": "Event Timeline",
|
||||
"ui.timeline.refresh_timeline": "Refresh timeline",
|
||||
"ui.timeline.loading": "Loading timeline...",
|
||||
"ui.timeline.empty_state": "Enter a correlation ID to view the event timeline",
|
||||
"ui.timeline.critical_path": "Critical path analysis",
|
||||
"ui.timeline.causal_lanes": "Event causal lanes",
|
||||
"ui.timeline.load_more": "Load more events",
|
||||
"ui.timeline.event_details": "Event details",
|
||||
"ui.timeline.events": "events",
|
||||
|
||||
"ui.exception_center.title": "Exception Center",
|
||||
"ui.exception_center.list_view": "List view",
|
||||
"ui.exception_center.kanban_view": "Kanban view",
|
||||
"ui.exception_center.new_exception": "+ New Exception",
|
||||
"ui.exception_center.search_placeholder": "Search exceptions...",
|
||||
"ui.exception_center.type_vulnerability": "vulnerability",
|
||||
"ui.exception_center.type_license": "license",
|
||||
"ui.exception_center.type_policy": "policy",
|
||||
"ui.exception_center.type_entropy": "entropy",
|
||||
"ui.exception_center.type_determinism": "determinism",
|
||||
"ui.exception_center.expiring_soon": "Expiring soon",
|
||||
"ui.exception_center.clear_filters": "Clear filters",
|
||||
"ui.exception_center.audit_label": "[A]",
|
||||
"ui.exception_center.audit_title": "View audit log",
|
||||
"ui.exception_center.no_exceptions": "No exceptions match the current filters",
|
||||
"ui.exception_center.column_empty": "No exceptions",
|
||||
"ui.exception_center.exceptions_suffix": "exceptions",
|
||||
|
||||
"ui.evidence_thread.back_to_list": "Back to list",
|
||||
"ui.evidence_thread.title_default": "Evidence Thread",
|
||||
"ui.evidence_thread.copy_digest": "Copy full digest",
|
||||
"ui.evidence_thread.risk_label": "Risk:",
|
||||
"ui.evidence_thread.nodes": "nodes",
|
||||
"ui.evidence_thread.loading": "Loading evidence thread...",
|
||||
"ui.evidence_thread.graph_tab": "Graph",
|
||||
"ui.evidence_thread.timeline_tab": "Timeline",
|
||||
"ui.evidence_thread.transcript_tab": "Transcript",
|
||||
"ui.evidence_thread.not_found": "No evidence thread found for this artifact.",
|
||||
|
||||
"ui.vulnerability_detail.eyebrow": "Vulnerability",
|
||||
"ui.vulnerability_detail.cvss": "CVSS",
|
||||
"ui.vulnerability_detail.impact_first": "Impact First",
|
||||
"ui.vulnerability_detail.epss": "EPSS",
|
||||
"ui.vulnerability_detail.kev": "KEV",
|
||||
"ui.vulnerability_detail.kev_listed": "Listed",
|
||||
"ui.vulnerability_detail.kev_not_listed": "Not listed",
|
||||
"ui.vulnerability_detail.reachability": "Reachability",
|
||||
"ui.vulnerability_detail.blast_radius": "Blast Radius",
|
||||
"ui.vulnerability_detail.assets": "assets",
|
||||
"ui.vulnerability_detail.binary_resolution": "Binary Resolution",
|
||||
"ui.vulnerability_detail.evidence_suffix": "evidence",
|
||||
"ui.vulnerability_detail.fingerprint_note": "This binary was identified as patched using fingerprint analysis, not just version matching.",
|
||||
"ui.vulnerability_detail.affected_components": "Affected Components",
|
||||
"ui.vulnerability_detail.fix": "fix",
|
||||
"ui.vulnerability_detail.evidence_tree": "Evidence Tree and Citation Links",
|
||||
"ui.vulnerability_detail.evidence_explorer": "evidence explorer",
|
||||
"ui.vulnerability_detail.references": "References",
|
||||
"ui.vulnerability_detail.back_to_risk": "Back to Risk"
|
||||
}
|
||||
@@ -23,6 +23,10 @@ public partial class PlatformDbContext : DbContext
|
||||
|
||||
public virtual DbSet<UiContextPreference> UiContextPreferences { get; set; }
|
||||
|
||||
public virtual DbSet<PlatformTranslation> Translations { get; set; }
|
||||
|
||||
public virtual DbSet<IdentityProviderConfig> IdentityProviderConfigs { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
var schemaName = _schemaName;
|
||||
@@ -111,6 +115,88 @@ public partial class PlatformDbContext : DbContext
|
||||
.HasColumnName("updated_by");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlatformTranslation>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("translations_pkey");
|
||||
|
||||
entity.ToTable("translations", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.Locale, e.Key }, "ux_translations_tenant_locale_key")
|
||||
.IsUnique();
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.Locale }, "ix_translations_tenant_locale");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.HasColumnName("id")
|
||||
.UseIdentityAlwaysColumn();
|
||||
entity.Property(e => e.Locale)
|
||||
.HasMaxLength(10)
|
||||
.HasColumnName("locale");
|
||||
entity.Property(e => e.Key)
|
||||
.HasMaxLength(512)
|
||||
.HasColumnName("key");
|
||||
entity.Property(e => e.Value)
|
||||
.HasColumnName("value");
|
||||
entity.Property(e => e.TenantId)
|
||||
.HasMaxLength(128)
|
||||
.HasDefaultValue("_system")
|
||||
.HasColumnName("tenant_id");
|
||||
entity.Property(e => e.UpdatedBy)
|
||||
.HasMaxLength(256)
|
||||
.HasColumnName("updated_by");
|
||||
entity.Property(e => e.UpdatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("updated_at");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<IdentityProviderConfig>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("identity_provider_configs_pkey");
|
||||
|
||||
entity.ToTable("identity_provider_configs", schemaName);
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.Name }, "ux_idp_configs_tenant_name")
|
||||
.IsUnique();
|
||||
|
||||
entity.HasIndex(e => new { e.TenantId, e.Type }, "ix_idp_configs_tenant_type");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.HasColumnName("id")
|
||||
.HasDefaultValueSql("gen_random_uuid()");
|
||||
entity.Property(e => e.TenantId)
|
||||
.HasMaxLength(128)
|
||||
.HasColumnName("tenant_id");
|
||||
entity.Property(e => e.Name)
|
||||
.HasMaxLength(256)
|
||||
.HasColumnName("name");
|
||||
entity.Property(e => e.Type)
|
||||
.HasMaxLength(50)
|
||||
.HasColumnName("type");
|
||||
entity.Property(e => e.Enabled)
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
entity.Property(e => e.ConfigurationJson)
|
||||
.HasColumnType("jsonb")
|
||||
.HasColumnName("configuration_json");
|
||||
entity.Property(e => e.Description)
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnName("description");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt)
|
||||
.HasDefaultValueSql("now()")
|
||||
.HasColumnName("updated_at");
|
||||
entity.Property(e => e.CreatedBy)
|
||||
.HasMaxLength(256)
|
||||
.HasDefaultValueSql("'system'")
|
||||
.HasColumnName("created_by");
|
||||
entity.Property(e => e.UpdatedBy)
|
||||
.HasMaxLength(256)
|
||||
.HasDefaultValueSql("'system'")
|
||||
.HasColumnName("updated_by");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Platform.Database.EfCore.Models;
|
||||
|
||||
public partial class IdentityProviderConfig
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string TenantId { get; set; } = null!;
|
||||
|
||||
public string Name { get; set; } = null!;
|
||||
|
||||
public string Type { get; set; } = null!;
|
||||
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
public string ConfigurationJson { get; set; } = null!;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
|
||||
public string CreatedBy { get; set; } = null!;
|
||||
|
||||
public string UpdatedBy { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Platform.Database.EfCore.Models;
|
||||
|
||||
public partial class PlatformTranslation
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public string Locale { get; set; } = null!;
|
||||
|
||||
public string Key { get; set; } = null!;
|
||||
|
||||
public string Value { get; set; } = null!;
|
||||
|
||||
public string TenantId { get; set; } = "_system";
|
||||
|
||||
public string UpdatedBy { get; set; } = null!;
|
||||
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
-- SPRINT_20260224_001 / LOC-002
|
||||
-- Platform localization overrides table used by /platform/i18n and /api/v1/platform/localization/*.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS platform;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS platform.translations (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
locale VARCHAR(10) NOT NULL,
|
||||
key VARCHAR(512) NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
tenant_id VARCHAR(128) NOT NULL DEFAULT '_system',
|
||||
updated_by VARCHAR(256) NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT ux_translations_tenant_locale_key UNIQUE (tenant_id, locale, key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_translations_tenant_locale
|
||||
ON platform.translations (tenant_id, locale);
|
||||
@@ -0,0 +1,347 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class IdentityProviderEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory factory;
|
||||
|
||||
public IdentityProviderEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
private HttpClient CreateClient(string tenantId = "tenant-idp", string actorId = "actor-idp")
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", tenantId);
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", actorId);
|
||||
return client;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task List_ReturnsEmptyForNewTenant()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-empty");
|
||||
|
||||
var items = await client.GetFromJsonAsync<List<IdentityProviderConfigDto>>(
|
||||
"/api/v1/platform/identity-providers",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(items);
|
||||
Assert.Empty(items!);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task CrudLifecycle_LdapProvider()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-crud");
|
||||
|
||||
// Create
|
||||
var createRequest = new CreateIdentityProviderRequest(
|
||||
"test-ldap",
|
||||
"ldap",
|
||||
true,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = "ldap.example.com",
|
||||
["port"] = "389",
|
||||
["bindDn"] = "cn=admin,dc=example,dc=com",
|
||||
["bindPassword"] = "secret",
|
||||
["searchBase"] = "dc=example,dc=com"
|
||||
},
|
||||
"Test LDAP provider");
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
createRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<IdentityProviderConfigDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("test-ldap", created!.Name);
|
||||
Assert.Equal("ldap", created.Type);
|
||||
Assert.True(created.Enabled);
|
||||
Assert.Equal("ldap.example.com", created.Configuration["host"]);
|
||||
|
||||
// Read
|
||||
var getResponse = await client.GetFromJsonAsync<IdentityProviderConfigDto>(
|
||||
$"/api/v1/platform/identity-providers/{created.Id}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(getResponse);
|
||||
Assert.Equal(created.Id, getResponse!.Id);
|
||||
|
||||
// Update
|
||||
var updateRequest = new UpdateIdentityProviderRequest(
|
||||
null,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = "ldap2.example.com",
|
||||
["port"] = "636",
|
||||
["bindDn"] = "cn=admin,dc=example,dc=com",
|
||||
["bindPassword"] = "new-secret",
|
||||
["searchBase"] = "dc=example,dc=com"
|
||||
},
|
||||
"Updated LDAP");
|
||||
|
||||
var updateResponse = await client.PutAsJsonAsync(
|
||||
$"/api/v1/platform/identity-providers/{created.Id}",
|
||||
updateRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
updateResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var updated = await updateResponse.Content.ReadFromJsonAsync<IdentityProviderConfigDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.Equal("ldap2.example.com", updated!.Configuration["host"]);
|
||||
Assert.Equal("Updated LDAP", updated.Description);
|
||||
|
||||
// List
|
||||
var items = await client.GetFromJsonAsync<List<IdentityProviderConfigDto>>(
|
||||
"/api/v1/platform/identity-providers",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Single(items!);
|
||||
|
||||
// Delete
|
||||
var deleteResponse = await client.DeleteAsync(
|
||||
$"/api/v1/platform/identity-providers/{created.Id}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
|
||||
|
||||
// Verify deleted
|
||||
var afterDelete = await client.GetAsync(
|
||||
$"/api/v1/platform/identity-providers/{created.Id}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, afterDelete.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Create_ValidationError_MissingRequiredFields()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-validation");
|
||||
|
||||
var request = new CreateIdentityProviderRequest(
|
||||
"invalid-ldap",
|
||||
"ldap",
|
||||
true,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = "ldap.example.com"
|
||||
// Missing port, bindDn, bindPassword, searchBase
|
||||
},
|
||||
null);
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Create_ValidationError_InvalidType()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-type");
|
||||
|
||||
var request = new CreateIdentityProviderRequest(
|
||||
"invalid-type",
|
||||
"kerberos",
|
||||
true,
|
||||
new Dictionary<string, string?>(),
|
||||
null);
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Create_DuplicateName_ReturnsBadRequest()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-dup");
|
||||
|
||||
var request = new CreateIdentityProviderRequest(
|
||||
"duplicate-provider",
|
||||
"standard",
|
||||
true,
|
||||
new Dictionary<string, string?>(),
|
||||
null);
|
||||
|
||||
var first = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, first.StatusCode);
|
||||
|
||||
var second = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task EnableDisable_TogglesState()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-toggle");
|
||||
|
||||
var createRequest = new CreateIdentityProviderRequest(
|
||||
"toggle-provider",
|
||||
"standard",
|
||||
true,
|
||||
new Dictionary<string, string?>(),
|
||||
null);
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
createRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<IdentityProviderConfigDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Disable
|
||||
var disableResponse = await client.PostAsync(
|
||||
$"/api/v1/platform/identity-providers/{created!.Id}/disable",
|
||||
null,
|
||||
TestContext.Current.CancellationToken);
|
||||
disableResponse.EnsureSuccessStatusCode();
|
||||
var disabled = await disableResponse.Content.ReadFromJsonAsync<IdentityProviderConfigDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.False(disabled!.Enabled);
|
||||
|
||||
// Enable
|
||||
var enableResponse = await client.PostAsync(
|
||||
$"/api/v1/platform/identity-providers/{created.Id}/enable",
|
||||
null,
|
||||
TestContext.Current.CancellationToken);
|
||||
enableResponse.EnsureSuccessStatusCode();
|
||||
var enabled = await enableResponse.Content.ReadFromJsonAsync<IdentityProviderConfigDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.True(enabled!.Enabled);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TestConnection_StandardProvider_AlwaysSucceeds()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-test");
|
||||
|
||||
var request = new TestConnectionRequest(
|
||||
"standard",
|
||||
new Dictionary<string, string?>());
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers/test-connection",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var result = await response.Content.ReadFromJsonAsync<TestConnectionResult>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.True(result!.Success);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task GetTypes_ReturnsAllProviderTypes()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-types");
|
||||
|
||||
var types = await client.GetFromJsonAsync<List<IdentityProviderTypeSchema>>(
|
||||
"/api/v1/platform/identity-providers/types",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(types);
|
||||
Assert.Equal(4, types!.Count);
|
||||
Assert.Contains(types, t => t.Type == "standard");
|
||||
Assert.Contains(types, t => t.Type == "ldap");
|
||||
Assert.Contains(types, t => t.Type == "saml");
|
||||
Assert.Contains(types, t => t.Type == "oidc");
|
||||
|
||||
var ldap = types.Find(t => t.Type == "ldap");
|
||||
Assert.NotNull(ldap);
|
||||
Assert.Contains(ldap!.RequiredFields, f => f.Name == "host");
|
||||
Assert.Contains(ldap.RequiredFields, f => f.Name == "bindDn");
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task TenantIsolation_CannotSeeOtherTenantProviders()
|
||||
{
|
||||
using var clientA = CreateClient("tenant-idp-a", "actor-a");
|
||||
using var clientB = CreateClient("tenant-idp-b", "actor-b");
|
||||
|
||||
var requestA = new CreateIdentityProviderRequest(
|
||||
"tenant-a-provider",
|
||||
"standard",
|
||||
true,
|
||||
new Dictionary<string, string?>(),
|
||||
null);
|
||||
|
||||
var createA = await clientA.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
requestA,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, createA.StatusCode);
|
||||
|
||||
var created = await createA.Content.ReadFromJsonAsync<IdentityProviderConfigDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
// Tenant B cannot see tenant A's provider
|
||||
var listB = await clientB.GetFromJsonAsync<List<IdentityProviderConfigDto>>(
|
||||
"/api/v1/platform/identity-providers",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Empty(listB!);
|
||||
|
||||
// Tenant B cannot get tenant A's provider by ID
|
||||
var getB = await clientB.GetAsync(
|
||||
$"/api/v1/platform/identity-providers/{created!.Id}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NotFound, getB.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task Health_ReturnsStatusForProvider()
|
||||
{
|
||||
using var client = CreateClient("tenant-idp-health");
|
||||
|
||||
var createRequest = new CreateIdentityProviderRequest(
|
||||
"health-check-provider",
|
||||
"standard",
|
||||
true,
|
||||
new Dictionary<string, string?>(),
|
||||
null);
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
createRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<IdentityProviderConfigDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
var healthResponse = await client.GetFromJsonAsync<TestConnectionResult>(
|
||||
$"/api/v1/platform/identity-providers/{created!.Id}/health",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(healthResponse);
|
||||
Assert.True(healthResponse!.Success);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
using StellaOps.TestKit;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests.Integration;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that require real IDP containers (OpenLDAP + Keycloak).
|
||||
/// Run: docker compose -f devops/compose/docker-compose.idp-testing.yml --profile idp up -d
|
||||
/// Execute: dotnet test --filter "FullyQualifiedName~IdentityProviderContainerTests"
|
||||
/// </summary>
|
||||
[Trait("Category", TestCategories.Integration)]
|
||||
[Collection("IdpContainerTests")]
|
||||
public sealed class IdentityProviderContainerTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private const string LdapHost = "localhost";
|
||||
private const int LdapPort = 3389;
|
||||
private const string KeycloakBaseUrl = "http://localhost:8280";
|
||||
|
||||
private readonly PlatformWebApplicationFactory factory;
|
||||
|
||||
public IdentityProviderContainerTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
private HttpClient CreateClient()
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-container-test");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-container-test");
|
||||
return client;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task TestConnection_Ldap_CorrectCredentials_Succeeds()
|
||||
{
|
||||
using var client = CreateClient();
|
||||
|
||||
var request = new TestConnectionRequest(
|
||||
"ldap",
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = LdapHost,
|
||||
["port"] = LdapPort.ToString(),
|
||||
["bindDn"] = "cn=admin,dc=stellaops,dc=test",
|
||||
["bindPassword"] = "admin-secret",
|
||||
["searchBase"] = "dc=stellaops,dc=test"
|
||||
});
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers/test-connection",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<TestConnectionResult>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.Success);
|
||||
Assert.NotNull(result.LatencyMs);
|
||||
Assert.True(result.LatencyMs > 0);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task TestConnection_Ldap_WrongCredentials_Fails()
|
||||
{
|
||||
using var client = CreateClient();
|
||||
|
||||
// TCP connect will succeed but bind would fail
|
||||
// (our current test only does TCP connect, so this tests unreachable host)
|
||||
var request = new TestConnectionRequest(
|
||||
"ldap",
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = "198.51.100.1", // RFC 5737 TEST-NET-2
|
||||
["port"] = "389",
|
||||
["bindDn"] = "cn=wrong,dc=stellaops,dc=test",
|
||||
["bindPassword"] = "wrong-password",
|
||||
["searchBase"] = "dc=stellaops,dc=test"
|
||||
});
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers/test-connection",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<TestConnectionResult>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result!.Success);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task TestConnection_SamlMetadata_Succeeds()
|
||||
{
|
||||
using var client = CreateClient();
|
||||
|
||||
var metadataUrl = $"{KeycloakBaseUrl}/realms/stellaops/protocol/saml/descriptor";
|
||||
|
||||
var request = new TestConnectionRequest(
|
||||
"saml",
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["spEntityId"] = "stellaops-saml-sp",
|
||||
["idpEntityId"] = $"{KeycloakBaseUrl}/realms/stellaops",
|
||||
["idpMetadataUrl"] = metadataUrl
|
||||
});
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers/test-connection",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<TestConnectionResult>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.Success);
|
||||
Assert.Contains("metadata", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task TestConnection_OidcDiscovery_Succeeds()
|
||||
{
|
||||
using var client = CreateClient();
|
||||
|
||||
var request = new TestConnectionRequest(
|
||||
"oidc",
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["authority"] = $"{KeycloakBaseUrl}/realms/stellaops",
|
||||
["clientId"] = "stellaops-oidc-client",
|
||||
["clientSecret"] = "stellaops-oidc-test-secret"
|
||||
});
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers/test-connection",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<TestConnectionResult>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result!.Success);
|
||||
Assert.Contains("discovery", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task TestConnection_UnreachableHost_TimesOut()
|
||||
{
|
||||
using var client = CreateClient();
|
||||
|
||||
var request = new TestConnectionRequest(
|
||||
"ldap",
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = "198.51.100.1", // TEST-NET-2 -- should be unreachable
|
||||
["port"] = "389",
|
||||
["bindDn"] = "cn=admin,dc=test",
|
||||
["bindPassword"] = "secret",
|
||||
["searchBase"] = "dc=test"
|
||||
});
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers/test-connection",
|
||||
request,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
var result = await response.Content.ReadFromJsonAsync<TestConnectionResult>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result!.Success);
|
||||
Assert.Contains("failed", result.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires docker compose idp containers")]
|
||||
public async Task FullCrudLifecycle_WithHealthCheck()
|
||||
{
|
||||
using var client = CreateClient();
|
||||
|
||||
// Create LDAP provider
|
||||
var createRequest = new CreateIdentityProviderRequest(
|
||||
"container-test-ldap",
|
||||
"ldap",
|
||||
true,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = LdapHost,
|
||||
["port"] = LdapPort.ToString(),
|
||||
["bindDn"] = "cn=admin,dc=stellaops,dc=test",
|
||||
["bindPassword"] = "admin-secret",
|
||||
["searchBase"] = "dc=stellaops,dc=test"
|
||||
},
|
||||
"Container integration test LDAP provider");
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/v1/platform/identity-providers",
|
||||
createRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<IdentityProviderConfigDto>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(created);
|
||||
|
||||
// Health check
|
||||
var healthResponse = await client.GetFromJsonAsync<TestConnectionResult>(
|
||||
$"/api/v1/platform/identity-providers/{created!.Id}/health",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(healthResponse);
|
||||
Assert.True(healthResponse!.Success);
|
||||
|
||||
// Update
|
||||
var updateRequest = new UpdateIdentityProviderRequest(
|
||||
null,
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["host"] = LdapHost,
|
||||
["port"] = LdapPort.ToString(),
|
||||
["bindDn"] = "cn=admin,dc=stellaops,dc=test",
|
||||
["bindPassword"] = "admin-secret",
|
||||
["searchBase"] = "ou=users,dc=stellaops,dc=test"
|
||||
},
|
||||
"Updated container test LDAP provider");
|
||||
|
||||
var updateResponse = await client.PutAsJsonAsync(
|
||||
$"/api/v1/platform/identity-providers/{created.Id}",
|
||||
updateRequest,
|
||||
TestContext.Current.CancellationToken);
|
||||
updateResponse.EnsureSuccessStatusCode();
|
||||
|
||||
// List
|
||||
var list = await client.GetFromJsonAsync<List<IdentityProviderConfigDto>>(
|
||||
"/api/v1/platform/identity-providers",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Contains(list!, p => p.Name == "container-test-ldap");
|
||||
|
||||
// Delete
|
||||
var deleteResponse = await client.DeleteAsync(
|
||||
$"/api/v1/platform/identity-providers/{created.Id}",
|
||||
TestContext.Current.CancellationToken);
|
||||
Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class LocalizationEndpointsTests : IClassFixture<PlatformWebApplicationFactory>
|
||||
{
|
||||
private readonly PlatformWebApplicationFactory _factory;
|
||||
|
||||
public LocalizationEndpointsTests(PlatformWebApplicationFactory factory)
|
||||
{
|
||||
_factory = factory;
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UiBundleEndpoint_ReturnsDefaultMergedBundle()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
"/platform/i18n/en-US.json",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
Assert.Equal("public, max-age=300", response.Headers.CacheControl?.ToString());
|
||||
|
||||
var bundle = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(bundle);
|
||||
Assert.True(bundle!.ContainsKey("common.actions.save"));
|
||||
Assert.True(bundle.ContainsKey("ui.actions.save"));
|
||||
Assert.False(string.IsNullOrWhiteSpace(bundle["common.actions.save"]));
|
||||
Assert.False(string.IsNullOrWhiteSpace(bundle["ui.actions.save"]));
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UpsertOverride_IsReturnedFromUiBundle()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-localization");
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-localization");
|
||||
client.DefaultRequestHeaders.Add("X-Actor", "test-actor");
|
||||
|
||||
var upsertPayload = new
|
||||
{
|
||||
locale = "en-US",
|
||||
strings = new Dictionary<string, string>
|
||||
{
|
||||
["ui.actions.save"] = "Speichern"
|
||||
}
|
||||
};
|
||||
|
||||
using var upsertResponse = await client.PutAsJsonAsync(
|
||||
"/api/v1/platform/localization/bundles",
|
||||
upsertPayload,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
upsertResponse.EnsureSuccessStatusCode();
|
||||
|
||||
using var bundleResponse = await client.GetAsync(
|
||||
"/platform/i18n/en-US.json",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
bundleResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await bundleResponse.Content.ReadFromJsonAsync<Dictionary<string, string>>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(bundle);
|
||||
Assert.Equal("Speichern", bundle!["ui.actions.save"]);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task UiBundles_IncludeCommonLayerForAllSupportedLocales()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
|
||||
var locales = new[]
|
||||
{
|
||||
"en-US",
|
||||
"de-DE",
|
||||
"bg-BG",
|
||||
"ru-RU",
|
||||
"es-ES",
|
||||
"fr-FR",
|
||||
"uk-UA",
|
||||
"zh-TW",
|
||||
"zh-CN",
|
||||
};
|
||||
|
||||
foreach (var locale in locales)
|
||||
{
|
||||
using var response = await client.GetAsync(
|
||||
$"/platform/i18n/{locale}.json",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var bundle = await response.Content.ReadFromJsonAsync<Dictionary<string, string>>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(bundle);
|
||||
Assert.Contains("common.actions.save", bundle!);
|
||||
Assert.Contains("ui.actions.save", bundle);
|
||||
}
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task AvailableLocales_IncludesExpandedLocaleSet()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-localization");
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-localization");
|
||||
client.DefaultRequestHeaders.Add("X-Actor", "test-actor");
|
||||
|
||||
using var response = await client.GetAsync(
|
||||
"/api/v1/platform/localization/locales",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonObject>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(payload);
|
||||
var locales = payload!["locales"]?.AsArray().Select(node => node?.GetValue<string>()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
Assert.NotNull(locales);
|
||||
Assert.Contains("en-US", locales!);
|
||||
Assert.Contains("de-DE", locales);
|
||||
Assert.Contains("bg-BG", locales);
|
||||
Assert.Contains("ru-RU", locales);
|
||||
Assert.Contains("es-ES", locales);
|
||||
Assert.Contains("fr-FR", locales);
|
||||
Assert.Contains("uk-UA", locales);
|
||||
Assert.Contains("zh-TW", locales);
|
||||
Assert.Contains("zh-CN", locales);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task PlatformNamespaceBundles_AreAvailableForAllSupportedLocales()
|
||||
{
|
||||
using var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-localization");
|
||||
client.DefaultRequestHeaders.Add("X-Tenant-Id", "tenant-localization");
|
||||
client.DefaultRequestHeaders.Add("X-Actor", "test-actor");
|
||||
|
||||
var locales = new[]
|
||||
{
|
||||
"en-US",
|
||||
"de-DE",
|
||||
"bg-BG",
|
||||
"ru-RU",
|
||||
"es-ES",
|
||||
"fr-FR",
|
||||
"uk-UA",
|
||||
"zh-TW",
|
||||
"zh-CN",
|
||||
};
|
||||
|
||||
foreach (var locale in locales)
|
||||
{
|
||||
using var response = await client.GetAsync(
|
||||
$"/api/v1/platform/localization/bundles/{locale}/platform",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var payload = await response.Content.ReadFromJsonAsync<JsonObject>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(payload);
|
||||
var strings = payload!["strings"]?.AsObject();
|
||||
Assert.NotNull(strings);
|
||||
Assert.Contains("platform.health.status_healthy", strings!);
|
||||
Assert.Contains("platform.migration.failed", strings);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using StellaOps.TestKit;
|
||||
|
||||
namespace StellaOps.Platform.WebService.Tests;
|
||||
|
||||
public sealed class PlatformTranslationsMigrationScriptTests
|
||||
{
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration057_DefinesPlatformTranslationsSchemaObjects()
|
||||
{
|
||||
var scriptPath = GetMigrationPath("057_PlatformTranslations.sql");
|
||||
var sql = File.ReadAllText(scriptPath);
|
||||
|
||||
Assert.Contains("CREATE TABLE IF NOT EXISTS platform.translations", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CONSTRAINT ux_translations_tenant_locale_key UNIQUE (tenant_id, locale, key)", sql, StringComparison.Ordinal);
|
||||
Assert.Contains("CREATE INDEX IF NOT EXISTS ix_translations_tenant_locale", sql, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public void Migration057_IsPresentInReleaseMigrationSequence()
|
||||
{
|
||||
var migrationsDir = GetMigrationsDirectory();
|
||||
var migrationNames = Directory.GetFiles(migrationsDir, "*.sql")
|
||||
.Select(Path.GetFileName)
|
||||
.Where(static name => name is not null)
|
||||
.Select(static name => name!)
|
||||
.OrderBy(static name => name, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var index056 = Array.IndexOf(migrationNames, "056_RunCapsuleReplayLinkage.sql");
|
||||
var index057 = Array.IndexOf(migrationNames, "057_PlatformTranslations.sql");
|
||||
|
||||
Assert.True(index056 >= 0, "Expected migration 056 to exist.");
|
||||
Assert.True(index057 > index056, "Expected migration 057 to appear after migration 056.");
|
||||
}
|
||||
|
||||
private static string GetMigrationPath(string fileName)
|
||||
{
|
||||
return Path.Combine(GetMigrationsDirectory(), fileName);
|
||||
}
|
||||
|
||||
private static string GetMigrationsDirectory()
|
||||
{
|
||||
var current = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (current is not null)
|
||||
{
|
||||
var candidate = Path.Combine(
|
||||
current.FullName,
|
||||
"src",
|
||||
"Platform",
|
||||
"__Libraries",
|
||||
"StellaOps.Platform.Database",
|
||||
"Migrations",
|
||||
"Release");
|
||||
|
||||
if (Directory.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate Platform release migrations directory.");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Platform.WebService.Contracts;
|
||||
@@ -44,4 +45,87 @@ public sealed class PreferencesEndpointsTests : IClassFixture<PlatformWebApplica
|
||||
Assert.NotNull(widgets);
|
||||
Assert.Equal(new[] { "health", "quota" }, widgets!.Select(widget => widget!.GetValue<string>()).ToArray());
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LanguagePreference_RoundTripAndSurvivesDashboardUpdate()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-preferences");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-preferences");
|
||||
|
||||
var initial = await client.GetFromJsonAsync<PlatformLanguagePreference>(
|
||||
"/api/v1/platform/preferences/language",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(initial);
|
||||
Assert.Null(initial!.Locale);
|
||||
|
||||
var setLanguage = await client.PutAsJsonAsync(
|
||||
"/api/v1/platform/preferences/language",
|
||||
new PlatformLanguagePreferenceRequest("es-ES"),
|
||||
TestContext.Current.CancellationToken);
|
||||
setLanguage.EnsureSuccessStatusCode();
|
||||
|
||||
var updated = await setLanguage.Content.ReadFromJsonAsync<PlatformLanguagePreference>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("es-ES", updated!.Locale);
|
||||
|
||||
var dashboardUpdate = new PlatformDashboardPreferencesRequest(new JsonObject
|
||||
{
|
||||
["layout"] = "incident",
|
||||
["widgets"] = new JsonArray("health"),
|
||||
["filters"] = new JsonObject { ["scope"] = "tenant" }
|
||||
});
|
||||
|
||||
var dashboardResponse = await client.PutAsJsonAsync(
|
||||
"/api/v1/platform/preferences/dashboard",
|
||||
dashboardUpdate,
|
||||
TestContext.Current.CancellationToken);
|
||||
dashboardResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var reloaded = await client.GetFromJsonAsync<PlatformLanguagePreference>(
|
||||
"/api/v1/platform/preferences/language",
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.NotNull(reloaded);
|
||||
Assert.Equal("es-ES", reloaded!.Locale);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LanguagePreference_RejectsUnsupportedLocale()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-preferences");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-preferences");
|
||||
|
||||
var response = await client.PutAsJsonAsync(
|
||||
"/api/v1/platform/preferences/language",
|
||||
new PlatformLanguagePreferenceRequest("xx-XX"),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Trait("Category", TestCategories.Unit)]
|
||||
[Fact]
|
||||
public async Task LanguagePreference_NormalizesUkrainianAlias()
|
||||
{
|
||||
using var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Tenant", "tenant-preferences");
|
||||
client.DefaultRequestHeaders.Add("X-StellaOps-Actor", "actor-preferences");
|
||||
|
||||
var response = await client.PutAsJsonAsync(
|
||||
"/api/v1/platform/preferences/language",
|
||||
new PlatformLanguagePreferenceRequest("uk"),
|
||||
TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var updated = await response.Content.ReadFromJsonAsync<PlatformLanguagePreference>(
|
||||
cancellationToken: TestContext.Current.CancellationToken);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("uk-UA", updated!.Locale);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,3 +19,7 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0762-A | DONE | Waived (test project; revalidated 2026-01-07). |
|
||||
| TASK-030-019 | BLOCKED | Added analytics maintenance + cache normalization + query executor tests; analytics schema fixtures blocked by ingestion dependencies. |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| SPRINT_20260224_001-LOC-002-T | DONE | Sprint `docs/implplan/SPRINT_20260224_001_Platform_unified_translation_gap_closure.md`: added migration script + localization endpoint tests for translation persistence and override behavior. |
|
||||
| SPRINT_20260224_004-LOC-302-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: added language preference endpoint coverage in `PreferencesEndpointsTests` (round-trip persistence + invalid locale rejection) and expanded locale catalog verification in `LocalizationEndpointsTests`. |
|
||||
| SPRINT_20260224_004-LOC-305-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: extended `LocalizationEndpointsTests` to verify common-layer and `platform.*` namespace bundle availability for all supported locales. |
|
||||
| SPRINT_20260224_004-LOC-307-T | DONE | Sprint `docs/implplan/SPRINT_20260224_004_Platform_user_locale_expansion_and_cli_persistence.md`: extended localization and preference endpoint tests for Ukrainian rollout (`uk-UA` locale catalog/bundle assertions and alias normalization to canonical `uk-UA`). |
|
||||
|
||||
Reference in New Issue
Block a user