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

@@ -44,6 +44,14 @@ public class ApplicationConfigController {
this.objectMapper = objectMapper;
}
@GetMapping
@Operation(summary = "List all application configs",
description = "Returns stored configurations for all applications")
@ApiResponse(responseCode = "200", description = "Configs returned")
public ResponseEntity<List<ApplicationConfig>> listConfigs() {
return ResponseEntity.ok(configRepository.findAll());
}
@GetMapping("/{application}")
@Operation(summary = "Get application config",
description = "Returns the current configuration for an application. Returns defaults if none stored.")
@@ -99,6 +107,9 @@ public class ApplicationConfigController {
config.setMetricsEnabled(true);
config.setSamplingRate(1.0);
config.setTracedProcessors(Map.of());
config.setLogForwardingLevel("INFO");
config.setEngineLevel("REGULAR");
config.setPayloadCaptureMode("NONE");
return config;
}
}

View File

@@ -20,6 +20,21 @@ public class PostgresApplicationConfigRepository {
this.objectMapper = objectMapper;
}
public List<ApplicationConfig> findAll() {
return jdbc.query(
"SELECT config_val, version, updated_at FROM application_config ORDER BY application",
(rs, rowNum) -> {
try {
ApplicationConfig cfg = objectMapper.readValue(rs.getString("config_val"), ApplicationConfig.class);
cfg.setVersion(rs.getInt("version"));
cfg.setUpdatedAt(rs.getTimestamp("updated_at").toInstant());
return cfg;
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to deserialize application config", e);
}
});
}
public Optional<ApplicationConfig> findByApplication(String application) {
List<ApplicationConfig> results = jdbc.query(
"SELECT config_val, version, updated_at FROM application_config WHERE application = ?",

View File

@@ -10,6 +10,7 @@ export interface ApplicationConfig {
updatedAt?: string
engineLevel?: string
payloadCaptureMode?: string
logForwardingLevel?: string
metricsEnabled: boolean
samplingRate: number
tracedProcessors: Record<string, string>
@@ -24,6 +25,17 @@ function authFetch(url: string, init?: RequestInit): Promise<Response> {
return fetch(url, { ...init, headers })
}
export function useAllApplicationConfigs() {
return useQuery({
queryKey: ['applicationConfig', 'all'],
queryFn: async () => {
const res = await authFetch('/api/v1/config')
if (!res.ok) throw new Error('Failed to fetch configs')
return res.json() as Promise<ApplicationConfig[]>
},
})
}
export function useApplicationConfig(application: string | undefined) {
return useQuery({
queryKey: ['applicationConfig', application],
@@ -50,6 +62,7 @@ export function useUpdateApplicationConfig() {
},
onSuccess: (saved) => {
queryClient.setQueryData(['applicationConfig', saved.application], saved)
queryClient.invalidateQueries({ queryKey: ['applicationConfig', 'all'] })
},
})
}

View File

@@ -156,6 +156,7 @@ function LayoutContent() {
oidc: 'OIDC',
database: 'Database',
opensearch: 'OpenSearch',
appconfig: 'App Config',
};
const parts = location.pathname.split('/').filter(Boolean);
return parts.map((part, i) => ({

View File

@@ -5,6 +5,7 @@ const ADMIN_TABS = [
{ label: 'User Management', value: '/admin/rbac' },
{ label: 'Audit Log', value: '/admin/audit' },
{ label: 'OIDC', value: '/admin/oidc' },
{ label: 'App Config', value: '/admin/appconfig' },
{ label: 'Database', value: '/admin/database' },
{ label: 'OpenSearch', value: '/admin/opensearch' },
];

View File

@@ -0,0 +1,53 @@
.inspectLink {
background: transparent;
border: none;
color: var(--text-faint);
opacity: 0.75;
cursor: pointer;
font-size: 13px;
padding: 2px 4px;
border-radius: var(--radius-sm);
line-height: 1;
display: inline-flex;
}
.inspectLink:hover {
color: var(--text-primary);
opacity: 1;
}
.inlineSelect {
padding: 3px 8px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-body);
color: var(--text-primary);
font-size: 11px;
font-family: var(--font-mono);
outline: none;
cursor: pointer;
}
.inlineSelect:focus {
border-color: var(--amber);
}
.inlineSelect:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.inlineToggle {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-secondary);
cursor: pointer;
}
.inlineToggle input {
accent-color: var(--amber);
cursor: pointer;
}

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

View File

@@ -31,6 +31,68 @@
.routesWarning { color: var(--warning); }
.routesError { color: var(--error); }
/* Application config bar */
.configBar {
display: flex;
align-items: flex-end;
gap: 20px;
padding: 12px 16px;
margin-bottom: 16px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
}
.configField {
display: flex;
flex-direction: column;
gap: 4px;
}
.configLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.configSelect {
padding: 5px 10px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--bg-body);
color: var(--text-primary);
font-size: 12px;
font-family: var(--font-mono);
outline: none;
cursor: pointer;
}
.configSelect:focus {
border-color: var(--amber);
}
.configSelect:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.configToggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-family: var(--font-mono);
color: var(--text-secondary);
cursor: pointer;
}
.configToggle input {
accent-color: var(--amber);
cursor: pointer;
}
/* Section header */
.sectionTitle {

View File

@@ -1,15 +1,16 @@
import { useState, useMemo } from 'react';
import { useState, useMemo, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router';
import {
StatCard, StatusDot, Badge, MonoText, ProgressBar,
GroupCard, DataTable, LineChart, EventFeed, DetailPanel,
LogViewer, ButtonGroup, SectionHeader,
LogViewer, ButtonGroup, SectionHeader, useToast,
} from '@cameleer/design-system';
import type { Column, FeedEvent, LogEntry, ButtonGroupItem } from '@cameleer/design-system';
import styles from './AgentHealth.module.css';
import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useApplicationLogs } from '../../api/queries/logs';
import { useAgentMetrics } from '../../api/queries/agent-metrics';
import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands';
import type { AgentInstance } from '../../api/types';
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -241,7 +242,23 @@ function mapLogLevel(level: string): LogEntry['level'] {
export default function AgentHealth() {
const { appId } = useParams();
const navigate = useNavigate();
const { toast } = useToast();
const { data: agents } = useAgents(undefined, appId);
const { data: appConfig } = useApplicationConfig(appId);
const updateConfig = useUpdateApplicationConfig();
const handleConfigChange = useCallback((field: string, value: string | boolean) => {
if (!appConfig) return;
const updated = { ...appConfig, [field]: value };
updateConfig.mutate(updated, {
onSuccess: (saved) => {
toast({ title: 'Config updated', description: `${field}${value} (v${saved.version})`, variant: 'success' });
},
onError: () => {
toast({ title: 'Config update failed', variant: 'error' });
},
});
}, [appConfig, updateConfig, toast]);
const [eventSortAsc, setEventSortAsc] = useState(false);
const [eventRefreshTo, setEventRefreshTo] = useState<string | undefined>();
const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo);
@@ -489,6 +506,66 @@ export default function AgentHealth() {
/>
</div>
{/* Application config bar */}
{appId && appConfig && (
<div className={styles.configBar}>
<div className={styles.configField}>
<span className={styles.configLabel}>Log Level</span>
<select
className={styles.configSelect}
value={appConfig.logForwardingLevel ?? 'INFO'}
onChange={(e) => handleConfigChange('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>
</div>
<div className={styles.configField}>
<span className={styles.configLabel}>Engine Level</span>
<select
className={styles.configSelect}
value={appConfig.engineLevel ?? 'REGULAR'}
onChange={(e) => handleConfigChange('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>
</div>
<div className={styles.configField}>
<span className={styles.configLabel}>Payload Capture</span>
<select
className={styles.configSelect}
value={appConfig.payloadCaptureMode ?? 'NONE'}
onChange={(e) => handleConfigChange('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>
</div>
<div className={styles.configField}>
<span className={styles.configLabel}>Metrics</span>
<label className={styles.configToggle}>
<input
type="checkbox"
checked={appConfig.metricsEnabled}
onChange={(e) => handleConfigChange('metricsEnabled', e.target.checked)}
disabled={updateConfig.isPending}
/>
<span>{appConfig.metricsEnabled ? 'Enabled' : 'Disabled'}</span>
</label>
</div>
</div>
)}
{/* Group cards grid */}
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
{groups.map((group) => (

View File

@@ -18,6 +18,7 @@ const AuditLogPage = lazy(() => import('./pages/Admin/AuditLogPage'));
const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage'));
const DatabaseAdminPage = lazy(() => import('./pages/Admin/DatabaseAdminPage'));
const OpenSearchAdminPage = lazy(() => import('./pages/Admin/OpenSearchAdminPage'));
const AppConfigPage = lazy(() => import('./pages/Admin/AppConfigPage'));
const SwaggerPage = lazy(() => import('./pages/Swagger/SwaggerPage'));
function SuspenseWrapper({ children }: { children: React.ReactNode }) {
@@ -56,6 +57,7 @@ export const router = createBrowserRouter([
{ path: 'rbac', element: <SuspenseWrapper><RbacPage /></SuspenseWrapper> },
{ path: 'audit', element: <SuspenseWrapper><AuditLogPage /></SuspenseWrapper> },
{ path: 'oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
{ path: 'appconfig', element: <SuspenseWrapper><AppConfigPage /></SuspenseWrapper> },
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
{ path: 'opensearch', element: <SuspenseWrapper><OpenSearchAdminPage /></SuspenseWrapper> },
],