diff --git a/.claude/rules/app-classes.md b/.claude/rules/app-classes.md index 1693efd2..41552371 100644 --- a/.claude/rules/app-classes.md +++ b/.claude/rules/app-classes.md @@ -9,7 +9,7 @@ paths: ## controller/ — REST endpoints -- `AgentRegistrationController` — POST /register, POST /heartbeat, GET / (list), POST /refresh-token +- `AgentRegistrationController` — POST /register (requires `environmentId` in body; 400 if missing/blank), POST /heartbeat (env from body `environmentId` → JWT `env` claim; 400 if neither present during auto-heal), GET / (list), POST /refresh-token (rejects tokens with no `env` claim) - `AgentSseController` — GET /sse (Server-Sent Events connection) - `AgentCommandController` — POST /broadcast, POST /{agentId}, POST /{agentId}/ack - `AppController` — CRUD /api/v1/apps, POST /{appId}/upload-jar, GET /{appId}/versions @@ -25,17 +25,17 @@ paths: - `RoleAdminController` — CRUD /api/v1/admin/roles - `GroupAdminController` — CRUD /api/v1/admin/groups - `OidcConfigAdminController` — GET/POST /api/v1/admin/oidc, POST /test -- `SensitiveKeysAdminController` — GET/PUT /api/v1/admin/sensitive-keys. GET returns 200 with config or 204 if not configured. PUT accepts `{ keys: [...] }` with optional `?pushToAgents=true` to fan out merged keys to all LIVE agents. Stored in `server_config` table (key `sensitive_keys`). +- `SensitiveKeysAdminController` — GET/PUT /api/v1/admin/sensitive-keys. GET returns 200 with config or 204 if not configured. PUT accepts `{ keys: [...] }` with optional `?pushToAgents=true`. The fan-out iterates over every distinct `(application, environment)` slice (from persisted `application_config` rows plus currently-registered agents) and pushes per-slice merged keys — intentional global baseline + per-env overrides. Stored in `server_config` table (key `sensitive_keys`). - `AuditLogController` — GET /api/v1/admin/audit - `MetricsController` — GET /api/v1/metrics, GET /timeseries -- `DiagramController` — GET /api/v1/diagrams/{id}, POST / +- `DiagramController` — GET /api/v1/diagrams/{id}, POST /api/v1/data/diagrams. Ingestion resolves applicationId + environment from the agent registry (keyed on JWT subject) and stamps both on the stored `TaggedDiagram`. `route_diagrams` CH table has an `environment` column; queries like `findProcessorRouteMapping(app, env)` filter by it. - `DiagramRenderController` — POST /api/v1/diagrams/render (ELK layout) - `ClaimMappingAdminController` — CRUD /api/v1/admin/claim-mappings, POST /test (accepts inline rules + claims for preview without saving) - `LicenseAdminController` — GET/POST /api/v1/admin/license - `AgentEventsController` — GET /api/v1/agent-events (agent state change history) - `AgentMetricsController` — GET /api/v1/agent-metrics (JVM/Camel metrics per agent instance) -- `AppSettingsController` — GET/PUT /api/v1/apps/{appId}/settings -- `ApplicationConfigController` — GET/PUT /api/v1/apps/{appId}/config (traced processors, route recording, sensitive keys per app) +- `AppSettingsController` — GET/PUT /api/v1/admin/app-settings (list), /api/v1/admin/app-settings/{appId} (per-app). All endpoints require `?environment=`. +- `ApplicationConfigController` — `/api/v1/config` (agent/admin observability config: traced processors, taps, route recording, per-app sensitive keys). GET list requires `?environment=`. GET/PUT/DELETE for a single app are env-scoped: for AGENT role the env comes from the JWT `env` claim (query param ignored, agents cannot spoof env); for non-agent callers env must be supplied via `?environment=` (user JWTs carry a placeholder env="default" that is NOT authoritative). `defaultConfig(application, environment)` is returned when no row exists. - `ClickHouseAdminController` — GET /api/v1/admin/clickhouse (ClickHouse admin, conditional on infrastructure endpoints) - `DatabaseAdminController` — GET /api/v1/admin/database (PG admin, conditional on infrastructure endpoints) - `DetailController` — GET /api/v1/detail (execution detail with processor tree) @@ -67,7 +67,7 @@ paths: - `PostgresDeploymentRepository` — includes JSONB replica_states, deploy_stage, findByContainerId - `PostgresUserRepository`, `PostgresRoleRepository`, `PostgresGroupRepository` - `PostgresAuditRepository`, `PostgresOidcConfigRepository`, `PostgresClaimMappingRepository`, `PostgresSensitiveKeysRepository` -- `PostgresAppSettingsRepository`, `PostgresApplicationConfigRepository`, `PostgresThresholdRepository` +- `PostgresAppSettingsRepository`, `PostgresApplicationConfigRepository`, `PostgresThresholdRepository`. Both `app_settings` and `application_config` are env-scoped (PK `(app_id, environment)` / `(application, environment)`); finders take `(app, env)` — no env-agnostic variants. ## storage/ — ClickHouse stores diff --git a/.claude/rules/core-classes.md b/.claude/rules/core-classes.md index 03e101d2..433f9b51 100644 --- a/.claude/rules/core-classes.md +++ b/.claude/rules/core-classes.md @@ -74,7 +74,7 @@ paths: - `SensitiveKeysConfig` — record: keys (List, immutable) - `SensitiveKeysRepository` — interface: find(), save() - `SensitiveKeysMerger` — pure function: merge(global, perApp) -> union with case-insensitive dedup, preserves first-seen casing. Returns null when both inputs null. -- `AppSettings`, `AppSettingsRepository` — per-app settings config and persistence +- `AppSettings`, `AppSettingsRepository` — per-app-per-env settings config and persistence. Record carries `(applicationId, environment, …)`; repository methods are `findByApplicationAndEnvironment`, `findByEnvironment`, `save`, `delete(appId, env)`. `AppSettings.defaults(appId, env)` produces a default instance scoped to an environment. - `ThresholdConfig`, `ThresholdRepository` — alerting threshold config and persistence - `AuditService` — audit logging facade - `AuditRecord`, `AuditResult`, `AuditCategory`, `AuditRepository` — audit trail records and persistence @@ -95,4 +95,4 @@ paths: - `ChunkAccumulator` — batches data for efficient flush - `WriteBuffer` — bounded ring buffer for async flush - `BufferedLogEntry` — log entry wrapper with metadata -- `MergedExecution`, `TaggedExecution`, `TaggedDiagram` — tagged ingestion records +- `MergedExecution`, `TaggedExecution`, `TaggedDiagram` — tagged ingestion records. `TaggedDiagram` carries `(instanceId, applicationId, environment, graph)` — env is resolved from the agent registry in the controller and stamped on the ClickHouse `route_diagrams` row. diff --git a/CLAUDE.md b/CLAUDE.md index 07d72cca..580ab363 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar - Jackson `JavaTimeModule` for `Instant` deserialization - Communication: receives HTTP POST data from agents (executions, diagrams, metrics, logs), serves SSE event streams for config push/commands (config-update, deep-trace, replay, route-control) - Environment filtering: all data queries filter by the selected environment. All commands target only agents in the selected environment. Backend endpoints accept optional `environment` query parameter; null = all environments (backward compatible). -- Maintains agent instance registry (in-memory) with states: LIVE -> STALE -> DEAD. Auto-heals from JWT `env` claim + heartbeat body on heartbeat/SSE after server restart (priority: heartbeat `environmentId` > JWT `env` claim > `"default"`). Capabilities and route states updated on every heartbeat (protocol v2). Route catalog merges three sources: in-memory agent registry, persistent `route_catalog` table (ClickHouse), and `stats_1m_route` execution stats. The persistent catalog tracks `first_seen`/`last_seen` per route per environment, updated on every registration and heartbeat. Routes appear in the sidebar when their lifecycle overlaps the selected time window (`first_seen <= to AND last_seen >= from`), so historical routes remain visible even after being dropped from newer app versions. +- Maintains agent instance registry (in-memory) with states: LIVE -> STALE -> DEAD. Auto-heals from JWT `env` claim + heartbeat body on heartbeat/SSE after server restart (priority: heartbeat `environmentId` > JWT `env` claim; no silent default — missing env on heartbeat auto-heal returns 400). Registration (`POST /api/v1/agents/register`) requires `environmentId` in the request body; missing or blank returns 400. Capabilities and route states updated on every heartbeat (protocol v2). Route catalog merges three sources: in-memory agent registry, persistent `route_catalog` table (ClickHouse), and `stats_1m_route` execution stats. The persistent catalog tracks `first_seen`/`last_seen` per route per environment, updated on every registration and heartbeat. Routes appear in the sidebar when their lifecycle overlaps the selected time window (`first_seen <= to AND last_seen >= from`), so historical routes remain visible even after being dropped from newer app versions. - Multi-tenancy: each server instance serves one tenant (configured via `CAMELEER_SERVER_TENANT_ID`, default: `"default"`). Environments (dev/staging/prod) are first-class. PostgreSQL isolated via schema-per-tenant (`?currentSchema=tenant_{id}`) and `ApplicationName=tenant_{id}` on the JDBC URL. ClickHouse shared DB with `tenant_id` + `environment` columns, partitioned by `(tenant_id, toYYYYMM(timestamp))`. - Storage: PostgreSQL for RBAC, config, and audit; ClickHouse for all observability data (executions, search, logs, metrics, stats, diagrams). ClickHouse schema migrations in `clickhouse/*.sql`, run idempotently on startup by `ClickHouseSchemaInitializer`. Use `IF NOT EXISTS` for CREATE and ADD PROJECTION. - Log exchange correlation: `ClickHouseLogStore` extracts `exchange_id` from log entry MDC, preferring `cameleer.exchangeId` over `camel.exchangeId` (fallback for older agents). For `ON_COMPLETION` exchange copies, the agent sets `cameleer.exchangeId` to the parent's exchange ID via `CORRELATION_ID`. @@ -54,7 +54,7 @@ java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar ## Database Migrations PostgreSQL (Flyway): `cameleer-server-app/src/main/resources/db/migration/` -- V1 — RBAC (users, roles, groups, audit_log) +- V1 — RBAC (users, roles, groups, audit_log). `application_config` PK is `(application, environment)`; `app_settings` PK is `(application_id, environment)` — both tables are env-scoped. - V2 — Claim mappings (OIDC) - V3 — Runtime management (apps, environments, deployments, app_versions) - V4 — Environment config (default_container_config JSONB) @@ -78,7 +78,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component # GitNexus — Code Intelligence -This project is indexed by GitNexus as **cameleer-server** (6281 symbols, 15871 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **cameleer-server** (6364 symbols, 16045 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java index 30cca8a4..29f504a6 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/AgentRegistrationController.java @@ -117,9 +117,15 @@ public class AgentRegistrationController { if (request.instanceId() == null || request.instanceId().isBlank()) { return ResponseEntity.badRequest().build(); } + if (request.environmentId() == null || request.environmentId().isBlank()) { + String remote = httpRequest.getRemoteAddr(); + log.warn("Agent registration rejected (no environmentId): instanceId={} remote={}", + request.instanceId(), remote); + return ResponseEntity.badRequest().build(); + } String application = request.applicationId() != null ? request.applicationId() : "default"; - String environmentId = request.environmentId() != null ? request.environmentId() : "default"; + String environmentId = request.environmentId(); List routeIds = request.routeIds() != null ? request.routeIds() : List.of(); var capabilities = request.capabilities() != null ? request.capabilities() : Collections.emptyMap(); @@ -206,9 +212,15 @@ public class AgentRegistrationController { List roles = result.roles().isEmpty() ? List.of("AGENT") : result.roles(); String application = result.application() != null ? result.application() : "default"; + String environment = result.environment(); - // Try to get application + environment from registry (agent may not be registered after server restart) - String environment = result.environment() != null ? result.environment() : "default"; + // Refresh-token env claim is required — agents without env shouldn't have gotten a token in the first place. + if (environment == null || environment.isBlank()) { + log.warn("Refresh token has no environment claim: agentId={}", agentId); + return ResponseEntity.status(401).build(); + } + + // Prefer registry if agent is still registered (covers env edits on re-registration) AgentInfo agent = registryService.findById(agentId); if (agent != null) { application = agent.applicationId(); @@ -242,9 +254,14 @@ public class AgentRegistrationController { JwtAuthenticationFilter.JWT_RESULT_ATTR); if (jwtResult != null) { String application = jwtResult.application() != null ? jwtResult.application() : "default"; - // Prefer environment from heartbeat body (most current), fall back to JWT claim - String env = heartbeatEnv != null ? heartbeatEnv - : jwtResult.environment() != null ? jwtResult.environment() : "default"; + // Env: prefer heartbeat body (current), else JWT claim. No silent default. + String env = (heartbeatEnv != null && !heartbeatEnv.isBlank()) + ? heartbeatEnv + : jwtResult.environment(); + if (env == null || env.isBlank()) { + log.warn("Heartbeat auto-heal rejected (no environment on JWT or body): agentId={}", id); + return ResponseEntity.status(400).build(); + } Map caps = capabilities != null ? capabilities : Map.of(); List healRouteIds = routeIds != null ? routeIds : List.of(); registryService.register(id, id, application, env, "unknown", 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 7c71b5bf..25fb86cb 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 @@ -19,6 +19,7 @@ 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; @@ -40,21 +41,24 @@ public class AppSettingsController { } @GetMapping - @Operation(summary = "List all application settings") - public ResponseEntity> getAll() { - return ResponseEntity.ok(repository.findAll()); + @Operation(summary = "List application settings in an environment") + public ResponseEntity> getAll(@RequestParam String environment) { + return ResponseEntity.ok(repository.findByEnvironment(environment)); } @GetMapping("/{appId}") - @Operation(summary = "Get settings for a specific application (returns defaults if not configured)") - public ResponseEntity getByAppId(@PathVariable String appId) { - AppSettings settings = repository.findByApplicationId(appId).orElse(AppSettings.defaults(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)); return ResponseEntity.ok(settings); } @PutMapping("/{appId}") - @Operation(summary = "Create or update settings for an application") + @Operation(summary = "Create or update settings for an application in an environment") public ResponseEntity update(@PathVariable String appId, + @RequestParam String environment, @Valid @RequestBody AppSettingsRequest request, HttpServletRequest httpRequest) { List errors = request.validate(); @@ -62,18 +66,20 @@ public class AppSettingsController { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors)); } - AppSettings saved = repository.save(request.toSettings(appId)); + AppSettings saved = repository.save(request.toSettings(appId, environment)); auditService.log("update_app_settings", AuditCategory.CONFIG, appId, - Map.of("settings", saved), AuditResult.SUCCESS, httpRequest); + Map.of("environment", environment, "settings", saved), AuditResult.SUCCESS, httpRequest); return ResponseEntity.ok(saved); } @DeleteMapping("/{appId}") - @Operation(summary = "Delete application settings (reverts to defaults)") - public ResponseEntity delete(@PathVariable String appId, HttpServletRequest httpRequest) { - repository.delete(appId); + @Operation(summary = "Delete application settings for an environment (reverts to defaults)") + public ResponseEntity delete(@PathVariable String appId, + @RequestParam String environment, + HttpServletRequest httpRequest) { + repository.delete(appId, environment); auditService.log("delete_app_settings", AuditCategory.CONFIG, appId, - Map.of(), AuditResult.SUCCESS, httpRequest); + Map.of("environment", environment), 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 503b40bb..a95e5070 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,7 +6,9 @@ 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.core.admin.AuditCategory; import com.cameleer.server.core.admin.AuditResult; import com.cameleer.server.core.admin.AuditService; @@ -73,23 +75,38 @@ public class ApplicationConfigController { } @GetMapping - @Operation(summary = "List all application configs", - description = "Returns stored configurations for all applications") + @Operation(summary = "List application configs in an environment", + description = "Returns stored configurations for all applications in the given environment") @ApiResponse(responseCode = "200", description = "Configs returned") - public ResponseEntity> listConfigs(HttpServletRequest httpRequest) { - auditService.log("view_app_configs", AuditCategory.CONFIG, null, null, AuditResult.SUCCESS, httpRequest); - return ResponseEntity.ok(configRepository.findAll()); + public ResponseEntity> listConfigs(@RequestParam String environment, + HttpServletRequest httpRequest) { + auditService.log("view_app_configs", AuditCategory.CONFIG, null, + Map.of("environment", environment), AuditResult.SUCCESS, httpRequest); + return ResponseEntity.ok(configRepository.findByEnvironment(environment)); } @GetMapping("/{application}") - @Operation(summary = "Get application config", - description = "Returns the current configuration for an application with merged sensitive keys.") + @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.") @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, HttpServletRequest httpRequest) { - auditService.log("view_app_config", AuditCategory.CONFIG, application, null, AuditResult.SUCCESS, httpRequest); - ApplicationConfig config = configRepository.findByApplication(application) - .orElse(defaultConfig(application)); + 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)); List globalKeys = sensitiveKeysRepository.find() .map(SensitiveKeysConfig::keys) @@ -99,19 +116,36 @@ 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", - description = "Saves config and pushes CONFIG_UPDATE to all LIVE agents of this application") + @Operation(summary = "Update application config for an 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(required = false) String environment, + @RequestParam String environment, @RequestBody ApplicationConfig config, Authentication auth, HttpServletRequest httpRequest) { String updatedBy = auth != null ? auth.getName() : "system"; config.setApplication(application); - ApplicationConfig saved = configRepository.save(application, config, updatedBy); + ApplicationConfig saved = configRepository.save(application, environment, config, updatedBy); // Merge global + per-app sensitive keys for the SSE push payload List globalKeys = sensitiveKeysRepository.find() @@ -126,7 +160,8 @@ public class ApplicationConfigController { saved.getVersion(), application, pushResult.total(), pushResult.responded()); auditService.log("update_app_config", AuditCategory.CONFIG, application, - Map.of("version", saved.getVersion(), "agentsPushed", pushResult.total(), + Map.of("environment", environment, "version", saved.getVersion(), + "agentsPushed", pushResult.total(), "responded", pushResult.responded(), "timedOut", pushResult.timedOut().size()), AuditResult.SUCCESS, httpRequest); @@ -134,30 +169,27 @@ public class ApplicationConfigController { } @GetMapping("/{application}/processor-routes") - @Operation(summary = "Get processor to route mapping", - description = "Returns a map of processorId → routeId for all processors seen in this application") + @Operation(summary = "Get processor to route mapping for an 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) { - return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(application)); + public ResponseEntity> getProcessorRouteMapping(@PathVariable String application, + @RequestParam String environment) { + return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(application, environment)); } @PostMapping("/{application}/test-expression") - @Operation(summary = "Test a tap expression against sample data via a live agent") + @Operation(summary = "Test a tap expression against sample data via a live agent in an environment") @ApiResponse(responseCode = "200", description = "Expression evaluated successfully") - @ApiResponse(responseCode = "404", description = "No live agent available for this application") + @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(required = false) String environment, + @RequestParam String environment, @RequestBody TestExpressionRequest request) { - // Find a LIVE agent for this application, optionally filtered by environment - var candidates = registryService.findAll().stream() - .filter(a -> application.equals(a.applicationId())) - .filter(a -> a.state() == AgentState.LIVE); - if (environment != null) { - candidates = candidates.filter(a -> environment.equals(a.environmentId())); - } - AgentInfo agent = candidates.findFirst().orElse(null); + AgentInfo agent = registryService.findByApplicationAndEnvironment(application, environment).stream() + .filter(a -> a.state() == AgentState.LIVE) + .findFirst() + .orElse(null); if (agent == null) { return ResponseEntity.status(HttpStatus.NOT_FOUND) @@ -201,6 +233,19 @@ 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. @@ -271,9 +316,10 @@ public class ApplicationConfigController { return new CommandGroupResponse(allSuccess, futures.size(), responses.size(), responses, timedOut); } - private static ApplicationConfig defaultConfig(String application) { + private static ApplicationConfig defaultConfig(String application, String environment) { ApplicationConfig config = new ApplicationConfig(); config.setApplication(application); + config.setEnvironment(environment); config.setVersion(0); config.setMetricsEnabled(true); config.setSamplingRate(1.0); diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DiagramController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DiagramController.java index 14fb263a..f94dc365 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DiagramController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/DiagramController.java @@ -50,11 +50,13 @@ public class DiagramController { @ApiResponse(responseCode = "202", description = "Data accepted for processing") public ResponseEntity ingestDiagrams(@RequestBody String body) throws JsonProcessingException { String instanceId = extractAgentId(); - String applicationId = resolveApplicationId(instanceId); + AgentInfo agent = registryService.findById(instanceId); + String applicationId = agent != null ? agent.applicationId() : ""; + String environment = agent != null ? agent.environmentId() : ""; List graphs = parsePayload(body); for (RouteGraph graph : graphs) { - ingestionService.ingestDiagram(new TaggedDiagram(instanceId, applicationId, graph)); + ingestionService.ingestDiagram(new TaggedDiagram(instanceId, applicationId, environment, graph)); } return ResponseEntity.accepted().build(); @@ -65,11 +67,6 @@ public class DiagramController { return auth != null ? auth.getName() : ""; } - private String resolveApplicationId(String instanceId) { - AgentInfo agent = registryService.findById(instanceId); - return agent != null ? agent.applicationId() : ""; - } - private List parsePayload(String body) throws JsonProcessingException { String trimmed = body.strip(); if (trimmed.startsWith("[")) { diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RouteMetricsController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RouteMetricsController.java index 2dc552df..7fee5819 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RouteMetricsController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/RouteMetricsController.java @@ -119,8 +119,10 @@ public class RouteMetricsController { if (!metrics.isEmpty()) { // Determine SLA threshold (per-app or default) String effectiveAppId = appId != null ? appId : (metrics.isEmpty() ? null : metrics.get(0).appId()); - int threshold = appSettingsRepository.findByApplicationId(effectiveAppId != null ? effectiveAppId : "") - .map(AppSettings::slaThresholdMs).orElse(300); + int threshold = (effectiveAppId != null && environment != null && !environment.isBlank()) + ? appSettingsRepository.findByApplicationAndEnvironment(effectiveAppId, environment) + .map(AppSettings::slaThresholdMs).orElse(300) + : 300; Map slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant, effectiveAppId, threshold, environment); diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SearchController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SearchController.java index ef63fd0c..68112c04 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SearchController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SearchController.java @@ -102,10 +102,12 @@ public class SearchController { stats = searchService.statsForRoute(from, end, routeId, application, environment); } - // Enrich with SLA compliance - int threshold = appSettingsRepository - .findByApplicationId(application != null ? application : "") - .map(AppSettings::slaThresholdMs).orElse(300); + // Enrich with SLA compliance (per-env threshold when both app and env are specified) + int threshold = (application != null && !application.isBlank() + && environment != null && !environment.isBlank()) + ? appSettingsRepository.findByApplicationAndEnvironment(application, environment) + .map(AppSettings::slaThresholdMs).orElse(300) + : 300; double sla = searchService.slaCompliance(from, end, threshold, application, routeId, environment); return ResponseEntity.ok(stats.withSlaCompliance(sla)); } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SensitiveKeysAdminController.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SensitiveKeysAdminController.java index 6c05c87e..76bdc22c 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SensitiveKeysAdminController.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/controller/SensitiveKeysAdminController.java @@ -120,31 +120,37 @@ public class SensitiveKeysAdminController { * not yet include that field accessor. */ private CommandGroupResponse fanOutToAllAgents(List globalKeys) { - // Collect all distinct application IDs - Set applications = new LinkedHashSet<>(); - configRepository.findAll().stream() - .map(ApplicationConfig::getApplication) - .filter(a -> a != null && !a.isBlank()) - .forEach(applications::add); + // Collect every (application, environment) slice we know about: persisted config rows + // PLUS currently-registered live agents (which may have no stored config yet). + // Global sensitive keys are server-wide, but per-app overrides live per env, so the + // push is scoped per (app, env) so each slice gets its own merged keys. + Set slices = new LinkedHashSet<>(); + for (ApplicationConfig cfg : configRepository.findAll()) { + if (cfg.getApplication() != null && !cfg.getApplication().isBlank() + && cfg.getEnvironment() != null && !cfg.getEnvironment().isBlank()) { + slices.add(new AppEnv(cfg.getApplication(), cfg.getEnvironment())); + } + } registryService.findAll().stream() - .map(a -> a.applicationId()) - .filter(a -> a != null && !a.isBlank()) - .forEach(applications::add); + .filter(a -> a.applicationId() != null && !a.applicationId().isBlank() + && a.environmentId() != null && !a.environmentId().isBlank()) + .forEach(a -> slices.add(new AppEnv(a.applicationId(), a.environmentId()))); - if (applications.isEmpty()) { + if (slices.isEmpty()) { return new CommandGroupResponse(true, 0, 0, List.of(), List.of()); } - // Shared 10-second deadline across all applications + // Shared 10-second deadline across all slices long deadline = System.currentTimeMillis() + 10_000; List allResponses = new ArrayList<>(); List allTimedOut = new ArrayList<>(); int totalAgents = 0; - for (String application : applications) { - // Load per-app sensitive keys via JsonNode to avoid dependency on + for (AppEnv slice : slices) { + // Load per-(app,env) sensitive keys via JsonNode to avoid dependency on // ApplicationConfig.getSensitiveKeys() which may not be in the published jar yet. - List perAppKeys = configRepository.findByApplication(application) + List perAppKeys = configRepository + .findByApplicationAndEnvironment(slice.application(), slice.environment()) .map(cfg -> extractSensitiveKeys(cfg)) .orElse(null); @@ -153,19 +159,22 @@ public class SensitiveKeysAdminController { // Build a minimal payload map — only sensitiveKeys + application fields. Map payloadMap = new LinkedHashMap<>(); - payloadMap.put("application", application); + payloadMap.put("application", slice.application()); payloadMap.put("sensitiveKeys", mergedKeys); String payloadJson; try { payloadJson = objectMapper.writeValueAsString(payloadMap); } catch (JsonProcessingException e) { - log.error("Failed to serialize sensitive keys push payload for application '{}'", application, e); + log.error("Failed to serialize sensitive keys push payload for {}/{}", + slice.application(), slice.environment(), e); continue; } Map> futures = - registryService.addGroupCommandWithReplies(application, null, CommandType.CONFIG_UPDATE, payloadJson); + registryService.addGroupCommandWithReplies( + slice.application(), slice.environment(), + CommandType.CONFIG_UPDATE, payloadJson); totalAgents += futures.size(); @@ -213,4 +222,7 @@ public class SensitiveKeysAdminController { return null; } } + + /** (application, environment) slice used by the fan-out loop. */ + private record AppEnv(String application, String environment) {} } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AppSettingsRequest.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AppSettingsRequest.java index 47846288..6998e2a7 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AppSettingsRequest.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/dto/AppSettingsRequest.java @@ -33,9 +33,9 @@ public record AppSettingsRequest( Double healthSlaCrit ) { - public AppSettings toSettings(String appId) { + public AppSettings toSettings(String appId, String environment) { Instant now = Instant.now(); - return new AppSettings(appId, slaThresholdMs, healthErrorWarn, healthErrorCrit, + return new AppSettings(appId, environment, slaThresholdMs, healthErrorWarn, healthErrorCrit, healthSlaWarn, healthSlaCrit, now, now); } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/ClickHouseDiagramStore.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/ClickHouseDiagramStore.java index efa61017..c5340f1a 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/ClickHouseDiagramStore.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/ClickHouseDiagramStore.java @@ -41,8 +41,8 @@ public class ClickHouseDiagramStore implements DiagramStore { private static final String INSERT_SQL = """ INSERT INTO route_diagrams - (tenant_id, content_hash, route_id, instance_id, application_id, definition, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + (tenant_id, content_hash, route_id, instance_id, application_id, environment, definition, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """; private static final String SELECT_BY_HASH = """ @@ -59,7 +59,7 @@ public class ClickHouseDiagramStore implements DiagramStore { private static final String SELECT_DEFINITIONS_FOR_APP = """ SELECT DISTINCT route_id, definition FROM route_diagrams - WHERE tenant_id = ? AND application_id = ? + WHERE tenant_id = ? AND application_id = ? AND environment = ? """; private final String tenantId; @@ -104,6 +104,8 @@ public class ClickHouseDiagramStore implements DiagramStore { RouteGraph graph = diagram.graph(); String agentId = diagram.instanceId() != null ? diagram.instanceId() : ""; String applicationId = diagram.applicationId() != null ? diagram.applicationId() : ""; + String environment = (diagram.environment() != null && !diagram.environment().isBlank()) + ? diagram.environment() : "default"; String json = objectMapper.writeValueAsString(graph); String contentHash = sha256Hex(json); String routeId = graph.getRouteId() != null ? graph.getRouteId() : ""; @@ -114,6 +116,7 @@ public class ClickHouseDiagramStore implements DiagramStore { routeId, agentId, applicationId, + environment, json, Timestamp.from(Instant.now())); @@ -197,10 +200,10 @@ public class ClickHouseDiagramStore implements DiagramStore { } @Override - public Map findProcessorRouteMapping(String applicationId) { + public Map findProcessorRouteMapping(String applicationId, String environment) { Map mapping = new HashMap<>(); List> rows = jdbc.queryForList( - SELECT_DEFINITIONS_FOR_APP, tenantId, applicationId); + SELECT_DEFINITIONS_FOR_APP, tenantId, applicationId, environment); for (Map row : rows) { String routeId = (String) row.get("route_id"); String json = (String) row.get("definition"); @@ -211,7 +214,8 @@ public class ClickHouseDiagramStore implements DiagramStore { RouteGraph graph = objectMapper.readValue(json, RouteGraph.class); collectNodeIds(graph.getRoot(), routeId, mapping); } catch (JsonProcessingException e) { - log.warn("Failed to deserialize RouteGraph for route={} app={}", routeId, applicationId, e); + log.warn("Failed to deserialize RouteGraph for route={} app={} env={}", + routeId, applicationId, environment, e); } } return mapping; diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresAppSettingsRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresAppSettingsRepository.java index 2a797458..5f607622 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresAppSettingsRepository.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresAppSettingsRepository.java @@ -16,6 +16,7 @@ public class PostgresAppSettingsRepository implements AppSettingsRepository { private static final RowMapper ROW_MAPPER = (rs, rowNum) -> new AppSettings( rs.getString("application_id"), + rs.getString("environment"), rs.getInt("sla_threshold_ms"), rs.getDouble("health_error_warn"), rs.getDouble("health_error_crit"), @@ -29,24 +30,27 @@ public class PostgresAppSettingsRepository implements AppSettingsRepository { } @Override - public Optional findByApplicationId(String applicationId) { + public Optional findByApplicationAndEnvironment(String applicationId, String environment) { List results = jdbc.query( - "SELECT * FROM app_settings WHERE application_id = ?", ROW_MAPPER, applicationId); + "SELECT * FROM app_settings WHERE application_id = ? AND environment = ?", + ROW_MAPPER, applicationId, environment); return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); } @Override - public List findAll() { - return jdbc.query("SELECT * FROM app_settings ORDER BY application_id", ROW_MAPPER); + public List findByEnvironment(String environment) { + return jdbc.query( + "SELECT * FROM app_settings WHERE environment = ? ORDER BY application_id", + ROW_MAPPER, environment); } @Override public AppSettings save(AppSettings settings) { jdbc.update(""" - INSERT INTO app_settings (application_id, sla_threshold_ms, health_error_warn, + INSERT INTO app_settings (application_id, environment, sla_threshold_ms, health_error_warn, health_error_crit, health_sla_warn, health_sla_crit, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, now(), now()) - ON CONFLICT (application_id) DO UPDATE SET + VALUES (?, ?, ?, ?, ?, ?, ?, now(), now()) + ON CONFLICT (application_id, environment) DO UPDATE SET sla_threshold_ms = EXCLUDED.sla_threshold_ms, health_error_warn = EXCLUDED.health_error_warn, health_error_crit = EXCLUDED.health_error_crit, @@ -54,14 +58,15 @@ public class PostgresAppSettingsRepository implements AppSettingsRepository { health_sla_crit = EXCLUDED.health_sla_crit, updated_at = now() """, - settings.applicationId(), settings.slaThresholdMs(), + settings.applicationId(), settings.environment(), settings.slaThresholdMs(), settings.healthErrorWarn(), settings.healthErrorCrit(), settings.healthSlaWarn(), settings.healthSlaCrit()); - return findByApplicationId(settings.applicationId()).orElseThrow(); + return findByApplicationAndEnvironment(settings.applicationId(), settings.environment()).orElseThrow(); } @Override - public void delete(String appId) { - jdbc.update("DELETE FROM app_settings WHERE application_id = ?", appId); + public void delete(String appId, String environment) { + jdbc.update("DELETE FROM app_settings WHERE application_id = ? AND environment = ?", + appId, environment); } } diff --git a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresApplicationConfigRepository.java b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresApplicationConfigRepository.java index f850268f..e24c3811 100644 --- a/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresApplicationConfigRepository.java +++ b/cameleer-server-app/src/main/java/com/cameleer/server/app/storage/PostgresApplicationConfigRepository.java @@ -4,6 +4,7 @@ import com.cameleer.common.model.ApplicationConfig; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; import org.springframework.stereotype.Repository; import java.util.List; @@ -20,39 +21,55 @@ 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); - } - }); + /** + * Row mapper — columns (application, environment) are authoritative and always + * overwrite whatever was in the stored JSON body. Callers must SELECT them. + */ + private RowMapper rowMapper() { + return (rs, rowNum) -> { + try { + ApplicationConfig cfg = objectMapper.readValue(rs.getString("config_val"), ApplicationConfig.class); + cfg.setApplication(rs.getString("application")); + cfg.setEnvironment(rs.getString("environment")); + 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) { + private static final String SELECT_FIELDS = + "application, environment, config_val, version, updated_at"; + + public List findByEnvironment(String environment) { + return jdbc.query( + "SELECT " + SELECT_FIELDS + " FROM application_config " + + "WHERE environment = ? ORDER BY application", + rowMapper(), environment); + } + + public List findAll() { + return jdbc.query( + "SELECT " + SELECT_FIELDS + " FROM application_config " + + "ORDER BY application, environment", + rowMapper()); + } + + public Optional findByApplicationAndEnvironment(String application, String environment) { List results = jdbc.query( - "SELECT config_val, version, updated_at FROM application_config WHERE 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); - } - }, - application); + "SELECT " + SELECT_FIELDS + " FROM application_config " + + "WHERE application = ? AND environment = ?", + rowMapper(), application, environment); return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); } - public ApplicationConfig save(String application, ApplicationConfig config, String updatedBy) { + public ApplicationConfig save(String application, String environment, ApplicationConfig config, String updatedBy) { + // Authoritative fields stamped on the DTO before serialization — guarantees the stored + // JSON agrees with the row's columns. + config.setApplication(application); + config.setEnvironment(environment); String json; try { json = objectMapper.writeValueAsString(config); @@ -60,18 +77,22 @@ public class PostgresApplicationConfigRepository { throw new RuntimeException("Failed to serialize application config", e); } - // Upsert: insert or update, auto-increment version jdbc.update(""" - INSERT INTO application_config (application, config_val, version, updated_at, updated_by) - VALUES (?, ?::jsonb, 1, now(), ?) - ON CONFLICT (application) DO UPDATE SET + INSERT INTO application_config (application, environment, config_val, version, updated_at, updated_by) + VALUES (?, ?, ?::jsonb, 1, now(), ?) + ON CONFLICT (application, environment) DO UPDATE SET config_val = EXCLUDED.config_val, version = application_config.version + 1, updated_at = now(), updated_by = EXCLUDED.updated_by """, - application, json, updatedBy); + application, environment, json, updatedBy); - return findByApplication(application).orElseThrow(); + return findByApplicationAndEnvironment(application, environment).orElseThrow(); + } + + public void delete(String application, String environment) { + jdbc.update("DELETE FROM application_config WHERE application = ? AND environment = ?", + application, environment); } } diff --git a/cameleer-server-app/src/main/resources/db/migration/V1__init.sql b/cameleer-server-app/src/main/resources/db/migration/V1__init.sql index 210e2b8e..7b3be360 100644 --- a/cameleer-server-app/src/main/resources/db/migration/V1__init.sql +++ b/cameleer-server-app/src/main/resources/db/migration/V1__init.sql @@ -83,22 +83,26 @@ CREATE TABLE server_config ( -- ============================================================= CREATE TABLE application_config ( - application TEXT PRIMARY KEY, + application TEXT NOT NULL, + environment TEXT NOT NULL, config_val JSONB NOT NULL, version INTEGER NOT NULL DEFAULT 1, updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_by TEXT + updated_by TEXT, + PRIMARY KEY (application, environment) ); CREATE TABLE app_settings ( - application_id TEXT PRIMARY KEY, + application_id TEXT NOT NULL, + environment TEXT NOT NULL, sla_threshold_ms INTEGER NOT NULL DEFAULT 300, health_error_warn DOUBLE PRECISION NOT NULL DEFAULT 1.0, health_error_crit DOUBLE PRECISION NOT NULL DEFAULT 5.0, health_sla_warn DOUBLE PRECISION NOT NULL DEFAULT 99.0, health_sla_crit DOUBLE PRECISION NOT NULL DEFAULT 95.0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (application_id, environment) ); -- ============================================================= diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/ClickHouseDiagramStoreIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/ClickHouseDiagramStoreIT.java index 46f3b4c5..d986a851 100644 --- a/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/ClickHouseDiagramStoreIT.java +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/ClickHouseDiagramStoreIT.java @@ -59,7 +59,7 @@ class ClickHouseDiagramStoreIT { } private TaggedDiagram tagged(String instanceId, String applicationId, RouteGraph graph) { - return new TaggedDiagram(instanceId, applicationId, graph); + return new TaggedDiagram(instanceId, applicationId, "default", graph); } // ── Tests ───────────────────────────────────────────────────────────── @@ -180,7 +180,7 @@ class ClickHouseDiagramStoreIT { jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL"); - Map mapping = store.findProcessorRouteMapping("app-mapping"); + Map mapping = store.findProcessorRouteMapping("app-mapping", "default"); assertThat(mapping).containsEntry("proc-from-1", "route-5"); assertThat(mapping).containsEntry("proc-to-2", "route-5"); @@ -196,7 +196,7 @@ class ClickHouseDiagramStoreIT { jdbc.execute("OPTIMIZE TABLE route_diagrams FINAL"); - Map mapping = store.findProcessorRouteMapping("multi-app"); + Map mapping = store.findProcessorRouteMapping("multi-app", "default"); assertThat(mapping).containsEntry("proc-a1", "route-a"); assertThat(mapping).containsEntry("proc-a2", "route-a"); @@ -205,7 +205,7 @@ class ClickHouseDiagramStoreIT { @Test void findProcessorRouteMapping_unknownAppReturnsEmpty() { - Map mapping = store.findProcessorRouteMapping("nonexistent-app"); + Map mapping = store.findProcessorRouteMapping("nonexistent-app", "default"); assertThat(mapping).isEmpty(); } } diff --git a/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/ConfigEnvIsolationIT.java b/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/ConfigEnvIsolationIT.java new file mode 100644 index 00000000..e0a4f169 --- /dev/null +++ b/cameleer-server-app/src/test/java/com/cameleer/server/app/storage/ConfigEnvIsolationIT.java @@ -0,0 +1,117 @@ +package com.cameleer.server.app.storage; + +import com.cameleer.common.model.ApplicationConfig; +import com.cameleer.server.app.AbstractPostgresIT; +import com.cameleer.server.core.admin.AppSettings; +import com.cameleer.server.core.admin.AppSettingsRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that the two per-app-per-env Postgres tables isolate data correctly between + * environments — writing to (app=X, env=dev) must not affect reads for (app=X, env=prod). + * Regression test for the pre-1.0 env-scoping gap (plans/2026-04-16-environment-scoping.md). + */ +class ConfigEnvIsolationIT extends AbstractPostgresIT { + + @Autowired PostgresApplicationConfigRepository configRepo; + @Autowired AppSettingsRepository settingsRepo; + @Autowired ObjectMapper objectMapper; + + @Test + void applicationConfig_isolatesByEnvironment() { + ApplicationConfig dev = new ApplicationConfig(); + dev.setSamplingRate(0.5); + dev.setApplicationLogLevel("DEBUG"); + configRepo.save("order-svc", "dev", dev, "test"); + + ApplicationConfig prod = new ApplicationConfig(); + prod.setSamplingRate(0.01); + prod.setApplicationLogLevel("WARN"); + configRepo.save("order-svc", "prod", prod, "test"); + + ApplicationConfig readDev = configRepo.findByApplicationAndEnvironment("order-svc", "dev") + .orElseThrow(); + assertThat(readDev.getEnvironment()).isEqualTo("dev"); + assertThat(readDev.getSamplingRate()).isEqualTo(0.5); + assertThat(readDev.getApplicationLogLevel()).isEqualTo("DEBUG"); + + ApplicationConfig readProd = configRepo.findByApplicationAndEnvironment("order-svc", "prod") + .orElseThrow(); + assertThat(readProd.getEnvironment()).isEqualTo("prod"); + assertThat(readProd.getSamplingRate()).isEqualTo(0.01); + assertThat(readProd.getApplicationLogLevel()).isEqualTo("WARN"); + + assertThat(configRepo.findByApplicationAndEnvironment("order-svc", "staging")).isEmpty(); + } + + @Test + void applicationConfig_saveReplacesOnlySameEnv() { + ApplicationConfig original = new ApplicationConfig(); + original.setSamplingRate(0.5); + configRepo.save("svc", "dev", original, "alice"); + + ApplicationConfig otherEnv = new ApplicationConfig(); + otherEnv.setSamplingRate(0.1); + configRepo.save("svc", "prod", otherEnv, "alice"); + + ApplicationConfig updated = new ApplicationConfig(); + updated.setSamplingRate(0.9); + configRepo.save("svc", "dev", updated, "bob"); + + assertThat(configRepo.findByApplicationAndEnvironment("svc", "dev").orElseThrow() + .getSamplingRate()).isEqualTo(0.9); + assertThat(configRepo.findByApplicationAndEnvironment("svc", "prod").orElseThrow() + .getSamplingRate()).isEqualTo(0.1); + } + + @Test + void applicationConfig_findByEnvironment_excludesOtherEnvs() { + ApplicationConfig a = new ApplicationConfig(); + a.setSamplingRate(1.0); + configRepo.save("a", "dev", a, "test"); + configRepo.save("b", "dev", a, "test"); + configRepo.save("a", "prod", a, "test"); + + assertThat(configRepo.findByEnvironment("dev")) + .extracting(ApplicationConfig::getApplication) + .containsExactlyInAnyOrder("a", "b"); + assertThat(configRepo.findByEnvironment("prod")) + .extracting(ApplicationConfig::getApplication) + .containsExactly("a"); + } + + @Test + void appSettings_isolatesByEnvironment() { + settingsRepo.save(new AppSettings( + "svc", "dev", 500, 1.0, 5.0, 99.0, 95.0, null, null)); + settingsRepo.save(new AppSettings( + "svc", "prod", 100, 0.5, 2.0, 99.9, 99.5, null, null)); + + AppSettings readDev = settingsRepo.findByApplicationAndEnvironment("svc", "dev").orElseThrow(); + assertThat(readDev.slaThresholdMs()).isEqualTo(500); + assertThat(readDev.environment()).isEqualTo("dev"); + + AppSettings readProd = settingsRepo.findByApplicationAndEnvironment("svc", "prod").orElseThrow(); + assertThat(readProd.slaThresholdMs()).isEqualTo(100); + assertThat(readProd.environment()).isEqualTo("prod"); + + assertThat(settingsRepo.findByApplicationAndEnvironment("svc", "staging")).isEmpty(); + } + + @Test + void appSettings_delete_scopedToSingleEnv() { + settingsRepo.save(new AppSettings( + "svc", "dev", 500, 1.0, 5.0, 99.0, 95.0, null, null)); + settingsRepo.save(new AppSettings( + "svc", "prod", 100, 0.5, 2.0, 99.9, 99.5, null, null)); + + settingsRepo.delete("svc", "dev"); + + assertThat(settingsRepo.findByApplicationAndEnvironment("svc", "dev")).isEmpty(); + assertThat(settingsRepo.findByApplicationAndEnvironment("svc", "prod")).isPresent(); + } +} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AppSettings.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AppSettings.java index 989a9e02..0f66d8d0 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AppSettings.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AppSettings.java @@ -4,6 +4,7 @@ import java.time.Instant; public record AppSettings( String applicationId, + String environment, int slaThresholdMs, double healthErrorWarn, double healthErrorCrit, @@ -12,8 +13,8 @@ public record AppSettings( Instant createdAt, Instant updatedAt) { - public static AppSettings defaults(String applicationId) { + public static AppSettings defaults(String applicationId, String environment) { Instant now = Instant.now(); - return new AppSettings(applicationId, 300, 1.0, 5.0, 99.0, 95.0, now, now); + return new AppSettings(applicationId, environment, 300, 1.0, 5.0, 99.0, 95.0, now, now); } } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AppSettingsRepository.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AppSettingsRepository.java index d8a55530..a9bb744d 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AppSettingsRepository.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/admin/AppSettingsRepository.java @@ -4,8 +4,8 @@ import java.util.List; import java.util.Optional; public interface AppSettingsRepository { - Optional findByApplicationId(String applicationId); - List findAll(); + Optional findByApplicationAndEnvironment(String applicationId, String environment); + List findByEnvironment(String environment); AppSettings save(AppSettings settings); - void delete(String applicationId); + void delete(String applicationId, String environment); } diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/ingestion/TaggedDiagram.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/ingestion/TaggedDiagram.java index 328bd0a9..af38a344 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/ingestion/TaggedDiagram.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/ingestion/TaggedDiagram.java @@ -3,9 +3,11 @@ package com.cameleer.server.core.ingestion; import com.cameleer.common.graph.RouteGraph; /** - * Pairs a {@link RouteGraph} with the authenticated agent identity. + * Pairs a {@link RouteGraph} with the authenticated agent identity and environment. *

- * The agent ID is extracted from the SecurityContext in the controller layer - * and carried through the write buffer so the flush scheduler can persist it. + * The agent ID is extracted from the SecurityContext in the controller layer, + * the environment from the agent registry (which in turn came from the agent's JWT + * at registration), and all are carried through the write buffer so the flush + * scheduler can persist them. */ -public record TaggedDiagram(String instanceId, String applicationId, RouteGraph graph) {} +public record TaggedDiagram(String instanceId, String applicationId, String environment, RouteGraph graph) {} diff --git a/cameleer-server-core/src/main/java/com/cameleer/server/core/storage/DiagramStore.java b/cameleer-server-core/src/main/java/com/cameleer/server/core/storage/DiagramStore.java index a3fe0ff6..21b9419d 100644 --- a/cameleer-server-core/src/main/java/com/cameleer/server/core/storage/DiagramStore.java +++ b/cameleer-server-core/src/main/java/com/cameleer/server/core/storage/DiagramStore.java @@ -17,5 +17,5 @@ public interface DiagramStore { Optional findContentHashForRouteByAgents(String routeId, List instanceIds); - Map findProcessorRouteMapping(String applicationId); + Map findProcessorRouteMapping(String applicationId, String environment); } diff --git a/cameleer-server-core/src/test/java/com/cameleer/server/core/ingestion/ChunkAccumulatorTest.java b/cameleer-server-core/src/test/java/com/cameleer/server/core/ingestion/ChunkAccumulatorTest.java index 53a0ce6a..2f1089d5 100644 --- a/cameleer-server-core/src/test/java/com/cameleer/server/core/ingestion/ChunkAccumulatorTest.java +++ b/cameleer-server-core/src/test/java/com/cameleer/server/core/ingestion/ChunkAccumulatorTest.java @@ -23,7 +23,7 @@ class ChunkAccumulatorTest { public Optional findByContentHash(String h) { return Optional.empty(); } public Optional findContentHashForRoute(String r, String a) { return Optional.empty(); } public Optional findContentHashForRouteByAgents(String r, List a) { return Optional.empty(); } - public Map findProcessorRouteMapping(String app) { return Map.of(); } + public Map findProcessorRouteMapping(String app, String env) { return Map.of(); } }; private CopyOnWriteArrayList executionSink; diff --git a/docs/superpowers/plans/2026-04-16-environment-scoping.md b/docs/superpowers/plans/2026-04-16-environment-scoping.md new file mode 100644 index 00000000..3ab1c3ab --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-environment-scoping.md @@ -0,0 +1,135 @@ +# Environment-scoped config — fixing cross-env data bleed + +**Date:** 2026-04-16 +**Status:** Not started +**Backwards compatibility:** None (pre-1.0; user will wipe dev DB) + +## Problem + +Two PostgreSQL tables key per-app state on the application name alone, despite environments (`dev`/`staging`/`prod`) being first-class in the rest of the system: + +- `application_config` PK `(application)` — traced processors, taps, route recording, per-app sensitive keys. All env-sensitive. +- `app_settings` PK `(application_id)` — SLA threshold, health warn/crit thresholds. All env-sensitive. + +Consequences: + +- **Config corruption**: `PUT /api/v1/config/{app}?environment=dev` correctly scopes the SSE fan-out but overwrites the single DB row, so when `prod` agents restart and fetch config they get the `dev` config. +- **Agent startup is env-blind**: `GET /api/v1/config/{app}` reads neither JWT `env` claim nor any request parameter; returns whichever row exists. +- **Dashboard settings ambiguous**: `AppSettings` endpoints have no env parameter; SLA/health displayed without env context. +- Ancillary: `ClickHouseDiagramStore.findProcessorRouteMapping(appId)` doesn't filter by env even though the table has an `environment` column. +- Ancillary: `AgentRegistrationController` accepts registrations without `environmentId` and silently defaults to `"default"` — masks misconfigured agents. + +## Non-goals / working correctly (do not touch) + +- All ClickHouse observability tables (executions, logs, metrics, stats_1m_*) — already env-scoped. +- `AgentCommandController` / SSE command fan-out — already env-filtered via `AgentRegistryService.findByApplicationAndEnvironment`. +- `SearchController` search path — fixed in commit `e2d9428`. +- RBAC (users/roles/groups/claim mappings) — tenant-wide by design. +- Global sensitive-keys push to all envs (`SensitiveKeysAdminController.fanOutToAllAgents`) — by design; global baseline. +- Admin UI per-page env indicator — not needed, already shown in top-right of the shell. + +## Design decisions (fixed) + +| Question | Answer | +|---|---| +| Schema migration strategy | Edit `V1__init.sql` in place. User wipes dev DB. | +| Agent config fetch with no/unknown env | Return `404 Not Found`. No `"default"` fallback. | +| `cameleer-common` `ApplicationConfig` model | Add `environment` field in-place; agent team coordinates the bump (SNAPSHOT). | +| Agent registration without `environmentId` | Return `400 Bad Request`. Registration MUST include env. | +| UI per-screen env display | Already covered by top-right global env indicator — no extra UI work. | + +## Plan + +### Phase 1 — PostgreSQL schema + +1. Edit `cameleer-server-app/src/main/resources/db/migration/V1__init.sql`: + - `application_config`: add `environment TEXT NOT NULL` column; change PK to `(application, environment)`. + - `app_settings`: add `environment TEXT NOT NULL` column; change PK to `(application_id, environment)`. +2. Commit message MUST call out: "Wipe dev DB before deploying — Flyway V1 checksum changes." + +### Phase 2 — Shared model (`cameleer-common`) + +3. **`ApplicationConfig` stays untouched on the server side.** The agent team is adding `environment` to the common class separately; the server doesn't depend on it. On the server, `environment` flows as a sidecar parameter through repositories/controllers and as a dedicated `environment` column on `application_config`. The stored JSON body contains only the config content. If/when the field appears in the common class, we'll hydrate it from the DB column into the returned DTO — no code change needed today. +4. Add `environment` field to `AppSettings` record in `cameleer-server-core` (`admin/AppSettings.java`). **Done.** + +### Phase 3 — Repositories + +5. `PostgresApplicationConfigRepository`: + - `findByApplicationAndEnvironment(String app, String env)` replaces `findByApplication(app)`. + - `findAll(String env)` (env required) replaces `findAll()`. + - `save(String app, String env, ApplicationConfig, String updatedBy)` replaces `save(app, config, updatedBy)`. + - Keep behaviour identical except for the PK. +6. `AppSettingsRepository` interface (core) and `PostgresAppSettingsRepository` (app) — same treatment with `(applicationId, environment)`. + +### Phase 4 — REST controllers + +7. `ApplicationConfigController`: + - `getConfig(@PathVariable app)`: dual-mode by caller role. For AGENT role → env taken from JWT `env` claim, query param ignored (agents cannot spoof env). For non-agent callers (admin UI, with user JWTs whose `env="default"` is a placeholder) → env must be passed via `?environment=` query param. If neither produces a value → **404**. + - `updateConfig(@PathVariable app, @RequestParam String environment, ...)`: make `environment` required. Forward to repo save. SSE push already env-scoped — keep. + - `listConfigs(@RequestParam String environment)`: require env; filter. + - `getProcessorRouteMapping(@PathVariable app, @RequestParam String environment)`: require env; forward to ClickHouse. + - `testExpression(@PathVariable app, @RequestParam String environment, ...)`: make env required (already accepted as optional — tighten). +8. `AppSettingsController`: + - `GET /api/v1/admin/app-settings?environment=`: list filtered. + - `GET /api/v1/admin/app-settings/{appId}?environment=`: require env. + - `PUT /api/v1/admin/app-settings/{appId}?environment=`: require env. +9. `SensitiveKeysAdminController`: review — global sensitive keys are server-wide (one row in `server_config`), no change needed. Add code comment clarifying env-wide push is intentional. +10. `SearchController.stats`: the SLA threshold lookup `appSettingsRepository.findByApplicationId(app)` becomes env-aware via the existing `environment` query param. + +### Phase 5 — Storage + +11. `ClickHouseDiagramStore.findProcessorRouteMapping(app)` → `findProcessorRouteMapping(app, env)`. Include `environment = ?` in `WHERE`. + +### Phase 6 — JWT surface + +12. Expose `env` claim via Spring `Authentication` principal — simplest path is a small custom `AuthenticationPrincipal` or `@RequestAttribute("env")` populated by `JwtAuthenticationFilter`. Keep scope minimal; only `ApplicationConfigController.getConfig` needs it directly for the 404 rule. + +### Phase 7 — Agent registration hardening + +13. `AgentRegistrationController.register`: + - If `request.environmentId()` is null or blank → `400 Bad Request` with an explicit error message. Drop the `"default"` fallback on line 122. + - Log the rejection (agent identity + remote IP) at INFO for diagnostics. +14. `AgentRegistrationController.refreshToken`: remove the `"default"` fallback at line 211 (dead after Phase 7.13, but harmless to clean up). +15. `AgentRegistrationController.heartbeat`: already falls back to JWT claim; after Phase 7.13 every JWT has a real env, so the `"default"` fallback at line 247 is dead code — remove. + +### Phase 8 — UI queries + +16. `ui/src/api/queries/dashboard.ts`: `useAppSettings(appId)` → `useAppSettings(appId, environment)`; same for `useAllAppSettings()`. Pull env from `useEnvironmentStore`. +17. `ui/src/api/queries/commands.ts`: verify `useApplicationConfig(appId)` / `useUpdateApplicationConfig` already pass env. Add if missing. (Audit pass only, may be no-op.) +18. Verify no other UI hook fetches per-app state without env. + +### Phase 9 — Tests + +19. Integration: write config for `(app=X, env=dev)`; read for `(app=X, env=prod)` returns empty/default. +20. Integration: agent JWT with `env=dev` calling `GET /api/v1/config/X` returns the dev config row. JWT with no env claim → 404. +21. Integration: `POST /api/v1/agents/register` with no `environmentId` → 400. +22. Unit: `AppSettingsRepository` env-isolation test. + +### Phase 10 — Documentation + +23. `CLAUDE.md`: + - "Storage" section: update `application_config` and `app_settings` PK description. + - Agent lifecycle section: note that registration requires `environmentId` (was optional, defaulted to `"default"`). + - Remove the "priority: heartbeat `environmentId` > JWT `env` claim > `"default"`" note — after fix, every agent has a real env on every path. +24. `.claude/rules/app-classes.md`: + - `ApplicationConfigController` — reflect env-required endpoints. + - `AppSettingsController` — reflect env-required endpoints. + - `AgentRegistrationController` — note env required. +25. `.claude/rules/core-classes.md`: + - `PostgresApplicationConfigRepository`, `PostgresAppSettingsRepository` — updated signatures. + +## Execution order + +Phases are mostly sequential by dependency: 1 → 2 → 3 → (4, 5 in parallel) → 6 → 7 → 8 → 9 → 10. Phase 6 (JWT surfacing) is a small dependency for Phase 4 controller changes; do them together. + +## Verification + +- `mvn clean verify` passes. +- `detect_changes` scope matches the files touched per phase. +- Manual: spin up two envs (`dev` + `prod`) locally; configure tap in `dev`; confirm `prod` agent doesn't receive it and its DB row is untouched. +- Manual: stop an agent without env in the registration payload; confirm server returns 400. + +## Out of scope / follow-ups + +- `audit_log` has no `environment` column; filtering audit by env would be nice-to-have but not a correctness issue. Defer. +- Agent bootstrap-token scoping to env (so a dev token can't register as prod) — security hardening for after 1.0. diff --git a/ui/src/api/queries/commands.ts b/ui/src/api/queries/commands.ts index cefa610f..a35285ea 100644 --- a/ui/src/api/queries/commands.ts +++ b/ui/src/api/queries/commands.ts @@ -56,11 +56,12 @@ export function useAllApplicationConfigs() { }) } -export function useApplicationConfig(application: string | undefined) { +export function useApplicationConfig(application: string | undefined, environment: string | undefined) { return useQuery({ - queryKey: ['applicationConfig', application], + queryKey: ['applicationConfig', application, environment], queryFn: async () => { - const res = await authFetch(`/config/${application}`) + const envParam = environment ? `?environment=${encodeURIComponent(environment)}` : '' + const res = await authFetch(`/config/${application}${envParam}`) if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`) const data = await res.json() // Server returns AppConfigResponse: { config, globalSensitiveKeys, mergedSensitiveKeys } @@ -69,7 +70,7 @@ export function useApplicationConfig(application: string | undefined) { cfg.mergedSensitiveKeys = data.mergedSensitiveKeys ?? null return cfg as ApplicationConfig }, - enabled: !!application, + enabled: !!application && !!environment, }) } @@ -100,15 +101,16 @@ export function useUpdateApplicationConfig() { // ── Processor → Route Mapping ───────────────────────────────────────────── -export function useProcessorRouteMapping(application?: string) { +export function useProcessorRouteMapping(application?: string, environment?: string) { return useQuery({ - queryKey: ['config', application, 'processor-routes'], + queryKey: ['config', application, environment, 'processor-routes'], queryFn: async () => { - const res = await authFetch(`/config/${application}/processor-routes`) + const res = await authFetch( + `/config/${application}/processor-routes?environment=${encodeURIComponent(environment!)}`) if (!res.ok) throw new Error('Failed to fetch processor-route mapping') return res.json() as Promise> }, - enabled: !!application, + enabled: !!application && !!environment, }) } diff --git a/ui/src/api/queries/dashboard.ts b/ui/src/api/queries/dashboard.ts index 889b9f97..6a38660d 100644 --- a/ui/src/api/queries/dashboard.ts +++ b/ui/src/api/queries/dashboard.ts @@ -115,6 +115,7 @@ export function usePunchcard(application?: string, environment?: string) { export interface AppSettings { appId: string; + environment: string; slaThresholdMs: number; healthErrorWarn: number; healthErrorCrit: number; @@ -124,19 +125,22 @@ export interface AppSettings { updatedAt: string; } -export function useAppSettings(appId?: string) { +export function useAppSettings(appId?: string, environment?: string) { return useQuery({ - queryKey: ['app-settings', appId], - queryFn: () => fetchJson(`/admin/app-settings/${appId}`), - enabled: !!appId, + queryKey: ['app-settings', appId, environment], + queryFn: () => fetchJson( + `/admin/app-settings/${appId}?environment=${encodeURIComponent(environment!)}`), + enabled: !!appId && !!environment, staleTime: 60_000, }); } -export function useAllAppSettings() { +export function useAllAppSettings(environment?: string) { return useQuery({ - queryKey: ['app-settings', 'all'], - queryFn: () => fetchJson('/admin/app-settings'), + queryKey: ['app-settings', 'all', environment], + queryFn: () => fetchJson( + `/admin/app-settings?environment=${encodeURIComponent(environment!)}`), + enabled: !!environment, staleTime: 60_000, }); } @@ -144,13 +148,15 @@ export function useAllAppSettings() { export function useUpdateAppSettings() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ appId, settings }: { appId: string; settings: Omit }) => { - const token = useAuthStore.getState().accessToken; - const res = await fetch(`${config.apiBaseUrl}/admin/app-settings/${appId}`, { - method: 'PUT', - headers: { ...authHeaders(), 'Content-Type': 'application/json' }, - body: JSON.stringify(settings), - }); + mutationFn: async ({ appId, environment, settings }: + { appId: string; environment: string; settings: Omit }) => { + const res = await fetch( + `${config.apiBaseUrl}/admin/app-settings/${appId}?environment=${encodeURIComponent(environment)}`, + { + method: 'PUT', + headers: { ...authHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); if (!res.ok) throw new Error('Failed to update app settings'); return res.json(); }, diff --git a/ui/src/pages/Admin/AppConfigDetailPage.tsx b/ui/src/pages/Admin/AppConfigDetailPage.tsx index d552c6c9..74783eb0 100644 --- a/ui/src/pages/Admin/AppConfigDetailPage.tsx +++ b/ui/src/pages/Admin/AppConfigDetailPage.tsx @@ -77,7 +77,7 @@ export default function AppConfigDetailPage() { const navigate = useNavigate(); const { toast } = useToast(); const selectedEnv = useEnvironmentStore((s) => s.environment); - const { data: config, isLoading } = useApplicationConfig(appId); + const { data: config, isLoading } = useApplicationConfig(appId, selectedEnv); const updateConfig = useUpdateApplicationConfig(); const { data: catalog } = useCatalog(); diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index a253f05a..d0bb7ec4 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -164,7 +164,7 @@ export default function AgentHealth() { const { toast } = useToast(); const selectedEnv = useEnvironmentStore((s) => s.environment); const { data: agents } = useAgents(undefined, appId, selectedEnv); - const { data: appConfig } = useApplicationConfig(appId); + const { data: appConfig } = useApplicationConfig(appId, selectedEnv); const updateConfig = useUpdateApplicationConfig(); const isAdmin = useIsAdmin(); diff --git a/ui/src/pages/AppsTab/AppsTab.tsx b/ui/src/pages/AppsTab/AppsTab.tsx index 33f981ed..46dd3278 100644 --- a/ui/src/pages/AppsTab/AppsTab.tsx +++ b/ui/src/pages/AppsTab/AppsTab.tsx @@ -941,11 +941,12 @@ interface RouteRecordingRow { id: string; routeId: string; recording: boolean; } function ConfigSubTab({ app, environment }: { app: App; environment?: Environment }) { const { toast } = useToast(); const navigate = useNavigate(); - const { data: agentConfig } = useApplicationConfig(app.slug); + const envSlug = environment?.slug; + const { data: agentConfig } = useApplicationConfig(app.slug, envSlug); const updateAgentConfig = useUpdateApplicationConfig(); const updateContainerConfig = useUpdateContainerConfig(); const { data: catalog } = useCatalog(); - const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug); + const { data: processorToRoute = {} } = useProcessorRouteMapping(app.slug, envSlug); const isProd = environment?.production ?? false; const [editing, setEditing] = useState(false); const [configTab, setConfigTab] = useState<'monitoring' | 'resources' | 'variables' | 'traces' | 'recording'>('monitoring'); diff --git a/ui/src/pages/DashboardTab/DashboardL1.tsx b/ui/src/pages/DashboardTab/DashboardL1.tsx index 4b3c353e..b369bbe4 100644 --- a/ui/src/pages/DashboardTab/DashboardL1.tsx +++ b/ui/src/pages/DashboardTab/DashboardL1.tsx @@ -305,7 +305,7 @@ export default function DashboardL1() { const { data: timeseriesByApp } = useTimeseriesByApp(timeFrom, timeTo, selectedEnv); const { data: topErrors } = useTopErrors(timeFrom, timeTo, undefined, undefined, selectedEnv); const { data: punchcardData } = usePunchcard(undefined, selectedEnv); - const { data: allAppSettings } = useAllAppSettings(); + const { data: allAppSettings } = useAllAppSettings(selectedEnv); // Build settings lookup map const settingsMap = useMemo(() => { diff --git a/ui/src/pages/DashboardTab/DashboardL2.tsx b/ui/src/pages/DashboardTab/DashboardL2.tsx index e0f1fa45..03ffaa36 100644 --- a/ui/src/pages/DashboardTab/DashboardL2.tsx +++ b/ui/src/pages/DashboardTab/DashboardL2.tsx @@ -287,7 +287,7 @@ export default function DashboardL2() { const { data: timeseriesByRoute } = useTimeseriesByRoute(timeFrom, timeTo, appId, selectedEnv); const { data: errors } = useTopErrors(timeFrom, timeTo, appId, undefined, selectedEnv); const { data: punchcardData } = usePunchcard(appId, selectedEnv); - const { data: appSettings } = useAppSettings(appId); + const { data: appSettings } = useAppSettings(appId, selectedEnv); const slaThresholdMs = appSettings?.slaThresholdMs ?? 300; diff --git a/ui/src/pages/DashboardTab/DashboardL3.tsx b/ui/src/pages/DashboardTab/DashboardL3.tsx index 9bccb7e3..6ab1c69b 100644 --- a/ui/src/pages/DashboardTab/DashboardL3.tsx +++ b/ui/src/pages/DashboardTab/DashboardL3.tsx @@ -261,7 +261,7 @@ export default function DashboardL3() { const { data: processorMetrics } = useProcessorMetrics(routeId ?? null, appId, selectedEnv); const { data: topErrors } = useTopErrors(timeFrom, timeTo, appId, routeId, selectedEnv); const { data: diagramLayout } = useDiagramByRoute(appId, routeId); - const { data: appSettings } = useAppSettings(appId); + const { data: appSettings } = useAppSettings(appId, selectedEnv); const slaThresholdMs = appSettings?.slaThresholdMs ?? 300; diff --git a/ui/src/pages/Exchanges/ExchangesPage.tsx b/ui/src/pages/Exchanges/ExchangesPage.tsx index 03b5f68d..df74a8eb 100644 --- a/ui/src/pages/Exchanges/ExchangesPage.tsx +++ b/ui/src/pages/Exchanges/ExchangesPage.tsx @@ -209,7 +209,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS }, [catalog]); // Build nodeConfigs from app config (for TRACE/TAP badges) - const { data: appConfig } = useApplicationConfig(appId); + const { data: appConfig } = useApplicationConfig(appId, selectedEnv); const nodeConfigs = useMemo(() => { const map = new Map(); if (appConfig?.tracedProcessors) { diff --git a/ui/src/pages/Routes/RouteDetail.tsx b/ui/src/pages/Routes/RouteDetail.tsx index fd98a57e..7a2b1387 100644 --- a/ui/src/pages/Routes/RouteDetail.tsx +++ b/ui/src/pages/Routes/RouteDetail.tsx @@ -340,7 +340,7 @@ export default function RouteDetail() { }); // ── Application config ────────────────────────────────────────────────────── - const config = useApplicationConfig(appId); + const config = useApplicationConfig(appId, selectedEnv); const updateConfig = useUpdateApplicationConfig(); const testExpressionMutation = useTestExpression();