54 Commits

Author SHA1 Message Date
hsiegeln
dafd7adb00 chore: upgrade @cameleer/design-system to v0.0.3
Some checks failed
CI / docker (push) Has been cancelled
CI / build (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / cleanup-branch (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:42:38 +01:00
hsiegeln
44eecfa5cd deleted obsolote files
All checks were successful
CI / build (push) Successful in 1m21s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 43s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped
2026-03-24 10:24:13 +01:00
hsiegeln
ff76751629 refactor: rename agent group→application across entire codebase
All checks were successful
CI / build (push) Successful in 1m22s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 52s
CI / deploy (push) Successful in 39s
CI / deploy-feature (push) Has been skipped
Complete the group→application terminology rename in the agent
registry subsystem:

- AgentInfo: field group → application, all wither methods updated
- AgentRegistryService: findByGroup → findByApplication
- AgentInstanceResponse: field group → application (API response)
- AgentRegistrationRequest: field group → application (API request)
- JwtServiceImpl: parameter names group → application (JWT claim
  string "group" preserved for token backward compatibility)
- All controllers, lifecycle monitor, command controller updated
- Integration tests: JSON request bodies "group" → "application"
- Frontend: schema.d.ts, openapi.json, agent queries, AgentHealth

RBAC group references (groups table, GroupAdminController, etc.)
are NOT affected — they are a separate domain concept.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:48:12 +01:00
hsiegeln
413839452c fix: use statsForApp when application is set without routeId
All checks were successful
CI / build (push) Successful in 1m21s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 44s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped
The stats endpoint was calling statsForRoute(null, agentIds) when
only application was set — this filtered by route_id=null, returning
zero results. Now correctly routes to statsForApp/timeseriesForApp
which queries the stats_1m_app continuous aggregate by application_name.

Also reverts the group parameter alias workaround — the deployed
backend correctly accepts 'application'.

Three code paths now:
- No filters → stats_1m_all (global)
- application only → stats_1m_app (per-app)
- routeId (±application) → stats_1m_route (per-route)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:28:05 +01:00
hsiegeln
c33e899be7 fix: accept both 'application' and 'group' query params in search API
All checks were successful
CI / build (push) Successful in 1m22s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 50s
CI / deploy (push) Successful in 37s
CI / deploy-feature (push) Has been skipped
The backend was renamed from group→application but Docker build cache
may serve old code. Accept 'group' as a fallback alias so the UI works
with both old and new backends. Applies to GET /search/executions,
/search/stats, and /search/stats/timeseries.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:25:05 +01:00
hsiegeln
180514a039 fix: align RBAC user management styling with mock design
All checks were successful
CI / build (push) Successful in 1m19s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 52s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped
- Split pane: card layout with border, border-radius, box-shadow
  matching mock's bordered panel look
- List pane: bg-surface background, padded header with border-bottom
- Entity items: border-bottom separators instead of gap spacing,
  flex-start alignment for multi-line content
- Detail pane: bg-surface background, 20px padding, right border-radius
- User meta line: show email + group path (like mock's "email · group")
- Create form: raised background with bottom border

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:21:11 +01:00
hsiegeln
60fced56ed fix: format Documents column with user locale in OpenSearch admin
All checks were successful
CI / build (push) Successful in 1m25s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 1m0s
CI / deploy (push) Successful in 39s
CI / deploy-feature (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:17:06 +01:00
hsiegeln
515c942623 feat: add admin tab navigation between subpages
All checks were successful
CI / build (push) Successful in 1m19s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 52s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped
Add AdminLayout wrapper with Tabs component for navigating between
admin sections: User Management, Audit Log, OIDC, Database, OpenSearch.

Nest all /admin/* routes under AdminLayout using React Router's
Outlet pattern so the tab bar persists across admin page navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:17:33 +01:00
hsiegeln
3ccd4b6548 fix: self-host fonts instead of loading from Google Fonts CDN
All checks were successful
CI / build (push) Successful in 1m23s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 56s
CI / deploy (push) Successful in 39s
CI / deploy-feature (push) Has been skipped
Loading fonts from fonts.googleapis.com sends user IP addresses to
Google on every page load — a GDPR violation. Self-host DM Sans and
JetBrains Mono as woff2 files bundled with the UI.

- Download DM Sans (400/500/600/700 + 400 italic) woff2 files
- Download JetBrains Mono (400/500/600) woff2 files
- Replace @import url(googleapis) with local @font-face declarations
- Both fonts are OFL-licensed (free to self-host)
- Total size: ~135KB for all 8 font files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:06:59 +01:00
hsiegeln
dad608e3a2 fix: display timestamps in user's local timezone, not UTC
Some checks failed
CI / build (push) Successful in 1m17s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 51s
CI / deploy-feature (push) Has been cancelled
CI / deploy (push) Has been cancelled
Two places in Dashboard used toISOString() for display, which always
renders UTC. Changed to toLocaleString() for the user's local timezone.

- Exchanges table "Started" column
- Detail panel "Timestamp" field

API query parameters correctly continue using toISOString() (UTC).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:00:44 +01:00
hsiegeln
7479dd6daf fix: convert Instant to Timestamp for JDBC agent metrics query
Some checks failed
CI / docker (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / cleanup-branch (push) Has been cancelled
CI / build (push) Has been cancelled
PostgreSQL JDBC driver can't infer SQL type for java.time.Instant.
Convert from/to parameters to java.sql.Timestamp before binding.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:59:22 +01:00
hsiegeln
e4dff0cad1 fix: align RoutesMetrics with mock — chart titles, Invalid Date bug
All checks were successful
CI / build (push) Successful in 1m20s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 50s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped
- Fix Invalid Date in Errors bar chart (guard against null timestamps)
- Table header: "Route Metrics" → "Per-Route Performance"
- Chart titles: add units — "Throughput (msg/s)", "Latency (ms)",
  "Errors by Route", "Message Volume (msg/min)"
- Add yLabel to charts for axis labels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:55:29 +01:00
hsiegeln
717367252c fix: align AgentInstance page with mock design
All checks were successful
CI / build (push) Successful in 1m13s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 49s
CI / deploy (push) Successful in 40s
CI / deploy-feature (push) Has been skipped
- Chart headers: add current value meta text (CPU %, memory MB, TPS,
  error rate, thread count) matching mock layout
- Bottom section: 2-column grid with log placeholder (left) and
  timeline events (right) matching mock layout
- Timeline header: show "Timeline" + event count like mock
- Remove duplicate EmptyState placeholder

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:51:44 +01:00
hsiegeln
a06808a2a2 fix: align AgentHealth page with mock design
Some checks failed
CI / build (push) Successful in 1m18s
CI / cleanup-branch (push) Has been skipped
CI / deploy (push) Has been cancelled
CI / deploy-feature (push) Has been cancelled
CI / docker (push) Has been cancelled
- DetailPanel: switch from tabs to flat children layout (fixes stale
  tab state bug), add position:fixed override, key on agent id
- Stat strip: colored status breakdown (live/stale/dead), msg/s detail
  on TPS, "requires attention" on dead count
- Scope trail: simplified to "X/Y live" label
- Event card header: rename "Event Log" to "Timeline" with count badge
- Remove unused Breadcrumb, scopeItems, groupHealth

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:50:16 +01:00
hsiegeln
6b750df1c4 fix: remove hardcoded locales from UI formatting
All checks were successful
CI / build (push) Successful in 1m21s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 52s
CI / deploy (push) Successful in 37s
CI / deploy-feature (push) Has been skipped
Use browser default locale instead of hardcoded 'en-US' and 'en-GB'
for number and time formatting.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:44:16 +01:00
hsiegeln
ea56bcf2d7 fix: split Flyway migration — DDL in V1, policies in V2
All checks were successful
CI / build (push) Successful in 1m20s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 43s
CI / deploy (push) Successful in 1m16s
CI / deploy-feature (push) Has been skipped
TimescaleDB add_continuous_aggregate_policy and add_compression_policy
cannot run inside a transaction block. Move all policy calls to V2
with flyway:executeInTransaction=false directive.

Also fix stats_1m_processor_detail: add WITH NO DATA and
materialized_only = false.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:34:35 +01:00
hsiegeln
826466aa55 fix: cast diagram layout response type to fix TS build error
Some checks failed
CI / build (push) Successful in 1m13s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 53s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Failing after 1m16s
The render endpoint returns a union type (SVG string | JSON object).
Cast to DiagramLayout interface so .nodes is accessible. Also rename
useDiagramByRoute parameter from group to application.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:25:36 +01:00
hsiegeln
6a5dba4eba refactor: rename group_name→application_name in DB, OpenSearch, SQL
Some checks failed
CI / build (push) Failing after 41s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
Consolidate V1-V7 Flyway migrations into single V1__init.sql with
all columns renamed from group_name to application_name. Requires
fresh database (wipe flyway_schema_history, all data).

- DB columns: executions.group_name → application_name,
  processor_executions.group_name → application_name
- Continuous aggregates: all views updated to use application_name
- OpenSearch field: group_name → application_name in index/query
- All Java SQL strings updated to match new column names
- Delete V2-V7 migration files (folded into V1)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:24:19 +01:00
hsiegeln
8ad0016a8e refactor: rename group/groupName to application/applicationName
Some checks failed
CI / build (push) Failing after 40s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
The execution-related "group" concept actually represents the
application name. Rename all Java fields, API parameters, and frontend
types from groupName→applicationName and group→application for clarity.

- Java records: ExecutionSummary, ExecutionDetail, ExecutionDocument,
  ExecutionRecord, ProcessorRecord
- API params: SearchRequest.group→application, SearchController
  @RequestParam group→application
- Services: IngestionService, DetailService, SearchIndexer, StatsStore
- Frontend: schema.d.ts, Dashboard, ExchangeDetail, RouteDetail,
  executions query hooks

Database column names (group_name) and OpenSearch field names are
unchanged — only the API-facing Java/TS field names are renamed.

RBAC group references (groups table, GroupRepository, GroupsTab) are
a separate domain concept and are NOT affected by this change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:21:38 +01:00
hsiegeln
3c226de62f fix: use diagramContentHash for Route Flow instead of groupName
Some checks failed
CI / build (push) Failing after 51s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
The deployed backend doesn't return groupName on ExecutionDetail or
ExecutionSummary (Docker build cache issue). Switch diagram lookup to
use diagramContentHash which is always available in the detail response.

- Dashboard: useDiagramLayout(detail.diagramContentHash) instead of
  useDiagramByRoute(groupName, routeId)
- ExchangeDetail: same change

Route Flow now renders correctly in both the slide-in panel and the
full exchange detail page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:13:01 +01:00
hsiegeln
c8c62a98bb fix: add groupName to ExecutionSummary in schema.d.ts
Some checks failed
CI / build (push) Successful in 1m12s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 1m10s
CI / deploy (push) Failing after 2m19s
CI / deploy-feature (push) Has been skipped
The Java record was updated but the OpenAPI schema was not regenerated,
causing a TypeScript build error in CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:03:45 +01:00
hsiegeln
2ae2871822 fix: add groupName to ExecutionDetail, rewrite ExchangeDetail to match mock
Some checks failed
CI / build (push) Failing after 40s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
- Add groupName field to ExecutionDetail record and DetailService
- Dashboard: fix TDZ error (rows referenced before definition), add
  selectedRow fallback for diagram groupName lookup
- ExchangeDetail: rewrite to match mock layout — auto-select first
  processor, Message IN/OUT split panels with header key-value rows,
  error panel for failed processors, Timeline/Flow toggle buttons
- Track diagram-mapping utility (was untracked, caused CI build failure)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:02:14 +01:00
hsiegeln
a950feaef1 fix: Dashboard DetailPanel uses flat scrollable layout matching mock
Some checks failed
CI / build (push) Failing after 41s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
Changed from tabs-based to children-based DetailPanel layout:
- Flat scrollable sections: Open Details → Overview → Errors → Route Flow → Processor Timeline
- Title shows "route — exchangeId" matching mock pattern
- Removed unused state (detailTab, processorIdx)
- Added panelSectionMeta CSS for duration display in timeline header

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:51:23 +01:00
hsiegeln
695969d759 fix: DetailPanel slide-in now visible — fixed empty content bug and positioning
Some checks failed
CI / build (push) Failing after 39s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
- Only render DetailPanel when detail data is loaded (key={selectedId} forces remount
  so internal activeTab state resets correctly)
- Override DetailPanel CSS with position:fixed to overlay on right side
  (AppShell layout doesn't support detail prop from child pages)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:47:43 +01:00
hsiegeln
a72b0954db fix: add groupName to ExecutionSummary, locale format stat values, inspect column, fix duplicate keys
Some checks failed
CI / build (push) Failing after 40s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
- Added groupName field to ExecutionSummary Java record and OpenSearch mapper
- Dashboard stat cards use locale-formatted numbers (en-US)
- Added inspect column (↗) linking directly to exchange detail page
- Fixed duplicate React key warning from two columns sharing executionId key

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:41:46 +01:00
hsiegeln
4572230c9c fix: align all pages with design system mocks — stat cards, tables, detail panels
Some checks failed
CI / build (push) Failing after 40s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
Dashboard: correct stat card labels (Exchanges/Success Rate/Errors/Throughput/Latency p99),
add detail text, trends, sparklines on all cards, Agent column, LIVE badge,
expanded detail panel with Agent/Correlation/Timestamp, "Open full details" link.

Agent Health: per-group meta (TPS/routes) in GroupCard header, proper HTML table
with column headers for instance list.

Agent Instance: stat card detail props (heap info, start date), scope trail with
inline status/version/routes badges.

Routes: 5th In-Flight stat card, enriched stat card props (detail/trend/sparkline),
SLA threshold line on latency chart.

Exchange Detail: Agent stat box in header.

Also: vite proxy CORS fix, cross-env dev scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:28:56 +01:00
hsiegeln
752d7ec0e7 feat: add Users tab with split-pane layout, inline create, detail panel
Some checks failed
CI / build (push) Failing after 39s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:32:45 +01:00
hsiegeln
9ab38dfc59 feat: add Groups tab with hierarchy management and member/role assignment
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:32:18 +01:00
hsiegeln
907bcd5017 feat: add Roles tab with system role protection and principal display
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:32:07 +01:00
hsiegeln
83caf4be5b feat: align Agent Instance with mock — JVM charts, process info, stat cards, log placeholder
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:29:25 +01:00
hsiegeln
1533bea2a6 refactor: restructure RBAC page to container + tab components, add CSS module
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:28:52 +01:00
hsiegeln
94d1e81852 feat: add Route Detail page with diagram, processor stats, and tabbed sections
Replaces the filtered RoutesMetrics view at /routes/:appId/:routeId with a
dedicated RouteDetail page showing route diagram, processor stats table,
performance charts, recent executions, and client-side grouped error patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:25:58 +01:00
hsiegeln
8e27f45a2b feat: add default roles and ConfirmDialog to OIDC config
Adds a Default Roles section with Tag components for viewing/removing roles and an Input+Button for adding new ones. Replaces the plain delete button with a ConfirmDialog requiring typed confirmation. Introduces OidcConfigPage.module.css for CSS module layout classes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:25:14 +01:00
hsiegeln
a86f56f588 feat: add Timeline/Flow toggle to Exchange Detail
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:22:45 +01:00
hsiegeln
651cf9de6e feat: add correlation chain and processor count to Exchange Detail
Adds a recursive processor count stat to the exchange header, and a
Correlation Chain section that visualises related executions sharing
the same correlationId, with the current exchange highlighted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:19:50 +01:00
hsiegeln
63d8078688 feat: align Dashboard stat cards with mock, add errors section to DetailPanel
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:19:33 +01:00
hsiegeln
ee69dbedfc feat: use TopBar onLogout prop, add ToastProvider
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:17:38 +01:00
hsiegeln
313d871948 chore: update design system to v0.0.2, regenerate schema.d.ts
Bumped @cameleer/design-system from ^0.0.1 to ^0.0.2 (adds onLogout prop to TopBar).
Fetched openapi.json from remote backend, stripped /api/v1 prefix, patched
ExecutionDetail with groupName and children fields to match UI expectations,
then regenerated schema.d.ts via openapi-typescript. TypeScript compiles clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:16:15 +01:00
hsiegeln
f4d2693561 feat: enrich AgentInstanceResponse with version/capabilities, add password reset endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:13:37 +01:00
hsiegeln
2051572ee2 feat: add GET /agents/{id}/metrics endpoint for JVM metrics
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:11:22 +01:00
hsiegeln
cc433b4215 feat: add GET /routes/metrics/processors endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 18:10:54 +01:00
hsiegeln
31b60c4e24 feat: add V7 migration for per-processor-id continuous aggregate 2026-03-23 18:09:24 +01:00
hsiegeln
017a0c218e docs: add UI mock alignment design spec and implementation plan
Comprehensive spec and 20-task plan to close all gaps between
@cameleer/design-system v0.0.2 mocks and the current server UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:06:26 +01:00
hsiegeln
4ff01681d4 style: add CSS modules to all pages matching design system mock layouts
All checks were successful
CI / build (push) Successful in 1m18s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 50s
CI / deploy (push) Successful in 50s
CI / deploy-feature (push) Has been skipped
Replace inline styles with semantic CSS module classes for proper visual
structure: card wrappers with borders/shadows, grid layouts for stat
strips and charts, section headers, and typography classes.

Pages updated: Dashboard, ExchangeDetail, RoutesMetrics, AgentHealth,
AgentInstance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:16:16 +01:00
hsiegeln
f2744e3094 fix: correct response field mappings and add logout button
All checks were successful
CI / build (push) Successful in 1m28s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 50s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped
- SearchResult uses 'data' not 'items', 'total' not 'totalCount'
- ExecutionStats uses 'p99LatencyMs' not 'p99DurationMs'
- TimeseriesBucket uses 'time' not 'timestamp'
- Add user Dropdown with logout action to LayoutShell

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:06:49 +01:00
hsiegeln
ea5b5a685d fix: correct SearchRequest field names (offset/limit, sortField/sortDir)
All checks were successful
CI / build (push) Successful in 1m19s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 50s
CI / deploy (push) Successful in 40s
CI / deploy-feature (push) Has been skipped
Dashboard was sending page/size but backend expects offset/limit.
Schema also had sort/order instead of sortField/sortDir.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:55:27 +01:00
hsiegeln
045d9ea890 fix: correct page directory casing for case-sensitive filesystems
All checks were successful
CI / build (push) Successful in 1m16s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 1m12s
CI / deploy (push) Successful in 1m1s
CI / deploy-feature (push) Has been skipped
Rename admin/ → Admin/ and swagger/ → Swagger/ to match router imports.
Windows is case-insensitive so the mismatch was invisible locally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:43:42 +01:00
hsiegeln
9613bddc60 docs: add UI dev instructions and configurable API proxy target
Some checks failed
CI / build (push) Failing after 38s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:42:17 +01:00
hsiegeln
2b111c603c feat: migrate UI to @cameleer/design-system, add backend endpoints
Some checks failed
CI / build (push) Failing after 47s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Has been skipped
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Has been skipped
Backend:
- Add agent_events table (V5) and lifecycle event recording
- Add route catalog endpoint (GET /routes/catalog)
- Add route metrics endpoint (GET /routes/metrics)
- Add agent events endpoint (GET /agents/events-log)
- Enrich AgentInstanceResponse with tps, errorRate, activeRoutes, uptimeSeconds
- Add TimescaleDB retention/compression policies (V6)

Frontend:
- Replace custom Mission Control UI with @cameleer/design-system components
- Rebuild all pages: Dashboard, ExchangeDetail, RoutesMetrics, AgentHealth,
  AgentInstance, RBAC, AuditLog, OIDC, DatabaseAdmin, OpenSearchAdmin, Swagger
- New LayoutShell with design system AppShell, Sidebar, TopBar, CommandPalette
- Consume design system from Gitea npm registry (@cameleer/design-system@0.0.1)
- Add .npmrc for scoped registry, update Dockerfile with REGISTRY_TOKEN arg

CI:
- Pass REGISTRY_TOKEN build-arg to UI Docker build step

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:38:39 +01:00
hsiegeln
82124c3145 fix: remove RBAC user_roles insert from agent registration
All checks were successful
CI / build (push) Successful in 1m22s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 42s
CI / deploy (push) Successful in 44s
CI / deploy-feature (push) Has been skipped
Agents are transient and should not be persisted in the users table.
The assignRoleToUser call caused a FK violation (user_roles → users),
resulting in HTTP 500 on registration. The AGENT role is already
embedded directly in the JWT claims.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:10:48 +01:00
hsiegeln
17ef48e392 fix: return rotated refresh token from agent token refresh endpoint
All checks were successful
CI / build (push) Successful in 1m22s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 56s
CI / deploy (push) Successful in 47s
CI / deploy-feature (push) Has been skipped
Previously the refresh endpoint only returned a new accessToken, causing
agents to lose their refreshToken after the first refresh cycle and
forcing a full re-registration every ~2 hours.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:44:16 +01:00
4085f42160 Merge pull request 'fix/admin-scope-filtering' (#88) from fix/admin-scope-filtering into main
All checks were successful
CI / build (push) Successful in 1m15s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 15s
CI / deploy (push) Successful in 39s
CI / deploy-feature (push) Has been skipped
Reviewed-on: cameleer/cameleer3-server#88
2026-03-18 11:21:52 +01:00
hsiegeln
0fcbe83cc2 refactor: consolidate oidc_config and admin_thresholds into generic server_config table
All checks were successful
CI / build (push) Successful in 1m19s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 42s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 34s
CI / build (pull_request) Successful in 1m23s
CI / cleanup-branch (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
CI / deploy-feature (pull_request) Has been skipped
Single JSONB key-value table replaces two singleton config tables, making
future config types trivial to add. Also fixes pre-existing IT failures:
Flyway URL not overridden by Testcontainers, threshold test ordering.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 11:16:31 +01:00
hsiegeln
5a0a915cc6 fix: scope admin infra pages to current tenant's tables and indices
All checks were successful
CI / build (push) Successful in 1m14s
CI / cleanup-branch (push) Has been skipped
CI / docker (push) Successful in 44s
CI / deploy (push) Has been skipped
CI / deploy-feature (push) Successful in 35s
Database tables filtered to current_schema(), active queries to
current_database(), OpenSearch indices to configured index-prefix.
Delete endpoint rejects indices outside application scope.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:29:06 +01:00
251 changed files with 30422 additions and 18982 deletions

View File

@@ -120,6 +120,7 @@ jobs:
done done
docker buildx build --platform linux/amd64 \ docker buildx build --platform linux/amd64 \
-f ui/Dockerfile \ -f ui/Dockerfile \
--build-arg REGISTRY_TOKEN="$REGISTRY_TOKEN" \
$TAGS \ $TAGS \
--cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache \ --cache-from type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache \
--cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache,mode=max \ --cache-to type=registry,ref=gitea.siegeln.net/cameleer/cameleer3-server-ui:buildcache,mode=max \

View File

@@ -40,7 +40,7 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar
- Maintains agent instance registry with states: LIVE → STALE → DEAD - Maintains agent instance registry with states: LIVE → STALE → DEAD
- Storage: PostgreSQL (TimescaleDB) for structured data, OpenSearch for full-text search - Storage: PostgreSQL (TimescaleDB) for structured data, OpenSearch for full-text search
- Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing, bootstrap token for registration - Security: JWT auth with RBAC (AGENT/VIEWER/OPERATOR/ADMIN roles), Ed25519 config signing, bootstrap token for registration
- OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API, stored in database (`oidc_config` table) - OIDC: Optional external identity provider support (token exchange pattern). Configured via admin API, stored in database (`server_config` table)
- User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users` - User persistence: PostgreSQL `users` table, admin CRUD at `/api/v1/admin/users`
## CI/CD & Deployment ## CI/CD & Deployment
@@ -56,3 +56,7 @@ java -jar cameleer3-server-app/target/cameleer3-server-app-1.0-SNAPSHOT.jar
- Secrets managed in CI deploy step (idempotent `--dry-run=client | kubectl apply`): `cameleer-auth`, `postgres-credentials`, `opensearch-credentials` - Secrets managed in CI deploy step (idempotent `--dry-run=client | kubectl apply`): `cameleer-auth`, `postgres-credentials`, `opensearch-credentials`
- K8s probes: server uses `/api/v1/health`, PostgreSQL uses `pg_isready`, OpenSearch uses `/_cluster/health` - K8s probes: server uses `/api/v1/health`, PostgreSQL uses `pg_isready`, OpenSearch uses `/_cluster/health`
- Docker build uses buildx registry cache + `--provenance=false` for Gitea compatibility - Docker build uses buildx registry cache + `--provenance=false` for Gitea compatibility
## Disabled Skills
- Do NOT use any `gsd:*` skills in this project. This includes all `/gsd:` prefixed commands.

View File

@@ -1,17 +1,23 @@
package com.cameleer3.server.app.agent; package com.cameleer3.server.app.agent;
import com.cameleer3.server.core.agent.AgentEventService;
import com.cameleer3.server.core.agent.AgentInfo;
import com.cameleer3.server.core.agent.AgentRegistryService; import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.agent.AgentState;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/** /**
* Periodic task that checks agent lifecycle and expires old commands. * Periodic task that checks agent lifecycle and expires old commands.
* <p> * <p>
* Runs on a configurable fixed delay (default 10 seconds). Transitions * Runs on a configurable fixed delay (default 10 seconds). Transitions
* agents LIVE -> STALE -> DEAD based on heartbeat timing, and removes * agents LIVE -> STALE -> DEAD based on heartbeat timing, and removes
* expired pending commands. * expired pending commands. Records lifecycle events for state transitions.
*/ */
@Component @Component
public class AgentLifecycleMonitor { public class AgentLifecycleMonitor {
@@ -19,18 +25,46 @@ public class AgentLifecycleMonitor {
private static final Logger log = LoggerFactory.getLogger(AgentLifecycleMonitor.class); private static final Logger log = LoggerFactory.getLogger(AgentLifecycleMonitor.class);
private final AgentRegistryService registryService; private final AgentRegistryService registryService;
private final AgentEventService agentEventService;
public AgentLifecycleMonitor(AgentRegistryService registryService) { public AgentLifecycleMonitor(AgentRegistryService registryService,
AgentEventService agentEventService) {
this.registryService = registryService; this.registryService = registryService;
this.agentEventService = agentEventService;
} }
@Scheduled(fixedDelayString = "${agent-registry.lifecycle-check-interval-ms:10000}") @Scheduled(fixedDelayString = "${agent-registry.lifecycle-check-interval-ms:10000}")
public void checkLifecycle() { public void checkLifecycle() {
try { try {
// Snapshot states before lifecycle check
Map<String, AgentState> statesBefore = new HashMap<>();
for (AgentInfo agent : registryService.findAll()) {
statesBefore.put(agent.id(), agent.state());
}
registryService.checkLifecycle(); registryService.checkLifecycle();
registryService.expireOldCommands(); registryService.expireOldCommands();
// Detect transitions and record events
for (AgentInfo agent : registryService.findAll()) {
AgentState before = statesBefore.get(agent.id());
if (before != null && before != agent.state()) {
String eventType = mapTransitionEvent(before, agent.state());
if (eventType != null) {
agentEventService.recordEvent(agent.id(), agent.application(), eventType,
agent.name() + " " + before + " -> " + agent.state());
}
}
}
} catch (Exception e) { } catch (Exception e) {
log.error("Error during agent lifecycle check", e); log.error("Error during agent lifecycle check", e);
} }
} }
private String mapTransitionEvent(AgentState from, AgentState to) {
if (from == AgentState.LIVE && to == AgentState.STALE) return "WENT_STALE";
if (from == AgentState.STALE && to == AgentState.DEAD) return "WENT_DEAD";
if (from == AgentState.STALE && to == AgentState.LIVE) return "RECOVERED";
return null;
}
} }

View File

@@ -1,11 +1,13 @@
package com.cameleer3.server.app.config; package com.cameleer3.server.app.config;
import com.cameleer3.server.core.agent.AgentEventRepository;
import com.cameleer3.server.core.agent.AgentEventService;
import com.cameleer3.server.core.agent.AgentRegistryService; import com.cameleer3.server.core.agent.AgentRegistryService;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
/** /**
* Creates the {@link AgentRegistryService} bean. * Creates the {@link AgentRegistryService} and {@link AgentEventService} beans.
* <p> * <p>
* Follows the established pattern: core module plain class, app module bean config. * Follows the established pattern: core module plain class, app module bean config.
*/ */
@@ -20,4 +22,9 @@ public class AgentRegistryBeanConfig {
config.getCommandExpiryMs() config.getCommandExpiryMs()
); );
} }
@Bean
public AgentEventService agentEventService(AgentEventRepository repository) {
return new AgentEventService(repository);
}
} }

View File

@@ -31,7 +31,10 @@ public class OpenApiConfig {
"ExecutionSummary", "ExecutionDetail", "ExecutionStats", "ExecutionSummary", "ExecutionDetail", "ExecutionStats",
"StatsTimeseries", "TimeseriesBucket", "StatsTimeseries", "TimeseriesBucket",
"SearchResultExecutionSummary", "UserInfo", "SearchResultExecutionSummary", "UserInfo",
"ProcessorNode" "ProcessorNode",
"AppCatalogEntry", "RouteSummary", "AgentSummary",
"RouteMetrics", "AgentEventResponse", "AgentInstanceResponse",
"ProcessorMetrics", "AgentMetricsResponse", "MetricBucket"
); );
@Bean @Bean

View File

@@ -92,7 +92,7 @@ public class AgentCommandController {
List<AgentInfo> agents = registryService.findAll().stream() List<AgentInfo> agents = registryService.findAll().stream()
.filter(a -> a.state() == AgentState.LIVE) .filter(a -> a.state() == AgentState.LIVE)
.filter(a -> group.equals(a.group())) .filter(a -> group.equals(a.application()))
.toList(); .toList();
List<String> commandIds = new ArrayList<>(); List<String> commandIds = new ArrayList<>();

View File

@@ -0,0 +1,49 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.AgentEventResponse;
import com.cameleer3.server.core.agent.AgentEventService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.http.ResponseEntity;
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.util.List;
@RestController
@RequestMapping("/api/v1/agents/events-log")
@Tag(name = "Agent Events", description = "Agent lifecycle event log")
public class AgentEventsController {
private final AgentEventService agentEventService;
public AgentEventsController(AgentEventService agentEventService) {
this.agentEventService = agentEventService;
}
@GetMapping
@Operation(summary = "Query agent events",
description = "Returns agent lifecycle events, optionally filtered by app and/or agent ID")
@ApiResponse(responseCode = "200", description = "Events returned")
public ResponseEntity<List<AgentEventResponse>> getEvents(
@RequestParam(required = false) String appId,
@RequestParam(required = false) String agentId,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
@RequestParam(defaultValue = "50") int limit) {
Instant fromInstant = from != null ? Instant.parse(from) : null;
Instant toInstant = to != null ? Instant.parse(to) : null;
var events = agentEventService.queryEvents(appId, agentId, fromInstant, toInstant, limit)
.stream()
.map(AgentEventResponse::from)
.toList();
return ResponseEntity.ok(events);
}
}

View File

@@ -0,0 +1,66 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.AgentMetricsResponse;
import com.cameleer3.server.app.dto.MetricBucket;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.*;
@RestController
@RequestMapping("/api/v1/agents/{agentId}/metrics")
public class AgentMetricsController {
private final JdbcTemplate jdbc;
public AgentMetricsController(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@GetMapping
public AgentMetricsResponse getMetrics(
@PathVariable String agentId,
@RequestParam String names,
@RequestParam(required = false) Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(defaultValue = "60") int buckets) {
if (from == null) from = Instant.now().minus(1, ChronoUnit.HOURS);
if (to == null) to = Instant.now();
List<String> metricNames = Arrays.asList(names.split(","));
long intervalMs = (to.toEpochMilli() - from.toEpochMilli()) / Math.max(buckets, 1);
String intervalStr = intervalMs + " milliseconds";
Map<String, List<MetricBucket>> result = new LinkedHashMap<>();
for (String name : metricNames) {
result.put(name.trim(), new ArrayList<>());
}
String sql = """
SELECT time_bucket(CAST(? AS interval), collected_at) AS bucket,
metric_name,
AVG(metric_value) AS avg_value
FROM agent_metrics
WHERE agent_id = ?
AND collected_at >= ? AND collected_at < ?
AND metric_name = ANY(?)
GROUP BY bucket, metric_name
ORDER BY bucket
""";
String[] namesArray = metricNames.stream().map(String::trim).toArray(String[]::new);
jdbc.query(sql, rs -> {
String metricName = rs.getString("metric_name");
Instant bucket = rs.getTimestamp("bucket").toInstant();
double value = rs.getDouble("avg_value");
result.computeIfAbsent(metricName, k -> new ArrayList<>())
.add(new MetricBucket(bucket, value));
}, intervalStr, agentId, Timestamp.from(from), Timestamp.from(to), namesArray);
return new AgentMetricsResponse(result);
}
}

View File

@@ -8,11 +8,10 @@ import com.cameleer3.server.app.dto.AgentRegistrationRequest;
import com.cameleer3.server.app.dto.AgentRegistrationResponse; import com.cameleer3.server.app.dto.AgentRegistrationResponse;
import com.cameleer3.server.app.dto.ErrorResponse; import com.cameleer3.server.app.dto.ErrorResponse;
import com.cameleer3.server.app.security.BootstrapTokenValidator; import com.cameleer3.server.app.security.BootstrapTokenValidator;
import com.cameleer3.server.core.agent.AgentEventService;
import com.cameleer3.server.core.agent.AgentInfo; import com.cameleer3.server.core.agent.AgentInfo;
import com.cameleer3.server.core.agent.AgentRegistryService; import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.agent.AgentState; import com.cameleer3.server.core.agent.AgentState;
import com.cameleer3.server.core.rbac.RbacService;
import com.cameleer3.server.core.rbac.SystemRole;
import com.cameleer3.server.core.security.Ed25519SigningService; import com.cameleer3.server.core.security.Ed25519SigningService;
import com.cameleer3.server.core.security.InvalidTokenException; import com.cameleer3.server.core.security.InvalidTokenException;
import com.cameleer3.server.core.security.JwtService; import com.cameleer3.server.core.security.JwtService;
@@ -25,6 +24,7 @@ import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
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.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -33,8 +33,13 @@ 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.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* Agent registration, heartbeat, listing, and token refresh endpoints. * Agent registration, heartbeat, listing, and token refresh endpoints.
@@ -52,20 +57,23 @@ public class AgentRegistrationController {
private final BootstrapTokenValidator bootstrapTokenValidator; private final BootstrapTokenValidator bootstrapTokenValidator;
private final JwtService jwtService; private final JwtService jwtService;
private final Ed25519SigningService ed25519SigningService; private final Ed25519SigningService ed25519SigningService;
private final RbacService rbacService; private final AgentEventService agentEventService;
private final JdbcTemplate jdbc;
public AgentRegistrationController(AgentRegistryService registryService, public AgentRegistrationController(AgentRegistryService registryService,
AgentRegistryConfig config, AgentRegistryConfig config,
BootstrapTokenValidator bootstrapTokenValidator, BootstrapTokenValidator bootstrapTokenValidator,
JwtService jwtService, JwtService jwtService,
Ed25519SigningService ed25519SigningService, Ed25519SigningService ed25519SigningService,
RbacService rbacService) { AgentEventService agentEventService,
JdbcTemplate jdbc) {
this.registryService = registryService; this.registryService = registryService;
this.config = config; this.config = config;
this.bootstrapTokenValidator = bootstrapTokenValidator; this.bootstrapTokenValidator = bootstrapTokenValidator;
this.jwtService = jwtService; this.jwtService = jwtService;
this.ed25519SigningService = ed25519SigningService; this.ed25519SigningService = ed25519SigningService;
this.rbacService = rbacService; this.agentEventService = agentEventService;
this.jdbc = jdbc;
} }
@PostMapping("/register") @PostMapping("/register")
@@ -94,21 +102,21 @@ public class AgentRegistrationController {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
String group = request.group() != null ? request.group() : "default"; String application = request.application() != null ? request.application() : "default";
List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of(); List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap(); var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
AgentInfo agent = registryService.register( AgentInfo agent = registryService.register(
request.agentId(), request.name(), group, request.version(), routeIds, capabilities); request.agentId(), request.name(), application, request.version(), routeIds, capabilities);
log.info("Agent registered: {} (name={}, group={})", request.agentId(), request.name(), group); log.info("Agent registered: {} (name={}, application={})", request.agentId(), request.name(), application);
// Assign AGENT role via RBAC agentEventService.recordEvent(request.agentId(), application, "REGISTERED",
rbacService.assignRoleToUser(request.agentId(), SystemRole.AGENT_ID); "Agent registered: " + request.name());
// Issue JWT tokens with AGENT role // Issue JWT tokens with AGENT role
List<String> roles = List.of("AGENT"); List<String> roles = List.of("AGENT");
String accessToken = jwtService.createAccessToken(request.agentId(), group, roles); String accessToken = jwtService.createAccessToken(request.agentId(), application, roles);
String refreshToken = jwtService.createRefreshToken(request.agentId(), group, roles); String refreshToken = jwtService.createRefreshToken(request.agentId(), application, roles);
return ResponseEntity.ok(new AgentRegistrationResponse( return ResponseEntity.ok(new AgentRegistrationResponse(
agent.id(), agent.id(),
@@ -158,9 +166,10 @@ public class AgentRegistrationController {
// Preserve roles from refresh token // Preserve roles from refresh token
List<String> roles = result.roles().isEmpty() List<String> roles = result.roles().isEmpty()
? List.of("AGENT") : result.roles(); ? List.of("AGENT") : result.roles();
String newAccessToken = jwtService.createAccessToken(agentId, agent.group(), roles); String newAccessToken = jwtService.createAccessToken(agentId, agent.application(), roles);
String newRefreshToken = jwtService.createRefreshToken(agentId, agent.application(), roles);
return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken)); return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken, newRefreshToken));
} }
@PostMapping("/{id}/heartbeat") @PostMapping("/{id}/heartbeat")
@@ -178,13 +187,13 @@ public class AgentRegistrationController {
@GetMapping @GetMapping
@Operation(summary = "List all agents", @Operation(summary = "List all agents",
description = "Returns all registered agents, optionally filtered by status and/or group") description = "Returns all registered agents with runtime metrics, optionally filtered by status and/or application")
@ApiResponse(responseCode = "200", description = "Agent list returned") @ApiResponse(responseCode = "200", description = "Agent list returned")
@ApiResponse(responseCode = "400", description = "Invalid status filter", @ApiResponse(responseCode = "400", description = "Invalid status filter",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))) content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<List<AgentInstanceResponse>> listAgents( public ResponseEntity<List<AgentInstanceResponse>> listAgents(
@RequestParam(required = false) String status, @RequestParam(required = false) String status,
@RequestParam(required = false) String group) { @RequestParam(required = false) String application) {
List<AgentInfo> agents; List<AgentInfo> agents;
if (status != null) { if (status != null) {
@@ -198,16 +207,59 @@ public class AgentRegistrationController {
agents = registryService.findAll(); agents = registryService.findAll();
} }
// Apply group filter if specified // Apply application filter if specified
if (group != null && !group.isBlank()) { if (application != null && !application.isBlank()) {
agents = agents.stream() agents = agents.stream()
.filter(a -> group.equals(a.group())) .filter(a -> application.equals(a.application()))
.toList(); .toList();
} }
List<AgentInstanceResponse> response = agents.stream() // Enrich with runtime metrics from continuous aggregates
.map(AgentInstanceResponse::from) Map<String, double[]> agentMetrics = queryAgentMetrics();
final List<AgentInfo> finalAgents = agents;
List<AgentInstanceResponse> response = finalAgents.stream()
.map(a -> {
AgentInstanceResponse dto = AgentInstanceResponse.from(a);
double[] m = agentMetrics.get(a.application());
if (m != null) {
long appAgentCount = finalAgents.stream()
.filter(ag -> ag.application().equals(a.application())).count();
double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0;
double errorRate = m[1];
int activeRoutes = (int) m[2];
return dto.withMetrics(agentTps, errorRate, activeRoutes);
}
return dto;
})
.toList(); .toList();
return ResponseEntity.ok(response); 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_name, " +
"SUM(total_count) AS total, " +
"SUM(failed_count) AS failed, " +
"COUNT(DISTINCT route_id) AS active_routes " +
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
"GROUP BY application_name",
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_name"), new double[]{tps, errorRate, activeRoutes});
},
Timestamp.from(from1m), Timestamp.from(now));
} catch (Exception e) {
log.debug("Could not query agent metrics: {}", e.getMessage());
}
return result;
}
} }

View File

@@ -72,13 +72,14 @@ public class DatabaseAdminController {
@Operation(summary = "Get table sizes and row counts") @Operation(summary = "Get table sizes and row counts")
public ResponseEntity<List<TableSizeResponse>> getTables() { public ResponseEntity<List<TableSizeResponse>> getTables() {
var tables = jdbc.query(""" var tables = jdbc.query("""
SELECT schemaname || '.' || relname AS table_name, SELECT relname AS table_name,
n_live_tup AS row_count, n_live_tup AS row_count,
pg_size_pretty(pg_total_relation_size(relid)) AS data_size, pg_size_pretty(pg_total_relation_size(relid)) AS data_size,
pg_total_relation_size(relid) AS data_size_bytes, pg_total_relation_size(relid) AS data_size_bytes,
pg_size_pretty(pg_indexes_size(relid)) AS index_size, pg_size_pretty(pg_indexes_size(relid)) AS index_size,
pg_indexes_size(relid) AS index_size_bytes pg_indexes_size(relid) AS index_size_bytes
FROM pg_stat_user_tables FROM pg_stat_user_tables
WHERE schemaname = current_schema()
ORDER BY pg_total_relation_size(relid) DESC ORDER BY pg_total_relation_size(relid) DESC
""", (rs, row) -> new TableSizeResponse( """, (rs, row) -> new TableSizeResponse(
rs.getString("table_name"), rs.getLong("row_count"), rs.getString("table_name"), rs.getLong("row_count"),
@@ -94,7 +95,7 @@ public class DatabaseAdminController {
SELECT pid, EXTRACT(EPOCH FROM (now() - query_start)) AS duration_seconds, SELECT pid, EXTRACT(EPOCH FROM (now() - query_start)) AS duration_seconds,
state, query state, query
FROM pg_stat_activity FROM pg_stat_activity
WHERE state != 'idle' AND pid != pg_backend_pid() WHERE state != 'idle' AND pid != pg_backend_pid() AND datname = current_database()
ORDER BY query_start ASC ORDER BY query_start ASC
""", (rs, row) -> new ActiveQueryResponse( """, (rs, row) -> new ActiveQueryResponse(
rs.getInt("pid"), rs.getDouble("duration_seconds"), rs.getInt("pid"), rs.getDouble("duration_seconds"),

View File

@@ -90,14 +90,14 @@ public class DiagramRenderController {
} }
@GetMapping @GetMapping
@Operation(summary = "Find diagram by application group and route ID", @Operation(summary = "Find diagram by application and route ID",
description = "Resolves group to agent IDs and finds the latest diagram for the route") description = "Resolves application to agent IDs and finds the latest diagram for the route")
@ApiResponse(responseCode = "200", description = "Diagram layout returned") @ApiResponse(responseCode = "200", description = "Diagram layout returned")
@ApiResponse(responseCode = "404", description = "No diagram found for the given group and route") @ApiResponse(responseCode = "404", description = "No diagram found for the given application and route")
public ResponseEntity<DiagramLayout> findByGroupAndRoute( public ResponseEntity<DiagramLayout> findByApplicationAndRoute(
@RequestParam String group, @RequestParam String application,
@RequestParam String routeId) { @RequestParam String routeId) {
List<String> agentIds = registryService.findByGroup(group).stream() List<String> agentIds = registryService.findByApplication(application).stream()
.map(AgentInfo::id) .map(AgentInfo::id)
.toList(); .toList();

View File

@@ -53,11 +53,11 @@ public class ExecutionController {
@ApiResponse(responseCode = "202", description = "Data accepted for processing") @ApiResponse(responseCode = "202", description = "Data accepted for processing")
public ResponseEntity<Void> ingestExecutions(@RequestBody String body) throws JsonProcessingException { public ResponseEntity<Void> ingestExecutions(@RequestBody String body) throws JsonProcessingException {
String agentId = extractAgentId(); String agentId = extractAgentId();
String groupName = resolveGroupName(agentId); String applicationName = resolveApplicationName(agentId);
List<RouteExecution> executions = parsePayload(body); List<RouteExecution> executions = parsePayload(body);
for (RouteExecution execution : executions) { for (RouteExecution execution : executions) {
ingestionService.ingestExecution(agentId, groupName, execution); ingestionService.ingestExecution(agentId, applicationName, execution);
} }
return ResponseEntity.accepted().build(); return ResponseEntity.accepted().build();
@@ -68,9 +68,9 @@ public class ExecutionController {
return auth != null ? auth.getName() : ""; return auth != null ? auth.getName() : "";
} }
private String resolveGroupName(String agentId) { private String resolveApplicationName(String agentId) {
AgentInfo agent = registryService.findById(agentId); AgentInfo agent = registryService.findById(agentId);
return agent != null ? agent.group() : ""; return agent != null ? agent.application() : "";
} }
private List<RouteExecution> parsePayload(String body) throws JsonProcessingException { private List<RouteExecution> parsePayload(String body) throws JsonProcessingException {

View File

@@ -48,17 +48,20 @@ public class OpenSearchAdminController {
private final AuditService auditService; private final AuditService auditService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final String opensearchUrl; private final String opensearchUrl;
private final String indexPrefix;
public OpenSearchAdminController(OpenSearchClient client, RestClient restClient, public OpenSearchAdminController(OpenSearchClient client, RestClient restClient,
SearchIndexerStats indexerStats, AuditService auditService, SearchIndexerStats indexerStats, AuditService auditService,
ObjectMapper objectMapper, ObjectMapper objectMapper,
@Value("${opensearch.url:http://localhost:9200}") String opensearchUrl) { @Value("${opensearch.url:http://localhost:9200}") String opensearchUrl,
@Value("${opensearch.index-prefix:executions-}") String indexPrefix) {
this.client = client; this.client = client;
this.restClient = restClient; this.restClient = restClient;
this.indexerStats = indexerStats; this.indexerStats = indexerStats;
this.auditService = auditService; this.auditService = auditService;
this.objectMapper = objectMapper; this.objectMapper = objectMapper;
this.opensearchUrl = opensearchUrl; this.opensearchUrl = opensearchUrl;
this.indexPrefix = indexPrefix;
} }
@GetMapping("/status") @GetMapping("/status")
@@ -109,6 +112,9 @@ public class OpenSearchAdminController {
List<IndexInfoResponse> allIndices = new ArrayList<>(); List<IndexInfoResponse> allIndices = new ArrayList<>();
for (JsonNode idx : indices) { for (JsonNode idx : indices) {
String name = idx.path("index").asText(""); String name = idx.path("index").asText("");
if (!name.startsWith(indexPrefix)) {
continue;
}
if (!search.isEmpty() && !name.contains(search)) { if (!search.isEmpty() && !name.contains(search)) {
continue; continue;
} }
@@ -146,6 +152,9 @@ public class OpenSearchAdminController {
@Operation(summary = "Delete an OpenSearch index") @Operation(summary = "Delete an OpenSearch index")
public ResponseEntity<Void> deleteIndex(@PathVariable String name, HttpServletRequest request) { public ResponseEntity<Void> deleteIndex(@PathVariable String name, HttpServletRequest request) {
try { try {
if (!name.startsWith(indexPrefix)) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Cannot delete index outside application scope");
}
boolean exists = client.indices().exists(r -> r.index(name)).value(); boolean exists = client.indices().exists(r -> r.index(name)).value();
if (!exists) { if (!exists) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Index not found: " + name); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Index not found: " + name);

View File

@@ -0,0 +1,151 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.AgentSummary;
import com.cameleer3.server.app.dto.AppCatalogEntry;
import com.cameleer3.server.app.dto.RouteSummary;
import com.cameleer3.server.core.agent.AgentInfo;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.agent.AgentState;
import com.cameleer3.server.core.storage.StatsStore;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
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.RestController;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/v1/routes")
@Tag(name = "Route Catalog", description = "Route catalog and discovery")
public class RouteCatalogController {
private final AgentRegistryService registryService;
private final JdbcTemplate jdbc;
public RouteCatalogController(AgentRegistryService registryService, JdbcTemplate jdbc) {
this.registryService = registryService;
this.jdbc = jdbc;
}
@GetMapping("/catalog")
@Operation(summary = "Get route catalog",
description = "Returns all applications with their routes, agents, and health status")
@ApiResponse(responseCode = "200", description = "Catalog returned")
public ResponseEntity<List<AppCatalogEntry>> getCatalog() {
List<AgentInfo> allAgents = registryService.findAll();
// Group agents by application name
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
.collect(Collectors.groupingBy(AgentInfo::application, LinkedHashMap::new, Collectors.toList()));
// Collect all distinct routes per app
Map<String, Set<String>> routesByApp = new LinkedHashMap<>();
for (var entry : agentsByApp.entrySet()) {
Set<String> routes = new LinkedHashSet<>();
for (AgentInfo agent : entry.getValue()) {
if (agent.routeIds() != null) {
routes.addAll(agent.routeIds());
}
}
routesByApp.put(entry.getKey(), routes);
}
// Query route-level stats for the last 24 hours
Instant now = Instant.now();
Instant from24h = now.minus(24, ChronoUnit.HOURS);
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
// Route exchange counts from continuous aggregate
Map<String, Long> routeExchangeCounts = new LinkedHashMap<>();
Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
try {
jdbc.query(
"SELECT application_name, route_id, SUM(total_count) AS cnt, MAX(bucket) AS last_seen " +
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
"GROUP BY application_name, route_id",
rs -> {
String key = rs.getString("application_name") + "/" + rs.getString("route_id");
routeExchangeCounts.put(key, rs.getLong("cnt"));
Timestamp ts = rs.getTimestamp("last_seen");
if (ts != null) routeLastSeen.put(key, ts.toInstant());
},
Timestamp.from(from24h), Timestamp.from(now));
} catch (Exception e) {
// Continuous aggregate may not exist yet
}
// Per-agent TPS from the last minute
Map<String, Double> agentTps = new LinkedHashMap<>();
try {
jdbc.query(
"SELECT application_name, SUM(total_count) AS cnt " +
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
"GROUP BY application_name",
rs -> {
// This gives per-app TPS; we'll distribute among agents below
},
Timestamp.from(from1m), Timestamp.from(now));
} catch (Exception e) {
// Continuous aggregate may not exist yet
}
// Build catalog entries
List<AppCatalogEntry> catalog = new ArrayList<>();
for (var entry : agentsByApp.entrySet()) {
String appId = entry.getKey();
List<AgentInfo> agents = entry.getValue();
// Routes
Set<String> routeIds = routesByApp.getOrDefault(appId, Set.of());
List<RouteSummary> routeSummaries = routeIds.stream()
.map(routeId -> {
String key = appId + "/" + routeId;
long count = routeExchangeCounts.getOrDefault(key, 0L);
Instant lastSeen = routeLastSeen.get(key);
return new RouteSummary(routeId, count, lastSeen);
})
.toList();
// Agent summaries
List<AgentSummary> agentSummaries = agents.stream()
.map(a -> new AgentSummary(a.id(), a.name(), a.state().name().toLowerCase(), 0.0))
.toList();
// Health = worst state among agents
String health = computeWorstHealth(agents);
// Total exchange count for the app
long totalExchanges = routeSummaries.stream().mapToLong(RouteSummary::exchangeCount).sum();
catalog.add(new AppCatalogEntry(appId, routeSummaries, agentSummaries,
agents.size(), health, totalExchanges));
}
return ResponseEntity.ok(catalog);
}
private String computeWorstHealth(List<AgentInfo> agents) {
boolean hasDead = false;
boolean hasStale = false;
for (AgentInfo a : agents) {
if (a.state() == AgentState.DEAD) hasDead = true;
if (a.state() == AgentState.STALE) hasStale = true;
}
if (hasDead) return "dead";
if (hasStale) return "stale";
return "live";
}
}

View File

@@ -0,0 +1,164 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.ProcessorMetrics;
import com.cameleer3.server.app.dto.RouteMetrics;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
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.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
@RestController
@RequestMapping("/api/v1/routes")
@Tag(name = "Route Metrics", description = "Route performance metrics")
public class RouteMetricsController {
private final JdbcTemplate jdbc;
public RouteMetricsController(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@GetMapping("/metrics")
@Operation(summary = "Get route metrics",
description = "Returns aggregated performance metrics per route for the given time window")
@ApiResponse(responseCode = "200", description = "Metrics returned")
public ResponseEntity<List<RouteMetrics>> getMetrics(
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
@RequestParam(required = false) String appId) {
Instant toInstant = to != null ? Instant.parse(to) : Instant.now();
Instant fromInstant = from != null ? Instant.parse(from) : toInstant.minus(24, ChronoUnit.HOURS);
long windowSeconds = Duration.between(fromInstant, toInstant).toSeconds();
var sql = new StringBuilder(
"SELECT application_name, route_id, " +
"SUM(total_count) AS total, " +
"SUM(failed_count) AS failed, " +
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum) / SUM(total_count) ELSE 0 END AS avg_dur, " +
"COALESCE(MAX(p99_duration), 0) AS p99_dur " +
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ?");
var params = new ArrayList<Object>();
params.add(Timestamp.from(fromInstant));
params.add(Timestamp.from(toInstant));
if (appId != null) {
sql.append(" AND application_name = ?");
params.add(appId);
}
sql.append(" GROUP BY application_name, route_id ORDER BY application_name, route_id");
// Key struct for sparkline lookup
record RouteKey(String appId, String routeId) {}
List<RouteKey> routeKeys = new ArrayList<>();
List<RouteMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
String applicationName = rs.getString("application_name");
String routeId = rs.getString("route_id");
long total = rs.getLong("total");
long failed = rs.getLong("failed");
double avgDur = rs.getDouble("avg_dur");
double p99Dur = rs.getDouble("p99_dur");
double successRate = total > 0 ? (double) (total - failed) / total : 1.0;
double errorRate = total > 0 ? (double) failed / total : 0.0;
double tps = windowSeconds > 0 ? (double) total / windowSeconds : 0.0;
routeKeys.add(new RouteKey(applicationName, routeId));
return new RouteMetrics(routeId, applicationName, total, successRate,
avgDur, p99Dur, errorRate, tps, List.of());
}, params.toArray());
// Fetch sparklines (12 buckets over the time window)
if (!metrics.isEmpty()) {
int sparkBuckets = 12;
long bucketSeconds = Math.max(windowSeconds / sparkBuckets, 60);
for (int i = 0; i < metrics.size(); i++) {
RouteMetrics m = metrics.get(i);
try {
List<Double> sparkline = jdbc.query(
"SELECT time_bucket(? * INTERVAL '1 second', bucket) AS period, " +
"COALESCE(SUM(total_count), 0) AS cnt " +
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
"AND application_name = ? AND route_id = ? " +
"GROUP BY period ORDER BY period",
(rs, rowNum) -> rs.getDouble("cnt"),
bucketSeconds, Timestamp.from(fromInstant), Timestamp.from(toInstant),
m.appId(), m.routeId());
metrics.set(i, new RouteMetrics(m.routeId(), m.appId(), m.exchangeCount(),
m.successRate(), m.avgDurationMs(), m.p99DurationMs(),
m.errorRate(), m.throughputPerSec(), sparkline));
} catch (Exception e) {
// Leave sparkline empty on error
}
}
}
return ResponseEntity.ok(metrics);
}
@GetMapping("/metrics/processors")
@Operation(summary = "Get processor metrics",
description = "Returns aggregated performance metrics per processor for the given route and time window")
@ApiResponse(responseCode = "200", description = "Metrics returned")
public ResponseEntity<List<ProcessorMetrics>> getProcessorMetrics(
@RequestParam String routeId,
@RequestParam(required = false) String appId,
@RequestParam(required = false) Instant from,
@RequestParam(required = false) Instant to) {
Instant toInstant = to != null ? to : Instant.now();
Instant fromInstant = from != null ? from : toInstant.minus(24, ChronoUnit.HOURS);
var sql = new StringBuilder(
"SELECT processor_id, processor_type, route_id, application_name, " +
"SUM(total_count) AS total_count, " +
"SUM(failed_count) AS failed_count, " +
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum)::double precision / SUM(total_count) ELSE 0 END AS avg_duration_ms, " +
"MAX(p99_duration) AS p99_duration_ms " +
"FROM stats_1m_processor_detail " +
"WHERE bucket >= ? AND bucket < ? AND route_id = ?");
var params = new ArrayList<Object>();
params.add(Timestamp.from(fromInstant));
params.add(Timestamp.from(toInstant));
params.add(routeId);
if (appId != null) {
sql.append(" AND application_name = ?");
params.add(appId);
}
sql.append(" GROUP BY processor_id, processor_type, route_id, application_name");
sql.append(" ORDER BY SUM(total_count) DESC");
List<ProcessorMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
long totalCount = rs.getLong("total_count");
long failedCount = rs.getLong("failed_count");
double errorRate = failedCount > 0 ? (double) failedCount / totalCount : 0.0;
return new ProcessorMetrics(
rs.getString("processor_id"),
rs.getString("processor_type"),
rs.getString("route_id"),
rs.getString("application_name"),
totalCount,
failedCount,
rs.getDouble("avg_duration_ms"),
rs.getDouble("p99_duration_ms"),
errorRate);
}, params.toArray());
return ResponseEntity.ok(metrics);
}
}

View File

@@ -51,13 +51,13 @@ public class SearchController {
@RequestParam(required = false) String routeId, @RequestParam(required = false) String routeId,
@RequestParam(required = false) String agentId, @RequestParam(required = false) String agentId,
@RequestParam(required = false) String processorType, @RequestParam(required = false) String processorType,
@RequestParam(required = false) String group, @RequestParam(required = false) String application,
@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,
@RequestParam(required = false) String sortDir) { @RequestParam(required = false) String sortDir) {
List<String> agentIds = resolveGroupToAgentIds(group); List<String> agentIds = resolveApplicationToAgentIds(application);
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
status, timeFrom, timeTo, status, timeFrom, timeTo,
@@ -65,7 +65,7 @@ public class SearchController {
correlationId, correlationId,
text, null, null, null, text, null, null, null,
routeId, agentId, processorType, routeId, agentId, processorType,
group, agentIds, application, agentIds,
offset, limit, offset, limit,
sortField, sortDir sortField, sortDir
); );
@@ -77,11 +77,11 @@ public class SearchController {
@Operation(summary = "Advanced search with all filters") @Operation(summary = "Advanced search with all filters")
public ResponseEntity<SearchResult<ExecutionSummary>> searchPost( public ResponseEntity<SearchResult<ExecutionSummary>> searchPost(
@RequestBody SearchRequest request) { @RequestBody SearchRequest request) {
// Resolve group to agentIds if group is specified but agentIds is not // Resolve application to agentIds if application is specified but agentIds is not
SearchRequest resolved = request; SearchRequest resolved = request;
if (request.group() != null && !request.group().isBlank() if (request.application() != null && !request.application().isBlank()
&& (request.agentIds() == null || request.agentIds().isEmpty())) { && (request.agentIds() == null || request.agentIds().isEmpty())) {
resolved = request.withAgentIds(resolveGroupToAgentIds(request.group())); resolved = request.withAgentIds(resolveApplicationToAgentIds(request.application()));
} }
return ResponseEntity.ok(searchService.search(resolved)); return ResponseEntity.ok(searchService.search(resolved));
} }
@@ -92,12 +92,15 @@ public class SearchController {
@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 group) { @RequestParam(required = false) String application) {
Instant end = to != null ? to : Instant.now(); Instant end = to != null ? to : Instant.now();
List<String> agentIds = resolveGroupToAgentIds(group); if (routeId == null && application == null) {
if (routeId == null && agentIds == null) {
return ResponseEntity.ok(searchService.stats(from, end)); return ResponseEntity.ok(searchService.stats(from, end));
} }
if (routeId == null) {
return ResponseEntity.ok(searchService.statsForApp(from, end, application));
}
List<String> agentIds = resolveApplicationToAgentIds(application);
return ResponseEntity.ok(searchService.stats(from, end, routeId, agentIds)); return ResponseEntity.ok(searchService.stats(from, end, routeId, agentIds));
} }
@@ -108,9 +111,15 @@ public class SearchController {
@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 group) { @RequestParam(required = false) String application) {
Instant end = to != null ? to : Instant.now(); Instant end = to != null ? to : Instant.now();
List<String> agentIds = resolveGroupToAgentIds(group); if (routeId == null && application == null) {
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
}
if (routeId == null) {
return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application));
}
List<String> agentIds = resolveApplicationToAgentIds(application);
if (routeId == null && agentIds == null) { if (routeId == null && agentIds == null) {
return ResponseEntity.ok(searchService.timeseries(from, end, buckets)); return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
} }
@@ -118,14 +127,14 @@ public class SearchController {
} }
/** /**
* Resolve an application group name to agent IDs. * Resolve an application name to agent IDs.
* Returns null if group is null/blank (no filtering). * Returns null if application is null/blank (no filtering).
*/ */
private List<String> resolveGroupToAgentIds(String group) { private List<String> resolveApplicationToAgentIds(String application) {
if (group == null || group.isBlank()) { if (application == null || application.isBlank()) {
return null; return null;
} }
return registryService.findByGroup(group).stream() return registryService.findByApplication(application).stream()
.map(AgentInfo::id) .map(AgentInfo::id)
.toList(); .toList();
} }

View File

@@ -1,5 +1,6 @@
package com.cameleer3.server.app.controller; package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.dto.SetPasswordRequest;
import com.cameleer3.server.core.admin.AuditCategory; import com.cameleer3.server.core.admin.AuditCategory;
import com.cameleer3.server.core.admin.AuditResult; import com.cameleer3.server.core.admin.AuditResult;
import com.cameleer3.server.core.admin.AuditService; import com.cameleer3.server.core.admin.AuditService;
@@ -12,6 +13,7 @@ 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;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.DeleteMapping;
@@ -172,6 +174,18 @@ public class UserAdminController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
@PostMapping("/{userId}/password")
@Operation(summary = "Reset user password")
@ApiResponse(responseCode = "204", description = "Password reset")
public ResponseEntity<Void> resetPassword(
@PathVariable String userId,
@Valid @RequestBody SetPasswordRequest request,
HttpServletRequest httpRequest) {
userRepository.setPassword(userId, passwordEncoder.encode(request.password()));
auditService.log("reset_password", AuditCategory.USER_MGMT, userId, null, AuditResult.SUCCESS, httpRequest);
return ResponseEntity.noContent().build();
}
public record CreateUserRequest(String username, String displayName, String email, String password) {} public record CreateUserRequest(String username, String displayName, String email, String password) {}
public record UpdateUserRequest(String displayName, String email) {} public record UpdateUserRequest(String displayName, String email) {}
} }

View File

@@ -0,0 +1,24 @@
package com.cameleer3.server.app.dto;
import com.cameleer3.server.core.agent.AgentEventRecord;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
@Schema(description = "Agent lifecycle event")
public record AgentEventResponse(
@NotNull long id,
@NotNull String agentId,
@NotNull String appId,
@NotNull String eventType,
String detail,
@NotNull Instant timestamp
) {
public static AgentEventResponse from(AgentEventRecord record) {
return new AgentEventResponse(
record.id(), record.agentId(), record.appId(),
record.eventType(), record.detail(), record.timestamp()
);
}
}

View File

@@ -4,24 +4,46 @@ import com.cameleer3.server.core.agent.AgentInfo;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map;
@Schema(description = "Agent instance summary") @Schema(description = "Agent instance summary with runtime metrics")
public record AgentInstanceResponse( public record AgentInstanceResponse(
@NotNull String id, @NotNull String id,
@NotNull String name, @NotNull String name,
@NotNull String group, @NotNull String application,
@NotNull String status, @NotNull String status,
@NotNull List<String> routeIds, @NotNull List<String> routeIds,
@NotNull Instant registeredAt, @NotNull Instant registeredAt,
@NotNull Instant lastHeartbeat @NotNull Instant lastHeartbeat,
String version,
Map<String, Object> capabilities,
double tps,
double errorRate,
int activeRoutes,
int totalRoutes,
long uptimeSeconds
) { ) {
public static AgentInstanceResponse from(AgentInfo info) { public static AgentInstanceResponse from(AgentInfo info) {
long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds();
return new AgentInstanceResponse( return new AgentInstanceResponse(
info.id(), info.name(), info.group(), info.id(), info.name(), info.application(),
info.state().name(), info.routeIds(), info.state().name(), info.routeIds(),
info.registeredAt(), info.lastHeartbeat() info.registeredAt(), info.lastHeartbeat(),
info.version(), info.capabilities(),
0.0, 0.0,
0, info.routeIds() != null ? info.routeIds().size() : 0,
uptime
);
}
public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) {
return new AgentInstanceResponse(
id, name, application, status, routeIds, registeredAt, lastHeartbeat,
version, capabilities,
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds
); );
} }
} }

View File

@@ -0,0 +1,9 @@
package com.cameleer3.server.app.dto;
import java.util.List;
import java.util.Map;
import jakarta.validation.constraints.NotNull;
public record AgentMetricsResponse(
@NotNull Map<String, List<MetricBucket>> metrics
) {}

View File

@@ -3,5 +3,5 @@ package com.cameleer3.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
@Schema(description = "Refreshed access token") @Schema(description = "Refreshed access and refresh tokens")
public record AgentRefreshResponse(@NotNull String accessToken) {} public record AgentRefreshResponse(@NotNull String accessToken, @NotNull String refreshToken) {}

View File

@@ -10,7 +10,7 @@ import java.util.Map;
public record AgentRegistrationRequest( public record AgentRegistrationRequest(
@NotNull String agentId, @NotNull String agentId,
@NotNull String name, @NotNull String name,
@Schema(defaultValue = "default") String group, @Schema(defaultValue = "default") String application,
String version, String version,
List<String> routeIds, List<String> routeIds,
Map<String, Object> capabilities Map<String, Object> capabilities

View File

@@ -0,0 +1,12 @@
package com.cameleer3.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
@Schema(description = "Summary of an agent instance for sidebar display")
public record AgentSummary(
@NotNull String id,
@NotNull String name,
@NotNull String status,
@NotNull double tps
) {}

View File

@@ -0,0 +1,16 @@
package com.cameleer3.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import java.util.List;
@Schema(description = "Application catalog entry with routes and agents")
public record AppCatalogEntry(
@NotNull String appId,
@NotNull List<RouteSummary> routes,
@NotNull List<AgentSummary> agents,
@NotNull int agentCount,
@NotNull String health,
@NotNull long exchangeCount
) {}

View File

@@ -0,0 +1,9 @@
package com.cameleer3.server.app.dto;
import java.time.Instant;
import jakarta.validation.constraints.NotNull;
public record MetricBucket(
@NotNull Instant time,
double value
) {}

View File

@@ -0,0 +1,15 @@
package com.cameleer3.server.app.dto;
import jakarta.validation.constraints.NotNull;
public record ProcessorMetrics(
@NotNull String processorId,
@NotNull String processorType,
@NotNull String routeId,
@NotNull String appId,
long totalCount,
long failedCount,
double avgDurationMs,
double p99DurationMs,
double errorRate
) {}

View File

@@ -0,0 +1,19 @@
package com.cameleer3.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import java.util.List;
@Schema(description = "Aggregated route performance metrics")
public record RouteMetrics(
@NotNull String routeId,
@NotNull String appId,
@NotNull long exchangeCount,
@NotNull double successRate,
@NotNull double avgDurationMs,
@NotNull double p99DurationMs,
@NotNull double errorRate,
@NotNull double throughputPerSec,
@NotNull List<Double> sparkline
) {}

View File

@@ -0,0 +1,13 @@
package com.cameleer3.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
@Schema(description = "Summary of a route within an application")
public record RouteSummary(
@NotNull String routeId,
@NotNull long exchangeCount,
Instant lastSeen
) {}

View File

@@ -0,0 +1,7 @@
package com.cameleer3.server.app.dto;
import jakarta.validation.constraints.NotBlank;
public record SetPasswordRequest(
@NotBlank String password
) {}

View File

@@ -288,7 +288,7 @@ public class OpenSearchIndex implements SearchIndex {
map.put("execution_id", doc.executionId()); map.put("execution_id", doc.executionId());
map.put("route_id", doc.routeId()); map.put("route_id", doc.routeId());
map.put("agent_id", doc.agentId()); map.put("agent_id", doc.agentId());
map.put("group_name", doc.groupName()); map.put("application_name", doc.applicationName());
map.put("status", doc.status()); map.put("status", doc.status());
map.put("correlation_id", doc.correlationId()); map.put("correlation_id", doc.correlationId());
map.put("exchange_id", doc.exchangeId()); map.put("exchange_id", doc.exchangeId());
@@ -323,6 +323,7 @@ public class OpenSearchIndex implements SearchIndex {
(String) src.get("execution_id"), (String) src.get("execution_id"),
(String) src.get("route_id"), (String) src.get("route_id"),
(String) src.get("agent_id"), (String) src.get("agent_id"),
(String) src.get("application_name"),
(String) src.get("status"), (String) src.get("status"),
src.get("start_time") != null ? Instant.parse((String) src.get("start_time")) : null, src.get("start_time") != null ? Instant.parse((String) src.get("start_time")) : null,
src.get("end_time") != null ? Instant.parse((String) src.get("end_time")) : null, src.get("end_time") != null ? Instant.parse((String) src.get("end_time")) : null,

View File

@@ -60,13 +60,13 @@ public class JwtServiceImpl implements JwtService {
} }
@Override @Override
public String createAccessToken(String subject, String group, List<String> roles) { public String createAccessToken(String subject, String application, List<String> roles) {
return createToken(subject, group, roles, "access", properties.getAccessTokenExpiryMs()); return createToken(subject, application, roles, "access", properties.getAccessTokenExpiryMs());
} }
@Override @Override
public String createRefreshToken(String subject, String group, List<String> roles) { public String createRefreshToken(String subject, String application, List<String> roles) {
return createToken(subject, group, roles, "refresh", properties.getRefreshTokenExpiryMs()); return createToken(subject, application, roles, "refresh", properties.getRefreshTokenExpiryMs());
} }
@Override @Override
@@ -84,12 +84,12 @@ public class JwtServiceImpl implements JwtService {
return validateAccessToken(token).subject(); return validateAccessToken(token).subject();
} }
private String createToken(String subject, String group, List<String> roles, private String createToken(String subject, String application, List<String> roles,
String type, long expiryMs) { String type, long expiryMs) {
Instant now = Instant.now(); Instant now = Instant.now();
JWTClaimsSet claims = new JWTClaimsSet.Builder() JWTClaimsSet claims = new JWTClaimsSet.Builder()
.subject(subject) .subject(subject)
.claim("group", group) .claim("group", application)
.claim("type", type) .claim("type", type)
.claim("roles", roles) .claim("roles", roles)
.issueTime(Date.from(now)) .issueTime(Date.from(now))
@@ -132,7 +132,7 @@ public class JwtServiceImpl implements JwtService {
throw new InvalidTokenException("Token has no subject"); throw new InvalidTokenException("Token has no subject");
} }
String group = claims.getStringClaim("group"); String application = claims.getStringClaim("group");
// Extract roles — may be absent in legacy tokens // Extract roles — may be absent in legacy tokens
List<String> roles; List<String> roles;
@@ -145,7 +145,7 @@ public class JwtServiceImpl implements JwtService {
roles = List.of(); roles = List.of();
} }
return new JwtValidationResult(subject, group, roles); return new JwtValidationResult(subject, application, roles);
} catch (ParseException e) { } catch (ParseException e) {
throw new InvalidTokenException("Failed to parse JWT", e); throw new InvalidTokenException("Failed to parse JWT", e);
} catch (JOSEException e) { } catch (JOSEException e) {

View File

@@ -80,7 +80,10 @@ public class SecurityConfig {
// Read-only data endpoints — viewer+ // Read-only data endpoints — viewer+
.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").hasAnyRole("VIEWER", "OPERATOR", "ADMIN") .requestMatchers(HttpMethod.GET, "/api/v1/agents").hasAnyRole("VIEWER", "OPERATOR", "ADMIN")
.requestMatchers(HttpMethod.GET, "/api/v1/agents/events-log").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")
// Admin endpoints // Admin endpoints

View File

@@ -0,0 +1,62 @@
package com.cameleer3.server.app.storage;
import com.cameleer3.server.core.agent.AgentEventRecord;
import com.cameleer3.server.core.agent.AgentEventRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Repository
public class PostgresAgentEventRepository implements AgentEventRepository {
private final JdbcTemplate jdbc;
public PostgresAgentEventRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public void insert(String agentId, String appId, String eventType, String detail) {
jdbc.update(
"INSERT INTO agent_events (agent_id, app_id, event_type, detail) VALUES (?, ?, ?, ?)",
agentId, appId, eventType, detail);
}
@Override
public List<AgentEventRecord> query(String appId, String agentId, Instant from, Instant to, int limit) {
var sql = new StringBuilder("SELECT id, agent_id, app_id, event_type, detail, timestamp FROM agent_events WHERE 1=1");
var params = new ArrayList<Object>();
if (appId != null) {
sql.append(" AND app_id = ?");
params.add(appId);
}
if (agentId != null) {
sql.append(" AND agent_id = ?");
params.add(agentId);
}
if (from != null) {
sql.append(" AND timestamp >= ?");
params.add(Timestamp.from(from));
}
if (to != null) {
sql.append(" AND timestamp < ?");
params.add(Timestamp.from(to));
}
sql.append(" ORDER BY timestamp DESC LIMIT ?");
params.add(limit);
return jdbc.query(sql.toString(), (rs, rowNum) -> new AgentEventRecord(
rs.getLong("id"),
rs.getString("agent_id"),
rs.getString("app_id"),
rs.getString("event_type"),
rs.getString("detail"),
rs.getTimestamp("timestamp").toInstant()
), params.toArray());
}
}

View File

@@ -24,7 +24,7 @@ public class PostgresExecutionStore implements ExecutionStore {
@Override @Override
public void upsert(ExecutionRecord execution) { public void upsert(ExecutionRecord execution) {
jdbc.update(""" jdbc.update("""
INSERT INTO executions (execution_id, route_id, agent_id, group_name, INSERT INTO executions (execution_id, route_id, agent_id, application_name,
status, correlation_id, exchange_id, start_time, end_time, status, correlation_id, exchange_id, start_time, end_time,
duration_ms, error_message, error_stacktrace, diagram_content_hash, duration_ms, error_message, error_stacktrace, diagram_content_hash,
created_at, updated_at) created_at, updated_at)
@@ -45,7 +45,7 @@ public class PostgresExecutionStore implements ExecutionStore {
updated_at = now() updated_at = now()
""", """,
execution.executionId(), execution.routeId(), execution.agentId(), execution.executionId(), execution.routeId(), execution.agentId(),
execution.groupName(), execution.status(), execution.correlationId(), execution.applicationName(), execution.status(), execution.correlationId(),
execution.exchangeId(), execution.exchangeId(),
Timestamp.from(execution.startTime()), Timestamp.from(execution.startTime()),
execution.endTime() != null ? Timestamp.from(execution.endTime()) : null, execution.endTime() != null ? Timestamp.from(execution.endTime()) : null,
@@ -55,11 +55,11 @@ public class PostgresExecutionStore implements ExecutionStore {
@Override @Override
public void upsertProcessors(String executionId, Instant startTime, public void upsertProcessors(String executionId, Instant startTime,
String groupName, String routeId, String applicationName, String routeId,
List<ProcessorRecord> processors) { List<ProcessorRecord> processors) {
jdbc.batchUpdate(""" jdbc.batchUpdate("""
INSERT INTO processor_executions (execution_id, processor_id, processor_type, INSERT INTO processor_executions (execution_id, processor_id, processor_type,
diagram_node_id, group_name, route_id, depth, parent_processor_id, diagram_node_id, application_name, route_id, depth, parent_processor_id,
status, start_time, end_time, duration_ms, error_message, error_stacktrace, status, start_time, end_time, duration_ms, error_message, error_stacktrace,
input_body, output_body, input_headers, output_headers) input_body, output_body, input_headers, output_headers)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb)
@@ -76,7 +76,7 @@ public class PostgresExecutionStore implements ExecutionStore {
""", """,
processors.stream().map(p -> new Object[]{ processors.stream().map(p -> new Object[]{
p.executionId(), p.processorId(), p.processorType(), p.executionId(), p.processorId(), p.processorType(),
p.diagramNodeId(), p.groupName(), p.routeId(), p.diagramNodeId(), p.applicationName(), p.routeId(),
p.depth(), p.parentProcessorId(), p.status(), p.depth(), p.parentProcessorId(), p.status(),
Timestamp.from(p.startTime()), Timestamp.from(p.startTime()),
p.endTime() != null ? Timestamp.from(p.endTime()) : null, p.endTime() != null ? Timestamp.from(p.endTime()) : null,
@@ -103,7 +103,7 @@ public class PostgresExecutionStore implements ExecutionStore {
private static final RowMapper<ExecutionRecord> EXECUTION_MAPPER = (rs, rowNum) -> private static final RowMapper<ExecutionRecord> EXECUTION_MAPPER = (rs, rowNum) ->
new ExecutionRecord( new ExecutionRecord(
rs.getString("execution_id"), rs.getString("route_id"), rs.getString("execution_id"), rs.getString("route_id"),
rs.getString("agent_id"), rs.getString("group_name"), rs.getString("agent_id"), rs.getString("application_name"),
rs.getString("status"), rs.getString("correlation_id"), rs.getString("status"), rs.getString("correlation_id"),
rs.getString("exchange_id"), rs.getString("exchange_id"),
toInstant(rs, "start_time"), toInstant(rs, "end_time"), toInstant(rs, "start_time"), toInstant(rs, "end_time"),
@@ -115,7 +115,7 @@ public class PostgresExecutionStore implements ExecutionStore {
new ProcessorRecord( new ProcessorRecord(
rs.getString("execution_id"), rs.getString("processor_id"), rs.getString("execution_id"), rs.getString("processor_id"),
rs.getString("processor_type"), rs.getString("diagram_node_id"), rs.getString("processor_type"), rs.getString("diagram_node_id"),
rs.getString("group_name"), rs.getString("route_id"), rs.getString("application_name"), rs.getString("route_id"),
rs.getInt("depth"), rs.getString("parent_processor_id"), rs.getInt("depth"), rs.getString("parent_processor_id"),
rs.getString("status"), rs.getString("status"),
toInstant(rs, "start_time"), toInstant(rs, "end_time"), toInstant(rs, "start_time"), toInstant(rs, "end_time"),

View File

@@ -2,10 +2,11 @@ package com.cameleer3.server.app.storage;
import com.cameleer3.server.core.security.OidcConfig; import com.cameleer3.server.core.security.OidcConfig;
import com.cameleer3.server.core.security.OidcConfigRepository; import com.cameleer3.server.core.security.OidcConfigRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.sql.Array;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -13,47 +14,49 @@ import java.util.Optional;
public class PostgresOidcConfigRepository implements OidcConfigRepository { public class PostgresOidcConfigRepository implements OidcConfigRepository {
private final JdbcTemplate jdbc; private final JdbcTemplate jdbc;
private final ObjectMapper objectMapper;
public PostgresOidcConfigRepository(JdbcTemplate jdbc) { public PostgresOidcConfigRepository(JdbcTemplate jdbc, ObjectMapper objectMapper) {
this.jdbc = jdbc; this.jdbc = jdbc;
this.objectMapper = objectMapper;
} }
@Override @Override
public Optional<OidcConfig> find() { public Optional<OidcConfig> find() {
var results = jdbc.query( List<OidcConfig> results = jdbc.query(
"SELECT * FROM oidc_config WHERE config_id = 'default'", "SELECT config_val FROM server_config WHERE config_key = 'oidc'",
(rs, rowNum) -> { (rs, rowNum) -> {
Array arr = rs.getArray("default_roles"); String json = rs.getString("config_val");
String[] roles = arr != null ? (String[]) arr.getArray() : new String[0]; try {
return new OidcConfig( return objectMapper.readValue(json, OidcConfig.class);
rs.getBoolean("enabled"), rs.getString("issuer_uri"), } catch (JsonProcessingException e) {
rs.getString("client_id"), rs.getString("client_secret"), throw new RuntimeException("Failed to deserialize OIDC config", e);
rs.getString("roles_claim"), List.of(roles), }
rs.getBoolean("auto_signup"), rs.getString("display_name_claim"));
}); });
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
} }
@Override @Override
public void save(OidcConfig config) { public void save(OidcConfig config) {
String json;
try {
json = objectMapper.writeValueAsString(config);
} catch (JsonProcessingException e) {
throw new RuntimeException("Failed to serialize OIDC config", e);
}
jdbc.update(""" jdbc.update("""
INSERT INTO oidc_config (config_id, enabled, issuer_uri, client_id, client_secret, INSERT INTO server_config (config_key, config_val, updated_at)
roles_claim, default_roles, auto_signup, display_name_claim, updated_at) VALUES ('oidc', ?::jsonb, now())
VALUES ('default', ?, ?, ?, ?, ?, ?, ?, ?, now()) ON CONFLICT (config_key) DO UPDATE SET
ON CONFLICT (config_id) DO UPDATE SET config_val = EXCLUDED.config_val,
enabled = EXCLUDED.enabled, issuer_uri = EXCLUDED.issuer_uri,
client_id = EXCLUDED.client_id, client_secret = EXCLUDED.client_secret,
roles_claim = EXCLUDED.roles_claim, default_roles = EXCLUDED.default_roles,
auto_signup = EXCLUDED.auto_signup, display_name_claim = EXCLUDED.display_name_claim,
updated_at = now() updated_at = now()
""", """,
config.enabled(), config.issuerUri(), config.clientId(), config.clientSecret(), json);
config.rolesClaim(), config.defaultRoles().toArray(new String[0]),
config.autoSignup(), config.displayNameClaim());
} }
@Override @Override
public void delete() { public void delete() {
jdbc.update("DELETE FROM oidc_config WHERE config_id = 'default'"); jdbc.update("DELETE FROM server_config WHERE config_key = 'oidc'");
} }
} }

View File

@@ -29,9 +29,9 @@ public class PostgresStatsStore implements StatsStore {
} }
@Override @Override
public ExecutionStats statsForApp(Instant from, Instant to, String groupName) { public ExecutionStats statsForApp(Instant from, Instant to, String applicationName) {
return queryStats("stats_1m_app", from, to, List.of( return queryStats("stats_1m_app", from, to, List.of(
new Filter("group_name", groupName))); new Filter("application_name", applicationName)));
} }
@Override @Override
@@ -56,9 +56,9 @@ public class PostgresStatsStore implements StatsStore {
} }
@Override @Override
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String groupName) { public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationName) {
return queryTimeseries("stats_1m_app", from, to, bucketCount, List.of( return queryTimeseries("stats_1m_app", from, to, bucketCount, List.of(
new Filter("group_name", groupName)), true); new Filter("application_name", applicationName)), true);
} }
@Override @Override

View File

@@ -24,9 +24,9 @@ public class PostgresThresholdRepository implements ThresholdRepository {
@Override @Override
public Optional<ThresholdConfig> find() { public Optional<ThresholdConfig> find() {
List<ThresholdConfig> results = jdbc.query( List<ThresholdConfig> results = jdbc.query(
"SELECT config FROM admin_thresholds WHERE id = 1", "SELECT config_val FROM server_config WHERE config_key = 'thresholds'",
(rs, rowNum) -> { (rs, rowNum) -> {
String json = rs.getString("config"); String json = rs.getString("config_val");
try { try {
return objectMapper.readValue(json, ThresholdConfig.class); return objectMapper.readValue(json, ThresholdConfig.class);
} catch (JsonProcessingException e) { } catch (JsonProcessingException e) {
@@ -46,10 +46,10 @@ public class PostgresThresholdRepository implements ThresholdRepository {
} }
jdbc.update(""" jdbc.update("""
INSERT INTO admin_thresholds (id, config, updated_by, updated_at) INSERT INTO server_config (config_key, config_val, updated_by, updated_at)
VALUES (1, ?::jsonb, ?, now()) VALUES ('thresholds', ?::jsonb, ?, now())
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (config_key) DO UPDATE SET
config = EXCLUDED.config, config_val = EXCLUDED.config_val,
updated_by = EXCLUDED.updated_by, updated_by = EXCLUDED.updated_by,
updated_at = now() updated_at = now()
""", """,

View File

@@ -13,6 +13,7 @@ CREATE TABLE users (
provider TEXT NOT NULL, provider TEXT NOT NULL,
email TEXT, email TEXT,
display_name TEXT, display_name TEXT,
password_hash TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
@@ -39,12 +40,20 @@ CREATE TABLE groups (
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
-- Built-in Admins group
INSERT INTO groups (id, name) VALUES
('00000000-0000-0000-0000-000000000010', 'Admins');
CREATE TABLE group_roles ( CREATE TABLE group_roles (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, role_id) PRIMARY KEY (group_id, role_id)
); );
-- Assign ADMIN role to Admins group
INSERT INTO group_roles (group_id, role_id) VALUES
('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004');
CREATE TABLE user_groups ( CREATE TABLE user_groups (
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE, group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
@@ -70,7 +79,7 @@ CREATE TABLE executions (
execution_id TEXT NOT NULL, execution_id TEXT NOT NULL,
route_id TEXT NOT NULL, route_id TEXT NOT NULL,
agent_id TEXT NOT NULL, agent_id TEXT NOT NULL,
group_name TEXT NOT NULL, application_name TEXT NOT NULL,
status TEXT NOT NULL, status TEXT NOT NULL,
correlation_id TEXT, correlation_id TEXT,
exchange_id TEXT, exchange_id TEXT,
@@ -89,7 +98,7 @@ SELECT create_hypertable('executions', 'start_time', chunk_time_interval => INTE
CREATE INDEX idx_executions_agent_time ON executions (agent_id, start_time DESC); CREATE INDEX idx_executions_agent_time ON executions (agent_id, start_time DESC);
CREATE INDEX idx_executions_route_time ON executions (route_id, start_time DESC); CREATE INDEX idx_executions_route_time ON executions (route_id, start_time DESC);
CREATE INDEX idx_executions_group_time ON executions (group_name, start_time DESC); CREATE INDEX idx_executions_app_time ON executions (application_name, start_time DESC);
CREATE INDEX idx_executions_correlation ON executions (correlation_id); CREATE INDEX idx_executions_correlation ON executions (correlation_id);
CREATE TABLE processor_executions ( CREATE TABLE processor_executions (
@@ -98,7 +107,7 @@ CREATE TABLE processor_executions (
processor_id TEXT NOT NULL, processor_id TEXT NOT NULL,
processor_type TEXT NOT NULL, processor_type TEXT NOT NULL,
diagram_node_id TEXT, diagram_node_id TEXT,
group_name TEXT NOT NULL, application_name TEXT NOT NULL,
route_id TEXT NOT NULL, route_id TEXT NOT NULL,
depth INT NOT NULL, depth INT NOT NULL,
parent_processor_id TEXT, parent_processor_id TEXT,
@@ -153,22 +162,56 @@ CREATE TABLE route_diagrams (
CREATE INDEX idx_diagrams_route_agent ON route_diagrams (route_id, agent_id); CREATE INDEX idx_diagrams_route_agent ON route_diagrams (route_id, agent_id);
-- ============================================================= -- =============================================================
-- OIDC configuration -- Agent events
-- ============================================================= -- =============================================================
CREATE TABLE oidc_config ( CREATE TABLE agent_events (
config_id TEXT PRIMARY KEY DEFAULT 'default', id BIGSERIAL PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false, agent_id TEXT NOT NULL,
issuer_uri TEXT, app_id TEXT NOT NULL,
client_id TEXT, event_type TEXT NOT NULL,
client_secret TEXT, detail TEXT,
roles_claim TEXT, timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
default_roles TEXT[] NOT NULL DEFAULT '{}',
auto_signup BOOLEAN DEFAULT false,
display_name_claim TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
CREATE INDEX idx_agent_events_agent ON agent_events(agent_id, timestamp DESC);
CREATE INDEX idx_agent_events_app ON agent_events(app_id, timestamp DESC);
CREATE INDEX idx_agent_events_time ON agent_events(timestamp DESC);
-- =============================================================
-- Server configuration
-- =============================================================
CREATE TABLE server_config (
config_key TEXT PRIMARY KEY,
config_val JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT
);
-- =============================================================
-- Admin
-- =============================================================
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
username TEXT NOT NULL,
action TEXT NOT NULL,
category TEXT NOT NULL,
target TEXT,
detail JSONB,
result TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT
);
CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC);
CREATE INDEX idx_audit_log_username ON audit_log (username);
CREATE INDEX idx_audit_log_category ON audit_log (category);
CREATE INDEX idx_audit_log_action ON audit_log (action);
CREATE INDEX idx_audit_log_target ON audit_log (target);
-- ============================================================= -- =============================================================
-- Continuous aggregates -- Continuous aggregates
-- ============================================================= -- =============================================================
@@ -188,16 +231,12 @@ WHERE status IS NOT NULL
GROUP BY bucket GROUP BY bucket
WITH NO DATA; WITH NO DATA;
SELECT add_continuous_aggregate_policy('stats_1m_all',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute');
CREATE MATERIALIZED VIEW stats_1m_app CREATE MATERIALIZED VIEW stats_1m_app
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
SELECT SELECT
time_bucket('1 minute', start_time) AS bucket, time_bucket('1 minute', start_time) AS bucket,
group_name, application_name,
COUNT(*) AS total_count, COUNT(*) AS total_count,
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count, COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count, COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
@@ -206,19 +245,15 @@ SELECT
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
FROM executions FROM executions
WHERE status IS NOT NULL WHERE status IS NOT NULL
GROUP BY bucket, group_name GROUP BY bucket, application_name
WITH NO DATA; WITH NO DATA;
SELECT add_continuous_aggregate_policy('stats_1m_app',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute');
CREATE MATERIALIZED VIEW stats_1m_route CREATE MATERIALIZED VIEW stats_1m_route
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
SELECT SELECT
time_bucket('1 minute', start_time) AS bucket, time_bucket('1 minute', start_time) AS bucket,
group_name, application_name,
route_id, route_id,
COUNT(*) AS total_count, COUNT(*) AS total_count,
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count, COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
@@ -228,19 +263,15 @@ SELECT
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
FROM executions FROM executions
WHERE status IS NOT NULL WHERE status IS NOT NULL
GROUP BY bucket, group_name, route_id GROUP BY bucket, application_name, route_id
WITH NO DATA; WITH NO DATA;
SELECT add_continuous_aggregate_policy('stats_1m_route',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute');
CREATE MATERIALIZED VIEW stats_1m_processor CREATE MATERIALIZED VIEW stats_1m_processor
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
SELECT SELECT
time_bucket('1 minute', start_time) AS bucket, time_bucket('1 minute', start_time) AS bucket,
group_name, application_name,
route_id, route_id,
processor_type, processor_type,
COUNT(*) AS total_count, COUNT(*) AS total_count,
@@ -249,41 +280,24 @@ SELECT
MAX(duration_ms) AS duration_max, MAX(duration_ms) AS duration_max,
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
FROM processor_executions FROM processor_executions
GROUP BY bucket, group_name, route_id, processor_type GROUP BY bucket, application_name, route_id, processor_type
WITH NO DATA; WITH NO DATA;
SELECT add_continuous_aggregate_policy('stats_1m_processor',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute');
-- ============================================================= CREATE MATERIALIZED VIEW stats_1m_processor_detail
-- Admin WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
-- ============================================================= SELECT
time_bucket('1 minute', start_time) AS bucket,
application_name,
route_id,
processor_id,
processor_type,
COUNT(*) AS total_count,
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
SUM(duration_ms) AS duration_sum,
MAX(duration_ms) AS duration_max,
approx_percentile(0.99, percentile_agg(duration_ms)) AS p99_duration
FROM processor_executions
GROUP BY bucket, application_name, route_id, processor_id, processor_type
WITH NO DATA;
CREATE TABLE admin_thresholds (
id INTEGER PRIMARY KEY DEFAULT 1,
config JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by TEXT NOT NULL,
CONSTRAINT single_row CHECK (id = 1)
);
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
username TEXT NOT NULL,
action TEXT NOT NULL,
category TEXT NOT NULL,
target TEXT,
detail JSONB,
result TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT
);
CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC);
CREATE INDEX idx_audit_log_username ON audit_log (username);
CREATE INDEX idx_audit_log_category ON audit_log (category);
CREATE INDEX idx_audit_log_action ON audit_log (action);
CREATE INDEX idx_audit_log_target ON audit_log (target);

View File

@@ -1,7 +0,0 @@
-- Built-in Admins group
INSERT INTO groups (id, name) VALUES
('00000000-0000-0000-0000-000000000010', 'Admins');
-- Assign ADMIN role to Admins group
INSERT INTO group_roles (group_id, role_id) VALUES
('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004');

View File

@@ -0,0 +1,38 @@
-- V2__policies.sql - TimescaleDB policies (must run outside transaction)
-- flyway:executeInTransaction=false
-- Agent metrics retention & compression
ALTER TABLE agent_metrics SET (timescaledb.compress);
SELECT add_retention_policy('agent_metrics', INTERVAL '90 days', if_not_exists => true);
SELECT add_compression_policy('agent_metrics', INTERVAL '7 days', if_not_exists => true);
-- Continuous aggregate refresh policies
SELECT add_continuous_aggregate_policy('stats_1m_all',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => true);
SELECT add_continuous_aggregate_policy('stats_1m_app',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => true);
SELECT add_continuous_aggregate_policy('stats_1m_route',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => true);
SELECT add_continuous_aggregate_policy('stats_1m_processor',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => true);
SELECT add_continuous_aggregate_policy('stats_1m_processor_detail',
start_offset => INTERVAL '1 hour',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute',
if_not_exists => true);

View File

@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN password_hash TEXT;

View File

@@ -42,6 +42,9 @@ public abstract class AbstractPostgresIT {
registry.add("spring.datasource.password", postgres::getPassword); registry.add("spring.datasource.password", postgres::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.flyway.enabled", () -> "true"); registry.add("spring.flyway.enabled", () -> "true");
registry.add("spring.flyway.url", postgres::getJdbcUrl);
registry.add("spring.flyway.user", postgres::getUsername);
registry.add("spring.flyway.password", postgres::getPassword);
registry.add("opensearch.url", opensearch::getHttpHostAddress); registry.add("opensearch.url", opensearch::getHttpHostAddress);
} }
} }

View File

@@ -37,8 +37,8 @@ public class TestSecurityHelper {
/** /**
* Returns a valid JWT access token with the given roles (no agent registration). * Returns a valid JWT access token with the given roles (no agent registration).
*/ */
public String createToken(String subject, String group, List<String> roles) { public String createToken(String subject, String application, List<String> roles) {
return jwtService.createAccessToken(subject, group, roles); return jwtService.createAccessToken(subject, application, roles);
} }
/** /**

View File

@@ -38,17 +38,17 @@ class AgentCommandControllerIT extends AbstractPostgresIT {
operatorJwt = securityHelper.operatorToken(); operatorJwt = securityHelper.operatorToken();
} }
private ResponseEntity<String> registerAgent(String agentId, String name, String group) { private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
String json = """ String json = """
{ {
"agentId": "%s", "agentId": "%s",
"name": "%s", "name": "%s",
"group": "%s", "application": "%s",
"version": "1.0.0", "version": "1.0.0",
"routeIds": ["route-1"], "routeIds": ["route-1"],
"capabilities": {} "capabilities": {}
} }
""".formatted(agentId, name, group); """.formatted(agentId, name, application);
return restTemplate.postForEntity( return restTemplate.postForEntity(
"/api/v1/agents/register", "/api/v1/agents/register",

View File

@@ -41,7 +41,7 @@ class AgentRegistrationControllerIT extends AbstractPostgresIT {
{ {
"agentId": "%s", "agentId": "%s",
"name": "%s", "name": "%s",
"group": "test-group", "application": "test-group",
"version": "1.0.0", "version": "1.0.0",
"routeIds": ["route-1", "route-2"], "routeIds": ["route-1", "route-2"],
"capabilities": {"tracing": true} "capabilities": {"tracing": true}

View File

@@ -53,17 +53,17 @@ class AgentSseControllerIT extends AbstractPostgresIT {
operatorJwt = securityHelper.operatorToken(); operatorJwt = securityHelper.operatorToken();
} }
private ResponseEntity<String> registerAgent(String agentId, String name, String group) { private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
String json = """ String json = """
{ {
"agentId": "%s", "agentId": "%s",
"name": "%s", "name": "%s",
"group": "%s", "application": "%s",
"version": "1.0.0", "version": "1.0.0",
"routeIds": ["route-1"], "routeIds": ["route-1"],
"capabilities": {} "capabilities": {}
} }
""".formatted(agentId, name, group); """.formatted(agentId, name, application);
return restTemplate.postForEntity( return restTemplate.postForEntity(
"/api/v1/agents/register", "/api/v1/agents/register",

View File

@@ -33,6 +33,7 @@ class ThresholdAdminControllerIT extends AbstractPostgresIT {
void setUp() { void setUp() {
adminJwt = securityHelper.adminToken(); adminJwt = securityHelper.adminToken();
viewerJwt = securityHelper.viewerToken(); viewerJwt = securityHelper.viewerToken();
jdbcTemplate.update("DELETE FROM server_config WHERE config_key = 'thresholds'");
} }
@Test @Test

View File

@@ -29,7 +29,7 @@ class BootstrapTokenIT extends AbstractPostgresIT {
{ {
"agentId": "bootstrap-test-agent", "agentId": "bootstrap-test-agent",
"name": "Bootstrap Test", "name": "Bootstrap Test",
"group": "test-group", "application": "test-group",
"version": "1.0.0", "version": "1.0.0",
"routeIds": [], "routeIds": [],
"capabilities": {} "capabilities": {}
@@ -97,7 +97,7 @@ class BootstrapTokenIT extends AbstractPostgresIT {
{ {
"agentId": "bootstrap-test-previous", "agentId": "bootstrap-test-previous",
"name": "Previous Token Test", "name": "Previous Token Test",
"group": "test-group", "application": "test-group",
"version": "1.0.0", "version": "1.0.0",
"routeIds": [], "routeIds": [],
"capabilities": {} "capabilities": {}

View File

@@ -39,7 +39,7 @@ class JwtRefreshIT extends AbstractPostgresIT {
{ {
"agentId": "%s", "agentId": "%s",
"name": "Refresh Test Agent", "name": "Refresh Test Agent",
"group": "test-group", "application": "test-group",
"version": "1.0.0", "version": "1.0.0",
"routeIds": [], "routeIds": [],
"capabilities": {} "capabilities": {}
@@ -79,6 +79,8 @@ class JwtRefreshIT extends AbstractPostgresIT {
JsonNode body = objectMapper.readTree(response.getBody()); JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("accessToken").asText()).isNotEmpty(); assertThat(body.get("accessToken").asText()).isNotEmpty();
assertThat(body.get("refreshToken").asText()).isNotEmpty();
assertThat(body.get("refreshToken").asText()).isNotEqualTo(refreshToken);
} }
@Test @Test

View File

@@ -78,7 +78,7 @@ class JwtServiceTest {
String token = jwtService.createAccessToken("user:admin", "user", roles); String token = jwtService.createAccessToken("user:admin", "user", roles);
JwtService.JwtValidationResult result = jwtService.validateAccessToken(token); JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
assertEquals("user:admin", result.subject()); assertEquals("user:admin", result.subject());
assertEquals("user", result.group()); assertEquals("user", result.application());
assertEquals(roles, result.roles()); assertEquals(roles, result.roles());
} }
@@ -88,7 +88,7 @@ class JwtServiceTest {
String token = jwtService.createRefreshToken("agent-1", "default", roles); String token = jwtService.createRefreshToken("agent-1", "default", roles);
JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token); JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token);
assertEquals("agent-1", result.subject()); assertEquals("agent-1", result.subject());
assertEquals("default", result.group()); assertEquals("default", result.application());
assertEquals(roles, result.roles()); assertEquals(roles, result.roles());
} }

View File

@@ -32,7 +32,7 @@ class RegistrationSecurityIT extends AbstractPostgresIT {
{ {
"agentId": "%s", "agentId": "%s",
"name": "Security Test Agent", "name": "Security Test Agent",
"group": "test-group", "application": "test-group",
"version": "1.0.0", "version": "1.0.0",
"routeIds": [], "routeIds": [],
"capabilities": {} "capabilities": {}

View File

@@ -90,7 +90,7 @@ class SseSigningIT extends AbstractPostgresIT {
{ {
"agentId": "%s", "agentId": "%s",
"name": "SSE Signing Test Agent", "name": "SSE Signing Test Agent",
"group": "test-group", "application": "test-group",
"version": "1.0.0", "version": "1.0.0",
"routeIds": ["route-1"], "routeIds": ["route-1"],
"capabilities": {} "capabilities": {}

View File

@@ -54,10 +54,10 @@ class PostgresStatsStoreIT extends AbstractPostgresIT {
assertFalse(ts.buckets().isEmpty()); assertFalse(ts.buckets().isEmpty());
} }
private void insertExecution(String id, String routeId, String groupName, private void insertExecution(String id, String routeId, String applicationName,
String status, Instant startTime, long durationMs) { String status, Instant startTime, long durationMs) {
executionStore.upsert(new ExecutionRecord( executionStore.upsert(new ExecutionRecord(
id, routeId, "agent-1", groupName, status, null, null, id, routeId, "agent-1", applicationName, status, null, null,
startTime, startTime.plusMillis(durationMs), durationMs, startTime, startTime.plusMillis(durationMs), durationMs,
status.equals("FAILED") ? "error" : null, null, null)); status.equals("FAILED") ? "error" : null, null, null));
} }

View File

@@ -0,0 +1,12 @@
package com.cameleer3.server.core.agent;
import java.time.Instant;
public record AgentEventRecord(
long id,
String agentId,
String appId,
String eventType,
String detail,
Instant timestamp
) {}

View File

@@ -0,0 +1,11 @@
package com.cameleer3.server.core.agent;
import java.time.Instant;
import java.util.List;
public interface AgentEventRepository {
void insert(String agentId, String appId, String eventType, String detail);
List<AgentEventRecord> query(String appId, String agentId, Instant from, Instant to, int limit);
}

View File

@@ -0,0 +1,27 @@
package com.cameleer3.server.core.agent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Instant;
import java.util.List;
public class AgentEventService {
private static final Logger log = LoggerFactory.getLogger(AgentEventService.class);
private final AgentEventRepository repository;
public AgentEventService(AgentEventRepository repository) {
this.repository = repository;
}
public void recordEvent(String agentId, String appId, String eventType, String detail) {
log.debug("Recording agent event: agent={}, app={}, type={}", agentId, appId, eventType);
repository.insert(agentId, appId, eventType, detail);
}
public List<AgentEventRecord> queryEvents(String appId, String agentId, Instant from, Instant to, int limit) {
return repository.query(appId, agentId, from, to, limit);
}
}

View File

@@ -13,7 +13,7 @@ import java.util.Map;
* *
* @param id agent-provided persistent identifier * @param id agent-provided persistent identifier
* @param name human-readable agent name * @param name human-readable agent name
* @param group logical grouping (e.g., "order-service-prod") * @param application application name (e.g., "order-service-prod")
* @param version agent software version * @param version agent software version
* @param routeIds list of Camel route IDs managed by this agent * @param routeIds list of Camel route IDs managed by this agent
* @param capabilities agent-declared capabilities (free-form) * @param capabilities agent-declared capabilities (free-form)
@@ -25,7 +25,7 @@ import java.util.Map;
public record AgentInfo( public record AgentInfo(
String id, String id,
String name, String name,
String group, String application,
String version, String version,
List<String> routeIds, List<String> routeIds,
Map<String, Object> capabilities, Map<String, Object> capabilities,
@@ -36,28 +36,28 @@ public record AgentInfo(
) { ) {
public AgentInfo withState(AgentState newState) { public AgentInfo withState(AgentState newState) {
return new AgentInfo(id, name, group, version, routeIds, capabilities, return new AgentInfo(id, name, application, version, routeIds, capabilities,
newState, registeredAt, lastHeartbeat, staleTransitionTime); newState, registeredAt, lastHeartbeat, staleTransitionTime);
} }
public AgentInfo withLastHeartbeat(Instant newLastHeartbeat) { public AgentInfo withLastHeartbeat(Instant newLastHeartbeat) {
return new AgentInfo(id, name, group, version, routeIds, capabilities, return new AgentInfo(id, name, application, version, routeIds, capabilities,
state, registeredAt, newLastHeartbeat, staleTransitionTime); state, registeredAt, newLastHeartbeat, staleTransitionTime);
} }
public AgentInfo withRegisteredAt(Instant newRegisteredAt) { public AgentInfo withRegisteredAt(Instant newRegisteredAt) {
return new AgentInfo(id, name, group, version, routeIds, capabilities, return new AgentInfo(id, name, application, version, routeIds, capabilities,
state, newRegisteredAt, lastHeartbeat, staleTransitionTime); state, newRegisteredAt, lastHeartbeat, staleTransitionTime);
} }
public AgentInfo withStaleTransitionTime(Instant newStaleTransitionTime) { public AgentInfo withStaleTransitionTime(Instant newStaleTransitionTime) {
return new AgentInfo(id, name, group, version, routeIds, capabilities, return new AgentInfo(id, name, application, version, routeIds, capabilities,
state, registeredAt, lastHeartbeat, newStaleTransitionTime); state, registeredAt, lastHeartbeat, newStaleTransitionTime);
} }
public AgentInfo withMetadata(String name, String group, String version, public AgentInfo withMetadata(String name, String application, String version,
List<String> routeIds, Map<String, Object> capabilities) { List<String> routeIds, Map<String, Object> capabilities) {
return new AgentInfo(id, name, group, version, routeIds, capabilities, return new AgentInfo(id, name, application, version, routeIds, capabilities,
state, registeredAt, lastHeartbeat, staleTransitionTime); state, registeredAt, lastHeartbeat, staleTransitionTime);
} }
} }

View File

@@ -43,10 +43,10 @@ public class AgentRegistryService {
* Register a new agent or re-register an existing one. * Register a new agent or re-register an existing one.
* Re-registration updates metadata, transitions state to LIVE, and resets timestamps. * Re-registration updates metadata, transitions state to LIVE, and resets timestamps.
*/ */
public AgentInfo register(String id, String name, String group, String version, public AgentInfo register(String id, String name, String application, String version,
List<String> routeIds, Map<String, Object> capabilities) { List<String> routeIds, Map<String, Object> capabilities) {
Instant now = Instant.now(); Instant now = Instant.now();
AgentInfo newAgent = new AgentInfo(id, name, group, version, AgentInfo newAgent = new AgentInfo(id, name, application, version,
List.copyOf(routeIds), Map.copyOf(capabilities), List.copyOf(routeIds), Map.copyOf(capabilities),
AgentState.LIVE, now, now, null); AgentState.LIVE, now, now, null);
@@ -55,13 +55,13 @@ public class AgentRegistryService {
// Re-registration: update metadata, reset to LIVE // Re-registration: update metadata, reset to LIVE
log.info("Agent {} re-registering (was {})", id, existing.state()); log.info("Agent {} re-registering (was {})", id, existing.state());
return existing return existing
.withMetadata(name, group, version, List.copyOf(routeIds), Map.copyOf(capabilities)) .withMetadata(name, application, version, List.copyOf(routeIds), Map.copyOf(capabilities))
.withState(AgentState.LIVE) .withState(AgentState.LIVE)
.withLastHeartbeat(now) .withLastHeartbeat(now)
.withRegisteredAt(now) .withRegisteredAt(now)
.withStaleTransitionTime(null); .withStaleTransitionTime(null);
} }
log.info("Agent {} registered (name={}, group={})", id, name, group); log.info("Agent {} registered (name={}, application={})", id, name, application);
return newAgent; return newAgent;
}); });
@@ -168,11 +168,11 @@ public class AgentRegistryService {
} }
/** /**
* Return all agents belonging to the given application group. * Return all agents belonging to the given application.
*/ */
public List<AgentInfo> findByGroup(String group) { public List<AgentInfo> findByApplication(String application) {
return agents.values().stream() return agents.values().stream()
.filter(a -> group.equals(a.group())) .filter(a -> application.equals(a.application()))
.collect(Collectors.toList()); .collect(Collectors.toList());
} }

View File

@@ -20,6 +20,7 @@ public class DetailService {
List<ProcessorNode> roots = buildTree(processors); List<ProcessorNode> roots = buildTree(processors);
return new ExecutionDetail( return new ExecutionDetail(
exec.executionId(), exec.routeId(), exec.agentId(), exec.executionId(), exec.routeId(), exec.agentId(),
exec.applicationName(),
exec.status(), exec.startTime(), exec.endTime(), exec.status(), exec.startTime(), exec.endTime(),
exec.durationMs() != null ? exec.durationMs() : 0L, exec.durationMs() != null ? exec.durationMs() : 0L,
exec.correlationId(), exec.exchangeId(), exec.correlationId(), exec.exchangeId(),

View File

@@ -27,6 +27,7 @@ public record ExecutionDetail(
String executionId, String executionId,
String routeId, String routeId,
String agentId, String agentId,
String applicationName,
String status, String status,
Instant startTime, Instant startTime,
Instant endTime, Instant endTime,

View File

@@ -74,7 +74,7 @@ public class SearchIndexer implements SearchIndexerStats {
.toList(); .toList();
searchIndex.index(new ExecutionDocument( searchIndex.index(new ExecutionDocument(
exec.executionId(), exec.routeId(), exec.agentId(), exec.groupName(), exec.executionId(), exec.routeId(), exec.agentId(), exec.applicationName(),
exec.status(), exec.correlationId(), exec.exchangeId(), exec.status(), exec.correlationId(), exec.exchangeId(),
exec.startTime(), exec.endTime(), exec.durationMs(), exec.startTime(), exec.endTime(), exec.durationMs(),
exec.errorMessage(), exec.errorStacktrace(), processorDocs)); exec.errorMessage(), exec.errorStacktrace(), processorDocs));

View File

@@ -38,18 +38,18 @@ public class IngestionService {
this.bodySizeLimit = bodySizeLimit; this.bodySizeLimit = bodySizeLimit;
} }
public void ingestExecution(String agentId, String groupName, RouteExecution execution) { public void ingestExecution(String agentId, String applicationName, RouteExecution execution) {
ExecutionRecord record = toExecutionRecord(agentId, groupName, execution); ExecutionRecord record = toExecutionRecord(agentId, applicationName, execution);
executionStore.upsert(record); executionStore.upsert(record);
if (execution.getProcessors() != null && !execution.getProcessors().isEmpty()) { if (execution.getProcessors() != null && !execution.getProcessors().isEmpty()) {
List<ProcessorRecord> processors = flattenProcessors( List<ProcessorRecord> processors = flattenProcessors(
execution.getProcessors(), record.executionId(), execution.getProcessors(), record.executionId(),
record.startTime(), groupName, execution.getRouteId(), record.startTime(), applicationName, execution.getRouteId(),
null, 0); null, 0);
executionStore.upsertProcessors( executionStore.upsertProcessors(
record.executionId(), record.startTime(), record.executionId(), record.startTime(),
groupName, execution.getRouteId(), processors); applicationName, execution.getRouteId(), processors);
} }
eventPublisher.accept(new ExecutionUpdatedEvent( eventPublisher.accept(new ExecutionUpdatedEvent(
@@ -72,13 +72,13 @@ public class IngestionService {
return metricsBuffer; return metricsBuffer;
} }
private ExecutionRecord toExecutionRecord(String agentId, String groupName, private ExecutionRecord toExecutionRecord(String agentId, String applicationName,
RouteExecution exec) { RouteExecution exec) {
String diagramHash = diagramStore String diagramHash = diagramStore
.findContentHashForRoute(exec.getRouteId(), agentId) .findContentHashForRoute(exec.getRouteId(), agentId)
.orElse(""); .orElse("");
return new ExecutionRecord( return new ExecutionRecord(
exec.getExchangeId(), exec.getRouteId(), agentId, groupName, exec.getExchangeId(), exec.getRouteId(), agentId, applicationName,
exec.getStatus() != null ? exec.getStatus().name() : "RUNNING", exec.getStatus() != null ? exec.getStatus().name() : "RUNNING",
exec.getCorrelationId(), exec.getExchangeId(), exec.getCorrelationId(), exec.getExchangeId(),
exec.getStartTime(), exec.getEndTime(), exec.getStartTime(), exec.getEndTime(),
@@ -90,13 +90,13 @@ public class IngestionService {
private List<ProcessorRecord> flattenProcessors( private List<ProcessorRecord> flattenProcessors(
List<ProcessorExecution> processors, String executionId, List<ProcessorExecution> processors, String executionId,
java.time.Instant execStartTime, String groupName, String routeId, java.time.Instant execStartTime, String applicationName, String routeId,
String parentProcessorId, int depth) { String parentProcessorId, int depth) {
List<ProcessorRecord> flat = new ArrayList<>(); List<ProcessorRecord> flat = new ArrayList<>();
for (ProcessorExecution p : processors) { for (ProcessorExecution p : processors) {
flat.add(new ProcessorRecord( flat.add(new ProcessorRecord(
executionId, p.getProcessorId(), p.getProcessorType(), executionId, p.getProcessorId(), p.getProcessorType(),
p.getDiagramNodeId(), groupName, routeId, p.getDiagramNodeId(), applicationName, routeId,
depth, parentProcessorId, depth, parentProcessorId,
p.getStatus() != null ? p.getStatus().name() : "RUNNING", p.getStatus() != null ? p.getStatus().name() : "RUNNING",
p.getStartTime() != null ? p.getStartTime() : execStartTime, p.getStartTime() != null ? p.getStartTime() : execStartTime,
@@ -109,7 +109,7 @@ public class IngestionService {
if (p.getChildren() != null) { if (p.getChildren() != null) {
flat.addAll(flattenProcessors( flat.addAll(flattenProcessors(
p.getChildren(), executionId, execStartTime, p.getChildren(), executionId, execStartTime,
groupName, routeId, p.getProcessorId(), depth + 1)); applicationName, routeId, p.getProcessorId(), depth + 1));
} }
} }
return flat; return flat;

View File

@@ -23,6 +23,7 @@ public record ExecutionSummary(
String executionId, String executionId,
String routeId, String routeId,
String agentId, String agentId,
String applicationName,
String status, String status,
Instant startTime, Instant startTime,
Instant endTime, Instant endTime,

View File

@@ -22,7 +22,7 @@ import java.util.List;
* @param routeId exact match on route_id * @param routeId exact match on route_id
* @param agentId exact match on agent_id * @param agentId exact match on agent_id
* @param processorType matches processor_types array via has() * @param processorType matches processor_types array via has()
* @param group application group filter (resolved to agentIds server-side) * @param application application name filter (resolved to agentIds server-side)
* @param agentIds list of agent IDs (resolved from group, used for IN clause) * @param agentIds list of agent IDs (resolved from group, used for IN clause)
* @param offset pagination offset (0-based) * @param offset pagination offset (0-based)
* @param limit page size (default 50, max 500) * @param limit page size (default 50, max 500)
@@ -43,7 +43,7 @@ public record SearchRequest(
String routeId, String routeId,
String agentId, String agentId,
String processorType, String processorType,
String group, String application,
List<String> agentIds, List<String> agentIds,
int offset, int offset,
int limit, int limit,
@@ -80,12 +80,12 @@ public record SearchRequest(
return SORT_FIELD_TO_COLUMN.getOrDefault(sortField, "start_time"); return SORT_FIELD_TO_COLUMN.getOrDefault(sortField, "start_time");
} }
/** Create a copy with resolved agentIds (from group lookup). */ /** Create a copy with resolved agentIds (from application name lookup). */
public SearchRequest withAgentIds(List<String> resolvedAgentIds) { public SearchRequest withAgentIds(List<String> resolvedAgentIds) {
return new SearchRequest( return new SearchRequest(
status, timeFrom, timeTo, durationMin, durationMax, correlationId, status, timeFrom, timeTo, durationMin, durationMax, correlationId,
text, textInBody, textInHeaders, textInErrors, text, textInBody, textInHeaders, textInErrors,
routeId, agentId, processorType, group, resolvedAgentIds, routeId, agentId, processorType, application, resolvedAgentIds,
offset, limit, sortField, sortDir offset, limit, sortField, sortDir
); );
} }

View File

@@ -28,6 +28,10 @@ public class SearchService {
return statsStore.stats(from, to); return statsStore.stats(from, to);
} }
public ExecutionStats statsForApp(Instant from, Instant to, String applicationName) {
return statsStore.statsForApp(from, to, applicationName);
}
public ExecutionStats stats(Instant from, Instant to, String routeId, List<String> agentIds) { public ExecutionStats stats(Instant from, Instant to, String routeId, List<String> agentIds) {
return statsStore.statsForRoute(from, to, routeId, agentIds); return statsStore.statsForRoute(from, to, routeId, agentIds);
} }
@@ -36,6 +40,10 @@ public class SearchService {
return statsStore.timeseries(from, to, bucketCount); return statsStore.timeseries(from, to, bucketCount);
} }
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationName) {
return statsStore.timeseriesForApp(from, to, bucketCount, applicationName);
}
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount, public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount,
String routeId, List<String> agentIds) { String routeId, List<String> agentIds) {
return statsStore.timeseriesForRoute(from, to, bucketCount, routeId, agentIds); return statsStore.timeseriesForRoute(from, to, bucketCount, routeId, agentIds);

View File

@@ -14,21 +14,21 @@ public interface JwtService {
/** /**
* Validated JWT payload. * Validated JWT payload.
* *
* @param subject the {@code sub} claim (agent ID or {@code user:<username>}) * @param subject the {@code sub} claim (agent ID or {@code user:<username>})
* @param group the {@code group} claim * @param application the {@code group} claim (application name)
* @param roles the {@code roles} claim (e.g. {@code ["AGENT"]}, {@code ["ADMIN"]}) * @param roles the {@code roles} claim (e.g. {@code ["AGENT"]}, {@code ["ADMIN"]})
*/ */
record JwtValidationResult(String subject, String group, List<String> roles) {} record JwtValidationResult(String subject, String application, List<String> roles) {}
/** /**
* Creates a signed access JWT with the given subject, group, and roles. * Creates a signed access JWT with the given subject, application, and roles.
*/ */
String createAccessToken(String subject, String group, List<String> roles); String createAccessToken(String subject, String application, List<String> roles);
/** /**
* Creates a signed refresh JWT with the given subject, group, and roles. * Creates a signed refresh JWT with the given subject, application, and roles.
*/ */
String createRefreshToken(String subject, String group, List<String> roles); String createRefreshToken(String subject, String application, List<String> roles);
/** /**
* Validates an access token and returns the full validation result. * Validates an access token and returns the full validation result.
@@ -46,12 +46,12 @@ public interface JwtService {
// --- Backward-compatible defaults (delegate to role-aware methods) --- // --- Backward-compatible defaults (delegate to role-aware methods) ---
default String createAccessToken(String subject, String group) { default String createAccessToken(String subject, String application) {
return createAccessToken(subject, group, List.of()); return createAccessToken(subject, application, List.of());
} }
default String createRefreshToken(String subject, String group) { default String createRefreshToken(String subject, String application) {
return createRefreshToken(subject, group, List.of()); return createRefreshToken(subject, application, List.of());
} }
default String validateAndExtractAgentId(String token) { default String validateAndExtractAgentId(String token) {

View File

@@ -9,7 +9,7 @@ public interface ExecutionStore {
void upsert(ExecutionRecord execution); void upsert(ExecutionRecord execution);
void upsertProcessors(String executionId, Instant startTime, void upsertProcessors(String executionId, Instant startTime,
String groupName, String routeId, String applicationName, String routeId,
List<ProcessorRecord> processors); List<ProcessorRecord> processors);
Optional<ExecutionRecord> findById(String executionId); Optional<ExecutionRecord> findById(String executionId);
@@ -17,7 +17,7 @@ public interface ExecutionStore {
List<ProcessorRecord> findProcessors(String executionId); List<ProcessorRecord> findProcessors(String executionId);
record ExecutionRecord( record ExecutionRecord(
String executionId, String routeId, String agentId, String groupName, String executionId, String routeId, String agentId, String applicationName,
String status, String correlationId, String exchangeId, String status, String correlationId, String exchangeId,
Instant startTime, Instant endTime, Long durationMs, Instant startTime, Instant endTime, Long durationMs,
String errorMessage, String errorStacktrace, String diagramContentHash String errorMessage, String errorStacktrace, String diagramContentHash
@@ -25,7 +25,7 @@ public interface ExecutionStore {
record ProcessorRecord( record ProcessorRecord(
String executionId, String processorId, String processorType, String executionId, String processorId, String processorType,
String diagramNodeId, String groupName, String routeId, String diagramNodeId, String applicationName, String routeId,
int depth, String parentProcessorId, String status, int depth, String parentProcessorId, String status,
Instant startTime, Instant endTime, Long durationMs, Instant startTime, Instant endTime, Long durationMs,
String errorMessage, String errorStacktrace, String errorMessage, String errorStacktrace,

View File

@@ -12,7 +12,7 @@ public interface StatsStore {
ExecutionStats stats(Instant from, Instant to); ExecutionStats stats(Instant from, Instant to);
// Per-app stats (stats_1m_app) // Per-app stats (stats_1m_app)
ExecutionStats statsForApp(Instant from, Instant to, String groupName); ExecutionStats statsForApp(Instant from, Instant to, String applicationName);
// Per-route stats (stats_1m_route), optionally scoped to specific agents // Per-route stats (stats_1m_route), optionally scoped to specific agents
ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List<String> agentIds); ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List<String> agentIds);
@@ -24,7 +24,7 @@ public interface StatsStore {
StatsTimeseries timeseries(Instant from, Instant to, int bucketCount); StatsTimeseries timeseries(Instant from, Instant to, int bucketCount);
// Per-app timeseries // Per-app timeseries
StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String groupName); StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationName);
// Per-route timeseries, optionally scoped to specific agents // Per-route timeseries, optionally scoped to specific agents
StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount, StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount,

View File

@@ -4,7 +4,7 @@ import java.time.Instant;
import java.util.List; import java.util.List;
public record ExecutionDocument( public record ExecutionDocument(
String executionId, String routeId, String agentId, String groupName, String executionId, String routeId, String agentId, String applicationName,
String status, String correlationId, String exchangeId, String status, String correlationId, String exchangeId,
Instant startTime, Instant endTime, Long durationMs, Instant startTime, Instant endTime, Long durationMs,
String errorMessage, String errorStacktrace, String errorMessage, String errorStacktrace,

View File

@@ -32,7 +32,7 @@ class AgentRegistryServiceTest {
assertThat(agent).isNotNull(); assertThat(agent).isNotNull();
assertThat(agent.id()).isEqualTo("agent-1"); assertThat(agent.id()).isEqualTo("agent-1");
assertThat(agent.name()).isEqualTo("Order Agent"); assertThat(agent.name()).isEqualTo("Order Agent");
assertThat(agent.group()).isEqualTo("order-svc"); assertThat(agent.application()).isEqualTo("order-svc");
assertThat(agent.version()).isEqualTo("1.0.0"); assertThat(agent.version()).isEqualTo("1.0.0");
assertThat(agent.routeIds()).containsExactly("route1", "route2"); assertThat(agent.routeIds()).containsExactly("route1", "route2");
assertThat(agent.capabilities()).containsEntry("feature", "tracing"); assertThat(agent.capabilities()).containsEntry("feature", "tracing");
@@ -52,7 +52,7 @@ class AgentRegistryServiceTest {
assertThat(updated.id()).isEqualTo("agent-1"); assertThat(updated.id()).isEqualTo("agent-1");
assertThat(updated.name()).isEqualTo("New Name"); assertThat(updated.name()).isEqualTo("New Name");
assertThat(updated.group()).isEqualTo("new-group"); assertThat(updated.application()).isEqualTo("new-group");
assertThat(updated.version()).isEqualTo("2.0.0"); assertThat(updated.version()).isEqualTo("2.0.0");
assertThat(updated.routeIds()).containsExactly("route1", "route2"); assertThat(updated.routeIds()).containsExactly("route1", "route2");
assertThat(updated.capabilities()).containsEntry("new", "cap"); assertThat(updated.capabilities()).containsEntry("new", "cap");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,142 @@
# RBAC CRUD Gaps — Design Specification
## Goal
Add missing CRUD and assignment UI to the RBAC management page, fix date formatting, seed a built-in Admins group, and fix dashboard diagram ordering.
## References
- Parent spec: `docs/superpowers/specs/2026-03-17-rbac-management-design.md`
- Visual prototype: `examples/RBAC/rbac_management_ui.html`
---
## Changes
### 1. Users Tab — Delete + Assignments
Users cannot be created manually (they arrive via login). The detail pane gains:
- **Delete button** in the detail header area. Uses existing `ConfirmDeleteDialog` with the user's `displayName` as the confirmation string. Calls `useDeleteUser()`. **Guard:** the currently authenticated user (from `useAuthStore`) cannot delete themselves — button disabled with tooltip "Cannot delete your own account".
- **Group membership section** — "+ Add" chip opens a **multi-select dropdown** listing all groups the user is NOT already a member of. Checkboxes for batch selection, "Apply" button to commit. Calls are batched via `Promise.allSettled()` — if any fail, show an inline error, invalidate queries regardless to refresh. Existing group chips gain an "x" remove button calling `useRemoveUserFromGroup()`.
- **Direct roles section** — the existing "Effective roles" section renders both direct and inherited roles. The "+ Add" multi-select dropdown lists roles not yet directly assigned. Calls `useAssignRoleToUser()` (batched via `Promise.allSettled()`). Direct role chips gain an "x" button calling `useRemoveRoleFromUser()`. Inherited role chips (dashed border) do NOT get remove buttons — they can only be removed by changing group membership or group role assignments.
- **Created field** — change from date-only to full date+time: `new Date(createdAt).toLocaleString()`.
- **Mutation button states** — all action buttons (delete, remove chip "x") disable while their mutation is in-flight to prevent double-clicks.
### 2. Groups Tab — CRUD + Assignments
- **"+ Add group" button** in the panel header (`.btnAdd` style exists). Opens an inline form below the search bar with: name text input, optional parent group dropdown, "Create" button. Calls `useCreateGroup()`. Form clears and closes on success. On error: shows error message inline.
- **Delete button** in detail pane header. Uses `ConfirmDeleteDialog` with group name. Calls `useDeleteGroup()`. Resets selected group. **Guard:** the built-in Admins group (`SystemRole.ADMINS_GROUP_ID`) cannot be deleted — button disabled with tooltip "Built-in group cannot be deleted".
- **Assigned roles section** — "+ Add" multi-select dropdown listing roles not yet assigned to this group. Batched via `Promise.allSettled()`. Calls `useAssignRoleToGroup()`. Role chips gain "x" for `useRemoveRoleFromGroup()`.
- **Parent group** — shown as a dropdown in the detail header area, allowing re-parenting. Calls `useUpdateGroup()`. The dropdown excludes the group itself and its transitive descendants (cycle prevention — requires recursive traversal of `childGroups` on each `GroupDetail`). Setting to empty/none makes it top-level.
### 3. Roles Tab — CRUD
- **"+ Add role" button** in panel header. Opens an inline form: name (required), description (optional), scope (optional, free-text, defaults to "custom"). Calls `useCreateRole()`.
- **Delete button** in detail pane header. **Disabled for system roles** (lock icon + tooltip "System roles cannot be deleted"). Custom roles use `ConfirmDeleteDialog` with role name → `useDeleteRole()`.
- No assignment UI on the roles tab — assignments are managed from the User and Group detail panes.
### 4. Multi-Select Dropdown Component
A reusable component used across all assignment actions:
```
Props:
items: { id: string; label: string }[] — available items to pick from
onApply: (selectedIds: string[]) => void — called with all checked IDs
placeholder?: string — search filter placeholder
```
Behavior:
- Opens as a positioned dropdown below the "+ Add" chip
- Search/filter input at top
- Checkbox list of items (max-height with scroll)
- "Apply" button at bottom (disabled when nothing selected)
- Closes on Apply, Escape, or click-outside
- Shows count badge on Apply button: "Apply (3)"
Styling: background `var(--bg-raised)`, border `var(--border)`, border-radius `var(--radius-md)`, items with `var(--bg-hover)` on hover, checkboxes with `var(--amber)` accent.
### 5. Inline Create Form
A reusable pattern for "Add group" and "Add role":
- Appears below the search bar in the list pane, pushing content down
- Input fields with labels
- "Create" and "Cancel" buttons
- On success: closes form, clears inputs, new entity appears in list
- On error: shows error message inline
- "Create" button disabled while mutation is in-flight
### 6. Built-in Admins Group Seed
**Database migration** — new `V2__admin_group_seed.sql` (V1 is already deployed, V2-V10 were deleted in the migration consolidation so V2 is safe):
```sql
-- Built-in Admins group
INSERT INTO groups (id, name) VALUES
('00000000-0000-0000-0000-000000000010', 'Admins');
-- Assign ADMIN role to Admins group
INSERT INTO group_roles (group_id, role_id) VALUES
('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004');
```
**SystemRole.java** — add constants:
```java
public static final UUID ADMINS_GROUP_ID = UUID.fromString("00000000-0000-0000-0000-000000000010");
```
**UiAuthController.login()** — after upserting the user and assigning ADMIN role, also add to Admins group:
```java
rbacService.addUserToGroup(subject, SystemRole.ADMINS_GROUP_ID);
```
**Frontend guard:** The Admins group UUID is hardcoded as a constant in the frontend to disable deletion. Alternatively, check if a group's ID matches a known system group ID.
### 7. Dashboard Diagram Ordering
The inheritance diagram's three columns (Groups → Roles → Users) must show items in a consistent, matching order:
- **Groups column**: alphabetical by name, children indented under parents
- **Roles column**: iterate groups top-to-bottom, collect their direct roles, deduplicate preserving first-seen order. Roles not assigned to any group are omitted from the diagram.
- **Users column**: alphabetical by display name
Sort explicitly in `DashboardTab.tsx` before rendering.
---
## Files Changed
### Frontend — Modified
| File | Change |
|---|---|
| `ui/src/pages/admin/rbac/UsersTab.tsx` | Delete button, group/role assignment dropdowns, date format fix, self-delete guard |
| `ui/src/pages/admin/rbac/GroupsTab.tsx` | Add group form, delete button, role assignment dropdown, parent group dropdown, Admins guard |
| `ui/src/pages/admin/rbac/RolesTab.tsx` | Add role form, delete button (disabled for system) |
| `ui/src/pages/admin/rbac/DashboardTab.tsx` | Sort diagram columns consistently |
| `ui/src/pages/admin/rbac/RbacPage.module.css` | Styles for multi-select dropdown, inline create form, delete button, action chips, remove buttons |
### Frontend — New
| File | Responsibility |
|---|---|
| `ui/src/pages/admin/rbac/components/MultiSelectDropdown.tsx` | Reusable multi-select picker with search, checkboxes, batch apply |
### Backend — Modified
| File | Change |
|---|---|
| `cameleer3-server-core/.../rbac/SystemRole.java` | Add `ADMINS_GROUP_ID` constant |
| `cameleer3-server-app/.../security/UiAuthController.java` | Add admin user to Admins group on login |
### Backend — New Migration
| File | Change |
|---|---|
| `cameleer3-server-app/src/main/resources/db/migration/V2__admin_group_seed.sql` | Seed Admins group + ADMIN role assignment |
---
## Out of Scope
- Editing user profile fields (name, email) — users are managed by their identity provider
- Drag-and-drop group hierarchy management
- Role permission editing (custom roles have no effect on Spring Security yet)

View File

@@ -0,0 +1,327 @@
# RBAC Management — Design Specification
## Goal
Implement a full RBAC management system (issue #41) with group hierarchy, role inheritance, and a management UI integrated into the admin section. Replace the flat `users.roles` text array with a proper relational model.
## References
- Functional spec: `examples/RBAC/rbac-ui-spec.md`
- Visual prototype: `examples/RBAC/rbac_management_ui.html`
---
## Backend
### Database Schema
Squash V1V10 Flyway migrations into a single `V1__init.sql`. The `users` table drops the `roles TEXT[]` column. New tables:
```sql
CREATE TABLE users (
user_id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
email TEXT,
display_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- RBAC: all roles — system roles seeded with fixed UUIDs, custom roles created by admins
CREATE TABLE roles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'custom',
system BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Seed system roles with fixed UUIDs (stable across environments)
INSERT INTO roles (id, name, description, scope, system) VALUES
('00000000-0000-0000-0000-000000000001', 'AGENT', 'Agent registration and data ingestion', 'system-wide', true),
('00000000-0000-0000-0000-000000000002', 'VIEWER', 'Read-only access to dashboards and data', 'system-wide', true),
('00000000-0000-0000-0000-000000000003', 'OPERATOR', 'Operational commands (start/stop/configure agents)', 'system-wide', true),
('00000000-0000-0000-0000-000000000004', 'ADMIN', 'Full administrative access', 'system-wide', true);
-- RBAC: groups with self-referential hierarchy
CREATE TABLE groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
parent_group_id UUID REFERENCES groups(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Join: roles assigned to groups (system + custom)
CREATE TABLE group_roles (
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, role_id)
);
-- Join: direct group membership for users
CREATE TABLE user_groups (
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id)
);
-- Join: direct role assignments to users (system + custom)
CREATE TABLE user_roles (
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
);
-- Indexes for join query performance
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
CREATE INDEX idx_user_groups_user_id ON user_groups(user_id);
CREATE INDEX idx_group_roles_group_id ON group_roles(group_id);
CREATE INDEX idx_groups_parent ON groups(parent_group_id);
```
Note: `roles TEXT[]` column is removed from `users`. All roles (system and custom) live in the `roles` table. System roles are seeded rows with `system = true` and fixed UUIDs — the application prevents their deletion or modification.
### System Roles
The four system roles (AGENT, VIEWER, OPERATOR, ADMIN) are:
- Seeded as rows in the `roles` table with `system = true` and fixed UUIDs
- Assigned to users via the same `user_roles` join table as custom roles
- Protected by application logic: creation, deletion, and name/scope modification are rejected
- Displayed in the UI as read-only entries (lock icon, non-deletable)
- Used by Spring Security / JWT for authorization decisions
Custom roles (`system = false`) are application-defined and have no effect on Spring Security — they serve the RBAC management model only (for future permission expansion).
The `scope` field distinguishes role domains: system roles use `system-wide`, custom roles can use descriptive scopes like `monitoring:read`, `config:write` for future permission gating.
### Domain Model (Java)
```java
// Existing, modified — drop roles field
public record UserInfo(
String userId,
String provider,
String email,
String displayName,
Instant createdAt
) {}
// New — enriched user for admin API responses
public record UserDetail(
String userId,
String provider,
String email,
String displayName,
Instant createdAt,
List<RoleSummary> directRoles, // from user_roles join (system + custom)
List<GroupSummary> directGroups, // from user_groups join
List<RoleSummary> effectiveRoles, // computed: union of direct + inherited via groups
List<GroupSummary> effectiveGroups // computed: direct groups + their ancestor chain
) {}
public record GroupDetail(
UUID id,
String name,
UUID parentGroupId, // nullable
Instant createdAt,
List<RoleSummary> directRoles,
List<RoleSummary> effectiveRoles, // direct + inherited from parent chain
List<UserSummary> members, // direct members
List<GroupSummary> childGroups
) {}
public record RoleDetail(
UUID id,
String name,
String description,
String scope,
boolean system, // true for AGENT/VIEWER/OPERATOR/ADMIN
Instant createdAt,
List<GroupSummary> assignedGroups,
List<UserSummary> directUsers,
List<UserSummary> effectivePrincipals // all users who hold this role
) {}
// Summaries for embedding in detail responses
public record UserSummary(String userId, String displayName, String provider) {}
public record GroupSummary(UUID id, String name) {}
public record RoleSummary(UUID id, String name, boolean system, String source) {}
// source: "direct" | group name (for inherited)
```
### Inheritance Logic
Server-side computation in a service class (e.g., `RbacService`):
1. **Effective groups for user**: Start from `user_groups` (direct memberships), then for each group walk `parent_group_id` chain upward to collect all ancestor groups. The union is every group the user is transitively a member of.
2. **Effective roles for user**: Direct `user_roles` + all `group_roles` for every effective group. Both system and custom roles flow through the same path.
3. **Effective roles for group**: Direct `group_roles` + inherited from parent chain.
4. **Effective principals for role**: All users who hold the role directly + all users in any group that has the role (transitively).
No role negation — roles only grant, never deny.
**Cycle detection**: When setting `parent_group_id` on a group, the application must walk the proposed parent chain upward and reject the update if it would create a cycle (i.e., the group appears in its own ancestor chain). Return HTTP 409 Conflict.
### Auth Integration
`JwtService` and `SecurityConfig` read system roles from `user_roles` joined to `roles WHERE system = true`, instead of `users.roles`. The `UserRepository` methods that currently read/write `users.roles` are updated to use the join table. JWT claims remain unchanged (`roles: ["ADMIN", "VIEWER"]`).
OIDC auto-signup: When a user is auto-registered via OIDC token exchange, they get a row in `users` with `provider = "oidc:<issuer>"` and a default system role (VIEWER) via `user_roles`. No group membership by default.
### API Endpoints
All under `/api/v1/admin/` prefix, protected by `@PreAuthorize("hasRole('ADMIN')")`.
The existing `PUT /users/{userId}/roles` bulk endpoint is removed. Role assignments use individual add/remove endpoints.
All mutation endpoints log to the `AuditService` (category: `USER_MGMT` for user operations, `RBAC` for group/role operations).
**Users** — response type: `UserDetail`
| Method | Path | Description | Request Body |
|---|---|---|---|
| GET | `/users` | List all users with effective roles/groups | — |
| GET | `/users/{id}` | Full user detail | — |
| POST | `/users/{id}/roles/{roleId}` | Assign role to user (system or custom) | — |
| DELETE | `/users/{id}/roles/{roleId}` | Remove role from user | — |
| POST | `/users/{id}/groups/{groupId}` | Add user to group | — |
| DELETE | `/users/{id}/groups/{groupId}` | Remove user from group | — |
| DELETE | `/users/{id}` | Delete user | — |
**Groups** — response type: `GroupDetail`
| Method | Path | Description | Request Body |
|---|---|---|---|
| GET | `/groups` | List all groups with hierarchy | — |
| GET | `/groups/{id}` | Full group detail | — |
| POST | `/groups` | Create group | `{ name, parentGroupId? }` |
| PUT | `/groups/{id}` | Update group | `{ name?, parentGroupId? }` — returns 409 on cycle |
| DELETE | `/groups/{id}` | Delete group — cascades role/member associations; child groups become top-level (parent set to null) | — |
| POST | `/groups/{id}/roles/{roleId}` | Assign role to group | — |
| DELETE | `/groups/{id}/roles/{roleId}` | Remove role from group | — |
**Roles** — response type: `RoleDetail`
| Method | Path | Description | Request Body |
|---|---|---|---|
| GET | `/roles` | List all roles (system + custom) | — |
| GET | `/roles/{id}` | Role detail | — |
| POST | `/roles` | Create custom role | `{ name, description?, scope? }` |
| PUT | `/roles/{id}` | Update custom role (rejects system roles) | `{ name?, description?, scope? }` |
| DELETE | `/roles/{id}` | Delete custom role (rejects system roles) | — |
**Dashboard:**
| Method | Path | Description |
|---|---|---|
| GET | `/rbac/stats` | `{ userCount, activeUserCount, groupCount, maxGroupDepth, roleCount }` |
---
## Frontend
### Routing
New route at `/admin/rbac` in `router.tsx`, lazy-loaded:
```tsx
const RbacPage = lazy(() => import('./pages/admin/rbac/RbacPage').then(m => ({ default: m.RbacPage })));
// ...
{ path: 'admin/rbac', element: <Suspense fallback={null}><RbacPage /></Suspense> }
```
Update `AppSidebar` ADMIN_LINKS to add `{ to: '/admin/rbac', label: 'User Management' }`.
### Component Structure
```
pages/admin/rbac/
├── RbacPage.tsx ← ADMIN role gate + tab navigation
├── RbacPage.module.css ← All RBAC-specific styles
├── DashboardTab.tsx ← Stat cards + inheritance diagram
├── UsersTab.tsx ← Split pane orchestrator
├── GroupsTab.tsx ← Split pane orchestrator
├── RolesTab.tsx ← Split pane orchestrator
├── components/
│ ├── EntityListPane.tsx ← Reusable: search input + scrollable card list
│ ├── EntityCard.tsx ← Single list row: avatar, name, meta, tags, status dot
│ ├── UserDetail.tsx ← Header, fields, groups, effective roles, group tree
│ ├── GroupDetail.tsx ← Header, fields, members, children, roles, hierarchy
│ ├── RoleDetail.tsx ← Header, fields, assigned groups/users, effective principals
│ ├── InheritanceChip.tsx ← Chip with dashed border + "↑ Source" annotation
│ ├── GroupTree.tsx ← Indented tree with corner connectors
│ ├── EntityAvatar.tsx ← Circle (user), rounded-square (group/role), color by type
│ ├── OidcBadge.tsx ← Small badge showing OIDC provider origin
│ ├── InheritanceDiagram.tsx ← Three-column Groups→Roles→Users read-only diagram
│ └── InheritanceNote.tsx ← Green-bordered explanation block
api/queries/admin/
│ └── rbac.ts ← useUsers, useUser, useGroups, useGroup, useRoles, useRole, useRbacStats + mutation hooks
```
### Tab Navigation
`RbacPage` uses a horizontal tab bar (Dashboard | Users | Groups | Roles) with URL-synced active state via query parameter (`?tab=users`). Each tab renders its content below the tab bar in the full main panel area.
### Split Pane Layout
Users, Groups, and Roles tabs share the same layout:
- **Left (52%)**: `EntityListPane` with search input + scrollable entity cards
- **Right (48%)**: Detail pane showing selected entity, or empty state prompt
- Resizable via `ResizableDivider` (existing shared component)
### Entity Card Patterns
**User card:** Circle avatar (initials, blue tint) + name + email/primary-group meta + role tags (amber) + group tags (green) + status dot + OIDC badge if `provider !== "local"`
**Group card:** Rounded-square avatar (initials, green/amber/red by domain) + name + parent/member-count meta + role tags (direct solid, inherited faded+italic)
**Role card:** Rounded-square avatar (initials, amber tint) + name + description/assignment-count meta + assigned-to tags. System roles show a lock icon.
### Badge/Chip Styling
Following the spec and existing CSS token system:
| Chip type | Background | Border | Text |
|---|---|---|---|
| Role (direct) | `var(--amber-dim)` | solid `var(--amber)` | amber text |
| Role (inherited) | transparent | dashed `var(--amber)` | faded amber, italic |
| Group | `var(--green-dim)` / `#E1F5EE` | solid green | green text |
| OIDC badge | `var(--cyan-dim)` | solid cyan | cyan text, shows provider |
| System role | Same as role but with lock icon | — | — |
Inherited role chips include `↑ GroupName` annotation in the detail pane.
### OIDC Badge
Displayed on user cards and user detail when `provider !== "local"`. Shows a small cyan-tinted pill with the provider name (e.g., "OIDC" or the issuer hostname). Positioned after the user's name in the card, and as a field in the detail pane.
### Search
Client-side filtering on entity list panes — filter by any visible text (name, email, group, role). Sufficient for the expected user count.
### State Management
- React Query for all server state (users, groups, roles, stats)
- Local `useState` for selected entity, search filter, active tab
- Mutations invalidate related queries (e.g., updating a user's groups invalidates both user and group queries)
---
## Migration Strategy
1. Delete all V1V10 migration files
2. Create single `V1__init.sql` containing the full consolidated schema
3. Deployed environments: drop and recreate the database (data loss accepted)
4. CI/CD: no special handling — clean database on deploy
5. Update `application.yml` if needed: `spring.flyway.clean-on-validation-error: true` or manual DB drop
---
## Out of Scope
- Permission-based access control (custom roles don't gate endpoints — system roles do)
- Audit log panel within RBAC (existing audit log page covers this)
- Bulk import/export of users or groups
- SCIM provisioning
- Role negation / deny rules

View File

@@ -0,0 +1,576 @@
# UI Mock Alignment Design
**Date:** 2026-03-23
**Status:** Reviewed
**Scope:** Close all gaps between `@cameleer/design-system` mocks and the cameleer3-server UI
## Context
The `@cameleer/design-system` package (v0.0.2) contains fully realized mock pages demonstrating the target UX for the Cameleer3 monitoring platform. The current server UI was built as a first pass and has significant deviations from these mocks across every page. This spec defines the work to align them.
**Out of scope:**
- Business context columns (Order ID, Customer) — not applicable to current data model
- Application log streaming — agent does not send logs; placeholder only
## 1. Backend — New Endpoints
### 1a. Processor Stats Endpoint
**`GET /api/v1/routes/metrics/processors`**
Exposes per-processor statistics. The current `stats_1m_processor` continuous aggregate groups by `(bucket, group_name, route_id, processor_type)` and lacks `processor_id`. TimescaleDB continuous aggregates cannot be ALTERed to add GROUP BY columns.
**Migration:** Add `V7__processor_stats_by_id.sql` creating a new continuous aggregate `stats_1m_processor_detail`:
```sql
CREATE MATERIALIZED VIEW stats_1m_processor_detail
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 minute', start_time) AS bucket,
group_name,
route_id,
processor_id,
processor_type,
COUNT(*) AS total_count,
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
SUM(duration_ms) AS duration_sum,
MAX(duration_ms) AS duration_max,
approx_percentile(0.99, percentile_agg(duration_ms)) AS p99_duration
FROM processor_executions
GROUP BY bucket, group_name, route_id, processor_id, processor_type;
```
Leave the original `stats_1m_processor` intact (used elsewhere).
**Controller:** Add new method in existing `RouteMetricsController.java` (shares `/api/v1/routes/metrics` base path) rather than a separate controller.
**Query params:**
- `routeId` (required) — filter by route
- `appId` (optional) — filter by application
- `from` / `to` (optional) — time window, defaults to last 24h
**Response:** `List<ProcessorMetrics>`
```java
record ProcessorMetrics(
String processorId, // unique processor ID within the route
String processorType, // e.g. "to", "process", "choice"
String routeId,
String appId,
long totalCount,
long failedCount,
double avgDurationMs,
double p99DurationMs,
double errorRate // failedCount / totalCount
)
```
**Security:** VIEWER+ role. Already covered by existing `GET /api/v1/routes/**` wildcard in `SecurityConfig`.
### 1b. Agent Metrics Query Endpoint
**`GET /api/v1/agents/{agentId}/metrics`**
Queries the `agent_metrics` hypertable and returns time-bucketed series.
**Query params:**
- `names` (required) — comma-separated metric names (e.g. `jvm.cpu.process,jvm.memory.heap.used`)
- `from` / `to` (optional) — time window, defaults to last 1h
- `buckets` (optional, default 60) — number of time buckets
**Response:** `AgentMetricsResponse`
```java
record AgentMetricsResponse(
Map<String, List<MetricBucket>> metrics
)
record MetricBucket(
Instant time,
double value // avg within bucket
)
```
**Implementation:** Use `time_bucket()` on `agent_metrics.collected_at`, grouped by `metric_name`, averaged by `metric_value`. Filter by `agent_id` and optional `tags` if needed.
**Security:** VIEWER+ role. Requires new `SecurityConfig` rule: `GET /api/v1/agents/*/metrics` (existing `/api/v1/agents` rule is exact-match only, does not cover sub-paths).
### 1c. Enrich AgentInstanceResponse
Add fields to existing `AgentInstanceResponse`:
```java
// existing fields...
String version, // from AgentInfo.version in registry
Map<String, Object> capabilities // from AgentInfo.capabilities
```
These values are already stored in the `AgentRegistry`'s `AgentInfo` objects. The `AgentRegistrationController.listAgents()` method just needs to map them into the response DTO.
### 1d. Password Reset Endpoint
**`POST /api/v1/admin/users/{userId}/password`**
The current `UpdateUserRequest` has no password field. Add a dedicated endpoint for admin password reset.
**Request body:** `SetPasswordRequest`
```java
record SetPasswordRequest(
@NotBlank String password
)
```
**Response:** 204 No Content
**Implementation:** Hash password with same BCrypt encoder used in `createUser`, update `users.password_hash` column.
**Security:** ADMIN role required (same as other user management endpoints).
**New files:** `cameleer3-server-app/.../dto/SetPasswordRequest.java`; new method in `UserAdminController`.
## 2. Dashboard Enhancements
### 2a. DetailPanel — Errors Section
When the selected execution has a non-null `errorMessage`:
- Insert an "Errors" section between Overview and Processors in the DetailPanel
- Display:
- Error class: parsed from `errorMessage` (text before `:` or first line)
- Error message: remainder of `errorMessage`
- Stack trace: `errorStackTrace` in a collapsible `CodeBlock` (content prop)
- Use `Alert` variant="error" for the error class/message, `Collapsible` + `CodeBlock` for the stack trace
### 2b. DetailPanel — Route Flow Tab
Add a third tab to the DetailPanel tabs: **Overview | Processors | Route Flow**
- Fetch diagram via `useDiagramByRoute(execution.groupName, execution.routeId)`
- Render `RouteFlow` component from design system
- Overlay execution data: map each `ProcessorNode` status onto diagram nodes using `diagramNodeId`
- Color nodes by status: success (green), failed (red), running (blue)
- Show duration labels on nodes
- **RouteFlow overlay API:** The `RouteFlow` component accepts execution data to color nodes. During implementation, read the `RouteFlow.tsx` source in the design system to confirm the exact props interface (likely an `overlays` or `nodeStates` prop mapping node IDs to status/duration). Map `ProcessorNode.diagramNodeId``PositionedNode.id` to connect execution data to diagram nodes.
### 2c. Stat Card Alignment
Change the 5 stat cards to match mock semantics:
| Position | Label | Value | Source |
|----------|-------|-------|--------|
| 1 | Throughput | exchanges/s | `totalCount / timeWindowSeconds` from stats |
| 2 | Error Rate | % | `failedCount / totalCount * 100` from stats |
| 3 | Avg Latency | ms | `avgDurationMs` from stats |
| 4 | P99 Latency | ms | `p99LatencyMs` from stats |
| 5 | In-Flight | count | `activeCount` from stats |
Each card includes:
- `Sparkline` from timeseries buckets (existing)
- Trend arrow: compare current vs `prev*` fields, show up/down indicator
## 3. Exchange Detail Enhancements
### 3a. Correlation Chain
Below the exchange header card, add a "Correlation Chain" section:
- **Data source:** `POST /search/executions` with filter `{ correlationId: execution.correlationId }`
- **Rendering:** Horizontal chain of small cards connected by arrows
- Each card: route name, `StatusDot`, duration, relative timestamp
- Current exchange highlighted
- Click navigates to that exchange (`/exchanges/:id`)
- **Conditional:** Only show section when correlationId is present and search returns > 1 result
- **Limit:** Request with `limit: 20` to prevent excessive results. If more exist, show "+N more" link
- **Hook:** `useCorrelationChain(correlationId)` — new query hook wrapping the search call
### 3b. Timeline / Flow Toggle
Above the processor timeline section:
- Add `SegmentedTabs` with options: **Timeline** | **Flow**
- **Timeline** (default): existing `ProcessorTimeline` component (Gantt view)
- **Flow**: `RouteFlow` component with execution overlay
- Fetch diagram via `useDiagramByRoute(execution.groupName, execution.routeId)` (same as 2b)
- Color nodes by processor status, show duration labels
- Clicking a processor node in either view selects it and loads its snapshot
### 3c. Header Enrichment
Add to the exchange header:
- Processor count: `execution.processors.length` (or recursive count for nested trees)
- Display as a stat in the header's right section alongside duration
## 4. Route Detail Page (NEW)
**New page** at `/routes/:appId/:routeId`. Currently this path renders a filtered `RoutesMetrics`; replace with a dedicated route detail page. The filtered table/chart view from `RoutesMetrics` is not lost — it is subsumed by the Performance and Recent Executions tabs in the new page, which provide the same data in a richer context alongside the route diagram.
Update `router.tsx`: the `/routes/:appId/:routeId` route imports a new `RouteDetail` component instead of `RoutesMetrics`. The `/routes` and `/routes/:appId` routes remain unchanged (continue to render `RoutesMetrics`).
### 4a. Route Header Card
Card displaying:
- Route name (`routeId`) and application name (`appId`)
- Health status from route catalog (`useRouteCatalog()` filtered)
- Exchange count (last 24h)
- Last seen timestamp
- Back link to `/routes/:appId`
### 4b. Route Diagram + Processor Stats (Side-by-Side)
Two-column grid:
- **Left:** `RouteFlow` component rendering the route diagram
- Data from `useDiagramByRoute(appId, routeId)` or `useDiagramLayout(contentHash)`
- **Right:** Processor stats table from new endpoint (1a)
- `DataTable` columns: Processor ID, Type, Executions, Avg Duration, P99 Duration, Error Rate
- Data from `useProcessorMetrics(routeId, appId)`
### 4c. Tabbed Section
`Tabs` component with three tabs:
**Performance tab:**
- 2x2 chart grid (same pattern as RoutesMetrics) filtered to this specific route
- Data from `useStatsTimeseries(from, to, routeId, appId)`
**Recent Executions tab:**
- `DataTable` showing recent executions for this route
- Data from `useSearchExecutions({ routeId, group: appId, limit: 20, sortField: 'startTime', sortDir: 'desc' })`
- Columns: Status, Execution ID, Duration, Start Time, Error Message
- Row click navigates to `/exchanges/:id`
**Error Patterns tab:**
- Group failed executions by `errorMessage`
- Display: error message (truncated), count, last occurrence timestamp, link to sample execution
- Data from `useSearchExecutions({ routeId, group: appId, status: 'FAILED', limit: 100 })` — client-side grouping by `errorMessage`
### 4d. CSS Module
`RouteDetail.module.css` with classes:
- `.headerCard` — surface card, padding, margin-bottom
- `.diagramStatsGrid` — 2-column grid
- `.diagramPane` / `.statsPane` — surface cards
- `.tabSection` — margin-top
- `.chartGrid` — 2x2 grid (reuse pattern from RoutesMetrics)
## 5. Agent Health Enhancements
### 5a. DetailPanel (Slide-In)
Add a `DetailPanel` from the design system, triggered by clicking an instance row in a GroupCard.
**Overview tab:**
- Status with `StatusDot` and `Badge`
- Application name, version (from enriched AgentInstanceResponse, section 1c)
- Uptime (formatted), last heartbeat (relative time)
- TPS, error rate
- Active routes / total routes
- Memory usage: `ProgressBar` — data from `GET /agents/{id}/metrics?names=jvm.memory.heap.used,jvm.memory.heap.max&buckets=1` (single latest bucket gives the most recent averaged value)
- CPU usage: `ProgressBar` — data from `GET /agents/{id}/metrics?names=jvm.cpu.process&buckets=1` (single latest bucket)
**Performance tab:**
- Two `LineChart` components:
- Throughput over time (from timeseries stats filtered by agentId, or from agent metrics)
- Error rate over time
### 5b. Instance Table Enrichment
Add columns to the instance rows within each `GroupCard`:
| Column | Source |
|--------|--------|
| Status dot | `agent.status` (existing) |
| Instance name | `agent.name` (existing) |
| State badge | `agent.status` (existing) |
| Uptime | `agent.uptimeSeconds` → formatted "2d 4h" / "15m" |
| TPS | `agent.tps` (existing) |
| Error rate | `agent.errorRate` → percentage |
| Last heartbeat | `agent.lastHeartbeat` → relative "2m ago" |
| Link | Icon button → `/agents/:appId/:instanceId` |
### 5c. Alert Banners
When a `GroupCard` contains instances with status `DEAD`:
- Show `Alert` variant="error" at the top of the card body
- Message: `"N instance(s) unreachable"` where N is the count of DEAD instances
### 5d. Stat Card Alignment
Replace current 4 cards (Total, Live, Stale, Dead) with 5 cards matching mock:
| Label | Value | Accent |
|-------|-------|--------|
| Total Agents | count (subtitle: "N live / N stale / N dead") | default |
| Applications | count of unique appIds | default |
| Active Routes | sum of activeRoutes across live agents | default |
| Total TPS | sum of tps across live agents | default |
| Dead | count of dead agents | error |
### 5e. Scope Trail
Add breadcrumb below stat cards:
- All agents view: `Agents` with live `Badge` showing "N live"
- Filtered by app: `Agents` > `{appName}` with health `Badge` (live/stale/dead color)
## 6. Agent Instance Enhancements
### 6a. JVM Metrics Charts (3x2 Grid)
Replace current 2-column chart grid with 3x2 grid. All data from new endpoint (1b).
| Chart | Type | Metric Name(s) |
|-------|------|----------------|
| CPU Usage | AreaChart | `jvm.cpu.process` (0-1 scale, display as %) |
| Memory (Heap) | AreaChart | `jvm.memory.heap.used` + `jvm.memory.heap.max` (two series) |
| Throughput | AreaChart | from `useStatsTimeseries` filtered by agent (existing) |
| Error Rate | LineChart | from `useStatsTimeseries` filtered by agent (existing) |
| Thread Count | LineChart | `jvm.threads.count` |
| GC Pauses | BarChart | `jvm.gc.time` |
**Hook:** `useAgentMetrics(agentId, metricNames[], from, to, buckets)` — wraps endpoint 1b.
### 6b. Process Information Card
Card with key-value pairs:
| Key | Source |
|-----|--------|
| JVM Version | `agent.capabilities.jvmVersion` or parse from registration |
| Camel Version | `agent.capabilities.camelVersion` |
| Spring Boot | `agent.capabilities.springBootVersion` |
| Started | `agent.registeredAt` formatted |
| Capabilities | render as tags: tracing, metrics, diagrams, replay |
Data from enriched `AgentInstanceResponse` (section 1c). If version details aren't in current capabilities, they can be added to agent registration in a future iteration — show what's available.
### 6c. Stat Card Alignment
Replace current 4 cards with 5 cards matching mock:
| Label | Value | Source |
|-------|-------|--------|
| CPU | % | latest `jvm.cpu.process` from agent metrics |
| Memory | % | latest `heap.used / heap.max * 100` |
| Throughput | req/s | `agent.tps` |
| Errors | % | `agent.errorRate` |
| Uptime | formatted | `agent.uptimeSeconds` |
CPU and Memory require a small fetch from endpoint 1b (latest single value).
### 6d. Application Log Placeholder
Below the EventFeed card, add an `EmptyState` component:
- Title: "Application Logs"
- Description: "Application log streaming is not yet available"
- No action button
### 6e. Version Badge in Scope Trail
Breadcrumb: `Agents` > `{appName}` > `{instanceName}`
- Add `Badge` next to instance name showing version (from enriched response)
- Add `StatusDot` + status `Badge` for visual state
## 7. Admin & Miscellaneous
### 7a. OIDC Config — Default Roles
Add a "Default Roles" section to the OIDC config page:
- Display current default roles as `Tag` components (removable, click X to remove)
- `Input` + "Add" `Button` to add a role
- Validate against existing roles from `useRoles()` query
- Persist via existing OIDC config save endpoint
### 7b. OIDC Config — ConfirmDialog on Delete
Replace direct delete button with `ConfirmDialog`:
- Message: "Delete OIDC configuration? All OIDC users will lose access."
- Require typing "DELETE" to confirm
### 7c. Design System Update
Update `@cameleer/design-system` from `^0.0.1` to `^0.0.2` in `ui/package.json`.
**TopBar `onLogout` prop:** Replace the custom `Dropdown` + `Avatar` logout hack in `LayoutShell.tsx` with the TopBar's new `onLogout` prop:
```tsx
<TopBar
breadcrumb={breadcrumb}
user={{ name: username }}
onLogout={handleLogout}
/>
```
Remove the manual Avatar/Dropdown logout code.
**Verification needed during implementation:** Confirm that the TopBar v0.0.2 renders a user avatar/menu internally when `user` + `onLogout` are provided. If it only renders a bare logout button without the "Signed in as" display, keep the custom Avatar/Dropdown and just wire up the TopBar's `onLogout` as an additional trigger.
### 7d. Regenerate schema.d.ts
After backend endpoints are added, regenerate types from the running server:
```bash
npm run generate-api:live
```
This ensures all new DTOs (`ProcessorMetrics`, `AgentMetricsResponse`, `MetricBucket`, enriched `AgentInstanceResponse`) are accurately typed.
## 8. RBAC / User Management Overhaul
The current RBAC page is a basic DataTable + Modal CRUD interface. The design system mock implements a split-pane detail-oriented admin panel with rich interactions. This section describes the full rebuild.
### 8a. Layout — Split-Pane Replaces DataTable
All three tabs (Users, Groups, Roles) adopt the same layout pattern:
```
┌─────────────────────┬────────────────────┐
│ List Pane (52%) │ Detail Pane (48%) │
│ │ │
│ [Search input] │ [Selected entity │
│ [+ Create button] │ detail view] │
│ │ │
│ [Inline create form]│ │
│ │ │
│ [Scrollable entity │ │
│ list with avatars, │ │
│ badges, tags] │ │
│ │ │
└─────────────────────┴────────────────────┘
```
**New file:** `ui/src/pages/Admin/UserManagement.module.css`
- `.splitPane` — CSS grid `52fr 48fr`, full height
- `.listPane` — scrollable, border-right
- `.detailPane` — scrollable, padding
- `.entityItem` / `.entityItemSelected` — list items with hover/selected states
- `.entityInfo`, `.entityName`, `.entityMeta`, `.entityTags` — list item layout
- `.createForm`, `.createFormActions` — inline form styling
- `.metaGrid` — key-value metadata layout
- `.sectionTags` — tag group with wrap
- `.inheritedNote` — small italic annotation text
- `.securitySection` / `.resetForm` — password management styling
**Keep existing stat cards** above tabs — these are a useful addition not present in the mock.
### 8b. Users Tab
**List pane:**
- **Search:** `Input` with search icon, filters across username, displayName, email (client-side)
- **Create button:** "+ Add User" opens inline form (not modal)
- **Inline create form:**
- `Input`: Username (required), Display Name, Email
- `Input`: Password (required)
- Client-side validation: duplicate username check, required fields
- Cancel + Create buttons
- **Note:** Admin-created users are always local. OIDC users are auto-provisioned on first login (no admin creation needed). The create form does not include a provider selector.
- **Entity list:** `role="listbox"`, each item `role="option"` with `tabIndex={0}`
- `Avatar` (initials, size sm)
- Display name + provider `Badge` (if not local)
- Email + group path in meta line
- Direct roles and groups as small `Badge` tags
- Click or Enter/Space to select → populates detail pane
**Detail pane (when user selected):**
- **Header:** `Avatar` (lg) + Display name (`InlineEdit` for rename) + Email + Delete button
- **Status:** "Active" `Tag`
- **Metadata grid:** User ID (`MonoText`), Created (formatted date+time), Provider
- **Security section:**
- Local users: masked password display + "Reset password" button → toggles inline form (new password `Input` + Cancel/Set)
- OIDC users: `InfoCallout` "Password managed by identity provider"
- **Group membership:**
- Current groups as removable `Tag` components
- `MultiSelect` dropdown to add groups
- Warning on removal if inherited roles would be revoked
- **Effective roles:**
- Direct roles: removable `Tag` (warning color)
- Inherited roles: dashed `Badge` with "↑ groupName" source notation (opacity 0.65, non-removable)
- `MultiSelect` to add direct roles
- Note: "Roles with ↑ are inherited through group membership"
- **Delete:** `ConfirmDialog` requiring username to be typed. Self-delete guard (can't delete own account).
**API hooks used:** `useUsers`, `useUser`, `useCreateUser`, `useUpdateUser`, `useDeleteUser`, `useAssignRoleToUser`, `useRemoveRoleFromUser`, `useAddUserToGroup`, `useRemoveUserFromGroup`, `useGroups`, `useRoles`
### 8c. Groups Tab
**List pane:**
- **Search:** filter by group name
- **Create form:** inline with name + parent group `Select` dropdown (options: "Top-level" + all existing groups)
- **Entity list:**
- `Avatar` + group name
- Meta: "Child of {parent}" or "Top-level" + child count + member count
- Role tags
**Detail pane:**
- **Header:** Group name (`InlineEdit` for non-built-in) + parent info + Delete button (disabled for built-in Admins group)
- **Metadata:** Group ID (`MonoText`)
- **Parent group:** display current parent
- **Members:** removable `Tag` list + `MultiSelect` to add users. Note: "+ all members of child groups" if applicable
- **Child groups:** removable `Tag` list + `MultiSelect` to add existing groups as children. Circular reference prevention (can't add ancestor as child)
- **Assigned roles:** removable `Tag` list + `MultiSelect` to add roles. Warning on removal: "Removing {role} from {group} will affect N member(s). Continue?"
- **Delete:** `ConfirmDialog`. Guard: built-in Admins group cannot be deleted.
**API hooks used:** `useGroups`, `useGroup`, `useCreateGroup`, `useUpdateGroup`, `useDeleteGroup`, `useAssignRoleToGroup`, `useRemoveRoleFromGroup`
### 8d. Roles Tab
**List pane:**
- **Search:** filter by role name
- **Create form:** inline with name (auto-uppercase) + description
- **Entity list:**
- `Avatar` + role name + "system" `Badge` (if system role)
- Meta: description + assignment count
- Tags: assigned groups (success color) + direct users
**Detail pane:**
- **Header:** Role name + description + Delete button (disabled for system roles)
- **Metadata:** Role ID (`MonoText`), scope, type (system/custom — "System role (read-only)")
- **Assigned to groups:** view-only `Tag` list (shows which groups have this role)
- **Assigned to users (direct):** view-only `Tag` list
- **Effective principals:** filled `Badge` (direct assignment) + dashed `Badge` (inherited via group). Note: "Dashed entries inherit this role through group membership"
**API hooks used:** `useRoles`, `useRole`, `useCreateRole`, `useUpdateRole`, `useDeleteRole`
### 8e. Shared Patterns
- **Toast notifications** for all mutations (create, update, delete, assign, remove) — use `useToast` from design system
- **Cascade warnings** when actions affect other entities (removing role from group, removing user from group with roles)
- **Keyboard accessibility:** Enter/Space to select, ARIA roles (`listbox`, `option`), `aria-selected`
- **Mutation button states:** disable while in-flight, show spinner
- **ToastProvider:** Add `ToastProvider` from design system to `LayoutShell.tsx` (or app root in `main.tsx`) to enable `useToast()` hook across admin pages
- **Graceful empty states:** When agent metrics are unavailable (agent not sending a particular metric), show per-chart empty state rather than crashing. Check metric name existence in response before rendering.
## File Impact Summary
### New files:
- `ui/src/pages/Routes/RouteDetail.tsx` + `RouteDetail.module.css`
- `ui/src/pages/Admin/UserManagement.module.css`
- `ui/src/pages/Admin/UsersTab.tsx`
- `ui/src/pages/Admin/GroupsTab.tsx`
- `ui/src/pages/Admin/RolesTab.tsx`
- `ui/src/api/queries/agent-metrics.ts` (useAgentMetrics hook)
- `ui/src/api/queries/processor-metrics.ts` (useProcessorMetrics hook)
- `ui/src/api/queries/correlation.ts` (useCorrelationChain hook)
- `cameleer3-server-app/.../controller/AgentMetricsController.java`
- `cameleer3-server-app/.../dto/ProcessorMetrics.java`
- `cameleer3-server-app/.../dto/AgentMetricsResponse.java`
- `cameleer3-server-app/.../dto/MetricBucket.java`
- `cameleer3-server-app/.../dto/SetPasswordRequest.java`
- `cameleer3-server-app/src/main/resources/db/migration/V7__processor_stats_by_id.sql`
### Modified files:
- `ui/package.json` — design system `^0.0.2`
- `ui/src/router.tsx` — add RouteDetail route
- `ui/src/components/LayoutShell.tsx` — TopBar `onLogout` prop, remove Dropdown/Avatar
- `ui/src/pages/Dashboard/Dashboard.tsx` — error section, RouteFlow tab, stat card changes
- `ui/src/pages/Dashboard/Dashboard.module.css` — new classes
- `ui/src/pages/ExchangeDetail/ExchangeDetail.tsx` — correlation chain, flow toggle, processor count
- `ui/src/pages/ExchangeDetail/ExchangeDetail.module.css` — new classes
- `ui/src/pages/Routes/RoutesMetrics.tsx` — stat card adjustments
- `ui/src/pages/AgentHealth/AgentHealth.tsx` — DetailPanel, table enrichment, alert banners, stat cards, scope trail
- `ui/src/pages/AgentHealth/AgentHealth.module.css` — new classes
- `ui/src/pages/AgentInstance/AgentInstance.tsx` — 3x2 charts, process info, stat cards, log placeholder, version badge
- `ui/src/pages/AgentInstance/AgentInstance.module.css` — new classes
- `ui/src/pages/Admin/RbacPage.tsx` — restructured to container with split-pane tabs
- `ui/src/pages/Admin/OidcConfigPage.tsx` — default roles, ConfirmDialog
- `ui/src/api/schema.d.ts` — regenerated with new types
- `cameleer3-server-app/.../dto/AgentInstanceResponse.java` — add version, capabilities
- `cameleer3-server-app/.../controller/AgentRegistrationController.java` — map version/capabilities
- `cameleer3-server-app/.../controller/RouteMetricsController.java` — add processor stats method
- `cameleer3-server-app/.../controller/UserAdminController.java` — add password reset method
- `cameleer3-server-app/.../SecurityConfig.java` — add rule for `GET /api/v1/agents/*/metrics`
- `ui/src/main.tsx` or `ui/src/components/LayoutShell.tsx` — add `ToastProvider`
- `cameleer3-server-app/.../OpenApiConfig.java` — register new DTOs
### Backend migration:
- `V7__processor_stats_by_id.sql` — new `stats_1m_processor_detail` continuous aggregate with `processor_id` grouping

View File

@@ -0,0 +1,261 @@
# Cameleer3 Dashboard Review -- Senior Camel Developer Perspective
**Reviewer**: Senior Apache Camel Developer (10+ years, Java DSL / Spring Boot)
**Artifact reviewed**: `mock-v2-light.html` -- Operations Dashboard (v2 synthesis)
**Date**: 2026-03-17
---
## 1. What the Dashboard Gets RIGHT
### Business ID as First-Class Citizen
The Order ID and Customer columns in the execution table are exactly what I need. When support calls me about "order OP-88421", I can paste that into the search and find the execution immediately. Every other monitoring tool I have used forces me to map business IDs to correlation IDs manually. This alone would save me 10-15 minutes per incident.
### Inline Error Previews
Showing the exception message directly in the table row without requiring a click-through is genuinely useful. The two error examples in the mock (`HttpOperationFailedException` with a 504, `SQLTransientConnectionException` with HikariPool exhaustion) are realistic Camel exceptions. I can scan the error list and immediately tell whether it is a downstream timeout or a connection pool issue. That distinction determines whether I investigate our code or page the DBA.
### Processor Timeline (Gantt View)
The processor timeline in the detail panel is the single most valuable feature. Seeing that `to(payment-api)` consumed 280ms out of a 412ms total execution, while `enrich(inventory)` took 85ms, immediately tells me WHERE the bottleneck is. In my experience, 95% of Camel performance issues are in external calls, and this view pinpoints them. The color coding (green/yellow/red) for processor bars makes the slow step obvious at a glance.
### SLA Awareness Baked In
The SLA threshold line on the latency chart, the "SLA" tag on slow durations, and the "CLOSE" warning on the p99 card are exactly the kind of proactive indicators I want. Most monitoring tools show me raw numbers; this dashboard shows me numbers in context. I know immediately that 287ms p99 is dangerously close to our 300ms SLA.
### Shift-Aware Time Context
The "since 06:00" shift concept is something I have never seen in a developer tool but actually matches how production support works. When I start my day shift, I want to see what happened overnight and what is happening now, not a rolling 24-hour window that mixes yesterday afternoon with this morning.
### Agent Health in Sidebar
Seeing agent status (live/stale/dead), throughput per agent, and error rates at a glance in the sidebar is practical. When an agent goes stale, I know to check if a pod restarted or if there is a network partition.
### Application-to-Route Navigation Hierarchy
The sidebar tree (Applications > order-service > Routes > order-intake, order-enrichment, etc.) matches how I think about Camel deployments. I have multiple applications, each with multiple routes. Being able to filter by application first, then drill into routes, is the right hierarchy.
---
## 2. What is MISSING or Could Be Better
### 2.1 Exchange Body/Header Inspection -- CRITICAL GAP
**Pain point**: The "Exchange" tab exists in the detail panel tabs but its content is not shown. This is the single most important debugging feature for a Camel developer. When a message fails at step 5 of 7, I need to see:
- What was the original inbound message (before any transformation)?
- What did the exchange body look like at each processor step?
- Which headers were present at each step, and which were added/removed?
- What was the exception body (often different from the exception message)?
**How to address it**: The Exchange tab should show a step-by-step diff view of the exchange. For each processor in the route, show the body (with a JSON/XML pretty-printer) and the headers map. Highlight headers that were added at that step. Allow comparing any two steps side-by-side. Show the original inbound message prominently at the top.
**Priority**: **Must-Have**. Without this, the dashboard is an operations monitor, not a debugging tool. This is the difference between "I can see something failed" and "I can see WHY it failed."
### 2.2 Route Diagram / Visual Graph -- MENTIONED BUT NOT SHOWN
**Pain point**: The "View Route Diagram" button exists in the detail actions, but there is no mockup of what the route diagram looks like. As a Camel developer, I need to see the DAG (directed acyclic graph) of my route: from(jms:orders) -> unmarshal -> validate -> choice -> [branch A: enrich -> transform -> to(http)] [branch B: log -> to(dlq)]. I also need to see execution overlay on the diagram -- which path did THIS specific exchange take, and how long did each node take.
**How to address it**: Add a Route Diagram page/view that shows:
- The route definition as an interactive DAG (nodes = processors, edges = flow)
- Execution overlay: color-code each node by success/failure for a specific execution
- Aggregate overlay: color-code each node by throughput/error rate over a time window
- Highlight the path taken by the selected exchange (dim the branches not taken)
- Show inter-route connections (e.g., `direct:`, `seda:`, `vm:` endpoints linking routes)
**Priority**: **Must-Have**. Cameleer already has `RouteGraph` data from agents -- this is the tool's differentiating feature.
### 2.3 Cross-Route Correlation / Message Tracing
**Pain point**: A single business transaction (e.g., an order) often spans multiple routes: `order-intake` -> `order-enrichment` -> `payment-process` -> `shipment-dispatch`. The dashboard shows each route execution as a separate row. There is no way to see the full journey of order OP-88421 across all routes.
**How to address it**: Add a "Transaction Trace" or "Message Flow" view that:
- Groups all executions sharing a breadcrumbId or correlation ID
- Shows them as a horizontal timeline or waterfall chart
- Highlights which route in the chain failed
- Works across `direct:`, `seda:`, and `vm:` endpoints that link routes
The search bar says "Search by Order ID, correlation ID" which is a good start, but the results should show the correlated group, not just individual rows.
**Priority**: **Must-Have**. Splitter/aggregator patterns and multi-route flows are the norm, not the exception, in real Camel applications.
### 2.4 Dead Letter Queue Monitoring
**Pain point**: When messages fail and are routed to a dead letter channel (which is the standard Camel error handling pattern), I need to know: how many messages are in the DLQ, what are they, how long have they been there, and can I retry them?
**How to address it**: Add a DLQ section or page showing:
- Count of messages per dead letter endpoint
- Age distribution (how many are from today vs. last week)
- Message preview (body + headers + the exception that caused routing to DLQ)
- Retry action (re-submit the message to the original route)
- Purge action (acknowledge and discard)
**Priority**: **Must-Have**. DLQ management is a daily production task.
### 2.5 Per-Processor Statistics (Aggregate View)
**Pain point**: The processor timeline in the detail panel shows per-processor timing for a single execution. But I also need aggregate statistics: for processor `to(payment-api)`, what is the p50/p95/p99 latency over the last hour? How many times did it fail? Is it getting slower over time?
**How to address it**: Clicking a processor name in the timeline should show aggregate stats for that processor. Alternatively, the Route Detail page should have a "Processors" tab with a table of all processors in the route, their call count, success rate, and latency percentiles.
**Priority**: **Must-Have**. Identifying a chronically slow processor is different from identifying a one-off slow execution.
### 2.6 Error Pattern Grouping / Top Errors
**Pain point**: The dashboard shows individual error rows. When there are 38 errors, I do not want to scroll through all 38. I want to see: "23 of the 38 errors are `HttpOperationFailedException` on `payment-process`, 10 are `SQLTransientConnectionException` on `order-enrichment`, 5 are `ValidationException` on `order-intake`." The design notes mention "Top error pattern grouping panel" from the operator expert, but it is not in the final mock.
**How to address it**: Add an error summary panel above or alongside the execution table showing errors grouped by exception class + route. Each group should show count, first/last occurrence, and whether the count is trending up.
**Priority**: **Must-Have**. Pattern recognition is more important than individual error viewing.
### 2.7 Route Status Management
**Pain point**: I need to know which routes are started, stopped, or suspended. And I need the ability to stop/start/suspend individual routes without redeploying. This is routine in production -- temporarily suspending a route that is flooding a downstream system.
**How to address it**: The sidebar route list should show route status (started/stopped/suspended) with icons. Right-click or action menu on a route should offer start/stop/suspend. This maps directly to Camel's route controller API.
**Priority**: **Nice-to-Have** for v1, **Must-Have** for v2. Operators will ask for this quickly.
### 2.8 Route Version Comparison
**Pain point**: After a deployment, I want to compare the current route definition with the previous version. Did someone add a processor? Change an endpoint URI? Route definition drift is a real source of production issues.
**How to address it**: Store route graph snapshots per deployment/version. Show a diff view highlighting added/removed/modified processors.
**Priority**: **Nice-to-Have**. Valuable but less urgent than the above.
### 2.9 Thread Pool / Resource Monitoring
**Pain point**: Camel's default thread pool max is 20. When all threads are consumed, messages queue up silently. The HikariPool error in the mock is a perfect example -- pool exhaustion. I need visibility into thread pool utilization, connection pool utilization, and inflight exchange count.
**How to address it**: Add a "Resources" section (either in the agent detail or a separate page) showing:
- Camel thread pool utilization (active/max)
- Connection pool utilization (from endpoint components)
- Inflight exchange count per route
- Consumer prefetch/backlog (for JMS/Kafka consumers)
**Priority**: **Nice-to-Have** initially, but becomes **Must-Have** when debugging pool exhaustion issues.
### 2.10 Saved Searches / Alert Rules
**Pain point**: I find myself searching for the same patterns repeatedly: "errors on payment-process in the last hour", "executions over 500ms for order-enrichment". There is no way to save these as bookmarks or convert them into alert rules.
**How to address it**: Allow saving filter configurations as named views. Allow converting a saved search into an alerting rule (email/webhook when count exceeds threshold).
**Priority**: **Nice-to-Have**.
---
## 3. Specific Page/Feature Recommendations
### 3.1 Route Detail Page
When I click a route name (e.g., `order-intake`) from the sidebar, I should see:
- **Header**: Route name, status (started/stopped), uptime, route definition source (Java DSL / XML / YAML)
- **KPI Strip**: Total executions, success rate, p50/p99 latency, inflight count, throughput -- all for this route only
- **Processor Table**: Every processor in the route with columns: name, type, call count, success rate, p50 latency, p99 latency, total time %. Sortable by any column. This is where I find the bottleneck processor.
- **Route Diagram**: Interactive DAG with execution overlay. Nodes sized by throughput, colored by error rate. Clicking a node filters the execution list to that processor.
- **Recent Executions**: Filtered version of the main table, showing only this route's executions.
- **Error Patterns**: Top errors for this route, grouped by exception class.
### 3.2 Exchange / Message Inspector
When I click "Exchange" tab in the detail panel:
- **Inbound Message**: The original message as received by the route's consumer. Body + headers. Shown prominently, always visible.
- **Step-by-Step Trace**: For each processor, show the exchange state AFTER that processor ran. Diff mode should highlight what changed (body mutations, added headers, removed headers).
- **Properties**: Camel exchange properties (not just headers). Properties often carry routing decisions.
- **Exception**: If the exchange failed, show the caught exception, the handled flag, and whether it was routed to a dead letter channel.
- **Response**: If the route produces a response (e.g., REST endpoint), show the outbound body.
Display format should auto-detect JSON/XML and pretty-print. Binary payloads should show hex dump with size.
### 3.3 Metrics Dashboard (Developer vs. Operator KPIs)
The current metrics (throughput, latency p99, error rate) are operator KPIs. A Camel developer also needs:
**Developer KPIs** (add a "Developer" metrics view):
- Per-processor latency breakdown (stacked bar: which processors consume the most time)
- External endpoint response time (HTTP, DB, JMS) -- separate from Camel processing time
- Type converter cache hit rate (rarely needed, but valuable when debugging serialization issues)
- Redelivery count (how many messages required retries before succeeding)
- Content-based router distribution (for `choice()` routes: how many messages went down each branch)
**Operator KPIs** (already well-covered):
- Throughput, error rate, latency percentiles -- these are solid as-is
### 3.4 Dead Letter Queue View
A dedicated DLQ page:
- **Summary Cards**: One card per DLQ endpoint (e.g., `jms:DLQ.orders`, `seda:error-handler`), showing message count, oldest message age, newest message timestamp.
- **Message List**: Table with columns: original route, exception class, business ID, timestamp, retry count.
- **Message Detail**: Click a DLQ message to see the exchange snapshot (body + headers + exception) at the time of failure.
- **Actions**: Retry (re-submit to original endpoint), Retry All (bulk retry for a pattern), Discard, Move to another queue.
- **Filters**: By exception type, by route, by age.
### 3.5 Route Comparison
Two use cases:
1. **Version diff**: Compare route graph v3.2.0 vs. v3.2.1. Show added/removed/modified processors as a visual diff on the DAG.
2. **Performance comparison**: Compare this week's latency distribution for `payment-process` with last week's. Overlay histograms. Useful for validating that a deployment improved (or degraded) performance.
---
## 4. Information Architecture Critique
### What Works
- **Sidebar hierarchy** (Applications > Routes) is correct and matches how Camel projects are structured.
- **Health strip at top** provides instant situational awareness without scrolling.
- **Master-detail pattern** (table + slide-in panel) avoids page navigation for quick inspection. This keeps context.
- **Keyboard shortcuts** (Ctrl+K search, arrow navigation, Esc to close) are the right accelerators for power users.
### What Needs Adjustment
**The sidebar is too flat.** It shows applications and routes in the same list, but there is no way to navigate to:
- A dedicated Route Detail page (with per-processor stats, diagram, error patterns)
- An Agent Detail page (with resource utilization, version info, configuration)
- A DLQ page
- A Search/Trace page (for cross-route correlation)
Recommendation: Add top-level navigation items to the sidebar:
```
Dashboard (the current view)
Routes (route list with status, drill into route detail)
Traces (cross-route message flow / correlation)
Errors (grouped error patterns, DLQ)
Agents (agent health, resource utilization)
Diagrams (route graph visualization)
```
**Route click should go deeper.** Currently, clicking a route in the sidebar filters the execution table. This is useful, but clicking the route NAME in a table row or in the detail panel should navigate to a dedicated Route Detail page with per-processor aggregate stats and the route diagram.
**Search results need grouping.** The Ctrl+K search bar says "Search by Order ID, route, error..." but search results should group by correlation ID when searching by business ID. If I search for "OP-88421", I want to see ALL executions related to that order across all routes, not just the one row in `payment-process`.
**1-click access priorities:**
- Health overview: 1 click (current: 0 clicks -- it is the home page -- good)
- Filter by errors only: 1 click (current: 1 click on Error pill -- good)
- View a specific execution's processor timeline: 2 clicks (current: 1 click on row -- good)
- View exchange body/headers: should be 2 clicks (click row, click Exchange tab). Currently not implemented.
- View route diagram: should be 2 clicks (click route name, see diagram). Currently requires finding the button in the detail panel.
- Cross-route trace: should be 2 clicks (click correlation ID or business ID, see trace). Currently not possible.
- DLQ status: should be 1 click from sidebar. Currently not available.
---
## 5. Score Card
| Dimension | Score (1-10) | Notes |
|-----------------------------|:---:|-------|
| Transaction tracking | 4 | Individual executions visible, but no cross-route transaction view. Correlation ID shown but not actionable. |
| Root cause analysis | 6 | Processor timeline identifies the slow/failing step. Error messages shown inline. But no exchange body inspection, no stack trace expansion, no header diff. |
| Performance monitoring | 7 | Throughput, latency p99, error rate charts with SLA lines are solid. Missing per-processor aggregate stats and resource utilization. |
| Route visualization | 3 | Route names in sidebar, but no actual route diagram/DAG. The "View Route Diagram" button exists with no destination. This is Cameleer's key differentiator -- it must ship. |
| Exchange/message visibility | 2 | Exchange tab exists but has no content. No body inspection, no header view, no step-by-step diff. This is the most critical gap. |
| Correlation/tracing | 3 | Correlation ID displayed in detail panel, but no way to trace a message across routes. No breadcrumb linking. No transaction waterfall. |
| Overall daily usefulness | 5 | As an operations monitor (is anything broken right now?), it scores 7-8. As a developer debugging tool (why is it broken and how do I fix it?), it scores 3-4. The gap is in the debugging/inspection features. |
### Summary Verdict
The dashboard is a **strong operations monitor** -- it answers "what is happening right now?" effectively. The health strip, SLA awareness, shift context, business ID columns, and inline error previews are genuinely useful and better than most tools I have used.
However, it is a **weak debugging tool** -- it does not yet answer "why did this specific message fail?" or "what did the exchange look like at each step?" The Exchange tab, route diagram, cross-route tracing, and error pattern grouping are the features that would make this a daily-driver tool rather than a pretty overview I glance at in the morning.
The processor Gantt chart in the detail panel is the single best feature in the entire dashboard. Build on that. Make it clickable (click a processor to see the exchange state at that point). Add aggregate stats. Link it to the route diagram. That is where this tool becomes indispensable.
**Bottom line**: Ship the exchange inspector, the route diagram, and cross-route tracing, and this goes from a 5/10 to an 8/10 daily-use tool.

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
ui/.npmrc Normal file
View File

@@ -0,0 +1 @@
@cameleer:registry=https://gitea.siegeln.net/api/packages/cameleer/npm/

View File

@@ -1,8 +1,11 @@
FROM --platform=$BUILDPLATFORM node:22-alpine AS build FROM --platform=$BUILDPLATFORM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ ARG REGISTRY_TOKEN
RUN npm ci COPY package.json package-lock.json .npmrc ./
RUN echo "//gitea.siegeln.net/api/packages/cameleer/npm/:_authToken=${REGISTRY_TOKEN}" >> .npmrc && \
npm ci && \
rm -f .npmrc
COPY . . COPY . .

View File

@@ -1,73 +1,32 @@
# React + TypeScript + Vite # Cameleer3 UI
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. React SPA built with [@cameleer/design-system](https://gitea.siegeln.net/cameleer/design-system), TanStack Query, and Zustand.
Currently, two official plugins are available: ## Development
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) ```bash
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) npm install
npm run dev
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
``` ```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: By default the dev server proxies `/api/*` to `http://localhost:8081`. To proxy to a remote server instead:
```js ```bash
// eslint.config.js VITE_API_TARGET=http://192.168.50.86:30090 npm run dev
import reactX from 'eslint-plugin-react-x' ```
import reactDom from 'eslint-plugin-react-dom'
No CORS issues — Vite's proxy makes API calls server-side.
export default defineConfig([
globalIgnores(['dist']), ## Build
{
files: ['**/*.{ts,tsx}'], ```bash
extends: [ npm run build
// Other configs... ```
// Enable lint rules for React
reactX.configs['recommended-typescript'], ## API Types
// Enable lint rules for React DOM
reactDom.configs.recommended, Regenerate TypeScript types from a running backend:
],
languageOptions: { ```bash
parserOptions: { npm run generate-api:live
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
``` ```

View File

@@ -5,7 +5,6 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Cameleer3</title> <title>Cameleer3</title>
<script src="/config.js"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

541
ui/package-lock.json generated
View File

@@ -8,22 +8,23 @@
"name": "ui", "name": "ui",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@cameleer/design-system": "^0.0.3",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"openapi-fetch": "^0.17.0", "openapi-fetch": "^0.17.0",
"panzoom": "^9.4.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router": "^7.13.1", "react-router": "^7.13.1",
"swagger-ui-dist": "^5.32.0", "swagger-ui-dist": "^5.32.0",
"uplot": "^1.6.32",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.58.2",
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0", "@vitejs/plugin-react": "^6.0.0",
"cross-env": "^10.1.0",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
@@ -197,23 +198,23 @@
} }
}, },
"node_modules/@babel/helpers": { "node_modules/@babel/helpers": {
"version": "7.28.6", "version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/template": "^7.28.6", "@babel/template": "^7.28.6",
"@babel/types": "^7.28.6" "@babel/types": "^7.29.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.29.0", "version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -274,10 +275,25 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@cameleer/design-system": {
"version": "0.0.3",
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.0.3/design-system-0.0.3.tgz",
"integrity": "sha512-x1mZvgYz7j57xFB26pMh9hn5waSJA1CcRWTgkzleLfaO/CmhekLup1HHlbh0b9SxVci6g2HzbcJldr4kvM1yzg==",
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.9.0", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -287,9 +303,9 @@
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.9.0", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -308,6 +324,13 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.9.1", "version": "4.9.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
@@ -584,26 +607,32 @@
"url": "https://github.com/sponsors/Brooooooklyn" "url": "https://github.com/sponsors/Brooooooklyn"
} }
}, },
"node_modules/@oxc-project/runtime": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
"integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.115.0", "version": "0.120.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz",
"integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@redocly/ajv": { "node_modules/@redocly/ajv": {
"version": "8.11.2", "version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
@@ -636,9 +665,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@redocly/openapi-core": { "node_modules/@redocly/openapi-core": {
"version": "1.34.10", "version": "1.34.11",
"resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.10.tgz", "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.11.tgz",
"integrity": "sha512-XCBR/9WHJ0cpezuunHMZjuFMl4KqUo7eiFwzrQrvm7lTXt0EBd3No8UY+9OyzXpDfreGEMMtxmaLZ+ksVw378g==", "integrity": "sha512-V09ayfnb5GyysmvARbt+voFZAjGcf7hSYxOYxSkCc4fbH/DTfq5YWoec8cflvmHHqyIFbqvmGKmYFzqhr9zxDg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -681,9 +710,9 @@
} }
}, },
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz",
"integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -698,9 +727,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-arm64": { "node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz",
"integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -715,9 +744,9 @@
} }
}, },
"node_modules/@rolldown/binding-darwin-x64": { "node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz",
"integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -732,9 +761,9 @@
} }
}, },
"node_modules/@rolldown/binding-freebsd-x64": { "node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz",
"integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -749,9 +778,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm-gnueabihf": { "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz",
"integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -766,9 +795,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-gnu": { "node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz",
"integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -783,9 +812,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-arm64-musl": { "node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz",
"integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -800,9 +829,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-ppc64-gnu": { "node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz",
"integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -817,9 +846,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-s390x-gnu": { "node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz",
"integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -834,9 +863,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-gnu": { "node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz",
"integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -851,9 +880,9 @@
} }
}, },
"node_modules/@rolldown/binding-linux-x64-musl": { "node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz",
"integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -868,9 +897,9 @@
} }
}, },
"node_modules/@rolldown/binding-openharmony-arm64": { "node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz",
"integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -885,9 +914,9 @@
} }
}, },
"node_modules/@rolldown/binding-wasm32-wasi": { "node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz",
"integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==",
"cpu": [ "cpu": [
"wasm32" "wasm32"
], ],
@@ -902,9 +931,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-arm64-msvc": { "node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz",
"integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -919,9 +948,9 @@
} }
}, },
"node_modules/@rolldown/binding-win32-x64-msvc": { "node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz",
"integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -950,9 +979,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@tanstack/query-core": { "node_modules/@tanstack/query-core": {
"version": "5.90.20", "version": "5.91.2",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.91.2.tgz",
"integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", "integrity": "sha512-Uz2pTgPC1mhqrrSGg18RKCWT/pkduAYtxbcyIyKBhw7dTWjXZIzqmpzO2lBkyWr4hlImQgpu1m1pei3UnkFRWw==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "github", "type": "github",
@@ -960,12 +989,12 @@
} }
}, },
"node_modules/@tanstack/react-query": { "node_modules/@tanstack/react-query": {
"version": "5.90.21", "version": "5.91.2",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.91.2.tgz",
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", "integrity": "sha512-GClLPzbM57iFXv+FlvOUL56XVe00PxuTaVEyj1zAObhRiKF008J5vedmaq7O6ehs+VmPHe8+PUQhMuEyv8d9wQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@tanstack/query-core": "5.90.20" "@tanstack/query-core": "5.91.2"
}, },
"funding": { "funding": {
"type": "github", "type": "github",
@@ -1031,17 +1060,17 @@
} }
}, },
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.57.0", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz",
"integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/scope-manager": "8.57.1",
"@typescript-eslint/type-utils": "8.57.0", "@typescript-eslint/type-utils": "8.57.1",
"@typescript-eslint/utils": "8.57.0", "@typescript-eslint/utils": "8.57.1",
"@typescript-eslint/visitor-keys": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.1",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
"ts-api-utils": "^2.4.0" "ts-api-utils": "^2.4.0"
@@ -1054,7 +1083,7 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
}, },
"peerDependencies": { "peerDependencies": {
"@typescript-eslint/parser": "^8.57.0", "@typescript-eslint/parser": "^8.57.1",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
@@ -1070,16 +1099,16 @@
} }
}, },
"node_modules/@typescript-eslint/parser": { "node_modules/@typescript-eslint/parser": {
"version": "8.57.0", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz",
"integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/scope-manager": "8.57.1",
"@typescript-eslint/types": "8.57.0", "@typescript-eslint/types": "8.57.1",
"@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.1",
"@typescript-eslint/visitor-keys": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.1",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@@ -1095,14 +1124,14 @@
} }
}, },
"node_modules/@typescript-eslint/project-service": { "node_modules/@typescript-eslint/project-service": {
"version": "8.57.0", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz",
"integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.57.0", "@typescript-eslint/tsconfig-utils": "^8.57.1",
"@typescript-eslint/types": "^8.57.0", "@typescript-eslint/types": "^8.57.1",
"debug": "^4.4.3" "debug": "^4.4.3"
}, },
"engines": { "engines": {
@@ -1117,14 +1146,14 @@
} }
}, },
"node_modules/@typescript-eslint/scope-manager": { "node_modules/@typescript-eslint/scope-manager": {
"version": "8.57.0", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz",
"integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.57.0", "@typescript-eslint/types": "8.57.1",
"@typescript-eslint/visitor-keys": "8.57.0" "@typescript-eslint/visitor-keys": "8.57.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1135,9 +1164,9 @@
} }
}, },
"node_modules/@typescript-eslint/tsconfig-utils": { "node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.57.0", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz",
"integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1152,15 +1181,15 @@
} }
}, },
"node_modules/@typescript-eslint/type-utils": { "node_modules/@typescript-eslint/type-utils": {
"version": "8.57.0", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz",
"integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.57.0", "@typescript-eslint/types": "8.57.1",
"@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.1",
"@typescript-eslint/utils": "8.57.0", "@typescript-eslint/utils": "8.57.1",
"debug": "^4.4.3", "debug": "^4.4.3",
"ts-api-utils": "^2.4.0" "ts-api-utils": "^2.4.0"
}, },
@@ -1177,9 +1206,9 @@
} }
}, },
"node_modules/@typescript-eslint/types": { "node_modules/@typescript-eslint/types": {
"version": "8.57.0", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz",
"integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -1191,16 +1220,16 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree": { "node_modules/@typescript-eslint/typescript-estree": {
"version": "8.57.0", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz",
"integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/project-service": "8.57.0", "@typescript-eslint/project-service": "8.57.1",
"@typescript-eslint/tsconfig-utils": "8.57.0", "@typescript-eslint/tsconfig-utils": "8.57.1",
"@typescript-eslint/types": "8.57.0", "@typescript-eslint/types": "8.57.1",
"@typescript-eslint/visitor-keys": "8.57.0", "@typescript-eslint/visitor-keys": "8.57.1",
"debug": "^4.4.3", "debug": "^4.4.3",
"minimatch": "^10.2.2", "minimatch": "^10.2.2",
"semver": "^7.7.3", "semver": "^7.7.3",
@@ -1271,16 +1300,16 @@
} }
}, },
"node_modules/@typescript-eslint/utils": { "node_modules/@typescript-eslint/utils": {
"version": "8.57.0", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz",
"integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.9.1", "@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/scope-manager": "8.57.1",
"@typescript-eslint/types": "8.57.0", "@typescript-eslint/types": "8.57.1",
"@typescript-eslint/typescript-estree": "8.57.0" "@typescript-eslint/typescript-estree": "8.57.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1295,13 +1324,13 @@
} }
}, },
"node_modules/@typescript-eslint/visitor-keys": { "node_modules/@typescript-eslint/visitor-keys": {
"version": "8.57.0", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz",
"integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/types": "8.57.0", "@typescript-eslint/types": "8.57.1",
"eslint-visitor-keys": "^5.0.0" "eslint-visitor-keys": "^5.0.0"
}, },
"engines": { "engines": {
@@ -1401,15 +1430,6 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/amator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz",
"integrity": "sha512-V5+aH8pe+Z3u/UG3L3pG3BaFQGXAyXHVQDroRwjPHdh08bcUEchAVsU1MCuJSCaU5o60wTK6KaE6te5memzgYw==",
"license": "MIT",
"dependencies": {
"bezier-easing": "^2.0.3"
}
},
"node_modules/ansi-colors": { "node_modules/ansi-colors": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@@ -1451,9 +1471,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.7", "version": "2.10.9",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.9.tgz",
"integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", "integrity": "sha512-OZd0e2mU11ClX8+IdXe3r0dbqMEznRiT4TfbhYIbcRPZkqJ7Qwer8ij3GZAmLsRKa+II9V1v5czCkvmHH3XZBg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -1463,12 +1483,6 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bezier-easing": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz",
"integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==",
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -1525,9 +1539,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001778", "version": "1.0.30001780",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz",
"integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==", "integrity": "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1623,6 +1637,24 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1681,9 +1713,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.313", "version": "1.5.321",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz",
"integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -1978,9 +2010,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.4.1", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -2597,12 +2629,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ngraph.events": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.4.0.tgz",
"integrity": "sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==",
"license": "BSD-3-Clause"
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.36", "version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -2709,17 +2735,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/panzoom": {
"version": "9.4.3",
"resolved": "https://registry.npmjs.org/panzoom/-/panzoom-9.4.3.tgz",
"integrity": "sha512-xaxCpElcRbQsUtIdwlrZA90P90+BHip4Vda2BC8MEb4tkI05PmR6cKECdqUCZ85ZvBHjpI9htJrZBxV5Gp/q/w==",
"license": "MIT",
"dependencies": {
"amator": "^1.1.0",
"ngraph.events": "^1.2.2",
"wheel": "^1.0.0"
}
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -2791,6 +2806,53 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pluralize": { "node_modules/pluralize": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",
@@ -2893,6 +2955,22 @@
} }
} }
}, },
"node_modules/react-router-dom": {
"version": "7.13.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
"license": "MIT",
"dependencies": {
"react-router": "7.13.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/require-from-string": { "node_modules/require-from-string": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -2914,14 +2992,14 @@
} }
}, },
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz",
"integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/types": "=0.115.0", "@oxc-project/types": "=0.120.0",
"@rolldown/pluginutils": "1.0.0-rc.9" "@rolldown/pluginutils": "1.0.0-rc.10"
}, },
"bin": { "bin": {
"rolldown": "bin/cli.mjs" "rolldown": "bin/cli.mjs"
@@ -2930,27 +3008,27 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.9", "@rolldown/binding-android-arm64": "1.0.0-rc.10",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.9", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10",
"@rolldown/binding-darwin-x64": "1.0.0-rc.9", "@rolldown/binding-darwin-x64": "1.0.0-rc.10",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.9", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10"
} }
}, },
"node_modules/rolldown/node_modules/@rolldown/pluginutils": { "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.9", "version": "1.0.0-rc.10",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz",
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
@@ -3036,9 +3114,9 @@
} }
}, },
"node_modules/swagger-ui-dist": { "node_modules/swagger-ui-dist": {
"version": "5.32.0", "version": "5.32.1",
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.0.tgz", "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.1.tgz",
"integrity": "sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==", "integrity": "sha512-6HQoo7+j8PA2QqP5kgAb9dl1uxUjvR0SAoL/WUp1sTEvm0F6D5npgU2OGCLwl++bIInqGlEUQ2mpuZRZYtyCzQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@scarf/scarf": "=1.4.0" "@scarf/scarf": "=1.4.0"
@@ -3062,9 +3140,9 @@
} }
}, },
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.4.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
"integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3123,16 +3201,16 @@
} }
}, },
"node_modules/typescript-eslint": { "node_modules/typescript-eslint": {
"version": "8.57.0", "version": "8.57.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz", "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz",
"integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@typescript-eslint/eslint-plugin": "8.57.0", "@typescript-eslint/eslint-plugin": "8.57.1",
"@typescript-eslint/parser": "8.57.0", "@typescript-eslint/parser": "8.57.1",
"@typescript-eslint/typescript-estree": "8.57.0", "@typescript-eslint/typescript-estree": "8.57.1",
"@typescript-eslint/utils": "8.57.0" "@typescript-eslint/utils": "8.57.1"
}, },
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3184,12 +3262,6 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/uplot": {
"version": "1.6.32",
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"license": "MIT"
},
"node_modules/uri-js": { "node_modules/uri-js": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -3208,17 +3280,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.0", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz",
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@oxc-project/runtime": "0.115.0",
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"postcss": "^8.5.8", "postcss": "^8.5.8",
"rolldown": "1.0.0-rc.9", "rolldown": "1.0.0-rc.10",
"tinyglobby": "^0.2.15" "tinyglobby": "^0.2.15"
}, },
"bin": { "bin": {
@@ -3235,7 +3306,7 @@
}, },
"peerDependencies": { "peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0", "@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.0.0-alpha.31", "@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"jiti": ">=1.21.0", "jiti": ">=1.21.0",
"less": "^4.0.0", "less": "^4.0.0",
@@ -3286,12 +3357,6 @@
} }
} }
}, },
"node_modules/wheel": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wheel/-/wheel-1.0.0.tgz",
"integrity": "sha512-XiCMHibOiqalCQ+BaNSwRoZ9FDTAvOsXxGHXChBugewDj7HC8VBIER71dEOiRH1fSdLbRCQzngKTSiZ06ZQzeA==",
"license": "MIT"
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3379,9 +3444,9 @@
} }
}, },
"node_modules/zustand": { "node_modules/zustand": {
"version": "5.0.11", "version": "5.0.12",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
"integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12.20.0" "node": ">=12.20.0"

View File

@@ -5,29 +5,32 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "dev:local": "cross-env VITE_API_TARGET=http://localhost:8081 vite",
"dev:remote": "cross-env VITE_API_TARGET=http://192.168.50.86:30090 vite",
"build": "tsc -p tsconfig.app.json --noEmit && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"generate-api": "openapi-typescript src/api/openapi.json -o src/api/schema.d.ts", "generate-api": "openapi-typescript src/api/openapi.json -o src/api/schema.d.ts",
"generate-api:live": "curl -s http://localhost:8081/api/v1/api-docs -o src/api/openapi.json && openapi-typescript src/api/openapi.json -o src/api/schema.d.ts" "generate-api:live": "curl -s http://localhost:8081/api/v1/api-docs -o src/api/openapi.json && openapi-typescript src/api/openapi.json -o src/api/schema.d.ts"
}, },
"dependencies": { "dependencies": {
"@cameleer/design-system": "^0.0.3",
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"openapi-fetch": "^0.17.0", "openapi-fetch": "^0.17.0",
"panzoom": "^9.4.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router": "^7.13.1", "react-router": "^7.13.1",
"swagger-ui-dist": "^5.32.0", "swagger-ui-dist": "^5.32.0",
"uplot": "^1.6.32",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.58.2",
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0", "@vitejs/plugin-react": "^6.0.0",
"cross-env": "^10.1.0",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,8 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { adminFetch } from './admin-api'; import { adminFetch } from './admin-api';
// ── Types ──────────────────────────────────────────────────────────────
export interface AuditEvent { export interface AuditEvent {
id: number; id: number;
timestamp: string; timestamp: string;
@@ -8,18 +10,18 @@ export interface AuditEvent {
action: string; action: string;
category: string; category: string;
target: string; target: string;
detail: Record<string, unknown>; detail: Record<string, unknown> | null;
result: string; result: string;
ipAddress: string; ipAddress: string;
userAgent: string; userAgent: string;
} }
export interface AuditLogParams { export interface AuditLogParams {
from?: string;
to?: string;
username?: string; username?: string;
category?: string; category?: string;
search?: string; search?: string;
from?: string;
to?: string;
sort?: string; sort?: string;
order?: string; order?: string;
page?: number; page?: number;
@@ -34,21 +36,25 @@ export interface AuditLogResponse {
totalPages: number; totalPages: number;
} }
export function useAuditLog(params: AuditLogParams) { // ── Query Hooks ────────────────────────────────────────────────────────
const query = new URLSearchParams();
if (params.from) query.set('from', params.from);
if (params.to) query.set('to', params.to);
if (params.username) query.set('username', params.username);
if (params.category) query.set('category', params.category);
if (params.search) query.set('search', params.search);
if (params.sort) query.set('sort', params.sort);
if (params.order) query.set('order', params.order);
if (params.page !== undefined) query.set('page', String(params.page));
if (params.size !== undefined) query.set('size', String(params.size));
const qs = query.toString();
export function useAuditLog(params: AuditLogParams = {}) {
return useQuery({ return useQuery({
queryKey: ['admin', 'audit', params], queryKey: ['admin', 'audit', params],
queryFn: () => adminFetch<AuditLogResponse>(`/audit${qs ? `?${qs}` : ''}`), queryFn: () => {
const qs = new URLSearchParams();
if (params.username) qs.set('username', params.username);
if (params.category) qs.set('category', params.category);
if (params.search) qs.set('search', params.search);
if (params.from) qs.set('from', params.from);
if (params.to) qs.set('to', params.to);
if (params.sort) qs.set('sort', params.sort);
if (params.order) qs.set('order', params.order);
if (params.page !== undefined) qs.set('page', String(params.page));
if (params.size !== undefined) qs.set('size', String(params.size));
const query = qs.toString();
return adminFetch<AuditLogResponse>(`/audit${query ? `?${query}` : ''}`);
},
placeholderData: (prev) => prev,
}); });
} }

View File

@@ -1,20 +1,22 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminFetch } from './admin-api'; import { adminFetch } from './admin-api';
// ── Types ──────────────────────────────────────────────────────────────
export interface DatabaseStatus { export interface DatabaseStatus {
connected: boolean; connected: boolean;
version: string; version: string | null;
host: string; host: string | null;
schema: string; schema: string | null;
timescaleDb: boolean; timescaleDb: boolean;
} }
export interface PoolStats { export interface PoolStats {
activeConnections: number; activeConnections: number;
idleConnections: number; idleConnections: number;
pendingThreads: number; threadsAwaitingConnection: number;
maxPoolSize: number; connectionTimeout: number;
maxWaitMs: number; maximumPoolSize: number;
} }
export interface TableInfo { export interface TableInfo {
@@ -33,18 +35,21 @@ export interface ActiveQuery {
query: string; query: string;
} }
// ── Query Hooks ────────────────────────────────────────────────────────
export function useDatabaseStatus() { export function useDatabaseStatus() {
return useQuery({ return useQuery({
queryKey: ['admin', 'database', 'status'], queryKey: ['admin', 'database', 'status'],
queryFn: () => adminFetch<DatabaseStatus>('/database/status'), queryFn: () => adminFetch<DatabaseStatus>('/database/status'),
refetchInterval: 30_000,
}); });
} }
export function useDatabasePool() { export function useConnectionPool() {
return useQuery({ return useQuery({
queryKey: ['admin', 'database', 'pool'], queryKey: ['admin', 'database', 'pool'],
queryFn: () => adminFetch<PoolStats>('/database/pool'), queryFn: () => adminFetch<PoolStats>('/database/pool'),
refetchInterval: 15000, refetchInterval: 10_000,
}); });
} }
@@ -52,23 +57,27 @@ export function useDatabaseTables() {
return useQuery({ return useQuery({
queryKey: ['admin', 'database', 'tables'], queryKey: ['admin', 'database', 'tables'],
queryFn: () => adminFetch<TableInfo[]>('/database/tables'), queryFn: () => adminFetch<TableInfo[]>('/database/tables'),
refetchInterval: 60_000,
}); });
} }
export function useDatabaseQueries() { export function useActiveQueries() {
return useQuery({ return useQuery({
queryKey: ['admin', 'database', 'queries'], queryKey: ['admin', 'database', 'queries'],
queryFn: () => adminFetch<ActiveQuery[]>('/database/queries'), queryFn: () => adminFetch<ActiveQuery[]>('/database/queries'),
refetchInterval: 15000, refetchInterval: 5_000,
}); });
} }
// ── Mutation Hooks ─────────────────────────────────────────────────────
export function useKillQuery() { export function useKillQuery() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (pid: number) => { mutationFn: (pid: number) =>
await adminFetch<void>(`/database/queries/${pid}/kill`, { method: 'POST' }); adminFetch<void>(`/database/queries/${pid}/kill`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'database', 'queries'] });
}, },
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'database', 'queries'] }),
}); });
} }

View File

@@ -1,19 +1,21 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminFetch } from './admin-api'; import { adminFetch } from './admin-api';
// ── Types ──────────────────────────────────────────────────────────────
export interface OpenSearchStatus { export interface OpenSearchStatus {
reachable: boolean; connected: boolean;
clusterHealth: string; clusterHealth: string;
version: string; version: string | null;
nodeCount: number; numberOfNodes: number;
host: string; url: string;
} }
export interface PipelineStats { export interface PipelineStats {
queueDepth: number; queueDepth: number;
maxQueueSize: number; maxQueueSize: number;
indexedCount: number;
failedCount: number; failedCount: number;
indexedCount: number;
debounceMs: number; debounceMs: number;
indexingRate: number; indexingRate: number;
lastIndexedAt: string | null; lastIndexedAt: string | null;
@@ -21,15 +23,15 @@ export interface PipelineStats {
export interface IndexInfo { export interface IndexInfo {
name: string; name: string;
health: string;
docCount: number; docCount: number;
size: string; size: string;
sizeBytes: number; sizeBytes: number;
health: string;
primaryShards: number; primaryShards: number;
replicaShards: number; replicas: number;
} }
export interface IndicesPageResponse { export interface IndicesPage {
indices: IndexInfo[]; indices: IndexInfo[];
totalIndices: number; totalIndices: number;
totalDocs: number; totalDocs: number;
@@ -44,20 +46,17 @@ export interface PerformanceStats {
requestCacheHitRate: number; requestCacheHitRate: number;
searchLatencyMs: number; searchLatencyMs: number;
indexingLatencyMs: number; indexingLatencyMs: number;
jvmHeapUsedBytes: number; heapUsedBytes: number;
jvmHeapMaxBytes: number; heapMaxBytes: number;
} }
export interface IndicesParams { // ── Query Hooks ────────────────────────────────────────────────────────
search?: string;
page?: number;
size?: number;
}
export function useOpenSearchStatus() { export function useOpenSearchStatus() {
return useQuery({ return useQuery({
queryKey: ['admin', 'opensearch', 'status'], queryKey: ['admin', 'opensearch', 'status'],
queryFn: () => adminFetch<OpenSearchStatus>('/opensearch/status'), queryFn: () => adminFetch<OpenSearchStatus>('/opensearch/status'),
refetchInterval: 30_000,
}); });
} }
@@ -65,42 +64,41 @@ export function usePipelineStats() {
return useQuery({ return useQuery({
queryKey: ['admin', 'opensearch', 'pipeline'], queryKey: ['admin', 'opensearch', 'pipeline'],
queryFn: () => adminFetch<PipelineStats>('/opensearch/pipeline'), queryFn: () => adminFetch<PipelineStats>('/opensearch/pipeline'),
refetchInterval: 15000, refetchInterval: 10_000,
}); });
} }
export function useIndices(params: IndicesParams) { export function useOpenSearchIndices(page = 0, size = 20, search = '') {
const query = new URLSearchParams();
if (params.search) query.set('search', params.search);
if (params.page !== undefined) query.set('page', String(params.page));
if (params.size !== undefined) query.set('size', String(params.size));
const qs = query.toString();
return useQuery({ return useQuery({
queryKey: ['admin', 'opensearch', 'indices', params], queryKey: ['admin', 'opensearch', 'indices', page, size, search],
queryFn: () => queryFn: () => {
adminFetch<IndicesPageResponse>( const params = new URLSearchParams();
`/opensearch/indices${qs ? `?${qs}` : ''}`, params.set('page', String(page));
), params.set('size', String(size));
if (search) params.set('search', search);
return adminFetch<IndicesPage>(`/opensearch/indices?${params}`);
},
placeholderData: (prev) => prev,
}); });
} }
export function usePerformanceStats() { export function useOpenSearchPerformance() {
return useQuery({ return useQuery({
queryKey: ['admin', 'opensearch', 'performance'], queryKey: ['admin', 'opensearch', 'performance'],
queryFn: () => adminFetch<PerformanceStats>('/opensearch/performance'), queryFn: () => adminFetch<PerformanceStats>('/opensearch/performance'),
refetchInterval: 15000, refetchInterval: 30_000,
}); });
} }
// ── Mutation Hooks ─────────────────────────────────────────────────────
export function useDeleteIndex() { export function useDeleteIndex() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (indexName: string) => { mutationFn: (indexName: string) =>
await adminFetch<void>(`/opensearch/indices/${encodeURIComponent(indexName)}`, { adminFetch<void>(`/opensearch/indices/${indexName}`, { method: 'DELETE' }),
method: 'DELETE', onSuccess: () => {
}); qc.invalidateQueries({ queryKey: ['admin', 'opensearch', 'indices'] });
}, },
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'opensearch', 'indices'] }),
}); });
} }

View File

@@ -1,24 +1,23 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminFetch } from './admin-api'; import { adminFetch } from './admin-api';
// ── Types ─── // ── Types ──────────────────────────────────────────────────────────────
export interface RoleSummary { export interface RoleSummary {
id: string; id: string;
name: string; name: string;
system: boolean; scope: string;
source: string;
} }
export interface GroupSummary { export interface GroupSummary {
id: string; id: string;
name: string; name: string;
parentGroupId: string | null;
} }
export interface UserSummary { export interface UserSummary {
userId: string; userId: string;
displayName: string; displayName: string;
provider: string;
} }
export interface UserDetail { export interface UserDetail {
@@ -33,17 +32,6 @@ export interface UserDetail {
effectiveGroups: GroupSummary[]; effectiveGroups: GroupSummary[];
} }
export interface GroupDetail {
id: string;
name: string;
parentGroupId: string | null;
createdAt: string;
directRoles: RoleSummary[];
effectiveRoles: RoleSummary[];
members: UserSummary[];
childGroups: GroupSummary[];
}
export interface RoleDetail { export interface RoleDetail {
id: string; id: string;
name: string; name: string;
@@ -56,6 +44,53 @@ export interface RoleDetail {
effectivePrincipals: UserSummary[]; effectivePrincipals: UserSummary[];
} }
export interface GroupDetail {
id: string;
name: string;
parentGroupId: string | null;
createdAt: string;
directRoles: RoleSummary[];
effectiveRoles: RoleSummary[];
members: UserSummary[];
childGroups: GroupSummary[];
}
export interface CreateUserRequest {
username: string;
displayName?: string;
email?: string;
password?: string;
}
export interface UpdateUserRequest {
displayName?: string;
email?: string;
}
export interface CreateRoleRequest {
name: string;
description?: string;
scope?: string;
}
export interface UpdateRoleRequest {
name: string;
description?: string;
scope?: string;
}
export interface CreateGroupRequest {
name: string;
parentGroupId?: string | null;
}
export interface UpdateGroupRequest {
name: string;
parentGroupId?: string | null;
}
// ── Stats Hook ───────────────────────────────────────────────────────
export interface RbacStats { export interface RbacStats {
userCount: number; userCount: number;
activeUserCount: number; activeUserCount: number;
@@ -64,53 +99,6 @@ export interface RbacStats {
roleCount: number; roleCount: number;
} }
// ─── Query hooks ───
export function useUsers() {
return useQuery({
queryKey: ['admin', 'rbac', 'users'],
queryFn: () => adminFetch<UserDetail[]>('/users'),
});
}
export function useUser(userId: string | null) {
return useQuery({
queryKey: ['admin', 'rbac', 'users', userId],
queryFn: () => adminFetch<UserDetail>(`/users/${encodeURIComponent(userId!)}`),
enabled: !!userId,
});
}
export function useGroups() {
return useQuery({
queryKey: ['admin', 'rbac', 'groups'],
queryFn: () => adminFetch<GroupDetail[]>('/groups'),
});
}
export function useGroup(groupId: string | null) {
return useQuery({
queryKey: ['admin', 'rbac', 'groups', groupId],
queryFn: () => adminFetch<GroupDetail>(`/groups/${groupId}`),
enabled: !!groupId,
});
}
export function useRoles() {
return useQuery({
queryKey: ['admin', 'rbac', 'roles'],
queryFn: () => adminFetch<RoleDetail[]>('/roles'),
});
}
export function useRole(roleId: string | null) {
return useQuery({
queryKey: ['admin', 'rbac', 'roles', roleId],
queryFn: () => adminFetch<RoleDetail>(`/roles/${roleId}`),
enabled: !!roleId,
});
}
export function useRbacStats() { export function useRbacStats() {
return useQuery({ return useQuery({
queryKey: ['admin', 'rbac', 'stats'], queryKey: ['admin', 'rbac', 'stats'],
@@ -118,162 +106,69 @@ export function useRbacStats() {
}); });
} }
// ─── Mutation hooks ─── // ── User Query Hooks ───────────────────────────────────────────────────
export function useAssignRoleToUser() { export function useUsers() {
const qc = useQueryClient(); return useQuery({
return useMutation({ queryKey: ['admin', 'users'],
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) => queryFn: () => adminFetch<UserDetail[]>('/users'),
adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
}); });
} }
export function useRemoveRoleFromUser() { export function useUser(userId: string | null) {
const qc = useQueryClient(); return useQuery({
return useMutation({ queryKey: ['admin', 'users', userId],
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) => queryFn: () => adminFetch<UserDetail>(`/users/${userId}`),
adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'DELETE' }), enabled: !!userId,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
}); });
} }
export function useAddUserToGroup() { // ── Role Query Hooks ───────────────────────────────────────────────────
const qc = useQueryClient();
return useMutation({ export function useRoles() {
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) => return useQuery({
adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'POST' }), queryKey: ['admin', 'roles'],
onSuccess: () => { queryFn: () => adminFetch<RoleDetail[]>('/roles'),
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
}); });
} }
export function useRemoveUserFromGroup() { export function useRole(roleId: string | null) {
const qc = useQueryClient(); return useQuery({
return useMutation({ queryKey: ['admin', 'roles', roleId],
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) => queryFn: () => adminFetch<RoleDetail>(`/roles/${roleId}`),
adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'DELETE' }), enabled: !!roleId,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
}); });
} }
export function useCreateGroup() { // ── Group Query Hooks ──────────────────────────────────────────────────
const qc = useQueryClient();
return useMutation({ export function useGroups() {
mutationFn: (data: { name: string; parentGroupId?: string }) => return useQuery({
adminFetch<{ id: string }>('/groups', { queryKey: ['admin', 'groups'],
method: 'POST', queryFn: () => adminFetch<GroupDetail[]>('/groups'),
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
}); });
} }
export function useUpdateGroup() { export function useGroup(groupId: string | null) {
const qc = useQueryClient(); return useQuery({
return useMutation({ queryKey: ['admin', 'groups', groupId],
mutationFn: ({ id, ...data }: { id: string; name?: string; parentGroupId?: string | null }) => queryFn: () => adminFetch<GroupDetail>(`/groups/${groupId}`),
adminFetch(`/groups/${id}`, { enabled: !!groupId,
method: 'PUT',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
}); });
} }
export function useDeleteGroup() { // ── User Mutation Hooks ────────────────────────────────────────────────
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
adminFetch(`/groups/${id}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useAssignRoleToGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
adminFetch(`/groups/${groupId}/roles/${roleId}`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useRemoveRoleFromGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
adminFetch(`/groups/${groupId}/roles/${roleId}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useCreateRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; description?: string; scope?: string }) =>
adminFetch<{ id: string }>('/roles', {
method: 'POST',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useUpdateRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: string; name?: string; description?: string; scope?: string }) =>
adminFetch(`/roles/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useDeleteRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
adminFetch(`/roles/${id}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
});
}
export function useCreateUser() { export function useCreateUser() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (data: { username: string; displayName?: string; email?: string; password?: string }) => mutationFn: (req: CreateUserRequest) =>
adminFetch<UserDetail>('/users', { adminFetch<UserDetail>('/users', {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(req),
}), }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); qc.invalidateQueries({ queryKey: ['admin', 'users'] });
}, },
}); });
} }
@@ -281,13 +176,13 @@ export function useCreateUser() {
export function useUpdateUser() { export function useUpdateUser() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ userId, ...data }: { userId: string; displayName?: string; email?: string }) => mutationFn: ({ userId, ...req }: UpdateUserRequest & { userId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}`, { adminFetch<void>(`/users/${userId}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(data), body: JSON.stringify(req),
}), }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); qc.invalidateQueries({ queryKey: ['admin', 'users'] });
}, },
}); });
} }
@@ -296,9 +191,176 @@ export function useDeleteUser() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (userId: string) => mutationFn: (userId: string) =>
adminFetch(`/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }), adminFetch<void>(`/users/${userId}`, { method: 'DELETE' }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] }); qc.invalidateQueries({ queryKey: ['admin', 'users'] });
},
});
}
export function useSetPassword() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ userId, password }: { userId: string; password: string }) => {
await adminFetch(`/users/${userId}/password`, {
method: 'POST',
body: JSON.stringify({ password }),
});
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'users'] }),
});
}
export function useAssignRoleToUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
adminFetch<void>(`/users/${userId}/roles/${roleId}`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
},
});
}
export function useRemoveRoleFromUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
adminFetch<void>(`/users/${userId}/roles/${roleId}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
},
});
}
export function useAddUserToGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
adminFetch<void>(`/users/${userId}/groups/${groupId}`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
},
});
}
export function useRemoveUserFromGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
adminFetch<void>(`/users/${userId}/groups/${groupId}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
},
});
}
// ── Role Mutation Hooks ────────────────────────────────────────────────
export function useCreateRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: CreateRoleRequest) =>
adminFetch<{ id: string }>('/roles', {
method: 'POST',
body: JSON.stringify(req),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
},
});
}
export function useUpdateRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...req }: UpdateRoleRequest & { id: string }) =>
adminFetch<void>(`/roles/${id}`, {
method: 'PUT',
body: JSON.stringify(req),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
},
});
}
export function useDeleteRole() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
adminFetch<void>(`/roles/${id}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
},
});
}
// ── Group Mutation Hooks ───────────────────────────────────────────────
export function useCreateGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: CreateGroupRequest) =>
adminFetch<{ id: string }>('/groups', {
method: 'POST',
body: JSON.stringify(req),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
},
});
}
export function useUpdateGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...req }: UpdateGroupRequest & { id: string }) =>
adminFetch<void>(`/groups/${id}`, {
method: 'PUT',
body: JSON.stringify(req),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
},
});
}
export function useDeleteGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
adminFetch<void>(`/groups/${id}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
},
});
}
export function useAssignRoleToGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
adminFetch<void>(`/groups/${groupId}/roles/${roleId}`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
},
});
}
export function useRemoveRoleFromGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ groupId, roleId }: { groupId: string; roleId: string }) =>
adminFetch<void>(`/groups/${groupId}/roles/${roleId}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'groups'] });
qc.invalidateQueries({ queryKey: ['admin', 'roles'] });
}, },
}); });
} }

View File

@@ -1,6 +1,8 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminFetch } from './admin-api'; import { adminFetch } from './admin-api';
// ── Types ──────────────────────────────────────────────────────────────
export interface DatabaseThresholds { export interface DatabaseThresholds {
connectionPoolWarning: number; connectionPoolWarning: number;
connectionPoolCritical: number; connectionPoolCritical: number;
@@ -24,6 +26,8 @@ export interface ThresholdConfig {
opensearch: OpenSearchThresholds; opensearch: OpenSearchThresholds;
} }
// ── Query Hooks ────────────────────────────────────────────────────────
export function useThresholds() { export function useThresholds() {
return useQuery({ return useQuery({
queryKey: ['admin', 'thresholds'], queryKey: ['admin', 'thresholds'],
@@ -31,15 +35,18 @@ export function useThresholds() {
}); });
} }
export function useSaveThresholds() { // ── Mutation Hooks ─────────────────────────────────────────────────────
export function useUpdateThresholds() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (body: ThresholdConfig) => { mutationFn: (config: ThresholdConfig) =>
await adminFetch<ThresholdConfig>('/thresholds', { adminFetch<ThresholdConfig>('/thresholds', {
method: 'PUT', method: 'PUT',
body: JSON.stringify(body), body: JSON.stringify(config),
}); }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'thresholds'] });
}, },
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'thresholds'] }),
}); });
} }

View File

@@ -0,0 +1,26 @@
import { useQuery } from '@tanstack/react-query';
import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store';
export function useAgentMetrics(agentId: string | null, names: string[], buckets = 60) {
return useQuery({
queryKey: ['agent-metrics', agentId, names.join(','), buckets],
queryFn: async () => {
const token = useAuthStore.getState().accessToken;
const params = new URLSearchParams({
names: names.join(','),
buckets: String(buckets),
});
const res = await fetch(`${config.apiBaseUrl}/agents/${agentId}/metrics?${params}`, {
headers: {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
},
});
if (!res.ok) throw new Error(`${res.status}`);
return res.json() as Promise<{ metrics: Record<string, Array<{ time: string; value: number }>> }>;
},
enabled: !!agentId && names.length > 0,
refetchInterval: 30_000,
});
}

View File

@@ -1,15 +1,40 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { api } from '../client'; import { api } from '../client';
import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store';
export function useAgents(status?: string) { export function useAgents(status?: string, application?: string) {
return useQuery({ return useQuery({
queryKey: ['agents', status], queryKey: ['agents', status, application],
queryFn: async () => { queryFn: async () => {
const { data, error } = await api.GET('/agents', { const { data, error } = await api.GET('/agents', {
params: { query: status ? { status } : {} }, params: { query: { ...(status ? { status } : {}), ...(application ? { application } : {}) } },
}); });
if (error) throw new Error('Failed to load agents'); if (error) throw new Error('Failed to load agents');
return data!; return data!;
}, },
refetchInterval: 10_000,
});
}
export function useAgentEvents(appId?: string, agentId?: string, limit = 50) {
return useQuery({
queryKey: ['agents', 'events', appId, agentId, limit],
queryFn: async () => {
const token = useAuthStore.getState().accessToken;
const params = new URLSearchParams();
if (appId) params.set('appId', appId);
if (agentId) params.set('agentId', agentId);
params.set('limit', String(limit));
const res = await fetch(`${config.apiBaseUrl}/agents/events-log?${params}`, {
headers: {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
},
});
if (!res.ok) throw new Error('Failed to load agent events');
return res.json();
},
refetchInterval: 15_000,
}); });
} }

Some files were not shown because too many files have changed in this diff Show More