diff --git a/CLAUDE.md b/CLAUDE.md index b7615352..a7d9da0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,8 +37,8 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar - Depends on `com.cameleer3:cameleer3-common` from Gitea Maven registry - Jackson `JavaTimeModule` for `Instant` deserialization - Communication: receives HTTP POST data from agents (executions, diagrams, metrics, logs), serves SSE event streams for config push/commands (config-update, deep-trace, replay, route-control) -- Maintains agent instance registry (in-memory) with states: LIVE → STALE → DEAD. Auto-heals from JWT claims + heartbeat capabilities on heartbeat/SSE after server restart. Capabilities are updated on every heartbeat (protocol v2). Route catalog falls back to ClickHouse stats for route discovery when registry has incomplete data. -- Multi-tenancy: each server instance serves one tenant (configured via `CAMELEER_TENANT_ID`, default: `"default"`). Environments (dev/staging/prod) are first-class — agents send `environmentId` at registration. PostgreSQL isolated via schema-per-tenant (`?currentSchema=tenant_{id}`). ClickHouse shared DB with `tenant_id` + `environment` columns, partitioned by `(tenant_id, toYYYYMM(timestamp))`. +- Maintains agent instance registry (in-memory) with states: LIVE → STALE → DEAD. Auto-heals from JWT `env` claim + heartbeat body on heartbeat/SSE after server restart (priority: heartbeat `environmentId` > JWT `env` claim > `"default"`). Capabilities and route states updated on every heartbeat (protocol v2). Route catalog falls back to ClickHouse stats for route discovery when registry has incomplete data. +- Multi-tenancy: each server instance serves one tenant (configured via `CAMELEER_TENANT_ID`, default: `"default"`). Environments (dev/staging/prod) are first-class — agents send `environmentId` at registration and in heartbeats. JWT carries `env` claim for environment persistence across token refresh. PostgreSQL isolated via schema-per-tenant (`?currentSchema=tenant_{id}`). 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. - Logging: ClickHouse JDBC set to INFO (`com.clickhouse`), HTTP client to WARN (`org.apache.hc.client5`) in application.yml - Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing (key derived deterministically from JWT secret via HMAC-SHA256), bootstrap token for registration @@ -63,6 +63,7 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar ## UI Styling - Always use `@cameleer/design-system` CSS variables for colors (`var(--amber)`, `var(--error)`, `var(--success)`, etc.) — never hardcode hex values. This applies to CSS modules, inline styles, and SVG `fill`/`stroke` attributes. SVG presentation attributes resolve `var()` correctly. +- Global user preferences (environment selection) use Zustand stores with localStorage persistence — never URL search params. URL params are for page-specific state only (e.g. `?text=` search query). Switching environment resets all filters and remounts pages. ## Disabled Skills diff --git a/ui/src/api/environment-store.ts b/ui/src/api/environment-store.ts new file mode 100644 index 00000000..4162854a --- /dev/null +++ b/ui/src/api/environment-store.ts @@ -0,0 +1,20 @@ +import { create } from 'zustand'; + +const STORAGE_KEY = 'cameleer-environment'; + +interface EnvironmentState { + environment: string | undefined; + setEnvironment: (env: string | undefined) => void; +} + +export const useEnvironmentStore = create((set) => ({ + environment: localStorage.getItem(STORAGE_KEY) || undefined, + setEnvironment: (env) => { + if (env) { + localStorage.setItem(STORAGE_KEY, env); + } else { + localStorage.removeItem(STORAGE_KEY); + } + set({ environment: env }); + }, +})); diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index d392613b..7a0704fe 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -190,7 +190,7 @@ export interface paths { put?: never; /** * Ingest application log entries - * @description Accepts a batch of log entries from an agent. Entries are stored in the configured log store. + * @description Accepts a batch of log entries from an agent. Entries are buffered and flushed periodically. */ post: operations["ingestLogs"]; delete?: never; @@ -372,7 +372,7 @@ export interface paths { put?: never; /** * Agent heartbeat ping - * @description Updates the agent's last heartbeat timestamp + * @description Updates the agent's last heartbeat timestamp. Auto-registers the agent if not in registry (e.g. after server restart). */ post: operations["heartbeat"]; delete?: never; @@ -472,7 +472,7 @@ export interface paths { put?: never; /** * Send command to all agents in a group - * @description Sends a command to all LIVE agents in the specified group + * @description Sends a command to all LIVE agents in the specified group and waits for responses */ post: operations["sendGroupCommand"]; delete?: never; @@ -762,6 +762,23 @@ export interface paths { patch?: never; trace?: never; }; + "/search/attributes/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Distinct attribute key names across all executions */ + get: operations["attributeKeys"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/routes/metrics": { parameters: { query?: never; @@ -831,7 +848,7 @@ export interface paths { }; /** * Search application log entries - * @description Returns log entries for a given application, optionally filtered by agent, level, time range, and text query + * @description Returns log entries with cursor-based pagination and level count aggregation. Supports free-text search, multi-level filtering, and optional application scoping. */ get: operations["searchLogs"]; put?: never; @@ -1350,6 +1367,24 @@ export interface components { /** Format: int32 */ version?: number; }; + AgentResponse: { + agentId?: string; + status?: string; + message?: string; + }; + CommandGroupResponse: { + success?: boolean; + /** Format: int32 */ + total?: number; + /** Format: int32 */ + responded?: number; + responses?: components["schemas"]["AgentResponse"][]; + timedOut?: string[]; + }; + ConfigUpdateResponse: { + config?: components["schemas"]["ApplicationConfig"]; + pushResult?: components["schemas"]["CommandGroupResponse"]; + }; UpdateUserRequest: { displayName?: string; email?: string; @@ -1610,6 +1645,15 @@ export interface components { accessToken: string; refreshToken: string; }; + HeartbeatRequest: { + routeStates?: { + [key: string]: string; + }; + capabilities?: { + [key: string]: Record; + }; + environmentId?: string; + }; /** @description Command to send to agent(s) */ CommandRequest: { /** @description Command type: config-update, deep-trace, or replay */ @@ -1633,6 +1677,8 @@ export interface components { displayName: string; /** @default default */ applicationId: string; + /** @default default */ + environmentId: string; version?: string; routeIds?: string[]; capabilities?: { @@ -1830,12 +1876,14 @@ export interface components { lastSeen: string; /** @description The from() endpoint URI, e.g. 'direct:processOrder' */ fromEndpointUri: string; + /** @description Operational state of the route: stopped, suspended, or null (started/default) */ + routeState: string; }; /** @description Application log entry */ LogEntryResponse: { /** @description Log timestamp (ISO-8601) */ timestamp?: string; - /** @description Log level (INFO, WARN, ERROR, DEBUG) */ + /** @description Log level (INFO, WARN, ERROR, DEBUG, TRACE) */ level?: string; /** @description Logger name */ loggerName?: string; @@ -1845,6 +1893,29 @@ export interface components { threadName?: string; /** @description Stack trace (if present) */ stackTrace?: string; + /** @description Camel exchange ID (if present) */ + exchangeId?: string; + /** @description Agent instance ID */ + instanceId?: string; + /** @description Application ID */ + application?: string; + /** @description MDC context map */ + mdc?: { + [key: string]: string; + }; + }; + /** @description Log search response with cursor pagination and level counts */ + LogSearchPageResponse: { + /** @description Log entries for the current page */ + data?: components["schemas"]["LogEntryResponse"][]; + /** @description Cursor for next page (null if no more results) */ + nextCursor?: string; + /** @description Whether more results exist beyond this page */ + hasMore?: boolean; + /** @description Count of logs per level (unaffected by level filter) */ + levelCounts?: { + [key: string]: number; + }; }; ExecutionDetail: { executionId: string; @@ -1961,7 +2032,7 @@ export interface components { instanceId: string; displayName: string; applicationId: string; - environmentId?: string; + environmentId: string; status: string; routeIds: string[]; /** Format: date-time */ @@ -2294,7 +2365,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["ApplicationConfig"]; + "*/*": components["schemas"]["ConfigUpdateResponse"]; }; }; }; @@ -3204,7 +3275,11 @@ export interface operations { }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + "application/json": components["schemas"]["HeartbeatRequest"]; + }; + }; responses: { /** @description Heartbeat accepted */ 200: { @@ -3213,13 +3288,6 @@ export interface operations { }; content?: never; }; - /** @description Agent not registered */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; }; }; deregister: { @@ -3382,13 +3450,13 @@ export interface operations { }; }; responses: { - /** @description Commands accepted */ - 202: { + /** @description Commands dispatched and responses collected */ + 200: { headers: { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["CommandBroadcastResponse"]; + "*/*": components["schemas"]["CommandGroupResponse"]; }; }; /** @description Invalid command payload */ @@ -3397,7 +3465,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["CommandBroadcastResponse"]; + "*/*": components["schemas"]["CommandGroupResponse"]; }; }; }; @@ -3949,6 +4017,26 @@ export interface operations { }; }; }; + attributeKeys: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": string[]; + }; + }; + }; + }; getMetrics: { parameters: { query?: { @@ -4003,6 +4091,7 @@ export interface operations { query?: { from?: string; to?: string; + environment?: string; }; header?: never; path?: never; @@ -4023,15 +4112,20 @@ export interface operations { }; searchLogs: { parameters: { - query: { - application: string; - agentId?: string; - level?: string; + query?: { + q?: string; query?: string; + level?: string; + application?: string; + agentId?: string; exchangeId?: string; + logger?: string; + environment?: string; from?: string; to?: string; + cursor?: string; limit?: number; + sort?: string; }; header?: never; path?: never; @@ -4045,7 +4139,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["LogEntryResponse"][]; + "*/*": components["schemas"]["LogSearchPageResponse"]; }; }; }; @@ -4394,7 +4488,7 @@ export interface operations { "text/event-stream": components["schemas"]["SseEmitter"]; }; }; - /** @description Agent not registered */ + /** @description Agent not registered and cannot be auto-registered */ 404: { headers: { [name: string]: unknown; diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index 543ffe02..5ba35c20 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -1,4 +1,4 @@ -import { Outlet, useNavigate, useLocation, useSearchParams } from 'react-router'; +import { Outlet, useNavigate, useLocation } from 'react-router'; import { AppShell, Sidebar, @@ -23,6 +23,7 @@ import { useSearchExecutions, useAttributeKeys } from '../api/queries/executions import { useUsers, useGroups, useRoles } from '../api/queries/admin/rbac'; import type { UserDetail, GroupDetail, RoleDetail } from '../api/queries/admin/rbac'; import { useAuthStore } from '../auth/auth-store'; +import { useEnvironmentStore } from '../api/environment-store'; import { useState, useMemo, useCallback, useEffect, useRef, createElement } from 'react'; import type { ReactNode } from 'react'; import { ContentTabs } from './ContentTabs'; @@ -272,23 +273,12 @@ const SK_COLLAPSED = 'sidebar:collapsed'; function LayoutContent() { const navigate = useNavigate(); const location = useLocation(); - const [searchParams, setSearchParams] = useSearchParams(); const queryClient = useQueryClient(); const { timeRange, autoRefresh, refreshTimeRange } = useGlobalFilters(); // --- Environment filtering ----------------------------------------- - const selectedEnv = searchParams.get('env') || undefined; - const setSelectedEnv = useCallback((env: string | undefined) => { - setSearchParams((prev) => { - const next = new URLSearchParams(prev); - if (env) { - next.set('env', env); - } else { - next.delete('env'); - } - return next; - }, { replace: true }); - }, [setSearchParams]); + const selectedEnv = useEnvironmentStore((s) => s.environment); + const setSelectedEnvRaw = useEnvironmentStore((s) => s.setEnvironment); const { data: catalog } = useRouteCatalog(timeRange.start.toISOString(), timeRange.end.toISOString(), selectedEnv); const { data: allAgents } = useAgents(); // unfiltered — for environment discovery @@ -330,6 +320,15 @@ function LayoutContent() { // --- Sidebar filter ----------------------------------------------- const [filterQuery, setFilterQuery] = useState(''); + const setSelectedEnv = useCallback((env: string | undefined) => { + setSelectedEnvRaw(env); + setFilterQuery(''); + if (location.search) { + navigate(location.pathname, { replace: true }); + } + queryClient.invalidateQueries(); + }, [setSelectedEnvRaw, navigate, location.pathname, location.search, queryClient]); + // --- Section open states ------------------------------------------ const [appsOpen, setAppsOpen] = useState(() => isAdminPage ? false : readCollapsed(SK_APPS, true)); const [adminOpen, setAdminOpen] = useState(() => isAdminPage ? true : readCollapsed(SK_ADMIN, false)); @@ -537,6 +536,7 @@ function LayoutContent() { // --- Callbacks ---------------------------------------------------- const handleLogout = useCallback(() => { logout(); + useEnvironmentStore.getState().setEnvironment(undefined); navigate('/login'); }, [logout, navigate]); @@ -729,7 +729,7 @@ function LayoutContent() { )}
- +
);