feat: add application config overview and inline editing
Some checks failed
CI / cleanup-branch (push) Has been skipped
CI / build (push) Failing after 22s
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped

Add admin page at /admin/appconfig with a DataTable showing all
application configurations. Inline dropdowns allow editing log level,
engine level, payload capture mode, and metrics toggle directly from
the table. Changes push to agents via SSE immediately.

Also adds a config bar on the AgentHealth page (/agents/:appId) for
per-application config management with the same 4 settings.

Backend: GET /api/v1/config list endpoint, findAll() on repository,
sensible defaults for logForwardingLevel/engineLevel/payloadCaptureMode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-26 12:51:07 +01:00
parent 056a6f0ff5
commit b0484459a2
10 changed files with 409 additions and 2 deletions

View File

@@ -0,0 +1,172 @@
import { useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router';
import { DataTable, Badge, MonoText, useToast } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useAllApplicationConfigs, useUpdateApplicationConfig } from '../../api/queries/commands';
import type { ApplicationConfig } from '../../api/queries/commands';
import styles from './AppConfigPage.module.css';
function timeAgo(iso?: string): string {
if (!iso) return '\u2014';
const diff = Date.now() - new Date(iso).getTime();
const secs = Math.floor(diff / 1000);
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
return `${Math.floor(hours / 24)}d ago`;
}
function logLevelColor(level?: string): string {
switch (level?.toUpperCase()) {
case 'ERROR': return 'error';
case 'WARN': return 'warning';
case 'DEBUG': return 'running';
default: return 'success';
}
}
export default function AppConfigPage() {
const navigate = useNavigate();
const { toast } = useToast();
const { data: configs } = useAllApplicationConfigs();
const updateConfig = useUpdateApplicationConfig();
const handleChange = useCallback((config: ApplicationConfig, field: string, value: string | boolean) => {
const updated = { ...config, [field]: value };
updateConfig.mutate(updated, {
onSuccess: (saved) => {
toast({ title: 'Config updated', description: `${config.application}: ${field} \u2192 ${value} (v${saved.version})`, variant: 'success' });
},
onError: () => {
toast({ title: 'Config update failed', description: config.application, variant: 'error' });
},
});
}, [updateConfig, toast]);
const columns: Column<ApplicationConfig>[] = useMemo(() => [
{
key: '_inspect',
header: '',
width: '36px',
render: (_val, row) => (
<button
className={styles.inspectLink}
title="Open agent page"
onClick={(e) => {
e.stopPropagation();
navigate(`/agents/${row.application}`);
}}
>
&#x2197;
</button>
),
},
{
key: 'application',
header: 'Application',
sortable: true,
render: (_val, row) => <MonoText size="sm">{row.application}</MonoText>,
},
{
key: 'logForwardingLevel',
header: 'Log Level',
render: (_val, row) => (
<select
className={styles.inlineSelect}
value={row.logForwardingLevel ?? 'INFO'}
onChange={(e) => { e.stopPropagation(); handleChange(row, 'logForwardingLevel', e.target.value); }}
disabled={updateConfig.isPending}
>
<option value="ERROR">ERROR</option>
<option value="WARN">WARN</option>
<option value="INFO">INFO</option>
<option value="DEBUG">DEBUG</option>
</select>
),
},
{
key: 'engineLevel',
header: 'Engine Level',
render: (_val, row) => (
<select
className={styles.inlineSelect}
value={row.engineLevel ?? 'REGULAR'}
onChange={(e) => { e.stopPropagation(); handleChange(row, 'engineLevel', e.target.value); }}
disabled={updateConfig.isPending}
>
<option value="NONE">None</option>
<option value="MINIMAL">Minimal</option>
<option value="REGULAR">Regular</option>
<option value="COMPLETE">Complete</option>
</select>
),
},
{
key: 'payloadCaptureMode',
header: 'Payload Capture',
render: (_val, row) => (
<select
className={styles.inlineSelect}
value={row.payloadCaptureMode ?? 'NONE'}
onChange={(e) => { e.stopPropagation(); handleChange(row, 'payloadCaptureMode', e.target.value); }}
disabled={updateConfig.isPending}
>
<option value="NONE">None</option>
<option value="INPUT">Input</option>
<option value="OUTPUT">Output</option>
<option value="BOTH">Both</option>
</select>
),
},
{
key: 'metricsEnabled',
header: 'Metrics',
width: '80px',
render: (_val, row) => (
<label className={styles.inlineToggle} onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={row.metricsEnabled}
onChange={(e) => handleChange(row, 'metricsEnabled', e.target.checked)}
disabled={updateConfig.isPending}
/>
<span>{row.metricsEnabled ? 'On' : 'Off'}</span>
</label>
),
},
{
key: 'tracedProcessors',
header: 'Traced',
width: '70px',
render: (_val, row) => {
const count = row.tracedProcessors ? Object.keys(row.tracedProcessors).length : 0;
return count > 0
? <Badge label={`${count}`} color="running" variant="filled" />
: <MonoText size="xs">0</MonoText>;
},
},
{
key: 'version',
header: 'v',
width: '40px',
render: (_val, row) => <MonoText size="xs">{row.version}</MonoText>,
},
{
key: 'updatedAt',
header: 'Updated',
render: (_val, row) => <MonoText size="xs">{timeAgo(row.updatedAt)}</MonoText>,
},
], [navigate, handleChange, updateConfig.isPending]);
return (
<div>
<DataTable<ApplicationConfig>
columns={columns}
data={configs ?? []}
pageSize={50}
/>
</div>
);
}