From b0484459a25a94b11d3a927665e5f4d99d8430ed Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 26 Mar 2026 12:51:07 +0100 Subject: [PATCH] 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) --- .../ApplicationConfigController.java | 11 ++ .../PostgresApplicationConfigRepository.java | 15 ++ ui/src/api/queries/commands.ts | 13 ++ ui/src/components/LayoutShell.tsx | 1 + ui/src/pages/Admin/AdminLayout.tsx | 1 + ui/src/pages/Admin/AppConfigPage.module.css | 53 ++++++ ui/src/pages/Admin/AppConfigPage.tsx | 172 ++++++++++++++++++ .../pages/AgentHealth/AgentHealth.module.css | 62 +++++++ ui/src/pages/AgentHealth/AgentHealth.tsx | 81 ++++++++- ui/src/router.tsx | 2 + 10 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 ui/src/pages/Admin/AppConfigPage.module.css create mode 100644 ui/src/pages/Admin/AppConfigPage.tsx diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ApplicationConfigController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ApplicationConfigController.java index 67a90cf1..970db4a9 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ApplicationConfigController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ApplicationConfigController.java @@ -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> 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; } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresApplicationConfigRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresApplicationConfigRepository.java index 14fa6e93..8274ea74 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresApplicationConfigRepository.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresApplicationConfigRepository.java @@ -20,6 +20,21 @@ public class PostgresApplicationConfigRepository { this.objectMapper = objectMapper; } + public List 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 findByApplication(String application) { List results = jdbc.query( "SELECT config_val, version, updated_at FROM application_config WHERE application = ?", diff --git a/ui/src/api/queries/commands.ts b/ui/src/api/queries/commands.ts index b761e80e..853c447f 100644 --- a/ui/src/api/queries/commands.ts +++ b/ui/src/api/queries/commands.ts @@ -10,6 +10,7 @@ export interface ApplicationConfig { updatedAt?: string engineLevel?: string payloadCaptureMode?: string + logForwardingLevel?: string metricsEnabled: boolean samplingRate: number tracedProcessors: Record @@ -24,6 +25,17 @@ function authFetch(url: string, init?: RequestInit): Promise { 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 + }, + }) +} + 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'] }) }, }) } diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index 982bd31a..475941c9 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -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) => ({ diff --git a/ui/src/pages/Admin/AdminLayout.tsx b/ui/src/pages/Admin/AdminLayout.tsx index 458a67db..70e4f4a6 100644 --- a/ui/src/pages/Admin/AdminLayout.tsx +++ b/ui/src/pages/Admin/AdminLayout.tsx @@ -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' }, ]; diff --git a/ui/src/pages/Admin/AppConfigPage.module.css b/ui/src/pages/Admin/AppConfigPage.module.css new file mode 100644 index 00000000..7f0ab0f1 --- /dev/null +++ b/ui/src/pages/Admin/AppConfigPage.module.css @@ -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; +} diff --git a/ui/src/pages/Admin/AppConfigPage.tsx b/ui/src/pages/Admin/AppConfigPage.tsx new file mode 100644 index 00000000..04b5360f --- /dev/null +++ b/ui/src/pages/Admin/AppConfigPage.tsx @@ -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[] = useMemo(() => [ + { + key: '_inspect', + header: '', + width: '36px', + render: (_val, row) => ( + + ), + }, + { + key: 'application', + header: 'Application', + sortable: true, + render: (_val, row) => {row.application}, + }, + { + key: 'logForwardingLevel', + header: 'Log Level', + render: (_val, row) => ( + + ), + }, + { + key: 'engineLevel', + header: 'Engine Level', + render: (_val, row) => ( + + ), + }, + { + key: 'payloadCaptureMode', + header: 'Payload Capture', + render: (_val, row) => ( + + ), + }, + { + key: 'metricsEnabled', + header: 'Metrics', + width: '80px', + render: (_val, row) => ( + + ), + }, + { + key: 'tracedProcessors', + header: 'Traced', + width: '70px', + render: (_val, row) => { + const count = row.tracedProcessors ? Object.keys(row.tracedProcessors).length : 0; + return count > 0 + ? + : 0; + }, + }, + { + key: 'version', + header: 'v', + width: '40px', + render: (_val, row) => {row.version}, + }, + { + key: 'updatedAt', + header: 'Updated', + render: (_val, row) => {timeAgo(row.updatedAt)}, + }, + ], [navigate, handleChange, updateConfig.isPending]); + + return ( +
+ + columns={columns} + data={configs ?? []} + pageSize={50} + /> +
+ ); +} diff --git a/ui/src/pages/AgentHealth/AgentHealth.module.css b/ui/src/pages/AgentHealth/AgentHealth.module.css index 744f577f..98efca28 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.module.css +++ b/ui/src/pages/AgentHealth/AgentHealth.module.css @@ -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 { diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index 1bafae5f..2623b737 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -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(); const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo); @@ -489,6 +506,66 @@ export default function AgentHealth() { /> + {/* Application config bar */} + {appId && appConfig && ( +
+
+ Log Level + +
+
+ Engine Level + +
+
+ Payload Capture + +
+
+ Metrics + +
+
+ )} + {/* Group cards grid */}
{groups.map((group) => ( diff --git a/ui/src/router.tsx b/ui/src/router.tsx index e8ae3d41..48eadc04 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -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: }, { path: 'audit', element: }, { path: 'oidc', element: }, + { path: 'appconfig', element: }, { path: 'database', element: }, { path: 'opensearch', element: }, ],