diff --git a/portal/frontend/src/api/adminConfig.ts b/portal/frontend/src/api/adminConfig.ts index b8eb3af..7b70f9d 100644 --- a/portal/frontend/src/api/adminConfig.ts +++ b/portal/frontend/src/api/adminConfig.ts @@ -1,6 +1,24 @@ import { api } from './client'; +export interface FleetIngestInfo { + enabled: boolean; + url: string; + intervalSeconds: number; + batchSize: number; + batchMaxBytes: number; + tokenConfigured: boolean; +} + +export interface FleetPushStateRow { + resourceType: string; + lastCursor: string | null; + lastSyncedAt: string | null; + consecutiveFailures: number; + lastError: string | null; +} + export interface ConfigOverview { + runMode: 'Client' | 'Admin'; application: { name: string; environment: string; publicUrl: string }; database: { provider: string; @@ -23,6 +41,8 @@ export interface ConfigOverview { monitoring: { chunkTimeInterval: string; enableHourlyAggregates: boolean }; authentication: { cookieName: string; requireConfirmedEmail: boolean; defaultAdminEmail: string }; build: { assemblyVersion: string; framework: string; startedAtUtc: string }; + fleetIngest: FleetIngestInfo | null; + fleetPushState: FleetPushStateRow[] | null; } export async function fetchConfigOverview(): Promise { diff --git a/portal/frontend/src/components/settings/ConfigOverviewCard.tsx b/portal/frontend/src/components/settings/ConfigOverviewCard.tsx index ed389d5..69a6190 100644 --- a/portal/frontend/src/components/settings/ConfigOverviewCard.tsx +++ b/portal/frontend/src/components/settings/ConfigOverviewCard.tsx @@ -1,6 +1,7 @@ -import { Card, Descriptions, Alert, Typography, Tag } from 'antd'; +import { Card, Descriptions, Alert, Typography, Tag, Table } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; import { useQuery } from '@tanstack/react-query'; -import { fetchConfigOverview } from '../../api/adminConfig'; +import { fetchConfigOverview, type FleetPushStateRow } from '../../api/adminConfig'; const { Text } = Typography; @@ -31,7 +32,12 @@ export function ConfigOverviewCard() { {data.application.environment} - {data.application.publicUrl} + + + {data.runMode === 'Admin' ? 'Admin (fleet aggregator)' : 'Client (per-customer)'} + + + {data.application.publicUrl} @@ -73,13 +79,74 @@ export function ConfigOverviewCard() { {data.authentication.defaultAdminEmail} - + {data.build.assemblyVersion} {data.build.framework} {new Date(data.build.startedAtUtc).toLocaleString()} + + {data.fleetIngest && ( + <> + + + {data.fleetIngest.enabled + ? Yes + : No (push service not running)} + + + {data.fleetIngest.tokenConfigured + ? Yes (value hidden) + : No} + + + {data.fleetIngest.url + ? {data.fleetIngest.url} + : (unset)} + + {data.fleetIngest.intervalSeconds}s + {data.fleetIngest.batchSize.toLocaleString()} rows + + {data.fleetIngest.batchMaxBytes.toLocaleString()} bytes + + + + {data.fleetPushState && data.fleetPushState.length > 0 && ( + + + rowKey="resourceType" + size="small" + pagination={false} + dataSource={data.fleetPushState} + columns={pushStateColumns} + /> + + )} + + )} )} ); } + +const pushStateColumns: ColumnsType = [ + { title: 'Resource', dataIndex: 'resourceType', key: 'rt', render: (v: string) => {v} }, + { + title: 'Last cursor', dataIndex: 'lastCursor', key: 'lc', + render: (v: string | null) => v ? new Date(v).toLocaleString() : never + }, + { + title: 'Last sync', dataIndex: 'lastSyncedAt', key: 'ls', + render: (v: string | null) => v ? new Date(v).toLocaleString() : never + }, + { + title: 'Failures', dataIndex: 'consecutiveFailures', key: 'cf', + render: (n: number) => n === 0 + ? 0 + : 5 ? 'red' : 'orange'}>{n} + }, + { + title: 'Last error', dataIndex: 'lastError', key: 'le', + render: (v: string | null) => v ? {v} : + }, +]; diff --git a/portal/src/Tau.Acuvim.Portal/DTOs/ConfigOverviewDtos.cs b/portal/src/Tau.Acuvim.Portal/DTOs/ConfigOverviewDtos.cs index 455df89..7d55ade 100644 --- a/portal/src/Tau.Acuvim.Portal/DTOs/ConfigOverviewDtos.cs +++ b/portal/src/Tau.Acuvim.Portal/DTOs/ConfigOverviewDtos.cs @@ -26,10 +26,28 @@ public sealed record AuthInfoDto(string CookieName, bool RequireConfirmedEmail, public sealed record BuildInfoDto(string AssemblyVersion, string Framework, DateTime StartedAtUtc); +public sealed record FleetIngestInfoDto( + bool Enabled, + string Url, + int IntervalSeconds, + int BatchSize, + int BatchMaxBytes, + bool TokenConfigured); + +public sealed record FleetPushStateRowDto( + string ResourceType, + DateTime? LastCursor, + DateTime? LastSyncedAt, + int ConsecutiveFailures, + string? LastError); + public sealed record ConfigOverviewDto( + string RunMode, AppInfoDto Application, DatabaseInfoDto Database, GrafanaInfoDto Grafana, MonitoringInfoDto Monitoring, AuthInfoDto Authentication, - BuildInfoDto Build); + BuildInfoDto Build, + FleetIngestInfoDto? FleetIngest, + IReadOnlyList? FleetPushState); diff --git a/portal/src/Tau.Acuvim.Portal/Endpoints/AdminConfigEndpoints.cs b/portal/src/Tau.Acuvim.Portal/Endpoints/AdminConfigEndpoints.cs index a004a88..087b331 100644 --- a/portal/src/Tau.Acuvim.Portal/Endpoints/AdminConfigEndpoints.cs +++ b/portal/src/Tau.Acuvim.Portal/Endpoints/AdminConfigEndpoints.cs @@ -7,7 +7,9 @@ public static class AdminConfigEndpoints { public static IEndpointRouteBuilder MapAdminConfigEndpoints(this IEndpointRouteBuilder app) { - app.MapGet("/api/admin/config-overview", (ConfigOverviewService svc) => Results.Ok(svc.Build())) + app.MapGet("/api/admin/config-overview", + async (ConfigOverviewService svc, CancellationToken ct) => + Results.Ok(await svc.BuildAsync(ct))) .RequireAuthorization(Policies.AdminOnly) .WithTags("Admin / Config"); diff --git a/portal/src/Tau.Acuvim.Portal/Services/ConfigOverviewService.cs b/portal/src/Tau.Acuvim.Portal/Services/ConfigOverviewService.cs index 3c36682..db2f4d5 100644 --- a/portal/src/Tau.Acuvim.Portal/Services/ConfigOverviewService.cs +++ b/portal/src/Tau.Acuvim.Portal/Services/ConfigOverviewService.cs @@ -1,6 +1,8 @@ +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Npgsql; using Tau.Acuvim.Portal.Configuration; +using Tau.Acuvim.Portal.Data; using Tau.Acuvim.Portal.DTOs; namespace Tau.Acuvim.Portal.Services; @@ -20,32 +22,28 @@ public sealed class ConfigOverviewService( IOptions grafanaOptions, IOptions monitoringOptions, IOptions authOptions, + IOptions fleetOptions, IHostEnvironment env, - DatabaseResolutionInfo dbResolution) + DatabaseResolutionInfo dbResolution, + IServiceProvider serviceProvider) { - public ConfigOverviewDto Build() + public async Task BuildAsync(CancellationToken ct = default) { var (host, port, database) = ParseConnection(dbOptions.Value.ConnectionString); + var runMode = appOptions.Value.RunMode; var application = new AppInfoDto( - appOptions.Value.Name, - env.EnvironmentName, - appOptions.Value.PublicUrl); + appOptions.Value.Name, env.EnvironmentName, appOptions.Value.PublicUrl); var database_ = new DatabaseInfoDto( - dbOptions.Value.Provider, - host, port, database, - dbOptions.Value.MigrateOnStartup, - dbOptions.Value.AutoProvisionLocalTimescaleDb, + dbOptions.Value.Provider, host, port, database, + dbOptions.Value.MigrateOnStartup, dbOptions.Value.AutoProvisionLocalTimescaleDb, dbResolution.Source); var grafana = new GrafanaInfoDto( - grafanaOptions.Value.BaseUrl, - grafanaOptions.Value.InternalUrl, - grafanaOptions.Value.EmbedPathPrefix, - grafanaOptions.Value.EmbedMode, - grafanaOptions.Value.DefaultDashboardUid, - grafanaOptions.Value.AuthMode, + grafanaOptions.Value.BaseUrl, grafanaOptions.Value.InternalUrl, + grafanaOptions.Value.EmbedPathPrefix, grafanaOptions.Value.EmbedMode, + grafanaOptions.Value.DefaultDashboardUid, grafanaOptions.Value.AuthMode, grafanaOptions.Value.Dashboards.Count); var monitoring = new MonitoringInfoDto( @@ -53,8 +51,7 @@ public sealed class ConfigOverviewService( monitoringOptions.Value.EnableHourlyAggregates); var auth = new AuthInfoDto( - authOptions.Value.CookieName, - authOptions.Value.RequireConfirmedEmail, + authOptions.Value.CookieName, authOptions.Value.RequireConfirmedEmail, authOptions.Value.DefaultAdminEmail); var assembly = typeof(ConfigOverviewService).Assembly.GetName(); @@ -63,7 +60,47 @@ public sealed class ConfigOverviewService( Environment.Version.ToString(), dbResolution.StartedAtUtc); - return new ConfigOverviewDto(application, database_, grafana, monitoring, auth, build); + // Fleet ingest + push state are only meaningful in Client mode. + // Token is never copied into the DTO — TokenConfigured is a bool, not the value. + FleetIngestInfoDto? fleet = null; + IReadOnlyList? pushState = null; + + if (runMode == RunMode.Client) + { + var fo = fleetOptions.Value; + fleet = new FleetIngestInfoDto( + Enabled: fo.Enabled, + Url: fo.Url, + IntervalSeconds: fo.IntervalSeconds, + BatchSize: fo.BatchSize, + BatchMaxBytes: fo.BatchMaxBytes, + TokenConfigured: !string.IsNullOrWhiteSpace(fo.Token)); + + // AppDbContext is registered in Client mode; not in Admin mode. + // GetService returns null when not registered — keeps this service + // mode-agnostic for DI registration. + var db = serviceProvider.GetService(); + if (db is not null) + { + pushState = await db.FleetPushState.AsNoTracking() + .OrderBy(x => x.ResourceType) + .Select(x => new FleetPushStateRowDto( + x.ResourceType, x.LastCursor, x.LastSyncedAt, + x.ConsecutiveFailures, x.LastError)) + .ToListAsync(ct); + } + } + + return new ConfigOverviewDto( + RunMode: runMode.ToString(), + Application: application, + Database: database_, + Grafana: grafana, + Monitoring: monitoring, + Authentication: auth, + Build: build, + FleetIngest: fleet, + FleetPushState: pushState); } private static (string Host, int Port, string Database) ParseConnection(string connectionString) diff --git a/portal/tests/Tau.Acuvim.Portal.Tests/ConfigOverviewRedactionTests.cs b/portal/tests/Tau.Acuvim.Portal.Tests/ConfigOverviewRedactionTests.cs new file mode 100644 index 0000000..ddac3bd --- /dev/null +++ b/portal/tests/Tau.Acuvim.Portal.Tests/ConfigOverviewRedactionTests.cs @@ -0,0 +1,55 @@ +using System.Text.Json; +using Tau.Acuvim.Portal.DTOs; + +namespace Tau.Acuvim.Portal.Tests; + +// Locks down the invariant that the App-config payload never carries the +// fleet-ingest token. Token is reduced to a boolean in the DTO, so even if a +// future change accidentally tries to copy the value it would fail to compile. +public class ConfigOverviewRedactionTests +{ + [Fact] + public void FleetIngestInfoDto_HasNoTokenProperty() + { + var properties = typeof(FleetIngestInfoDto).GetProperties().Select(p => p.Name).ToArray(); + Assert.DoesNotContain(properties, p => p.Equals("Token", StringComparison.OrdinalIgnoreCase)); + Assert.Contains("TokenConfigured", properties); + } + + [Fact] + public void Serialised_FleetIngestInfoDto_DoesNotMentionTokenValue() + { + var supposedSecret = "this-should-never-be-in-the-payload"; + var dto = new FleetIngestInfoDto( + Enabled: true, + Url: "https://admin.example.com/api/fleet/ingest", + IntervalSeconds: 60, + BatchSize: 5000, + BatchMaxBytes: 1_048_576, + TokenConfigured: !string.IsNullOrWhiteSpace(supposedSecret)); + + var json = JsonSerializer.Serialize(dto); + Assert.DoesNotContain(supposedSecret, json); + Assert.Contains("tokenConfigured", json, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ConfigOverviewDto_FleetIngestNullable_ForAdminMode() + { + // Admin mode never has fleet push state — both FleetIngest and FleetPushState + // are nullable on the DTO so they serialise as absent rather than empty. + var dto = new ConfigOverviewDto( + RunMode: "Admin", + Application: new("X", "Production", "https://x"), + Database: new("PostgreSQL", "h", 5432, "d", true, false, "src"), + Grafana: new("u", "u", "/g", "iframe", "", "anonymous-local-only", 0), + Monitoring: new("7 days", false), + Authentication: new("c", false, "a@b.c"), + Build: new("1", "10.0.0", DateTime.UtcNow), + FleetIngest: null, + FleetPushState: null); + + Assert.Null(dto.FleetIngest); + Assert.Null(dto.FleetPushState); + } +}