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:
parent
c5787a7a7f
commit
aaa522058e
@ -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> {
|
||||
|
||||
@ -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>
|
||||
},
|
||||
];
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user