Settings → App config: surface RunMode + FleetIngest push state

ConfigOverviewService now reports the runtime mode and (on Client only)
the fleet-push configuration + live push-state per resource. The token
is reduced to a boolean (TokenConfigured) in the DTO — never a string —
so it cannot be accidentally serialised.

Backend
- FleetIngestInfoDto: { enabled, url, intervalSeconds, batchSize,
  batchMaxBytes, tokenConfigured }. No Token property at all.
- FleetPushStateRowDto: { resourceType, lastCursor, lastSyncedAt,
  consecutiveFailures, lastError }.
- ConfigOverviewDto gains RunMode (string), nullable FleetIngest +
  FleetPushState (null in Admin mode).
- ConfigOverviewService becomes async + injects IServiceProvider so it
  can read AppDbContext in Client mode without that DbContext being
  required (GetService returns null in Admin mode, where it's not
  registered).
- AdminConfigEndpoints awaits the async call.

Tests (+3, 56/56 passing)
- FleetIngestInfoDto has no Token property (reflection check).
- Serialised DTO never contains the literal token value (string scan).
- ConfigOverviewDto's FleetIngest + FleetPushState are nullable so
  Admin-mode payloads serialise them as absent rather than empty.

Frontend
- ConfigOverviewCard adds a Run mode row to the Application section
  (gold tag for Admin, blue for Client).
- New "Fleet push (Client → Admin)" descriptions card (enabled, token
  configured, url, interval, batch sizes) — hidden in Admin mode.
- "Push state per resource" table — resource, last cursor, last sync,
  consecutive failures (color-coded), last error.

Verified end-to-end on the dev host
- /api/admin/config-overview on the Client returns runMode=Client +
  fleetIngest={enabled,url,interval,batchSize,batchMaxBytes,tokenConfigured}
  + fleetPushState[3] rows (sites/devices/measurements, failures=0).
- The 64-char dev token (from the running Client's .env) is verified
  absent from the response body via direct string search.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Diseri Pearson 2026-05-18 10:33:52 +02:00
parent c5787a7a7f
commit aaa522058e
6 changed files with 223 additions and 24 deletions

View File

@ -1,6 +1,24 @@
import { api } from './client'; 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 { export interface ConfigOverview {
runMode: 'Client' | 'Admin';
application: { name: string; environment: string; publicUrl: string }; application: { name: string; environment: string; publicUrl: string };
database: { database: {
provider: string; provider: string;
@ -23,6 +41,8 @@ export interface ConfigOverview {
monitoring: { chunkTimeInterval: string; enableHourlyAggregates: boolean }; monitoring: { chunkTimeInterval: string; enableHourlyAggregates: boolean };
authentication: { cookieName: string; requireConfirmedEmail: boolean; defaultAdminEmail: string }; authentication: { cookieName: string; requireConfirmedEmail: boolean; defaultAdminEmail: string };
build: { assemblyVersion: string; framework: string; startedAtUtc: string }; build: { assemblyVersion: string; framework: string; startedAtUtc: string };
fleetIngest: FleetIngestInfo | null;
fleetPushState: FleetPushStateRow[] | null;
} }
export async function fetchConfigOverview(): Promise<ConfigOverview> { export async function fetchConfigOverview(): Promise<ConfigOverview> {

View File

@ -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 { useQuery } from '@tanstack/react-query';
import { fetchConfigOverview } from '../../api/adminConfig'; import { fetchConfigOverview, type FleetPushStateRow } from '../../api/adminConfig';
const { Text } = Typography; const { Text } = Typography;
@ -31,7 +32,12 @@ export function ConfigOverviewCard() {
{data.application.environment} {data.application.environment}
</Tag> </Tag>
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="Public URL" span={2}>{data.application.publicUrl}</Descriptions.Item> <Descriptions.Item label="Run mode">
<Tag color={data.runMode === 'Admin' ? 'gold' : 'blue'}>
{data.runMode === 'Admin' ? 'Admin (fleet aggregator)' : 'Client (per-customer)'}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Public URL">{data.application.publicUrl}</Descriptions.Item>
</Descriptions> </Descriptions>
<Descriptions title="Database" column={2} size="small" bordered style={{ marginBottom: 16 }}> <Descriptions title="Database" column={2} size="small" bordered style={{ marginBottom: 16 }}>
@ -73,13 +79,74 @@ export function ConfigOverviewCard() {
<Descriptions.Item label="Default admin email" span={2}>{data.authentication.defaultAdminEmail}</Descriptions.Item> <Descriptions.Item label="Default admin email" span={2}>{data.authentication.defaultAdminEmail}</Descriptions.Item>
</Descriptions> </Descriptions>
<Descriptions title="Build" column={2} size="small" bordered> <Descriptions title="Build" column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="Assembly version">{data.build.assemblyVersion}</Descriptions.Item> <Descriptions.Item label="Assembly version">{data.build.assemblyVersion}</Descriptions.Item>
<Descriptions.Item label=".NET runtime">{data.build.framework}</Descriptions.Item> <Descriptions.Item label=".NET runtime">{data.build.framework}</Descriptions.Item>
<Descriptions.Item label="Started" span={2}>{new Date(data.build.startedAtUtc).toLocaleString()}</Descriptions.Item> <Descriptions.Item label="Started" span={2}>{new Date(data.build.startedAtUtc).toLocaleString()}</Descriptions.Item>
</Descriptions> </Descriptions>
{data.fleetIngest && (
<>
<Descriptions title="Fleet push (Client → Admin)" column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="Enabled">
{data.fleetIngest.enabled
? <Tag color="green">Yes</Tag>
: <Tag>No (push service not running)</Tag>}
</Descriptions.Item>
<Descriptions.Item label="Token configured">
{data.fleetIngest.tokenConfigured
? <Tag color="green">Yes (value hidden)</Tag>
: <Tag color="red">No</Tag>}
</Descriptions.Item>
<Descriptions.Item label="Url" span={2}>
{data.fleetIngest.url
? <Text code>{data.fleetIngest.url}</Text>
: <Text type="secondary">(unset)</Text>}
</Descriptions.Item>
<Descriptions.Item label="Interval">{data.fleetIngest.intervalSeconds}s</Descriptions.Item>
<Descriptions.Item label="Batch size">{data.fleetIngest.batchSize.toLocaleString()} rows</Descriptions.Item>
<Descriptions.Item label="Batch max bytes" span={2}>
{data.fleetIngest.batchMaxBytes.toLocaleString()} bytes
</Descriptions.Item>
</Descriptions>
{data.fleetPushState && data.fleetPushState.length > 0 && (
<Card size="small" title="Push state per resource" style={{ marginBottom: 16 }}>
<Table<FleetPushStateRow>
rowKey="resourceType"
size="small"
pagination={false}
dataSource={data.fleetPushState}
columns={pushStateColumns}
/>
</Card>
)}
</>
)}
</> </>
)} )}
</Card> </Card>
); );
} }
const pushStateColumns: ColumnsType<FleetPushStateRow> = [
{ title: 'Resource', dataIndex: 'resourceType', key: 'rt', render: (v: string) => <Text strong>{v}</Text> },
{
title: 'Last cursor', dataIndex: 'lastCursor', key: 'lc',
render: (v: string | null) => v ? new Date(v).toLocaleString() : <Text type="secondary">never</Text>
},
{
title: 'Last sync', dataIndex: 'lastSyncedAt', key: 'ls',
render: (v: string | null) => v ? new Date(v).toLocaleString() : <Text type="secondary">never</Text>
},
{
title: 'Failures', dataIndex: 'consecutiveFailures', key: 'cf',
render: (n: number) => n === 0
? <Tag color="green">0</Tag>
: <Tag color={n > 5 ? 'red' : 'orange'}>{n}</Tag>
},
{
title: 'Last error', dataIndex: 'lastError', key: 'le',
render: (v: string | null) => v ? <Text type="danger" style={{ fontSize: 12 }}>{v}</Text> : <Text type="secondary"></Text>
},
];

View File

@ -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 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( public sealed record ConfigOverviewDto(
string RunMode,
AppInfoDto Application, AppInfoDto Application,
DatabaseInfoDto Database, DatabaseInfoDto Database,
GrafanaInfoDto Grafana, GrafanaInfoDto Grafana,
MonitoringInfoDto Monitoring, MonitoringInfoDto Monitoring,
AuthInfoDto Authentication, AuthInfoDto Authentication,
BuildInfoDto Build); BuildInfoDto Build,
FleetIngestInfoDto? FleetIngest,
IReadOnlyList<FleetPushStateRowDto>? FleetPushState);

View File

@ -7,7 +7,9 @@ public static class AdminConfigEndpoints
{ {
public static IEndpointRouteBuilder MapAdminConfigEndpoints(this IEndpointRouteBuilder app) 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) .RequireAuthorization(Policies.AdminOnly)
.WithTags("Admin / Config"); .WithTags("Admin / Config");

View File

@ -1,6 +1,8 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Npgsql; using Npgsql;
using Tau.Acuvim.Portal.Configuration; using Tau.Acuvim.Portal.Configuration;
using Tau.Acuvim.Portal.Data;
using Tau.Acuvim.Portal.DTOs; using Tau.Acuvim.Portal.DTOs;
namespace Tau.Acuvim.Portal.Services; namespace Tau.Acuvim.Portal.Services;
@ -20,32 +22,28 @@ public sealed class ConfigOverviewService(
IOptions<GrafanaOptions> grafanaOptions, IOptions<GrafanaOptions> grafanaOptions,
IOptions<MonitoringOptions> monitoringOptions, IOptions<MonitoringOptions> monitoringOptions,
IOptions<AuthenticationOptions> authOptions, IOptions<AuthenticationOptions> authOptions,
IOptions<FleetIngestOptions> fleetOptions,
IHostEnvironment env, IHostEnvironment env,
DatabaseResolutionInfo dbResolution) DatabaseResolutionInfo dbResolution,
IServiceProvider serviceProvider)
{ {
public ConfigOverviewDto Build() public async Task<ConfigOverviewDto> BuildAsync(CancellationToken ct = default)
{ {
var (host, port, database) = ParseConnection(dbOptions.Value.ConnectionString); var (host, port, database) = ParseConnection(dbOptions.Value.ConnectionString);
var runMode = appOptions.Value.RunMode;
var application = new AppInfoDto( var application = new AppInfoDto(
appOptions.Value.Name, appOptions.Value.Name, env.EnvironmentName, appOptions.Value.PublicUrl);
env.EnvironmentName,
appOptions.Value.PublicUrl);
var database_ = new DatabaseInfoDto( var database_ = new DatabaseInfoDto(
dbOptions.Value.Provider, dbOptions.Value.Provider, host, port, database,
host, port, database, dbOptions.Value.MigrateOnStartup, dbOptions.Value.AutoProvisionLocalTimescaleDb,
dbOptions.Value.MigrateOnStartup,
dbOptions.Value.AutoProvisionLocalTimescaleDb,
dbResolution.Source); dbResolution.Source);
var grafana = new GrafanaInfoDto( var grafana = new GrafanaInfoDto(
grafanaOptions.Value.BaseUrl, grafanaOptions.Value.BaseUrl, grafanaOptions.Value.InternalUrl,
grafanaOptions.Value.InternalUrl, grafanaOptions.Value.EmbedPathPrefix, grafanaOptions.Value.EmbedMode,
grafanaOptions.Value.EmbedPathPrefix, grafanaOptions.Value.DefaultDashboardUid, grafanaOptions.Value.AuthMode,
grafanaOptions.Value.EmbedMode,
grafanaOptions.Value.DefaultDashboardUid,
grafanaOptions.Value.AuthMode,
grafanaOptions.Value.Dashboards.Count); grafanaOptions.Value.Dashboards.Count);
var monitoring = new MonitoringInfoDto( var monitoring = new MonitoringInfoDto(
@ -53,8 +51,7 @@ public sealed class ConfigOverviewService(
monitoringOptions.Value.EnableHourlyAggregates); monitoringOptions.Value.EnableHourlyAggregates);
var auth = new AuthInfoDto( var auth = new AuthInfoDto(
authOptions.Value.CookieName, authOptions.Value.CookieName, authOptions.Value.RequireConfirmedEmail,
authOptions.Value.RequireConfirmedEmail,
authOptions.Value.DefaultAdminEmail); authOptions.Value.DefaultAdminEmail);
var assembly = typeof(ConfigOverviewService).Assembly.GetName(); var assembly = typeof(ConfigOverviewService).Assembly.GetName();
@ -63,7 +60,47 @@ public sealed class ConfigOverviewService(
Environment.Version.ToString(), Environment.Version.ToString(),
dbResolution.StartedAtUtc); 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<FleetPushStateRowDto>? 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<AppDbContext>();
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) private static (string Host, int Port, string Database) ParseConnection(string connectionString)

View File

@ -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);
}
}