From 69055f7d741f3202fde981bd8fb8c91edffaaf6b Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:12:16 +0200 Subject: [PATCH] fix: persist environment selection in Zustand store instead of URL params Environment selector was losing its value on navigation because URL search params were silently dropped by navigate() calls. Moved to a Zustand store with localStorage persistence so the selection survives navigation, page refresh, and new tabs. Switching environment now resets all filters, clears URL params, invalidates queries, and remounts pages via Outlet key. Also syncs openapi.json schema with running backend. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 5 +- ui/src/api/environment-store.ts | 20 +++++ ui/src/api/schema.d.ts | 144 ++++++++++++++++++++++++------ ui/src/components/LayoutShell.tsx | 30 +++---- 4 files changed, 157 insertions(+), 42 deletions(-) create mode 100644 ui/src/api/environment-store.ts 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() { )}
- +
);