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';
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<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 { fetchConfigOverview } from '../../api/adminConfig';
import { fetchConfigOverview, type FleetPushStateRow } from '../../api/adminConfig';
const { Text } = Typography;
@ -31,7 +32,12 @@ export function ConfigOverviewCard() {
{data.application.environment}
</Tag>
</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 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>
<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=".NET runtime">{data.build.framework}</Descriptions.Item>
<Descriptions.Item label="Started" span={2}>{new Date(data.build.startedAtUtc).toLocaleString()}</Descriptions.Item>
</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>
);
}
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 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<FleetPushStateRowDto>? FleetPushState);

View File

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

View File

@ -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> grafanaOptions,
IOptions<MonitoringOptions> monitoringOptions,
IOptions<AuthenticationOptions> authOptions,
IOptions<FleetIngestOptions> fleetOptions,
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 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<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)

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