Compare commits
8 Commits
9b1ef51d77
...
b7a107d33f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7a107d33f | ||
|
|
51d7bda5b8 | ||
|
|
873e1d3df7 | ||
|
|
6d9e456b97 | ||
|
|
969cdb3bd0 | ||
|
|
6b5ee10944 | ||
|
|
fcb53dd010 | ||
|
|
c97d0ea061 |
@@ -7,44 +7,94 @@ paths:
|
|||||||
|
|
||||||
`cameleer-server-app/src/main/java/com/cameleer/server/app/`
|
`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
|
## 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)
|
### Env-scoped (user-facing data & config)
|
||||||
- `AgentSseController` — GET /sse (Server-Sent Events connection)
|
|
||||||
- `AgentCommandController` — POST /broadcast, POST /{agentId}, POST /{agentId}/ack
|
- `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.
|
||||||
- `AppController` — CRUD /api/v1/apps, POST /{appId}/upload-jar, GET /{appId}/versions
|
- `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`.
|
||||||
- `DeploymentController` — GET/POST /api/v1/apps/{appId}/deployments, POST /{id}/stop, POST /{id}/promote, 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.
|
||||||
- `EnvironmentAdminController` — CRUD /api/v1/admin/environments, PUT /{id}/jar-retention
|
- `AppSettingsController` — `/api/v1/environments/{envSlug}`. GET `/app-settings` (list), GET/PUT/DELETE `/apps/{appSlug}/settings`. ADMIN/OPERATOR only.
|
||||||
- `ExecutionController` — GET /api/v1/executions (search + detail)
|
- `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`.
|
||||||
- `SearchController` — POST /api/v1/search, GET /routes, GET /top-errors, GET /punchcard
|
- `LogQueryController` — GET `/api/v1/environments/{envSlug}/logs` (filters: source, application, agentId, exchangeId, level, logger, q, time range).
|
||||||
- `LogQueryController` — GET /api/v1/logs (filters: source, application, agentId, exchangeId, level, logger, q, environment, time range)
|
- `RouteCatalogController` — GET `/api/v1/environments/{envSlug}/routes` (merged route catalog from registry + ClickHouse; env filter unconditional).
|
||||||
- `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.
|
- `RouteMetricsController` — GET `/api/v1/environments/{envSlug}/routes/metrics`, GET `/api/v1/environments/{envSlug}/routes/metrics/processors`.
|
||||||
- `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.
|
- `AgentListController` — GET `/api/v1/environments/{envSlug}/agents` (registered agents with runtime metrics, filtered to env).
|
||||||
- `ChunkIngestionController` — POST /api/v1/ingestion/chunk/{executions|metrics|diagrams}
|
- `AgentEventsController` — GET `/api/v1/environments/{envSlug}/agents/events` (lifecycle events).
|
||||||
- `UserAdminController` — CRUD /api/v1/admin/users, POST /{id}/roles, POST /{id}/set-password
|
- `AgentMetricsController` — GET `/api/v1/environments/{envSlug}/agents/{agentId}/metrics` (JVM/Camel metrics). Rejects cross-env agents (404) as defence-in-depth.
|
||||||
- `RoleAdminController` — CRUD /api/v1/admin/roles
|
- `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).
|
||||||
- `GroupAdminController` — CRUD /api/v1/admin/groups
|
|
||||||
- `OidcConfigAdminController` — GET/POST /api/v1/admin/oidc, POST /test
|
### Env admin (env-slug-parameterized, not env-scoped data)
|
||||||
- `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
|
- `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.
|
||||||
- `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.
|
### Agent-only (JWT-authoritative, intentionally flat)
|
||||||
- `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)
|
- `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`.
|
||||||
- `LicenseAdminController` — GET/POST /api/v1/admin/license
|
- `AgentSseController` — GET `/{id}/events` (SSE connection).
|
||||||
- `AgentEventsController` — GET /api/v1/agent-events (agent state change history)
|
- `AgentCommandController` — POST `/{agentId}/commands`, POST `/groups/{group}/commands`, POST `/commands` (broadcast), POST `/{agentId}/commands/{commandId}/ack`, POST `/{agentId}/replay`.
|
||||||
- `AgentMetricsController` — GET /api/v1/agent-metrics (JVM/Camel metrics per agent instance)
|
- `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).
|
||||||
- `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.
|
### Ingestion (agent-only, JWT-authoritative)
|
||||||
- `ClickHouseAdminController` — GET /api/v1/admin/clickhouse (ClickHouse admin, conditional on infrastructure endpoints)
|
|
||||||
- `DatabaseAdminController` — GET /api/v1/admin/database (PG admin, conditional on infrastructure endpoints)
|
- `LogIngestionController` — POST `/api/v1/data/logs` (accepts `List<LogEntry>`; WARNs on missing identity, unregistered agents, empty payloads, buffer-full drops).
|
||||||
- `DetailController` — GET /api/v1/detail (execution detail with processor tree)
|
- `EventIngestionController` — POST `/api/v1/data/events`.
|
||||||
- `EventIngestionController` — POST /api/v1/data/events (agent event ingestion)
|
- `ChunkIngestionController` — POST `/api/v1/ingestion/chunk/{executions|metrics|diagrams}`.
|
||||||
- `RbacStatsController` — GET /api/v1/admin/rbac/stats
|
- `ExecutionController` — POST `/api/v1/data/executions` (legacy ingestion path when ClickHouse disabled).
|
||||||
- `RouteCatalogController` — GET /api/v1/routes/catalog (merged route catalog from registry + ClickHouse)
|
- `MetricsController` — POST `/api/v1/data/metrics`.
|
||||||
- `RouteMetricsController` — GET /api/v1/route-metrics (per-route Camel metrics)
|
- `DiagramController` — POST `/api/v1/data/diagrams` (resolves applicationId + environment from the agent registry keyed on JWT subject; stamps both on the stored `TaggedDiagram`).
|
||||||
- `ThresholdAdminController` — CRUD /api/v1/admin/thresholds
|
|
||||||
- `UsageAnalyticsController` — GET /api/v1/admin/usage (ClickHouse usage_events)
|
### 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
|
## runtime/ — Docker orchestration
|
||||||
|
|
||||||
|
|||||||
20
CLAUDE.md
20
CLAUDE.md
@@ -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
|
- Depends on `com.cameleer:cameleer-common` from Gitea Maven registry
|
||||||
- Jackson `JavaTimeModule` for `Instant` deserialization
|
- 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)
|
- 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.
|
- 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))`.
|
- 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.
|
- 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)
|
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/
|
## 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.
|
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:start -->
|
||||||
# GitNexus — Code Intelligence
|
# 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.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ package com.cameleer.server.app.config;
|
|||||||
import com.cameleer.server.app.analytics.UsageTrackingInterceptor;
|
import com.cameleer.server.app.analytics.UsageTrackingInterceptor;
|
||||||
import com.cameleer.server.app.interceptor.AuditInterceptor;
|
import com.cameleer.server.app.interceptor.AuditInterceptor;
|
||||||
import com.cameleer.server.app.interceptor.ProtocolVersionInterceptor;
|
import com.cameleer.server.app.interceptor.ProtocolVersionInterceptor;
|
||||||
|
import com.cameleer.server.app.web.EnvironmentPathResolver;
|
||||||
import org.springframework.context.annotation.Configuration;
|
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.InterceptorRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Web MVC configuration.
|
* Web MVC configuration.
|
||||||
*/
|
*/
|
||||||
@@ -16,13 +20,21 @@ public class WebConfig implements WebMvcConfigurer {
|
|||||||
private final ProtocolVersionInterceptor protocolVersionInterceptor;
|
private final ProtocolVersionInterceptor protocolVersionInterceptor;
|
||||||
private final AuditInterceptor auditInterceptor;
|
private final AuditInterceptor auditInterceptor;
|
||||||
private final UsageTrackingInterceptor usageTrackingInterceptor;
|
private final UsageTrackingInterceptor usageTrackingInterceptor;
|
||||||
|
private final EnvironmentPathResolver environmentPathResolver;
|
||||||
|
|
||||||
public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor,
|
public WebConfig(ProtocolVersionInterceptor protocolVersionInterceptor,
|
||||||
AuditInterceptor auditInterceptor,
|
AuditInterceptor auditInterceptor,
|
||||||
@org.springframework.lang.Nullable UsageTrackingInterceptor usageTrackingInterceptor) {
|
@org.springframework.lang.Nullable UsageTrackingInterceptor usageTrackingInterceptor,
|
||||||
|
EnvironmentPathResolver environmentPathResolver) {
|
||||||
this.protocolVersionInterceptor = protocolVersionInterceptor;
|
this.protocolVersionInterceptor = protocolVersionInterceptor;
|
||||||
this.auditInterceptor = auditInterceptor;
|
this.auditInterceptor = auditInterceptor;
|
||||||
this.usageTrackingInterceptor = usageTrackingInterceptor;
|
this.usageTrackingInterceptor = usageTrackingInterceptor;
|
||||||
|
this.environmentPathResolver = environmentPathResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
|
||||||
|
resolvers.add(environmentPathResolver);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.cameleer.server.app.controller;
|
package com.cameleer.server.app.controller;
|
||||||
|
|
||||||
import com.cameleer.server.app.dto.AgentEventResponse;
|
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.agent.AgentEventService;
|
||||||
|
import com.cameleer.server.core.runtime.Environment;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@@ -15,8 +17,8 @@ import java.time.Instant;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/agents/events-log")
|
@RequestMapping("/api/v1/environments/{envSlug}/agents/events")
|
||||||
@Tag(name = "Agent Events", description = "Agent lifecycle event log")
|
@Tag(name = "Agent Events", description = "Agent lifecycle event log (env-scoped)")
|
||||||
public class AgentEventsController {
|
public class AgentEventsController {
|
||||||
|
|
||||||
private final AgentEventService agentEventService;
|
private final AgentEventService agentEventService;
|
||||||
@@ -26,13 +28,13 @@ public class AgentEventsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@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")
|
description = "Returns agent lifecycle events, optionally filtered by app and/or agent ID")
|
||||||
@ApiResponse(responseCode = "200", description = "Events returned")
|
@ApiResponse(responseCode = "200", description = "Events returned")
|
||||||
public ResponseEntity<List<AgentEventResponse>> getEvents(
|
public ResponseEntity<List<AgentEventResponse>> getEvents(
|
||||||
|
@EnvPath Environment env,
|
||||||
@RequestParam(required = false) String appId,
|
@RequestParam(required = false) String appId,
|
||||||
@RequestParam(required = false) String agentId,
|
@RequestParam(required = false) String agentId,
|
||||||
@RequestParam(required = false) String environment,
|
|
||||||
@RequestParam(required = false) String from,
|
@RequestParam(required = false) String from,
|
||||||
@RequestParam(required = false) String to,
|
@RequestParam(required = false) String to,
|
||||||
@RequestParam(defaultValue = "50") int limit) {
|
@RequestParam(defaultValue = "50") int limit) {
|
||||||
@@ -40,7 +42,7 @@ public class AgentEventsController {
|
|||||||
Instant fromInstant = from != null ? Instant.parse(from) : null;
|
Instant fromInstant = from != null ? Instant.parse(from) : null;
|
||||||
Instant toInstant = to != null ? Instant.parse(to) : 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()
|
.stream()
|
||||||
.map(AgentEventResponse::from)
|
.map(AgentEventResponse::from)
|
||||||
.toList();
|
.toList();
|
||||||
|
|||||||
@@ -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)) + "'";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,13 @@ package com.cameleer.server.app.controller;
|
|||||||
|
|
||||||
import com.cameleer.server.app.dto.AgentMetricsResponse;
|
import com.cameleer.server.app.dto.AgentMetricsResponse;
|
||||||
import com.cameleer.server.app.dto.MetricBucket;
|
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.MetricsQueryStore;
|
||||||
import com.cameleer.server.core.storage.model.MetricTimeSeries;
|
import com.cameleer.server.core.storage.model.MetricTimeSeries;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
@@ -12,17 +17,21 @@ import java.util.*;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/agents/{agentId}/metrics")
|
@RequestMapping("/api/v1/environments/{envSlug}/agents/{agentId}/metrics")
|
||||||
public class AgentMetricsController {
|
public class AgentMetricsController {
|
||||||
|
|
||||||
private final MetricsQueryStore metricsQueryStore;
|
private final MetricsQueryStore metricsQueryStore;
|
||||||
|
private final AgentRegistryService registryService;
|
||||||
|
|
||||||
public AgentMetricsController(MetricsQueryStore metricsQueryStore) {
|
public AgentMetricsController(MetricsQueryStore metricsQueryStore,
|
||||||
|
AgentRegistryService registryService) {
|
||||||
this.metricsQueryStore = metricsQueryStore;
|
this.metricsQueryStore = metricsQueryStore;
|
||||||
|
this.registryService = registryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
public AgentMetricsResponse getMetrics(
|
public ResponseEntity<AgentMetricsResponse> getMetrics(
|
||||||
|
@EnvPath Environment env,
|
||||||
@PathVariable String agentId,
|
@PathVariable String agentId,
|
||||||
@RequestParam String names,
|
@RequestParam String names,
|
||||||
@RequestParam(required = false) Instant from,
|
@RequestParam(required = false) Instant from,
|
||||||
@@ -30,6 +39,13 @@ public class AgentMetricsController {
|
|||||||
@RequestParam(defaultValue = "60") int buckets,
|
@RequestParam(defaultValue = "60") int buckets,
|
||||||
@RequestParam(defaultValue = "gauge") String mode) {
|
@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 (from == null) from = Instant.now().minus(1, ChronoUnit.HOURS);
|
||||||
if (to == null) to = Instant.now();
|
if (to == null) to = Instant.now();
|
||||||
|
|
||||||
@@ -48,6 +64,6 @@ public class AgentMetricsController {
|
|||||||
(a, b) -> a,
|
(a, b) -> a,
|
||||||
LinkedHashMap::new));
|
LinkedHashMap::new));
|
||||||
|
|
||||||
return new AgentMetricsResponse(result);
|
return ResponseEntity.ok(new AgentMetricsResponse(result));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,123 +321,7 @@ public class AgentRegistrationController {
|
|||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
// Agent list moved to AgentListController at /api/v1/environments/{envSlug}/agents.
|
||||||
@Operation(summary = "List all agents",
|
// Agent register/refresh/heartbeat/deregister remain here at /api/v1/agents/** —
|
||||||
description = "Returns all registered agents with runtime metrics, optionally filtered by status and/or application")
|
// these are JWT-authoritative and intentionally flat (env from token).
|
||||||
@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)) + "'";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.cameleer.server.app.controller;
|
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.App;
|
||||||
import com.cameleer.server.core.runtime.AppService;
|
import com.cameleer.server.core.runtime.AppService;
|
||||||
import com.cameleer.server.core.runtime.AppVersion;
|
import com.cameleer.server.core.runtime.AppVersion;
|
||||||
|
import com.cameleer.server.core.runtime.Environment;
|
||||||
import com.cameleer.server.core.runtime.RuntimeType;
|
import com.cameleer.server.core.runtime.RuntimeType;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
@@ -27,13 +29,13 @@ import java.util.Map;
|
|||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App CRUD and JAR upload endpoints.
|
* App CRUD and JAR upload. All routes env-scoped: the (env, appSlug) pair
|
||||||
* All app-scoped endpoints accept the app slug (not UUID) as path variable.
|
* identifies a single app — the same app slug can legitimately exist in
|
||||||
* Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}.
|
* multiple environments with independent configuration and history.
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/apps")
|
@RequestMapping("/api/v1/environments/{envSlug}/apps")
|
||||||
@Tag(name = "App Management", description = "Application lifecycle and JAR uploads")
|
@Tag(name = "App Management", description = "Application lifecycle and JAR uploads (env-scoped)")
|
||||||
@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")
|
@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")
|
||||||
public class AppController {
|
public class AppController {
|
||||||
|
|
||||||
@@ -44,46 +46,45 @@ public class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@Operation(summary = "List apps by environment")
|
@Operation(summary = "List apps in this environment")
|
||||||
@ApiResponse(responseCode = "200", description = "App list returned")
|
@ApiResponse(responseCode = "200", description = "App list returned")
|
||||||
public ResponseEntity<List<App>> listApps(@RequestParam(required = false) UUID environmentId) {
|
public ResponseEntity<List<App>> listApps(@EnvPath Environment env) {
|
||||||
if (environmentId != null) {
|
return ResponseEntity.ok(appService.listByEnvironment(env.id()));
|
||||||
return ResponseEntity.ok(appService.listByEnvironment(environmentId));
|
|
||||||
}
|
|
||||||
return ResponseEntity.ok(appService.listAll());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{appSlug}")
|
@GetMapping("/{appSlug}")
|
||||||
@Operation(summary = "Get app by slug")
|
@Operation(summary = "Get app by env + slug")
|
||||||
@ApiResponse(responseCode = "200", description = "App found")
|
@ApiResponse(responseCode = "200", description = "App found")
|
||||||
@ApiResponse(responseCode = "404", description = "App not found")
|
@ApiResponse(responseCode = "404", description = "App not found in this environment")
|
||||||
public ResponseEntity<App> getApp(@PathVariable String appSlug) {
|
public ResponseEntity<App> getApp(@EnvPath Environment env, @PathVariable String appSlug) {
|
||||||
try {
|
try {
|
||||||
return ResponseEntity.ok(appService.getBySlug(appSlug));
|
return ResponseEntity.ok(appService.getByEnvironmentAndSlug(env.id(), appSlug));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@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 = "201", description = "App created")
|
||||||
@ApiResponse(responseCode = "400", description = "Slug already exists in environment")
|
@ApiResponse(responseCode = "400", description = "Invalid slug, or slug already exists in this environment")
|
||||||
public ResponseEntity<App> createApp(@RequestBody CreateAppRequest request) {
|
public ResponseEntity<?> createApp(@EnvPath Environment env, @RequestBody CreateAppRequest request) {
|
||||||
try {
|
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));
|
return ResponseEntity.status(201).body(appService.getById(id));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{appSlug}/versions")
|
@GetMapping("/{appSlug}/versions")
|
||||||
@Operation(summary = "List app versions")
|
@Operation(summary = "List versions for this app")
|
||||||
@ApiResponse(responseCode = "200", description = "Version list returned")
|
@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 {
|
try {
|
||||||
App app = appService.getBySlug(appSlug);
|
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||||
return ResponseEntity.ok(appService.listVersions(app.id()));
|
return ResponseEntity.ok(appService.listVersions(app.id()));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
@@ -91,13 +92,14 @@ public class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping(value = "/{appSlug}/versions", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
@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 = "201", description = "JAR uploaded and version created")
|
||||||
@ApiResponse(responseCode = "404", description = "App not found")
|
@ApiResponse(responseCode = "404", description = "App not found in this environment")
|
||||||
public ResponseEntity<AppVersion> uploadJar(@PathVariable String appSlug,
|
public ResponseEntity<AppVersion> uploadJar(@EnvPath Environment env,
|
||||||
|
@PathVariable String appSlug,
|
||||||
@RequestParam("file") MultipartFile file) throws IOException {
|
@RequestParam("file") MultipartFile file) throws IOException {
|
||||||
try {
|
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());
|
AppVersion version = appService.uploadJar(app.id(), file.getOriginalFilename(), file.getInputStream(), file.getSize());
|
||||||
return ResponseEntity.status(201).body(version);
|
return ResponseEntity.status(201).body(version);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
@@ -106,11 +108,11 @@ public class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{appSlug}")
|
@DeleteMapping("/{appSlug}")
|
||||||
@Operation(summary = "Delete an app")
|
@Operation(summary = "Delete this app")
|
||||||
@ApiResponse(responseCode = "204", description = "App deleted")
|
@ApiResponse(responseCode = "204", description = "App deleted")
|
||||||
public ResponseEntity<Void> deleteApp(@PathVariable String appSlug) {
|
public ResponseEntity<Void> deleteApp(@EnvPath Environment env, @PathVariable String appSlug) {
|
||||||
try {
|
try {
|
||||||
App app = appService.getBySlug(appSlug);
|
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||||
appService.deleteApp(app.id());
|
appService.deleteApp(app.id());
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
@@ -134,24 +136,25 @@ public class AppController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{appSlug}/container-config")
|
@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 = "200", description = "Container config updated")
|
||||||
@ApiResponse(responseCode = "400", description = "Invalid configuration")
|
@ApiResponse(responseCode = "400", description = "Invalid configuration")
|
||||||
@ApiResponse(responseCode = "404", description = "App not found")
|
@ApiResponse(responseCode = "404", description = "App not found in this environment")
|
||||||
public ResponseEntity<App> updateContainerConfig(@PathVariable String appSlug,
|
public ResponseEntity<?> updateContainerConfig(@EnvPath Environment env,
|
||||||
@RequestBody Map<String, Object> containerConfig) {
|
@PathVariable String appSlug,
|
||||||
|
@RequestBody Map<String, Object> containerConfig) {
|
||||||
try {
|
try {
|
||||||
validateContainerConfig(containerConfig);
|
validateContainerConfig(containerConfig);
|
||||||
App app = appService.getBySlug(appSlug);
|
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||||
appService.updateContainerConfig(app.id(), containerConfig);
|
appService.updateContainerConfig(app.id(), containerConfig);
|
||||||
return ResponseEntity.ok(appService.getById(app.id()));
|
return ResponseEntity.ok(appService.getById(app.id()));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
if (e.getMessage().contains("not found")) {
|
if (e.getMessage().contains("not found")) {
|
||||||
return ResponseEntity.notFound().build();
|
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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package com.cameleer.server.app.controller;
|
package com.cameleer.server.app.controller;
|
||||||
|
|
||||||
import com.cameleer.server.app.dto.AppSettingsRequest;
|
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.AppSettings;
|
||||||
import com.cameleer.server.core.admin.AppSettingsRepository;
|
import com.cameleer.server.core.admin.AppSettingsRepository;
|
||||||
import com.cameleer.server.core.admin.AuditCategory;
|
import com.cameleer.server.core.admin.AuditCategory;
|
||||||
import com.cameleer.server.core.admin.AuditResult;
|
import com.cameleer.server.core.admin.AuditResult;
|
||||||
import com.cameleer.server.core.admin.AuditService;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
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.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
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.bind.annotation.RestController;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/admin/app-settings")
|
@RequestMapping("/api/v1/environments/{envSlug}")
|
||||||
@PreAuthorize("hasAnyRole('ADMIN', 'OPERATOR')")
|
@PreAuthorize("hasAnyRole('ADMIN', 'OPERATOR')")
|
||||||
@Tag(name = "App Settings", description = "Per-application dashboard settings (ADMIN/OPERATOR)")
|
@Tag(name = "App Settings", description = "Per-application dashboard settings (ADMIN/OPERATOR)")
|
||||||
public class AppSettingsController {
|
public class AppSettingsController {
|
||||||
@@ -40,25 +41,25 @@ public class AppSettingsController {
|
|||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping("/app-settings")
|
||||||
@Operation(summary = "List application settings in an environment")
|
@Operation(summary = "List application settings in this environment")
|
||||||
public ResponseEntity<List<AppSettings>> getAll(@RequestParam String environment) {
|
public ResponseEntity<List<AppSettings>> getAll(@EnvPath Environment env) {
|
||||||
return ResponseEntity.ok(repository.findByEnvironment(environment));
|
return ResponseEntity.ok(repository.findByEnvironment(env.slug()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{appId}")
|
@GetMapping("/apps/{appSlug}/settings")
|
||||||
@Operation(summary = "Get settings for an application in an environment (returns defaults if not configured)")
|
@Operation(summary = "Get settings for an application in this environment (returns defaults if not configured)")
|
||||||
public ResponseEntity<AppSettings> getByAppId(@PathVariable String appId,
|
public ResponseEntity<AppSettings> getByAppId(@EnvPath Environment env,
|
||||||
@RequestParam String environment) {
|
@PathVariable String appSlug) {
|
||||||
AppSettings settings = repository.findByApplicationAndEnvironment(appId, environment)
|
AppSettings settings = repository.findByApplicationAndEnvironment(appSlug, env.slug())
|
||||||
.orElse(AppSettings.defaults(appId, environment));
|
.orElse(AppSettings.defaults(appSlug, env.slug()));
|
||||||
return ResponseEntity.ok(settings);
|
return ResponseEntity.ok(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{appId}")
|
@PutMapping("/apps/{appSlug}/settings")
|
||||||
@Operation(summary = "Create or update settings for an application in an environment")
|
@Operation(summary = "Create or update settings for an application in this environment")
|
||||||
public ResponseEntity<AppSettings> update(@PathVariable String appId,
|
public ResponseEntity<AppSettings> update(@EnvPath Environment env,
|
||||||
@RequestParam String environment,
|
@PathVariable String appSlug,
|
||||||
@Valid @RequestBody AppSettingsRequest request,
|
@Valid @RequestBody AppSettingsRequest request,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
List<String> errors = request.validate();
|
List<String> errors = request.validate();
|
||||||
@@ -66,20 +67,20 @@ public class AppSettingsController {
|
|||||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors));
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, String.join("; ", errors));
|
||||||
}
|
}
|
||||||
|
|
||||||
AppSettings saved = repository.save(request.toSettings(appId, environment));
|
AppSettings saved = repository.save(request.toSettings(appSlug, env.slug()));
|
||||||
auditService.log("update_app_settings", AuditCategory.CONFIG, appId,
|
auditService.log("update_app_settings", AuditCategory.CONFIG, appSlug,
|
||||||
Map.of("environment", environment, "settings", saved), AuditResult.SUCCESS, httpRequest);
|
Map.of("environment", env.slug(), "settings", saved), AuditResult.SUCCESS, httpRequest);
|
||||||
return ResponseEntity.ok(saved);
|
return ResponseEntity.ok(saved);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{appId}")
|
@DeleteMapping("/apps/{appSlug}/settings")
|
||||||
@Operation(summary = "Delete application settings for an environment (reverts to defaults)")
|
@Operation(summary = "Delete application settings for this environment (reverts to defaults)")
|
||||||
public ResponseEntity<Void> delete(@PathVariable String appId,
|
public ResponseEntity<Void> delete(@EnvPath Environment env,
|
||||||
@RequestParam String environment,
|
@PathVariable String appSlug,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
repository.delete(appId, environment);
|
repository.delete(appSlug, env.slug());
|
||||||
auditService.log("delete_app_settings", AuditCategory.CONFIG, appId,
|
auditService.log("delete_app_settings", AuditCategory.CONFIG, appSlug,
|
||||||
Map.of("environment", environment), AuditResult.SUCCESS, httpRequest);
|
Map.of("environment", env.slug()), AuditResult.SUCCESS, httpRequest);
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ import com.cameleer.server.app.dto.CommandGroupResponse;
|
|||||||
import com.cameleer.server.app.dto.ConfigUpdateResponse;
|
import com.cameleer.server.app.dto.ConfigUpdateResponse;
|
||||||
import com.cameleer.server.app.dto.TestExpressionRequest;
|
import com.cameleer.server.app.dto.TestExpressionRequest;
|
||||||
import com.cameleer.server.app.dto.TestExpressionResponse;
|
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.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.AuditCategory;
|
||||||
import com.cameleer.server.core.admin.AuditResult;
|
import com.cameleer.server.core.admin.AuditResult;
|
||||||
import com.cameleer.server.core.admin.AuditService;
|
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.AgentState;
|
||||||
import com.cameleer.server.core.agent.CommandReply;
|
import com.cameleer.server.core.agent.CommandReply;
|
||||||
import com.cameleer.server.core.agent.CommandType;
|
import com.cameleer.server.core.agent.CommandType;
|
||||||
|
import com.cameleer.server.core.runtime.Environment;
|
||||||
import com.cameleer.server.core.storage.DiagramStore;
|
import com.cameleer.server.core.storage.DiagramStore;
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
@@ -43,12 +43,13 @@ import java.util.concurrent.TimeUnit;
|
|||||||
import java.util.concurrent.TimeoutException;
|
import java.util.concurrent.TimeoutException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-application configuration management.
|
* Per-application configuration for UI/admin callers. Env comes from the path,
|
||||||
* Agents fetch config at startup; the UI modifies config which is persisted and pushed to agents via SSE.
|
* app comes from the path. Agents use {@link AgentConfigController} instead —
|
||||||
|
* env is derived from the JWT there, not spoofable via URL.
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/config")
|
@RequestMapping("/api/v1/environments/{envSlug}")
|
||||||
@Tag(name = "Application Config", description = "Per-application observability configuration")
|
@Tag(name = "Application Config", description = "Per-application observability configuration (user-facing)")
|
||||||
public class ApplicationConfigController {
|
public class ApplicationConfigController {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(ApplicationConfigController.class);
|
private static final Logger log = LoggerFactory.getLogger(ApplicationConfigController.class);
|
||||||
@@ -74,39 +75,28 @@ public class ApplicationConfigController {
|
|||||||
this.sensitiveKeysRepository = sensitiveKeysRepository;
|
this.sensitiveKeysRepository = sensitiveKeysRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping("/config")
|
||||||
@Operation(summary = "List application configs in an environment",
|
@Operation(summary = "List application configs in this environment")
|
||||||
description = "Returns stored configurations for all applications in the given environment")
|
|
||||||
@ApiResponse(responseCode = "200", description = "Configs returned")
|
@ApiResponse(responseCode = "200", description = "Configs returned")
|
||||||
public ResponseEntity<List<ApplicationConfig>> listConfigs(@RequestParam String environment,
|
public ResponseEntity<List<ApplicationConfig>> listConfigs(@EnvPath Environment env,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
auditService.log("view_app_configs", AuditCategory.CONFIG, null,
|
auditService.log("view_app_configs", AuditCategory.CONFIG, null,
|
||||||
Map.of("environment", environment), AuditResult.SUCCESS, httpRequest);
|
Map.of("environment", env.slug()), AuditResult.SUCCESS, httpRequest);
|
||||||
return ResponseEntity.ok(configRepository.findByEnvironment(environment));
|
return ResponseEntity.ok(configRepository.findByEnvironment(env.slug()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{application}")
|
@GetMapping("/apps/{appSlug}/config")
|
||||||
@Operation(summary = "Get application config for an environment",
|
@Operation(summary = "Get application config for this environment",
|
||||||
description = "For agents: environment is taken from the JWT env claim; the query param is ignored. "
|
description = "Returns stored config merged with global sensitive keys. "
|
||||||
+ "For UI/admin callers: environment must be provided via the `environment` query param. "
|
+ "Falls back to defaults if no row is persisted yet.")
|
||||||
+ "Returns 404 if the environment cannot be resolved. Includes merged sensitive keys.")
|
|
||||||
@ApiResponse(responseCode = "200", description = "Config returned")
|
@ApiResponse(responseCode = "200", description = "Config returned")
|
||||||
@ApiResponse(responseCode = "404", description = "Environment could not be resolved")
|
public ResponseEntity<AppConfigResponse> getConfig(@EnvPath Environment env,
|
||||||
public ResponseEntity<AppConfigResponse> getConfig(@PathVariable String application,
|
@PathVariable String appSlug,
|
||||||
@RequestParam(required = false) String environment,
|
|
||||||
Authentication auth,
|
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
String resolved = resolveEnvironmentForRead(auth, httpRequest, environment);
|
auditService.log("view_app_config", AuditCategory.CONFIG, appSlug,
|
||||||
if (resolved == null || resolved.isBlank()) {
|
Map.of("environment", env.slug()), AuditResult.SUCCESS, httpRequest);
|
||||||
auditService.log("view_app_config", AuditCategory.CONFIG, application,
|
ApplicationConfig config = configRepository.findByApplicationAndEnvironment(appSlug, env.slug())
|
||||||
Map.of("reason", "missing_environment"), AuditResult.FAILURE, httpRequest);
|
.orElse(defaultConfig(appSlug, env.slug()));
|
||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
auditService.log("view_app_config", AuditCategory.CONFIG, application,
|
|
||||||
Map.of("environment", resolved), AuditResult.SUCCESS, httpRequest);
|
|
||||||
ApplicationConfig config = configRepository.findByApplicationAndEnvironment(application, resolved)
|
|
||||||
.orElse(defaultConfig(application, resolved));
|
|
||||||
|
|
||||||
List<String> globalKeys = sensitiveKeysRepository.find()
|
List<String> globalKeys = sensitiveKeysRepository.find()
|
||||||
.map(SensitiveKeysConfig::keys)
|
.map(SensitiveKeysConfig::keys)
|
||||||
@@ -116,51 +106,32 @@ public class ApplicationConfigController {
|
|||||||
return ResponseEntity.ok(new AppConfigResponse(config, globalKeys, merged));
|
return ResponseEntity.ok(new AppConfigResponse(config, globalKeys, merged));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@PutMapping("/apps/{appSlug}/config")
|
||||||
* Agents identify themselves via AGENT role and a real JWT env claim — use that,
|
@Operation(summary = "Update application config for this environment",
|
||||||
* 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",
|
|
||||||
description = "Saves config and pushes CONFIG_UPDATE to LIVE agents of this application in the given 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")
|
@ApiResponse(responseCode = "200", description = "Config saved and pushed")
|
||||||
public ResponseEntity<ConfigUpdateResponse> updateConfig(@PathVariable String application,
|
public ResponseEntity<ConfigUpdateResponse> updateConfig(@EnvPath Environment env,
|
||||||
@RequestParam String environment,
|
@PathVariable String appSlug,
|
||||||
@RequestBody ApplicationConfig config,
|
@RequestBody ApplicationConfig config,
|
||||||
Authentication auth,
|
Authentication auth,
|
||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
String updatedBy = auth != null ? auth.getName() : "system";
|
String updatedBy = auth != null ? auth.getName() : "system";
|
||||||
|
|
||||||
config.setApplication(application);
|
config.setApplication(appSlug);
|
||||||
ApplicationConfig saved = configRepository.save(application, environment, config, updatedBy);
|
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()
|
List<String> globalKeys = sensitiveKeysRepository.find()
|
||||||
.map(SensitiveKeysConfig::keys)
|
.map(SensitiveKeysConfig::keys)
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
List<String> perAppKeys = extractSensitiveKeys(saved);
|
List<String> perAppKeys = extractSensitiveKeys(saved);
|
||||||
List<String> mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys);
|
List<String> mergedKeys = SensitiveKeysMerger.merge(globalKeys, perAppKeys);
|
||||||
|
|
||||||
// Push with merged sensitive keys injected into the payload
|
CommandGroupResponse pushResult = pushConfigToAgentsWithMergedKeys(appSlug, env.slug(), saved, mergedKeys);
|
||||||
CommandGroupResponse pushResult = pushConfigToAgentsWithMergedKeys(application, environment, saved, mergedKeys);
|
|
||||||
log.info("Config v{} saved for '{}', pushed to {} agent(s), {} responded",
|
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,
|
auditService.log("update_app_config", AuditCategory.CONFIG, appSlug,
|
||||||
Map.of("environment", environment, "version", saved.getVersion(),
|
Map.of("environment", env.slug(), "version", saved.getVersion(),
|
||||||
"agentsPushed", pushResult.total(),
|
"agentsPushed", pushResult.total(),
|
||||||
"responded", pushResult.responded(), "timedOut", pushResult.timedOut().size()),
|
"responded", pushResult.responded(), "timedOut", pushResult.timedOut().size()),
|
||||||
AuditResult.SUCCESS, httpRequest);
|
AuditResult.SUCCESS, httpRequest);
|
||||||
@@ -168,35 +139,34 @@ public class ApplicationConfigController {
|
|||||||
return ResponseEntity.ok(new ConfigUpdateResponse(saved, pushResult));
|
return ResponseEntity.ok(new ConfigUpdateResponse(saved, pushResult));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{application}/processor-routes")
|
@GetMapping("/apps/{appSlug}/processor-routes")
|
||||||
@Operation(summary = "Get processor to route mapping for an environment",
|
@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")
|
description = "Returns a map of processorId → routeId for all processors seen in this application + environment")
|
||||||
@ApiResponse(responseCode = "200", description = "Mapping returned")
|
@ApiResponse(responseCode = "200", description = "Mapping returned")
|
||||||
public ResponseEntity<Map<String, String>> getProcessorRouteMapping(@PathVariable String application,
|
public ResponseEntity<Map<String, String>> getProcessorRouteMapping(@EnvPath Environment env,
|
||||||
@RequestParam String environment) {
|
@PathVariable String appSlug) {
|
||||||
return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(application, environment));
|
return ResponseEntity.ok(diagramStore.findProcessorRouteMapping(appSlug, env.slug()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{application}/test-expression")
|
@PostMapping("/apps/{appSlug}/config/test-expression")
|
||||||
@Operation(summary = "Test a tap expression against sample data via a live agent in an environment")
|
@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 = "200", description = "Expression evaluated successfully")
|
||||||
@ApiResponse(responseCode = "404", description = "No live agent available for this application in this environment")
|
@ApiResponse(responseCode = "404", description = "No live agent available for this application in this environment")
|
||||||
@ApiResponse(responseCode = "504", description = "Agent did not respond in time")
|
@ApiResponse(responseCode = "504", description = "Agent did not respond in time")
|
||||||
public ResponseEntity<TestExpressionResponse> testExpression(
|
public ResponseEntity<TestExpressionResponse> testExpression(
|
||||||
@PathVariable String application,
|
@EnvPath Environment env,
|
||||||
@RequestParam String environment,
|
@PathVariable String appSlug,
|
||||||
@RequestBody TestExpressionRequest request) {
|
@RequestBody TestExpressionRequest request) {
|
||||||
AgentInfo agent = registryService.findByApplicationAndEnvironment(application, environment).stream()
|
AgentInfo agent = registryService.findByApplicationAndEnvironment(appSlug, env.slug()).stream()
|
||||||
.filter(a -> a.state() == AgentState.LIVE)
|
.filter(a -> a.state() == AgentState.LIVE)
|
||||||
.findFirst()
|
.findFirst()
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
|
|
||||||
if (agent == null) {
|
if (agent == null) {
|
||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
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;
|
String payloadJson;
|
||||||
try {
|
try {
|
||||||
payloadJson = objectMapper.writeValueAsString(Map.of(
|
payloadJson = objectMapper.writeValueAsString(Map.of(
|
||||||
@@ -211,7 +181,6 @@ public class ApplicationConfigController {
|
|||||||
.body(new TestExpressionResponse(null, "Failed to serialize request"));
|
.body(new TestExpressionResponse(null, "Failed to serialize request"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send command and await reply
|
|
||||||
CompletableFuture<CommandReply> future = registryService.addCommandWithReply(
|
CompletableFuture<CommandReply> future = registryService.addCommandWithReply(
|
||||||
agent.instanceId(), CommandType.TEST_EXPRESSION, payloadJson);
|
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) {
|
private List<String> extractSensitiveKeys(ApplicationConfig config) {
|
||||||
try {
|
try {
|
||||||
com.fasterxml.jackson.databind.JsonNode node = objectMapper.valueToTree(config);
|
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,
|
private CommandGroupResponse pushConfigToAgentsWithMergedKeys(String application, String environment,
|
||||||
ApplicationConfig config, List<String> mergedKeys) {
|
ApplicationConfig config, List<String> mergedKeys) {
|
||||||
String payloadJson;
|
String payloadJson;
|
||||||
try {
|
try {
|
||||||
// Serialize config to a mutable map, inject merged keys
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Map<String, Object> configMap = objectMapper.convertValue(config, Map.class);
|
Map<String, Object> configMap = objectMapper.convertValue(config, Map.class);
|
||||||
configMap.put("sensitiveKeys", mergedKeys);
|
configMap.put("sensitiveKeys", mergedKeys);
|
||||||
@@ -316,7 +264,7 @@ public class ApplicationConfigController {
|
|||||||
return new CommandGroupResponse(allSuccess, futures.size(), responses.size(), responses, timedOut);
|
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();
|
ApplicationConfig config = new ApplicationConfig();
|
||||||
config.setApplication(application);
|
config.setApplication(application);
|
||||||
config.setEnvironment(environment);
|
config.setEnvironment(environment);
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
package com.cameleer.server.app.controller;
|
package com.cameleer.server.app.controller;
|
||||||
|
|
||||||
import com.cameleer.server.app.runtime.DeploymentExecutor;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
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 org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deployment management: deploy, stop, promote, and view logs.
|
* Deployment management. Env + app come from the URL. Promote is inherently
|
||||||
* All app-scoped endpoints accept the app slug (not UUID) as path variable.
|
* cross-env, so the target environment stays explicit in the request body
|
||||||
* Protected by {@code ROLE_OPERATOR} or {@code ROLE_ADMIN}.
|
* (as a slug).
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/apps/{appSlug}/deployments")
|
@RequestMapping("/api/v1/environments/{envSlug}/apps/{appSlug}/deployments")
|
||||||
@Tag(name = "Deployment Management", description = "Deploy, stop, restart, promote, and view logs")
|
@Tag(name = "Deployment Management", description = "Deploy, stop, promote, and view logs")
|
||||||
@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")
|
@PreAuthorize("hasAnyRole('OPERATOR', 'ADMIN')")
|
||||||
public class DeploymentController {
|
public class DeploymentController {
|
||||||
|
|
||||||
@@ -33,23 +41,26 @@ public class DeploymentController {
|
|||||||
private final DeploymentExecutor deploymentExecutor;
|
private final DeploymentExecutor deploymentExecutor;
|
||||||
private final RuntimeOrchestrator orchestrator;
|
private final RuntimeOrchestrator orchestrator;
|
||||||
private final AppService appService;
|
private final AppService appService;
|
||||||
|
private final EnvironmentService environmentService;
|
||||||
|
|
||||||
public DeploymentController(DeploymentService deploymentService,
|
public DeploymentController(DeploymentService deploymentService,
|
||||||
DeploymentExecutor deploymentExecutor,
|
DeploymentExecutor deploymentExecutor,
|
||||||
RuntimeOrchestrator orchestrator,
|
RuntimeOrchestrator orchestrator,
|
||||||
AppService appService) {
|
AppService appService,
|
||||||
|
EnvironmentService environmentService) {
|
||||||
this.deploymentService = deploymentService;
|
this.deploymentService = deploymentService;
|
||||||
this.deploymentExecutor = deploymentExecutor;
|
this.deploymentExecutor = deploymentExecutor;
|
||||||
this.orchestrator = orchestrator;
|
this.orchestrator = orchestrator;
|
||||||
this.appService = appService;
|
this.appService = appService;
|
||||||
|
this.environmentService = environmentService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@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")
|
@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 {
|
try {
|
||||||
App app = appService.getBySlug(appSlug);
|
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||||
return ResponseEntity.ok(deploymentService.listByApp(app.id()));
|
return ResponseEntity.ok(deploymentService.listByApp(app.id()));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
@@ -60,7 +71,9 @@ public class DeploymentController {
|
|||||||
@Operation(summary = "Get deployment by ID")
|
@Operation(summary = "Get deployment by ID")
|
||||||
@ApiResponse(responseCode = "200", description = "Deployment found")
|
@ApiResponse(responseCode = "200", description = "Deployment found")
|
||||||
@ApiResponse(responseCode = "404", description = "Deployment not 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 {
|
try {
|
||||||
return ResponseEntity.ok(deploymentService.getById(deploymentId));
|
return ResponseEntity.ok(deploymentService.getById(deploymentId));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
@@ -69,12 +82,14 @@ public class DeploymentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@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")
|
@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 {
|
try {
|
||||||
App app = appService.getBySlug(appSlug);
|
App app = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||||
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), request.environmentId());
|
Deployment deployment = deploymentService.createDeployment(app.id(), request.appVersionId(), env.id());
|
||||||
deploymentExecutor.executeAsync(deployment);
|
deploymentExecutor.executeAsync(deployment);
|
||||||
return ResponseEntity.accepted().body(deployment);
|
return ResponseEntity.accepted().body(deployment);
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
@@ -86,7 +101,9 @@ public class DeploymentController {
|
|||||||
@Operation(summary = "Stop a running deployment")
|
@Operation(summary = "Stop a running deployment")
|
||||||
@ApiResponse(responseCode = "200", description = "Deployment stopped")
|
@ApiResponse(responseCode = "200", description = "Deployment stopped")
|
||||||
@ApiResponse(responseCode = "404", description = "Deployment not found")
|
@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 {
|
try {
|
||||||
Deployment deployment = deploymentService.getById(deploymentId);
|
Deployment deployment = deploymentService.getById(deploymentId);
|
||||||
deploymentExecutor.stopDeployment(deployment);
|
deploymentExecutor.stopDeployment(deployment);
|
||||||
@@ -97,27 +114,37 @@ public class DeploymentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/{deploymentId}/promote")
|
@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 = "202", description = "Promotion accepted and starting")
|
||||||
@ApiResponse(responseCode = "404", description = "Deployment not found")
|
@ApiResponse(responseCode = "404", description = "Deployment or target environment not found")
|
||||||
public ResponseEntity<Deployment> promote(@PathVariable String appSlug, @PathVariable UUID deploymentId,
|
public ResponseEntity<?> promote(@EnvPath Environment env,
|
||||||
@RequestBody PromoteRequest request) {
|
@PathVariable String appSlug,
|
||||||
|
@PathVariable UUID deploymentId,
|
||||||
|
@RequestBody PromoteRequest request) {
|
||||||
try {
|
try {
|
||||||
App app = appService.getBySlug(appSlug);
|
App sourceApp = appService.getByEnvironmentAndSlug(env.id(), appSlug);
|
||||||
Deployment source = deploymentService.getById(deploymentId);
|
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);
|
deploymentExecutor.executeAsync(promoted);
|
||||||
return ResponseEntity.accepted().body(promoted);
|
return ResponseEntity.accepted().body(promoted);
|
||||||
} catch (IllegalArgumentException e) {
|
} 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")
|
@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 = "200", description = "Logs returned")
|
||||||
@ApiResponse(responseCode = "404", description = "Deployment not found or no container")
|
@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 {
|
try {
|
||||||
Deployment deployment = deploymentService.getById(deploymentId);
|
Deployment deployment = deploymentService.getById(deploymentId);
|
||||||
if (deployment.containerId() == null) {
|
if (deployment.containerId() == null) {
|
||||||
@@ -130,6 +157,6 @@ public class DeploymentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record DeployRequest(UUID appVersionId, UUID environmentId) {}
|
public record DeployRequest(UUID appVersionId) {}
|
||||||
public record PromoteRequest(UUID targetEnvironmentId) {}
|
public record PromoteRequest(String targetEnvironment) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package com.cameleer.server.app.controller;
|
package com.cameleer.server.app.controller;
|
||||||
|
|
||||||
import com.cameleer.common.graph.RouteGraph;
|
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.AgentInfo;
|
||||||
import com.cameleer.server.core.agent.AgentRegistryService;
|
import com.cameleer.server.core.agent.AgentRegistryService;
|
||||||
import com.cameleer.server.core.diagram.DiagramLayout;
|
import com.cameleer.server.core.diagram.DiagramLayout;
|
||||||
import com.cameleer.server.core.diagram.DiagramRenderer;
|
import com.cameleer.server.core.diagram.DiagramRenderer;
|
||||||
|
import com.cameleer.server.core.runtime.Environment;
|
||||||
import com.cameleer.server.core.storage.DiagramStore;
|
import com.cameleer.server.core.storage.DiagramStore;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.media.Content;
|
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.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
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.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
@@ -24,16 +25,16 @@ import java.util.List;
|
|||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* REST endpoint for rendering route diagrams.
|
* Diagram rendering and lookup.
|
||||||
* <p>
|
* <p>
|
||||||
* Supports content negotiation via Accept header:
|
* Content-addressed rendering stays flat at /api/v1/diagrams/{contentHash}/render:
|
||||||
* <ul>
|
* the hash is globally unique, permalinks are valuable, and no env partitioning
|
||||||
* <li>{@code image/svg+xml} or default: returns SVG document</li>
|
* is possible or needed.
|
||||||
* <li>{@code application/json}: returns JSON layout with node positions</li>
|
* <p>
|
||||||
* </ul>
|
* By-app-and-route lookup is env-scoped at
|
||||||
|
* /api/v1/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram.
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/diagrams")
|
|
||||||
@Tag(name = "Diagrams", description = "Diagram rendering endpoints")
|
@Tag(name = "Diagrams", description = "Diagram rendering endpoints")
|
||||||
public class DiagramRenderController {
|
public class DiagramRenderController {
|
||||||
|
|
||||||
@@ -51,9 +52,10 @@ public class DiagramRenderController {
|
|||||||
this.registryService = registryService;
|
this.registryService = registryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{contentHash}/render")
|
@GetMapping("/api/v1/diagrams/{contentHash}/render")
|
||||||
@Operation(summary = "Render a route diagram",
|
@Operation(summary = "Render a route diagram by content hash",
|
||||||
description = "Returns SVG (default) or JSON layout based on Accept header")
|
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",
|
@ApiResponse(responseCode = "200", description = "Diagram rendered successfully",
|
||||||
content = {
|
content = {
|
||||||
@Content(mediaType = "image/svg+xml", schema = @Schema(type = "string")),
|
@Content(mediaType = "image/svg+xml", schema = @Schema(type = "string")),
|
||||||
@@ -73,9 +75,6 @@ public class DiagramRenderController {
|
|||||||
RouteGraph graph = graphOpt.get();
|
RouteGraph graph = graphOpt.get();
|
||||||
String accept = request.getHeader("Accept");
|
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)) {
|
if (accept != null && isJsonPreferred(accept)) {
|
||||||
DiagramLayout layout = diagramRenderer.layoutJson(graph, direction);
|
DiagramLayout layout = diagramRenderer.layoutJson(graph, direction);
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
@@ -83,23 +82,24 @@ public class DiagramRenderController {
|
|||||||
.body(layout);
|
.body(layout);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default to SVG for image/svg+xml, */* or no Accept header
|
|
||||||
String svg = diagramRenderer.renderSvg(graph);
|
String svg = diagramRenderer.renderSvg(graph);
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.contentType(SVG_MEDIA_TYPE)
|
.contentType(SVG_MEDIA_TYPE)
|
||||||
.body(svg);
|
.body(svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping("/api/v1/environments/{envSlug}/apps/{appSlug}/routes/{routeId}/diagram")
|
||||||
@Operation(summary = "Find diagram by application and route ID",
|
@Operation(summary = "Find the latest diagram for this app's route in this environment",
|
||||||
description = "Resolves application to agent IDs and finds the latest diagram for the route")
|
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 = "200", description = "Diagram layout returned")
|
||||||
@ApiResponse(responseCode = "404", description = "No diagram found for the given application and route")
|
@ApiResponse(responseCode = "404", description = "No diagram found")
|
||||||
public ResponseEntity<DiagramLayout> findByApplicationAndRoute(
|
public ResponseEntity<DiagramLayout> findByAppAndRoute(
|
||||||
@RequestParam String application,
|
@EnvPath Environment env,
|
||||||
@RequestParam String routeId,
|
@PathVariable String appSlug,
|
||||||
|
@PathVariable String routeId,
|
||||||
@RequestParam(defaultValue = "LR") String direction) {
|
@RequestParam(defaultValue = "LR") String direction) {
|
||||||
List<String> agentIds = registryService.findByApplication(application).stream()
|
List<String> agentIds = registryService.findByApplicationAndEnvironment(appSlug, env.slug()).stream()
|
||||||
.map(AgentInfo::instanceId)
|
.map(AgentInfo::instanceId)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
@@ -121,14 +121,6 @@ public class DiagramRenderController {
|
|||||||
return ResponseEntity.ok(layout);
|
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, */*" are treated as unspecific
|
|
||||||
* and receive the SVG default.
|
|
||||||
*/
|
|
||||||
private boolean isJsonPreferred(String accept) {
|
private boolean isJsonPreferred(String accept) {
|
||||||
String[] parts = accept.split(",");
|
String[] parts = accept.split(",");
|
||||||
if (parts.length == 0) return false;
|
if (parts.length == 0) return false;
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/admin/environments")
|
@RequestMapping("/api/v1/admin/environments")
|
||||||
@@ -33,13 +32,13 @@ public class EnvironmentAdminController {
|
|||||||
return ResponseEntity.ok(environmentService.listAll());
|
return ResponseEntity.ok(environmentService.listAll());
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{envSlug}")
|
||||||
@Operation(summary = "Get environment by ID")
|
@Operation(summary = "Get environment by slug")
|
||||||
@ApiResponse(responseCode = "200", description = "Environment found")
|
@ApiResponse(responseCode = "200", description = "Environment found")
|
||||||
@ApiResponse(responseCode = "404", description = "Environment not found")
|
@ApiResponse(responseCode = "404", description = "Environment not found")
|
||||||
public ResponseEntity<Environment> getEnvironment(@PathVariable UUID id) {
|
public ResponseEntity<Environment> getEnvironment(@PathVariable String envSlug) {
|
||||||
try {
|
try {
|
||||||
return ResponseEntity.ok(environmentService.getById(id));
|
return ResponseEntity.ok(environmentService.getBySlug(envSlug));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
@@ -48,24 +47,28 @@ public class EnvironmentAdminController {
|
|||||||
@PostMapping
|
@PostMapping
|
||||||
@Operation(summary = "Create a new environment")
|
@Operation(summary = "Create a new environment")
|
||||||
@ApiResponse(responseCode = "201", description = "Environment created")
|
@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) {
|
public ResponseEntity<?> createEnvironment(@RequestBody CreateEnvironmentRequest request) {
|
||||||
try {
|
try {
|
||||||
UUID id = environmentService.create(request.slug(), request.displayName(), request.production());
|
environmentService.create(request.slug(), request.displayName(), request.production());
|
||||||
return ResponseEntity.status(201).body(environmentService.getById(id));
|
return ResponseEntity.status(201).body(environmentService.getBySlug(request.slug()));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}")
|
@PutMapping("/{envSlug}")
|
||||||
@Operation(summary = "Update an environment")
|
@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 = "200", description = "Environment updated")
|
||||||
@ApiResponse(responseCode = "404", description = "Environment not found")
|
@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 {
|
try {
|
||||||
environmentService.update(id, request.displayName(), request.production(), request.enabled());
|
Environment current = environmentService.getBySlug(envSlug);
|
||||||
return ResponseEntity.ok(environmentService.getById(id));
|
environmentService.update(current.id(), request.displayName(), request.production(), request.enabled());
|
||||||
|
return ResponseEntity.ok(environmentService.getBySlug(envSlug));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
if (e.getMessage().contains("not found")) {
|
if (e.getMessage().contains("not found")) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
@@ -74,14 +77,15 @@ public class EnvironmentAdminController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@DeleteMapping("/{id}")
|
@DeleteMapping("/{envSlug}")
|
||||||
@Operation(summary = "Delete an environment")
|
@Operation(summary = "Delete an environment")
|
||||||
@ApiResponse(responseCode = "204", description = "Environment deleted")
|
@ApiResponse(responseCode = "204", description = "Environment deleted")
|
||||||
@ApiResponse(responseCode = "400", description = "Cannot delete default environment")
|
@ApiResponse(responseCode = "400", description = "Cannot delete default environment")
|
||||||
@ApiResponse(responseCode = "404", description = "Environment not found")
|
@ApiResponse(responseCode = "404", description = "Environment not found")
|
||||||
public ResponseEntity<?> deleteEnvironment(@PathVariable UUID id) {
|
public ResponseEntity<?> deleteEnvironment(@PathVariable String envSlug) {
|
||||||
try {
|
try {
|
||||||
environmentService.delete(id);
|
Environment current = environmentService.getBySlug(envSlug);
|
||||||
|
environmentService.delete(current.id());
|
||||||
return ResponseEntity.noContent().build();
|
return ResponseEntity.noContent().build();
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
if (e.getMessage().contains("not found")) {
|
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")
|
@Operation(summary = "Update default container config for an environment")
|
||||||
@ApiResponse(responseCode = "200", description = "Default container config updated")
|
@ApiResponse(responseCode = "200", description = "Default container config updated")
|
||||||
@ApiResponse(responseCode = "400", description = "Invalid configuration")
|
@ApiResponse(responseCode = "400", description = "Invalid configuration")
|
||||||
@ApiResponse(responseCode = "404", description = "Environment not found")
|
@ApiResponse(responseCode = "404", description = "Environment not found")
|
||||||
public ResponseEntity<?> updateDefaultContainerConfig(@PathVariable UUID id,
|
public ResponseEntity<?> updateDefaultContainerConfig(@PathVariable String envSlug,
|
||||||
@RequestBody Map<String, Object> defaultContainerConfig) {
|
@RequestBody Map<String, Object> defaultContainerConfig) {
|
||||||
try {
|
try {
|
||||||
validateContainerConfig(defaultContainerConfig);
|
validateContainerConfig(defaultContainerConfig);
|
||||||
environmentService.updateDefaultContainerConfig(id, defaultContainerConfig);
|
Environment current = environmentService.getBySlug(envSlug);
|
||||||
return ResponseEntity.ok(environmentService.getById(id));
|
environmentService.updateDefaultContainerConfig(current.id(), defaultContainerConfig);
|
||||||
|
return ResponseEntity.ok(environmentService.getBySlug(envSlug));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
if (e.getMessage().contains("not found")) {
|
if (e.getMessage().contains("not found")) {
|
||||||
return ResponseEntity.notFound().build();
|
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")
|
@Operation(summary = "Update JAR retention policy for an environment")
|
||||||
@ApiResponse(responseCode = "200", description = "Retention policy updated")
|
@ApiResponse(responseCode = "200", description = "Retention policy updated")
|
||||||
@ApiResponse(responseCode = "404", description = "Environment not found")
|
@ApiResponse(responseCode = "404", description = "Environment not found")
|
||||||
public ResponseEntity<?> updateJarRetention(@PathVariable UUID id,
|
public ResponseEntity<?> updateJarRetention(@PathVariable String envSlug,
|
||||||
@RequestBody JarRetentionRequest request) {
|
@RequestBody JarRetentionRequest request) {
|
||||||
try {
|
try {
|
||||||
environmentService.updateJarRetentionCount(id, request.jarRetentionCount());
|
Environment current = environmentService.getBySlug(envSlug);
|
||||||
return ResponseEntity.ok(environmentService.getById(id));
|
environmentService.updateJarRetentionCount(current.id(), request.jarRetentionCount());
|
||||||
|
return ResponseEntity.ok(environmentService.getBySlug(envSlug));
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
if (e.getMessage().contains("not found")) {
|
if (e.getMessage().contains("not found")) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.cameleer.server.app.controller;
|
|||||||
|
|
||||||
import com.cameleer.server.app.dto.LogEntryResponse;
|
import com.cameleer.server.app.dto.LogEntryResponse;
|
||||||
import com.cameleer.server.app.dto.LogSearchPageResponse;
|
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.LogSearchRequest;
|
||||||
import com.cameleer.server.core.search.LogSearchResponse;
|
import com.cameleer.server.core.search.LogSearchResponse;
|
||||||
import com.cameleer.server.core.storage.LogIndex;
|
import com.cameleer.server.core.storage.LogIndex;
|
||||||
@@ -18,8 +20,8 @@ import java.util.Arrays;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/logs")
|
@RequestMapping("/api/v1/environments/{envSlug}")
|
||||||
@Tag(name = "Application Logs", description = "Query application logs")
|
@Tag(name = "Application Logs", description = "Query application logs (env-scoped)")
|
||||||
public class LogQueryController {
|
public class LogQueryController {
|
||||||
|
|
||||||
private final LogIndex logIndex;
|
private final LogIndex logIndex;
|
||||||
@@ -28,11 +30,12 @@ public class LogQueryController {
|
|||||||
this.logIndex = logIndex;
|
this.logIndex = logIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping("/logs")
|
||||||
@Operation(summary = "Search application log entries",
|
@Operation(summary = "Search application log entries in this environment",
|
||||||
description = "Returns log entries with cursor-based pagination and level count aggregation. " +
|
description = "Cursor-paginated log search scoped to the env in the path. "
|
||||||
"Supports free-text search, multi-level filtering, and optional application scoping.")
|
+ "Supports free-text search, multi-level filtering, and optional application/agent scoping.")
|
||||||
public ResponseEntity<LogSearchPageResponse> searchLogs(
|
public ResponseEntity<LogSearchPageResponse> searchLogs(
|
||||||
|
@EnvPath Environment env,
|
||||||
@RequestParam(required = false) String q,
|
@RequestParam(required = false) String q,
|
||||||
@RequestParam(required = false) String query,
|
@RequestParam(required = false) String query,
|
||||||
@RequestParam(required = false) String level,
|
@RequestParam(required = false) String level,
|
||||||
@@ -40,7 +43,6 @@ public class LogQueryController {
|
|||||||
@RequestParam(name = "agentId", required = false) String instanceId,
|
@RequestParam(name = "agentId", required = false) String instanceId,
|
||||||
@RequestParam(required = false) String exchangeId,
|
@RequestParam(required = false) String exchangeId,
|
||||||
@RequestParam(required = false) String logger,
|
@RequestParam(required = false) String logger,
|
||||||
@RequestParam(required = false) String environment,
|
|
||||||
@RequestParam(required = false) String source,
|
@RequestParam(required = false) String source,
|
||||||
@RequestParam(required = false) String from,
|
@RequestParam(required = false) String from,
|
||||||
@RequestParam(required = false) String to,
|
@RequestParam(required = false) String to,
|
||||||
@@ -51,7 +53,6 @@ public class LogQueryController {
|
|||||||
// q takes precedence over deprecated query param
|
// q takes precedence over deprecated query param
|
||||||
String searchText = q != null ? q : query;
|
String searchText = q != null ? q : query;
|
||||||
|
|
||||||
// Parse CSV levels
|
|
||||||
List<String> levels = List.of();
|
List<String> levels = List.of();
|
||||||
if (level != null && !level.isEmpty()) {
|
if (level != null && !level.isEmpty()) {
|
||||||
levels = Arrays.stream(level.split(","))
|
levels = Arrays.stream(level.split(","))
|
||||||
@@ -65,7 +66,7 @@ public class LogQueryController {
|
|||||||
|
|
||||||
LogSearchRequest request = new LogSearchRequest(
|
LogSearchRequest request = new LogSearchRequest(
|
||||||
searchText, levels, application, instanceId, exchangeId,
|
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);
|
LogSearchResponse result = logIndex.search(request);
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,16 @@ package com.cameleer.server.app.controller;
|
|||||||
import com.cameleer.server.app.dto.AgentSummary;
|
import com.cameleer.server.app.dto.AgentSummary;
|
||||||
import com.cameleer.server.app.dto.AppCatalogEntry;
|
import com.cameleer.server.app.dto.AppCatalogEntry;
|
||||||
import com.cameleer.server.app.dto.RouteSummary;
|
import com.cameleer.server.app.dto.RouteSummary;
|
||||||
|
import com.cameleer.server.app.web.EnvPath;
|
||||||
import com.cameleer.common.graph.RouteGraph;
|
import com.cameleer.common.graph.RouteGraph;
|
||||||
import com.cameleer.server.core.agent.AgentInfo;
|
import com.cameleer.server.core.agent.AgentInfo;
|
||||||
import com.cameleer.server.core.agent.AgentRegistryService;
|
import com.cameleer.server.core.agent.AgentRegistryService;
|
||||||
import com.cameleer.server.core.agent.AgentState;
|
import com.cameleer.server.core.agent.AgentState;
|
||||||
import com.cameleer.server.core.agent.RouteStateRegistry;
|
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.DiagramStore;
|
||||||
import com.cameleer.server.core.storage.RouteCatalogEntry;
|
import com.cameleer.server.core.storage.RouteCatalogEntry;
|
||||||
import com.cameleer.server.core.storage.RouteCatalogStore;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@@ -34,8 +35,8 @@ import java.util.Set;
|
|||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/routes")
|
@RequestMapping("/api/v1/environments/{envSlug}")
|
||||||
@Tag(name = "Route Catalog", description = "Route catalog and discovery")
|
@Tag(name = "Route Catalog", description = "Route catalog and discovery (env-scoped)")
|
||||||
public class RouteCatalogController {
|
public class RouteCatalogController {
|
||||||
|
|
||||||
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(RouteCatalogController.class);
|
private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(RouteCatalogController.class);
|
||||||
@@ -58,28 +59,22 @@ public class RouteCatalogController {
|
|||||||
this.routeCatalogStore = routeCatalogStore;
|
this.routeCatalogStore = routeCatalogStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/catalog")
|
@GetMapping("/routes")
|
||||||
@Operation(summary = "Get route catalog",
|
@Operation(summary = "Get route catalog for this environment",
|
||||||
description = "Returns all applications with their routes, agents, and health status")
|
description = "Returns all applications with their routes, agents, and health status — filtered to this environment")
|
||||||
@ApiResponse(responseCode = "200", description = "Catalog returned")
|
@ApiResponse(responseCode = "200", description = "Catalog returned")
|
||||||
public ResponseEntity<List<AppCatalogEntry>> getCatalog(
|
public ResponseEntity<List<AppCatalogEntry>> getCatalog(
|
||||||
|
@EnvPath Environment env,
|
||||||
@RequestParam(required = false) String from,
|
@RequestParam(required = false) String from,
|
||||||
@RequestParam(required = false) String to,
|
@RequestParam(required = false) String to) {
|
||||||
@RequestParam(required = false) String environment) {
|
String envSlug = env.slug();
|
||||||
List<AgentInfo> allAgents = registryService.findAll();
|
List<AgentInfo> allAgents = registryService.findAll().stream()
|
||||||
|
.filter(a -> envSlug.equals(a.environmentId()))
|
||||||
|
.toList();
|
||||||
|
|
||||||
// Filter agents by environment if specified
|
|
||||||
if (environment != null && !environment.isBlank()) {
|
|
||||||
allAgents = allAgents.stream()
|
|
||||||
.filter(a -> environment.equals(a.environmentId()))
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group agents by application name
|
|
||||||
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
|
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
|
||||||
.collect(Collectors.groupingBy(AgentInfo::applicationId, LinkedHashMap::new, Collectors.toList()));
|
.collect(Collectors.groupingBy(AgentInfo::applicationId, LinkedHashMap::new, Collectors.toList()));
|
||||||
|
|
||||||
// Collect all distinct routes per app
|
|
||||||
Map<String, Set<String>> routesByApp = new LinkedHashMap<>();
|
Map<String, Set<String>> routesByApp = new LinkedHashMap<>();
|
||||||
for (var entry : agentsByApp.entrySet()) {
|
for (var entry : agentsByApp.entrySet()) {
|
||||||
Set<String> routes = new LinkedHashSet<>();
|
Set<String> routes = new LinkedHashSet<>();
|
||||||
@@ -91,21 +86,16 @@ public class RouteCatalogController {
|
|||||||
routesByApp.put(entry.getKey(), routes);
|
routesByApp.put(entry.getKey(), routes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time range for exchange counts — use provided range or default to last 24h
|
|
||||||
Instant now = Instant.now();
|
Instant now = Instant.now();
|
||||||
Instant rangeFrom = from != null ? Instant.parse(from) : now.minus(24, ChronoUnit.HOURS);
|
Instant rangeFrom = from != null ? Instant.parse(from) : now.minus(24, ChronoUnit.HOURS);
|
||||||
Instant rangeTo = to != null ? Instant.parse(to) : now;
|
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, Long> routeExchangeCounts = new LinkedHashMap<>();
|
||||||
Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
|
Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
|
||||||
try {
|
try {
|
||||||
String envFilter = (environment != null && !environment.isBlank())
|
|
||||||
? " AND environment = " + lit(environment) : "";
|
|
||||||
jdbc.query(
|
jdbc.query(
|
||||||
"SELECT application_id, route_id, uniqMerge(total_count) AS cnt, MAX(bucket) AS last_seen " +
|
"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) +
|
"FROM stats_1m_route WHERE bucket >= " + lit(rangeFrom) + " AND bucket < " + lit(rangeTo) +
|
||||||
envFilter +
|
" AND environment = " + lit(envSlug) +
|
||||||
" GROUP BY application_id, route_id",
|
" GROUP BY application_id, route_id",
|
||||||
rs -> {
|
rs -> {
|
||||||
String key = rs.getString("application_id") + "/" + rs.getString("route_id");
|
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());
|
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()) {
|
for (var countEntry : routeExchangeCounts.entrySet()) {
|
||||||
String[] parts = countEntry.getKey().split("/", 2);
|
String[] parts = countEntry.getKey().split("/", 2);
|
||||||
if (parts.length == 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 {
|
try {
|
||||||
List<RouteCatalogEntry> catalogEntries = (environment != null && !environment.isBlank())
|
List<RouteCatalogEntry> catalogEntries = routeCatalogStore.findByEnvironment(envSlug, rangeFrom, rangeTo);
|
||||||
? routeCatalogStore.findByEnvironment(environment, rangeFrom, rangeTo)
|
|
||||||
: routeCatalogStore.findAll(rangeFrom, rangeTo);
|
|
||||||
for (RouteCatalogEntry entry : catalogEntries) {
|
for (RouteCatalogEntry entry : catalogEntries) {
|
||||||
routesByApp.computeIfAbsent(entry.applicationId(), k -> new LinkedHashSet<>())
|
routesByApp.computeIfAbsent(entry.applicationId(), k -> new LinkedHashSet<>())
|
||||||
.add(entry.routeId());
|
.add(entry.routeId());
|
||||||
@@ -141,7 +124,6 @@ public class RouteCatalogController {
|
|||||||
log.warn("Failed to query route catalog: {}", e.getMessage());
|
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());
|
Set<String> allAppIds = new LinkedHashSet<>(agentsByApp.keySet());
|
||||||
allAppIds.addAll(routesByApp.keySet());
|
allAppIds.addAll(routesByApp.keySet());
|
||||||
|
|
||||||
@@ -149,7 +131,6 @@ public class RouteCatalogController {
|
|||||||
for (String appId : allAppIds) {
|
for (String appId : allAppIds) {
|
||||||
List<AgentInfo> agents = agentsByApp.getOrDefault(appId, List.of());
|
List<AgentInfo> agents = agentsByApp.getOrDefault(appId, List.of());
|
||||||
|
|
||||||
// Routes
|
|
||||||
Set<String> routeIds = routesByApp.getOrDefault(appId, Set.of());
|
Set<String> routeIds = routesByApp.getOrDefault(appId, Set.of());
|
||||||
List<String> agentIds = agents.stream().map(AgentInfo::instanceId).toList();
|
List<String> agentIds = agents.stream().map(AgentInfo::instanceId).toList();
|
||||||
List<RouteSummary> routeSummaries = routeIds.stream()
|
List<RouteSummary> routeSummaries = routeIds.stream()
|
||||||
@@ -159,21 +140,17 @@ public class RouteCatalogController {
|
|||||||
Instant lastSeen = routeLastSeen.get(key);
|
Instant lastSeen = routeLastSeen.get(key);
|
||||||
String fromUri = resolveFromEndpointUri(routeId, agentIds);
|
String fromUri = resolveFromEndpointUri(routeId, agentIds);
|
||||||
String state = routeStateRegistry.getState(appId, routeId).name().toLowerCase();
|
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;
|
String routeState = "started".equals(state) ? null : state;
|
||||||
return new RouteSummary(routeId, count, lastSeen, fromUri, routeState);
|
return new RouteSummary(routeId, count, lastSeen, fromUri, routeState);
|
||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Agent summaries
|
|
||||||
List<AgentSummary> agentSummaries = agents.stream()
|
List<AgentSummary> agentSummaries = agents.stream()
|
||||||
.map(a -> new AgentSummary(a.instanceId(), a.displayName(), a.state().name().toLowerCase(), 0.0))
|
.map(a -> new AgentSummary(a.instanceId(), a.displayName(), a.state().name().toLowerCase(), 0.0))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
// Health = worst state among agents
|
|
||||||
String health = computeWorstHealth(agents);
|
String health = computeWorstHealth(agents);
|
||||||
|
|
||||||
// Total exchange count for the app
|
|
||||||
long totalExchanges = routeSummaries.stream().mapToLong(RouteSummary::exchangeCount).sum();
|
long totalExchanges = routeSummaries.stream().mapToLong(RouteSummary::exchangeCount).sum();
|
||||||
|
|
||||||
catalog.add(new AppCatalogEntry(appId, routeSummaries, agentSummaries,
|
catalog.add(new AppCatalogEntry(appId, routeSummaries, agentSummaries,
|
||||||
@@ -183,7 +160,6 @@ public class RouteCatalogController {
|
|||||||
return ResponseEntity.ok(catalog);
|
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) {
|
private String resolveFromEndpointUri(String routeId, List<String> agentIds) {
|
||||||
return diagramStore.findContentHashForRouteByAgents(routeId, agentIds)
|
return diagramStore.findContentHashForRouteByAgents(routeId, agentIds)
|
||||||
.flatMap(diagramStore::findByContentHash)
|
.flatMap(diagramStore::findByContentHash)
|
||||||
@@ -192,14 +168,12 @@ public class RouteCatalogController {
|
|||||||
.orElse(null);
|
.orElse(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format an Instant as a ClickHouse DateTime literal in UTC. */
|
|
||||||
private static String lit(Instant instant) {
|
private static String lit(Instant instant) {
|
||||||
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
.withZone(java.time.ZoneOffset.UTC)
|
.withZone(java.time.ZoneOffset.UTC)
|
||||||
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'";
|
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a string as a ClickHouse SQL literal with backslash + quote escaping. */
|
|
||||||
private static String lit(String value) {
|
private static String lit(String value) {
|
||||||
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
|
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package com.cameleer.server.app.controller;
|
|||||||
|
|
||||||
import com.cameleer.server.app.dto.ProcessorMetrics;
|
import com.cameleer.server.app.dto.ProcessorMetrics;
|
||||||
import com.cameleer.server.app.dto.RouteMetrics;
|
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.AppSettings;
|
||||||
import com.cameleer.server.core.admin.AppSettingsRepository;
|
import com.cameleer.server.core.admin.AppSettingsRepository;
|
||||||
|
import com.cameleer.server.core.runtime.Environment;
|
||||||
import com.cameleer.server.core.storage.StatsStore;
|
import com.cameleer.server.core.storage.StatsStore;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
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.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.sql.Timestamp;
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.temporal.ChronoUnit;
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/routes")
|
@RequestMapping("/api/v1/environments/{envSlug}/routes")
|
||||||
@Tag(name = "Route Metrics", description = "Route performance metrics")
|
@Tag(name = "Route Metrics", description = "Route performance metrics (env-scoped)")
|
||||||
public class RouteMetricsController {
|
public class RouteMetricsController {
|
||||||
|
|
||||||
private final JdbcTemplate jdbc;
|
private final JdbcTemplate jdbc;
|
||||||
private final StatsStore statsStore;
|
private final StatsStore statsStore;
|
||||||
private final AppSettingsRepository appSettingsRepository;
|
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) {
|
AppSettingsRepository appSettingsRepository) {
|
||||||
this.jdbc = jdbc;
|
this.jdbc = jdbc;
|
||||||
this.statsStore = statsStore;
|
this.statsStore = statsStore;
|
||||||
@@ -40,35 +41,32 @@ public class RouteMetricsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/metrics")
|
@GetMapping("/metrics")
|
||||||
@Operation(summary = "Get route metrics",
|
@Operation(summary = "Get route metrics for this environment",
|
||||||
description = "Returns aggregated performance metrics per route for the given time window")
|
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")
|
@ApiResponse(responseCode = "200", description = "Metrics returned")
|
||||||
public ResponseEntity<List<RouteMetrics>> getMetrics(
|
public ResponseEntity<List<RouteMetrics>> getMetrics(
|
||||||
|
@EnvPath Environment env,
|
||||||
@RequestParam(required = false) String from,
|
@RequestParam(required = false) String from,
|
||||||
@RequestParam(required = false) String to,
|
@RequestParam(required = false) String to,
|
||||||
@RequestParam(required = false) String appId,
|
@RequestParam(required = false) String appId) {
|
||||||
@RequestParam(required = false) String environment) {
|
|
||||||
|
|
||||||
Instant toInstant = to != null ? Instant.parse(to) : Instant.now();
|
Instant toInstant = to != null ? Instant.parse(to) : Instant.now();
|
||||||
Instant fromInstant = from != null ? Instant.parse(from) : toInstant.minus(24, ChronoUnit.HOURS);
|
Instant fromInstant = from != null ? Instant.parse(from) : toInstant.minus(24, ChronoUnit.HOURS);
|
||||||
long windowSeconds = Duration.between(fromInstant, toInstant).toSeconds();
|
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(
|
var sql = new StringBuilder(
|
||||||
"SELECT application_id, route_id, " +
|
"SELECT application_id, route_id, " +
|
||||||
"uniqMerge(total_count) AS total, " +
|
"uniqMerge(total_count) AS total, " +
|
||||||
"uniqIfMerge(failed_count) AS failed, " +
|
"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, " +
|
"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 " +
|
"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) {
|
if (appId != null) {
|
||||||
sql.append(" AND application_id = " + lit(appId));
|
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");
|
sql.append(" GROUP BY application_id, route_id ORDER BY application_id, route_id");
|
||||||
|
|
||||||
List<RouteMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
|
List<RouteMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
|
||||||
@@ -87,7 +85,7 @@ public class RouteMetricsController {
|
|||||||
avgDur, p99Dur, errorRate, tps, List.of(), -1.0);
|
avgDur, p99Dur, errorRate, tps, List.of(), -1.0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch sparklines (12 buckets over the time window)
|
// Sparklines
|
||||||
if (!metrics.isEmpty()) {
|
if (!metrics.isEmpty()) {
|
||||||
int sparkBuckets = 12;
|
int sparkBuckets = 12;
|
||||||
long bucketSeconds = Math.max(windowSeconds / sparkBuckets, 60);
|
long bucketSeconds = Math.max(windowSeconds / sparkBuckets, 60);
|
||||||
@@ -95,15 +93,12 @@ public class RouteMetricsController {
|
|||||||
for (int i = 0; i < metrics.size(); i++) {
|
for (int i = 0; i < metrics.size(); i++) {
|
||||||
RouteMetrics m = metrics.get(i);
|
RouteMetrics m = metrics.get(i);
|
||||||
try {
|
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, " +
|
String sparkSql = "SELECT toStartOfInterval(bucket, toIntervalSecond(" + bucketSeconds + ")) AS period, " +
|
||||||
"COALESCE(uniqMerge(total_count), 0) AS cnt " +
|
"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,
|
List<Double> sparkline = jdbc.query(sparkSql,
|
||||||
(rs, rowNum) -> rs.getDouble("cnt"));
|
(rs, rowNum) -> rs.getDouble("cnt"));
|
||||||
metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(),
|
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()) {
|
if (!metrics.isEmpty()) {
|
||||||
// Determine SLA threshold (per-app or default)
|
String effectiveAppId = appId != null ? appId : metrics.get(0).appId();
|
||||||
String effectiveAppId = appId != null ? appId : (metrics.isEmpty() ? null : metrics.get(0).appId());
|
int threshold = effectiveAppId != null
|
||||||
int threshold = (effectiveAppId != null && environment != null && !environment.isBlank())
|
? appSettingsRepository.findByApplicationAndEnvironment(effectiveAppId, env.slug())
|
||||||
? appSettingsRepository.findByApplicationAndEnvironment(effectiveAppId, environment)
|
|
||||||
.map(AppSettings::slaThresholdMs).orElse(300)
|
.map(AppSettings::slaThresholdMs).orElse(300)
|
||||||
: 300;
|
: 300;
|
||||||
|
|
||||||
Map<String, long[]> slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant,
|
Map<String, long[]> slaCounts = statsStore.slaCountsByRoute(fromInstant, toInstant,
|
||||||
effectiveAppId, threshold, environment);
|
effectiveAppId, threshold, env.slug());
|
||||||
|
|
||||||
for (int i = 0; i < metrics.size(); i++) {
|
for (int i = 0; i < metrics.size(); i++) {
|
||||||
RouteMetrics m = metrics.get(i);
|
RouteMetrics m = metrics.get(i);
|
||||||
@@ -142,24 +136,19 @@ public class RouteMetricsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/metrics/processors")
|
@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")
|
description = "Returns aggregated performance metrics per processor for the given route and time window")
|
||||||
@ApiResponse(responseCode = "200", description = "Metrics returned")
|
@ApiResponse(responseCode = "200", description = "Metrics returned")
|
||||||
public ResponseEntity<List<ProcessorMetrics>> getProcessorMetrics(
|
public ResponseEntity<List<ProcessorMetrics>> getProcessorMetrics(
|
||||||
|
@EnvPath Environment env,
|
||||||
@RequestParam String routeId,
|
@RequestParam String routeId,
|
||||||
@RequestParam(required = false) String appId,
|
@RequestParam(required = false) String appId,
|
||||||
@RequestParam(required = false) Instant from,
|
@RequestParam(required = false) Instant from,
|
||||||
@RequestParam(required = false) Instant to,
|
@RequestParam(required = false) Instant to) {
|
||||||
@RequestParam(required = false) String environment) {
|
|
||||||
|
|
||||||
Instant toInstant = to != null ? to : Instant.now();
|
Instant toInstant = to != null ? to : Instant.now();
|
||||||
Instant fromInstant = from != null ? from : toInstant.minus(24, ChronoUnit.HOURS);
|
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(
|
var sql = new StringBuilder(
|
||||||
"SELECT processor_id, processor_type, route_id, application_id, " +
|
"SELECT processor_id, processor_type, route_id, application_id, " +
|
||||||
"uniqMerge(total_count) AS tc, " +
|
"uniqMerge(total_count) AS tc, " +
|
||||||
@@ -168,14 +157,12 @@ public class RouteMetricsController {
|
|||||||
"quantileMerge(0.99)(p99_duration) AS p99_duration_ms " +
|
"quantileMerge(0.99)(p99_duration) AS p99_duration_ms " +
|
||||||
"FROM stats_1m_processor_detail " +
|
"FROM stats_1m_processor_detail " +
|
||||||
"WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant) +
|
"WHERE bucket >= " + lit(fromInstant) + " AND bucket < " + lit(toInstant) +
|
||||||
|
" AND environment = " + lit(env.slug()) +
|
||||||
" AND route_id = " + lit(routeId));
|
" AND route_id = " + lit(routeId));
|
||||||
|
|
||||||
if (appId != null) {
|
if (appId != null) {
|
||||||
sql.append(" AND application_id = " + lit(appId));
|
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(" GROUP BY processor_id, processor_type, route_id, application_id");
|
||||||
sql.append(" ORDER BY tc DESC");
|
sql.append(" ORDER BY tc DESC");
|
||||||
|
|
||||||
@@ -198,14 +185,12 @@ public class RouteMetricsController {
|
|||||||
return ResponseEntity.ok(metrics);
|
return ResponseEntity.ok(metrics);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format an Instant as a ClickHouse DateTime literal. */
|
|
||||||
private static String lit(Instant instant) {
|
private static String lit(Instant instant) {
|
||||||
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
return "'" + java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
.withZone(java.time.ZoneOffset.UTC)
|
.withZone(java.time.ZoneOffset.UTC)
|
||||||
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'";
|
.format(instant.truncatedTo(ChronoUnit.SECONDS)) + "'";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Format a string as a ClickHouse SQL literal with backslash + quote escaping. */
|
|
||||||
private static String lit(String value) {
|
private static String lit(String value) {
|
||||||
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
|
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package com.cameleer.server.app.controller;
|
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.AppSettings;
|
||||||
import com.cameleer.server.core.admin.AppSettingsRepository;
|
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.ExecutionStats;
|
||||||
import com.cameleer.server.core.search.ExecutionSummary;
|
import com.cameleer.server.core.search.ExecutionSummary;
|
||||||
import com.cameleer.server.core.search.SearchRequest;
|
import com.cameleer.server.core.search.SearchRequest;
|
||||||
@@ -25,14 +27,12 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search endpoints for querying route executions.
|
* Execution search and stats endpoints. Env is the path; env filter is
|
||||||
* <p>
|
* derived from the path and always applied to underlying ClickHouse queries.
|
||||||
* GET supports basic filters via query parameters. POST accepts a full
|
|
||||||
* {@link SearchRequest} JSON body for advanced search with all filter types.
|
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/search")
|
@RequestMapping("/api/v1/environments/{envSlug}")
|
||||||
@Tag(name = "Search", description = "Transaction search endpoints")
|
@Tag(name = "Search", description = "Transaction search and stats (env-scoped)")
|
||||||
public class SearchController {
|
public class SearchController {
|
||||||
|
|
||||||
private final SearchService searchService;
|
private final SearchService searchService;
|
||||||
@@ -45,8 +45,9 @@ public class SearchController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/executions")
|
@GetMapping("/executions")
|
||||||
@Operation(summary = "Search executions with basic filters")
|
@Operation(summary = "Search executions with basic filters (env from path)")
|
||||||
public ResponseEntity<SearchResult<ExecutionSummary>> searchGet(
|
public ResponseEntity<SearchResult<ExecutionSummary>> searchGet(
|
||||||
|
@EnvPath Environment env,
|
||||||
@RequestParam(required = false) String status,
|
@RequestParam(required = false) String status,
|
||||||
@RequestParam(required = false) Instant timeFrom,
|
@RequestParam(required = false) Instant timeFrom,
|
||||||
@RequestParam(required = false) Instant timeTo,
|
@RequestParam(required = false) Instant timeTo,
|
||||||
@@ -56,7 +57,6 @@ public class SearchController {
|
|||||||
@RequestParam(name = "agentId", required = false) String instanceId,
|
@RequestParam(name = "agentId", required = false) String instanceId,
|
||||||
@RequestParam(required = false) String processorType,
|
@RequestParam(required = false) String processorType,
|
||||||
@RequestParam(required = false) String application,
|
@RequestParam(required = false) String application,
|
||||||
@RequestParam(required = false) String environment,
|
|
||||||
@RequestParam(defaultValue = "0") int offset,
|
@RequestParam(defaultValue = "0") int offset,
|
||||||
@RequestParam(defaultValue = "50") int limit,
|
@RequestParam(defaultValue = "50") int limit,
|
||||||
@RequestParam(required = false) String sortField,
|
@RequestParam(required = false) String sortField,
|
||||||
@@ -71,115 +71,116 @@ public class SearchController {
|
|||||||
application, null,
|
application, null,
|
||||||
offset, limit,
|
offset, limit,
|
||||||
sortField, sortDir,
|
sortField, sortDir,
|
||||||
environment
|
env.slug()
|
||||||
);
|
);
|
||||||
|
|
||||||
return ResponseEntity.ok(searchService.search(request));
|
return ResponseEntity.ok(searchService.search(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/executions")
|
@PostMapping("/executions/search")
|
||||||
@Operation(summary = "Advanced search with all filters")
|
@Operation(summary = "Advanced search with all filters",
|
||||||
|
description = "Env from the path overrides any environment field in the body.")
|
||||||
public ResponseEntity<SearchResult<ExecutionSummary>> searchPost(
|
public ResponseEntity<SearchResult<ExecutionSummary>> searchPost(
|
||||||
|
@EnvPath Environment env,
|
||||||
@RequestBody SearchRequest request) {
|
@RequestBody SearchRequest request) {
|
||||||
return ResponseEntity.ok(searchService.search(request));
|
SearchRequest scoped = request.withEnvironment(env.slug());
|
||||||
|
return ResponseEntity.ok(searchService.search(scoped));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/stats")
|
@GetMapping("/stats")
|
||||||
@Operation(summary = "Aggregate execution stats (P99 latency, active count, SLA compliance)")
|
@Operation(summary = "Aggregate execution stats (P99 latency, active count, SLA compliance)")
|
||||||
public ResponseEntity<ExecutionStats> stats(
|
public ResponseEntity<ExecutionStats> stats(
|
||||||
|
@EnvPath Environment env,
|
||||||
@RequestParam Instant from,
|
@RequestParam Instant from,
|
||||||
@RequestParam(required = false) Instant to,
|
@RequestParam(required = false) Instant to,
|
||||||
@RequestParam(required = false) String routeId,
|
@RequestParam(required = false) String routeId,
|
||||||
@RequestParam(required = false) String application,
|
@RequestParam(required = false) String application) {
|
||||||
@RequestParam(required = false) String environment) {
|
|
||||||
Instant end = to != null ? to : Instant.now();
|
Instant end = to != null ? to : Instant.now();
|
||||||
ExecutionStats stats;
|
ExecutionStats stats;
|
||||||
if (routeId == null && application == null) {
|
if (routeId == null && application == null) {
|
||||||
stats = searchService.stats(from, end, environment);
|
stats = searchService.stats(from, end, env.slug());
|
||||||
} else if (routeId == null) {
|
} else if (routeId == null) {
|
||||||
stats = searchService.statsForApp(from, end, application, environment);
|
stats = searchService.statsForApp(from, end, application, env.slug());
|
||||||
} else {
|
} 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()
|
||||||
int threshold = (application != null && !application.isBlank()
|
? appSettingsRepository.findByApplicationAndEnvironment(application, env.slug())
|
||||||
&& environment != null && !environment.isBlank())
|
|
||||||
? appSettingsRepository.findByApplicationAndEnvironment(application, environment)
|
|
||||||
.map(AppSettings::slaThresholdMs).orElse(300)
|
.map(AppSettings::slaThresholdMs).orElse(300)
|
||||||
: 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));
|
return ResponseEntity.ok(stats.withSlaCompliance(sla));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/stats/timeseries")
|
@GetMapping("/stats/timeseries")
|
||||||
@Operation(summary = "Bucketed time-series stats over a time window")
|
@Operation(summary = "Bucketed time-series stats over a time window")
|
||||||
public ResponseEntity<StatsTimeseries> timeseries(
|
public ResponseEntity<StatsTimeseries> timeseries(
|
||||||
|
@EnvPath Environment env,
|
||||||
@RequestParam Instant from,
|
@RequestParam Instant from,
|
||||||
@RequestParam(required = false) Instant to,
|
@RequestParam(required = false) Instant to,
|
||||||
@RequestParam(defaultValue = "24") int buckets,
|
@RequestParam(defaultValue = "24") int buckets,
|
||||||
@RequestParam(required = false) String routeId,
|
@RequestParam(required = false) String routeId,
|
||||||
@RequestParam(required = false) String application,
|
@RequestParam(required = false) String application) {
|
||||||
@RequestParam(required = false) String environment) {
|
|
||||||
Instant end = to != null ? to : Instant.now();
|
Instant end = to != null ? to : Instant.now();
|
||||||
if (routeId == null && application == null) {
|
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) {
|
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")
|
@GetMapping("/stats/timeseries/by-app")
|
||||||
@Operation(summary = "Timeseries grouped by application")
|
@Operation(summary = "Timeseries grouped by application")
|
||||||
public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByApp(
|
public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByApp(
|
||||||
|
@EnvPath Environment env,
|
||||||
@RequestParam Instant from,
|
@RequestParam Instant from,
|
||||||
@RequestParam(required = false) Instant to,
|
@RequestParam(required = false) Instant to,
|
||||||
@RequestParam(defaultValue = "24") int buckets,
|
@RequestParam(defaultValue = "24") int buckets) {
|
||||||
@RequestParam(required = false) String environment) {
|
|
||||||
Instant end = to != null ? to : Instant.now();
|
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")
|
@GetMapping("/stats/timeseries/by-route")
|
||||||
@Operation(summary = "Timeseries grouped by route for an application")
|
@Operation(summary = "Timeseries grouped by route for an application")
|
||||||
public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByRoute(
|
public ResponseEntity<Map<String, StatsTimeseries>> timeseriesByRoute(
|
||||||
|
@EnvPath Environment env,
|
||||||
@RequestParam Instant from,
|
@RequestParam Instant from,
|
||||||
@RequestParam(required = false) Instant to,
|
@RequestParam(required = false) Instant to,
|
||||||
@RequestParam(defaultValue = "24") int buckets,
|
@RequestParam(defaultValue = "24") int buckets,
|
||||||
@RequestParam String application,
|
@RequestParam String application) {
|
||||||
@RequestParam(required = false) String environment) {
|
|
||||||
Instant end = to != null ? to : Instant.now();
|
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")
|
@GetMapping("/stats/punchcard")
|
||||||
@Operation(summary = "Transaction punchcard: weekday x hour grid (rolling 7 days)")
|
@Operation(summary = "Transaction punchcard: weekday x hour grid (rolling 7 days)")
|
||||||
public ResponseEntity<List<StatsStore.PunchcardCell>> punchcard(
|
public ResponseEntity<List<StatsStore.PunchcardCell>> punchcard(
|
||||||
@RequestParam(required = false) String application,
|
@EnvPath Environment env,
|
||||||
@RequestParam(required = false) String environment) {
|
@RequestParam(required = false) String application) {
|
||||||
Instant to = Instant.now();
|
Instant to = Instant.now();
|
||||||
Instant from = to.minus(java.time.Duration.ofDays(7));
|
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")
|
@GetMapping("/attributes/keys")
|
||||||
@Operation(summary = "Distinct attribute key names across all executions")
|
@Operation(summary = "Distinct attribute key names for this environment")
|
||||||
public ResponseEntity<List<String>> attributeKeys() {
|
public ResponseEntity<List<String>> attributeKeys(@EnvPath Environment env) {
|
||||||
return ResponseEntity.ok(searchService.distinctAttributeKeys());
|
return ResponseEntity.ok(searchService.distinctAttributeKeys(env.slug()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/errors/top")
|
@GetMapping("/errors/top")
|
||||||
@Operation(summary = "Top N errors with velocity trend")
|
@Operation(summary = "Top N errors with velocity trend")
|
||||||
public ResponseEntity<List<TopError>> topErrors(
|
public ResponseEntity<List<TopError>> topErrors(
|
||||||
|
@EnvPath Environment env,
|
||||||
@RequestParam Instant from,
|
@RequestParam Instant from,
|
||||||
@RequestParam(required = false) Instant to,
|
@RequestParam(required = false) Instant to,
|
||||||
@RequestParam(required = false) String application,
|
@RequestParam(required = false) String application,
|
||||||
@RequestParam(required = false) String routeId,
|
@RequestParam(required = false) String routeId,
|
||||||
@RequestParam(required = false) String environment,
|
|
||||||
@RequestParam(defaultValue = "5") int limit) {
|
@RequestParam(defaultValue = "5") int limit) {
|
||||||
Instant end = to != null ? to : Instant.now();
|
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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -318,14 +318,14 @@ public class ClickHouseSearchIndex implements SearchIndex {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<String> distinctAttributeKeys() {
|
public List<String> distinctAttributeKeys(String environment) {
|
||||||
try {
|
try {
|
||||||
return jdbc.queryForList("""
|
return jdbc.queryForList("""
|
||||||
SELECT DISTINCT arrayJoin(JSONExtractKeys(attributes)) AS attr_key
|
SELECT DISTINCT arrayJoin(JSONExtractKeys(attributes)) AS attr_key
|
||||||
FROM executions FINAL
|
FROM executions FINAL
|
||||||
WHERE tenant_id = ? AND attributes != '' AND attributes != '{}'
|
WHERE tenant_id = ? AND environment = ? AND attributes != '' AND attributes != '{}'
|
||||||
ORDER BY attr_key
|
ORDER BY attr_key
|
||||||
""", String.class, tenantId);
|
""", String.class, tenantId, environment);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Failed to query distinct attribute keys", e);
|
log.error("Failed to query distinct attribute keys", e);
|
||||||
return List.of();
|
return List.of();
|
||||||
|
|||||||
@@ -119,11 +119,37 @@ public class SecurityConfig {
|
|||||||
.requestMatchers(HttpMethod.GET, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT")
|
.requestMatchers(HttpMethod.GET, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT")
|
||||||
.requestMatchers(HttpMethod.POST, "/api/v1/search/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
.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.GET, "/api/v1/config/*").hasAnyRole("VIEWER", "OPERATOR", "ADMIN", "AGENT")
|
||||||
.requestMatchers(HttpMethod.PUT, "/api/v1/config/*").hasAnyRole("OPERATOR", "ADMIN")
|
.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/executions/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/diagrams/**").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")
|
.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/routes/**").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
|
||||||
.requestMatchers(HttpMethod.GET, "/api/v1/stats/**").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")
|
.requestMatchers("/api/v1/apps/**").hasAnyRole("OPERATOR", "ADMIN")
|
||||||
|
|
||||||
// Admin endpoints
|
// Admin endpoints
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,12 +60,12 @@ class AppControllerIT extends AbstractPostgresIT {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createApp_asOperator_returns201() throws Exception {
|
void createApp_asOperator_returns201() throws Exception {
|
||||||
String json = String.format("""
|
String json = """
|
||||||
{"environmentId": "%s", "slug": "my-app", "displayName": "My App"}
|
{"slug": "my-app", "displayName": "My App"}
|
||||||
""", defaultEnvId);
|
""";
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
ResponseEntity<String> response = restTemplate.exchange(
|
||||||
"/api/v1/apps", HttpMethod.POST,
|
"/api/v1/environments/default/apps", HttpMethod.POST,
|
||||||
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
|
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
|
||||||
String.class);
|
String.class);
|
||||||
|
|
||||||
@@ -79,16 +79,16 @@ class AppControllerIT extends AbstractPostgresIT {
|
|||||||
@Test
|
@Test
|
||||||
void listApps_asOperator_returnsCreatedApp() throws Exception {
|
void listApps_asOperator_returnsCreatedApp() throws Exception {
|
||||||
// Create an app first
|
// Create an app first
|
||||||
String json = String.format("""
|
String json = """
|
||||||
{"environmentId": "%s", "slug": "list-test", "displayName": "List Test"}
|
{"slug": "list-test", "displayName": "List Test"}
|
||||||
""", defaultEnvId);
|
""";
|
||||||
restTemplate.exchange(
|
restTemplate.exchange(
|
||||||
"/api/v1/apps", HttpMethod.POST,
|
"/api/v1/environments/default/apps", HttpMethod.POST,
|
||||||
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
|
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
|
||||||
String.class);
|
String.class);
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
ResponseEntity<String> response = restTemplate.exchange(
|
||||||
"/api/v1/apps?environmentId=" + defaultEnvId, HttpMethod.GET,
|
"/api/v1/environments/default/apps", HttpMethod.GET,
|
||||||
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||||
String.class);
|
String.class);
|
||||||
|
|
||||||
@@ -100,12 +100,12 @@ class AppControllerIT extends AbstractPostgresIT {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void createApp_asViewer_returns403() {
|
void createApp_asViewer_returns403() {
|
||||||
String json = String.format("""
|
String json = """
|
||||||
{"environmentId": "%s", "slug": "viewer-app", "displayName": "Viewer App"}
|
{"slug": "viewer-app", "displayName": "Viewer App"}
|
||||||
""", defaultEnvId);
|
""";
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
ResponseEntity<String> response = restTemplate.exchange(
|
||||||
"/api/v1/apps", HttpMethod.POST,
|
"/api/v1/environments/default/apps", HttpMethod.POST,
|
||||||
new HttpEntity<>(json, securityHelper.authHeaders(viewerJwt)),
|
new HttpEntity<>(json, securityHelper.authHeaders(viewerJwt)),
|
||||||
String.class);
|
String.class);
|
||||||
|
|
||||||
@@ -114,15 +114,15 @@ class AppControllerIT extends AbstractPostgresIT {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void uploadJar_asOperator_returns201() throws Exception {
|
void uploadJar_asOperator_returns201() throws Exception {
|
||||||
|
String appSlug = "jar-test";
|
||||||
// Create app
|
// Create app
|
||||||
String json = String.format("""
|
String json = String.format("""
|
||||||
{"environmentId": "%s", "slug": "jar-test", "displayName": "JAR Test"}
|
{"slug": "%s", "displayName": "JAR Test"}
|
||||||
""", defaultEnvId);
|
""", appSlug);
|
||||||
ResponseEntity<String> createResponse = restTemplate.exchange(
|
restTemplate.exchange(
|
||||||
"/api/v1/apps", HttpMethod.POST,
|
"/api/v1/environments/default/apps", HttpMethod.POST,
|
||||||
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
|
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
|
||||||
String.class);
|
String.class);
|
||||||
String appId = objectMapper.readTree(createResponse.getBody()).path("id").asText();
|
|
||||||
|
|
||||||
// Upload JAR (fake content)
|
// Upload JAR (fake content)
|
||||||
byte[] jarContent = "fake-jar-content".getBytes();
|
byte[] jarContent = "fake-jar-content".getBytes();
|
||||||
@@ -142,7 +142,7 @@ class AppControllerIT extends AbstractPostgresIT {
|
|||||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
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),
|
new HttpEntity<>(body, headers),
|
||||||
String.class);
|
String.class);
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class DeploymentControllerIT extends AbstractPostgresIT {
|
|||||||
private String adminJwt;
|
private String adminJwt;
|
||||||
private String defaultEnvId;
|
private String defaultEnvId;
|
||||||
private String appId;
|
private String appId;
|
||||||
|
private String appSlugRef;
|
||||||
private String versionId;
|
private String versionId;
|
||||||
|
|
||||||
@BeforeEach
|
@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("""
|
String appJson = String.format("""
|
||||||
{"environmentId": "%s", "slug": "deploy-test", "displayName": "Deploy Test"}
|
{"slug": "%s", "displayName": "Deploy Test"}
|
||||||
""", defaultEnvId);
|
""", appSlug);
|
||||||
ResponseEntity<String> appResponse = restTemplate.exchange(
|
ResponseEntity<String> appResponse = restTemplate.exchange(
|
||||||
"/api/v1/apps", HttpMethod.POST,
|
"/api/v1/environments/default/apps", HttpMethod.POST,
|
||||||
new HttpEntity<>(appJson, securityHelper.authHeaders(operatorJwt)),
|
new HttpEntity<>(appJson, securityHelper.authHeaders(operatorJwt)),
|
||||||
String.class);
|
String.class);
|
||||||
appId = objectMapper.readTree(appResponse.getBody()).path("id").asText();
|
appId = objectMapper.readTree(appResponse.getBody()).path("id").asText();
|
||||||
|
appSlugRef = appSlug;
|
||||||
|
|
||||||
// Upload a JAR version
|
// Upload a JAR version
|
||||||
byte[] jarContent = "fake-jar-for-deploy".getBytes();
|
byte[] jarContent = "fake-jar-for-deploy".getBytes();
|
||||||
@@ -85,7 +88,7 @@ class DeploymentControllerIT extends AbstractPostgresIT {
|
|||||||
headers.set("X-Cameleer-Protocol-Version", "1");
|
headers.set("X-Cameleer-Protocol-Version", "1");
|
||||||
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
||||||
ResponseEntity<String> versionResponse = restTemplate.exchange(
|
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),
|
new HttpEntity<>(body, headers),
|
||||||
String.class);
|
String.class);
|
||||||
versionId = objectMapper.readTree(versionResponse.getBody()).path("id").asText();
|
versionId = objectMapper.readTree(versionResponse.getBody()).path("id").asText();
|
||||||
@@ -95,11 +98,11 @@ class DeploymentControllerIT extends AbstractPostgresIT {
|
|||||||
void deploy_asOperator_returns202() throws Exception {
|
void deploy_asOperator_returns202() throws Exception {
|
||||||
// Deploy creates a record; the async executor will fail (no Docker) but the record should exist
|
// Deploy creates a record; the async executor will fail (no Docker) but the record should exist
|
||||||
String json = String.format("""
|
String json = String.format("""
|
||||||
{"appVersionId": "%s", "environmentId": "%s"}
|
{"appVersionId": "%s"}
|
||||||
""", versionId, defaultEnvId);
|
""", versionId);
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
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)),
|
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
|
||||||
String.class);
|
String.class);
|
||||||
|
|
||||||
@@ -114,15 +117,15 @@ class DeploymentControllerIT extends AbstractPostgresIT {
|
|||||||
void listDeployments_asOperator_returnsDeployments() throws Exception {
|
void listDeployments_asOperator_returnsDeployments() throws Exception {
|
||||||
// Create a deployment first
|
// Create a deployment first
|
||||||
String json = String.format("""
|
String json = String.format("""
|
||||||
{"appVersionId": "%s", "environmentId": "%s"}
|
{"appVersionId": "%s"}
|
||||||
""", versionId, defaultEnvId);
|
""", versionId);
|
||||||
restTemplate.exchange(
|
restTemplate.exchange(
|
||||||
"/api/v1/apps/" + appId + "/deployments", HttpMethod.POST,
|
"/api/v1/environments/default/apps/" + appSlugRef + "/deployments", HttpMethod.POST,
|
||||||
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
|
new HttpEntity<>(json, securityHelper.authHeaders(operatorJwt)),
|
||||||
String.class);
|
String.class);
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
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)),
|
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||||
String.class);
|
String.class);
|
||||||
|
|
||||||
@@ -135,7 +138,7 @@ class DeploymentControllerIT extends AbstractPostgresIT {
|
|||||||
@Test
|
@Test
|
||||||
void getDeployment_notFound_returns404() {
|
void getDeployment_notFound_returns404() {
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
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,
|
HttpMethod.GET,
|
||||||
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
new HttpEntity<>(securityHelper.authHeadersNoBody(operatorJwt)),
|
||||||
String.class);
|
String.class);
|
||||||
|
|||||||
@@ -97,29 +97,65 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
|
|||||||
String createJson = """
|
String createJson = """
|
||||||
{"slug": "update-test", "displayName": "Before", "production": false}
|
{"slug": "update-test", "displayName": "Before", "production": false}
|
||||||
""";
|
""";
|
||||||
ResponseEntity<String> createResponse = restTemplate.exchange(
|
restTemplate.exchange(
|
||||||
"/api/v1/admin/environments", HttpMethod.POST,
|
"/api/v1/admin/environments", HttpMethod.POST,
|
||||||
new HttpEntity<>(createJson, securityHelper.authHeaders(adminJwt)),
|
new HttpEntity<>(createJson, securityHelper.authHeaders(adminJwt)),
|
||||||
String.class);
|
String.class);
|
||||||
JsonNode created = objectMapper.readTree(createResponse.getBody());
|
|
||||||
String envId = created.path("id").asText();
|
|
||||||
|
|
||||||
// Update it
|
// Update it by slug
|
||||||
String updateJson = """
|
String updateJson = """
|
||||||
{"displayName": "After", "production": true, "enabled": false}
|
{"displayName": "After", "production": true, "enabled": false}
|
||||||
""";
|
""";
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
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)),
|
new HttpEntity<>(updateJson, securityHelper.authHeaders(adminJwt)),
|
||||||
String.class);
|
String.class);
|
||||||
|
|
||||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||||
JsonNode body = objectMapper.readTree(response.getBody());
|
JsonNode body = objectMapper.readTree(response.getBody());
|
||||||
|
assertThat(body.path("slug").asText()).isEqualTo("update-test");
|
||||||
assertThat(body.path("displayName").asText()).isEqualTo("After");
|
assertThat(body.path("displayName").asText()).isEqualTo("After");
|
||||||
assertThat(body.path("production").asBoolean()).isTrue();
|
assertThat(body.path("production").asBoolean()).isTrue();
|
||||||
assertThat(body.path("enabled").asBoolean()).isFalse();
|
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
|
@Test
|
||||||
void createEnvironment_duplicateSlug_returns400() {
|
void createEnvironment_duplicateSlug_returns400() {
|
||||||
String json = """
|
String json = """
|
||||||
@@ -142,25 +178,9 @@ class EnvironmentAdminControllerIT extends AbstractPostgresIT {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void deleteEnvironment_defaultEnv_returns400() throws Exception {
|
void deleteEnvironment_defaultEnv_returns400() {
|
||||||
// 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();
|
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
ResponseEntity<String> response = restTemplate.exchange(
|
||||||
"/api/v1/admin/environments/" + defaultId, HttpMethod.DELETE,
|
"/api/v1/admin/environments/default", HttpMethod.DELETE,
|
||||||
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
|
new HttpEntity<>(securityHelper.authHeadersNoBody(adminJwt)),
|
||||||
String.class);
|
String.class);
|
||||||
|
|
||||||
|
|||||||
@@ -375,7 +375,7 @@ class SearchControllerIT extends AbstractPostgresIT {
|
|||||||
private ResponseEntity<String> searchGet(String queryString) {
|
private ResponseEntity<String> searchGet(String queryString) {
|
||||||
HttpHeaders headers = securityHelper.authHeadersNoBody(jwt);
|
HttpHeaders headers = securityHelper.authHeadersNoBody(jwt);
|
||||||
return restTemplate.exchange(
|
return restTemplate.exchange(
|
||||||
"/api/v1/search/executions" + queryString,
|
"/api/v1/environments/default/executions" + queryString,
|
||||||
HttpMethod.GET,
|
HttpMethod.GET,
|
||||||
new HttpEntity<>(headers),
|
new HttpEntity<>(headers),
|
||||||
String.class);
|
String.class);
|
||||||
@@ -383,7 +383,7 @@ class SearchControllerIT extends AbstractPostgresIT {
|
|||||||
|
|
||||||
private ResponseEntity<String> searchPost(String jsonBody) {
|
private ResponseEntity<String> searchPost(String jsonBody) {
|
||||||
return restTemplate.exchange(
|
return restTemplate.exchange(
|
||||||
"/api/v1/search/executions",
|
"/api/v1/environments/default/executions/search",
|
||||||
HttpMethod.POST,
|
HttpMethod.POST,
|
||||||
new HttpEntity<>(jsonBody, securityHelper.authHeaders(viewerJwt)),
|
new HttpEntity<>(jsonBody, securityHelper.authHeaders(viewerJwt)),
|
||||||
String.class);
|
String.class);
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ class JwtRefreshIT extends AbstractPostgresIT {
|
|||||||
authHeaders.set("X-Cameleer-Protocol-Version", "1");
|
authHeaders.set("X-Cameleer-Protocol-Version", "1");
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
ResponseEntity<String> response = restTemplate.exchange(
|
||||||
"/api/v1/search/executions",
|
"/api/v1/environments/default/executions",
|
||||||
HttpMethod.GET,
|
HttpMethod.GET,
|
||||||
new HttpEntity<>(authHeaders),
|
new HttpEntity<>(authHeaders),
|
||||||
String.class);
|
String.class);
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class RegistrationSecurityIT extends AbstractPostgresIT {
|
|||||||
headers.set("X-Cameleer-Protocol-Version", "1");
|
headers.set("X-Cameleer-Protocol-Version", "1");
|
||||||
|
|
||||||
ResponseEntity<String> response = restTemplate.exchange(
|
ResponseEntity<String> response = restTemplate.exchange(
|
||||||
"/api/v1/search/executions",
|
"/api/v1/environments/default/executions",
|
||||||
HttpMethod.GET,
|
HttpMethod.GET,
|
||||||
new HttpEntity<>(headers),
|
new HttpEntity<>(headers),
|
||||||
String.class);
|
String.class);
|
||||||
|
|||||||
@@ -12,10 +12,14 @@ import java.util.HexFormat;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
public class AppService {
|
public class AppService {
|
||||||
private static final Logger log = LoggerFactory.getLogger(AppService.class);
|
private static final Logger log = LoggerFactory.getLogger(AppService.class);
|
||||||
|
|
||||||
|
/** Slug rules mirror {@link EnvironmentService#SLUG_PATTERN}: lowercase letters, digits, hyphens; 1–64 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 AppRepository appRepo;
|
||||||
private final AppVersionRepository versionRepo;
|
private final AppVersionRepository versionRepo;
|
||||||
private final String jarStoragePath;
|
private final String jarStoragePath;
|
||||||
@@ -30,6 +34,10 @@ public class AppService {
|
|||||||
public List<App> listByEnvironment(UUID environmentId) { return appRepo.findByEnvironmentId(environmentId); }
|
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 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 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 List<AppVersion> listVersions(UUID appId) { return versionRepo.findByAppId(appId); }
|
||||||
|
|
||||||
public AppVersion getVersion(UUID versionId) {
|
public AppVersion getVersion(UUID versionId) {
|
||||||
@@ -43,6 +51,10 @@ public class AppService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public UUID createApp(UUID environmentId, String slug, String displayName) {
|
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()) {
|
if (appRepo.findByEnvironmentIdAndSlug(environmentId, slug).isPresent()) {
|
||||||
throw new IllegalArgumentException("App with slug '" + slug + "' already exists in this environment");
|
throw new IllegalArgumentException("App with slug '" + slug + "' already exists in this environment");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,19 @@ package com.cameleer.server.core.runtime;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
public class EnvironmentService {
|
public class EnvironmentService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Slug must start with a lowercase letter or digit, contain only lowercase
|
||||||
|
* letters, digits, and hyphens, and be 1–64 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;
|
private final EnvironmentRepository repo;
|
||||||
|
|
||||||
public EnvironmentService(EnvironmentRepository repo) {
|
public EnvironmentService(EnvironmentRepository repo) {
|
||||||
@@ -22,6 +33,10 @@ public class EnvironmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public UUID create(String slug, String displayName, boolean production) {
|
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()) {
|
if (repo.findBySlug(slug).isPresent()) {
|
||||||
throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists");
|
throw new IllegalArgumentException("Environment with slug '" + slug + "' already exists");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ public class SearchService {
|
|||||||
return searchIndex.count(request);
|
return searchIndex.count(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<String> distinctAttributeKeys() {
|
public List<String> distinctAttributeKeys(String environment) {
|
||||||
return searchIndex.distinctAttributeKeys();
|
return searchIndex.distinctAttributeKeys(environment);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ExecutionStats stats(Instant from, Instant to) {
|
public ExecutionStats stats(Instant from, Instant to) {
|
||||||
|
|||||||
@@ -17,6 +17,6 @@ public interface SearchIndex {
|
|||||||
|
|
||||||
void delete(String executionId);
|
void delete(String executionId);
|
||||||
|
|
||||||
/** Returns distinct attribute key names across all executions. */
|
/** Returns distinct attribute key names across executions in the given environment. */
|
||||||
List<String> distinctAttributeKeys();
|
List<String> distinctAttributeKeys(String environment);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,14 @@ export interface Deployment {
|
|||||||
createdAt: string;
|
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 token = useAuthStore.getState().accessToken;
|
||||||
const res = await fetch(`${config.apiBaseUrl}/apps${path}`, {
|
const res = await fetch(`${config.apiBaseUrl}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -65,28 +70,28 @@ async function appFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
|||||||
return JSON.parse(text);
|
return JSON.parse(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Apps ---
|
function envBase(envSlug: string): string {
|
||||||
|
return `/environments/${encodeURIComponent(envSlug)}/apps`;
|
||||||
export function useAllApps() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ['apps', 'all'],
|
|
||||||
queryFn: () => appFetch<App[]>(''),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useApps(environmentId: string | undefined) {
|
// --- Apps ---
|
||||||
|
|
||||||
|
export function useApps(envSlug: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['apps', environmentId],
|
queryKey: ['apps', envSlug],
|
||||||
queryFn: () => appFetch<App[]>(`?environmentId=${environmentId}`),
|
queryFn: () => apiFetch<App[]>(envBase(envSlug!)),
|
||||||
enabled: !!environmentId,
|
enabled: !!envSlug,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateApp() {
|
export function useCreateApp() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (req: { environmentId: string; slug: string; displayName: string }) =>
|
mutationFn: ({ envSlug, slug, displayName }: { envSlug: string; slug: string; displayName: string }) =>
|
||||||
appFetch<App>('', { method: 'POST', body: JSON.stringify(req) }),
|
apiFetch<App>(envBase(envSlug), {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ slug, displayName }),
|
||||||
|
}),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -94,8 +99,8 @@ export function useCreateApp() {
|
|||||||
export function useDeleteApp() {
|
export function useDeleteApp() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (slug: string) =>
|
mutationFn: ({ envSlug, appSlug }: { envSlug: string; appSlug: string }) =>
|
||||||
appFetch<void>(`/${slug}`, { method: 'DELETE' }),
|
apiFetch<void>(`${envBase(envSlug)}/${encodeURIComponent(appSlug)}`, { method: 'DELETE' }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ['apps'] });
|
qc.invalidateQueries({ queryKey: ['apps'] });
|
||||||
qc.invalidateQueries({ queryKey: ['catalog'] });
|
qc.invalidateQueries({ queryKey: ['catalog'] });
|
||||||
@@ -106,30 +111,34 @@ export function useDeleteApp() {
|
|||||||
export function useUpdateContainerConfig() {
|
export function useUpdateContainerConfig() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ appId, config }: { appId: string; config: Record<string, unknown> }) =>
|
mutationFn: ({ envSlug, appSlug, config }: { envSlug: string; appSlug: string; config: Record<string, unknown> }) =>
|
||||||
appFetch<App>(`/${appId}/container-config`, { method: 'PUT', body: JSON.stringify(config) }),
|
apiFetch<App>(`${envBase(envSlug)}/${encodeURIComponent(appSlug)}/container-config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
}),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['apps'] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Versions ---
|
// --- Versions ---
|
||||||
|
|
||||||
export function useAppVersions(appId: string | undefined) {
|
export function useAppVersions(envSlug: string | undefined, appSlug: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['apps', appId, 'versions'],
|
queryKey: ['apps', envSlug, appSlug, 'versions'],
|
||||||
queryFn: () => appFetch<AppVersion[]>(`/${appId}/versions`),
|
queryFn: () => apiFetch<AppVersion[]>(`${envBase(envSlug!)}/${encodeURIComponent(appSlug!)}/versions`),
|
||||||
enabled: !!appId,
|
enabled: !!envSlug && !!appSlug,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUploadJar() {
|
export function useUploadJar() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
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 token = useAuthStore.getState().accessToken;
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
@@ -140,18 +149,18 @@ export function useUploadJar() {
|
|||||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
||||||
return res.json() as Promise<AppVersion>;
|
return res.json() as Promise<AppVersion>;
|
||||||
},
|
},
|
||||||
onSuccess: (_data, { appId }) =>
|
onSuccess: (_data, { envSlug, appSlug }) =>
|
||||||
qc.invalidateQueries({ queryKey: ['apps', appId, 'versions'] }),
|
qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'versions'] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Deployments ---
|
// --- Deployments ---
|
||||||
|
|
||||||
export function useDeployments(appId: string | undefined) {
|
export function useDeployments(envSlug: string | undefined, appSlug: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['apps', appId, 'deployments'],
|
queryKey: ['apps', envSlug, appSlug, 'deployments'],
|
||||||
queryFn: () => appFetch<Deployment[]>(`/${appId}/deployments`),
|
queryFn: () => apiFetch<Deployment[]>(`${envBase(envSlug!)}/${encodeURIComponent(appSlug!)}/deployments`),
|
||||||
enabled: !!appId,
|
enabled: !!envSlug && !!appSlug,
|
||||||
refetchInterval: 5000,
|
refetchInterval: 5000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -159,19 +168,38 @@ export function useDeployments(appId: string | undefined) {
|
|||||||
export function useCreateDeployment() {
|
export function useCreateDeployment() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ appId, ...req }: { appId: string; appVersionId: string; environmentId: string }) =>
|
mutationFn: ({ envSlug, appSlug, appVersionId }: { envSlug: string; appSlug: string; appVersionId: string }) =>
|
||||||
appFetch<Deployment>(`/${appId}/deployments`, { method: 'POST', body: JSON.stringify(req) }),
|
apiFetch<Deployment>(
|
||||||
onSuccess: (_data, { appId }) =>
|
`${envBase(envSlug)}/${encodeURIComponent(appSlug)}/deployments`,
|
||||||
qc.invalidateQueries({ queryKey: ['apps', appId, 'deployments'] }),
|
{ method: 'POST', body: JSON.stringify({ appVersionId }) },
|
||||||
|
),
|
||||||
|
onSuccess: (_data, { envSlug, appSlug }) =>
|
||||||
|
qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'deployments'] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStopDeployment() {
|
export function useStopDeployment() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ appId, deploymentId }: { appId: string; deploymentId: string }) =>
|
mutationFn: ({ envSlug, appSlug, deploymentId }: { envSlug: string; appSlug: string; deploymentId: string }) =>
|
||||||
appFetch<Deployment>(`/${appId}/deployments/${deploymentId}/stop`, { method: 'POST' }),
|
apiFetch<Deployment>(
|
||||||
onSuccess: (_data, { appId }) =>
|
`${envBase(envSlug)}/${encodeURIComponent(appSlug)}/deployments/${deploymentId}/stop`,
|
||||||
qc.invalidateQueries({ queryKey: ['apps', appId, 'deployments'] }),
|
{ 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'] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ export function useCreateEnvironment() {
|
|||||||
export function useUpdateEnvironment() {
|
export function useUpdateEnvironment() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, ...req }: UpdateEnvironmentRequest & { id: string }) =>
|
mutationFn: ({ slug, ...req }: UpdateEnvironmentRequest & { slug: string }) =>
|
||||||
adminFetch<Environment>(`/environments/${id}`, {
|
adminFetch<Environment>(`/environments/${slug}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(req),
|
body: JSON.stringify(req),
|
||||||
}),
|
}),
|
||||||
@@ -58,8 +58,8 @@ export function useUpdateEnvironment() {
|
|||||||
export function useUpdateDefaultContainerConfig() {
|
export function useUpdateDefaultContainerConfig() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, config }: { id: string; config: Record<string, unknown> }) =>
|
mutationFn: ({ slug, config }: { slug: string; config: Record<string, unknown> }) =>
|
||||||
adminFetch<Environment>(`/environments/${id}/default-container-config`, {
|
adminFetch<Environment>(`/environments/${slug}/default-container-config`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(config),
|
body: JSON.stringify(config),
|
||||||
}),
|
}),
|
||||||
@@ -70,8 +70,8 @@ export function useUpdateDefaultContainerConfig() {
|
|||||||
export function useUpdateJarRetention() {
|
export function useUpdateJarRetention() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ id, jarRetentionCount }: { id: string; jarRetentionCount: number | null }) =>
|
mutationFn: ({ slug, jarRetentionCount }: { slug: string; jarRetentionCount: number | null }) =>
|
||||||
adminFetch<Environment>(`/environments/${id}/jar-retention`, {
|
adminFetch<Environment>(`/environments/${slug}/jar-retention`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ jarRetentionCount }),
|
body: JSON.stringify({ jarRetentionCount }),
|
||||||
}),
|
}),
|
||||||
@@ -82,8 +82,8 @@ export function useUpdateJarRetention() {
|
|||||||
export function useDeleteEnvironment() {
|
export function useDeleteEnvironment() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (slug: string) =>
|
||||||
adminFetch<void>(`/environments/${id}`, { method: 'DELETE' }),
|
adminFetch<void>(`/environments/${slug}`, { method: 'DELETE' }),
|
||||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'environments'] }),
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'environments'] }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { config } from '../../config';
|
import { config } from '../../config';
|
||||||
import { useAuthStore } from '../../auth/auth-store';
|
import { useAuthStore } from '../../auth/auth-store';
|
||||||
|
import { useEnvironmentStore } from '../environment-store';
|
||||||
import { useRefreshInterval } from './use-refresh-interval';
|
import { useRefreshInterval } from './use-refresh-interval';
|
||||||
|
|
||||||
export function useAgentMetrics(
|
export function useAgentMetrics(
|
||||||
@@ -11,9 +12,10 @@ export function useAgentMetrics(
|
|||||||
to?: string,
|
to?: string,
|
||||||
mode: 'gauge' | 'delta' = 'gauge',
|
mode: 'gauge' | 'delta' = 'gauge',
|
||||||
) {
|
) {
|
||||||
|
const environment = useEnvironmentStore((s) => s.environment);
|
||||||
const refetchInterval = useRefreshInterval(30_000);
|
const refetchInterval = useRefreshInterval(30_000);
|
||||||
return useQuery({
|
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 () => {
|
queryFn: async () => {
|
||||||
const token = useAuthStore.getState().accessToken;
|
const token = useAuthStore.getState().accessToken;
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -23,16 +25,18 @@ export function useAgentMetrics(
|
|||||||
});
|
});
|
||||||
if (from) params.set('from', from);
|
if (from) params.set('from', from);
|
||||||
if (to) params.set('to', to);
|
if (to) params.set('to', to);
|
||||||
const res = await fetch(`${config.apiBaseUrl}/agents/${agentId}/metrics?${params}`, {
|
const res = await fetch(
|
||||||
headers: {
|
`${config.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/agents/${encodeURIComponent(agentId!)}/metrics?${params}`,
|
||||||
Authorization: `Bearer ${token}`,
|
{
|
||||||
'X-Cameleer-Protocol-Version': '1',
|
headers: {
|
||||||
},
|
Authorization: `Bearer ${token}`,
|
||||||
});
|
'X-Cameleer-Protocol-Version': '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
if (!res.ok) throw new Error(`${res.status}`);
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
return res.json() as Promise<{ metrics: Record<string, Array<{ time: string; value: number }>> }>;
|
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,
|
refetchInterval,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,60 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { config } from '../../config';
|
import { config } from '../../config';
|
||||||
import { useAuthStore } from '../../auth/auth-store';
|
import { useAuthStore } from '../../auth/auth-store';
|
||||||
|
import { useEnvironmentStore } from '../environment-store';
|
||||||
import { useRefreshInterval } from './use-refresh-interval';
|
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);
|
const refetchInterval = useRefreshInterval(10_000);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['agents', status, application, environment],
|
queryKey: ['agents', environment, status, application],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const token = useAuthStore.getState().accessToken;
|
const token = useAuthStore.getState().accessToken;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (status) params.set('status', status);
|
if (status) params.set('status', status);
|
||||||
if (application) params.set('application', application);
|
if (application) params.set('application', application);
|
||||||
if (environment) params.set('environment', environment);
|
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
const res = await fetch(`${config.apiBaseUrl}/agents${qs ? `?${qs}` : ''}`, {
|
const res = await fetch(
|
||||||
headers: {
|
`${config.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/agents${qs ? `?${qs}` : ''}`,
|
||||||
Authorization: `Bearer ${token}`,
|
{
|
||||||
'X-Cameleer-Protocol-Version': '1',
|
headers: {
|
||||||
},
|
Authorization: `Bearer ${token}`,
|
||||||
});
|
'X-Cameleer-Protocol-Version': '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
if (!res.ok) throw new Error('Failed to load agents');
|
if (!res.ok) throw new Error('Failed to load agents');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
enabled: !!environment,
|
||||||
refetchInterval,
|
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);
|
const refetchInterval = useRefreshInterval(15_000);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['agents', 'events', appId, agentId, limit, toOverride, environment],
|
queryKey: ['agents', 'events', environment, appId, agentId, limit, toOverride],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const token = useAuthStore.getState().accessToken;
|
const token = useAuthStore.getState().accessToken;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (appId) params.set('appId', appId);
|
if (appId) params.set('appId', appId);
|
||||||
if (agentId) params.set('agentId', agentId);
|
if (agentId) params.set('agentId', agentId);
|
||||||
if (environment) params.set('environment', environment);
|
|
||||||
if (toOverride) params.set('to', toOverride);
|
if (toOverride) params.set('to', toOverride);
|
||||||
params.set('limit', String(limit));
|
params.set('limit', String(limit));
|
||||||
const res = await fetch(`${config.apiBaseUrl}/agents/events-log?${params}`, {
|
const res = await fetch(
|
||||||
headers: {
|
`${config.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/agents/events?${params}`,
|
||||||
Authorization: `Bearer ${token}`,
|
{
|
||||||
'X-Cameleer-Protocol-Version': '1',
|
headers: {
|
||||||
},
|
Authorization: `Bearer ${token}`,
|
||||||
});
|
'X-Cameleer-Protocol-Version': '1',
|
||||||
|
},
|
||||||
|
});
|
||||||
if (!res.ok) throw new Error('Failed to load agent events');
|
if (!res.ok) throw new Error('Failed to load agent events');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
enabled: !!environment,
|
||||||
refetchInterval,
|
refetchInterval,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,15 +89,15 @@ export function useDismissApp() {
|
|||||||
export function useRouteMetrics(from?: string, to?: string, appId?: string, environment?: string) {
|
export function useRouteMetrics(from?: string, to?: string, appId?: string, environment?: string) {
|
||||||
const refetchInterval = useRefreshInterval(30_000);
|
const refetchInterval = useRefreshInterval(30_000);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['routes', 'metrics', from, to, appId, environment],
|
queryKey: ['routes', 'metrics', environment, from, to, appId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const token = useAuthStore.getState().accessToken;
|
const token = useAuthStore.getState().accessToken;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (from) params.set('from', from);
|
if (from) params.set('from', from);
|
||||||
if (to) params.set('to', to);
|
if (to) params.set('to', to);
|
||||||
if (appId) params.set('appId', appId);
|
if (appId) params.set('appId', appId);
|
||||||
if (environment) params.set('environment', environment);
|
const res = await fetch(
|
||||||
const res = await fetch(`${config.apiBaseUrl}/routes/metrics?${params}`, {
|
`${config.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/routes/metrics?${params}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'X-Cameleer-Protocol-Version': '1',
|
'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');
|
if (!res.ok) throw new Error('Failed to load route metrics');
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
|
enabled: !!environment,
|
||||||
placeholderData: (prev: unknown) => prev,
|
placeholderData: (prev: unknown) => prev,
|
||||||
refetchInterval,
|
refetchInterval,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -45,23 +45,24 @@ function authFetch(path: string, init?: RequestInit): Promise<Response> {
|
|||||||
return fetch(`${config.apiBaseUrl}${path}`, { ...init, headers })
|
return fetch(`${config.apiBaseUrl}${path}`, { ...init, headers })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAllApplicationConfigs() {
|
export function useAllApplicationConfigs(environment: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['applicationConfig', 'all'],
|
queryKey: ['applicationConfig', 'all', environment],
|
||||||
queryFn: async () => {
|
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')
|
if (!res.ok) throw new Error('Failed to fetch configs')
|
||||||
return res.json() as Promise<ApplicationConfig[]>
|
return res.json() as Promise<ApplicationConfig[]>
|
||||||
},
|
},
|
||||||
|
enabled: !!environment,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useApplicationConfig(application: string | undefined, environment: string | undefined) {
|
export function useApplicationConfig(application: string | undefined, environment: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['applicationConfig', application, environment],
|
queryKey: ['applicationConfig', environment, application],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const envParam = environment ? `?environment=${encodeURIComponent(environment)}` : ''
|
const res = await authFetch(
|
||||||
const res = await authFetch(`/config/${application}${envParam}`)
|
`/environments/${encodeURIComponent(environment!)}/apps/${encodeURIComponent(application!)}/config`)
|
||||||
if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`)
|
if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
// Server returns AppConfigResponse: { config, globalSensitiveKeys, mergedSensitiveKeys }
|
// Server returns AppConfigResponse: { config, globalSensitiveKeys, mergedSensitiveKeys }
|
||||||
@@ -82,9 +83,9 @@ export interface ConfigUpdateResponse {
|
|||||||
export function useUpdateApplicationConfig() {
|
export function useUpdateApplicationConfig() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ config, environment }: { config: ApplicationConfig; environment?: string }) => {
|
mutationFn: async ({ config, environment }: { config: ApplicationConfig; environment: string }) => {
|
||||||
const envParam = environment ? `?environment=${encodeURIComponent(environment)}` : ''
|
const res = await authFetch(
|
||||||
const res = await authFetch(`/config/${config.application}${envParam}`, {
|
`/environments/${encodeURIComponent(environment)}/apps/${encodeURIComponent(config.application)}/config`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(config),
|
body: JSON.stringify(config),
|
||||||
@@ -92,9 +93,9 @@ export function useUpdateApplicationConfig() {
|
|||||||
if (!res.ok) throw new Error('Failed to update config')
|
if (!res.ok) throw new Error('Failed to update config')
|
||||||
return res.json() as Promise<ConfigUpdateResponse>
|
return res.json() as Promise<ConfigUpdateResponse>
|
||||||
},
|
},
|
||||||
onSuccess: (result) => {
|
onSuccess: (result, vars) => {
|
||||||
queryClient.setQueryData(['applicationConfig', result.config.application], result.config)
|
queryClient.setQueryData(['applicationConfig', vars.environment, result.config.application], result.config)
|
||||||
queryClient.invalidateQueries({ queryKey: ['applicationConfig', 'all'] })
|
queryClient.invalidateQueries({ queryKey: ['applicationConfig'] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -103,10 +104,10 @@ export function useUpdateApplicationConfig() {
|
|||||||
|
|
||||||
export function useProcessorRouteMapping(application?: string, environment?: string) {
|
export function useProcessorRouteMapping(application?: string, environment?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['config', application, environment, 'processor-routes'],
|
queryKey: ['config', environment, application, 'processor-routes'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await authFetch(
|
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')
|
if (!res.ok) throw new Error('Failed to fetch processor-route mapping')
|
||||||
return res.json() as Promise<Record<string, string>>
|
return res.json() as Promise<Record<string, string>>
|
||||||
},
|
},
|
||||||
@@ -154,19 +155,21 @@ export function useTestExpression() {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async ({
|
||||||
application,
|
application,
|
||||||
|
environment,
|
||||||
expression,
|
expression,
|
||||||
language,
|
language,
|
||||||
body,
|
body,
|
||||||
target,
|
target,
|
||||||
}: {
|
}: {
|
||||||
application: string
|
application: string
|
||||||
|
environment: string
|
||||||
expression: string
|
expression: string
|
||||||
language: string
|
language: string
|
||||||
body: string
|
body: string
|
||||||
target: string
|
target: string
|
||||||
}) => {
|
}) => {
|
||||||
const res = await authFetch(
|
const res = await authFetch(
|
||||||
`/config/${encodeURIComponent(application)}/test-expression`,
|
`/environments/${encodeURIComponent(environment)}/apps/${encodeURIComponent(application)}/config/test-expression`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -1,21 +1,31 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
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) {
|
export function useCorrelationChain(correlationId: string | null, environment?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['correlation-chain', correlationId, environment],
|
queryKey: ['correlation-chain', environment, correlationId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data } = await api.POST('/search/executions', {
|
const token = useAuthStore.getState().accessToken;
|
||||||
body: {
|
const res = await fetch(
|
||||||
correlationId: correlationId!,
|
`${appConfig.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/executions/search`,
|
||||||
environment,
|
{
|
||||||
limit: 20,
|
method: 'POST',
|
||||||
sortField: 'startTime',
|
headers: {
|
||||||
sortDir: 'asc',
|
'Content-Type': 'application/json',
|
||||||
},
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
});
|
'X-Cameleer-Protocol-Version': '1',
|
||||||
return data;
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
correlationId: correlationId!,
|
||||||
|
limit: 20,
|
||||||
|
sortField: 'startTime',
|
||||||
|
sortDir: 'asc',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Failed to load correlation chain');
|
||||||
|
return res.json();
|
||||||
},
|
},
|
||||||
enabled: !!correlationId,
|
enabled: !!correlationId && !!environment,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,11 +42,12 @@ export interface GroupedTimeseries {
|
|||||||
export function useTimeseriesByApp(from?: string, to?: string, environment?: string) {
|
export function useTimeseriesByApp(from?: string, to?: string, environment?: string) {
|
||||||
const refetchInterval = useRefreshInterval(30_000);
|
const refetchInterval = useRefreshInterval(30_000);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['dashboard', 'timeseries-by-app', from, to, environment],
|
queryKey: ['dashboard', 'timeseries-by-app', environment, from, to],
|
||||||
queryFn: () => fetchJson<GroupedTimeseries>('/search/stats/timeseries/by-app', {
|
queryFn: () => fetchJson<GroupedTimeseries>(
|
||||||
from, to, buckets: '24', environment,
|
`/environments/${encodeURIComponent(environment!)}/stats/timeseries/by-app`, {
|
||||||
|
from, to, buckets: '24',
|
||||||
}),
|
}),
|
||||||
enabled: !!from,
|
enabled: !!from && !!environment,
|
||||||
placeholderData: (prev: GroupedTimeseries | undefined) => prev,
|
placeholderData: (prev: GroupedTimeseries | undefined) => prev,
|
||||||
refetchInterval,
|
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) {
|
export function useTimeseriesByRoute(from?: string, to?: string, application?: string, environment?: string) {
|
||||||
const refetchInterval = useRefreshInterval(30_000);
|
const refetchInterval = useRefreshInterval(30_000);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['dashboard', 'timeseries-by-route', from, to, application, environment],
|
queryKey: ['dashboard', 'timeseries-by-route', environment, from, to, application],
|
||||||
queryFn: () => fetchJson<GroupedTimeseries>('/search/stats/timeseries/by-route', {
|
queryFn: () => fetchJson<GroupedTimeseries>(
|
||||||
from, to, application, buckets: '24', environment,
|
`/environments/${encodeURIComponent(environment!)}/stats/timeseries/by-route`, {
|
||||||
|
from, to, application, buckets: '24',
|
||||||
}),
|
}),
|
||||||
enabled: !!from && !!application,
|
enabled: !!from && !!application && !!environment,
|
||||||
placeholderData: (prev: GroupedTimeseries | undefined) => prev,
|
placeholderData: (prev: GroupedTimeseries | undefined) => prev,
|
||||||
refetchInterval,
|
refetchInterval,
|
||||||
});
|
});
|
||||||
@@ -82,11 +84,12 @@ export interface TopError {
|
|||||||
export function useTopErrors(from?: string, to?: string, application?: string, routeId?: string, environment?: string) {
|
export function useTopErrors(from?: string, to?: string, application?: string, routeId?: string, environment?: string) {
|
||||||
const refetchInterval = useRefreshInterval(10_000);
|
const refetchInterval = useRefreshInterval(10_000);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['dashboard', 'top-errors', from, to, application, routeId, environment],
|
queryKey: ['dashboard', 'top-errors', environment, from, to, application, routeId],
|
||||||
queryFn: () => fetchJson<TopError[]>('/search/errors/top', {
|
queryFn: () => fetchJson<TopError[]>(
|
||||||
from, to, application, routeId, limit: '5', environment,
|
`/environments/${encodeURIComponent(environment!)}/errors/top`, {
|
||||||
|
from, to, application, routeId, limit: '5',
|
||||||
}),
|
}),
|
||||||
enabled: !!from,
|
enabled: !!from && !!environment,
|
||||||
placeholderData: (prev: TopError[] | undefined) => prev,
|
placeholderData: (prev: TopError[] | undefined) => prev,
|
||||||
refetchInterval,
|
refetchInterval,
|
||||||
});
|
});
|
||||||
@@ -104,8 +107,10 @@ export interface PunchcardCell {
|
|||||||
export function usePunchcard(application?: string, environment?: string) {
|
export function usePunchcard(application?: string, environment?: string) {
|
||||||
const refetchInterval = useRefreshInterval(60_000);
|
const refetchInterval = useRefreshInterval(60_000);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['dashboard', 'punchcard', application, environment],
|
queryKey: ['dashboard', 'punchcard', environment, application],
|
||||||
queryFn: () => fetchJson<PunchcardCell[]>('/search/stats/punchcard', { application, environment }),
|
queryFn: () => fetchJson<PunchcardCell[]>(
|
||||||
|
`/environments/${encodeURIComponent(environment!)}/stats/punchcard`, { application }),
|
||||||
|
enabled: !!environment,
|
||||||
placeholderData: (prev: PunchcardCell[] | undefined) => prev ?? [],
|
placeholderData: (prev: PunchcardCell[] | undefined) => prev ?? [],
|
||||||
refetchInterval,
|
refetchInterval,
|
||||||
});
|
});
|
||||||
@@ -127,9 +132,9 @@ export interface AppSettings {
|
|||||||
|
|
||||||
export function useAppSettings(appId?: string, environment?: string) {
|
export function useAppSettings(appId?: string, environment?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['app-settings', appId, environment],
|
queryKey: ['app-settings', environment, appId],
|
||||||
queryFn: () => fetchJson<AppSettings>(
|
queryFn: () => fetchJson<AppSettings>(
|
||||||
`/admin/app-settings/${appId}?environment=${encodeURIComponent(environment!)}`),
|
`/environments/${encodeURIComponent(environment!)}/apps/${encodeURIComponent(appId!)}/settings`),
|
||||||
enabled: !!appId && !!environment,
|
enabled: !!appId && !!environment,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
@@ -139,7 +144,7 @@ export function useAllAppSettings(environment?: string) {
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['app-settings', 'all', environment],
|
queryKey: ['app-settings', 'all', environment],
|
||||||
queryFn: () => fetchJson<AppSettings[]>(
|
queryFn: () => fetchJson<AppSettings[]>(
|
||||||
`/admin/app-settings?environment=${encodeURIComponent(environment!)}`),
|
`/environments/${encodeURIComponent(environment!)}/app-settings`),
|
||||||
enabled: !!environment,
|
enabled: !!environment,
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
@@ -151,7 +156,7 @@ export function useUpdateAppSettings() {
|
|||||||
mutationFn: async ({ appId, environment, settings }:
|
mutationFn: async ({ appId, environment, settings }:
|
||||||
{ appId: string; environment: string; settings: Omit<AppSettings, 'appId' | 'createdAt' | 'updatedAt'> }) => {
|
{ appId: string; environment: string; settings: Omit<AppSettings, 'appId' | 'createdAt' | 'updatedAt'> }) => {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${config.apiBaseUrl}/admin/app-settings/${appId}?environment=${encodeURIComponent(environment)}`,
|
`${config.apiBaseUrl}/environments/${encodeURIComponent(environment)}/apps/${encodeURIComponent(appId)}/settings`,
|
||||||
{
|
{
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '../client';
|
import { api } from '../client';
|
||||||
|
import { useEnvironmentStore } from '../environment-store';
|
||||||
|
|
||||||
export interface DiagramNode {
|
export interface DiagramNode {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -53,15 +54,26 @@ export function useDiagramByRoute(
|
|||||||
routeId: string | undefined,
|
routeId: string | undefined,
|
||||||
direction: 'LR' | 'TB' = 'LR',
|
direction: 'LR' | 'TB' = 'LR',
|
||||||
) {
|
) {
|
||||||
|
const environment = useEnvironmentStore((s) => s.environment);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['diagrams', 'byRoute', application, routeId, direction],
|
queryKey: ['diagrams', 'byRoute', environment, application, routeId, direction],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await api.GET('/diagrams', {
|
const { useAuthStore } = await import('../../auth/auth-store');
|
||||||
params: { query: { application: application!, routeId: routeId!, direction } },
|
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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (error) throw new Error('Failed to load diagram for route');
|
if (!res.ok) throw new Error('Failed to load diagram for route');
|
||||||
return data as DiagramLayout;
|
return (await res.json()) as DiagramLayout;
|
||||||
},
|
},
|
||||||
enabled: !!application && !!routeId,
|
enabled: !!application && !!routeId && !!environment,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,35 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api } from '../client';
|
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 type { SearchRequest } from '../types';
|
||||||
import { useLiveQuery } from './use-refresh-interval';
|
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(
|
export function useExecutionStats(
|
||||||
timeFrom: string | undefined,
|
timeFrom: string | undefined,
|
||||||
timeTo: string | undefined,
|
timeTo: string | undefined,
|
||||||
@@ -12,57 +39,41 @@ export function useExecutionStats(
|
|||||||
) {
|
) {
|
||||||
const live = useLiveQuery(10_000);
|
const live = useLiveQuery(10_000);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, application, environment],
|
queryKey: ['executions', 'stats', environment, timeFrom, timeTo, routeId, application],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await api.GET('/search/stats', {
|
const params = new URLSearchParams({ from: timeFrom! });
|
||||||
params: {
|
if (timeTo) params.set('to', timeTo);
|
||||||
query: {
|
if (routeId) params.set('routeId', routeId);
|
||||||
from: timeFrom!,
|
if (application) params.set('application', application);
|
||||||
to: timeTo || undefined,
|
return envFetch<ExecutionStats>(environment!, `/stats?${params}`);
|
||||||
routeId: routeId || undefined,
|
|
||||||
application: application || undefined,
|
|
||||||
environment: environment || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (error) throw new Error('Failed to load stats');
|
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: !!timeFrom && live.enabled,
|
enabled: !!timeFrom && !!environment && live.enabled,
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
refetchInterval: live.refetchInterval,
|
refetchInterval: live.refetchInterval,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAttributeKeys() {
|
export function useAttributeKeys() {
|
||||||
|
const environment = useEnvironmentStore((s) => s.environment);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['search', 'attribute-keys'],
|
queryKey: ['search', 'attribute-keys', environment],
|
||||||
queryFn: async () => {
|
queryFn: () => envFetch<string[]>(environment!, '/attributes/keys'),
|
||||||
const token = (await import('../../auth/auth-store')).useAuthStore.getState().accessToken;
|
enabled: !!environment,
|
||||||
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[]>;
|
|
||||||
},
|
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSearchExecutions(filters: SearchRequest, live = false) {
|
export function useSearchExecutions(filters: SearchRequest, live = false) {
|
||||||
|
const environment = useEnvironmentStore((s) => s.environment);
|
||||||
const liveQuery = useLiveQuery(5_000);
|
const liveQuery = useLiveQuery(5_000);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['executions', 'search', filters],
|
queryKey: ['executions', 'search', environment, filters],
|
||||||
queryFn: async () => {
|
queryFn: () => envFetch<SearchResultSummary>(environment!, '/executions/search', {
|
||||||
const { data, error } = await api.POST('/search/executions', {
|
method: 'POST',
|
||||||
body: filters,
|
body: JSON.stringify(filters),
|
||||||
});
|
}),
|
||||||
if (error) throw new Error('Search failed');
|
|
||||||
return data!;
|
|
||||||
},
|
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
enabled: live ? liveQuery.enabled : true,
|
enabled: !!environment && (live ? liveQuery.enabled : true),
|
||||||
refetchInterval: live ? liveQuery.refetchInterval : false,
|
refetchInterval: live ? liveQuery.refetchInterval : false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -76,24 +87,15 @@ export function useStatsTimeseries(
|
|||||||
) {
|
) {
|
||||||
const live = useLiveQuery(30_000);
|
const live = useLiveQuery(30_000);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, application, environment],
|
queryKey: ['executions', 'timeseries', environment, timeFrom, timeTo, routeId, application],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const { data, error } = await api.GET('/search/stats/timeseries', {
|
const params = new URLSearchParams({ from: timeFrom!, buckets: '24' });
|
||||||
params: {
|
if (timeTo) params.set('to', timeTo);
|
||||||
query: {
|
if (routeId) params.set('routeId', routeId);
|
||||||
from: timeFrom!,
|
if (application) params.set('application', application);
|
||||||
to: timeTo || undefined,
|
return envFetch<StatsTimeseries>(environment!, `/stats/timeseries?${params}`);
|
||||||
buckets: 24,
|
|
||||||
routeId: routeId || undefined,
|
|
||||||
application: application || undefined,
|
|
||||||
environment: environment || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (error) throw new Error('Failed to load timeseries');
|
|
||||||
return data!;
|
|
||||||
},
|
},
|
||||||
enabled: !!timeFrom && live.enabled,
|
enabled: !!timeFrom && !!environment && live.enabled,
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
refetchInterval: live.refetchInterval,
|
refetchInterval: live.refetchInterval,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ export interface LogSearchParams {
|
|||||||
application?: string;
|
application?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
environment?: string;
|
/** Required: env in path */
|
||||||
|
environment: string;
|
||||||
exchangeId?: string;
|
exchangeId?: string;
|
||||||
logger?: string;
|
logger?: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
@@ -50,7 +51,6 @@ async function fetchLogs(params: LogSearchParams): Promise<LogSearchPageResponse
|
|||||||
if (params.application) urlParams.set('application', params.application);
|
if (params.application) urlParams.set('application', params.application);
|
||||||
if (params.agentId) urlParams.set('agentId', params.agentId);
|
if (params.agentId) urlParams.set('agentId', params.agentId);
|
||||||
if (params.source) urlParams.set('source', params.source);
|
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.exchangeId) urlParams.set('exchangeId', params.exchangeId);
|
||||||
if (params.logger) urlParams.set('logger', params.logger);
|
if (params.logger) urlParams.set('logger', params.logger);
|
||||||
if (params.from) urlParams.set('from', params.from);
|
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.limit) urlParams.set('limit', String(params.limit));
|
||||||
if (params.sort) urlParams.set('sort', params.sort);
|
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: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
'X-Cameleer-Protocol-Version': '1',
|
'X-Cameleer-Protocol-Version': '1',
|
||||||
@@ -81,7 +82,7 @@ export function useLogs(
|
|||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['logs', params],
|
queryKey: ['logs', params],
|
||||||
queryFn: () => fetchLogs(params),
|
queryFn: () => fetchLogs(params),
|
||||||
enabled: options?.enabled ?? true,
|
enabled: (options?.enabled ?? true) && !!params.environment,
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
refetchInterval: options?.refetchInterval ?? defaultRefetch,
|
refetchInterval: options?.refetchInterval ?? defaultRefetch,
|
||||||
staleTime: 300,
|
staleTime: 300,
|
||||||
@@ -107,7 +108,7 @@ export function useApplicationLogs(
|
|||||||
application: application || undefined,
|
application: application || undefined,
|
||||||
agentId: agentId || undefined,
|
agentId: agentId || undefined,
|
||||||
source: options?.source || undefined,
|
source: options?.source || undefined,
|
||||||
environment: selectedEnv || undefined,
|
environment: selectedEnv ?? '',
|
||||||
exchangeId: options?.exchangeId || undefined,
|
exchangeId: options?.exchangeId || undefined,
|
||||||
from: useTimeRange ? timeRange.start.toISOString() : undefined,
|
from: useTimeRange ? timeRange.start.toISOString() : undefined,
|
||||||
to: useTimeRange ? to : undefined,
|
to: useTimeRange ? to : undefined,
|
||||||
@@ -120,7 +121,7 @@ export function useApplicationLogs(
|
|||||||
useTimeRange ? to : null,
|
useTimeRange ? to : null,
|
||||||
options?.limit, options?.exchangeId, options?.source],
|
options?.limit, options?.exchangeId, options?.source],
|
||||||
queryFn: () => fetchLogs(params),
|
queryFn: () => fetchLogs(params),
|
||||||
enabled: !!application,
|
enabled: !!application && !!selectedEnv,
|
||||||
placeholderData: (prev) => prev,
|
placeholderData: (prev) => prev,
|
||||||
refetchInterval,
|
refetchInterval,
|
||||||
});
|
});
|
||||||
@@ -144,7 +145,7 @@ export function useStartupLogs(
|
|||||||
) {
|
) {
|
||||||
const params: LogSearchParams = {
|
const params: LogSearchParams = {
|
||||||
application: application || undefined,
|
application: application || undefined,
|
||||||
environment: environment || undefined,
|
environment: environment ?? '',
|
||||||
source: 'container',
|
source: 'container',
|
||||||
from: deployCreatedAt || undefined,
|
from: deployCreatedAt || undefined,
|
||||||
sort: 'asc',
|
sort: 'asc',
|
||||||
@@ -152,7 +153,7 @@ export function useStartupLogs(
|
|||||||
};
|
};
|
||||||
|
|
||||||
return useLogs(params, {
|
return useLogs(params, {
|
||||||
enabled: !!application && !!deployCreatedAt,
|
enabled: !!application && !!deployCreatedAt && !!environment,
|
||||||
refetchInterval: isStarting ? 3_000 : false,
|
refetchInterval: isStarting ? 3_000 : false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,23 +6,24 @@ import { useRefreshInterval } from './use-refresh-interval';
|
|||||||
export function useProcessorMetrics(routeId: string | null, appId?: string, environment?: string) {
|
export function useProcessorMetrics(routeId: string | null, appId?: string, environment?: string) {
|
||||||
const refetchInterval = useRefreshInterval(30_000);
|
const refetchInterval = useRefreshInterval(30_000);
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['processor-metrics', routeId, appId, environment],
|
queryKey: ['processor-metrics', environment, routeId, appId],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const token = useAuthStore.getState().accessToken;
|
const token = useAuthStore.getState().accessToken;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (routeId) params.set('routeId', routeId);
|
if (routeId) params.set('routeId', routeId);
|
||||||
if (appId) params.set('appId', appId);
|
if (appId) params.set('appId', appId);
|
||||||
if (environment) params.set('environment', environment);
|
const res = await fetch(
|
||||||
const res = await fetch(`${config.apiBaseUrl}/routes/metrics/processors?${params}`, {
|
`${config.apiBaseUrl}/environments/${encodeURIComponent(environment!)}/routes/metrics/processors?${params}`,
|
||||||
headers: {
|
{
|
||||||
Authorization: `Bearer ${token}`,
|
headers: {
|
||||||
'X-Cameleer-Protocol-Version': '1',
|
Authorization: `Bearer ${token}`,
|
||||||
},
|
'X-Cameleer-Protocol-Version': '1',
|
||||||
});
|
},
|
||||||
|
});
|
||||||
if (!res.ok) throw new Error(`${res.status}`);
|
if (!res.ok) throw new Error(`${res.status}`);
|
||||||
return res.json();
|
return res.json();
|
||||||
},
|
},
|
||||||
enabled: !!routeId,
|
enabled: !!routeId && !!environment,
|
||||||
refetchInterval,
|
refetchInterval,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Input, Button, LogViewer } from '@cameleer/design-system';
|
|||||||
import type { LogEntry } from '@cameleer/design-system';
|
import type { LogEntry } from '@cameleer/design-system';
|
||||||
import { useLogs } from '../../../api/queries/logs';
|
import { useLogs } from '../../../api/queries/logs';
|
||||||
import type { LogEntryResponse } from '../../../api/queries/logs';
|
import type { LogEntryResponse } from '../../../api/queries/logs';
|
||||||
|
import { useEnvironmentStore } from '../../../api/environment-store';
|
||||||
import { mapLogLevel } from '../../../utils/agent-utils';
|
import { mapLogLevel } from '../../../utils/agent-utils';
|
||||||
import logStyles from './LogTab.module.css';
|
import logStyles from './LogTab.module.css';
|
||||||
import diagramStyles from '../ExecutionDiagram.module.css';
|
import diagramStyles from '../ExecutionDiagram.module.css';
|
||||||
@@ -27,9 +28,10 @@ export function LogTab({ applicationId, exchangeId, processorId }: LogTabProps)
|
|||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const environment = useEnvironmentStore((s) => s.environment) ?? '';
|
||||||
const { data: logPage, isLoading } = useLogs(
|
const { data: logPage, isLoading } = useLogs(
|
||||||
{ exchangeId, limit: 500 },
|
{ exchangeId, environment, limit: 500 },
|
||||||
{ enabled: !!exchangeId },
|
{ enabled: !!exchangeId && !!environment },
|
||||||
);
|
);
|
||||||
|
|
||||||
const entries = useMemo<LogEntry[]>(() => {
|
const entries = useMemo<LogEntry[]>(() => {
|
||||||
|
|||||||
@@ -303,8 +303,10 @@ function LayoutContent() {
|
|||||||
const setSelectedEnvRaw = useEnvironmentStore((s) => s.setEnvironment);
|
const setSelectedEnvRaw = useEnvironmentStore((s) => s.setEnvironment);
|
||||||
|
|
||||||
const { data: catalog } = useCatalog(selectedEnv);
|
const { data: catalog } = useCatalog(selectedEnv);
|
||||||
const { data: allAgents } = useAgents(); // unfiltered — for environment discovery
|
// Env is always required now (path-based endpoint). For cross-env "all agents"
|
||||||
const { data: agents } = useAgents(undefined, undefined, selectedEnv); // filtered — for sidebar/search
|
// 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: attributeKeys } = useAttributeKeys();
|
||||||
const { data: envRecords = [] } = useEnvironments();
|
const { data: envRecords = [] } = useEnvironments();
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export interface TapConfigModalProps {
|
|||||||
defaultProcessorId?: string;
|
defaultProcessorId?: string;
|
||||||
/** Application name (for test expression API) */
|
/** Application name (for test expression API) */
|
||||||
application: string;
|
application: string;
|
||||||
|
/** Environment slug (for test expression API) */
|
||||||
|
environment: string;
|
||||||
/** Current application config (taps array will be modified) */
|
/** Current application config (taps array will be modified) */
|
||||||
config: ApplicationConfig;
|
config: ApplicationConfig;
|
||||||
/** Called with the updated config to persist */
|
/** Called with the updated config to persist */
|
||||||
@@ -53,7 +55,7 @@ export interface TapConfigModalProps {
|
|||||||
|
|
||||||
export function TapConfigModal({
|
export function TapConfigModal({
|
||||||
open, onClose, tap, processorOptions, defaultProcessorId,
|
open, onClose, tap, processorOptions, defaultProcessorId,
|
||||||
application, config, onSave, onDelete,
|
application, environment, config, onSave, onDelete,
|
||||||
}: TapConfigModalProps) {
|
}: TapConfigModalProps) {
|
||||||
const isEdit = !!tap;
|
const isEdit = !!tap;
|
||||||
|
|
||||||
@@ -125,7 +127,7 @@ export function TapConfigModal({
|
|||||||
|
|
||||||
function handleTest() {
|
function handleTest() {
|
||||||
testMutation.mutate(
|
testMutation.mutate(
|
||||||
{ application, expression, language, body: testPayload, target },
|
{ application, environment, expression, language, body: testPayload, target },
|
||||||
{
|
{
|
||||||
onSuccess: (data) => setTestResult(data),
|
onSuccess: (data) => setTestResult(data),
|
||||||
onError: (err) => setTestResult({ error: (err as Error).message }),
|
onError: (err) => setTestResult({ error: (err as Error).message }),
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function EnvironmentsPage() {
|
|||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
try {
|
try {
|
||||||
await deleteEnv.mutateAsync(deleteTarget.id);
|
await deleteEnv.mutateAsync(deleteTarget.slug);
|
||||||
toast({ title: 'Environment deleted', description: deleteTarget.slug, variant: 'warning' });
|
toast({ title: 'Environment deleted', description: deleteTarget.slug, variant: 'warning' });
|
||||||
if (selectedId === deleteTarget.id) setSelectedId(null);
|
if (selectedId === deleteTarget.id) setSelectedId(null);
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
@@ -116,7 +116,7 @@ export default function EnvironmentsPage() {
|
|||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
try {
|
try {
|
||||||
await updateEnv.mutateAsync({
|
await updateEnv.mutateAsync({
|
||||||
id: selected.id,
|
slug: selected.slug,
|
||||||
displayName: newName,
|
displayName: newName,
|
||||||
production: selected.production,
|
production: selected.production,
|
||||||
enabled: selected.enabled,
|
enabled: selected.enabled,
|
||||||
@@ -131,7 +131,7 @@ export default function EnvironmentsPage() {
|
|||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
try {
|
try {
|
||||||
await updateEnv.mutateAsync({
|
await updateEnv.mutateAsync({
|
||||||
id: selected.id,
|
slug: selected.slug,
|
||||||
displayName: selected.displayName,
|
displayName: selected.displayName,
|
||||||
production: value,
|
production: value,
|
||||||
enabled: selected.enabled,
|
enabled: selected.enabled,
|
||||||
@@ -146,7 +146,7 @@ export default function EnvironmentsPage() {
|
|||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
try {
|
try {
|
||||||
await updateEnv.mutateAsync({
|
await updateEnv.mutateAsync({
|
||||||
id: selected.id,
|
slug: selected.slug,
|
||||||
displayName: selected.displayName,
|
displayName: selected.displayName,
|
||||||
production: selected.production,
|
production: selected.production,
|
||||||
enabled: value,
|
enabled: value,
|
||||||
@@ -300,7 +300,7 @@ export default function EnvironmentsPage() {
|
|||||||
|
|
||||||
<DefaultResourcesSection environment={selected} onSave={async (config) => {
|
<DefaultResourcesSection environment={selected} onSave={async (config) => {
|
||||||
try {
|
try {
|
||||||
await updateDefaults.mutateAsync({ id: selected.id, config });
|
await updateDefaults.mutateAsync({ slug: selected.slug, config });
|
||||||
toast({ title: 'Default resources updated', variant: 'success' });
|
toast({ title: 'Default resources updated', variant: 'success' });
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: 'Failed to update defaults', variant: 'error', duration: 86_400_000 });
|
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) => {
|
<JarRetentionSection environment={selected} onSave={async (count) => {
|
||||||
try {
|
try {
|
||||||
await updateRetention.mutateAsync({ id: selected.id, jarRetentionCount: count });
|
await updateRetention.mutateAsync({ slug: selected.slug, jarRetentionCount: count });
|
||||||
toast({ title: 'Retention policy updated', variant: 'success' });
|
toast({ title: 'Retention policy updated', variant: 'success' });
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: 'Failed to update retention', variant: 'error', duration: 86_400_000 });
|
toast({ title: 'Failed to update retention', variant: 'error', duration: 86_400_000 });
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export default function AgentHealth() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
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 { data: appConfig } = useApplicationConfig(appId, selectedEnv);
|
||||||
const updateConfig = useUpdateApplicationConfig();
|
const updateConfig = useUpdateApplicationConfig();
|
||||||
|
|
||||||
@@ -282,7 +282,7 @@ export default function AgentHealth() {
|
|||||||
}, [appConfig, configDraft, updateConfig, toast, appId]);
|
}, [appConfig, configDraft, updateConfig, toast, appId]);
|
||||||
const [eventSortAsc, setEventSortAsc] = useState(false);
|
const [eventSortAsc, setEventSortAsc] = useState(false);
|
||||||
const [eventRefreshTo, setEventRefreshTo] = useState<string | undefined>();
|
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('');
|
const [appFilter, setAppFilter] = useState('');
|
||||||
type AppSortKey = 'status' | 'name' | 'tps' | 'cpu' | 'heartbeat';
|
type AppSortKey = 'status' | 'name' | 'tps' | 'cpu' | 'heartbeat';
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export default function AgentInstance() {
|
|||||||
const timeTo = timeRange.end.toISOString();
|
const timeTo = timeRange.end.toISOString();
|
||||||
|
|
||||||
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
const selectedEnv = useEnvironmentStore((s) => s.environment);
|
||||||
const { data: agents, isLoading } = useAgents(undefined, appId, selectedEnv);
|
const { data: agents, isLoading } = useAgents(undefined, appId);
|
||||||
const { data: events } = useAgentEvents(appId, instanceId, 50, eventRefreshTo, selectedEnv);
|
const { data: events } = useAgentEvents(appId, instanceId, 50, eventRefreshTo);
|
||||||
|
|
||||||
const agent = useMemo(
|
const agent = useMemo(
|
||||||
() => (agents || []).find((a: any) => a.instanceId === instanceId) as any,
|
() => (agents || []).find((a: any) => a.instanceId === instanceId) as any,
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { EnvEditor } from '../../components/EnvEditor';
|
|||||||
import { useEnvironmentStore } from '../../api/environment-store';
|
import { useEnvironmentStore } from '../../api/environment-store';
|
||||||
import { useEnvironments } from '../../api/queries/admin/environments';
|
import { useEnvironments } from '../../api/queries/admin/environments';
|
||||||
import {
|
import {
|
||||||
useAllApps,
|
|
||||||
useApps,
|
useApps,
|
||||||
useCreateApp,
|
useCreateApp,
|
||||||
useDeleteApp,
|
useDeleteApp,
|
||||||
@@ -92,13 +91,13 @@ export default function AppsTab() {
|
|||||||
|
|
||||||
function AppListView({ selectedEnv, environments }: { selectedEnv: string | undefined; environments: Environment[] }) {
|
function AppListView({ selectedEnv, environments }: { selectedEnv: string | undefined; environments: Environment[] }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: allApps = [], isLoading: allLoading } = useAllApps();
|
const { data: envApps = [], isLoading: envLoading } = useApps(selectedEnv);
|
||||||
const envId = useMemo(() => environments.find((e) => e.slug === selectedEnv)?.id, [environments, selectedEnv]);
|
|
||||||
const { data: envApps = [], isLoading: envLoading } = useApps(envId);
|
|
||||||
const { data: catalog = [] } = useCatalog(selectedEnv);
|
const { data: catalog = [] } = useCatalog(selectedEnv);
|
||||||
|
|
||||||
const apps = selectedEnv ? envApps : allApps;
|
// Apps are env-scoped; without an env selection there is no managed-app list
|
||||||
const isLoading = selectedEnv ? envLoading : allLoading;
|
// 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]);
|
const envMap = useMemo(() => new Map(environments.map((e) => [e.id, e])), [environments]);
|
||||||
|
|
||||||
@@ -259,13 +258,13 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
|||||||
try {
|
try {
|
||||||
// 1. Create app
|
// 1. Create app
|
||||||
setStep('Creating 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)
|
// 2. Upload JAR (if provided)
|
||||||
let version: AppVersion | null = null;
|
let version: AppVersion | null = null;
|
||||||
if (file) {
|
if (file) {
|
||||||
setStep('Uploading JAR...');
|
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
|
// 3. Save container config
|
||||||
@@ -286,7 +285,7 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
|||||||
customArgs: customArgs || null,
|
customArgs: customArgs || null,
|
||||||
extraNetworks: extraNetworks,
|
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)
|
// 4. Save agent config (will be pushed to agent on first connect)
|
||||||
setStep('Saving monitoring config...');
|
setStep('Saving monitoring config...');
|
||||||
@@ -307,13 +306,13 @@ function CreateAppView({ environments, selectedEnv }: { environments: Environmen
|
|||||||
routeRecording: {},
|
routeRecording: {},
|
||||||
sensitiveKeys: sensitiveKeys.length > 0 ? sensitiveKeys : undefined,
|
sensitiveKeys: sensitiveKeys.length > 0 ? sensitiveKeys : undefined,
|
||||||
},
|
},
|
||||||
environment: selectedEnv,
|
environment: selectedEnv!,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Deploy (if requested and JAR was uploaded)
|
// 5. Deploy (if requested and JAR was uploaded)
|
||||||
if (deploy && version) {
|
if (deploy && version) {
|
||||||
setStep('Starting deployment...');
|
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' });
|
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 }) {
|
function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: string; environments: Environment[]; selectedEnv: string | undefined }) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: allApps = [] } = useAllApps();
|
const { data: envApps = [] } = useApps(selectedEnv);
|
||||||
const app = useMemo(() => allApps.find((a) => a.slug === appSlug), [allApps, appSlug]);
|
const app = useMemo(() => envApps.find((a) => a.slug === appSlug), [envApps, appSlug]);
|
||||||
const { data: catalogApps } = useCatalog(selectedEnv);
|
const { data: catalogApps } = useCatalog(selectedEnv);
|
||||||
const catalogEntry = useMemo(() => (catalogApps ?? []).find((c: CatalogApp) => c.slug === appSlug), [catalogApps, appSlug]);
|
const catalogEntry = useMemo(() => (catalogApps ?? []).find((c: CatalogApp) => c.slug === appSlug), [catalogApps, appSlug]);
|
||||||
const { data: versions = [] } = useAppVersions(appSlug);
|
const { data: versions = [] } = useAppVersions(selectedEnv, appSlug);
|
||||||
const { data: deployments = [] } = useDeployments(appSlug);
|
const { data: deployments = [] } = useDeployments(selectedEnv, appSlug);
|
||||||
const uploadJar = useUploadJar();
|
const uploadJar = useUploadJar();
|
||||||
const createDeployment = useCreateDeployment();
|
const createDeployment = useCreateDeployment();
|
||||||
const stopDeployment = useStopDeployment();
|
const stopDeployment = useStopDeployment();
|
||||||
@@ -699,15 +698,15 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
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' });
|
toast({ title: `Version ${v.version} uploaded`, description: file.name, variant: 'success' });
|
||||||
} catch { toast({ title: 'Failed to upload JAR', variant: 'error', duration: 86_400_000 }); }
|
} catch { toast({ title: 'Failed to upload JAR', variant: 'error', duration: 86_400_000 }); }
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeploy(versionId: string, environmentId: string) {
|
async function handleDeploy(versionId: string) {
|
||||||
try {
|
try {
|
||||||
await createDeployment.mutateAsync({ appId: appSlug, appVersionId: versionId, environmentId });
|
await createDeployment.mutateAsync({ envSlug: selectedEnv!, appSlug, appVersionId: versionId });
|
||||||
toast({ title: 'Deployment started', variant: 'success' });
|
toast({ title: 'Deployment started', variant: 'success' });
|
||||||
} catch { toast({ title: 'Failed to deploy application', variant: 'error', duration: 86_400_000 }); }
|
} 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() {
|
async function confirmStop() {
|
||||||
if (!stopTarget) return;
|
if (!stopTarget) return;
|
||||||
try {
|
try {
|
||||||
await stopDeployment.mutateAsync({ appId: appSlug, deploymentId: stopTarget.id });
|
await stopDeployment.mutateAsync({ envSlug: selectedEnv!, appSlug, deploymentId: stopTarget.id });
|
||||||
toast({ title: 'Deployment stopped', variant: 'warning' });
|
toast({ title: 'Deployment stopped', variant: 'warning' });
|
||||||
} catch { toast({ title: 'Failed to stop deployment', variant: 'error', duration: 86_400_000 }); }
|
} catch { toast({ title: 'Failed to stop deployment', variant: 'error', duration: 86_400_000 }); }
|
||||||
setStopTarget(null);
|
setStopTarget(null);
|
||||||
@@ -727,7 +726,7 @@ function AppDetailView({ appId: appSlug, environments, selectedEnv }: { appId: s
|
|||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
try {
|
try {
|
||||||
await deleteApp.mutateAsync(appSlug);
|
await deleteApp.mutateAsync({ envSlug: selectedEnv!, appSlug });
|
||||||
toast({ title: 'App deleted', variant: 'warning' });
|
toast({ title: 'App deleted', variant: 'warning' });
|
||||||
navigate('/apps');
|
navigate('/apps');
|
||||||
} catch { toast({ title: 'Delete failed', variant: 'error', duration: 86_400_000 }); }
|
} 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('');
|
const [newNetwork, setNewNetwork] = useState('');
|
||||||
|
|
||||||
// Versions query for runtime detection hints
|
// 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;
|
const latestVersion = versions?.[0] ?? null;
|
||||||
|
|
||||||
// Sync from server data
|
// Sync from server data
|
||||||
@@ -1080,7 +1079,7 @@ function ConfigSubTab({ app, environment }: { app: App; environment?: Environmen
|
|||||||
extraNetworks: extraNetworks,
|
extraNetworks: extraNetworks,
|
||||||
};
|
};
|
||||||
try {
|
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' });
|
toast({ title: 'Configuration saved', description: 'Redeploy to apply changes to running deployments.', variant: 'success' });
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
} catch { toast({ title: 'Failed to save container config', variant: 'error', duration: 86_400_000 }); }
|
} catch { toast({ title: 'Failed to save container config', variant: 'error', duration: 86_400_000 }); }
|
||||||
|
|||||||
@@ -322,6 +322,7 @@ function DiagramPanel({ appId, routeId, exchangeId, onCorrelatedSelect, onClearS
|
|||||||
processorOptions={processorOptions}
|
processorOptions={processorOptions}
|
||||||
defaultProcessorId={tapModalTarget}
|
defaultProcessorId={tapModalTarget}
|
||||||
application={appId}
|
application={appId}
|
||||||
|
environment={selectedEnv}
|
||||||
config={appConfig}
|
config={appConfig}
|
||||||
onSave={handleTapSave}
|
onSave={handleTapSave}
|
||||||
onDelete={handleTapDelete}
|
onDelete={handleTapDelete}
|
||||||
|
|||||||
@@ -573,7 +573,7 @@ export default function RouteDetail() {
|
|||||||
if (!appId) return;
|
if (!appId) return;
|
||||||
const body = testTab === 'recent' ? testExchangeId : testPayload;
|
const body = testTab === 'recent' ? testExchangeId : testPayload;
|
||||||
testExpressionMutation.mutate(
|
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 }) },
|
{ onSuccess: (data) => setTestResult(data), onError: (err) => setTestResult({ error: (err as Error).message }) },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user