diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentConfigController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentConfigController.java new file mode 100644 index 00000000..dab1e3cd --- /dev/null +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentConfigController.java @@ -0,0 +1,115 @@ +package com.cameleer.server.app.controller; + +import com.cameleer.common.model.ApplicationConfig; +import com.cameleer.server.app.dto.AppConfigResponse; +import com.cameleer.server.app.security.JwtAuthenticationFilter; +import com.cameleer.server.app.storage.PostgresApplicationConfigRepository; +import com.cameleer.server.core.admin.SensitiveKeysConfig; +import com.cameleer.server.core.admin.SensitiveKeysMerger; +import com.cameleer.server.core.admin.SensitiveKeysRepository; +import com.cameleer.server.core.agent.AgentInfo; +import com.cameleer.server.core.agent.AgentRegistryService; +import com.cameleer.server.core.security.JwtService.JwtValidationResult; +import com.fasterxml.jackson.databind.JsonNode; +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 jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * Agent-authoritative application config read. Env and application are derived + * from the agent's JWT + registry entry — not from the URL or query params, so + * agents cannot spoof env. + */ +@RestController +@RequestMapping("/api/v1/agents") +@Tag(name = "Agent Config", description = "Agent-authoritative config read (AGENT only)") +public class AgentConfigController { + + private final PostgresApplicationConfigRepository configRepository; + private final AgentRegistryService registryService; + private final SensitiveKeysRepository sensitiveKeysRepository; + private final ObjectMapper objectMapper; + + public AgentConfigController(PostgresApplicationConfigRepository configRepository, + AgentRegistryService registryService, + SensitiveKeysRepository sensitiveKeysRepository, + ObjectMapper objectMapper) { + this.configRepository = configRepository; + this.registryService = registryService; + this.sensitiveKeysRepository = sensitiveKeysRepository; + this.objectMapper = objectMapper; + } + + @GetMapping("/config") + @Operation(summary = "Get application config for the calling agent", + description = "Resolves (application, environment) from the agent's JWT + registry. " + + "Prefers the registry entry (heartbeat-authoritative); falls back to the JWT env claim. " + + "Returns 404 if neither identifies a valid agent.") + @ApiResponse(responseCode = "200", description = "Config returned") + @ApiResponse(responseCode = "404", description = "Calling agent could not be resolved") + public ResponseEntity getConfigForAgent(Authentication auth, + HttpServletRequest request) { + String instanceId = auth != null ? auth.getName() : null; + if (instanceId == null || instanceId.isBlank()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + AgentInfo agent = registryService.findById(instanceId); + String application; + String environment; + if (agent != null) { + application = agent.applicationId(); + environment = agent.environmentId(); + } else { + // Registry miss — fall back to JWT env claim; application can't be + // derived from JWT alone, so without a registry entry we 404. + environment = environmentFromJwt(request); + application = null; + } + + if (application == null || application.isBlank() || environment == null || environment.isBlank()) { + return ResponseEntity.notFound().build(); + } + + ApplicationConfig config = configRepository.findByApplicationAndEnvironment(application, environment) + .orElse(ApplicationConfigController.defaultConfig(application, environment)); + + List globalKeys = sensitiveKeysRepository.find() + .map(SensitiveKeysConfig::keys) + .orElse(null); + List merged = SensitiveKeysMerger.merge(globalKeys, extractSensitiveKeys(config)); + + return ResponseEntity.ok(new AppConfigResponse(config, globalKeys, merged)); + } + + private static String environmentFromJwt(HttpServletRequest request) { + Object attr = request.getAttribute(JwtAuthenticationFilter.JWT_RESULT_ATTR); + if (attr instanceof JwtValidationResult result) { + return result.environment(); + } + return null; + } + + private List extractSensitiveKeys(ApplicationConfig config) { + try { + JsonNode node = objectMapper.valueToTree(config); + JsonNode keysNode = node.get("sensitiveKeys"); + if (keysNode == null || keysNode.isNull() || !keysNode.isArray()) { + return null; + } + return objectMapper.convertValue(keysNode, new com.fasterxml.jackson.core.type.TypeReference>() {}); + } catch (Exception e) { + return null; + } + } +} diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppSettingsController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppSettingsController.java index 25fb86cb..0bc4d9b5 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppSettingsController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AppSettingsController.java @@ -1,11 +1,13 @@ package com.cameleer.server.app.controller; import com.cameleer.server.app.dto.AppSettingsRequest; +import com.cameleer.server.app.web.EnvPath; import com.cameleer.server.core.admin.AppSettings; import com.cameleer.server.core.admin.AppSettingsRepository; import com.cameleer.server.core.admin.AuditCategory; import com.cameleer.server.core.admin.AuditResult; import com.cameleer.server.core.admin.AuditService; +import com.cameleer.server.core.runtime.Environment; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @@ -19,7 +21,6 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; @@ -27,7 +28,7 @@ import java.util.List; import java.util.Map; @RestController -@RequestMapping("/api/v1/admin/app-settings") +@RequestMapping("/api/v1/environments/{envSlug}") @PreAuthorize("hasAnyRole('ADMIN', 'OPERATOR')") @Tag(name = "App Settings", description = "Per-application dashboard settings (ADMIN/OPERATOR)") public class AppSettingsController { @@ -40,25 +41,25 @@ public class AppSettingsController { this.auditService = auditService; } - @GetMapping - @Operation(summary = "List application settings in an environment") - public ResponseEntity> getAll(@RequestParam String environment) { - return ResponseEntity.ok(repository.findByEnvironment(environment)); + @GetMapping("/app-settings") + @Operation(summary = "List application settings in this environment") + public ResponseEntity> getAll(@EnvPath Environment env) { + return ResponseEntity.ok(repository.findByEnvironment(env.slug())); } - @GetMapping("/{appId}") - @Operation(summary = "Get settings for an application in an environment (returns defaults if not configured)") - public ResponseEntity getByAppId(@PathVariable String appId, - @RequestParam String environment) { - AppSettings settings = repository.findByApplicationAndEnvironment(appId, environment) - .orElse(AppSettings.defaults(appId, environment)); + @GetMapping("/apps/{appSlug}/settings") + @Operation(summary = "Get settings for an application in this environment (returns defaults if not configured)") + public ResponseEntity getByAppId(@EnvPath Environment env, + @PathVariable String appSlug) { + AppSettings settings = repository.findByApplicationAndEnvironment(appSlug, env.slug()) + .orElse(AppSettings.defaults(appSlug, env.slug())); return ResponseEntity.ok(settings); } - @PutMapping("/{appId}") - @Operation(summary = "Create or update settings for an application in an environment") - public ResponseEntity update(@PathVariable String appId, - @RequestParam String environment, + @PutMapping("/apps/{appSlug}/settings") + @Operation(summary = "Create or update settings for an application in this environment") + public ResponseEntity update(@EnvPath Environment env, + @PathVariable String appSlug, @Valid @RequestBody AppSettingsRequest request, HttpServletRequest httpRequest) { List errors = request.validate(); @@ -66,20 +67,20 @@ public class AppSettingsController { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors)); } - AppSettings saved = repository.save(request.toSettings(appId, environment)); - auditService.log("update_app_settings", AuditCategory.CONFIG, appId, - Map.of("environment", environment, "settings", saved), AuditResult.SUCCESS, httpRequest); + AppSettings saved = repository.save(request.toSettings(appSlug, env.slug())); + auditService.log("update_app_settings", AuditCategory.CONFIG, appSlug, + Map.of("environment", env.slug(), "settings", saved), AuditResult.SUCCESS, httpRequest); return ResponseEntity.ok(saved); } - @DeleteMapping("/{appId}") - @Operation(summary = "Delete application settings for an environment (reverts to defaults)") - public ResponseEntity delete(@PathVariable String appId, - @RequestParam String environment, + @DeleteMapping("/apps/{appSlug}/settings") + @Operation(summary = "Delete application settings for this environment (reverts to defaults)") + public ResponseEntity delete(@EnvPath Environment env, + @PathVariable String appSlug, HttpServletRequest httpRequest) { - repository.delete(appId, environment); - auditService.log("delete_app_settings", AuditCategory.CONFIG, appId, - Map.of("environment", environment), AuditResult.SUCCESS, httpRequest); + repository.delete(appSlug, env.slug()); + auditService.log("delete_app_settings", AuditCategory.CONFIG, appSlug, + Map.of("environment", env.slug()), AuditResult.SUCCESS, httpRequest); return ResponseEntity.noContent().build(); } } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ApplicationConfigController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ApplicationConfigController.java index a95e5070..cb176867 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ApplicationConfigController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/ApplicationConfigController.java @@ -6,9 +6,8 @@ import com.cameleer.server.app.dto.CommandGroupResponse; import com.cameleer.server.app.dto.ConfigUpdateResponse; import com.cameleer.server.app.dto.TestExpressionRequest; import com.cameleer.server.app.dto.TestExpressionResponse; -import com.cameleer.server.app.security.JwtAuthenticationFilter; import com.cameleer.server.app.storage.PostgresApplicationConfigRepository; -import com.cameleer.server.core.security.JwtService.JwtValidationResult; +import com.cameleer.server.app.web.EnvPath; import com.cameleer.server.core.admin.AuditCategory; import com.cameleer.server.core.admin.AuditResult; import com.cameleer.server.core.admin.AuditService; @@ -20,6 +19,7 @@ import com.cameleer.server.core.agent.AgentRegistryService; import com.cameleer.server.core.agent.AgentState; import com.cameleer.server.core.agent.CommandReply; import com.cameleer.server.core.agent.CommandType; +import com.cameleer.server.core.runtime.Environment; import com.cameleer.server.core.storage.DiagramStore; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -43,12 +43,13 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; /** - * Per-application configuration management. - * Agents fetch config at startup; the UI modifies config which is persisted and pushed to agents via SSE. + * Per-application configuration for UI/admin callers. Env comes from the path, + * app comes from the path. Agents use {@link AgentConfigController} instead — + * env is derived from the JWT there, not spoofable via URL. */ @RestController -@RequestMapping("/api/v1/config") -@Tag(name = "Application Config", description = "Per-application observability configuration") +@RequestMapping("/api/v1/environments/{envSlug}") +@Tag(name = "Application Config", description = "Per-application observability configuration (user-facing)") public class ApplicationConfigController { private static final Logger log = LoggerFactory.getLogger(ApplicationConfigController.class); @@ -74,39 +75,28 @@ public class ApplicationConfigController { this.sensitiveKeysRepository = sensitiveKeysRepository; } - @GetMapping - @Operation(summary = "List application configs in an environment", - description = "Returns stored configurations for all applications in the given environment") + @GetMapping("/config") + @Operation(summary = "List application configs in this environment") @ApiResponse(responseCode = "200", description = "Configs returned") - public ResponseEntity> listConfigs(@RequestParam String environment, + public ResponseEntity> listConfigs(@EnvPath Environment env, HttpServletRequest httpRequest) { auditService.log("view_app_configs", AuditCategory.CONFIG, null, - Map.of("environment", environment), AuditResult.SUCCESS, httpRequest); - return ResponseEntity.ok(configRepository.findByEnvironment(environment)); + Map.of("environment", env.slug()), AuditResult.SUCCESS, httpRequest); + return ResponseEntity.ok(configRepository.findByEnvironment(env.slug())); } - @GetMapping("/{application}") - @Operation(summary = "Get application config for an environment", - description = "For agents: environment is taken from the JWT env claim; the query param is ignored. " - + "For UI/admin callers: environment must be provided via the `environment` query param. " - + "Returns 404 if the environment cannot be resolved. Includes merged sensitive keys.") + @GetMapping("/apps/{appSlug}/config") + @Operation(summary = "Get application config for this environment", + description = "Returns stored config merged with global sensitive keys. " + + "Falls back to defaults if no row is persisted yet.") @ApiResponse(responseCode = "200", description = "Config returned") - @ApiResponse(responseCode = "404", description = "Environment could not be resolved") - public ResponseEntity getConfig(@PathVariable String application, - @RequestParam(required = false) String environment, - Authentication auth, + public ResponseEntity getConfig(@EnvPath Environment env, + @PathVariable String appSlug, HttpServletRequest httpRequest) { - String resolved = resolveEnvironmentForRead(auth, httpRequest, environment); - if (resolved == null || resolved.isBlank()) { - auditService.log("view_app_config", AuditCategory.CONFIG, application, - Map.of("reason", "missing_environment"), AuditResult.FAILURE, httpRequest); - return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); - } - - auditService.log("view_app_config", AuditCategory.CONFIG, application, - Map.of("environment", resolved), AuditResult.SUCCESS, httpRequest); - ApplicationConfig config = configRepository.findByApplicationAndEnvironment(application, resolved) - .orElse(defaultConfig(application, resolved)); + auditService.log("view_app_config", AuditCategory.CONFIG, appSlug, + Map.of("environment", env.slug()), AuditResult.SUCCESS, httpRequest); + ApplicationConfig config = configRepository.findByApplicationAndEnvironment(appSlug, env.slug()) + .orElse(defaultConfig(appSlug, env.slug())); List globalKeys = sensitiveKeysRepository.find() .map(SensitiveKeysConfig::keys) @@ -116,51 +106,32 @@ public class ApplicationConfigController { return ResponseEntity.ok(new AppConfigResponse(config, globalKeys, merged)); } - /** - * Agents identify themselves via AGENT role and a real JWT env claim — use that, - * ignoring any query param (agents can't spoof env). Non-agent callers (admin UI) - * must pass the env explicitly; their JWT env claim is a placeholder and not - * authoritative. - */ - private String resolveEnvironmentForRead(Authentication auth, - HttpServletRequest request, - String queryEnvironment) { - boolean isAgent = auth != null && auth.getAuthorities().stream() - .anyMatch(a -> "ROLE_AGENT".equals(a.getAuthority())); - if (isAgent) { - return environmentFromJwt(request); - } - return queryEnvironment; - } - - @PutMapping("/{application}") - @Operation(summary = "Update application config for an environment", + @PutMapping("/apps/{appSlug}/config") + @Operation(summary = "Update application config for this environment", description = "Saves config and pushes CONFIG_UPDATE to LIVE agents of this application in the given environment") @ApiResponse(responseCode = "200", description = "Config saved and pushed") - public ResponseEntity updateConfig(@PathVariable String application, - @RequestParam String environment, + public ResponseEntity updateConfig(@EnvPath Environment env, + @PathVariable String appSlug, @RequestBody ApplicationConfig config, Authentication auth, HttpServletRequest httpRequest) { String updatedBy = auth != null ? auth.getName() : "system"; - config.setApplication(application); - ApplicationConfig saved = configRepository.save(application, environment, config, updatedBy); + config.setApplication(appSlug); + ApplicationConfig saved = configRepository.save(appSlug, env.slug(), config, updatedBy); - // Merge global + per-app sensitive keys for the SSE push payload List globalKeys = sensitiveKeysRepository.find() .map(SensitiveKeysConfig::keys) .orElse(null); List perAppKeys = extractSensitiveKeys(saved); List mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys); - // Push with merged sensitive keys injected into the payload - CommandGroupResponse pushResult = pushConfigToAgentsWithMergedKeys(application, environment, saved, mergedKeys); + CommandGroupResponse pushResult = pushConfigToAgentsWithMergedKeys(appSlug, env.slug(), saved, mergedKeys); log.info("Config v{} saved for '{}', pushed to {} agent(s), {} responded", - saved.getVersion(), application, pushResult.total(), pushResult.responded()); + saved.getVersion(), appSlug, pushResult.total(), pushResult.responded()); - auditService.log("update_app_config", AuditCategory.CONFIG, application, - Map.of("environment", environment, "version", saved.getVersion(), + auditService.log("update_app_config", AuditCategory.CONFIG, appSlug, + Map.of("environment", env.slug(), "version", saved.getVersion(), "agentsPushed", pushResult.total(), "responded", pushResult.responded(), "timedOut", pushResult.timedOut().size()), AuditResult.SUCCESS, httpRequest); @@ -168,35 +139,34 @@ public class ApplicationConfigController { return ResponseEntity.ok(new ConfigUpdateResponse(saved, pushResult)); } - @GetMapping("/{application}/processor-routes") - @Operation(summary = "Get processor to route mapping for an environment", + @GetMapping("/apps/{appSlug}/processor-routes") + @Operation(summary = "Get processor to route mapping for this environment", description = "Returns a map of processorId → routeId for all processors seen in this application + environment") @ApiResponse(responseCode = "200", description = "Mapping returned") - public ResponseEntity> getProcessorRouteMapping(@PathVariable String application, - @RequestParam String environment) { - return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(application, environment)); + public ResponseEntity> getProcessorRouteMapping(@EnvPath Environment env, + @PathVariable String appSlug) { + return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(appSlug, env.slug())); } - @PostMapping("/{application}/test-expression") - @Operation(summary = "Test a tap expression against sample data via a live agent in an environment") + @PostMapping("/apps/{appSlug}/config/test-expression") + @Operation(summary = "Test a tap expression against sample data via a live agent in this environment") @ApiResponse(responseCode = "200", description = "Expression evaluated successfully") @ApiResponse(responseCode = "404", description = "No live agent available for this application in this environment") @ApiResponse(responseCode = "504", description = "Agent did not respond in time") public ResponseEntity testExpression( - @PathVariable String application, - @RequestParam String environment, + @EnvPath Environment env, + @PathVariable String appSlug, @RequestBody TestExpressionRequest request) { - AgentInfo agent = registryService.findByApplicationAndEnvironment(application, environment).stream() + AgentInfo agent = registryService.findByApplicationAndEnvironment(appSlug, env.slug()).stream() .filter(a -> a.state() == AgentState.LIVE) .findFirst() .orElse(null); if (agent == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new TestExpressionResponse(null, "No live agent available for application: " + application)); + .body(new TestExpressionResponse(null, "No live agent available for application: " + appSlug)); } - // Build payload JSON String payloadJson; try { payloadJson = objectMapper.writeValueAsString(Map.of( @@ -211,7 +181,6 @@ public class ApplicationConfigController { .body(new TestExpressionResponse(null, "Failed to serialize request")); } - // Send command and await reply CompletableFuture future = registryService.addCommandWithReply( agent.instanceId(), CommandType.TEST_EXPRESSION, payloadJson); @@ -233,23 +202,6 @@ public class ApplicationConfigController { } } - /** - * Reads the {@code env} claim from the caller's validated JWT (populated by - * {@link JwtAuthenticationFilter}). Returns null if no internal JWT was seen - * on this request, or the token has no env claim. - */ - private static String environmentFromJwt(HttpServletRequest request) { - Object attr = request.getAttribute(JwtAuthenticationFilter.JWT_RESULT_ATTR); - if (attr instanceof JwtValidationResult result) { - return result.environment(); - } - return null; - } - - /** - * Extracts sensitiveKeys from ApplicationConfig via JsonNode to avoid compile-time - * dependency on getSensitiveKeys() which may not be in the published cameleer-common jar yet. - */ private List extractSensitiveKeys(ApplicationConfig config) { try { com.fasterxml.jackson.databind.JsonNode node = objectMapper.valueToTree(config); @@ -263,14 +215,10 @@ public class ApplicationConfigController { } } - /** - * Push config to agents with merged sensitive keys injected into the JSON payload. - */ private CommandGroupResponse pushConfigToAgentsWithMergedKeys(String application, String environment, ApplicationConfig config, List mergedKeys) { String payloadJson; try { - // Serialize config to a mutable map, inject merged keys @SuppressWarnings("unchecked") Map configMap = objectMapper.convertValue(config, Map.class); configMap.put("sensitiveKeys", mergedKeys); @@ -316,7 +264,7 @@ public class ApplicationConfigController { return new CommandGroupResponse(allSuccess, futures.size(), responses.size(), responses, timedOut); } - private static ApplicationConfig defaultConfig(String application, String environment) { + static ApplicationConfig defaultConfig(String application, String environment) { ApplicationConfig config = new ApplicationConfig(); config.setApplication(application); config.setEnvironment(environment); diff --git a/ui/src/api/queries/commands.ts b/ui/src/api/queries/commands.ts index a35285ea..99fcc1c4 100644 --- a/ui/src/api/queries/commands.ts +++ b/ui/src/api/queries/commands.ts @@ -45,23 +45,24 @@ function authFetch(path: string, init?: RequestInit): Promise { return fetch(`${config.apiBaseUrl}${path}`, { ...init, headers }) } -export function useAllApplicationConfigs() { +export function useAllApplicationConfigs(environment: string | undefined) { return useQuery({ - queryKey: ['applicationConfig', 'all'], + queryKey: ['applicationConfig', 'all', environment], queryFn: async () => { - const res = await authFetch('/config') + const res = await authFetch(`/environments/${encodeURIComponent(environment!)}/config`) if (!res.ok) throw new Error('Failed to fetch configs') return res.json() as Promise }, + enabled: !!environment, }) } export function useApplicationConfig(application: string | undefined, environment: string | undefined) { return useQuery({ - queryKey: ['applicationConfig', application, environment], + queryKey: ['applicationConfig', environment, application], queryFn: async () => { - const envParam = environment ? `?environment=${encodeURIComponent(environment)}` : '' - const res = await authFetch(`/config/${application}${envParam}`) + const res = await authFetch( + `/environments/${encodeURIComponent(environment!)}/apps/${encodeURIComponent(application!)}/config`) if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`) const data = await res.json() // Server returns AppConfigResponse: { config, globalSensitiveKeys, mergedSensitiveKeys } @@ -82,9 +83,9 @@ export interface ConfigUpdateResponse { export function useUpdateApplicationConfig() { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ config, environment }: { config: ApplicationConfig; environment?: string }) => { - const envParam = environment ? `?environment=${encodeURIComponent(environment)}` : '' - const res = await authFetch(`/config/${config.application}${envParam}`, { + mutationFn: async ({ config, environment }: { config: ApplicationConfig; environment: string }) => { + const res = await authFetch( + `/environments/${encodeURIComponent(environment)}/apps/${encodeURIComponent(config.application)}/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config), @@ -92,9 +93,9 @@ export function useUpdateApplicationConfig() { if (!res.ok) throw new Error('Failed to update config') return res.json() as Promise }, - onSuccess: (result) => { - queryClient.setQueryData(['applicationConfig', result.config.application], result.config) - queryClient.invalidateQueries({ queryKey: ['applicationConfig', 'all'] }) + onSuccess: (result, vars) => { + queryClient.setQueryData(['applicationConfig', vars.environment, result.config.application], result.config) + queryClient.invalidateQueries({ queryKey: ['applicationConfig'] }) }, }) } @@ -103,10 +104,10 @@ export function useUpdateApplicationConfig() { export function useProcessorRouteMapping(application?: string, environment?: string) { return useQuery({ - queryKey: ['config', application, environment, 'processor-routes'], + queryKey: ['config', environment, application, 'processor-routes'], queryFn: async () => { const res = await authFetch( - `/config/${application}/processor-routes?environment=${encodeURIComponent(environment!)}`) + `/environments/${encodeURIComponent(environment!)}/apps/${encodeURIComponent(application!)}/processor-routes`) if (!res.ok) throw new Error('Failed to fetch processor-route mapping') return res.json() as Promise> }, @@ -154,19 +155,21 @@ export function useTestExpression() { return useMutation({ mutationFn: async ({ application, + environment, expression, language, body, target, }: { application: string + environment: string expression: string language: string body: string target: string }) => { const res = await authFetch( - `/config/${encodeURIComponent(application)}/test-expression`, + `/environments/${encodeURIComponent(environment)}/apps/${encodeURIComponent(application)}/config/test-expression`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/ui/src/api/queries/dashboard.ts b/ui/src/api/queries/dashboard.ts index 6a38660d..a5bbea50 100644 --- a/ui/src/api/queries/dashboard.ts +++ b/ui/src/api/queries/dashboard.ts @@ -127,9 +127,9 @@ export interface AppSettings { export function useAppSettings(appId?: string, environment?: string) { return useQuery({ - queryKey: ['app-settings', appId, environment], + queryKey: ['app-settings', environment, appId], queryFn: () => fetchJson( - `/admin/app-settings/${appId}?environment=${encodeURIComponent(environment!)}`), + `/environments/${encodeURIComponent(environment!)}/apps/${encodeURIComponent(appId!)}/settings`), enabled: !!appId && !!environment, staleTime: 60_000, }); @@ -139,7 +139,7 @@ export function useAllAppSettings(environment?: string) { return useQuery({ queryKey: ['app-settings', 'all', environment], queryFn: () => fetchJson( - `/admin/app-settings?environment=${encodeURIComponent(environment!)}`), + `/environments/${encodeURIComponent(environment!)}/app-settings`), enabled: !!environment, staleTime: 60_000, }); @@ -151,7 +151,7 @@ export function useUpdateAppSettings() { mutationFn: async ({ appId, environment, settings }: { appId: string; environment: string; settings: Omit }) => { const res = await fetch( - `${config.apiBaseUrl}/admin/app-settings/${appId}?environment=${encodeURIComponent(environment)}`, + `${config.apiBaseUrl}/environments/${encodeURIComponent(environment)}/apps/${encodeURIComponent(appId)}/settings`, { method: 'PUT', headers: { ...authHeaders(), 'Content-Type': 'application/json' }, diff --git a/ui/src/components/TapConfigModal.tsx b/ui/src/components/TapConfigModal.tsx index 9aae620f..727d11a8 100644 --- a/ui/src/components/TapConfigModal.tsx +++ b/ui/src/components/TapConfigModal.tsx @@ -43,6 +43,8 @@ export interface TapConfigModalProps { defaultProcessorId?: string; /** Application name (for test expression API) */ application: string; + /** Environment slug (for test expression API) */ + environment: string; /** Current application config (taps array will be modified) */ config: ApplicationConfig; /** Called with the updated config to persist */ @@ -53,7 +55,7 @@ export interface TapConfigModalProps { export function TapConfigModal({ open, onClose, tap, processorOptions, defaultProcessorId, - application, config, onSave, onDelete, + application, environment, config, onSave, onDelete, }: TapConfigModalProps) { const isEdit = !!tap; @@ -125,7 +127,7 @@ export function TapConfigModal({ function handleTest() { testMutation.mutate( - { application, expression, language, body: testPayload, target }, + { application, environment, expression, language, body: testPayload, target }, { onSuccess: (data) => setTestResult(data), onError: (err) => setTestResult({ error: (err as Error).message }), diff --git a/ui/src/pages/Exchanges/ExchangesPage.tsx b/ui/src/pages/Exchanges/ExchangesPage.tsx index df74a8eb..dabc30da 100644 --- a/ui/src/pages/Exchanges/ExchangesPage.tsx +++ b/ui/src/pages/Exchanges/ExchangesPage.tsx @@ -322,6 +322,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS processorOptions={processorOptions} defaultProcessorId={tapModalTarget} application={appId} + environment={selectedEnv} config={appConfig} onSave={handleTapSave} onDelete={handleTapDelete} diff --git a/ui/src/pages/Routes/RouteDetail.tsx b/ui/src/pages/Routes/RouteDetail.tsx index 7a2b1387..7a542319 100644 --- a/ui/src/pages/Routes/RouteDetail.tsx +++ b/ui/src/pages/Routes/RouteDetail.tsx @@ -573,7 +573,7 @@ export default function RouteDetail() { if (!appId) return; const body = testTab === 'recent' ? testExchangeId : testPayload; testExpressionMutation.mutate( - { application: appId, expression: tapExpression, language: tapLanguage, body, target: tapTarget }, + { application: appId, environment: selectedEnv, expression: tapExpression, language: tapLanguage, body, target: tapTarget }, { onSuccess: (data) => setTestResult(data), onError: (err) => setTestResult({ error: (err as Error).message }) }, ); }