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: }, ],