Compare commits

8 Commits

Author SHA1 Message Date
hsiegeln
b7a107d33f test: update integration tests for env-scoped URL shape
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m49s
CI / docker (push) Successful in 2m5s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 1m37s
Picks up the URL moves from P2/P3A/P3B/P3C. Also fixes a latent bug in
AppControllerIT.uploadJar_asOperator_returns201 / DeploymentControllerIT
setUp: the tests were passing the app's UUID as the {appSlug} path
variable (via `path("id").asText()`); the old AppController looked up
apps via getBySlug(), so the legacy URL call would 404 when the slug
literal was a UUID. Now the test tracks the known slug string and uses
it for every /apps/{appSlug}/... path.

Test URL updates:
- SearchControllerIT: /api/v1/search/executions →
  /api/v1/environments/default/executions (GET) and
  /api/v1/environments/default/executions/search (POST).
- AppControllerIT: /api/v1/apps → /api/v1/environments/default/apps.
  Request bodies drop environmentId (it's in the path).
- DeploymentControllerIT: /api/v1/apps/{appId}/deployments →
  /api/v1/environments/default/apps/{appSlug}/deployments. DeployRequest
  body drops environmentId.
- JwtRefreshIT + RegistrationSecurityIT: smoke-test protected endpoint
  call updated to the new /environments/default/executions shape.

All tests compile clean. Runtime behavior requires a full stack
(Postgres + ClickHouse + Docker); validating integration tests is a
pre-merge step before merging the feature branch.

Remaining pre-merge items (not blocked by code):
1. Regenerate ui/src/api/schema.d.ts + openapi.json by running
   `cd ui && npm run generate-api:live` against a running backend.
   SearchController, DeploymentController, etc. DTO signatures have
   changed; schema.d.ts is frozen at the pre-migration shape.
   Raw-fetch call sites introduced in P3A/P3C work at runtime without
   the schema; the regen only sharpens TypeScript coverage.
2. Smoke test locally: boot server, verify EnvironmentsPage,
   AppsTab, Exchanges, Dashboard, Runtime pages all function.
3. Run `mvn verify` end-to-end (Testcontainers + Docker required).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:53:55 +02:00
hsiegeln
51d7bda5b8 docs: document P3 URL taxonomy, slug immutability, tenant invariant
Locks the new conventions into rule files so future agents and humans
don't drift back into old patterns.

- .claude/rules/app-classes.md: replaces the flat endpoint catalog
  with a taxonomy-aware reorganization (env-scoped / env-admin /
  agent-only / ingestion / cross-env discovery / admin / other).
  Adds the flat-endpoint allow-list with rationale per prefix and
  documents the tenant-filter invariant for ClickHouse queries.
- CLAUDE.md: adds four convention bullets in Key Conventions —
  URL taxonomy with allow-list pointer, slug immutability rule,
  app uniqueness as (env, app_slug), env-required on env-scoped
  endpoints.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:50:38 +02:00
hsiegeln
873e1d3df7 feat!: move query/logs/routes/diagram/agent-view endpoints under /environments/{envSlug}/
P3C — the last data/query wave of the taxonomy migration. Every user-
facing read endpoint that was keyed on env-as-query-param is now under
the env-scoped URL, making env impossible to omit and unambiguous in
server-side tenant+env filtering.

Server:
- SearchController: /api/v1/search/** → /api/v1/environments/{envSlug}/...
  Endpoints: /executions (GET), /executions/search (POST), /stats,
  /stats/timeseries, /stats/timeseries/by-app, /stats/timeseries/by-route,
  /stats/punchcard, /attributes/keys, /errors/top. Env comes from path.
- LogQueryController: /api/v1/logs → /api/v1/environments/{envSlug}/logs.
- RouteCatalogController: /api/v1/routes/catalog → /api/v1/environments/
  {envSlug}/routes. Env filter unconditional (path).
- RouteMetricsController: /api/v1/routes/metrics →
  /api/v1/environments/{envSlug}/routes/metrics (and /metrics/processors).
- DiagramRenderController: /{contentHash}/render stays flat (hashes are
  globally unique). Find-by-route moved to /api/v1/environments/{envSlug}/
  apps/{appSlug}/routes/{routeId}/diagram — the old GET /diagrams?...
  handler is removed.
- Agent views split cleanly:
  - AgentListController (new): /api/v1/environments/{envSlug}/agents
  - AgentEventsController: /api/v1/environments/{envSlug}/agents/events
  - AgentMetricsController: /api/v1/environments/{envSlug}/agents/
    {agentId}/metrics — now also rejects cross-env agents (404) as a
    defense-in-depth check, fulfilling B3.
  Agent self-service endpoints (register/refresh/heartbeat/deregister)
  remain flat at /api/v1/agents/** — JWT-authoritative.

SPA:
- queries/agents.ts, agent-metrics.ts, logs.ts, catalog.ts (route
  metrics only; /catalog stays flat), processor-metrics.ts,
  executions.ts (attributes/keys, stats, timeseries, search),
  dashboard.ts (all stats/errors/punchcard), correlation.ts,
  diagrams.ts (by-route) — all rewritten to env-scoped URLs.
- Hooks now either read env from useEnvironmentStore internally or
  require it as an argument. Query keys include env so switching env
  invalidates caches.
- useAgents/useAgentEvents signature simplified — env is no longer a
  parameter; it's read from the store. Callers (LayoutShell,
  AgentHealth, AgentInstance) updated accordingly.
- LogTab and useStartupLogs thread env through to useLogs.
- envFetch helper introduced in executions.ts for env-prefixed raw
  fetch until schema.d.ts is regenerated against the new backend.

BREAKING CHANGE: All these flat paths are removed:
  /api/v1/search/**, /api/v1/logs, /api/v1/routes/catalog,
  /api/v1/routes/metrics (and /processors), /api/v1/diagrams
  (lookup), /api/v1/agents (list), /api/v1/agents/events-log,
  /api/v1/agents/{id}/metrics, /api/v1/agent-events.
Clients must use the /api/v1/environments/{envSlug}/... equivalents.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:48:25 +02:00
hsiegeln
6d9e456b97 feat!: move apps & deployments under /api/v1/environments/{envSlug}/apps/{appSlug}/...
P3B of the taxonomy migration. App and deployment routes are now
env-scoped in the URL itself, making the (env, app_slug) uniqueness
key explicit. Previously /api/v1/apps/{appSlug} was ambiguous: with
the same app deployed to multiple environments (dev/staging/prod),
the handler called AppService.getBySlug(slug) which returns the
first row matching slug regardless of env.

Server:
- AppController: @RequestMapping("/api/v1/environments/{envSlug}/
  apps"). Every handler now calls
  appService.getByEnvironmentAndSlug(env.id(), appSlug) — 404 if the
  app doesn't exist in *this* env. CreateAppRequest body drops
  environmentId (it's in the path).
- DeploymentController: @RequestMapping("/api/v1/environments/
  {envSlug}/apps/{appSlug}/deployments"). DeployRequest body drops
  environmentId. PromoteRequest body switches from
  targetEnvironmentId (UUID) to targetEnvironment (slug);
  promote handler resolves the target env by slug and looks up the
  app with the same slug in the target env (fails with 404 if the
  target app doesn't exist yet — apps must exist in both source
  and target before promote).
- AppService: added getByEnvironmentAndSlug helper; createApp now
  validates slug against ^[a-z0-9][a-z0-9-]{0,63}$ (400 on
  invalid).

SPA:
- queries/admin/apps.ts: rewritten. Hooks take envSlug where
  env-scoped. Removed useAllApps (no flat endpoint). Renamed path
  param naming: appId → appSlug throughout. Added
  usePromoteDeployment. Query keys include envSlug so cache is
  env-scoped.
- AppsTab.tsx: call sites updated. When no environment is selected,
  the managed-app list is empty — cross-env discovery lives in the
  Runtime tab (catalog). handleDeploy/handleStop/etc. pass envSlug
  to the new hook signatures.

BREAKING CHANGE: /api/v1/apps/** paths removed. Clients must use
/api/v1/environments/{envSlug}/apps/{appSlug}/**. Request bodies
for POST /apps and POST /apps/{slug}/deployments no longer accept
environmentId (use the URL path instead). Promote body uses slug
not UUID.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:38:37 +02:00
hsiegeln
969cdb3bd0 feat!: move config & settings under /api/v1/environments/{envSlug}/...
P3A of the taxonomy migration. Env-scoped config and settings endpoints
now live under the env-prefixed URL shape, making env a first-class
path segment instead of a query param. Agent-authoritative config is
split off into a dedicated endpoint so agent env comes from the JWT
only — never spoofable via URL.

Server:
- ApplicationConfigController: @RequestMapping("/api/v1/environments/
  {envSlug}"). Handlers use @EnvPath Environment env, appSlug as
  @PathVariable. Removed the dual-mode resolveEnvironmentForRead —
  user flow only; agent flow moved to AgentConfigController.
- AgentConfigController (new): GET /api/v1/agents/config. Reads
  instanceId from JWT subject, resolves (app, env) from registry,
  returns AppConfigResponse. Registry miss → falls back to JWT env
  claim for environment, but 404s if application cannot be derived
  (no other source without registry).
- AppSettingsController: @RequestMapping("/api/v1/environments/
  {envSlug}"). List at /app-settings, per-app at /apps/{appSlug}/
  settings. Access class-wide PreAuthorize preserved (ADMIN/OPERATOR).

SPA:
- commands.ts: useAllApplicationConfigs, useApplicationConfig,
  useUpdateApplicationConfig, useProcessorRouteMapping,
  useTestExpression — rewritten URLs to /environments/{env}/apps/
  {app}/... shape. environment now required on every call. Query
  keys include environment so cache is env-scoped.
- dashboard.ts: useAppSettings, useAllAppSettings, useUpdateAppSettings
  rewritten.
- TapConfigModal: new required environment prop; callers updated.
- RouteDetail, ExchangesPage: thread selectedEnv into test-expression
  and modal.

Config changes in SecurityConfig for the new shape landed earlier in
P0.2; no security rule changes needed in this commit.

BREAKING CHANGE: /api/v1/config/** and /api/v1/admin/app-settings/**
paths removed. Agents must use /api/v1/agents/config instead of
GET /api/v1/config/{app}; users must use /api/v1/environments/{env}/
apps/{app}/config and /api/v1/environments/{env}/apps/{app}/settings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:33:25 +02:00
hsiegeln
6b5ee10944 feat!: environment admin URLs use slug; validate and immutabilize slug
UUID-based admin paths were the only remaining UUID-in-URL pattern in
the API. Migrates /api/v1/admin/environments/{id} to /{envSlug} so
slugs are the single environment identifier in every URL. UUIDs stay
internal to the database.

- Controller: @PathVariable UUID id → @PathVariable String envSlug on
  get/update/delete and the two nested endpoints (default-container-
  config, jar-retention). Handlers resolve slug → Environment via
  EnvironmentService.getBySlug, then delegate to existing UUID-based
  service methods.
- Service: create() now validates slug against ^[a-z0-9][a-z0-9-]{0,63}$
  and returns 400 on invalid slugs. Rationale documented in the class:
  slugs are immutable after creation because they appear in URLs,
  Docker network names, container names, and ClickHouse partition keys.
- UpdateEnvironmentRequest has no slug field and Jackson's default
  ignore-unknown behavior drops any slug supplied in a PUT body;
  regression test (updateEnvironment_withSlugInBody_ignoresSlug)
  documents this invariant.
- SPA: mutation args change from { id } to { slug }. EnvironmentsPage
  still uses env.id for local selection state (UUID from DB) but
  passes env.slug to every mutation.

BREAKING CHANGE: /api/v1/admin/environments/{id:UUID}/... paths removed.
Clients must use /{envSlug}/... (slug from the environments list).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:23:31 +02:00
hsiegeln
fcb53dd010 fix!: require environment on diagram lookup and attribute keys queries
Closes two cross-env data leakage paths. Both endpoints previously
returned data aggregated across all environments, so a diagram or
attribute key from dev could appear in a prod UI query (and vice versa).

B1: GET /api/v1/diagrams?application=&routeId= now requires
?environment= and resolves agents via
registryService.findByApplicationAndEnvironment instead of
findByApplication. Prevents serving a dev diagram for a prod route.

B2: GET /api/v1/search/attributes/keys now requires ?environment=.
SearchIndex.distinctAttributeKeys gains an environment parameter and
the ClickHouse query adds the env filter alongside the existing
tenant_id filter. Prevents prod attribute names leaking into dev
autocompletion (and vice versa).

SPA hooks updated to thread environment through from
useEnvironmentStore; query keys include environment so React Query
re-fetches on env switch. No call-site changes needed — hook
signatures unchanged.

B3 (AgentMetricsController env scope) deferred to P3C: agent-env is
effectively 1:1 today via the instance_id naming
({envSlug}-{appSlug}-{replicaIndex}), and the URL migration in P3C
to /api/v1/environments/{env}/agents/{agentId}/metrics naturally
introduces env from path. A minimal P1 fix would regress the "view
metrics of a killed agent" case.

BREAKING CHANGE: Both endpoints now require ?environment= (slug).
Clients omitting the parameter receive 400.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:19:55 +02:00
hsiegeln
c97d0ea061 feat: add @EnvPath resolver and security matchers for env-scoped URLs
Groundwork for the REST API taxonomy migration. Introduces the
infrastructure that future waves use to move data/query endpoints under
/api/v1/environments/{envSlug}/... without per-handler boilerplate.

- Add @EnvPath annotation + EnvironmentPathResolver: injects the
  Environment identified by the {envSlug} path variable, 404 on unknown
  slug, registered via WebConfig.addArgumentResolvers.
- Add env-scoped URL matchers to SecurityConfig (config, settings,
  executions/stats/logs/routes/agents/apps/deployments under
  /environments/*/**). Legacy flat matchers kept in place and will be
  removed per-wave as controllers migrate. New agent-authoritative
  /api/v1/agents/config matcher prepared for the agent/user split.
- Document OpenAPI schema regen workflow in CLAUDE.md so future API
  changes cover schema.d.ts regeneration as part of the change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 23:13:17 +02:00
53 changed files with 1278 additions and 844 deletions

View File

@@ -7,44 +7,94 @@ paths:
`cameleer-server-app/src/main/java/com/cameleer/server/app/`
## URL taxonomy
User-facing data and config endpoints live under `/api/v1/environments/{envSlug}/...`. Env is a path segment, never a query param. The `envSlug` is resolved to an `Environment` bean via the `@EnvPath` argument resolver (`web/EnvironmentPathResolver.java`) — 404 on unknown slug.
**Slugs are immutable after creation** for both environments and apps. Slug regex: `^[a-z0-9][a-z0-9-]{0,63}$`. Validated in `EnvironmentService.create` and `AppService.createApp`. Update endpoints (`PUT`) do not accept a slug field; Jackson drops it as an unknown property.
### Flat-endpoint allow-list
These paths intentionally stay flat (no `/environments/{envSlug}` prefix). Every new endpoint should be env-scoped unless it appears here and the reason is documented.
| Path prefix | Why flat |
|---|---|
| `/api/v1/data/**` | Agent ingestion. JWT `env` claim is authoritative; URL-embedded env would invite spoofing. |
| `/api/v1/agents/register`, `/refresh`, `/{id}/heartbeat`, `/{id}/events` (SSE), `/{id}/deregister`, `/{id}/commands`, `/{id}/commands/{id}/ack`, `/{id}/replay` | Agent self-service; JWT-bound. |
| `/api/v1/agents/commands`, `/api/v1/agents/groups/{group}/commands` | Operator fan-out; target scope is explicit in query params. |
| `/api/v1/agents/config` | Agent-authoritative config read; JWT → registry → (app, env). |
| `/api/v1/admin/{users,roles,groups,oidc,license,audit,rbac/stats,claim-mappings,thresholds,sensitive-keys,usage,clickhouse,database,environments}` | Truly cross-env admin. Env CRUD URLs use `{envSlug}`, not UUID. |
| `/api/v1/catalog`, `/api/v1/catalog/{applicationId}` | Cross-env discovery is the purpose. Env is an optional filter via `?environment=`. |
| `/api/v1/executions/{execId}`, `/processors/**` | Exchange IDs are globally unique; permalinks. |
| `/api/v1/diagrams/{contentHash}/render`, `POST /api/v1/diagrams/render` | Content-addressed or stateless. |
| `/api/v1/auth/**` | Pre-auth; no env context exists. |
| `/api/v1/health`, `/prometheus`, `/api-docs/**`, `/swagger-ui/**` | Server metadata. |
## Tenant isolation invariant
ClickHouse is shared across tenants. Every ClickHouse query must filter by `tenant_id` (from `CAMELEER_SERVER_TENANT_ID` env var, resolved via `TenantContext`/config) in addition to `environment`. New controllers added under `/environments/{envSlug}/...` must preserve this — the env filter from the path does not replace the tenant filter.
## controller/ — REST endpoints
- `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
- `DeploymentController` — GET/POST /api/v1/apps/{appId}/deployments, POST /{id}/stop, POST /{id}/promote, GET /{id}/logs
- `EnvironmentAdminController`CRUD /api/v1/admin/environments, PUT /{id}/jar-retention
- `ExecutionController`GET /api/v1/executions (search + detail)
- `SearchController`POST /api/v1/search, GET /routes, GET /top-errors, GET /punchcard
- `LogQueryController` — GET /api/v1/logs (filters: source, application, agentId, exchangeId, level, logger, q, environment, time range)
- `LogIngestionController`POST /api/v1/data/logs (accepts `List<LogEntry>` JSON array, each entry has `source`: app/agent). Logs WARN for: missing agent identity, unregistered agents, empty payloads, buffer-full drops, deserialization failures. Normal acceptance at DEBUG.
- `CatalogController` — GET /api/v1/catalog (unified app catalog merging PG managed apps + in-memory agents + CH stats), DELETE /api/v1/catalog/{applicationId} (ADMIN: dismiss app, purge all CH data + PG record). Auto-filters discovered apps older than `discoveryttldays` with no live agents.
- `ChunkIngestionController`POST /api/v1/ingestion/chunk/{executions|metrics|diagrams}
- `UserAdminController`CRUD /api/v1/admin/users, POST /{id}/roles, POST /{id}/set-password
- `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`. 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 /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/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)
- `EventIngestionController` — POST /api/v1/data/events (agent event ingestion)
- `RbacStatsController`GET /api/v1/admin/rbac/stats
- `RouteCatalogController`GET /api/v1/routes/catalog (merged route catalog from registry + ClickHouse)
- `RouteMetricsController`GET /api/v1/route-metrics (per-route Camel metrics)
- `ThresholdAdminController` — CRUD /api/v1/admin/thresholds
- `UsageAnalyticsController` — GET /api/v1/admin/usage (ClickHouse usage_events)
### Env-scoped (user-facing data & config)
- `AppController``/api/v1/environments/{envSlug}/apps`. GET list / POST create / GET `{appSlug}` / DELETE `{appSlug}` / GET `{appSlug}/versions` / POST `{appSlug}/versions` (JAR upload) / PUT `{appSlug}/container-config`. App slug uniqueness is per-env (`(env, app_slug)` is the natural key). `CreateAppRequest` body has no env (path), validates slug regex.
- `DeploymentController``/api/v1/environments/{envSlug}/apps/{appSlug}/deployments`. GET list / POST create (body `{ appVersionId }`) / POST `{id}/stop` / POST `{id}/promote` (body `{ targetEnvironment: slug }` — target app slug must exist in target env) / GET `{id}/logs`.
- `ApplicationConfigController``/api/v1/environments/{envSlug}`. GET `/config` (list), GET/PUT `/apps/{appSlug}/config`, GET `/apps/{appSlug}/processor-routes`, POST `/apps/{appSlug}/config/test-expression`. PUT also pushes `CONFIG_UPDATE` to LIVE agents in this env.
- `AppSettingsController``/api/v1/environments/{envSlug}`. GET `/app-settings` (list), GET/PUT/DELETE `/apps/{appSlug}/settings`. ADMIN/OPERATOR only.
- `SearchController``/api/v1/environments/{envSlug}`. GET `/executions`, POST `/executions/search`, GET `/stats`, `/stats/timeseries`, `/stats/timeseries/by-app`, `/stats/timeseries/by-route`, `/stats/punchcard`, `/attributes/keys`, `/errors/top`.
- `LogQueryController`GET `/api/v1/environments/{envSlug}/logs` (filters: source, application, agentId, exchangeId, level, logger, q, time range).
- `RouteCatalogController` — GET `/api/v1/environments/{envSlug}/routes` (merged route catalog from registry + ClickHouse; env filter unconditional).
- `RouteMetricsController`GET `/api/v1/environments/{envSlug}/routes/metrics`, GET `/api/v1/environments/{envSlug}/routes/metrics/processors`.
- `AgentListController` — GET `/api/v1/environments/{envSlug}/agents` (registered agents with runtime metrics, filtered to env).
- `AgentEventsController`GET `/api/v1/environments/{envSlug}/agents/events` (lifecycle events).
- `AgentMetricsController`GET `/api/v1/environments/{envSlug}/agents/{agentId}/metrics` (JVM/Camel metrics). Rejects cross-env agents (404) as defence-in-depth.
- `DiagramRenderController`GET `/api/v1/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram` (env-scoped lookup). Also GET `/api/v1/diagrams/{contentHash}/render` (flat — content hashes are globally unique).
### Env admin (env-slug-parameterized, not env-scoped data)
- `EnvironmentAdminController``/api/v1/admin/environments`. GET list / POST create / GET `{envSlug}` / PUT `{envSlug}` / DELETE `{envSlug}` / PUT `{envSlug}/default-container-config` / PUT `{envSlug}/jar-retention`. Slug immutable — PUT body has no slug field; any slug supplied is dropped by Jackson. Slug validated on POST.
### Agent-only (JWT-authoritative, intentionally flat)
- `AgentRegistrationController` — POST `/register` (requires `environmentId` in body; 400 if missing), POST `/{id}/refresh` (rejects tokens with no `env` claim), POST `/{id}/heartbeat` (env from body preferred, JWT fallback; 400 if neither), POST `/{id}/deregister`.
- `AgentSseController` — GET `/{id}/events` (SSE connection).
- `AgentCommandController`POST `/{agentId}/commands`, POST `/groups/{group}/commands`, POST `/commands` (broadcast), POST `/{agentId}/commands/{commandId}/ack`, POST `/{agentId}/replay`.
- `AgentConfigController` — GET `/api/v1/agents/config`. Agent-authoritative config read: resolves (app, env) from JWT subject → registry (registry miss falls back to JWT env claim; no registry entry → 404 since application can't be derived).
### Ingestion (agent-only, JWT-authoritative)
- `LogIngestionController`POST `/api/v1/data/logs` (accepts `List<LogEntry>`; WARNs on missing identity, unregistered agents, empty payloads, buffer-full drops).
- `EventIngestionController`POST `/api/v1/data/events`.
- `ChunkIngestionController` — POST `/api/v1/ingestion/chunk/{executions|metrics|diagrams}`.
- `ExecutionController`POST `/api/v1/data/executions` (legacy ingestion path when ClickHouse disabled).
- `MetricsController`POST `/api/v1/data/metrics`.
- `DiagramController`POST `/api/v1/data/diagrams` (resolves applicationId + environment from the agent registry keyed on JWT subject; stamps both on the stored `TaggedDiagram`).
### Cross-env discovery (flat)
- `CatalogController` — GET `/api/v1/catalog` (merges managed apps + in-memory agents + CH stats; optional `?environment=` filter). DELETE `/api/v1/catalog/{applicationId}` (ADMIN: dismiss app, purge all CH data + PG record).
### Admin (cross-env, flat)
- `UserAdminController` — CRUD `/api/v1/admin/users`, POST `/{id}/roles`, POST `/{id}/set-password`.
- `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 or 204 if not configured. PUT accepts `{ keys: [...] }` with optional `?pushToAgents=true`. Fan-out iterates every distinct `(application, environment)` slice — intentional global baseline + per-env overrides.
- `ClaimMappingAdminController` — CRUD `/api/v1/admin/claim-mappings`, POST `/test`.
- `LicenseAdminController` — GET/POST `/api/v1/admin/license`.
- `ThresholdAdminController` — CRUD `/api/v1/admin/thresholds`.
- `AuditLogController` — GET `/api/v1/admin/audit`.
- `RbacStatsController` — GET `/api/v1/admin/rbac/stats`.
- `UsageAnalyticsController` — GET `/api/v1/admin/usage` (ClickHouse `usage_events`).
- `ClickHouseAdminController` — GET `/api/v1/admin/clickhouse/**` (conditional on `infrastructureendpoints` flag).
- `DatabaseAdminController` — GET `/api/v1/admin/database/**` (conditional on `infrastructureendpoints` flag).
### Other (flat)
- `DetailController` — GET `/api/v1/executions/{executionId}` + processor snapshot endpoints.
- `MetricsController` — exposes `/api/v1/metrics` and `/api/v1/prometheus` (server-side Prometheus scrape endpoint).
## runtime/ — Docker orchestration

View File

@@ -37,7 +37,10 @@ java -jar cameleer-server-app/target/cameleer-server-app-1.0-SNAPSHOT.jar
- Depends on `com.cameleer:cameleer-common` from Gitea Maven registry
- 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).
- URL taxonomy: user-facing data, config, and query endpoints live under `/api/v1/environments/{envSlug}/...`. Env is a path segment, resolved via the `@EnvPath` argument resolver (404 on unknown slug). Flat endpoints are only for: agent self-service (JWT-authoritative), cross-env admin (RBAC, OIDC, audit, license, thresholds, env CRUD), cross-env discovery (`/catalog`), content-addressed lookups (`/diagrams/{contentHash}/render`, `/executions/{id}`), and auth. See `.claude/rules/app-classes.md` for the full allow-list.
- Slug immutability: environment and app slugs are immutable after creation (both appear in URLs, Docker network names, container names, and ClickHouse partition keys). Slug regex `^[a-z0-9][a-z0-9-]{0,63}$` is enforced on POST; update endpoints silently drop any slug field in the request body via Jackson's default unknown-property handling.
- App uniqueness: `(environment_id, app_slug)` is the natural key. The same app slug can legitimately exist in multiple environments; `AppService.getByEnvironmentAndSlug(envId, slug)` is the canonical lookup for controllers. Bare `getBySlug(slug)` remains for internal use but is ambiguous across envs.
- Environment filtering: all data queries filter by the selected environment. All commands target only agents in the selected environment. Env is required on every env-scoped endpoint (path param); the legacy `?environment=` query form is retired.
- 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.
@@ -67,6 +70,19 @@ PostgreSQL (Flyway): `cameleer-server-app/src/main/resources/db/migration/`
ClickHouse: `cameleer-server-app/src/main/resources/clickhouse/init.sql` (run idempotently on startup)
## Regenerating OpenAPI schema (SPA types)
After any change to REST controller paths, request/response DTOs, or `@PathVariable`/`@RequestParam`/`@RequestBody` signatures, regenerate the TypeScript types the SPA consumes. Required for every controller-level change.
```bash
# Backend must be running on :8081
cd ui && npm run generate-api:live # fetches fresh openapi.json AND regenerates schema.d.ts
# OR, if openapi.json was updated by other means:
cd ui && npm run generate-api # regenerates schema.d.ts from existing openapi.json
```
After regeneration, `ui/src/api/schema.d.ts` and `ui/src/api/openapi.json` will update. The TypeScript compiler then surfaces every SPA call site that needs updating — fix all compile errors before testing in the browser. Commit the regenerated files with the controller change.
## Maintaining .claude/rules/
When adding, removing, or renaming classes, controllers, endpoints, UI components, or metrics, update the corresponding `.claude/rules/` file as part of the same change. The rule files are the class/API map that future sessions rely on — stale rules cause wrong assumptions. Treat rule file updates like updating an import: part of the change, not a separate task.
@@ -78,7 +94,7 @@ When adding, removing, or renaming classes, controllers, endpoints, UI component
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
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.
This project is indexed by GitNexus as **cameleer-server** (6436 symbols, 16257 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.

View File

@@ -3,10 +3,14 @@ package com.cameleer.server.app.config;
import com.cameleer.server.app.analytics.UsageTrackingInterceptor;
import com.cameleer.server.app.interceptor.AuditInterceptor;
import com.cameleer.server.app.interceptor.ProtocolVersionInterceptor;
import com.cameleer.server.app.web.EnvironmentPathResolver;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
/**
* Web MVC configuration.
*/
@@ -16,13 +20,21 @@ public class WebConfig implements WebMvcConfigurer {
private final ProtocolVersionInterceptor protocolVersionInterceptor;
private final AuditInterceptor auditInterceptor;
private final UsageTrackingInterceptor usageTrackingInterceptor;
private final EnvironmentPathResolver environmentPathResolver;
public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor,
AuditInterceptor auditInterceptor,
@org.springframework.lang.Nullable UsageTrackingInterceptor usageTrackingInterceptor) {
@org.springframework.lang.Nullable UsageTrackingInterceptor usageTrackingInterceptor,
EnvironmentPathResolver environmentPathResolver) {
this.protocolVersionInterceptor = protocolVersionInterceptor;
this.auditInterceptor = auditInterceptor;
this.usageTrackingInterceptor = usageTrackingInterceptor;
this.environmentPathResolver = environmentPathResolver;
}
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(environmentPathResolver);
}
@Override

View File

@@ -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<AppConfigResponse> 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<String> globalKeys = sensitiveKeysRepository.find()
.map(SensitiveKeysConfig::keys)
.orElse(null);
List<String> 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<String> 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<List<String>>() {});
} catch (Exception e) {
return null;
}
}
}

View File

@@ -1,7 +1,9 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.AgentEventResponse;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.agent.AgentEventService;
import com.cameleer.server.core.runtime.Environment;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -15,8 +17,8 @@ import java.time.Instant;
import java.util.List;
@RestController
@RequestMapping("/api/v1/agents/events-log")
@Tag(name = "Agent Events", description = "Agent lifecycle event log")
@RequestMapping("/api/v1/environments/{envSlug}/agents/events")
@Tag(name = "Agent Events", description = "Agent lifecycle event log (env-scoped)")
public class AgentEventsController {
private final AgentEventService agentEventService;
@@ -26,13 +28,13 @@ public class AgentEventsController {
}
@GetMapping
@Operation(summary = "Query agent events",
@Operation(summary = "Query agent events in this environment",
description = "Returns agent lifecycle events, optionally filtered by app and/or agent ID")
@ApiResponse(responseCode = "200", description = "Events returned")
public ResponseEntity<List<AgentEventResponse>> getEvents(
@EnvPath Environment env,
@RequestParam(required = false) String appId,
@RequestParam(required = false) String agentId,
@RequestParam(required = false) String environment,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
@RequestParam(defaultValue = "50") int limit) {
@@ -40,7 +42,7 @@ public class AgentEventsController {
Instant fromInstant = from != null ? Instant.parse(from) : null;
Instant toInstant = to != null ? Instant.parse(to) : null;
var events = agentEventService.queryEvents(appId, agentId, environment, fromInstant, toInstant, limit)
var events = agentEventService.queryEvents(appId, agentId, env.slug(), fromInstant, toInstant, limit)
.stream()
.map(AgentEventResponse::from)
.toList();

View File

@@ -0,0 +1,163 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.AgentInstanceResponse;
import com.cameleer.server.app.dto.ErrorResponse;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.agent.AgentState;
import com.cameleer.server.core.runtime.Environment;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Read-only user-facing list of agents in an environment. Agent self-service
* endpoints (register/heartbeat/refresh/deregister/events/commands) remain
* flat at /api/v1/agents/... — those are JWT-authoritative and env is
* derived from the token.
*/
@RestController
@RequestMapping("/api/v1/environments/{envSlug}/agents")
@Tag(name = "Agent List", description = "List registered agents in an environment")
public class AgentListController {
private static final Logger log = LoggerFactory.getLogger(AgentListController.class);
private final AgentRegistryService registryService;
private final JdbcTemplate jdbc;
public AgentListController(AgentRegistryService registryService,
@org.springframework.beans.factory.annotation.Qualifier("clickHouseJdbcTemplate") JdbcTemplate jdbc) {
this.registryService = registryService;
this.jdbc = jdbc;
}
@GetMapping
@Operation(summary = "List all agents in this environment",
description = "Returns registered agents with runtime metrics, optionally filtered by status and/or application")
@ApiResponse(responseCode = "200", description = "Agent list returned")
@ApiResponse(responseCode = "400", description = "Invalid status filter",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<List<AgentInstanceResponse>> listAgents(
@EnvPath Environment env,
@RequestParam(required = false) String status,
@RequestParam(required = false) String application) {
List<AgentInfo> agents;
if (status != null) {
try {
AgentState stateFilter = AgentState.valueOf(status.toUpperCase());
agents = registryService.findByState(stateFilter);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
} else {
agents = registryService.findAll();
}
// Filter by env (from path — always applied)
agents = agents.stream()
.filter(a -> env.slug().equals(a.environmentId()))
.toList();
if (application != null && !application.isBlank()) {
agents = agents.stream()
.filter(a -> application.equals(a.applicationId()))
.toList();
}
Map<String, double[]> agentMetrics = queryAgentMetrics();
Map<String, Double> cpuByInstance = queryAgentCpuUsage();
final List<AgentInfo> finalAgents = agents;
List<AgentInstanceResponse> response = finalAgents.stream()
.map(a -> {
AgentInstanceResponse dto = AgentInstanceResponse.from(a);
double[] m = agentMetrics.get(a.applicationId());
if (m != null) {
long appAgentCount = finalAgents.stream()
.filter(ag -> ag.applicationId().equals(a.applicationId())).count();
double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0;
double errorRate = m[1];
int activeRoutes = (int) m[2];
dto = dto.withMetrics(agentTps, errorRate, activeRoutes);
}
Double cpu = cpuByInstance.get(a.instanceId());
if (cpu != null) {
dto = dto.withCpuUsage(cpu);
}
return dto;
})
.toList();
return ResponseEntity.ok(response);
}
private Map<String, double[]> queryAgentMetrics() {
Map<String, double[]> result = new HashMap<>();
Instant now = Instant.now();
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
try {
jdbc.query(
"SELECT application_id, " +
"uniqMerge(total_count) AS total, " +
"uniqIfMerge(failed_count) AS failed, " +
"COUNT(DISTINCT route_id) AS active_routes " +
"FROM stats_1m_route WHERE bucket >= " + lit(from1m) + " AND bucket < " + lit(now) +
" GROUP BY application_id",
rs -> {
long total = rs.getLong("total");
long failed = rs.getLong("failed");
double tps = total / 60.0;
double errorRate = total > 0 ? (double) failed / total : 0.0;
int activeRoutes = rs.getInt("active_routes");
result.put(rs.getString("application_id"), new double[]{tps, errorRate, activeRoutes});
});
} catch (Exception e) {
log.debug("Could not query agent metrics: {}", e.getMessage());
}
return result;
}
private Map<String, Double> queryAgentCpuUsage() {
Map<String, Double> result = new HashMap<>();
Instant now = Instant.now();
Instant from2m = now.minus(2, ChronoUnit.MINUTES);
try {
jdbc.query(
"SELECT instance_id, avg(metric_value) AS cpu_avg " +
"FROM agent_metrics " +
"WHERE metric_name = 'process.cpu.usage.value'" +
" AND collected_at >= " + lit(from2m) + " AND collected_at < " + lit(now) +
" GROUP BY instance_id",
rs -> {
result.put(rs.getString("instance_id"), rs.getDouble("cpu_avg"));
});
} catch (Exception e) {
log.debug("Could not query agent CPU usage: {}", e.getMessage());
}
return result;
}
private static String lit(Instant instant) {
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(java.time.ZoneOffset.UTC)
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'";
}
}

View File

@@ -2,8 +2,13 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.AgentMetricsResponse;
import com.cameleer.server.app.dto.MetricBucket;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.storage.MetricsQueryStore;
import com.cameleer.server.core.storage.model.MetricTimeSeries;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
@@ -12,17 +17,21 @@ import java.util.*;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/v1/agents/{agentId}/metrics")
@RequestMapping("/api/v1/environments/{envSlug}/agents/{agentId}/metrics")
public class AgentMetricsController {
private final MetricsQueryStore metricsQueryStore;
private final AgentRegistryService registryService;
public AgentMetricsController(MetricsQueryStore metricsQueryStore) {
public AgentMetricsController(MetricsQueryStore metricsQueryStore,
AgentRegistryService registryService) {
this.metricsQueryStore = metricsQueryStore;
this.registryService = registryService;
}
@GetMapping
public AgentMetricsResponse getMetrics(
public ResponseEntity<AgentMetricsResponse> getMetrics(
@EnvPath Environment env,
@PathVariable String agentId,
@RequestParam String names,
@RequestParam(required = false) Instant from,
@@ -30,6 +39,13 @@ public class AgentMetricsController {
@RequestParam(defaultValue = "60") int buckets,
@RequestParam(defaultValue = "gauge") String mode) {
// Defence in depth: if the agent is currently in the registry, reject
// requests that cross-env (path env doesn't match the agent's env).
AgentInfo agent = registryService.findById(agentId);
if (agent != null && !env.slug().equals(agent.environmentId())) {
return ResponseEntity.notFound().build();
}
if (from == null) from = Instant.now().minus(1, ChronoUnit.HOURS);
if (to == null) to = Instant.now();
@@ -48,6 +64,6 @@ public class AgentMetricsController {
(a, b) -> a,
LinkedHashMap::new));
return new AgentMetricsResponse(result);
return ResponseEntity.ok(new AgentMetricsResponse(result));
}
}

View File

@@ -321,123 +321,7 @@ public class AgentRegistrationController {
return ResponseEntity.ok().build();
}
@GetMapping
@Operation(summary = "List all agents",
description = "Returns all registered agents with runtime metrics, optionally filtered by status and/or application")
@ApiResponse(responseCode = "200", description = "Agent list returned")
@ApiResponse(responseCode = "400", description = "Invalid status filter",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<List<AgentInstanceResponse>> listAgents(
@RequestParam(required = false) String status,
@RequestParam(required = false) String application,
@RequestParam(required = false) String environment) {
List<AgentInfo> agents;
if (status != null) {
try {
AgentState stateFilter = AgentState.valueOf(status.toUpperCase());
agents = registryService.findByState(stateFilter);
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
}
} else {
agents = registryService.findAll();
}
// Apply application filter if specified
if (application != null && !application.isBlank()) {
agents = agents.stream()
.filter(a -> application.equals(a.applicationId()))
.toList();
}
// Apply environment filter if specified
if (environment != null && !environment.isBlank()) {
agents = agents.stream()
.filter(a -> environment.equals(a.environmentId()))
.toList();
}
// Enrich with runtime metrics from continuous aggregates
Map<String, double[]> agentMetrics = queryAgentMetrics();
Map<String, Double> cpuByInstance = queryAgentCpuUsage();
final List<AgentInfo> finalAgents = agents;
List<AgentInstanceResponse> response = finalAgents.stream()
.map(a -> {
AgentInstanceResponse dto = AgentInstanceResponse.from(a);
double[] m = agentMetrics.get(a.applicationId());
if (m != null) {
long appAgentCount = finalAgents.stream()
.filter(ag -> ag.applicationId().equals(a.applicationId())).count();
double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0;
double errorRate = m[1];
int activeRoutes = (int) m[2];
dto = dto.withMetrics(agentTps, errorRate, activeRoutes);
}
Double cpu = cpuByInstance.get(a.instanceId());
if (cpu != null) {
dto = dto.withCpuUsage(cpu);
}
return dto;
})
.toList();
return ResponseEntity.ok(response);
}
private Map<String, double[]> queryAgentMetrics() {
Map<String, double[]> result = new HashMap<>();
Instant now = Instant.now();
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
try {
// Literal SQL — ClickHouse JDBC driver wraps prepared statements in sub-queries
// that strip AggregateFunction column types, breaking -Merge combinators
jdbc.query(
"SELECT application_id, " +
"uniqMerge(total_count) AS total, " +
"uniqIfMerge(failed_count) AS failed, " +
"COUNT(DISTINCT route_id) AS active_routes " +
"FROM stats_1m_route WHERE bucket >= " + lit(from1m) + " AND bucket < " + lit(now) +
" GROUP BY application_id",
rs -> {
long total = rs.getLong("total");
long failed = rs.getLong("failed");
double tps = total / 60.0;
double errorRate = total > 0 ? (double) failed / total : 0.0;
int activeRoutes = rs.getInt("active_routes");
result.put(rs.getString("application_id"), new double[]{tps, errorRate, activeRoutes});
});
} catch (Exception e) {
log.debug("Could not query agent metrics: {}", e.getMessage());
}
return result;
}
/** Query average CPU usage per agent instance over the last 2 minutes. */
private Map<String, Double> queryAgentCpuUsage() {
Map<String, Double> result = new HashMap<>();
Instant now = Instant.now();
Instant from2m = now.minus(2, ChronoUnit.MINUTES);
try {
jdbc.query(
"SELECT instance_id, avg(metric_value) AS cpu_avg " +
"FROM agent_metrics " +
"WHERE metric_name = 'process.cpu.usage.value'" +
" AND collected_at >= " + lit(from2m) + " AND collected_at < " + lit(now) +
" GROUP BY instance_id",
rs -> {
result.put(rs.getString("instance_id"), rs.getDouble("cpu_avg"));
});
} catch (Exception e) {
log.debug("Could not query agent CPU usage: {}", e.getMessage());
}
return result;
}
/** Format an Instant as a ClickHouse DateTime literal. */
private static String lit(Instant instant) {
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(java.time.ZoneOffset.UTC)
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'";
}
// Agent list moved to AgentListController at /api/v1/environments/{envSlug}/agents.
// Agent register/refresh/heartbeat/deregister remain here at /api/v1/agents/** —
// these are JWT-authoritative and intentionally flat (env from token).
}

View File

@@ -1,8 +1,10 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.runtime.App;
import com.cameleer.server.core.runtime.AppService;
import com.cameleer.server.core.runtime.AppVersion;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.RuntimeType;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -27,13 +29,13 @@ import java.util.Map;
import java.util.UUID;
/**
* App CRUD and JAR upload endpoints.
* All app-scoped endpoints accept the app slug (not UUID) as path variable.
* Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}.
* App CRUD and JAR upload. All routes env-scoped: the (env, appSlug) pair
* identifies a single app — the same app slug can legitimately exist in
* multiple environments with independent configuration and history.
*/
@RestController
@RequestMapping("/api/v1/apps")
@Tag(name = "App Management", description = "Application lifecycle and JAR uploads")
@RequestMapping("/api/v1/environments/{envSlug}/apps")
@Tag(name = "App Management", description = "Application lifecycle and JAR uploads (env-scoped)")
@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")
public class AppController {
@@ -44,46 +46,45 @@ public class AppController {
}
@GetMapping
@Operation(summary = "List apps by environment")
@Operation(summary = "List apps in this environment")
@ApiResponse(responseCode = "200", description = "App list returned")
public ResponseEntity<List<App>> listApps(@RequestParam(required = false) UUID environmentId) {
if (environmentId != null) {
return ResponseEntity.ok(appService.listByEnvironment(environmentId));
}
return ResponseEntity.ok(appService.listAll());
public ResponseEntity<List<App>> listApps(@EnvPath Environment env) {
return ResponseEntity.ok(appService.listByEnvironment(env.id()));
}
@GetMapping("/{appSlug}")
@Operation(summary = "Get app by slug")
@Operation(summary = "Get app by env + slug")
@ApiResponse(responseCode = "200", description = "App found")
@ApiResponse(responseCode = "404", description = "App not found")
public ResponseEntity<App> getApp(@PathVariable String appSlug) {
@ApiResponse(responseCode = "404", description = "App not found in this environment")
public ResponseEntity<App> getApp(@EnvPath Environment env, @PathVariable String appSlug) {
try {
return ResponseEntity.ok(appService.getBySlug(appSlug));
return ResponseEntity.ok(appService.getByEnvironmentAndSlug(env.id(), appSlug));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
@PostMapping
@Operation(summary = "Create a new app")
@Operation(summary = "Create a new app in this environment",
description = "Slug must match ^[a-z0-9][a-z0-9-]{0,63}$ and be unique within the environment. "
+ "Slug is immutable after creation.")
@ApiResponse(responseCode = "201", description = "App created")
@ApiResponse(responseCode = "400", description = "Slug already exists in environment")
public ResponseEntity<App> createApp(@RequestBody CreateAppRequest request) {
@ApiResponse(responseCode = "400", description = "Invalid slug, or slug already exists in this environment")
public ResponseEntity<?> createApp(@EnvPath Environment env, @RequestBody CreateAppRequest request) {
try {
UUID id = appService.createApp(request.environmentId(), request.slug(), request.displayName());
UUID id = appService.createApp(env.id(), request.slug(), request.displayName());
return ResponseEntity.status(201).body(appService.getById(id));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().build();
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/{appSlug}/versions")
@Operation(summary = "List app versions")
@Operation(summary = "List versions for this app")
@ApiResponse(responseCode = "200", description = "Version list returned")
public ResponseEntity<List<AppVersion>> listVersions(@PathVariable String appSlug) {
public ResponseEntity<List<AppVersion>> listVersions(@EnvPath Environment env, @PathVariable String appSlug) {
try {
App app = appService.getBySlug(appSlug);
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
return ResponseEntity.ok(appService.listVersions(app.id()));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
@@ -91,13 +92,14 @@ public class AppController {
}
@PostMapping(value = "/{appSlug}/versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(summary = "Upload a JAR for a new app version")
@Operation(summary = "Upload a JAR for a new version of this app")
@ApiResponse(responseCode = "201", description = "JAR uploaded and version created")
@ApiResponse(responseCode = "404", description = "App not found")
public ResponseEntity<AppVersion> uploadJar(@PathVariable String appSlug,
@ApiResponse(responseCode = "404", description = "App not found in this environment")
public ResponseEntity<AppVersion> uploadJar(@EnvPath Environment env,
@PathVariable String appSlug,
@RequestParam("file") MultipartFile file) throws IOException {
try {
App app = appService.getBySlug(appSlug);
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
AppVersion version = appService.uploadJar(app.id(), file.getOriginalFilename(), file.getInputStream(), file.getSize());
return ResponseEntity.status(201).body(version);
} catch (IllegalArgumentException e) {
@@ -106,11 +108,11 @@ public class AppController {
}
@DeleteMapping("/{appSlug}")
@Operation(summary = "Delete an app")
@Operation(summary = "Delete this app")
@ApiResponse(responseCode = "204", description = "App deleted")
public ResponseEntity<Void> deleteApp(@PathVariable String appSlug) {
public ResponseEntity<Void> deleteApp(@EnvPath Environment env, @PathVariable String appSlug) {
try {
App app = appService.getBySlug(appSlug);
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
appService.deleteApp(app.id());
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
@@ -134,24 +136,25 @@ public class AppController {
}
@PutMapping("/{appSlug}/container-config")
@Operation(summary = "Update container config for an app")
@Operation(summary = "Update container config for this app")
@ApiResponse(responseCode = "200", description = "Container config updated")
@ApiResponse(responseCode = "400", description = "Invalid configuration")
@ApiResponse(responseCode = "404", description = "App not found")
public ResponseEntity<App> updateContainerConfig(@PathVariable String appSlug,
@ApiResponse(responseCode = "404", description = "App not found in this environment")
public ResponseEntity<?> updateContainerConfig(@EnvPath Environment env,
@PathVariable String appSlug,
@RequestBody Map<String, Object> containerConfig) {
try {
validateContainerConfig(containerConfig);
App app = appService.getBySlug(appSlug);
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
appService.updateContainerConfig(app.id(), containerConfig);
return ResponseEntity.ok(appService.getById(app.id()));
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.badRequest().build();
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
public record CreateAppRequest(UUID environmentId, String slug, String displayName) {}
public record CreateAppRequest(String slug, String displayName) {}
}

View File

@@ -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<List<AppSettings>> getAll(@RequestParam String environment) {
return ResponseEntity.ok(repository.findByEnvironment(environment));
@GetMapping("/app-settings")
@Operation(summary = "List application settings in this environment")
public ResponseEntity<List<AppSettings>> 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<AppSettings> 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<AppSettings> 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<AppSettings> 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<AppSettings> update(@EnvPath Environment env,
@PathVariable String appSlug,
@Valid @RequestBody AppSettingsRequest request,
HttpServletRequest httpRequest) {
List<String> 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<Void> delete(@PathVariable String appId,
@RequestParam String environment,
@DeleteMapping("/apps/{appSlug}/settings")
@Operation(summary = "Delete application settings for this environment (reverts to defaults)")
public ResponseEntity<Void> 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();
}
}

View File

@@ -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<List<ApplicationConfig>> listConfigs(@RequestParam String environment,
public ResponseEntity<List<ApplicationConfig>> 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<AppConfigResponse> getConfig(@PathVariable String application,
@RequestParam(required = false) String environment,
Authentication auth,
public ResponseEntity<AppConfigResponse> 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<String> 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<ConfigUpdateResponse> updateConfig(@PathVariable String application,
@RequestParam String environment,
public ResponseEntity<ConfigUpdateResponse> 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<String> globalKeys = sensitiveKeysRepository.find()
.map(SensitiveKeysConfig::keys)
.orElse(null);
List<String> perAppKeys = extractSensitiveKeys(saved);
List<String> 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<Map<String, String>> getProcessorRouteMapping(@PathVariable String application,
@RequestParam String environment) {
return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(application, environment));
public ResponseEntity<Map<String, String>> 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<TestExpressionResponse> 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<CommandReply> 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<String> 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<String> mergedKeys) {
String payloadJson;
try {
// Serialize config to a mutable map, inject merged keys
@SuppressWarnings("unchecked")
Map<String, Object> 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);

View File

@@ -1,7 +1,14 @@
package com.cameleer.server.app.controller;
import com.cameleer.server.app.runtime.DeploymentExecutor;
import com.cameleer.server.core.runtime.*;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.runtime.App;
import com.cameleer.server.core.runtime.AppService;
import com.cameleer.server.core.runtime.Deployment;
import com.cameleer.server.core.runtime.DeploymentService;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.EnvironmentService;
import com.cameleer.server.core.runtime.RuntimeOrchestrator;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -15,17 +22,18 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Deployment management: deploy, stop, promote, and view logs.
* All app-scoped endpoints accept the app slug (not UUID) as path variable.
* Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}.
* Deployment management. Env + app come from the URL. Promote is inherently
* cross-env, so the target environment stays explicit in the request body
* (as a slug).
*/
@RestController
@RequestMapping("/api/v1/apps/{appSlug}/deployments")
@Tag(name = "Deployment Management", description = "Deploy, stop, restart, promote, and view logs")
@RequestMapping("/api/v1/environments/{envSlug}/apps/{appSlug}/deployments")
@Tag(name = "Deployment Management", description = "Deploy, stop, promote, and view logs")
@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")
public class DeploymentController {
@@ -33,23 +41,26 @@ public class DeploymentController {
private final DeploymentExecutor deploymentExecutor;
private final RuntimeOrchestrator orchestrator;
private final AppService appService;
private final EnvironmentService environmentService;
public DeploymentController(DeploymentService deploymentService,
DeploymentExecutor deploymentExecutor,
RuntimeOrchestrator orchestrator,
AppService appService) {
AppService appService,
EnvironmentService environmentService) {
this.deploymentService = deploymentService;
this.deploymentExecutor = deploymentExecutor;
this.orchestrator = orchestrator;
this.appService = appService;
this.environmentService = environmentService;
}
@GetMapping
@Operation(summary = "List deployments for an app")
@Operation(summary = "List deployments for this app in this environment")
@ApiResponse(responseCode = "200", description = "Deployment list returned")
public ResponseEntity<List<Deployment>> listDeployments(@PathVariable String appSlug) {
public ResponseEntity<List<Deployment>> listDeployments(@EnvPath Environment env, @PathVariable String appSlug) {
try {
App app = appService.getBySlug(appSlug);
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
return ResponseEntity.ok(deploymentService.listByApp(app.id()));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
@@ -60,7 +71,9 @@ public class DeploymentController {
@Operation(summary = "Get deployment by ID")
@ApiResponse(responseCode = "200", description = "Deployment found")
@ApiResponse(responseCode = "404", description = "Deployment not found")
public ResponseEntity<Deployment> getDeployment(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
public ResponseEntity<Deployment> getDeployment(@EnvPath Environment env,
@PathVariable String appSlug,
@PathVariable UUID deploymentId) {
try {
return ResponseEntity.ok(deploymentService.getById(deploymentId));
} catch (IllegalArgumentException e) {
@@ -69,12 +82,14 @@ public class DeploymentController {
}
@PostMapping
@Operation(summary = "Create and start a new deployment")
@Operation(summary = "Create and start a new deployment for this app in this environment")
@ApiResponse(responseCode = "202", description = "Deployment accepted and starting")
public ResponseEntity<Deployment> deploy(@PathVariable String appSlug, @RequestBody DeployRequest request) {
public ResponseEntity<Deployment> deploy(@EnvPath Environment env,
@PathVariable String appSlug,
@RequestBody DeployRequest request) {
try {
App app = appService.getBySlug(appSlug);
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), request.environmentId());
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), env.id());
deploymentExecutor.executeAsync(deployment);
return ResponseEntity.accepted().body(deployment);
} catch (IllegalArgumentException e) {
@@ -86,7 +101,9 @@ public class DeploymentController {
@Operation(summary = "Stop a running deployment")
@ApiResponse(responseCode = "200", description = "Deployment stopped")
@ApiResponse(responseCode = "404", description = "Deployment not found")
public ResponseEntity<Deployment> stop(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
public ResponseEntity<Deployment> stop(@EnvPath Environment env,
@PathVariable String appSlug,
@PathVariable UUID deploymentId) {
try {
Deployment deployment = deploymentService.getById(deploymentId);
deploymentExecutor.stopDeployment(deployment);
@@ -97,27 +114,37 @@ public class DeploymentController {
}
@PostMapping("/{deploymentId}/promote")
@Operation(summary = "Promote deployment to a different environment")
@Operation(summary = "Promote this deployment to a different environment",
description = "Target environment is specified by slug in the request body. "
+ "The same app slug must exist in the target environment (or be created separately first).")
@ApiResponse(responseCode = "202", description = "Promotion accepted and starting")
@ApiResponse(responseCode = "404", description = "Deployment not found")
public ResponseEntity<Deployment> promote(@PathVariable String appSlug, @PathVariable UUID deploymentId,
@ApiResponse(responseCode = "404", description = "Deployment or target environment not found")
public ResponseEntity<?> promote(@EnvPath Environment env,
@PathVariable String appSlug,
@PathVariable UUID deploymentId,
@RequestBody PromoteRequest request) {
try {
App app = appService.getBySlug(appSlug);
App sourceApp = appService.getByEnvironmentAndSlug(env.id(), appSlug);
Deployment source = deploymentService.getById(deploymentId);
Deployment promoted = deploymentService.promote(app.id(), source.appVersionId(), request.targetEnvironmentId());
Environment targetEnv = environmentService.getBySlug(request.targetEnvironment());
// Target must also have the app with the same slug
App targetApp = appService.getByEnvironmentAndSlug(targetEnv.id(), appSlug);
Deployment promoted = deploymentService.promote(targetApp.id(), source.appVersionId(), targetEnv.id());
deploymentExecutor.executeAsync(promoted);
return ResponseEntity.accepted().body(promoted);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
return ResponseEntity.status(org.springframework.http.HttpStatus.NOT_FOUND)
.body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/{deploymentId}/logs")
@Operation(summary = "Get container logs for a deployment")
@Operation(summary = "Get container logs for this deployment")
@ApiResponse(responseCode = "200", description = "Logs returned")
@ApiResponse(responseCode = "404", description = "Deployment not found or no container")
public ResponseEntity<List<String>> getLogs(@PathVariable String appSlug, @PathVariable UUID deploymentId) {
public ResponseEntity<List<String>> getLogs(@EnvPath Environment env,
@PathVariable String appSlug,
@PathVariable UUID deploymentId) {
try {
Deployment deployment = deploymentService.getById(deploymentId);
if (deployment.containerId() == null) {
@@ -130,6 +157,6 @@ public class DeploymentController {
}
}
public record DeployRequest(UUID appVersionId, UUID environmentId) {}
public record PromoteRequest(UUID targetEnvironmentId) {}
public record DeployRequest(UUID appVersionId) {}
public record PromoteRequest(String targetEnvironment) {}
}

View File

@@ -1,10 +1,12 @@
package com.cameleer.server.app.controller;
import com.cameleer.common.graph.RouteGraph;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.diagram.DiagramLayout;
import com.cameleer.server.core.diagram.DiagramRenderer;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.storage.DiagramStore;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
@@ -16,7 +18,6 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@@ -24,16 +25,16 @@ import java.util.List;
import java.util.Optional;
/**
* REST endpoint for rendering route diagrams.
* Diagram rendering and lookup.
* <p>
* Supports content negotiation via Accept header:
* <ul>
* <li>{@code image/svg+xml} or default: returns SVG document</li>
* <li>{@code application/json}: returns JSON layout with node positions</li>
* </ul>
* Content-addressed rendering stays flat at /api/v1/diagrams/{contentHash}/render:
* the hash is globally unique, permalinks are valuable, and no env partitioning
* is possible or needed.
* <p>
* By-app-and-route lookup is env-scoped at
* /api/v1/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram.
*/
@RestController
@RequestMapping("/api/v1/diagrams")
@Tag(name = "Diagrams", description = "Diagram rendering endpoints")
public class DiagramRenderController {
@@ -51,9 +52,10 @@ public class DiagramRenderController {
this.registryService = registryService;
}
@GetMapping("/{contentHash}/render")
@Operation(summary = "Render a route diagram",
description = "Returns SVG (default) or JSON layout based on Accept header")
@GetMapping("/api/v1/diagrams/{contentHash}/render")
@Operation(summary = "Render a route diagram by content hash",
description = "Returns SVG (default) or JSON layout based on Accept header. "
+ "Content hashes are globally unique, so this endpoint is intentionally flat (no env).")
@ApiResponse(responseCode = "200", description = "Diagram rendered successfully",
content = {
@Content(mediaType = "image/svg+xml", schema = @Schema(type = "string")),
@@ -73,9 +75,6 @@ public class DiagramRenderController {
RouteGraph graph = graphOpt.get();
String accept = request.getHeader("Accept");
// Return JSON only when the client explicitly requests application/json
// without also accepting everything (*/*). This means "application/json"
// must appear and wildcards must not dominate the preference.
if (accept != null && isJsonPreferred(accept)) {
DiagramLayout layout = diagramRenderer.layoutJson(graph, direction);
return ResponseEntity.ok()
@@ -83,23 +82,24 @@ public class DiagramRenderController {
.body(layout);
}
// Default to SVG for image/svg+xml, */* or no Accept header
String svg = diagramRenderer.renderSvg(graph);
return ResponseEntity.ok()
.contentType(SVG_MEDIA_TYPE)
.body(svg);
}
@GetMapping
@Operation(summary = "Find diagram by application and route ID",
description = "Resolves application to agent IDs and finds the latest diagram for the route")
@GetMapping("/api/v1/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram")
@Operation(summary = "Find the latest diagram for this app's route in this environment",
description = "Resolves agents in this env for this app, then looks up the latest diagram for the route "
+ "they reported. Env scope prevents a dev route from returning a prod diagram.")
@ApiResponse(responseCode = "200", description = "Diagram layout returned")
@ApiResponse(responseCode = "404", description = "No diagram found for the given application and route")
public ResponseEntity<DiagramLayout> findByApplicationAndRoute(
@RequestParam String application,
@RequestParam String routeId,
@ApiResponse(responseCode = "404", description = "No diagram found")
public ResponseEntity<DiagramLayout> findByAppAndRoute(
@EnvPath Environment env,
@PathVariable String appSlug,
@PathVariable String routeId,
@RequestParam(defaultValue = "LR") String direction) {
List<String> agentIds = registryService.findByApplication(application).stream()
List<String> agentIds = registryService.findByApplicationAndEnvironment(appSlug, env.slug()).stream()
.map(AgentInfo::instanceId)
.toList();
@@ -121,14 +121,6 @@ public class DiagramRenderController {
return ResponseEntity.ok(layout);
}
/**
* Determine if JSON is the explicitly preferred format.
* <p>
* Returns true only when the first media type in the Accept header is
* "application/json". Clients sending broad Accept lists like
* "text/plain, application/json, *&#47;*" are treated as unspecific
* and receive the SVG default.
*/
private boolean isJsonPreferred(String accept) {
String[] parts = accept.split(",");
if (parts.length == 0) return false;

View File

@@ -12,7 +12,6 @@ import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/admin/environments")
@@ -33,13 +32,13 @@ public class EnvironmentAdminController {
return ResponseEntity.ok(environmentService.listAll());
}
@GetMapping("/{id}")
@Operation(summary = "Get environment by ID")
@GetMapping("/{envSlug}")
@Operation(summary = "Get environment by slug")
@ApiResponse(responseCode = "200", description = "Environment found")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<Environment> getEnvironment(@PathVariable UUID id) {
public ResponseEntity<Environment> getEnvironment(@PathVariable String envSlug) {
try {
return ResponseEntity.ok(environmentService.getById(id));
return ResponseEntity.ok(environmentService.getBySlug(envSlug));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
@@ -48,24 +47,28 @@ public class EnvironmentAdminController {
@PostMapping
@Operation(summary = "Create a new environment")
@ApiResponse(responseCode = "201", description = "Environment created")
@ApiResponse(responseCode = "400", description = "Slug already exists")
@ApiResponse(responseCode = "400", description = "Invalid slug or slug already exists")
public ResponseEntity<?> createEnvironment(@RequestBody CreateEnvironmentRequest request) {
try {
UUID id = environmentService.create(request.slug(), request.displayName(), request.production());
return ResponseEntity.status(201).body(environmentService.getById(id));
environmentService.create(request.slug(), request.displayName(), request.production());
return ResponseEntity.status(201).body(environmentService.getBySlug(request.slug()));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
@PutMapping("/{id}")
@Operation(summary = "Update an environment")
@PutMapping("/{envSlug}")
@Operation(summary = "Update an environment's mutable fields (displayName, production, enabled)",
description = "Slug is immutable after creation and cannot be changed. "
+ "Any slug field in the request body is ignored.")
@ApiResponse(responseCode = "200", description = "Environment updated")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<?> updateEnvironment(@PathVariable UUID id, @RequestBody UpdateEnvironmentRequest request) {
public ResponseEntity<?> updateEnvironment(@PathVariable String envSlug,
@RequestBody UpdateEnvironmentRequest request) {
try {
environmentService.update(id, request.displayName(), request.production(), request.enabled());
return ResponseEntity.ok(environmentService.getById(id));
Environment current = environmentService.getBySlug(envSlug);
environmentService.update(current.id(), request.displayName(), request.production(), request.enabled());
return ResponseEntity.ok(environmentService.getBySlug(envSlug));
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
return ResponseEntity.notFound().build();
@@ -74,14 +77,15 @@ public class EnvironmentAdminController {
}
}
@DeleteMapping("/{id}")
@DeleteMapping("/{envSlug}")
@Operation(summary = "Delete an environment")
@ApiResponse(responseCode = "204", description = "Environment deleted")
@ApiResponse(responseCode = "400", description = "Cannot delete default environment")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<?> deleteEnvironment(@PathVariable UUID id) {
public ResponseEntity<?> deleteEnvironment(@PathVariable String envSlug) {
try {
environmentService.delete(id);
Environment current = environmentService.getBySlug(envSlug);
environmentService.delete(current.id());
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
@@ -106,17 +110,18 @@ public class EnvironmentAdminController {
}
}
@PutMapping("/{id}/default-container-config")
@PutMapping("/{envSlug}/default-container-config")
@Operation(summary = "Update default container config for an environment")
@ApiResponse(responseCode = "200", description = "Default container config updated")
@ApiResponse(responseCode = "400", description = "Invalid configuration")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<?> updateDefaultContainerConfig(@PathVariable UUID id,
public ResponseEntity<?> updateDefaultContainerConfig(@PathVariable String envSlug,
@RequestBody Map<String, Object> defaultContainerConfig) {
try {
validateContainerConfig(defaultContainerConfig);
environmentService.updateDefaultContainerConfig(id, defaultContainerConfig);
return ResponseEntity.ok(environmentService.getById(id));
Environment current = environmentService.getBySlug(envSlug);
environmentService.updateDefaultContainerConfig(current.id(), defaultContainerConfig);
return ResponseEntity.ok(environmentService.getBySlug(envSlug));
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
return ResponseEntity.notFound().build();
@@ -125,15 +130,16 @@ public class EnvironmentAdminController {
}
}
@PutMapping("/{id}/jar-retention")
@PutMapping("/{envSlug}/jar-retention")
@Operation(summary = "Update JAR retention policy for an environment")
@ApiResponse(responseCode = "200", description = "Retention policy updated")
@ApiResponse(responseCode = "404", description = "Environment not found")
public ResponseEntity<?> updateJarRetention(@PathVariable UUID id,
public ResponseEntity<?> updateJarRetention(@PathVariable String envSlug,
@RequestBody JarRetentionRequest request) {
try {
environmentService.updateJarRetentionCount(id, request.jarRetentionCount());
return ResponseEntity.ok(environmentService.getById(id));
Environment current = environmentService.getBySlug(envSlug);
environmentService.updateJarRetentionCount(current.id(), request.jarRetentionCount());
return ResponseEntity.ok(environmentService.getBySlug(envSlug));
} catch (IllegalArgumentException e) {
if (e.getMessage().contains("not found")) {
return ResponseEntity.notFound().build();

View File

@@ -2,6 +2,8 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.LogEntryResponse;
import com.cameleer.server.app.dto.LogSearchPageResponse;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.search.LogSearchRequest;
import com.cameleer.server.core.search.LogSearchResponse;
import com.cameleer.server.core.storage.LogIndex;
@@ -18,8 +20,8 @@ import java.util.Arrays;
import java.util.List;
@RestController
@RequestMapping("/api/v1/logs")
@Tag(name = "Application Logs", description = "Query application logs")
@RequestMapping("/api/v1/environments/{envSlug}")
@Tag(name = "Application Logs", description = "Query application logs (env-scoped)")
public class LogQueryController {
private final LogIndex logIndex;
@@ -28,11 +30,12 @@ public class LogQueryController {
this.logIndex = logIndex;
}
@GetMapping
@Operation(summary = "Search application log entries",
description = "Returns log entries with cursor-based pagination and level count aggregation. " +
"Supports free-text search, multi-level filtering, and optional application scoping.")
@GetMapping("/logs")
@Operation(summary = "Search application log entries in this environment",
description = "Cursor-paginated log search scoped to the env in the path. "
+ "Supports free-text search, multi-level filtering, and optional application/agent scoping.")
public ResponseEntity<LogSearchPageResponse> searchLogs(
@EnvPath Environment env,
@RequestParam(required = false) String q,
@RequestParam(required = false) String query,
@RequestParam(required = false) String level,
@@ -40,7 +43,6 @@ public class LogQueryController {
@RequestParam(name = "agentId", required = false) String instanceId,
@RequestParam(required = false) String exchangeId,
@RequestParam(required = false) String logger,
@RequestParam(required = false) String environment,
@RequestParam(required = false) String source,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
@@ -51,7 +53,6 @@ public class LogQueryController {
// q takes precedence over deprecated query param
String searchText = q != null ? q : query;
// Parse CSV levels
List<String> levels = List.of();
if (level != null && !level.isEmpty()) {
levels = Arrays.stream(level.split(","))
@@ -65,7 +66,7 @@ public class LogQueryController {
LogSearchRequest request = new LogSearchRequest(
searchText, levels, application, instanceId, exchangeId,
logger, environment, source, fromInstant, toInstant, cursor, limit, sort);
logger, env.slug(), source, fromInstant, toInstant, cursor, limit, sort);
LogSearchResponse result = logIndex.search(request);

View File

@@ -3,15 +3,16 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.AgentSummary;
import com.cameleer.server.app.dto.AppCatalogEntry;
import com.cameleer.server.app.dto.RouteSummary;
import com.cameleer.server.app.web.EnvPath;
import com.cameleer.common.graph.RouteGraph;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.cameleer.server.core.agent.AgentState;
import com.cameleer.server.core.agent.RouteStateRegistry;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.storage.DiagramStore;
import com.cameleer.server.core.storage.RouteCatalogEntry;
import com.cameleer.server.core.storage.RouteCatalogStore;
import com.cameleer.server.core.storage.StatsStore;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -34,8 +35,8 @@ import java.util.Set;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/v1/routes")
@Tag(name = "Route Catalog", description = "Route catalog and discovery")
@RequestMapping("/api/v1/environments/{envSlug}")
@Tag(name = "Route Catalog", description = "Route catalog and discovery (env-scoped)")
public class RouteCatalogController {
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(RouteCatalogController.class);
@@ -58,28 +59,22 @@ public class RouteCatalogController {
this.routeCatalogStore = routeCatalogStore;
}
@GetMapping("/catalog")
@Operation(summary = "Get route catalog",
description = "Returns all applications with their routes, agents, and health status")
@GetMapping("/routes")
@Operation(summary = "Get route catalog for this environment",
description = "Returns all applications with their routes, agents, and health status — filtered to this environment")
@ApiResponse(responseCode = "200", description = "Catalog returned")
public ResponseEntity<List<AppCatalogEntry>> getCatalog(
@EnvPath Environment env,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
@RequestParam(required = false) String environment) {
List<AgentInfo> allAgents = registryService.findAll();
// Filter agents by environment if specified
if (environment != null && !environment.isBlank()) {
allAgents = allAgents.stream()
.filter(a -> environment.equals(a.environmentId()))
@RequestParam(required = false) String to) {
String envSlug = env.slug();
List<AgentInfo> allAgents = registryService.findAll().stream()
.filter(a -> envSlug.equals(a.environmentId()))
.toList();
}
// Group agents by application name
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
.collect(Collectors.groupingBy(AgentInfo::applicationId, LinkedHashMap::new, Collectors.toList()));
// Collect all distinct routes per app
Map<String, Set<String>> routesByApp = new LinkedHashMap<>();
for (var entry : agentsByApp.entrySet()) {
Set<String> routes = new LinkedHashSet<>();
@@ -91,21 +86,16 @@ public class RouteCatalogController {
routesByApp.put(entry.getKey(), routes);
}
// Time range for exchange counts — use provided range or default to last 24h
Instant now = Instant.now();
Instant rangeFrom = from != null ? Instant.parse(from) : now.minus(24, ChronoUnit.HOURS);
Instant rangeTo = to != null ? Instant.parse(to) : now;
// Route exchange counts from AggregatingMergeTree (literal SQL — ClickHouse JDBC driver
// wraps prepared statements in sub-queries that strip AggregateFunction column types)
Map<String, Long> routeExchangeCounts = new LinkedHashMap<>();
Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
try {
String envFilter = (environment != null && !environment.isBlank())
? " AND environment = " + lit(environment) : "";
jdbc.query(
"SELECT application_id, route_id, uniqMerge(total_count) AS cnt, MAX(bucket) AS last_seen " +
"FROM stats_1m_route WHERE bucket >= " + lit(rangeFrom) + " AND bucket < " + lit(rangeTo) +
envFilter +
" AND environment = " + lit(envSlug) +
" GROUP BY application_id, route_id",
rs -> {
String key = rs.getString("application_id") + "/" + rs.getString("route_id");
@@ -117,9 +107,6 @@ public class RouteCatalogController {
log.warn("Failed to query route exchange counts: {}", e.getMessage());
}
// Merge route IDs from ClickHouse stats into routesByApp.
// After server restart, auto-healed agents have empty routeIds, but
// ClickHouse still has execution data with the correct route IDs.
for (var countEntry : routeExchangeCounts.entrySet()) {
String[] parts = countEntry.getKey().split("/", 2);
if (parts.length == 2) {
@@ -127,12 +114,8 @@ public class RouteCatalogController {
}
}
// Merge routes from persistent catalog (covers routes with 0 executions
// and routes from previous app versions within the selected time window)
try {
List<RouteCatalogEntry> catalogEntries = (environment != null && !environment.isBlank())
? routeCatalogStore.findByEnvironment(environment, rangeFrom, rangeTo)
: routeCatalogStore.findAll(rangeFrom, rangeTo);
List<RouteCatalogEntry> catalogEntries = routeCatalogStore.findByEnvironment(envSlug, rangeFrom, rangeTo);
for (RouteCatalogEntry entry : catalogEntries) {
routesByApp.computeIfAbsent(entry.applicationId(), k -> new LinkedHashSet<>())
.add(entry.routeId());
@@ -141,7 +124,6 @@ public class RouteCatalogController {
log.warn("Failed to query route catalog: {}", e.getMessage());
}
// Build catalog entries — merge apps from agent registry + ClickHouse data
Set<String> allAppIds = new LinkedHashSet<>(agentsByApp.keySet());
allAppIds.addAll(routesByApp.keySet());
@@ -149,7 +131,6 @@ public class RouteCatalogController {
for (String appId : allAppIds) {
List<AgentInfo> agents = agentsByApp.getOrDefault(appId, List.of());
// Routes
Set<String> routeIds = routesByApp.getOrDefault(appId, Set.of());
List<String> agentIds = agents.stream().map(AgentInfo::instanceId).toList();
List<RouteSummary> routeSummaries = routeIds.stream()
@@ -159,21 +140,17 @@ public class RouteCatalogController {
Instant lastSeen = routeLastSeen.get(key);
String fromUri = resolveFromEndpointUri(routeId, agentIds);
String state = routeStateRegistry.getState(appId, routeId).name().toLowerCase();
// Only include non-default states (stopped/suspended); null means started
String routeState = "started".equals(state) ? null : state;
return new RouteSummary(routeId, count, lastSeen, fromUri, routeState);
})
.toList();
// Agent summaries
List<AgentSummary> agentSummaries = agents.stream()
.map(a -> new AgentSummary(a.instanceId(), a.displayName(), a.state().name().toLowerCase(), 0.0))
.toList();
// Health = worst state among agents
String health = computeWorstHealth(agents);
// Total exchange count for the app
long totalExchanges = routeSummaries.stream().mapToLong(RouteSummary::exchangeCount).sum();
catalog.add(new AppCatalogEntry(appId, routeSummaries, agentSummaries,
@@ -183,7 +160,6 @@ public class RouteCatalogController {
return ResponseEntity.ok(catalog);
}
/** Resolve the from() endpoint URI for a route by looking up its diagram. */
private String resolveFromEndpointUri(String routeId, List<String> agentIds) {
return diagramStore.findContentHashForRouteByAgents(routeId, agentIds)
.flatMap(diagramStore::findByContentHash)
@@ -192,14 +168,12 @@ public class RouteCatalogController {
.orElse(null);
}
/** Format an Instant as a ClickHouse DateTime literal in UTC. */
private static String lit(Instant instant) {
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(java.time.ZoneOffset.UTC)
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'";
}
/** Format a string as a ClickHouse SQL literal with backslash + quote escaping. */
private static String lit(String value) {
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
}

View File

@@ -2,8 +2,10 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.dto.ProcessorMetrics;
import com.cameleer.server.app.dto.RouteMetrics;
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.runtime.Environment;
import com.cameleer.server.core.storage.StatsStore;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -15,24 +17,23 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/routes")
@Tag(name = "Route Metrics", description = "Route performance metrics")
@RequestMapping("/api/v1/environments/{envSlug}/routes")
@Tag(name = "Route Metrics", description = "Route performance metrics (env-scoped)")
public class RouteMetricsController {
private final JdbcTemplate jdbc;
private final StatsStore statsStore;
private final AppSettingsRepository appSettingsRepository;
public RouteMetricsController(@org.springframework.beans.factory.annotation.Qualifier("clickHouseJdbcTemplate") JdbcTemplate jdbc, StatsStore statsStore,
public RouteMetricsController(@org.springframework.beans.factory.annotation.Qualifier("clickHouseJdbcTemplate") JdbcTemplate jdbc,
StatsStore statsStore,
AppSettingsRepository appSettingsRepository) {
this.jdbc = jdbc;
this.statsStore = statsStore;
@@ -40,35 +41,32 @@ public class RouteMetricsController {
}
@GetMapping("/metrics")
@Operation(summary = "Get route metrics",
description = "Returns aggregated performance metrics per route for the given time window")
@Operation(summary = "Get route metrics for this environment",
description = "Returns aggregated performance metrics per route for the given time window. "
+ "Optional appId filter narrows to a single application.")
@ApiResponse(responseCode = "200", description = "Metrics returned")
public ResponseEntity<List<RouteMetrics>> getMetrics(
@EnvPath Environment env,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
@RequestParam(required = false) String appId,
@RequestParam(required = false) String environment) {
@RequestParam(required = false) String appId) {
Instant toInstant = to != null ? Instant.parse(to) : Instant.now();
Instant fromInstant = from != null ? Instant.parse(from) : toInstant.minus(24, ChronoUnit.HOURS);
long windowSeconds = Duration.between(fromInstant, toInstant).toSeconds();
// Literal SQL — ClickHouse JDBC driver wraps prepared statements in sub-queries
// that strip AggregateFunction column types, breaking -Merge combinators
var sql = new StringBuilder(
"SELECT application_id, route_id, " +
"uniqMerge(total_count) AS total, " +
"uniqIfMerge(failed_count) AS failed, " +
"CASE WHEN uniqMerge(total_count) > 0 THEN toFloat64(sumMerge(duration_sum)) / uniqMerge(total_count) ELSE 0 END AS avg_dur, " +
"COALESCE(quantileMerge(0.99)(p99_duration), 0) AS p99_dur " +
"FROM stats_1m_route WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant));
"FROM stats_1m_route WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant) +
" AND environment = " + lit(env.slug()));
if (appId != null) {
sql.append(" AND application_id = " + lit(appId));
}
if (environment != null) {
sql.append(" AND environment = " + lit(environment));
}
sql.append(" GROUP BY application_id, route_id ORDER BY application_id, route_id");
List<RouteMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
@@ -87,7 +85,7 @@ public class RouteMetricsController {
avgDur, p99Dur, errorRate, tps, List.of(), -1.0);
});
// Fetch sparklines (12 buckets over the time window)
// Sparklines
if (!metrics.isEmpty()) {
int sparkBuckets = 12;
long bucketSeconds = Math.max(windowSeconds / sparkBuckets, 60);
@@ -95,15 +93,12 @@ public class RouteMetricsController {
for (int i = 0; i < metrics.size(); i++) {
RouteMetrics m = metrics.get(i);
try {
var sparkWhere = new StringBuilder(
"FROM stats_1m_route WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant) +
" AND application_id = " + lit(m.appId()) + " AND route_id = " + lit(m.routeId()));
if (environment != null) {
sparkWhere.append(" AND environment = " + lit(environment));
}
String sparkSql = "SELECT toStartOfInterval(bucket, toIntervalSecond(" + bucketSeconds + ")) AS period, " +
"COALESCE(uniqMerge(total_count), 0) AS cnt " +
sparkWhere + " GROUP BY period ORDER BY period";
"FROM stats_1m_route WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant) +
" AND environment = " + lit(env.slug()) +
" AND application_id = " + lit(m.appId()) + " AND route_id = " + lit(m.routeId()) +
" GROUP BY period ORDER BY period";
List<Double> sparkline = jdbc.query(sparkSql,
(rs, rowNum) -> rs.getDouble("cnt"));
metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(),
@@ -115,17 +110,16 @@ public class RouteMetricsController {
}
}
// Enrich with SLA compliance per route
// SLA compliance
if (!metrics.isEmpty()) {
// Determine SLA threshold (per-app or default)
String effectiveAppId = appId != null ? appId : (metrics.isEmpty() ? null : metrics.get(0).appId());
int threshold = (effectiveAppId != null && environment != null && !environment.isBlank())
? appSettingsRepository.findByApplicationAndEnvironment(effectiveAppId, environment)
String effectiveAppId = appId != null ? appId : metrics.get(0).appId();
int threshold = effectiveAppId != null
? appSettingsRepository.findByApplicationAndEnvironment(effectiveAppId, env.slug())
.map(AppSettings::slaThresholdMs).orElse(300)
: 300;
Map<String, long[]> slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant,
effectiveAppId, threshold, environment);
effectiveAppId, threshold, env.slug());
for (int i = 0; i < metrics.size(); i++) {
RouteMetrics m = metrics.get(i);
@@ -142,24 +136,19 @@ public class RouteMetricsController {
}
@GetMapping("/metrics/processors")
@Operation(summary = "Get processor metrics",
@Operation(summary = "Get processor metrics for this environment",
description = "Returns aggregated performance metrics per processor for the given route and time window")
@ApiResponse(responseCode = "200", description = "Metrics returned")
public ResponseEntity<List<ProcessorMetrics>> getProcessorMetrics(
@EnvPath Environment env,
@RequestParam String routeId,
@RequestParam(required = false) String appId,
@RequestParam(required = false) Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(required = false) String environment) {
@RequestParam(required = false) Instant to) {
Instant toInstant = to != null ? to : Instant.now();
Instant fromInstant = from != null ? from : toInstant.minus(24, ChronoUnit.HOURS);
// Literal SQL for AggregatingMergeTree -Merge combinators.
// Aliases (tc, fc) must NOT shadow column names (total_count, failed_count) —
// ClickHouse 24.12 new analyzer resolves subsequent uniqMerge(total_count)
// to the alias (UInt64) instead of the AggregateFunction column.
// total_count/failed_count use uniq(execution_id) to deduplicate repeated inserts.
var sql = new StringBuilder(
"SELECT processor_id, processor_type, route_id, application_id, " +
"uniqMerge(total_count) AS tc, " +
@@ -168,14 +157,12 @@ public class RouteMetricsController {
"quantileMerge(0.99)(p99_duration) AS p99_duration_ms " +
"FROM stats_1m_processor_detail " +
"WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant) +
" AND environment = " + lit(env.slug()) +
" AND route_id = " + lit(routeId));
if (appId != null) {
sql.append(" AND application_id = " + lit(appId));
}
if (environment != null) {
sql.append(" AND environment = " + lit(environment));
}
sql.append(" GROUP BY processor_id, processor_type, route_id, application_id");
sql.append(" ORDER BY tc DESC");
@@ -198,14 +185,12 @@ public class RouteMetricsController {
return ResponseEntity.ok(metrics);
}
/** Format an Instant as a ClickHouse DateTime literal. */
private static String lit(Instant instant) {
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(java.time.ZoneOffset.UTC)
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'";
}
/** Format a string as a ClickHouse SQL literal with backslash + quote escaping. */
private static String lit(String value) {
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
}

View File

@@ -1,7 +1,9 @@
package com.cameleer.server.app.controller;
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.runtime.Environment;
import com.cameleer.server.core.search.ExecutionStats;
import com.cameleer.server.core.search.ExecutionSummary;
import com.cameleer.server.core.search.SearchRequest;
@@ -25,14 +27,12 @@ import java.util.List;
import java.util.Map;
/**
* Search endpoints for querying route executions.
* <p>
* GET supports basic filters via query parameters. POST accepts a full
* {@link SearchRequest} JSON body for advanced search with all filter types.
* Execution search and stats endpoints. Env is the path; env filter is
* derived from the path and always applied to underlying ClickHouse queries.
*/
@RestController
@RequestMapping("/api/v1/search")
@Tag(name = "Search", description = "Transaction search endpoints")
@RequestMapping("/api/v1/environments/{envSlug}")
@Tag(name = "Search", description = "Transaction search and stats (env-scoped)")
public class SearchController {
private final SearchService searchService;
@@ -45,8 +45,9 @@ public class SearchController {
}
@GetMapping("/executions")
@Operation(summary = "Search executions with basic filters")
@Operation(summary = "Search executions with basic filters (env from path)")
public ResponseEntity<SearchResult<ExecutionSummary>> searchGet(
@EnvPath Environment env,
@RequestParam(required = false) String status,
@RequestParam(required = false) Instant timeFrom,
@RequestParam(required = false) Instant timeTo,
@@ -56,7 +57,6 @@ public class SearchController {
@RequestParam(name = "agentId", required = false) String instanceId,
@RequestParam(required = false) String processorType,
@RequestParam(required = false) String application,
@RequestParam(required = false) String environment,
@RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "50") int limit,
@RequestParam(required = false) String sortField,
@@ -71,115 +71,116 @@ public class SearchController {
application, null,
offset, limit,
sortField, sortDir,
environment
env.slug()
);
return ResponseEntity.ok(searchService.search(request));
}
@PostMapping("/executions")
@Operation(summary = "Advanced search with all filters")
@PostMapping("/executions/search")
@Operation(summary = "Advanced search with all filters",
description = "Env from the path overrides any environment field in the body.")
public ResponseEntity<SearchResult<ExecutionSummary>> searchPost(
@EnvPath Environment env,
@RequestBody SearchRequest request) {
return ResponseEntity.ok(searchService.search(request));
SearchRequest scoped = request.withEnvironment(env.slug());
return ResponseEntity.ok(searchService.search(scoped));
}
@GetMapping("/stats")
@Operation(summary = "Aggregate execution stats (P99 latency, active count, SLA compliance)")
public ResponseEntity<ExecutionStats> stats(
@EnvPath Environment env,
@RequestParam Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(required = false) String routeId,
@RequestParam(required = false) String application,
@RequestParam(required = false) String environment) {
@RequestParam(required = false) String application) {
Instant end = to != null ? to : Instant.now();
ExecutionStats stats;
if (routeId == null && application == null) {
stats = searchService.stats(from, end, environment);
stats = searchService.stats(from, end, env.slug());
} else if (routeId == null) {
stats = searchService.statsForApp(from, end, application, environment);
stats = searchService.statsForApp(from, end, application, env.slug());
} else {
stats = searchService.statsForRoute(from, end, routeId, application, environment);
stats = searchService.statsForRoute(from, end, routeId, application, env.slug());
}
// 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)
int threshold = application != null && !application.isBlank()
? appSettingsRepository.findByApplicationAndEnvironment(application, env.slug())
.map(AppSettings::slaThresholdMs).orElse(300)
: 300;
double sla = searchService.slaCompliance(from, end, threshold, application, routeId, environment);
double sla = searchService.slaCompliance(from, end, threshold, application, routeId, env.slug());
return ResponseEntity.ok(stats.withSlaCompliance(sla));
}
@GetMapping("/stats/timeseries")
@Operation(summary = "Bucketed time-series stats over a time window")
public ResponseEntity<StatsTimeseries> timeseries(
@EnvPath Environment env,
@RequestParam Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(defaultValue = "24") int buckets,
@RequestParam(required = false) String routeId,
@RequestParam(required = false) String application,
@RequestParam(required = false) String environment) {
@RequestParam(required = false) String application) {
Instant end = to != null ? to : Instant.now();
if (routeId == null && application == null) {
return ResponseEntity.ok(searchService.timeseries(from, end, buckets, environment));
return ResponseEntity.ok(searchService.timeseries(from, end, buckets, env.slug()));
}
if (routeId == null) {
return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application, environment));
return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application, env.slug()));
}
return ResponseEntity.ok(searchService.timeseriesForRoute(from, end, buckets, routeId, application, environment));
return ResponseEntity.ok(searchService.timeseriesForRoute(from, end, buckets, routeId, application, env.slug()));
}
@GetMapping("/stats/timeseries/by-app")
@Operation(summary = "Timeseries grouped by application")
public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByApp(
@EnvPath Environment env,
@RequestParam Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(defaultValue = "24") int buckets,
@RequestParam(required = false) String environment) {
@RequestParam(defaultValue = "24") int buckets) {
Instant end = to != null ? to : Instant.now();
return ResponseEntity.ok(searchService.timeseriesGroupedByApp(from, end, buckets, environment));
return ResponseEntity.ok(searchService.timeseriesGroupedByApp(from, end, buckets, env.slug()));
}
@GetMapping("/stats/timeseries/by-route")
@Operation(summary = "Timeseries grouped by route for an application")
public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByRoute(
@EnvPath Environment env,
@RequestParam Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(defaultValue = "24") int buckets,
@RequestParam String application,
@RequestParam(required = false) String environment) {
@RequestParam String application) {
Instant end = to != null ? to : Instant.now();
return ResponseEntity.ok(searchService.timeseriesGroupedByRoute(from, end, buckets, application, environment));
return ResponseEntity.ok(searchService.timeseriesGroupedByRoute(from, end, buckets, application, env.slug()));
}
@GetMapping("/stats/punchcard")
@Operation(summary = "Transaction punchcard: weekday x hour grid (rolling 7 days)")
public ResponseEntity<List<StatsStore.PunchcardCell>> punchcard(
@RequestParam(required = false) String application,
@RequestParam(required = false) String environment) {
@EnvPath Environment env,
@RequestParam(required = false) String application) {
Instant to = Instant.now();
Instant from = to.minus(java.time.Duration.ofDays(7));
return ResponseEntity.ok(searchService.punchcard(from, to, application, environment));
return ResponseEntity.ok(searchService.punchcard(from, to, application, env.slug()));
}
@GetMapping("/attributes/keys")
@Operation(summary = "Distinct attribute key names across all executions")
public ResponseEntity<List<String>> attributeKeys() {
return ResponseEntity.ok(searchService.distinctAttributeKeys());
@Operation(summary = "Distinct attribute key names for this environment")
public ResponseEntity<List<String>> attributeKeys(@EnvPath Environment env) {
return ResponseEntity.ok(searchService.distinctAttributeKeys(env.slug()));
}
@GetMapping("/errors/top")
@Operation(summary = "Top N errors with velocity trend")
public ResponseEntity<List<TopError>> topErrors(
@EnvPath Environment env,
@RequestParam Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(required = false) String application,
@RequestParam(required = false) String routeId,
@RequestParam(required = false) String environment,
@RequestParam(defaultValue = "5") int limit) {
Instant end = to != null ? to : Instant.now();
return ResponseEntity.ok(searchService.topErrors(from, end, application, routeId, limit, environment));
return ResponseEntity.ok(searchService.topErrors(from, end, application, routeId, limit, env.slug()));
}
}

View File

@@ -318,14 +318,14 @@ public class ClickHouseSearchIndex implements SearchIndex {
}
@Override
public List<String> distinctAttributeKeys() {
public List<String> distinctAttributeKeys(String environment) {
try {
return jdbc.queryForList("""
SELECT DISTINCT arrayJoin(JSONExtractKeys(attributes)) AS attr_key
FROM executions FINAL
WHERE tenant_id = ? AND attributes != '' AND attributes != '{}'
WHERE tenant_id = ? AND environment = ? AND attributes != '' AND attributes != '{}'
ORDER BY attr_key
""", String.class, tenantId);
""", String.class, tenantId, environment);
} catch (Exception e) {
log.error("Failed to query distinct attribute keys", e);
return List.of();

View File

@@ -119,11 +119,37 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT")
.requestMatchers(HttpMethod.POST, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
// Application config endpoints
// Application config endpoints (legacy flat shape — removed as controllers migrate to /environments/{env}/...)
.requestMatchers(HttpMethod.GET, "/api/v1/config/*").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT")
.requestMatchers(HttpMethod.PUT, "/api/v1/config/*").hasAnyRole("OPERATOR", "ADMIN")
// Read-only data endpoints — viewer+
// Agent-authoritative config (post-migration split from /api/v1/config/{app})
.requestMatchers(HttpMethod.GET, "/api/v1/agents/config").hasRole("AGENT")
// Env-scoped config & settings (specific rules BEFORE the generic /apps/** OPERATOR rule)
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/config").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/apps/*/config").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/apps/*/processor-routes").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/v1/environments/*/apps/*/config").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/apps/*/config/test-expression").hasAnyRole("OPERATOR", "ADMIN")
.requestMatchers("/api/v1/environments/*/app-settings").hasRole("ADMIN")
.requestMatchers("/api/v1/environments/*/apps/*/settings").hasRole("ADMIN")
// Env-scoped data reads (executions/stats/logs/routes/agents/diagrams)
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.POST, "/api/v1/environments/*/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/errors/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/attributes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/logs").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/agents/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/environments/*/apps/*/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
// Env-scoped app & deployment management (OPERATOR+) — catch-all for /environments/*/apps/**
.requestMatchers("/api/v1/environments/*/apps/**").hasAnyRole("OPERATOR", "ADMIN")
// Read-only data endpoints — viewer+ (legacy flat shape — removed per-wave)
.requestMatchers(HttpMethod.GET, "/api/v1/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/agents/*/metrics").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
@@ -132,7 +158,7 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/v1/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/stats/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
// Runtime management (OPERATOR+)
// Runtime management (OPERATOR+) — legacy flat shape
.requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN")
// Admin endpoints

View File

@@ -0,0 +1,21 @@
package com.cameleer.server.app.web;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Injects the {@link com.cameleer.server.core.runtime.Environment} identified by the
* {@code {envSlug}} path variable. Returns 404 if the slug does not exist.
* <p>
* Use on handlers under {@code /api/v1/environments/{envSlug}/...}:
* <pre>{@code
* @GetMapping("/api/v1/environments/{envSlug}/apps")
* public List<App> list(@EnvPath Environment env) { ... }
* }</pre>
*/
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface EnvPath {
}

View File

@@ -0,0 +1,61 @@
package com.cameleer.server.app.web;
import com.cameleer.server.core.runtime.Environment;
import com.cameleer.server.core.runtime.EnvironmentRepository;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.HandlerMapping;
import java.util.Map;
/**
* Resolves the {@code {envSlug}} path variable into an {@link Environment} for handlers
* annotated with {@link EnvPath}. Validates the slug exists in the repository and rejects
* unknown slugs with 404.
* <p>
* Paired with {@code SecurityConfig} role rules, this guarantees every env-scoped data
* endpoint receives a real, authorized env without per-handler boilerplate.
*/
@Component
public class EnvironmentPathResolver implements HandlerMethodArgumentResolver {
static final String ENV_SLUG_VARIABLE = "envSlug";
private final EnvironmentRepository environments;
public EnvironmentPathResolver(EnvironmentRepository environments) {
this.environments = environments;
}
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(EnvPath.class)
&& Environment.class.equals(parameter.getParameterType());
}
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest request,
WebDataBinderFactory binderFactory) {
@SuppressWarnings("unchecked")
Map<String, String> vars = (Map<String, String>) request.getAttribute(
HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE,
RequestAttributes.SCOPE_REQUEST);
String slug = vars != null ? vars.get(ENV_SLUG_VARIABLE) : null;
if (slug == null || slug.isBlank()) {
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
"Handler uses @EnvPath but path has no {envSlug} variable");
}
return environments.findBySlug(slug)
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "Unknown environment: " + slug));
}
}

View File

@@ -60,12 +60,12 @@ class AppControllerIT extends AbstractPostgresIT {
@Test
void createApp_asOperator_returns201() throws Exception {
String json = String.format("""
{"environmentId": "%s", "slug": "my-app", "displayName": "My App"}
""", defaultEnvId);
String json = """
{"slug": "my-app", "displayName": "My App"}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/apps", HttpMethod.POST,
"/api/v1/environments/default/apps", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
String.class);
@@ -79,16 +79,16 @@ class AppControllerIT extends AbstractPostgresIT {
@Test
void listApps_asOperator_returnsCreatedApp() throws Exception {
// Create an app first
String json = String.format("""
{"environmentId": "%s", "slug": "list-test", "displayName": "List Test"}
""", defaultEnvId);
String json = """
{"slug": "list-test", "displayName": "List Test"}
""";
restTemplate.exchange(
"/api/v1/apps", HttpMethod.POST,
"/api/v1/environments/default/apps", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
String.class);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/apps?environmentId=" + defaultEnvId, HttpMethod.GET,
"/api/v1/environments/default/apps", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
@@ -100,12 +100,12 @@ class AppControllerIT extends AbstractPostgresIT {
@Test
void createApp_asViewer_returns403() {
String json = String.format("""
{"environmentId": "%s", "slug": "viewer-app", "displayName": "Viewer App"}
""", defaultEnvId);
String json = """
{"slug": "viewer-app", "displayName": "Viewer App"}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/apps", HttpMethod.POST,
"/api/v1/environments/default/apps", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(viewerJwt)),
String.class);
@@ -114,15 +114,15 @@ class AppControllerIT extends AbstractPostgresIT {
@Test
void uploadJar_asOperator_returns201() throws Exception {
String appSlug = "jar-test";
// Create app
String json = String.format("""
{"environmentId": "%s", "slug": "jar-test", "displayName": "JAR Test"}
""", defaultEnvId);
ResponseEntity<String> createResponse = restTemplate.exchange(
"/api/v1/apps", HttpMethod.POST,
{"slug": "%s", "displayName": "JAR Test"}
""", appSlug);
restTemplate.exchange(
"/api/v1/environments/default/apps", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
String.class);
String appId = objectMapper.readTree(createResponse.getBody()).path("id").asText();
// Upload JAR (fake content)
byte[] jarContent = "fake-jar-content".getBytes();
@@ -142,7 +142,7 @@ class AppControllerIT extends AbstractPostgresIT {
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/apps/" + appId + "/versions", HttpMethod.POST,
"/api/v1/environments/default/apps/" + appSlug + "/versions", HttpMethod.POST,
new HttpEntity<>(body, headers),
String.class);

View File

@@ -35,6 +35,7 @@ class DeploymentControllerIT extends AbstractPostgresIT {
private String adminJwt;
private String defaultEnvId;
private String appId;
private String appSlugRef;
private String versionId;
@BeforeEach
@@ -60,15 +61,17 @@ class DeploymentControllerIT extends AbstractPostgresIT {
}
}
// Create app
// Create app (slug becomes the path identifier)
String appSlug = "deploy-test";
String appJson = String.format("""
{"environmentId": "%s", "slug": "deploy-test", "displayName": "Deploy Test"}
""", defaultEnvId);
{"slug": "%s", "displayName": "Deploy Test"}
""", appSlug);
ResponseEntity<String> appResponse = restTemplate.exchange(
"/api/v1/apps", HttpMethod.POST,
"/api/v1/environments/default/apps", HttpMethod.POST,
new HttpEntity<>(appJson, securityHelper.authHeaders(operatorJwt)),
String.class);
appId = objectMapper.readTree(appResponse.getBody()).path("id").asText();
appSlugRef = appSlug;
// Upload a JAR version
byte[] jarContent = "fake-jar-for-deploy".getBytes();
@@ -85,7 +88,7 @@ class DeploymentControllerIT extends AbstractPostgresIT {
headers.set("X-Cameleer-Protocol-Version", "1");
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
ResponseEntity<String> versionResponse = restTemplate.exchange(
"/api/v1/apps/" + appId + "/versions", HttpMethod.POST,
"/api/v1/environments/default/apps/" + appSlug + "/versions", HttpMethod.POST,
new HttpEntity<>(body, headers),
String.class);
versionId = objectMapper.readTree(versionResponse.getBody()).path("id").asText();
@@ -95,11 +98,11 @@ class DeploymentControllerIT extends AbstractPostgresIT {
void deploy_asOperator_returns202() throws Exception {
// Deploy creates a record; the async executor will fail (no Docker) but the record should exist
String json = String.format("""
{"appVersionId": "%s", "environmentId": "%s"}
""", versionId, defaultEnvId);
{"appVersionId": "%s"}
""", versionId);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/apps/" + appId + "/deployments", HttpMethod.POST,
"/api/v1/environments/default/apps/" + appSlugRef + "/deployments", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
String.class);
@@ -114,15 +117,15 @@ class DeploymentControllerIT extends AbstractPostgresIT {
void listDeployments_asOperator_returnsDeployments() throws Exception {
// Create a deployment first
String json = String.format("""
{"appVersionId": "%s", "environmentId": "%s"}
""", versionId, defaultEnvId);
{"appVersionId": "%s"}
""", versionId);
restTemplate.exchange(
"/api/v1/apps/" + appId + "/deployments", HttpMethod.POST,
"/api/v1/environments/default/apps/" + appSlugRef + "/deployments", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
String.class);
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/apps/" + appId + "/deployments", HttpMethod.GET,
"/api/v1/environments/default/apps/" + appSlugRef + "/deployments", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);
@@ -135,7 +138,7 @@ class DeploymentControllerIT extends AbstractPostgresIT {
@Test
void getDeployment_notFound_returns404() {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/apps/" + appId + "/deployments/00000000-0000-0000-0000-000000000000",
"/api/v1/environments/default/apps/" + appSlugRef + "/deployments/00000000-0000-0000-0000-000000000000",
HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
String.class);

View File

@@ -97,29 +97,65 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
String createJson = """
{"slug": "update-test", "displayName": "Before", "production": false}
""";
ResponseEntity<String> createResponse = restTemplate.exchange(
restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(createJson, securityHelper.authHeaders(adminJwt)),
String.class);
JsonNode created = objectMapper.readTree(createResponse.getBody());
String envId = created.path("id").asText();
// Update it
// Update it by slug
String updateJson = """
{"displayName": "After", "production": true, "enabled": false}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/" + envId, HttpMethod.PUT,
"/api/v1/admin/environments/update-test", HttpMethod.PUT,
new HttpEntity<>(updateJson, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("slug").asText()).isEqualTo("update-test");
assertThat(body.path("displayName").asText()).isEqualTo("After");
assertThat(body.path("production").asBoolean()).isTrue();
assertThat(body.path("enabled").asBoolean()).isFalse();
}
@Test
void updateEnvironment_withSlugInBody_ignoresSlug() throws Exception {
String createJson = """
{"slug": "slug-immutable-test", "displayName": "Original", "production": false}
""";
restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(createJson, securityHelper.authHeaders(adminJwt)),
String.class);
// Attempt to change slug via body — Jackson drops the unknown field
String updateJson = """
{"slug": "hacked", "displayName": "Renamed", "production": false, "enabled": true}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/slug-immutable-test", HttpMethod.PUT,
new HttpEntity<>(updateJson, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.path("slug").asText()).isEqualTo("slug-immutable-test");
}
@Test
void createEnvironment_invalidSlug_returns400() {
String json = """
{"slug": "Invalid Slug!", "displayName": "Bad"}
""";
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.POST,
new HttpEntity<>(json, securityHelper.authHeaders(adminJwt)),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
@Test
void createEnvironment_duplicateSlug_returns400() {
String json = """
@@ -142,25 +178,9 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
}
@Test
void deleteEnvironment_defaultEnv_returns400() throws Exception {
// Find the default environment ID
ResponseEntity<String> listResponse = restTemplate.exchange(
"/api/v1/admin/environments", HttpMethod.GET,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);
JsonNode envs = objectMapper.readTree(listResponse.getBody());
String defaultId = null;
for (JsonNode env : envs) {
if ("default".equals(env.path("slug").asText())) {
defaultId = env.path("id").asText();
break;
}
}
assertThat(defaultId).isNotNull();
void deleteEnvironment_defaultEnv_returns400() {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/admin/environments/" + defaultId, HttpMethod.DELETE,
"/api/v1/admin/environments/default", HttpMethod.DELETE,
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
String.class);

View File

@@ -375,7 +375,7 @@ class SearchControllerIT extends AbstractPostgresIT {
private ResponseEntity<String> searchGet(String queryString) {
HttpHeaders headers = securityHelper.authHeadersNoBody(jwt);
return restTemplate.exchange(
"/api/v1/search/executions" + queryString,
"/api/v1/environments/default/executions" + queryString,
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);
@@ -383,7 +383,7 @@ class SearchControllerIT extends AbstractPostgresIT {
private ResponseEntity<String> searchPost(String jsonBody) {
return restTemplate.exchange(
"/api/v1/search/executions",
"/api/v1/environments/default/executions/search",
HttpMethod.POST,
new HttpEntity<>(jsonBody, securityHelper.authHeaders(viewerJwt)),
String.class);

View File

@@ -160,7 +160,7 @@ class JwtRefreshIT extends AbstractPostgresIT {
authHeaders.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/search/executions",
"/api/v1/environments/default/executions",
HttpMethod.GET,
new HttpEntity<>(authHeaders),
String.class);

View File

@@ -86,7 +86,7 @@ class RegistrationSecurityIT extends AbstractPostgresIT {
headers.set("X-Cameleer-Protocol-Version", "1");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/search/executions",
"/api/v1/environments/default/executions",
HttpMethod.GET,
new HttpEntity<>(headers),
String.class);

View File

@@ -12,10 +12,14 @@ import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Pattern;
public class AppService {
private static final Logger log = LoggerFactory.getLogger(AppService.class);
/** Slug rules mirror {@link EnvironmentService#SLUG_PATTERN}: lowercase letters, digits, hyphens; 164 chars; starts with alnum. Immutable after creation. */
private static final Pattern SLUG_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9-]{0,63}$");
private final AppRepository appRepo;
private final AppVersionRepository versionRepo;
private final String jarStoragePath;
@@ -30,6 +34,10 @@ public class AppService {
public List<App> listByEnvironment(UUID environmentId) { return appRepo.findByEnvironmentId(environmentId); }
public App getById(UUID id) { return appRepo.findById(id).orElseThrow(() -> new IllegalArgumentException("App not found: " + id)); }
public App getBySlug(String slug) { return appRepo.findBySlug(slug).orElseThrow(() -> new IllegalArgumentException("App not found: " + slug)); }
public App getByEnvironmentAndSlug(UUID environmentId, String slug) {
return appRepo.findByEnvironmentIdAndSlug(environmentId, slug)
.orElseThrow(() -> new IllegalArgumentException("App not found in environment: " + slug));
}
public List<AppVersion> listVersions(UUID appId) { return versionRepo.findByAppId(appId); }
public AppVersion getVersion(UUID versionId) {
@@ -43,6 +51,10 @@ public class AppService {
}
public UUID createApp(UUID environmentId, String slug, String displayName) {
if (slug == null || !SLUG_PATTERN.matcher(slug).matches()) {
throw new IllegalArgumentException(
"Invalid app slug: must match ^[a-z0-9][a-z0-9-]{0,63}$ (lowercase letters, digits, hyphens)");
}
if (appRepo.findByEnvironmentIdAndSlug(environmentId, slug).isPresent()) {
throw new IllegalArgumentException("App with slug '" + slug + "' already exists in this environment");
}

View File

@@ -3,8 +3,19 @@ package com.cameleer.server.core.runtime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Pattern;
public class EnvironmentService {
/**
* Slug must start with a lowercase letter or digit, contain only lowercase
* letters, digits, and hyphens, and be 164 characters long. Slugs are
* immutable after creation — they appear in URLs, Docker network names,
* container names, and ClickHouse partition keys, so renaming is not
* supported by design.
*/
private static final Pattern SLUG_PATTERN = Pattern.compile("^[a-z0-9][a-z0-9-]{0,63}$");
private final EnvironmentRepository repo;
public EnvironmentService(EnvironmentRepository repo) {
@@ -22,6 +33,10 @@ public class EnvironmentService {
}
public UUID create(String slug, String displayName, boolean production) {
if (slug == null || !SLUG_PATTERN.matcher(slug).matches()) {
throw new IllegalArgumentException(
"Invalid slug: must match ^[a-z0-9][a-z0-9-]{0,63}$ (lowercase letters, digits, hyphens)");
}
if (repo.findBySlug(slug).isPresent()) {
throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists");
}

View File

@@ -25,8 +25,8 @@ public class SearchService {
return searchIndex.count(request);
}
public List<String> distinctAttributeKeys() {
return searchIndex.distinctAttributeKeys();
public List<String> distinctAttributeKeys(String environment) {
return searchIndex.distinctAttributeKeys(environment);
}
public ExecutionStats stats(Instant from, Instant to) {

View File

@@ -17,6 +17,6 @@ public interface SearchIndex {
void delete(String executionId);
/** Returns distinct attribute key names across all executions. */
List<String> distinctAttributeKeys();
/** Returns distinct attribute key names across executions in the given environment. */
List<String> distinctAttributeKeys(String environment);
}

View File

@@ -43,9 +43,14 @@ export interface Deployment {
createdAt: string;
}
async function appFetch<T>(path: string, options?: RequestInit): Promise<T> {
/**
* Authenticated fetch. `path` is relative to apiBaseUrl, must include the
* leading slash. All app/deployment endpoints now live under
* /api/v1/environments/{envSlug}/...
*/
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const token = useAuthStore.getState().accessToken;
const res = await fetch(`${config.apiBaseUrl}/apps${path}`, {
const res = await fetch(`${config.apiBaseUrl}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
@@ -65,28 +70,28 @@ async function appFetch<T>(path: string, options?: RequestInit): Promise<T> {
return JSON.parse(text);
}
// --- Apps ---
export function useAllApps() {
return useQuery({
queryKey: ['apps', 'all'],
queryFn: () => appFetch<App[]>(''),
});
function envBase(envSlug: string): string {
return `/environments/${encodeURIComponent(envSlug)}/apps`;
}
export function useApps(environmentId: string | undefined) {
// --- Apps ---
export function useApps(envSlug: string | undefined) {
return useQuery({
queryKey: ['apps', environmentId],
queryFn: () => appFetch<App[]>(`?environmentId=${environmentId}`),
enabled: !!environmentId,
queryKey: ['apps', envSlug],
queryFn: () => apiFetch<App[]>(envBase(envSlug!)),
enabled: !!envSlug,
});
}
export function useCreateApp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: { environmentId: string; slug: string; displayName: string }) =>
appFetch<App>('', { method: 'POST', body: JSON.stringify(req) }),
mutationFn: ({ envSlug, slug, displayName }: { envSlug: string; slug: string; displayName: string }) =>
apiFetch<App>(envBase(envSlug), {
method: 'POST',
body: JSON.stringify({ slug, displayName }),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
});
}
@@ -94,8 +99,8 @@ export function useCreateApp() {
export function useDeleteApp() {
const qc = useQueryClient();
return useMutation({
mutationFn: (slug: string) =>
appFetch<void>(`/${slug}`, { method: 'DELETE' }),
mutationFn: ({ envSlug, appSlug }: { envSlug: string; appSlug: string }) =>
apiFetch<void>(`${envBase(envSlug)}/${encodeURIComponent(appSlug)}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['apps'] });
qc.invalidateQueries({ queryKey: ['catalog'] });
@@ -106,30 +111,34 @@ export function useDeleteApp() {
export function useUpdateContainerConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ appId, config }: { appId: string; config: Record<string, unknown> }) =>
appFetch<App>(`/${appId}/container-config`, { method: 'PUT', body: JSON.stringify(config) }),
mutationFn: ({ envSlug, appSlug, config }: { envSlug: string; appSlug: string; config: Record<string, unknown> }) =>
apiFetch<App>(`${envBase(envSlug)}/${encodeURIComponent(appSlug)}/container-config`, {
method: 'PUT',
body: JSON.stringify(config),
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
});
}
// --- Versions ---
export function useAppVersions(appId: string | undefined) {
export function useAppVersions(envSlug: string | undefined, appSlug: string | undefined) {
return useQuery({
queryKey: ['apps', appId, 'versions'],
queryFn: () => appFetch<AppVersion[]>(`/${appId}/versions`),
enabled: !!appId,
queryKey: ['apps', envSlug, appSlug, 'versions'],
queryFn: () => apiFetch<AppVersion[]>(`${envBase(envSlug!)}/${encodeURIComponent(appSlug!)}/versions`),
enabled: !!envSlug && !!appSlug,
});
}
export function useUploadJar() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ appId, file }: { appId: string; file: File }) => {
mutationFn: async ({ envSlug, appSlug, file }: { envSlug: string; appSlug: string; file: File }) => {
const token = useAuthStore.getState().accessToken;
const form = new FormData();
form.append('file', file);
const res = await fetch(`${config.apiBaseUrl}/apps/${appId}/versions`, {
const res = await fetch(
`${config.apiBaseUrl}${envBase(envSlug)}/${encodeURIComponent(appSlug)}/versions`, {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
@@ -140,18 +149,18 @@ export function useUploadJar() {
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
return res.json() as Promise<AppVersion>;
},
onSuccess: (_data, { appId }) =>
qc.invalidateQueries({ queryKey: ['apps', appId, 'versions'] }),
onSuccess: (_data, { envSlug, appSlug }) =>
qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'versions'] }),
});
}
// --- Deployments ---
export function useDeployments(appId: string | undefined) {
export function useDeployments(envSlug: string | undefined, appSlug: string | undefined) {
return useQuery({
queryKey: ['apps', appId, 'deployments'],
queryFn: () => appFetch<Deployment[]>(`/${appId}/deployments`),
enabled: !!appId,
queryKey: ['apps', envSlug, appSlug, 'deployments'],
queryFn: () => apiFetch<Deployment[]>(`${envBase(envSlug!)}/${encodeURIComponent(appSlug!)}/deployments`),
enabled: !!envSlug && !!appSlug,
refetchInterval: 5000,
});
}
@@ -159,19 +168,38 @@ export function useDeployments(appId: string | undefined) {
export function useCreateDeployment() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ appId, ...req }: { appId: string; appVersionId: string; environmentId: string }) =>
appFetch<Deployment>(`/${appId}/deployments`, { method: 'POST', body: JSON.stringify(req) }),
onSuccess: (_data, { appId }) =>
qc.invalidateQueries({ queryKey: ['apps', appId, 'deployments'] }),
mutationFn: ({ envSlug, appSlug, appVersionId }: { envSlug: string; appSlug: string; appVersionId: string }) =>
apiFetch<Deployment>(
`${envBase(envSlug)}/${encodeURIComponent(appSlug)}/deployments`,
{ method: 'POST', body: JSON.stringify({ appVersionId }) },
),
onSuccess: (_data, { envSlug, appSlug }) =>
qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'deployments'] }),
});
}
export function useStopDeployment() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ appId, deploymentId }: { appId: string; deploymentId: string }) =>
appFetch<Deployment>(`/${appId}/deployments/${deploymentId}/stop`, { method: 'POST' }),
onSuccess: (_data, { appId }) =>
qc.invalidateQueries({ queryKey: ['apps', appId, 'deployments'] }),
mutationFn: ({ envSlug, appSlug, deploymentId }: { envSlug: string; appSlug: string; deploymentId: string }) =>
apiFetch<Deployment>(
`${envBase(envSlug)}/${encodeURIComponent(appSlug)}/deployments/${deploymentId}/stop`,
{ method: 'POST' },
),
onSuccess: (_data, { envSlug, appSlug }) =>
qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'deployments'] }),
});
}
export function usePromoteDeployment() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ envSlug, appSlug, deploymentId, targetEnvironment }:
{ envSlug: string; appSlug: string; deploymentId: string; targetEnvironment: string }) =>
apiFetch<Deployment>(
`${envBase(envSlug)}/${encodeURIComponent(appSlug)}/deployments/${deploymentId}/promote`,
{ method: 'POST', body: JSON.stringify({ targetEnvironment }) },
),
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
});
}

View File

@@ -46,8 +46,8 @@ export function useCreateEnvironment() {
export function useUpdateEnvironment() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...req }: UpdateEnvironmentRequest & { id: string }) =>
adminFetch<Environment>(`/environments/${id}`, {
mutationFn: ({ slug, ...req }: UpdateEnvironmentRequest & { slug: string }) =>
adminFetch<Environment>(`/environments/${slug}`, {
method: 'PUT',
body: JSON.stringify(req),
}),
@@ -58,8 +58,8 @@ export function useUpdateEnvironment() {
export function useUpdateDefaultContainerConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, config }: { id: string; config: Record<string, unknown> }) =>
adminFetch<Environment>(`/environments/${id}/default-container-config`, {
mutationFn: ({ slug, config }: { slug: string; config: Record<string, unknown> }) =>
adminFetch<Environment>(`/environments/${slug}/default-container-config`, {
method: 'PUT',
body: JSON.stringify(config),
}),
@@ -70,8 +70,8 @@ export function useUpdateDefaultContainerConfig() {
export function useUpdateJarRetention() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, jarRetentionCount }: { id: string; jarRetentionCount: number | null }) =>
adminFetch<Environment>(`/environments/${id}/jar-retention`, {
mutationFn: ({ slug, jarRetentionCount }: { slug: string; jarRetentionCount: number | null }) =>
adminFetch<Environment>(`/environments/${slug}/jar-retention`, {
method: 'PUT',
body: JSON.stringify({ jarRetentionCount }),
}),
@@ -82,8 +82,8 @@ export function useUpdateJarRetention() {
export function useDeleteEnvironment() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
adminFetch<void>(`/environments/${id}`, { method: 'DELETE' }),
mutationFn: (slug: string) =>
adminFetch<void>(`/environments/${slug}`, { method: 'DELETE' }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'environments'] }),
});
}

View File

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store';
import { useEnvironmentStore } from '../environment-store';
import { useRefreshInterval } from './use-refresh-interval';
export function useAgentMetrics(
@@ -11,9 +12,10 @@ export function useAgentMetrics(
to?: string,
mode: 'gauge' | 'delta' = 'gauge',
) {
const environment = useEnvironmentStore((s) => s.environment);
const refetchInterval = useRefreshInterval(30_000);
return useQuery({
queryKey: ['agent-metrics', agentId, names.join(','), buckets, from, to, mode],
queryKey: ['agent-metrics', environment, agentId, names.join(','), buckets, from, to, mode],
queryFn: async () => {
const token = useAuthStore.getState().accessToken;
const params = new URLSearchParams({
@@ -23,7 +25,9 @@ export function useAgentMetrics(
});
if (from) params.set('from', from);
if (to) params.set('to', to);
const res = await fetch(`${config.apiBaseUrl}/agents/${agentId}/metrics?${params}`, {
const res = await fetch(
`${config.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/agents/${encodeURIComponent(agentId!)}/metrics?${params}`,
{
headers: {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
@@ -32,7 +36,7 @@ export function useAgentMetrics(
if (!res.ok) throw new Error(`${res.status}`);
return res.json() as Promise<{ metrics: Record<string, Array<{ time: string; value: number }>> }>;
},
enabled: !!agentId && names.length > 0,
enabled: !!agentId && names.length > 0 && !!environment,
refetchInterval,
});
}

View File

@@ -1,20 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store';
import { useEnvironmentStore } from '../environment-store';
import { useRefreshInterval } from './use-refresh-interval';
export function useAgents(status?: string, application?: string, environment?: string) {
export function useAgents(status?: string, application?: string) {
const environment = useEnvironmentStore((s) => s.environment);
const refetchInterval = useRefreshInterval(10_000);
return useQuery({
queryKey: ['agents', status, application, environment],
queryKey: ['agents', environment, status, application],
queryFn: async () => {
const token = useAuthStore.getState().accessToken;
const params = new URLSearchParams();
if (status) params.set('status', status);
if (application) params.set('application', application);
if (environment) params.set('environment', environment);
const qs = params.toString();
const res = await fetch(`${config.apiBaseUrl}/agents${qs ? `?${qs}` : ''}`, {
const res = await fetch(
`${config.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/agents${qs ? `?${qs}` : ''}`,
{
headers: {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
@@ -23,23 +26,26 @@ export function useAgents(status?: string, application?: string, environment?: s
if (!res.ok) throw new Error('Failed to load agents');
return res.json();
},
enabled: !!environment,
refetchInterval,
});
}
export function useAgentEvents(appId?: string, agentId?: string, limit = 50, toOverride?: string, environment?: string) {
export function useAgentEvents(appId?: string, agentId?: string, limit = 50, toOverride?: string) {
const environment = useEnvironmentStore((s) => s.environment);
const refetchInterval = useRefreshInterval(15_000);
return useQuery({
queryKey: ['agents', 'events', appId, agentId, limit, toOverride, environment],
queryKey: ['agents', 'events', environment, appId, agentId, limit, toOverride],
queryFn: async () => {
const token = useAuthStore.getState().accessToken;
const params = new URLSearchParams();
if (appId) params.set('appId', appId);
if (agentId) params.set('agentId', agentId);
if (environment) params.set('environment', environment);
if (toOverride) params.set('to', toOverride);
params.set('limit', String(limit));
const res = await fetch(`${config.apiBaseUrl}/agents/events-log?${params}`, {
const res = await fetch(
`${config.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/agents/events?${params}`,
{
headers: {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
@@ -48,6 +54,7 @@ export function useAgentEvents(appId?: string, agentId?: string, limit = 50, toO
if (!res.ok) throw new Error('Failed to load agent events');
return res.json();
},
enabled: !!environment,
refetchInterval,
});
}

View File

@@ -89,15 +89,15 @@ export function useDismissApp() {
export function useRouteMetrics(from?: string, to?: string, appId?: string, environment?: string) {
const refetchInterval = useRefreshInterval(30_000);
return useQuery({
queryKey: ['routes', 'metrics', from, to, appId, environment],
queryKey: ['routes', 'metrics', environment, from, to, appId],
queryFn: async () => {
const token = useAuthStore.getState().accessToken;
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
if (appId) params.set('appId', appId);
if (environment) params.set('environment', environment);
const res = await fetch(`${config.apiBaseUrl}/routes/metrics?${params}`, {
const res = await fetch(
`${config.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/routes/metrics?${params}`, {
headers: {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
@@ -106,6 +106,7 @@ export function useRouteMetrics(from?: string, to?: string, appId?: string, envi
if (!res.ok) throw new Error('Failed to load route metrics');
return res.json();
},
enabled: !!environment,
placeholderData: (prev: unknown) => prev,
refetchInterval,
});

View File

@@ -45,23 +45,24 @@ function authFetch(path: string, init?: RequestInit): Promise<Response> {
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<ApplicationConfig[]>
},
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<ConfigUpdateResponse>
},
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<Record<string, string>>
},
@@ -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' },

View File

@@ -1,21 +1,31 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../client';
import { config as appConfig } from '../../config';
import { useAuthStore } from '../../auth/auth-store';
export function useCorrelationChain(correlationId: string | null, environment?: string) {
return useQuery({
queryKey: ['correlation-chain', correlationId, environment],
queryKey: ['correlation-chain', environment, correlationId],
queryFn: async () => {
const { data } = await api.POST('/search/executions', {
body: {
const token = useAuthStore.getState().accessToken;
const res = await fetch(
`${appConfig.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/executions/search`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
'X-Cameleer-Protocol-Version': '1',
},
body: JSON.stringify({
correlationId: correlationId!,
environment,
limit: 20,
sortField: 'startTime',
sortDir: 'asc',
},
}),
});
return data;
if (!res.ok) throw new Error('Failed to load correlation chain');
return res.json();
},
enabled: !!correlationId,
enabled: !!correlationId && !!environment,
});
}

View File

@@ -42,11 +42,12 @@ export interface GroupedTimeseries {
export function useTimeseriesByApp(from?: string, to?: string, environment?: string) {
const refetchInterval = useRefreshInterval(30_000);
return useQuery({
queryKey: ['dashboard', 'timeseries-by-app', from, to, environment],
queryFn: () => fetchJson<GroupedTimeseries>('/search/stats/timeseries/by-app', {
from, to, buckets: '24', environment,
queryKey: ['dashboard', 'timeseries-by-app', environment, from, to],
queryFn: () => fetchJson<GroupedTimeseries>(
`/environments/${encodeURIComponent(environment!)}/stats/timeseries/by-app`, {
from, to, buckets: '24',
}),
enabled: !!from,
enabled: !!from && !!environment,
placeholderData: (prev: GroupedTimeseries | undefined) => prev,
refetchInterval,
});
@@ -57,11 +58,12 @@ export function useTimeseriesByApp(from?: string, to?: string, environment?: str
export function useTimeseriesByRoute(from?: string, to?: string, application?: string, environment?: string) {
const refetchInterval = useRefreshInterval(30_000);
return useQuery({
queryKey: ['dashboard', 'timeseries-by-route', from, to, application, environment],
queryFn: () => fetchJson<GroupedTimeseries>('/search/stats/timeseries/by-route', {
from, to, application, buckets: '24', environment,
queryKey: ['dashboard', 'timeseries-by-route', environment, from, to, application],
queryFn: () => fetchJson<GroupedTimeseries>(
`/environments/${encodeURIComponent(environment!)}/stats/timeseries/by-route`, {
from, to, application, buckets: '24',
}),
enabled: !!from && !!application,
enabled: !!from && !!application && !!environment,
placeholderData: (prev: GroupedTimeseries | undefined) => prev,
refetchInterval,
});
@@ -82,11 +84,12 @@ export interface TopError {
export function useTopErrors(from?: string, to?: string, application?: string, routeId?: string, environment?: string) {
const refetchInterval = useRefreshInterval(10_000);
return useQuery({
queryKey: ['dashboard', 'top-errors', from, to, application, routeId, environment],
queryFn: () => fetchJson<TopError[]>('/search/errors/top', {
from, to, application, routeId, limit: '5', environment,
queryKey: ['dashboard', 'top-errors', environment, from, to, application, routeId],
queryFn: () => fetchJson<TopError[]>(
`/environments/${encodeURIComponent(environment!)}/errors/top`, {
from, to, application, routeId, limit: '5',
}),
enabled: !!from,
enabled: !!from && !!environment,
placeholderData: (prev: TopError[] | undefined) => prev,
refetchInterval,
});
@@ -104,8 +107,10 @@ export interface PunchcardCell {
export function usePunchcard(application?: string, environment?: string) {
const refetchInterval = useRefreshInterval(60_000);
return useQuery({
queryKey: ['dashboard', 'punchcard', application, environment],
queryFn: () => fetchJson<PunchcardCell[]>('/search/stats/punchcard', { application, environment }),
queryKey: ['dashboard', 'punchcard', environment, application],
queryFn: () => fetchJson<PunchcardCell[]>(
`/environments/${encodeURIComponent(environment!)}/stats/punchcard`, { application }),
enabled: !!environment,
placeholderData: (prev: PunchcardCell[] | undefined) => prev ?? [],
refetchInterval,
});
@@ -127,9 +132,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<AppSettings>(
`/admin/app-settings/${appId}?environment=${encodeURIComponent(environment!)}`),
`/environments/${encodeURIComponent(environment!)}/apps/${encodeURIComponent(appId!)}/settings`),
enabled: !!appId && !!environment,
staleTime: 60_000,
});
@@ -139,7 +144,7 @@ export function useAllAppSettings(environment?: string) {
return useQuery({
queryKey: ['app-settings', 'all', environment],
queryFn: () => fetchJson<AppSettings[]>(
`/admin/app-settings?environment=${encodeURIComponent(environment!)}`),
`/environments/${encodeURIComponent(environment!)}/app-settings`),
enabled: !!environment,
staleTime: 60_000,
});
@@ -151,7 +156,7 @@ export function useUpdateAppSettings() {
mutationFn: async ({ appId, environment, settings }:
{ appId: string; environment: string; settings: Omit<AppSettings, 'appId' | 'createdAt' | 'updatedAt'> }) => {
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' },

View File

@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../client';
import { useEnvironmentStore } from '../environment-store';
export interface DiagramNode {
id?: string;
@@ -53,15 +54,26 @@ export function useDiagramByRoute(
routeId: string | undefined,
direction: 'LR' | 'TB' = 'LR',
) {
const environment = useEnvironmentStore((s) => s.environment);
return useQuery({
queryKey: ['diagrams', 'byRoute', application, routeId, direction],
queryKey: ['diagrams', 'byRoute', environment, application, routeId, direction],
queryFn: async () => {
const { data, error } = await api.GET('/diagrams', {
params: { query: { application: application!, routeId: routeId!, direction } },
});
if (error) throw new Error('Failed to load diagram for route');
return data as DiagramLayout;
const { useAuthStore } = await import('../../auth/auth-store');
const { config: appConfig } = await import('../../config');
const token = useAuthStore.getState().accessToken;
const url = `${appConfig.apiBaseUrl}/environments/${encodeURIComponent(environment!)}` +
`/apps/${encodeURIComponent(application!)}` +
`/routes/${encodeURIComponent(routeId!)}/diagram?direction=${direction}`;
const res = await fetch(url, {
headers: {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
'X-Cameleer-Protocol-Version': '1',
},
enabled: !!application && !!routeId,
});
if (!res.ok) throw new Error('Failed to load diagram for route');
return (await res.json()) as DiagramLayout;
},
enabled: !!application && !!routeId && !!environment,
});
}

View File

@@ -1,8 +1,35 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../client';
import { config as appConfig } from '../../config';
import { useAuthStore } from '../../auth/auth-store';
import { useEnvironmentStore } from '../environment-store';
import type { components } from '../schema';
import type { SearchRequest } from '../types';
import { useLiveQuery } from './use-refresh-interval';
type ExecutionStats = components['schemas']['ExecutionStats'];
type StatsTimeseries = components['schemas']['StatsTimeseries'];
type SearchResultSummary = components['schemas']['SearchResultExecutionSummary'];
// Raw authenticated fetch — used for env-scoped endpoints where the
// generated openapi schema is still on the old flat shape. Switch back to
// api.GET once the schema is regenerated against a running P3-era backend.
async function envFetch<T>(envSlug: string, path: string, init?: RequestInit): Promise<T> {
const token = useAuthStore.getState().accessToken;
const res = await fetch(
`${appConfig.apiBaseUrl}/environments/${encodeURIComponent(envSlug)}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
'X-Cameleer-Protocol-Version': '1',
...init?.headers,
},
});
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json() as Promise<T>;
}
export function useExecutionStats(
timeFrom: string | undefined,
timeTo: string | undefined,
@@ -12,57 +39,41 @@ export function useExecutionStats(
) {
const live = useLiveQuery(10_000);
return useQuery({
queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, application, environment],
queryKey: ['executions', 'stats', environment, timeFrom, timeTo, routeId, application],
queryFn: async () => {
const { data, error } = await api.GET('/search/stats', {
params: {
query: {
from: timeFrom!,
to: timeTo || undefined,
routeId: routeId || undefined,
application: application || undefined,
environment: environment || undefined,
const params = new URLSearchParams({ from: timeFrom! });
if (timeTo) params.set('to', timeTo);
if (routeId) params.set('routeId', routeId);
if (application) params.set('application', application);
return envFetch<ExecutionStats>(environment!, `/stats?${params}`);
},
},
});
if (error) throw new Error('Failed to load stats');
return data!;
},
enabled: !!timeFrom && live.enabled,
enabled: !!timeFrom && !!environment && live.enabled,
placeholderData: (prev) => prev,
refetchInterval: live.refetchInterval,
});
}
export function useAttributeKeys() {
const environment = useEnvironmentStore((s) => s.environment);
return useQuery({
queryKey: ['search', 'attribute-keys'],
queryFn: async () => {
const token = (await import('../../auth/auth-store')).useAuthStore.getState().accessToken;
const { config } = await import('../../config');
const res = await fetch(`${config.apiBaseUrl}/search/attributes/keys`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error('Failed to load attribute keys');
return res.json() as Promise<string[]>;
},
queryKey: ['search', 'attribute-keys', environment],
queryFn: () => envFetch<string[]>(environment!, '/attributes/keys'),
enabled: !!environment,
staleTime: 60_000,
});
}
export function useSearchExecutions(filters: SearchRequest, live = false) {
const environment = useEnvironmentStore((s) => s.environment);
const liveQuery = useLiveQuery(5_000);
return useQuery({
queryKey: ['executions', 'search', filters],
queryFn: async () => {
const { data, error } = await api.POST('/search/executions', {
body: filters,
});
if (error) throw new Error('Search failed');
return data!;
},
queryKey: ['executions', 'search', environment, filters],
queryFn: () => envFetch<SearchResultSummary>(environment!, '/executions/search', {
method: 'POST',
body: JSON.stringify(filters),
}),
placeholderData: (prev) => prev,
enabled: live ? liveQuery.enabled : true,
enabled: !!environment && (live ? liveQuery.enabled : true),
refetchInterval: live ? liveQuery.refetchInterval : false,
});
}
@@ -76,24 +87,15 @@ export function useStatsTimeseries(
) {
const live = useLiveQuery(30_000);
return useQuery({
queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, application, environment],
queryKey: ['executions', 'timeseries', environment, timeFrom, timeTo, routeId, application],
queryFn: async () => {
const { data, error } = await api.GET('/search/stats/timeseries', {
params: {
query: {
from: timeFrom!,
to: timeTo || undefined,
buckets: 24,
routeId: routeId || undefined,
application: application || undefined,
environment: environment || undefined,
const params = new URLSearchParams({ from: timeFrom!, buckets: '24' });
if (timeTo) params.set('to', timeTo);
if (routeId) params.set('routeId', routeId);
if (application) params.set('application', application);
return envFetch<StatsTimeseries>(environment!, `/stats/timeseries?${params}`);
},
},
});
if (error) throw new Error('Failed to load timeseries');
return data!;
},
enabled: !!timeFrom && live.enabled,
enabled: !!timeFrom && !!environment && live.enabled,
placeholderData: (prev) => prev,
refetchInterval: live.refetchInterval,
});

View File

@@ -32,7 +32,8 @@ export interface LogSearchParams {
application?: string;
agentId?: string;
source?: string;
environment?: string;
/** Required: env in path */
environment: string;
exchangeId?: string;
logger?: string;
from?: string;
@@ -50,7 +51,6 @@ async function fetchLogs(params: LogSearchParams): Promise<LogSearchPageResponse
if (params.application) urlParams.set('application', params.application);
if (params.agentId) urlParams.set('agentId', params.agentId);
if (params.source) urlParams.set('source', params.source);
if (params.environment) urlParams.set('environment', params.environment);
if (params.exchangeId) urlParams.set('exchangeId', params.exchangeId);
if (params.logger) urlParams.set('logger', params.logger);
if (params.from) urlParams.set('from', params.from);
@@ -59,7 +59,8 @@ async function fetchLogs(params: LogSearchParams): Promise<LogSearchPageResponse
if (params.limit) urlParams.set('limit', String(params.limit));
if (params.sort) urlParams.set('sort', params.sort);
const res = await fetch(`${config.apiBaseUrl}/logs?${urlParams}`, {
const res = await fetch(
`${config.apiBaseUrl}/environments/${encodeURIComponent(params.environment)}/logs?${urlParams}`, {
headers: {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
@@ -81,7 +82,7 @@ export function useLogs(
return useQuery({
queryKey: ['logs', params],
queryFn: () => fetchLogs(params),
enabled: options?.enabled ?? true,
enabled: (options?.enabled ?? true) && !!params.environment,
placeholderData: (prev) => prev,
refetchInterval: options?.refetchInterval ?? defaultRefetch,
staleTime: 300,
@@ -107,7 +108,7 @@ export function useApplicationLogs(
application: application || undefined,
agentId: agentId || undefined,
source: options?.source || undefined,
environment: selectedEnv || undefined,
environment: selectedEnv ?? '',
exchangeId: options?.exchangeId || undefined,
from: useTimeRange ? timeRange.start.toISOString() : undefined,
to: useTimeRange ? to : undefined,
@@ -120,7 +121,7 @@ export function useApplicationLogs(
useTimeRange ? to : null,
options?.limit, options?.exchangeId, options?.source],
queryFn: () => fetchLogs(params),
enabled: !!application,
enabled: !!application && !!selectedEnv,
placeholderData: (prev) => prev,
refetchInterval,
});
@@ -144,7 +145,7 @@ export function useStartupLogs(
) {
const params: LogSearchParams = {
application: application || undefined,
environment: environment || undefined,
environment: environment ?? '',
source: 'container',
from: deployCreatedAt || undefined,
sort: 'asc',
@@ -152,7 +153,7 @@ export function useStartupLogs(
};
return useLogs(params, {
enabled: !!application && !!deployCreatedAt,
enabled: !!application && !!deployCreatedAt && !!environment,
refetchInterval: isStarting ? 3_000 : false,
});
}

View File

@@ -6,14 +6,15 @@ import { useRefreshInterval } from './use-refresh-interval';
export function useProcessorMetrics(routeId: string | null, appId?: string, environment?: string) {
const refetchInterval = useRefreshInterval(30_000);
return useQuery({
queryKey: ['processor-metrics', routeId, appId, environment],
queryKey: ['processor-metrics', environment, routeId, appId],
queryFn: async () => {
const token = useAuthStore.getState().accessToken;
const params = new URLSearchParams();
if (routeId) params.set('routeId', routeId);
if (appId) params.set('appId', appId);
if (environment) params.set('environment', environment);
const res = await fetch(`${config.apiBaseUrl}/routes/metrics/processors?${params}`, {
const res = await fetch(
`${config.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/routes/metrics/processors?${params}`,
{
headers: {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
@@ -22,7 +23,7 @@ export function useProcessorMetrics(routeId: string | null, appId?: string, envi
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
},
enabled: !!routeId,
enabled: !!routeId && !!environment,
refetchInterval,
});
}

View File

@@ -4,6 +4,7 @@ import { Input, Button, LogViewer } from '@cameleer/design-system';
import type { LogEntry } from '@cameleer/design-system';
import { useLogs } from '../../../api/queries/logs';
import type { LogEntryResponse } from '../../../api/queries/logs';
import { useEnvironmentStore } from '../../../api/environment-store';
import { mapLogLevel } from '../../../utils/agent-utils';
import logStyles from './LogTab.module.css';
import diagramStyles from '../ExecutionDiagram.module.css';
@@ -27,9 +28,10 @@ export function LogTab({ applicationId, exchangeId, processorId }: LogTabProps)
const [filter, setFilter] = useState('');
const navigate = useNavigate();
const environment = useEnvironmentStore((s) => s.environment) ?? '';
const { data: logPage, isLoading } = useLogs(
{ exchangeId, limit: 500 },
{ enabled: !!exchangeId },
{ exchangeId, environment, limit: 500 },
{ enabled: !!exchangeId && !!environment },
);
const entries = useMemo<LogEntry[]>(() => {

View File

@@ -303,8 +303,10 @@ function LayoutContent() {
const setSelectedEnvRaw = useEnvironmentStore((s) => s.setEnvironment);
const { data: catalog } = useCatalog(selectedEnv);
const { data: allAgents } = useAgents(); // unfiltered — for environment discovery
const { data: agents } = useAgents(undefined, undefined, selectedEnv); // filtered — for sidebar/search
// Env is always required now (path-based endpoint). For cross-env "all agents"
// we'd need a separate flat endpoint; sidebar uses env-filtered list directly.
const { data: agents } = useAgents(); // env pulled from store internally
const allAgents = agents;
const { data: attributeKeys } = useAttributeKeys();
const { data: envRecords = [] } = useEnvironments();

View File

@@ -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 }),

View File

@@ -102,7 +102,7 @@ export default function EnvironmentsPage() {
async function handleDelete() {
if (!deleteTarget) return;
try {
await deleteEnv.mutateAsync(deleteTarget.id);
await deleteEnv.mutateAsync(deleteTarget.slug);
toast({ title: 'Environment deleted', description: deleteTarget.slug, variant: 'warning' });
if (selectedId === deleteTarget.id) setSelectedId(null);
setDeleteTarget(null);
@@ -116,7 +116,7 @@ export default function EnvironmentsPage() {
if (!selected) return;
try {
await updateEnv.mutateAsync({
id: selected.id,
slug: selected.slug,
displayName: newName,
production: selected.production,
enabled: selected.enabled,
@@ -131,7 +131,7 @@ export default function EnvironmentsPage() {
if (!selected) return;
try {
await updateEnv.mutateAsync({
id: selected.id,
slug: selected.slug,
displayName: selected.displayName,
production: value,
enabled: selected.enabled,
@@ -146,7 +146,7 @@ export default function EnvironmentsPage() {
if (!selected) return;
try {
await updateEnv.mutateAsync({
id: selected.id,
slug: selected.slug,
displayName: selected.displayName,
production: selected.production,
enabled: value,
@@ -300,7 +300,7 @@ export default function EnvironmentsPage() {
<DefaultResourcesSection environment={selected} onSave={async (config) => {
try {
await updateDefaults.mutateAsync({ id: selected.id, config });
await updateDefaults.mutateAsync({ slug: selected.slug, config });
toast({ title: 'Default resources updated', variant: 'success' });
} catch {
toast({ title: 'Failed to update defaults', variant: 'error', duration: 86_400_000 });
@@ -309,7 +309,7 @@ export default function EnvironmentsPage() {
<JarRetentionSection environment={selected} onSave={async (count) => {
try {
await updateRetention.mutateAsync({ id: selected.id, jarRetentionCount: count });
await updateRetention.mutateAsync({ slug: selected.slug, jarRetentionCount: count });
toast({ title: 'Retention policy updated', variant: 'success' });
} catch {
toast({ title: 'Failed to update retention', variant: 'error', duration: 86_400_000 });

View File

@@ -163,7 +163,7 @@ export default function AgentHealth() {
const navigate = useNavigate();
const { toast } = useToast();
const selectedEnv = useEnvironmentStore((s) => s.environment);
const { data: agents } = useAgents(undefined, appId, selectedEnv);
const { data: agents } = useAgents(undefined, appId);
const { data: appConfig } = useApplicationConfig(appId, selectedEnv);
const updateConfig = useUpdateApplicationConfig();
@@ -282,7 +282,7 @@ export default function AgentHealth() {
}, [appConfig, configDraft, updateConfig, toast, appId]);
const [eventSortAsc, setEventSortAsc] = useState(false);
const [eventRefreshTo, setEventRefreshTo] = useState<string | undefined>();
const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo, selectedEnv);
const { data: events } = useAgentEvents(appId, undefined, 50, eventRefreshTo);
const [appFilter, setAppFilter] = useState('');
type AppSortKey = 'status' | 'name' | 'tps' | 'cpu' | 'heartbeat';

View File

@@ -45,8 +45,8 @@ export default function AgentInstance() {
const timeTo = timeRange.end.toISOString();
const selectedEnv = useEnvironmentStore((s) => s.environment);
const { data: agents, isLoading } = useAgents(undefined, appId, selectedEnv);
const { data: events } = useAgentEvents(appId, instanceId, 50, eventRefreshTo, selectedEnv);
const { data: agents, isLoading } = useAgents(undefined, appId);
const { data: events } = useAgentEvents(appId, instanceId, 50, eventRefreshTo);
const agent = useMemo(
() => (agents || []).find((a: any) => a.instanceId === instanceId) as any,

View File

@@ -23,7 +23,6 @@ import { EnvEditor } from '../../components/EnvEditor';
import { useEnvironmentStore } from '../../api/environment-store';
import { useEnvironments } from '../../api/queries/admin/environments';
import {
useAllApps,
useApps,
useCreateApp,
useDeleteApp,
@@ -92,13 +91,13 @@ export default function AppsTab() {
function AppListView({ selectedEnv, environments }: { selectedEnv: string | undefined; environments: Environment[] }) {
const navigate = useNavigate();
const { data: allApps = [], isLoading: allLoading } = useAllApps();
const envId = useMemo(() => environments.find((e) => e.slug === selectedEnv)?.id, [environments, selectedEnv]);
const { data: envApps = [], isLoading: envLoading } = useApps(envId);
const { data: envApps = [], isLoading: envLoading } = useApps(selectedEnv);
const { data: catalog = [] } = useCatalog(selectedEnv);
const apps = selectedEnv ? envApps : allApps;
const isLoading = selectedEnv ? envLoading : allLoading;
// Apps are env-scoped; without an env selection there is no managed-app list
// to show. The Runtime tab (catalog) is the cross-env discovery surface.
const apps = selectedEnv ? envApps : [];
const isLoading = selectedEnv ? envLoading : false;
const envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]);
@@ -259,13 +258,13 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
try {
// 1. Create app
setStep('Creating app...');
const app = await createApp.mutateAsync({ environmentId: envId, slug: slug.trim(), displayName: name.trim() });
const app = await createApp.mutateAsync({ envSlug: selectedEnv!, slug: slug.trim(), displayName: name.trim() });
// 2. Upload JAR (if provided)
let version: AppVersion | null = null;
if (file) {
setStep('Uploading JAR...');
version = await uploadJar.mutateAsync({ appId: app.slug, file });
version = await uploadJar.mutateAsync({ envSlug: selectedEnv!, appSlug: app.slug, file });
}
// 3. Save container config
@@ -286,7 +285,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
customArgs: customArgs || null,
extraNetworks: extraNetworks,
};
await updateContainerConfig.mutateAsync({ appId: app.slug, config: containerConfig });
await updateContainerConfig.mutateAsync({ envSlug: selectedEnv!, appSlug: app.slug, config: containerConfig });
// 4. Save agent config (will be pushed to agent on first connect)
setStep('Saving monitoring config...');
@@ -307,13 +306,13 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
routeRecording: {},
sensitiveKeys: sensitiveKeys.length > 0 ? sensitiveKeys : undefined,
},
environment: selectedEnv,
environment: selectedEnv!,
});
// 5. Deploy (if requested and JAR was uploaded)
if (deploy && version) {
setStep('Starting deployment...');
await createDeployment.mutateAsync({ appId: app.slug, appVersionId: version.id, environmentId: envId });
await createDeployment.mutateAsync({ envSlug: selectedEnv!, appSlug: app.slug, appVersionId: version.id });
}
toast({ title: deploy ? 'App created and deployed' : 'App created', description: name.trim(), variant: 'success' });
@@ -661,12 +660,12 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: string; environments: Environment[]; selectedEnv: string | undefined }) {
const { toast } = useToast();
const navigate = useNavigate();
const { data: allApps = [] } = useAllApps();
const app = useMemo(() => allApps.find((a) => a.slug === appSlug), [allApps, appSlug]);
const { data: envApps = [] } = useApps(selectedEnv);
const app = useMemo(() => envApps.find((a) => a.slug === appSlug), [envApps, appSlug]);
const { data: catalogApps } = useCatalog(selectedEnv);
const catalogEntry = useMemo(() => (catalogApps ?? []).find((c: CatalogApp) => c.slug === appSlug), [catalogApps, appSlug]);
const { data: versions = [] } = useAppVersions(appSlug);
const { data: deployments = [] } = useDeployments(appSlug);
const { data: versions = [] } = useAppVersions(selectedEnv, appSlug);
const { data: deployments = [] } = useDeployments(selectedEnv, appSlug);
const uploadJar = useUploadJar();
const createDeployment = useCreateDeployment();
const stopDeployment = useStopDeployment();
@@ -699,15 +698,15 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
const file = e.target.files?.[0];
if (!file) return;
try {
const v = await uploadJar.mutateAsync({ appId: appSlug, file });
const v = await uploadJar.mutateAsync({ envSlug: selectedEnv!, appSlug, file });
toast({ title: `Version ${v.version} uploaded`, description: file.name, variant: 'success' });
} catch { toast({ title: 'Failed to upload JAR', variant: 'error', duration: 86_400_000 }); }
if (fileInputRef.current) fileInputRef.current.value = '';
}
async function handleDeploy(versionId: string, environmentId: string) {
async function handleDeploy(versionId: string) {
try {
await createDeployment.mutateAsync({ appId: appSlug, appVersionId: versionId, environmentId });
await createDeployment.mutateAsync({ envSlug: selectedEnv!, appSlug, appVersionId: versionId });
toast({ title: 'Deployment started', variant: 'success' });
} catch { toast({ title: 'Failed to deploy application', variant: 'error', duration: 86_400_000 }); }
}
@@ -719,7 +718,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
async function confirmStop() {
if (!stopTarget) return;
try {
await stopDeployment.mutateAsync({ appId: appSlug, deploymentId: stopTarget.id });
await stopDeployment.mutateAsync({ envSlug: selectedEnv!, appSlug, deploymentId: stopTarget.id });
toast({ title: 'Deployment stopped', variant: 'warning' });
} catch { toast({ title: 'Failed to stop deployment', variant: 'error', duration: 86_400_000 }); }
setStopTarget(null);
@@ -727,7 +726,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
async function handleDelete() {
try {
await deleteApp.mutateAsync(appSlug);
await deleteApp.mutateAsync({ envSlug: selectedEnv!, appSlug });
toast({ title: 'App deleted', variant: 'warning' });
navigate('/apps');
} catch { toast({ title: 'Delete failed', variant: 'error', duration: 86_400_000 }); }
@@ -994,7 +993,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
const [newNetwork, setNewNetwork] = useState('');
// Versions query for runtime detection hints
const { data: versions = [] } = useAppVersions(app.slug);
const { data: versions = [] } = useAppVersions(environment?.slug, app.slug);
const latestVersion = versions?.[0] ?? null;
// Sync from server data
@@ -1080,7 +1079,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
extraNetworks: extraNetworks,
};
try {
await updateContainerConfig.mutateAsync({ appId: app.slug, config: containerConfig });
await updateContainerConfig.mutateAsync({ envSlug: environment?.slug ?? '', appSlug: app.slug, config: containerConfig });
toast({ title: 'Configuration saved', description: 'Redeploy to apply changes to running deployments.', variant: 'success' });
setEditing(false);
} catch { toast({ title: 'Failed to save container config', variant: 'error', duration: 86_400_000 }); }

View File

@@ -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}

View File

@@ -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 }) },
);
}