From 69a3eb192f0021f25120dd4ee7e111930aa16df6 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:42:55 +0100 Subject: [PATCH] feat: persistent per-application config with GET/PUT endpoints Add application_config table (V4 migration), repository, and REST controller. GET /api/v1/config/{app} returns config, PUT saves and pushes CONFIG_UPDATE to all LIVE agents via SSE. UI tracing toggle now uses config API instead of direct SET_TRACED_PROCESSORS command. Tracing store syncs with server config on load. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ApplicationConfigController.java | 104 ++++++++++++++++++ .../server/app/security/SecurityConfig.java | 4 + .../PostgresApplicationConfigRepository.java | 59 ++++++++++ .../db/migration/V4__application_config.sql | 9 ++ ui/src/api/queries/commands.ts | 47 +++++++- .../pages/ExchangeDetail/ExchangeDetail.tsx | 38 ++++--- ui/src/stores/tracing-store.ts | 12 ++ 7 files changed, 257 insertions(+), 16 deletions(-) create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ApplicationConfigController.java create mode 100644 cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresApplicationConfigRepository.java create mode 100644 cameleer3-server-app/src/main/resources/db/migration/V4__application_config.sql 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 new file mode 100644 index 00000000..67a90cf1 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/ApplicationConfigController.java @@ -0,0 +1,104 @@ +package com.cameleer3.server.app.controller; + +import com.cameleer3.common.model.ApplicationConfig; +import com.cameleer3.server.app.storage.PostgresApplicationConfigRepository; +import com.cameleer3.server.core.agent.AgentCommand; +import com.cameleer3.server.core.agent.AgentInfo; +import com.cameleer3.server.core.agent.AgentRegistryService; +import com.cameleer3.server.core.agent.AgentState; +import com.cameleer3.server.core.agent.CommandType; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * Per-application configuration management. + * Agents fetch config at startup; the UI modifies config which is persisted and pushed to agents via SSE. + */ +@RestController +@RequestMapping("/api/v1/config") +@Tag(name = "Application Config", description = "Per-application observability configuration") +public class ApplicationConfigController { + + private static final Logger log = LoggerFactory.getLogger(ApplicationConfigController.class); + + private final PostgresApplicationConfigRepository configRepository; + private final AgentRegistryService registryService; + private final ObjectMapper objectMapper; + + public ApplicationConfigController(PostgresApplicationConfigRepository configRepository, + AgentRegistryService registryService, + ObjectMapper objectMapper) { + this.configRepository = configRepository; + this.registryService = registryService; + this.objectMapper = objectMapper; + } + + @GetMapping("/{application}") + @Operation(summary = "Get application config", + description = "Returns the current configuration for an application. Returns defaults if none stored.") + @ApiResponse(responseCode = "200", description = "Config returned") + public ResponseEntity getConfig(@PathVariable String application) { + return ResponseEntity.ok( + configRepository.findByApplication(application) + .orElse(defaultConfig(application))); + } + + @PutMapping("/{application}") + @Operation(summary = "Update application config", + description = "Saves config and pushes CONFIG_UPDATE to all LIVE agents of this application") + @ApiResponse(responseCode = "200", description = "Config saved and pushed") + public ResponseEntity updateConfig(@PathVariable String application, + @RequestBody ApplicationConfig config, + Authentication auth) { + String updatedBy = auth != null ? auth.getName() : "system"; + + config.setApplication(application); + ApplicationConfig saved = configRepository.save(application, config, updatedBy); + + int pushed = pushConfigToAgents(application, saved); + log.info("Config v{} saved for '{}', pushed to {} agent(s)", saved.getVersion(), application, pushed); + + return ResponseEntity.ok(saved); + } + + private int pushConfigToAgents(String application, ApplicationConfig config) { + String payloadJson; + try { + payloadJson = objectMapper.writeValueAsString(config); + } catch (JsonProcessingException e) { + log.error("Failed to serialize config for push", e); + return 0; + } + + List agents = registryService.findAll().stream() + .filter(a -> a.state() == AgentState.LIVE) + .filter(a -> application.equals(a.application())) + .toList(); + + for (AgentInfo agent : agents) { + registryService.addCommand(agent.id(), CommandType.CONFIG_UPDATE, payloadJson); + } + return agents.size(); + } + + private static ApplicationConfig defaultConfig(String application) { + ApplicationConfig config = new ApplicationConfig(); + config.setApplication(application); + config.setVersion(0); + config.setMetricsEnabled(true); + config.setSamplingRate(1.0); + config.setTracedProcessors(Map.of()); + return config; + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java index 4cbd2d2e..1b5ec7b1 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/SecurityConfig.java @@ -77,6 +77,10 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT") .requestMatchers(HttpMethod.POST, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") + // Application config endpoints + .requestMatchers(HttpMethod.GET, "/api/v1/config/*").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT") + .requestMatchers(HttpMethod.PUT, "/api/v1/config/*").hasAnyRole("OPERATOR", "ADMIN") + // Read-only data endpoints — viewer+ .requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") 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 new file mode 100644 index 00000000..be4adc02 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresApplicationConfigRepository.java @@ -0,0 +1,59 @@ +package com.cameleer3.server.app.storage; + +import com.cameleer3.common.model.ApplicationConfig; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public class PostgresApplicationConfigRepository { + + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + + public PostgresApplicationConfigRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + } + + public Optional findByApplication(String application) { + List results = jdbc.query( + "SELECT config_val FROM application_config WHERE application = ?", + (rs, rowNum) -> { + try { + return objectMapper.readValue(rs.getString("config_val"), ApplicationConfig.class); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to deserialize application config", e); + } + }, + application); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + + public ApplicationConfig save(String application, ApplicationConfig config, String updatedBy) { + String json; + try { + json = objectMapper.writeValueAsString(config); + } catch (JsonProcessingException e) { + throw new RuntimeException("Failed to serialize application config", e); + } + + // Upsert: insert or update, auto-increment version + int updated = jdbc.update(""" + INSERT INTO application_config (application, config_val, version, updated_at, updated_by) + VALUES (?, ?::jsonb, 1, now(), ?) + ON CONFLICT (application) DO UPDATE SET + config_val = EXCLUDED.config_val, + version = application_config.version + 1, + updated_at = now(), + updated_by = EXCLUDED.updated_by + """, + application, json, updatedBy); + + return findByApplication(application).orElseThrow(); + } +} diff --git a/cameleer3-server-app/src/main/resources/db/migration/V4__application_config.sql b/cameleer3-server-app/src/main/resources/db/migration/V4__application_config.sql new file mode 100644 index 00000000..ffff157a --- /dev/null +++ b/cameleer3-server-app/src/main/resources/db/migration/V4__application_config.sql @@ -0,0 +1,9 @@ +-- Per-application configuration for agent observability settings. +-- Agents download this at startup and receive updates via SSE CONFIG_UPDATE. +CREATE TABLE application_config ( + application TEXT PRIMARY KEY, + config_val JSONB NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_by TEXT +); diff --git a/ui/src/api/queries/commands.ts b/ui/src/api/queries/commands.ts index 08fdb93b..2f039603 100644 --- a/ui/src/api/queries/commands.ts +++ b/ui/src/api/queries/commands.ts @@ -1,6 +1,51 @@ -import { useMutation } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '../client' +// ── Application Config ──────────────────────────────────────────────────── + +export interface ApplicationConfig { + application: string + version: number + updatedAt?: string + engineLevel?: string + payloadCaptureMode?: string + metricsEnabled: boolean + samplingRate: number + tracedProcessors: Record +} + +export function useApplicationConfig(application: string | undefined) { + return useQuery({ + queryKey: ['applicationConfig', application], + queryFn: async () => { + const res = await fetch(`/api/v1/config/${application}`) + if (!res.ok) throw new Error('Failed to fetch config') + return res.json() as Promise + }, + enabled: !!application, + }) +} + +export function useUpdateApplicationConfig() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (config: ApplicationConfig) => { + const res = await fetch(`/api/v1/config/${config.application}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config), + }) + if (!res.ok) throw new Error('Failed to update config') + return res.json() as Promise + }, + onSuccess: (saved) => { + queryClient.setQueryData(['applicationConfig', saved.application], saved) + }, + }) +} + +// ── Generic Group Command (kept for non-config commands) ────────────────── + interface SendGroupCommandParams { group: string type: string diff --git a/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx b/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx index 0339f74b..059d9ba6 100644 --- a/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx +++ b/ui/src/pages/ExchangeDetail/ExchangeDetail.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback } from 'react' +import { useState, useMemo, useCallback, useEffect } from 'react' import { useParams, useNavigate } from 'react-router' import { Badge, StatusDot, MonoText, CodeBlock, InfoCallout, @@ -10,7 +10,7 @@ import { useCorrelationChain } from '../../api/queries/correlation' import { useDiagramLayout } from '../../api/queries/diagrams' import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping' import { useTracingStore } from '../../stores/tracing-store' -import { useSendGroupCommand } from '../../api/queries/commands' +import { useApplicationConfig, useUpdateApplicationConfig } from '../../api/queries/commands' import styles from './ExchangeDetail.module.css' // ── Helpers ────────────────────────────────────────────────────────────────── @@ -187,27 +187,35 @@ export default function ExchangeDetail() { // ── Tracing toggle ────────────────────────────────────────────────────── const { toast } = useToast() const tracingStore = useTracingStore() - const sendCommand = useSendGroupCommand() const app = detail?.applicationName ?? '' + const { data: appConfig } = useApplicationConfig(app || undefined) + const updateConfig = useUpdateApplicationConfig() + + // Sync tracing store with server config + useEffect(() => { + if (appConfig?.tracedProcessors && app) { + tracingStore.syncFromServer(app, appConfig.tracedProcessors) + } + }, [appConfig, app]) const handleToggleTracing = useCallback((processorId: string) => { - if (!processorId || !detail?.applicationName) return + if (!processorId || !detail?.applicationName || !appConfig) return const newMap = tracingStore.toggleProcessor(app, processorId) - sendCommand.mutate({ - group: detail.applicationName, - type: 'set-traced-processors', - payload: { processors: newMap }, - }, { - onSuccess: (data) => { + const updatedConfig = { + ...appConfig, + tracedProcessors: { ...newMap }, + } + updateConfig.mutate(updatedConfig, { + onSuccess: (saved) => { const action = processorId in newMap ? 'enabled' : 'disabled' - toast({ title: `Tracing ${action}`, description: `${processorId} — sent to ${data?.targetCount ?? 0} agent(s)`, variant: 'success' }) + toast({ title: `Tracing ${action}`, description: `${processorId} — config v${saved.version}`, variant: 'success' }) }, onError: () => { tracingStore.toggleProcessor(app, processorId) - toast({ title: 'Command failed', description: 'Could not send tracing command', variant: 'error' }) + toast({ title: 'Config update failed', description: 'Could not save configuration', variant: 'error' }) }, }) - }, [detail, app, tracingStore, sendCommand, toast]) + }, [detail, app, appConfig, tracingStore, updateConfig, toast]) // Correlation chain const correlatedExchanges = useMemo(() => { @@ -372,7 +380,7 @@ export default function ExchangeDetail() { return [{ label: tracingStore.isTraced(app, pid) ? 'Disable Tracing' : 'Enable Tracing', onClick: () => handleToggleTracing(pid), - disabled: sendCommand.isPending, + disabled: updateConfig.isPending, }] }} /> @@ -391,7 +399,7 @@ export default function ExchangeDetail() { return [{ label: tracingStore.isTraced(app, pid) ? 'Disable Tracing' : 'Enable Tracing', onClick: () => handleToggleTracing(pid), - disabled: sendCommand.isPending, + disabled: updateConfig.isPending, }] }} /> diff --git a/ui/src/stores/tracing-store.ts b/ui/src/stores/tracing-store.ts index c5c8c3be..cb506f2d 100644 --- a/ui/src/stores/tracing-store.ts +++ b/ui/src/stores/tracing-store.ts @@ -8,6 +8,8 @@ interface TracingState { isTraced: (app: string, processorId: string) => boolean /** Toggle processor tracing (BOTH on, remove on off). Returns the full map for the app. */ toggleProcessor: (app: string, processorId: string) => Record + /** Sync store with server-side config (called when config is fetched). */ + syncFromServer: (app: string, tracedProcessors: Record) => void } export const useTracingStore = create((set, get) => ({ @@ -25,4 +27,14 @@ export const useTracingStore = create((set, get) => ({ })) return current }, + + syncFromServer: (app, serverMap) => { + const mapped: Record = {} + for (const [k, v] of Object.entries(serverMap)) { + mapped[k] = v as CaptureMode + } + set((state) => ({ + tracedProcessors: { ...state.tracedProcessors, [app]: mapped }, + })) + }, }))