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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = ?",
|
||||
|
||||
@@ -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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
53
ui/src/pages/Admin/AppConfigPage.module.css
Normal file
53
ui/src/pages/Admin/AppConfigPage.module.css
Normal 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;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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> },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user