feat: add application config overview and inline editing
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:
172
ui/src/pages/Admin/AppConfigPage.tsx
Normal file
172
ui/src/pages/Admin/AppConfigPage.tsx
Normal 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}`);
|
||||
}}
|
||||
>
|
||||
↗
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user