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>
This commit is contained in:
hsiegeln
2026-03-19 17:38:39 +01:00
parent 82124c3145
commit 2b111c603c
150 changed files with 2750 additions and 21779 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { adminFetch } from './admin-api';
// ── Types ──────────────────────────────────────────────────────────────
export interface AuditEvent {
id: number;
timestamp: string;
@@ -8,18 +10,18 @@ export interface AuditEvent {
action: string;
category: string;
target: string;
detail: Record<string, unknown>;
detail: Record<string, unknown> | null;
result: string;
ipAddress: string;
userAgent: string;
}
export interface AuditLogParams {
from?: string;
to?: string;
username?: string;
category?: string;
search?: string;
from?: string;
to?: string;
sort?: string;
order?: string;
page?: number;
@@ -34,21 +36,25 @@ export interface AuditLogResponse {
totalPages: number;
}
export function useAuditLog(params: AuditLogParams) {
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();
// ── Query Hooks ────────────────────────────────────────────────────────
export function useAuditLog(params: AuditLogParams = {}) {
return useQuery({
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';
// ── Types ──────────────────────────────────────────────────────────────
export interface DatabaseStatus {
connected: boolean;
version: string;
host: string;
schema: string;
version: string | null;
host: string | null;
schema: string | null;
timescaleDb: boolean;
}
export interface PoolStats {
activeConnections: number;
idleConnections: number;
pendingThreads: number;
maxPoolSize: number;
maxWaitMs: number;
threadsAwaitingConnection: number;
connectionTimeout: number;
maximumPoolSize: number;
}
export interface TableInfo {
@@ -33,18 +35,21 @@ export interface ActiveQuery {
query: string;
}
// ── Query Hooks ────────────────────────────────────────────────────────
export function useDatabaseStatus() {
return useQuery({
queryKey: ['admin', 'database', 'status'],
queryFn: () => adminFetch<DatabaseStatus>('/database/status'),
refetchInterval: 30_000,
});
}
export function useDatabasePool() {
export function useConnectionPool() {
return useQuery({
queryKey: ['admin', 'database', 'pool'],
queryFn: () => adminFetch<PoolStats>('/database/pool'),
refetchInterval: 15000,
refetchInterval: 10_000,
});
}
@@ -52,23 +57,27 @@ export function useDatabaseTables() {
return useQuery({
queryKey: ['admin', 'database', 'tables'],
queryFn: () => adminFetch<TableInfo[]>('/database/tables'),
refetchInterval: 60_000,
});
}
export function useDatabaseQueries() {
export function useActiveQueries() {
return useQuery({
queryKey: ['admin', 'database', 'queries'],
queryFn: () => adminFetch<ActiveQuery[]>('/database/queries'),
refetchInterval: 15000,
refetchInterval: 5_000,
});
}
// ── Mutation Hooks ─────────────────────────────────────────────────────
export function useKillQuery() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (pid: number) => {
await adminFetch<void>(`/database/queries/${pid}/kill`, { method: 'POST' });
mutationFn: (pid: number) =>
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';
// ── Types ──────────────────────────────────────────────────────────────
export interface OpenSearchStatus {
reachable: boolean;
connected: boolean;
clusterHealth: string;
version: string;
nodeCount: number;
host: string;
version: string | null;
numberOfNodes: number;
url: string;
}
export interface PipelineStats {
queueDepth: number;
maxQueueSize: number;
indexedCount: number;
failedCount: number;
indexedCount: number;
debounceMs: number;
indexingRate: number;
lastIndexedAt: string | null;
@@ -21,15 +23,15 @@ export interface PipelineStats {
export interface IndexInfo {
name: string;
health: string;
docCount: number;
size: string;
sizeBytes: number;
health: string;
primaryShards: number;
replicaShards: number;
replicas: number;
}
export interface IndicesPageResponse {
export interface IndicesPage {
indices: IndexInfo[];
totalIndices: number;
totalDocs: number;
@@ -44,20 +46,17 @@ export interface PerformanceStats {
requestCacheHitRate: number;
searchLatencyMs: number;
indexingLatencyMs: number;
jvmHeapUsedBytes: number;
jvmHeapMaxBytes: number;
heapUsedBytes: number;
heapMaxBytes: number;
}
export interface IndicesParams {
search?: string;
page?: number;
size?: number;
}
// ── Query Hooks ────────────────────────────────────────────────────────
export function useOpenSearchStatus() {
return useQuery({
queryKey: ['admin', 'opensearch', 'status'],
queryFn: () => adminFetch<OpenSearchStatus>('/opensearch/status'),
refetchInterval: 30_000,
});
}
@@ -65,42 +64,41 @@ export function usePipelineStats() {
return useQuery({
queryKey: ['admin', 'opensearch', 'pipeline'],
queryFn: () => adminFetch<PipelineStats>('/opensearch/pipeline'),
refetchInterval: 15000,
refetchInterval: 10_000,
});
}
export function useIndices(params: IndicesParams) {
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();
export function useOpenSearchIndices(page = 0, size = 20, search = '') {
return useQuery({
queryKey: ['admin', 'opensearch', 'indices', params],
queryFn: () =>
adminFetch<IndicesPageResponse>(
`/opensearch/indices${qs ? `?${qs}` : ''}`,
),
queryKey: ['admin', 'opensearch', 'indices', page, size, search],
queryFn: () => {
const params = new URLSearchParams();
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({
queryKey: ['admin', 'opensearch', 'performance'],
queryFn: () => adminFetch<PerformanceStats>('/opensearch/performance'),
refetchInterval: 15000,
refetchInterval: 30_000,
});
}
// ── Mutation Hooks ─────────────────────────────────────────────────────
export function useDeleteIndex() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (indexName: string) => {
await adminFetch<void>(`/opensearch/indices/${encodeURIComponent(indexName)}`, {
method: 'DELETE',
});
mutationFn: (indexName: string) =>
adminFetch<void>(`/opensearch/indices/${indexName}`, { 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 { adminFetch } from './admin-api';
// ── Types ───
// ── Types ──────────────────────────────────────────────────────────────
export interface RoleSummary {
id: string;
name: string;
system: boolean;
source: string;
scope: string;
}
export interface GroupSummary {
id: string;
name: string;
parentGroupId: string | null;
}
export interface UserSummary {
userId: string;
displayName: string;
provider: string;
}
export interface UserDetail {
@@ -33,17 +32,6 @@ export interface UserDetail {
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 {
id: string;
name: string;
@@ -56,6 +44,53 @@ export interface RoleDetail {
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 {
userCount: number;
activeUserCount: number;
@@ -64,53 +99,6 @@ export interface RbacStats {
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() {
return useQuery({
queryKey: ['admin', 'rbac', 'stats'],
@@ -118,162 +106,69 @@ export function useRbacStats() {
});
}
// ─── Mutation hooks ───
// ── User Query Hooks ───────────────────────────────────────────────────
export function useAssignRoleToUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
export function useUsers() {
return useQuery({
queryKey: ['admin', 'users'],
queryFn: () => adminFetch<UserDetail[]>('/users'),
});
}
export function useRemoveRoleFromUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, roleId }: { userId: string; roleId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}/roles/${roleId}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
export function useUser(userId: string | null) {
return useQuery({
queryKey: ['admin', 'users', userId],
queryFn: () => adminFetch<UserDetail>(`/users/${userId}`),
enabled: !!userId,
});
}
export function useAddUserToGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'POST' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
// ── Role Query Hooks ───────────────────────────────────────────────────
export function useRoles() {
return useQuery({
queryKey: ['admin', 'roles'],
queryFn: () => adminFetch<RoleDetail[]>('/roles'),
});
}
export function useRemoveUserFromGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, groupId }: { userId: string; groupId: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}/groups/${groupId}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
export function useRole(roleId: string | null) {
return useQuery({
queryKey: ['admin', 'roles', roleId],
queryFn: () => adminFetch<RoleDetail>(`/roles/${roleId}`),
enabled: !!roleId,
});
}
export function useCreateGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { name: string; parentGroupId?: string }) =>
adminFetch<{ id: string }>('/groups', {
method: 'POST',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
// ── Group Query Hooks ──────────────────────────────────────────────────
export function useGroups() {
return useQuery({
queryKey: ['admin', 'groups'],
queryFn: () => adminFetch<GroupDetail[]>('/groups'),
});
}
export function useUpdateGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, ...data }: { id: string; name?: string; parentGroupId?: string | null }) =>
adminFetch(`/groups/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
},
export function useGroup(groupId: string | null) {
return useQuery({
queryKey: ['admin', 'groups', groupId],
queryFn: () => adminFetch<GroupDetail>(`/groups/${groupId}`),
enabled: !!groupId,
});
}
export function useDeleteGroup() {
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'] });
},
});
}
// ── User Mutation Hooks ────────────────────────────────────────────────
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { username: string; displayName?: string; email?: string; password?: string }) =>
mutationFn: (req: CreateUserRequest) =>
adminFetch<UserDetail>('/users', {
method: 'POST',
body: JSON.stringify(data),
body: JSON.stringify(req),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
},
});
}
@@ -281,13 +176,13 @@ export function useCreateUser() {
export function useUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ userId, ...data }: { userId: string; displayName?: string; email?: string }) =>
adminFetch(`/users/${encodeURIComponent(userId)}`, {
mutationFn: ({ userId, ...req }: UpdateUserRequest & { userId: string }) =>
adminFetch<void>(`/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(data),
body: JSON.stringify(req),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
qc.invalidateQueries({ queryKey: ['admin', 'users'] });
},
});
}
@@ -296,9 +191,163 @@ export function useDeleteUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: (userId: string) =>
adminFetch(`/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }),
adminFetch<void>(`/users/${userId}`, { method: 'DELETE' }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'rbac'] });
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';
// ── Types ──────────────────────────────────────────────────────────────
export interface DatabaseThresholds {
connectionPoolWarning: number;
connectionPoolCritical: number;
@@ -24,6 +26,8 @@ export interface ThresholdConfig {
opensearch: OpenSearchThresholds;
}
// ── Query Hooks ────────────────────────────────────────────────────────
export function useThresholds() {
return useQuery({
queryKey: ['admin', 'thresholds'],
@@ -31,15 +35,18 @@ export function useThresholds() {
});
}
export function useSaveThresholds() {
// ── Mutation Hooks ─────────────────────────────────────────────────────
export function useUpdateThresholds() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: ThresholdConfig) => {
await adminFetch<ThresholdConfig>('/thresholds', {
mutationFn: (config: ThresholdConfig) =>
adminFetch<ThresholdConfig>('/thresholds', {
method: 'PUT',
body: JSON.stringify(body),
});
body: JSON.stringify(config),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin', 'thresholds'] });
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'thresholds'] }),
});
}

View File

@@ -1,15 +1,40 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../client';
import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store';
export function useAgents(status?: string) {
export function useAgents(status?: string, group?: string) {
return useQuery({
queryKey: ['agents', status],
queryKey: ['agents', status, group],
queryFn: async () => {
const { data, error } = await api.GET('/agents', {
params: { query: status ? { status } : {} },
params: { query: { ...(status ? { status } : {}), ...(group ? { group } : {}) } },
});
if (error) throw new Error('Failed to load agents');
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,
});
}

View File

@@ -0,0 +1,43 @@
import { useQuery } from '@tanstack/react-query';
import { config } from '../../config';
import { useAuthStore } from '../../auth/auth-store';
export function useRouteCatalog() {
return useQuery({
queryKey: ['routes', 'catalog'],
queryFn: async () => {
const token = useAuthStore.getState().accessToken;
const res = await fetch(`${config.apiBaseUrl}/routes/catalog`, {
headers: {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
},
});
if (!res.ok) throw new Error('Failed to load route catalog');
return res.json();
},
refetchInterval: 15_000,
});
}
export function useRouteMetrics(from?: string, to?: string, appId?: string) {
return useQuery({
queryKey: ['routes', 'metrics', from, to, appId],
queryFn: async () => {
const token = useAuthStore.getState().accessToken;
const params = new URLSearchParams();
if (from) params.set('from', from);
if (to) params.set('to', to);
if (appId) params.set('appId', appId);
const res = await fetch(`${config.apiBaseUrl}/routes/metrics?${params}`, {
headers: {
Authorization: `Bearer ${token}`,
'X-Cameleer-Protocol-Version': '1',
},
});
if (!res.ok) throw new Error('Failed to load route metrics');
return res.json();
},
refetchInterval: 30_000,
});
}

View File

@@ -1,47 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../client';
import type { OidcAdminConfigRequest } from '../types';
export function useOidcConfig() {
return useQuery({
queryKey: ['admin', 'oidc'],
queryFn: async () => {
const { data, error } = await api.GET('/admin/oidc');
if (error) throw new Error('Failed to load OIDC config');
return data!;
},
});
}
export function useSaveOidcConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: OidcAdminConfigRequest) => {
const { data, error } = await api.PUT('/admin/oidc', { body });
if (error) throw new Error('Failed to save OIDC config');
return data!;
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'oidc'] }),
});
}
export function useTestOidcConnection() {
return useMutation({
mutationFn: async () => {
const { data, error } = await api.POST('/admin/oidc/test');
if (error) throw new Error('OIDC test failed');
return data!;
},
});
}
export function useDeleteOidcConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => {
const { error } = await api.DELETE('/admin/oidc');
if (error) throw new Error('Failed to delete OIDC config');
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['admin', 'oidc'] }),
});
}

3503
ui/src/api/schema.d.ts vendored

File diff suppressed because it is too large Load Diff

View File

@@ -17,3 +17,8 @@ export type ErrorResponse = components['schemas']['ErrorResponse'];
export type DiagramLayout = components['schemas']['DiagramLayout'];
export type PositionedNode = components['schemas']['PositionedNode'];
export type PositionedEdge = components['schemas']['PositionedEdge'];
export type AppCatalogEntry = components['schemas']['AppCatalogEntry'];
export type RouteSummary = components['schemas']['RouteSummary'];
export type AgentSummary = components['schemas']['AgentSummary'];
export type RouteMetrics = components['schemas']['RouteMetrics'];
export type AgentEventResponse = components['schemas']['AgentEventResponse'];

View File

@@ -1,145 +0,0 @@
.page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
position: relative;
z-index: 1;
}
.card {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 40px;
width: 100%;
max-width: 400px;
animation: fadeIn 0.3s ease-out both;
}
.logo {
font-family: var(--font-mono);
font-weight: 600;
font-size: 20px;
color: var(--amber);
letter-spacing: -0.5px;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.subtitle {
font-size: 13px;
color: var(--text-muted);
margin-bottom: 28px;
}
.field {
margin-bottom: 16px;
}
.label {
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 6px;
}
.input {
width: 100%;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input:focus {
border-color: var(--amber-dim);
box-shadow: 0 0 0 3px var(--amber-glow);
}
.submit {
width: 100%;
margin-top: 8px;
padding: 10px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--amber);
background: var(--amber);
color: #0a0e17;
font-family: var(--font-body);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.submit:hover {
background: var(--amber-hover);
border-color: var(--amber-hover);
}
.submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ssoButton {
width: 100%;
padding: 10px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.ssoButton:hover {
border-color: var(--amber-dim);
background: var(--bg-surface);
}
.ssoButton:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.divider {
position: relative;
text-align: center;
margin: 20px 0;
border-top: 1px solid var(--border-subtle);
}
.dividerText {
position: relative;
top: -0.65em;
padding: 0 12px;
background: var(--bg-surface);
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.error {
margin-top: 12px;
padding: 10px 12px;
background: var(--rose-glow);
border: 1px solid rgba(244, 63, 94, 0.2);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--rose);
}

View File

@@ -2,7 +2,7 @@ import { type FormEvent, useEffect, useState } from 'react';
import { Navigate } from 'react-router';
import { useAuthStore } from './auth-store';
import { api } from '../api/client';
import styles from './LoginPage.module.css';
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
interface OidcInfo {
clientId: string;
@@ -50,62 +50,54 @@ export function LoginPage() {
};
return (
<div className={styles.page}>
<form className={styles.card} onSubmit={handleSubmit}>
<div className={styles.logo}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2" />
<path d="M12 6v6l4 2" />
</svg>
cameleer3
</div>
<div className={styles.subtitle}>Sign in to access the observability dashboard</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'var(--surface-ground)' }}>
<Card>
<form onSubmit={handleSubmit} style={{ padding: '2rem', minWidth: 360 }}>
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>cameleer3</h1>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem', fontSize: '0.875rem' }}>
Sign in to access the observability dashboard
</p>
</div>
{oidc && (
<>
<button
className={styles.ssoButton}
type="button"
onClick={handleOidcLogin}
disabled={oidcLoading}
>
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'}
</button>
<div className={styles.divider}>
<span className={styles.dividerText}>or</span>
</div>
</>
)}
{oidc && (
<>
<Button variant="secondary" onClick={handleOidcLogin} disabled={oidcLoading} style={{ width: '100%', marginBottom: '1rem' }}>
{oidcLoading ? 'Redirecting...' : 'Sign in with SSO'}
</Button>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', margin: '1rem 0' }}>
<hr style={{ flex: 1, border: 'none', borderTop: '1px solid var(--border)' }} />
<span style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem' }}>or</span>
<hr style={{ flex: 1, border: 'none', borderTop: '1px solid var(--border)' }} />
</div>
</>
)}
<div className={styles.field}>
<label className={styles.label}>Username</label>
<input
className={styles.input}
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
autoComplete="username"
/>
</div>
<FormField label="Username">
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoFocus
autoComplete="username"
/>
</FormField>
<div className={styles.field}>
<label className={styles.label}>Password</label>
<input
className={styles.input}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<FormField label="Password">
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</FormField>
<button className={styles.submit} type="submit" disabled={loading || !username || !password}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
<Button variant="primary" disabled={loading || !username || !password} style={{ width: '100%', marginTop: '0.5rem' }}>
{loading ? 'Signing in...' : 'Sign In'}
</Button>
{error && <div className={styles.error}>{error}</div>}
</form>
{error && <div style={{ marginTop: '1rem' }}><Alert variant="error">{error}</Alert></div>}
</form>
</Card>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
import { Navigate, useNavigate } from 'react-router';
import { useAuthStore } from './auth-store';
import styles from './LoginPage.module.css';
import { Card, Spinner, Alert, Button } from '@cameleer/design-system';
export function OidcCallback() {
const { isAuthenticated, loading, error, loginWithOidcCode } = useAuthStore();
@@ -36,29 +36,21 @@ export function OidcCallback() {
if (isAuthenticated) return <Navigate to="/" replace />;
return (
<div className={styles.page}>
<div className={styles.card}>
<div className={styles.logo}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2" />
<path d="M12 6v6l4 2" />
</svg>
cameleer3
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', background: 'var(--surface-ground)' }}>
<Card>
<div style={{ padding: '2rem', textAlign: 'center', minWidth: 320 }}>
<h2 style={{ marginBottom: '1rem' }}>cameleer3</h2>
{loading && <Spinner />}
{error && (
<>
<Alert variant="error">{error}</Alert>
<Button variant="secondary" onClick={() => navigate('/login')} style={{ marginTop: 16 }}>
Back to Login
</Button>
</>
)}
</div>
{loading && <div className={styles.subtitle}>Completing sign-in...</div>}
{error && (
<>
<div className={styles.error}>{error}</div>
<button
className={styles.submit}
style={{ marginTop: 16 }}
onClick={() => navigate('/login')}
>
Back to Login
</button>
</>
)}
</div>
</Card>
</div>
);
}

View File

@@ -4,7 +4,6 @@ import { useAuth } from './use-auth';
export function ProtectedRoute() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
// Initialize auth hooks (auto-refresh, API client wiring)
useAuth();
if (!isAuthenticated) return <Navigate to="/login" replace />;

View File

@@ -7,7 +7,6 @@ export function useAuth() {
const { accessToken, isAuthenticated, refresh, logout } = useAuthStore();
const navigate = useNavigate();
// Wire onUnauthorized handler (needs navigate from router context)
useEffect(() => {
configureAuth({
onUnauthorized: async () => {
@@ -20,7 +19,6 @@ export function useAuth() {
});
}, [navigate]);
// Auto-refresh: check token expiry every 30s
useEffect(() => {
if (!isAuthenticated) return;
const interval = setInterval(async () => {
@@ -29,12 +27,11 @@ export function useAuth() {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const expiresIn = payload.exp * 1000 - Date.now();
// Refresh when less than 5 minutes remaining
if (expiresIn < 5 * 60 * 1000) {
await refresh();
}
} catch {
// Token parse failure — ignore, will fail on next API call
// Token parse failure
}
}, 30_000);
return () => clearInterval(interval);

View File

@@ -0,0 +1,84 @@
import { Outlet, useNavigate, useLocation } from 'react-router';
import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, useCommandPalette } from '@cameleer/design-system';
import { useRouteCatalog } from '../api/queries/catalog';
import { useAuthStore } from '../auth/auth-store';
import { useMemo, useCallback } from 'react';
import type { SidebarApp } from '@cameleer/design-system';
function LayoutContent() {
const navigate = useNavigate();
const location = useLocation();
const { data: catalog } = useRouteCatalog();
const { username, roles } = useAuthStore();
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
const sidebarApps: SidebarApp[] = useMemo(() => {
if (!catalog) return [];
return catalog.map((app: any) => ({
id: app.appId,
name: app.appId,
health: app.health as 'live' | 'stale' | 'dead',
exchangeCount: app.exchangeCount,
routes: (app.routes || []).map((r: any) => ({
id: r.routeId,
name: r.routeId,
exchangeCount: r.exchangeCount,
})),
agents: (app.agents || []).map((a: any) => ({
id: a.id,
name: a.name,
status: a.status as 'live' | 'stale' | 'dead',
tps: a.tps,
})),
}));
}, [catalog]);
const breadcrumb = useMemo(() => {
const parts = location.pathname.split('/').filter(Boolean);
return parts.map((part, i) => ({
label: part,
href: '/' + parts.slice(0, i + 1).join('/'),
}));
}, [location.pathname]);
const handlePaletteSelect = useCallback((result: any) => {
if (result.path) navigate(result.path);
setPaletteOpen(false);
}, [navigate, setPaletteOpen]);
const isAdmin = roles.includes('ADMIN');
return (
<AppShell
sidebar={
<Sidebar
apps={sidebarApps}
/>
}
>
<TopBar
breadcrumb={breadcrumb}
user={username ? { name: username } : undefined}
/>
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
onSelect={handlePaletteSelect}
data={[]}
/>
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
<Outlet />
</main>
</AppShell>
);
}
export function LayoutShell() {
return (
<CommandPaletteProvider>
<GlobalFilterProvider>
<LayoutContent />
</GlobalFilterProvider>
</CommandPaletteProvider>
);
}

View File

@@ -1,103 +0,0 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.dialog {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 24px;
width: 420px;
max-width: 90vw;
}
.title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 12px;
}
.message {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 16px;
line-height: 1.5;
}
.label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 6px;
}
.input {
width: 100%;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input:focus {
border-color: var(--amber-dim);
box-shadow: 0 0 0 3px var(--amber-glow);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.btnCancel {
padding: 8px 20px;
border-radius: var(--radius-sm);
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
font-family: var(--font-body);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.btnCancel:hover {
border-color: var(--amber-dim);
color: var(--text-primary);
}
.btnDelete {
padding: 8px 20px;
border-radius: var(--radius-sm);
background: transparent;
border: 1px solid var(--rose-dim);
color: var(--rose);
font-family: var(--font-body);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btnDelete:hover:not(:disabled) {
background: var(--rose-glow);
}
.btnDelete:disabled {
opacity: 0.4;
cursor: not-allowed;
}

View File

@@ -1,70 +0,0 @@
import { useState } from 'react';
import styles from './ConfirmDeleteDialog.module.css';
interface ConfirmDeleteDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
resourceName: string;
resourceType: string;
}
export function ConfirmDeleteDialog({
isOpen,
onClose,
onConfirm,
resourceName,
resourceType,
}: ConfirmDeleteDialogProps) {
const [confirmText, setConfirmText] = useState('');
if (!isOpen) return null;
const canDelete = confirmText === resourceName;
function handleClose() {
setConfirmText('');
onClose();
}
function handleConfirm() {
if (!canDelete) return;
setConfirmText('');
onConfirm();
}
return (
<div className={styles.overlay} onClick={handleClose}>
<div className={styles.dialog} onClick={(e) => e.stopPropagation()}>
<h3 className={styles.title}>Confirm Deletion</h3>
<p className={styles.message}>
Delete {resourceType} &lsquo;{resourceName}&rsquo;? This cannot be undone.
</p>
<label className={styles.label}>
Type <strong>{resourceName}</strong> to confirm:
</label>
<input
className={styles.input}
type="text"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={resourceName}
autoFocus
/>
<div className={styles.actions}>
<button type="button" className={styles.btnCancel} onClick={handleClose}>
Cancel
</button>
<button
type="button"
className={styles.btnDelete}
onClick={handleConfirm}
disabled={!canDelete}
>
Delete
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,96 +0,0 @@
.card {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
margin-bottom: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
}
.headerClickable {
cursor: pointer;
user-select: none;
}
.headerClickable:hover {
background: var(--bg-hover);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.titleRow {
display: flex;
align-items: center;
gap: 8px;
}
.chevron {
font-size: 10px;
color: var(--text-muted);
transition: transform 0.2s;
}
.chevronOpen {
transform: rotate(90deg);
}
.title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.autoIndicator {
font-size: 10px;
color: var(--text-muted);
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 99px;
padding: 1px 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.refreshBtn {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 16px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s;
}
.refreshBtn:hover {
border-color: var(--amber-dim);
color: var(--text-primary);
}
.refreshBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.refreshing {
animation: spin 1s linear infinite;
}
.body {
padding: 20px;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@@ -1,70 +0,0 @@
import { type ReactNode, useState } from 'react';
import styles from './RefreshableCard.module.css';
interface RefreshableCardProps {
title: string;
onRefresh?: () => void;
isRefreshing?: boolean;
autoRefresh?: boolean;
collapsible?: boolean;
defaultCollapsed?: boolean;
children: ReactNode;
}
export function RefreshableCard({
title,
onRefresh,
isRefreshing,
autoRefresh,
collapsible,
defaultCollapsed,
children,
}: RefreshableCardProps) {
const [collapsed, setCollapsed] = useState(defaultCollapsed ?? false);
const headerProps = collapsible
? {
onClick: () => setCollapsed((c) => !c),
className: `${styles.header} ${styles.headerClickable}`,
role: 'button' as const,
tabIndex: 0,
onKeyDown: (e: React.KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setCollapsed((c) => !c);
}
},
}
: { className: styles.header };
return (
<div className={styles.card}>
<div {...headerProps}>
<div className={styles.titleRow}>
{collapsible && (
<span className={`${styles.chevron} ${collapsed ? '' : styles.chevronOpen}`}>
&#9654;
</span>
)}
<h3 className={styles.title}>{title}</h3>
{autoRefresh && <span className={styles.autoIndicator}>auto</span>}
</div>
{onRefresh && (
<button
type="button"
className={`${styles.refreshBtn} ${isRefreshing ? styles.refreshing : ''}`}
onClick={(e) => {
e.stopPropagation();
onRefresh();
}}
disabled={isRefreshing}
title="Refresh"
>
&#8635;
</button>
)}
</div>
{!collapsed && <div className={styles.body}>{children}</div>}
</div>
);
}

View File

@@ -1,34 +0,0 @@
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.healthy {
background: #22c55e;
}
.warning {
background: #eab308;
}
.critical {
background: #ef4444;
}
.unknown {
background: #6b7280;
}
.label {
font-size: 13px;
color: var(--text-secondary);
font-weight: 500;
}

View File

@@ -1,17 +0,0 @@
import styles from './StatusBadge.module.css';
export type Status = 'healthy' | 'warning' | 'critical' | 'unknown';
interface StatusBadgeProps {
status: Status;
label?: string;
}
export function StatusBadge({ status, label }: StatusBadgeProps) {
return (
<span className={styles.badge}>
<span className={`${styles.dot} ${styles[status]}`} />
{label && <span className={styles.label}>{label}</span>}
</span>
);
}

View File

@@ -1,108 +0,0 @@
import { useRef, useEffect, useMemo } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { baseOpts, chartColors } from './theme';
import type { TimeseriesBucket } from '../../api/types';
interface DurationHistogramProps {
buckets: TimeseriesBucket[];
}
export function DurationHistogram({ buckets }: DurationHistogramProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
// Build histogram bins from avg durations
const histData = useMemo(() => {
const durations = buckets.map((b) => b.avgDurationMs ?? 0).filter((d) => d > 0);
if (durations.length < 2) return null;
const min = Math.min(...durations);
const max = Math.max(...durations);
const range = max - min || 1;
const binCount = Math.min(20, durations.length);
const binSize = range / binCount;
const bins = new Array(binCount).fill(0);
const labels = new Array(binCount).fill(0);
for (let i = 0; i < binCount; i++) {
labels[i] = Math.round(min + binSize * i + binSize / 2);
}
for (const d of durations) {
const idx = Math.min(Math.floor((d - min) / binSize), binCount - 1);
bins[idx]++;
}
return { xs: labels, counts: bins };
}, [buckets]);
useEffect(() => {
if (!containerRef.current || !histData) return;
const el = containerRef.current;
const w = el.clientWidth || 600;
const opts: uPlot.Options = {
...baseOpts(w, 220),
width: w,
height: 220,
scales: {
x: { time: false },
},
axes: [
{
stroke: chartColors.axis,
grid: { show: false },
ticks: { show: false },
font: '11px JetBrains Mono, monospace',
gap: 8,
values: (_, ticks) => ticks.map((v) => `${Math.round(v)}ms`),
},
{
stroke: chartColors.axis,
grid: { stroke: chartColors.grid, width: 1, dash: [2, 4] },
ticks: { show: false },
font: '11px JetBrains Mono, monospace',
size: 40,
gap: 8,
},
],
series: [
{ label: 'Duration (ms)' },
{
label: 'Count',
stroke: chartColors.cyan,
fill: `${chartColors.cyan}30`,
width: 2,
paths: (u, seriesIdx, idx0, idx1) => {
const path = new Path2D();
const fillPath = new Path2D();
const barWidth = Math.max(2, (u.bbox.width / (idx1 - idx0 + 1)) * 0.7);
const yBase = u.valToPos(0, 'y', true);
fillPath.moveTo(u.valToPos(0, 'x', true), yBase);
for (let i = idx0; i <= idx1; i++) {
const x = u.valToPos(u.data[0][i], 'x', true) - barWidth / 2;
const y = u.valToPos(u.data[seriesIdx][i] ?? 0, 'y', true);
path.rect(x, y, barWidth, yBase - y);
fillPath.rect(x, y, barWidth, yBase - y);
}
return { stroke: path, fill: fillPath };
},
},
],
};
chartRef.current?.destroy();
chartRef.current = new uPlot(opts, [histData.xs, histData.counts], el);
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, [histData]);
if (!histData) return <div style={{ color: 'var(--text-muted)', padding: 20 }}>Not enough data for histogram</div>;
return <div ref={containerRef} />;
}

View File

@@ -1,75 +0,0 @@
import { useRef, useEffect } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { baseOpts, chartColors } from './theme';
import type { TimeseriesBucket } from '../../api/types';
interface LatencyHeatmapProps {
buckets: TimeseriesBucket[];
}
export function LatencyHeatmap({ buckets }: LatencyHeatmapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
useEffect(() => {
if (!containerRef.current || buckets.length < 2) return;
const el = containerRef.current;
const w = el.clientWidth || 600;
const xs = buckets.map((b) => new Date(b.time!).getTime() / 1000);
const avgDurations = buckets.map((b) => b.avgDurationMs ?? 0);
const p99Durations = buckets.map((b) => b.p99DurationMs ?? 0);
const opts: uPlot.Options = {
...baseOpts(w, 220),
width: w,
height: 220,
series: [
{ label: 'Time' },
{
label: 'Avg Duration',
stroke: chartColors.cyan,
width: 2,
dash: [4, 2],
},
{
label: 'P99 Duration',
stroke: chartColors.amber,
fill: `${chartColors.amber}15`,
width: 2,
},
],
axes: [
{
stroke: chartColors.axis,
grid: { show: false },
ticks: { show: false },
font: '11px JetBrains Mono, monospace',
gap: 8,
},
{
stroke: chartColors.axis,
grid: { stroke: chartColors.grid, width: 1, dash: [2, 4] },
ticks: { show: false },
font: '11px JetBrains Mono, monospace',
size: 50,
gap: 8,
values: (_, ticks) => ticks.map((v) => `${Math.round(v)}ms`),
},
],
};
chartRef.current?.destroy();
chartRef.current = new uPlot(opts, [xs, avgDurations, p99Durations], el);
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, [buckets]);
if (buckets.length < 2) return null;
return <div ref={containerRef} />;
}

View File

@@ -1,62 +0,0 @@
import { useRef, useEffect, useMemo } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { sparkOpts, accentHex } from './theme';
interface MiniChartProps {
data: number[];
color: string;
}
export function MiniChart({ data, color }: MiniChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
// Trim first/last buckets (partial time windows) like the old Sparkline
const trimmed = useMemo(() => (data.length > 4 ? data.slice(1, -1) : data), [data]);
const resolvedColor = color.startsWith('#') || color.startsWith('rgb')
? color
: accentHex(color);
useEffect(() => {
if (!containerRef.current || trimmed.length < 2) return;
const el = containerRef.current;
const w = el.clientWidth || 200;
const h = 24;
// x-axis: simple index values
const xs = Float64Array.from(trimmed, (_, i) => i);
const ys = Float64Array.from(trimmed);
const opts: uPlot.Options = {
...sparkOpts(w, h),
width: w,
height: h,
series: [
{},
{
stroke: resolvedColor,
width: 1.5,
fill: `${resolvedColor}30`,
},
],
};
if (chartRef.current) {
chartRef.current.destroy();
}
chartRef.current = new uPlot(opts, [xs as unknown as number[], ys as unknown as number[]], el);
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, [trimmed, resolvedColor]);
if (trimmed.length < 2) return null;
return <div ref={containerRef} style={{ marginTop: 10, height: 24, width: '100%' }} />;
}

View File

@@ -1,57 +0,0 @@
import { useRef, useEffect } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { baseOpts, chartColors } from './theme';
import type { TimeseriesBucket } from '../../api/types';
interface ThroughputChartProps {
buckets: TimeseriesBucket[];
}
export function ThroughputChart({ buckets }: ThroughputChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
useEffect(() => {
if (!containerRef.current || buckets.length < 2) return;
const el = containerRef.current;
const w = el.clientWidth || 600;
const xs = buckets.map((b) => new Date(b.time!).getTime() / 1000);
const totals = buckets.map((b) => b.totalCount ?? 0);
const failed = buckets.map((b) => b.failedCount ?? 0);
const opts: uPlot.Options = {
...baseOpts(w, 220),
width: w,
height: 220,
series: [
{ label: 'Time' },
{
label: 'Total',
stroke: chartColors.amber,
fill: `${chartColors.amber}20`,
width: 2,
},
{
label: 'Failed',
stroke: chartColors.rose,
fill: `${chartColors.rose}20`,
width: 2,
},
],
};
chartRef.current?.destroy();
chartRef.current = new uPlot(opts, [xs, totals, failed], el);
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, [buckets]);
if (buckets.length < 2) return null;
return <div ref={containerRef} />;
}

View File

@@ -1,71 +0,0 @@
import type uPlot from 'uplot';
/** Shared uPlot color tokens matching Cameleer3 design system */
export const chartColors = {
amber: '#f0b429',
cyan: '#22d3ee',
rose: '#f43f5e',
green: '#10b981',
blue: '#3b82f6',
purple: '#a855f7',
grid: 'rgba(30, 45, 61, 0.18)',
axis: '#4a5e7a',
text: '#8b9cb6',
bg: '#111827',
cursor: 'rgba(240, 180, 41, 0.15)',
} as const;
export type AccentColor = keyof typeof chartColors;
/** Resolve an accent name to a CSS hex color */
export function accentHex(accent: string): string {
return (chartColors as Record<string, string>)[accent] ?? chartColors.amber;
}
/** Base uPlot options shared across all Cameleer3 charts */
export function baseOpts(width: number, height: number): Partial<uPlot.Options> {
return {
width,
height,
cursor: {
show: true,
x: true,
y: false,
},
legend: { show: false },
axes: [
{
stroke: chartColors.axis,
grid: { show: false },
ticks: { show: false },
font: '11px JetBrains Mono, monospace',
gap: 8,
},
{
stroke: chartColors.axis,
grid: { stroke: chartColors.grid, width: 1, dash: [2, 4] },
ticks: { show: false },
font: '11px JetBrains Mono, monospace',
size: 50,
gap: 8,
},
],
};
}
/** Mini sparkline chart options (no axes, no cursor) */
export function sparkOpts(width: number, height: number): Partial<uPlot.Options> {
return {
width,
height,
cursor: { show: false },
legend: { show: false },
axes: [
{ show: false },
{ show: false },
],
scales: {
x: { time: false },
},
};
}

View File

@@ -1,495 +0,0 @@
/* ── Overlay ── */
.overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(6, 10, 19, 0.75);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
justify-content: center;
padding-top: 12vh;
animation: fadeIn 0.12s ease-out;
}
[data-theme="light"] .overlay {
background: rgba(247, 245, 242, 0.75);
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(16px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes slideInResult {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
/* ── Modal ── */
.modal {
width: 680px;
max-height: 520px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 16px 72px rgba(0, 0, 0, 0.5), 0 0 40px rgba(240, 180, 41, 0.04);
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideUp 0.18s cubic-bezier(0.16, 1, 0.3, 1);
align-self: flex-start;
}
/* ── Input Area ── */
.inputWrap {
display: flex;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--border-subtle);
gap: 10px;
}
.searchIcon {
width: 20px;
height: 20px;
color: var(--amber);
flex-shrink: 0;
filter: drop-shadow(0 0 6px var(--amber-glow));
}
.chipList {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.chip {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
background: var(--amber-glow);
color: var(--amber);
font-size: 12px;
font-weight: 500;
border-radius: 4px;
white-space: nowrap;
font-family: var(--font-mono);
}
.chipKey {
color: var(--text-muted);
font-size: 11px;
}
.chipRemove {
background: none;
border: none;
color: var(--amber);
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0 0 0 2px;
opacity: 0.5;
}
.chipRemove:hover {
opacity: 1;
}
.input {
flex: 1;
background: none;
border: none;
outline: none;
font-size: 16px;
font-family: var(--font-body);
color: var(--text-primary);
caret-color: var(--amber);
min-width: 100px;
}
.input::placeholder {
color: var(--text-muted);
}
.inputHint {
font-size: 11px;
color: var(--text-muted);
display: flex;
gap: 4px;
align-items: center;
flex-shrink: 0;
}
.kbd {
font-family: var(--font-mono);
font-size: 10px;
padding: 1px 5px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 4px;
line-height: 1.5;
color: var(--text-muted);
}
/* ── Scope Tabs ── */
.scopeTabs {
display: flex;
padding: 8px 18px 0;
gap: 2px;
border-bottom: 1px solid var(--border-subtle);
}
.scopeTab {
padding: 6px 12px;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
border: none;
background: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
display: flex;
align-items: center;
gap: 6px;
}
.scopeTab:hover {
color: var(--text-secondary);
}
.scopeTabActive {
composes: scopeTab;
color: var(--amber);
border-bottom-color: var(--amber);
}
.scopeCount {
font-size: 10px;
padding: 1px 6px;
background: var(--bg-raised);
border-radius: 10px;
font-weight: 600;
min-width: 20px;
text-align: center;
}
.scopeTabActive .scopeCount {
background: var(--amber-glow);
color: var(--amber);
}
.scopeTabDisabled {
composes: scopeTab;
opacity: 0.4;
cursor: default;
}
/* ── Results ── */
.results {
flex: 1;
overflow-y: auto;
padding: 6px 8px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.results::-webkit-scrollbar { width: 6px; }
.results::-webkit-scrollbar-track { background: transparent; }
.results::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.groupLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--text-muted);
padding: 10px 12px 4px;
}
.resultItem {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px 12px;
border-radius: var(--radius-md);
cursor: pointer;
transition: background 0.1s;
animation: slideInResult 0.2s ease-out both;
}
.resultItem:nth-child(2) { animation-delay: 0.03s; }
.resultItem:nth-child(3) { animation-delay: 0.06s; }
.resultItem:nth-child(4) { animation-delay: 0.09s; }
.resultItem:nth-child(5) { animation-delay: 0.12s; }
.resultItem:hover {
background: var(--bg-hover);
}
.resultItemSelected {
composes: resultItem;
background: var(--amber-glow);
outline: 1px solid rgba(240, 180, 41, 0.2);
}
.resultItemSelected:hover {
background: var(--amber-glow);
}
/* ── Result Icon ── */
.resultIcon {
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.resultIcon svg {
width: 18px;
height: 18px;
}
.iconExecution {
composes: resultIcon;
background: rgba(59, 130, 246, 0.12);
color: var(--blue);
}
.iconAgent {
composes: resultIcon;
background: var(--green-glow);
color: var(--green);
}
.iconError {
composes: resultIcon;
background: var(--rose-glow);
color: var(--rose);
}
.iconRoute {
composes: resultIcon;
background: rgba(168, 85, 247, 0.12);
color: var(--purple);
}
/* ── Result Body ── */
.resultBody {
flex: 1;
min-width: 0;
padding-top: 1px;
}
.resultTitle {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
line-height: 1.3;
}
.highlight {
color: var(--amber);
font-weight: 600;
}
.resultMeta {
font-size: 12px;
color: var(--text-muted);
margin-top: 3px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.sep {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--text-muted);
opacity: 0.5;
flex-shrink: 0;
}
/* ── Badges ── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 12px;
line-height: 1.4;
white-space: nowrap;
}
.badgeCompleted {
composes: badge;
background: var(--green-glow);
color: var(--green);
}
.badgeFailed {
composes: badge;
background: var(--rose-glow);
color: var(--rose);
}
.badgeRunning {
composes: badge;
background: rgba(240, 180, 41, 0.12);
color: var(--amber);
}
.badgeDuration {
composes: badge;
background: var(--bg-raised);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 10.5px;
}
.badgeRoute {
composes: badge;
background: rgba(168, 85, 247, 0.1);
color: var(--purple);
font-family: var(--font-mono);
font-size: 10.5px;
}
.badgeLive {
composes: badge;
background: var(--green-glow);
color: var(--green);
}
.badgeStale {
composes: badge;
background: rgba(240, 180, 41, 0.12);
color: var(--amber);
}
.badgeDead {
composes: badge;
background: var(--rose-glow);
color: var(--rose);
}
.resultRight {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
flex-shrink: 0;
padding-top: 2px;
}
.resultTime {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
white-space: nowrap;
}
/* ── Empty / Loading ── */
.emptyState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
color: var(--text-muted);
gap: 8px;
}
.emptyIcon {
width: 40px;
height: 40px;
opacity: 0.4;
}
.emptyText {
font-size: 14px;
}
.emptyHint {
font-size: 12px;
opacity: 0.6;
}
.loadingDots {
display: flex;
gap: 4px;
padding: 24px;
justify-content: center;
}
.loadingDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
animation: pulse 1.2s ease-in-out infinite;
}
.loadingDot:nth-child(2) { animation-delay: 0.2s; }
.loadingDot:nth-child(3) { animation-delay: 0.4s; }
@keyframes pulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
/* ── Footer ── */
.footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 18px;
border-top: 1px solid var(--border-subtle);
background: var(--bg-raised);
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}
.footerHints {
display: flex;
gap: 16px;
font-size: 11px;
color: var(--text-muted);
}
.footerHint {
display: flex;
align-items: center;
gap: 5px;
}
.footerBrand {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* ── Responsive ── */
@media (max-width: 768px) {
.modal {
width: calc(100vw - 32px);
max-height: 70vh;
}
}

View File

@@ -1,27 +0,0 @@
import { useEffect } from 'react';
import { useCommandPalette } from './use-command-palette';
/**
* Headless component: only registers the global Cmd+K / Ctrl+K keyboard shortcut.
* The palette UI itself is rendered inline within SearchFilters.
*/
export function CommandPalette() {
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
const store = useCommandPalette.getState();
if (store.isOpen) {
store.close();
store.reset();
} else {
store.open();
}
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
return null;
}

View File

@@ -1,24 +0,0 @@
import styles from './CommandPalette.module.css';
export function PaletteFooter() {
return (
<div className={styles.footer}>
<div className={styles.footerHints}>
<span className={styles.footerHint}>
<kbd className={styles.kbd}>&uarr;</kbd>
<kbd className={styles.kbd}>&darr;</kbd> navigate
</span>
<span className={styles.footerHint}>
<kbd className={styles.kbd}>&crarr;</kbd> open
</span>
<span className={styles.footerHint}>
<kbd className={styles.kbd}>tab</kbd> scope
</span>
<span className={styles.footerHint}>
<kbd className={styles.kbd}>esc</kbd> close
</span>
</div>
<span className={styles.footerBrand}>cameleer3</span>
</div>
);
}

View File

@@ -1,72 +0,0 @@
import { useRef, useEffect } from 'react';
import { useCommandPalette } from './use-command-palette';
import { parseFilterPrefix, checkTrailingFilter } from './utils';
import styles from './CommandPalette.module.css';
export function PaletteInput() {
const { query, filters, setQuery, addFilter, removeLastFilter, removeFilter } =
useCommandPalette();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
function handleChange(value: string) {
// Check if user typed a filter prefix like "status:failed "
const parsed = parseFilterPrefix(value);
if (parsed) {
addFilter(parsed.filter);
setQuery(parsed.remaining);
return;
}
const trailing = checkTrailingFilter(value);
if (trailing) {
addFilter(trailing);
setQuery('');
return;
}
setQuery(value);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === 'Backspace' && query === '' && filters.length > 0) {
e.preventDefault();
removeLastFilter();
}
}
return (
<div className={styles.inputWrap}>
<svg className={styles.searchIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
{filters.length > 0 && (
<div className={styles.chipList}>
{filters.map((f, i) => (
<span key={f.key} className={styles.chip}>
<span className={styles.chipKey}>{f.key}:</span>
{f.value}
<button className={styles.chipRemove} onClick={() => removeFilter(i)}>
&times;
</button>
</span>
))}
</div>
)}
<input
ref={inputRef}
className={styles.input}
type="text"
value={query}
onChange={(e) => handleChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={filters.length > 0 ? 'Refine search...' : 'Search executions, agents...'}
/>
<div className={styles.inputHint}>
<kbd className={styles.kbd}>esc</kbd> close
</div>
</div>
);
}

View File

@@ -1,156 +0,0 @@
import type { ExecutionSummary, AgentInstance } from '../../api/types';
import type { PaletteResult, RouteInfo } from './use-palette-search';
import { highlightMatch, formatRelativeTime } from './utils';
import { AppBadge } from '../shared/AppBadge';
import styles from './CommandPalette.module.css';
interface ResultItemProps {
result: PaletteResult;
selected: boolean;
query: string;
onClick: () => void;
}
function HighlightedText({ text, query }: { text: string; query: string }) {
const parts = highlightMatch(text, query);
return (
<>
{parts.map((p, i) =>
typeof p === 'string' ? (
<span key={i}>{p}</span>
) : (
<span key={i} className={styles.highlight}>{p.highlight}</span>
),
)}
</>
);
}
function statusBadgeClass(status: string): string {
switch (status.toUpperCase()) {
case 'COMPLETED': return styles.badgeCompleted;
case 'FAILED': return styles.badgeFailed;
case 'RUNNING': return styles.badgeRunning;
default: return styles.badge;
}
}
function stateBadgeClass(state: string): string {
switch (state) {
case 'LIVE': return styles.badgeLive;
case 'STALE': return styles.badgeStale;
case 'DEAD': return styles.badgeDead;
default: return styles.badge;
}
}
function ExecutionResult({ data, query }: { data: ExecutionSummary; query: string }) {
const isFailed = data.status === 'FAILED';
return (
<>
<div className={isFailed ? styles.iconError : styles.iconExecution}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
</div>
<div className={styles.resultBody}>
<div className={styles.resultTitle}>
<HighlightedText text={data.routeId} query={query} />
<span className={statusBadgeClass(data.status)}>{data.status}</span>
<span className={styles.badgeDuration}>{data.durationMs}ms</span>
</div>
<div className={styles.resultMeta}>
<AppBadge name={data.agentId} />
<span className={styles.sep} />
<HighlightedText text={data.executionId.slice(0, 16)} query={query} />
{data.errorMessage && (
<>
<span className={styles.sep} />
<span style={{ color: 'var(--rose)' }}>
{data.errorMessage.slice(0, 60)}
{data.errorMessage.length > 60 ? '...' : ''}
</span>
</>
)}
</div>
</div>
<div className={styles.resultRight}>
<span className={styles.resultTime}>{formatRelativeTime(data.startTime)}</span>
</div>
</>
);
}
function ApplicationResult({ data, query }: { data: AgentInstance; query: string }) {
return (
<>
<div className={styles.iconAgent}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
</svg>
</div>
<div className={styles.resultBody}>
<div className={styles.resultTitle}>
<HighlightedText text={data.id} query={query} />
<span className={stateBadgeClass(data.status)}>{data.status}</span>
</div>
<div className={styles.resultMeta}>
<span>group: {data.group}</span>
<span className={styles.sep} />
<span>last heartbeat: {formatRelativeTime(data.lastHeartbeat)}</span>
</div>
</div>
<div className={styles.resultRight}>
<span className={styles.resultTime}>Application</span>
</div>
</>
);
}
function RouteResult({ data, query }: { data: RouteInfo; query: string }) {
return (
<>
<div className={styles.iconRoute}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="6" cy="19" r="3" />
<path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15" />
<circle cx="18" cy="5" r="3" />
</svg>
</div>
<div className={styles.resultBody}>
<div className={styles.resultTitle}>
<HighlightedText text={data.routeId} query={query} />
</div>
<div className={styles.resultMeta}>
<span>{data.agentIds.length} {data.agentIds.length === 1 ? 'application' : 'applications'}</span>
<span className={styles.sep} />
{data.agentIds.map((id) => <AppBadge key={id} name={id} />)}
</div>
</div>
<div className={styles.resultRight}>
<span className={styles.resultTime}>Route</span>
</div>
</>
);
}
export function ResultItem({ result, selected, query, onClick }: ResultItemProps) {
return (
<div
className={selected ? styles.resultItemSelected : styles.resultItem}
onClick={onClick}
data-palette-item
>
{result.type === 'execution' && (
<ExecutionResult data={result.data as ExecutionSummary} query={query} />
)}
{result.type === 'application' && (
<ApplicationResult data={result.data as AgentInstance} query={query} />
)}
{result.type === 'route' && (
<RouteResult data={result.data as RouteInfo} query={query} />
)}
</div>
);
}

View File

@@ -1,113 +0,0 @@
import { useRef, useEffect } from 'react';
import { useCommandPalette } from './use-command-palette';
import type { PaletteResult } from './use-palette-search';
import { ResultItem } from './ResultItem';
import styles from './CommandPalette.module.css';
interface ResultsListProps {
results: PaletteResult[];
isLoading: boolean;
onSelect: (result: PaletteResult) => void;
}
export function ResultsList({ results, isLoading, onSelect }: ResultsListProps) {
const { selectedIndex, query } = useCommandPalette();
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const items = listRef.current?.querySelectorAll('[data-palette-item]');
items?.[selectedIndex]?.scrollIntoView({ block: 'nearest' });
}, [selectedIndex]);
if (isLoading && results.length === 0) {
return (
<div className={styles.results}>
<div className={styles.loadingDots}>
<div className={styles.loadingDot} />
<div className={styles.loadingDot} />
<div className={styles.loadingDot} />
</div>
</div>
);
}
if (results.length === 0) {
return (
<div className={styles.results}>
<div className={styles.emptyState}>
<svg className={styles.emptyIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.35-4.35" />
</svg>
<span className={styles.emptyText}>No results found</span>
<span className={styles.emptyHint}>
Try a different search or use filters like status:failed
</span>
</div>
</div>
);
}
// Group results by type
const executions = results.filter((r) => r.type === 'execution');
const applications = results.filter((r) => r.type === 'application');
const routes = results.filter((r) => r.type === 'route');
let globalIndex = 0;
return (
<div className={styles.results} ref={listRef}>
{executions.length > 0 && (
<>
<div className={styles.groupLabel}>Executions</div>
{executions.map((r) => {
const idx = globalIndex++;
return (
<ResultItem
key={r.id}
result={r}
selected={idx === selectedIndex}
query={query}
onClick={() => onSelect(r)}
/>
);
})}
</>
)}
{applications.length > 0 && (
<>
<div className={styles.groupLabel}>Applications</div>
{applications.map((r) => {
const idx = globalIndex++;
return (
<ResultItem
key={r.id}
result={r}
selected={idx === selectedIndex}
query={query}
onClick={() => onSelect(r)}
/>
);
})}
</>
)}
{routes.length > 0 && (
<>
<div className={styles.groupLabel}>Routes</div>
{routes.map((r) => {
const idx = globalIndex++;
return (
<ResultItem
key={r.id}
result={r}
selected={idx === selectedIndex}
query={query}
onClick={() => onSelect(r)}
/>
);
})}
</>
)}
</div>
);
}

View File

@@ -1,42 +0,0 @@
import { useCommandPalette, type PaletteScope } from './use-command-palette';
import styles from './CommandPalette.module.css';
interface ScopeTabsProps {
executionCount: number;
applicationCount: number;
routeCount: number;
}
const SCOPES: { key: PaletteScope; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'executions', label: 'Executions' },
{ key: 'applications', label: 'Applications' },
{ key: 'routes', label: 'Routes' },
];
export function ScopeTabs({ executionCount, applicationCount, routeCount }: ScopeTabsProps) {
const { scope, setScope } = useCommandPalette();
function getCount(key: PaletteScope): number {
if (key === 'all') return executionCount + applicationCount + routeCount;
if (key === 'executions') return executionCount;
if (key === 'applications') return applicationCount;
if (key === 'routes') return routeCount;
return 0;
}
return (
<div className={styles.scopeTabs}>
{SCOPES.map((s) => (
<button
key={s.key}
className={scope === s.key ? styles.scopeTabActive : styles.scopeTab}
onClick={() => setScope(s.key)}
>
{s.label}
<span className={styles.scopeCount}>{getCount(s.key)}</span>
</button>
))}
</div>
);
}

View File

@@ -1,57 +0,0 @@
import { create } from 'zustand';
export type PaletteScope = 'all' | 'executions' | 'applications' | 'routes';
export interface PaletteFilter {
key: 'status' | 'route' | 'agent' | 'processor';
value: string;
}
interface CommandPaletteState {
isOpen: boolean;
query: string;
scope: PaletteScope;
filters: PaletteFilter[];
selectedIndex: number;
open: () => void;
close: () => void;
setQuery: (q: string) => void;
setScope: (s: PaletteScope) => void;
addFilter: (f: PaletteFilter) => void;
removeLastFilter: () => void;
removeFilter: (index: number) => void;
setSelectedIndex: (i: number) => void;
reset: () => void;
}
export const useCommandPalette = create<CommandPaletteState>((set) => ({
isOpen: false,
query: '',
scope: 'all',
filters: [],
selectedIndex: 0,
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false, selectedIndex: 0 }),
setQuery: (q) => set({ query: q, selectedIndex: 0 }),
setScope: (s) => set({ scope: s, selectedIndex: 0 }),
addFilter: (f) =>
set((state) => ({
filters: [...state.filters.filter((x) => x.key !== f.key), f],
query: '',
selectedIndex: 0,
})),
removeLastFilter: () =>
set((state) => ({
filters: state.filters.slice(0, -1),
selectedIndex: 0,
})),
removeFilter: (index) =>
set((state) => ({
filters: state.filters.filter((_, i) => i !== index),
selectedIndex: 0,
})),
setSelectedIndex: (i) => set({ selectedIndex: i }),
reset: () => set({ query: '', scope: 'all', filters: [], selectedIndex: 0 }),
}));

View File

@@ -1,134 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../../api/client';
import type { ExecutionSummary, AgentInstance } from '../../api/types';
import { useCommandPalette, type PaletteScope } from './use-command-palette';
import { useDebouncedValue } from './utils';
export interface RouteInfo {
routeId: string;
agentIds: string[];
}
export interface PaletteResult {
type: 'execution' | 'application' | 'route';
id: string;
data: ExecutionSummary | AgentInstance | RouteInfo;
}
function isExecutionScope(scope: PaletteScope) {
return scope === 'all' || scope === 'executions';
}
function isApplicationScope(scope: PaletteScope) {
return scope === 'all' || scope === 'applications';
}
function isRouteScope(scope: PaletteScope) {
return scope === 'all' || scope === 'routes';
}
export function usePaletteSearch() {
const { query, scope, filters, isOpen } = useCommandPalette();
const debouncedQuery = useDebouncedValue(query, 300);
const statusFilter = filters.find((f) => f.key === 'status')?.value;
const routeFilter = filters.find((f) => f.key === 'route')?.value;
const agentFilter = filters.find((f) => f.key === 'agent')?.value;
const processorFilter = filters.find((f) => f.key === 'processor')?.value;
const executionsQuery = useQuery({
queryKey: ['palette', 'executions', debouncedQuery, statusFilter, routeFilter, agentFilter, processorFilter],
queryFn: async () => {
const { data, error } = await api.POST('/search/executions', {
body: {
text: debouncedQuery || undefined,
status: statusFilter || undefined,
routeId: routeFilter || undefined,
agentId: agentFilter || undefined,
processorType: processorFilter || undefined,
limit: 10,
offset: 0,
},
});
if (error) throw new Error('Search failed');
return data!;
},
enabled: isOpen && isExecutionScope(scope),
placeholderData: (prev) => prev,
});
const agentsQuery = useQuery({
queryKey: ['agents'],
queryFn: async () => {
const { data, error } = await api.GET('/agents', {
params: { query: {} },
});
if (error) throw new Error('Failed to load agents');
return data!;
},
enabled: isOpen && (isApplicationScope(scope) || isRouteScope(scope)),
staleTime: 30_000,
});
const executionResults: PaletteResult[] = (executionsQuery.data?.data ?? []).map((e) => ({
type: 'execution' as const,
id: e.executionId,
data: e,
}));
const filteredAgents = (agentsQuery.data ?? []).filter((a) => {
if (!debouncedQuery) return true;
const q = debouncedQuery.toLowerCase();
return a.id.toLowerCase().includes(q) || a.group.toLowerCase().includes(q);
});
const applicationResults: PaletteResult[] = filteredAgents.slice(0, 10).map((a) => ({
type: 'application' as const,
id: a.id,
data: a,
}));
// Derive unique routes from all agents
const routeMap = new Map<string, string[]>();
for (const agent of agentsQuery.data ?? []) {
for (const routeId of agent.routeIds ?? []) {
const existing = routeMap.get(routeId);
if (existing) {
if (!existing.includes(agent.id)) existing.push(agent.id);
} else {
routeMap.set(routeId, [agent.id]);
}
}
}
const allRoutes: RouteInfo[] = Array.from(routeMap.entries()).map(([routeId, agentIds]) => ({
routeId,
agentIds,
}));
const filteredRoutes = allRoutes.filter((r) => {
if (!debouncedQuery) return true;
const q = debouncedQuery.toLowerCase();
return r.routeId.toLowerCase().includes(q) || r.agentIds.some((a) => a.toLowerCase().includes(q));
});
const routeResults: PaletteResult[] = filteredRoutes.slice(0, 10).map((r) => ({
type: 'route' as const,
id: r.routeId,
data: r,
}));
let results: PaletteResult[] = [];
if (scope === 'all') results = [...executionResults, ...applicationResults, ...routeResults];
else if (scope === 'executions') results = executionResults;
else if (scope === 'applications') results = applicationResults;
else if (scope === 'routes') results = routeResults;
return {
results,
executionCount: executionsQuery.data?.total ?? 0,
applicationCount: filteredAgents.length,
routeCount: filteredRoutes.length,
isLoading: executionsQuery.isFetching || agentsQuery.isFetching,
};
}

View File

@@ -1,91 +0,0 @@
import { useState, useEffect } from 'react';
import type { PaletteFilter } from './use-command-palette';
const FILTER_PREFIXES = ['status:', 'route:', 'agent:', 'processor:'] as const;
type FilterKey = PaletteFilter['key'];
const PREFIX_TO_KEY: Record<string, FilterKey> = {
'status:': 'status',
'route:': 'route',
'agent:': 'agent',
'processor:': 'processor',
};
export function parseFilterPrefix(
input: string,
): { filter: PaletteFilter; remaining: string } | null {
for (const prefix of FILTER_PREFIXES) {
if (input.startsWith(prefix)) {
const value = input.slice(prefix.length).trim();
if (value && value.includes(' ')) {
const spaceIdx = value.indexOf(' ');
return {
filter: { key: PREFIX_TO_KEY[prefix], value: value.slice(0, spaceIdx) },
remaining: value.slice(spaceIdx + 1).trim(),
};
}
}
}
return null;
}
export function checkTrailingFilter(input: string): PaletteFilter | null {
for (const prefix of FILTER_PREFIXES) {
if (input.endsWith(' ') && input.trimEnd().length > prefix.length) {
const trimmed = input.trimEnd();
for (const p of FILTER_PREFIXES) {
const idx = trimmed.lastIndexOf(p);
if (idx !== -1 && idx === trimmed.length - p.length - (trimmed.length - trimmed.lastIndexOf(p) - p.length)) {
// This is getting complex, let's use a simpler approach
}
}
}
}
// Simple approach: check if last word matches prefix:value pattern
const words = input.trimEnd().split(/\s+/);
const lastWord = words[words.length - 1];
for (const prefix of FILTER_PREFIXES) {
if (lastWord.startsWith(prefix) && lastWord.length > prefix.length && input.endsWith(' ')) {
return {
key: PREFIX_TO_KEY[prefix],
value: lastWord.slice(prefix.length),
};
}
}
return null;
}
export function highlightMatch(text: string, query: string): (string | { highlight: string })[] {
if (!query) return [text];
const lower = text.toLowerCase();
const qLower = query.toLowerCase();
const idx = lower.indexOf(qLower);
if (idx === -1) return [text];
return [
text.slice(0, idx),
{ highlight: text.slice(idx, idx + query.length) },
text.slice(idx + query.length),
].filter((s) => (typeof s === 'string' ? s.length > 0 : true));
}
export function useDebouncedValue<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
export function formatRelativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const seconds = Math.floor(diff / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}

View File

@@ -1,12 +0,0 @@
.layout {
display: flex;
position: relative;
z-index: 1;
}
.main {
flex: 1;
min-width: 0;
padding: 24px;
min-height: calc(100vh - 56px);
}

View File

@@ -1,48 +0,0 @@
import { useState, useEffect } from 'react';
import { Outlet } from 'react-router';
import { TopNav } from './TopNav';
import { AppSidebar } from './AppSidebar';
import { CommandPalette } from '../command-palette/CommandPalette';
import styles from './AppShell.module.css';
const COLLAPSED_KEY = 'cameleer-sidebar-collapsed';
export function AppShell() {
const [collapsed, setCollapsed] = useState(() => {
try { return localStorage.getItem(COLLAPSED_KEY) === 'true'; }
catch { return false; }
});
// Auto-collapse on small screens
useEffect(() => {
const mq = window.matchMedia('(max-width: 1024px)');
function handleChange(e: MediaQueryListEvent | MediaQueryList) {
if (e.matches) setCollapsed(true);
}
handleChange(mq);
mq.addEventListener('change', handleChange);
return () => mq.removeEventListener('change', handleChange);
}, []);
function toggleSidebar() {
setCollapsed((prev) => {
const next = !prev;
try { localStorage.setItem(COLLAPSED_KEY, String(next)); }
catch { /* ignore */ }
return next;
});
}
return (
<>
<TopNav onToggleSidebar={toggleSidebar} />
<div className={styles.layout}>
<AppSidebar collapsed={collapsed} />
<main className={styles.main}>
<Outlet />
</main>
</div>
<CommandPalette />
</>
);
}

View File

@@ -1,287 +0,0 @@
/* ─── Sidebar Container ─── */
.sidebar {
width: 240px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--bg-surface);
border-right: 1px solid var(--border-subtle);
height: calc(100vh - 56px);
position: sticky;
top: 56px;
overflow: hidden;
transition: width 0.2s ease;
}
.sidebarCollapsed {
width: 48px;
}
/* ─── Search ─── */
.search {
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
}
.sidebarCollapsed .search {
display: none;
}
.searchInput {
width: 100%;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-primary);
font-size: 12px;
font-family: var(--font-body);
outline: none;
transition: border-color 0.15s;
}
.searchInput::placeholder {
color: var(--text-muted);
}
.searchInput:focus {
border-color: var(--amber);
}
/* ─── App List ─── */
.appList {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
/* ─── Section Divider ─── */
.divider {
height: 1px;
background: var(--border-subtle);
margin: 4px 12px;
}
.sidebarCollapsed .divider {
margin: 4px 8px;
}
/* ─── App Item ─── */
.appItem {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 16px;
border: none;
background: none;
color: var(--text-secondary);
font-size: 13px;
font-family: var(--font-body);
cursor: pointer;
transition: all 0.1s;
text-align: left;
white-space: nowrap;
overflow: hidden;
}
.appItem:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.appItemActive {
background: var(--amber-glow);
color: var(--amber);
}
.sidebarCollapsed .appItem {
padding: 8px 0;
justify-content: center;
gap: 0;
}
/* ─── Health Dot ─── */
.healthDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.dotLive { background: var(--green); }
.dotStale { background: var(--amber); }
.dotDead { background: var(--text-muted); }
/* ─── App Info (hidden when collapsed) ─── */
.appInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.sidebarCollapsed .appInfo {
display: none;
}
.appName {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
}
.appMeta {
font-size: 11px;
color: var(--text-muted);
}
/* ─── All Item icon ─── */
.allIcon {
width: 8px;
height: 8px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
line-height: 1;
}
.appItemActive .allIcon {
color: var(--amber);
}
/* ─── Bottom Section ─── */
.bottom {
border-top: 1px solid var(--border-subtle);
padding: 8px 0;
}
.bottomItem {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 16px;
border: none;
background: none;
color: var(--text-muted);
font-size: 12px;
font-family: var(--font-body);
cursor: pointer;
transition: all 0.1s;
text-decoration: none;
white-space: nowrap;
}
.bottomItem:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.bottomItemActive {
color: var(--amber);
background: var(--amber-glow);
}
.sidebarCollapsed .bottomItem {
padding: 8px 0;
justify-content: center;
gap: 0;
}
.bottomLabel {
overflow: hidden;
text-overflow: ellipsis;
}
.sidebarCollapsed .bottomLabel {
display: none;
}
.bottomIcon {
font-size: 14px;
flex-shrink: 0;
width: 16px;
text-align: center;
}
/* ─── Admin Sub-Menu ─── */
.adminChevron {
margin-left: 6px;
font-size: 8px;
color: var(--text-muted);
}
.adminSubMenu {
display: flex;
flex-direction: column;
}
.adminSubItem {
display: block;
padding: 6px 16px 6px 42px;
font-size: 12px;
color: var(--text-muted);
text-decoration: none;
transition: all 0.1s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.adminSubItem:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.adminSubItemActive {
color: var(--amber);
background: var(--amber-glow);
}
.sidebarCollapsed .adminSubMenu {
display: none;
}
/* ─── Responsive ─── */
@media (max-width: 1024px) {
.sidebar {
width: 48px;
}
.sidebar .search {
display: none;
}
.sidebar .appInfo {
display: none;
}
.sidebar .appItem {
padding: 8px 0;
justify-content: center;
gap: 0;
}
.sidebar .divider {
margin: 4px 8px;
}
.sidebar .bottomItem {
padding: 8px 0;
justify-content: center;
gap: 0;
}
.sidebar .bottomLabel {
display: none;
}
.sidebar .adminSubMenu {
display: none;
}
}

View File

@@ -1,185 +0,0 @@
import { useMemo, useState } from 'react';
import { NavLink, useParams, useLocation } from 'react-router';
import { useAgents } from '../../api/queries/agents';
import { useAuthStore } from '../../auth/auth-store';
import type { AgentInstance } from '../../api/types';
import styles from './AppSidebar.module.css';
interface GroupInfo {
group: string;
agents: AgentInstance[];
liveCount: number;
staleCount: number;
deadCount: number;
}
function healthStatus(g: GroupInfo): 'live' | 'stale' | 'dead' {
if (g.liveCount > 0) return 'live';
if (g.staleCount > 0) return 'stale';
return 'dead';
}
interface AppSidebarProps {
collapsed: boolean;
}
export function AppSidebar({ collapsed }: AppSidebarProps) {
const { group: activeGroup } = useParams<{ group: string }>();
const { data: agents } = useAgents();
const { roles } = useAuthStore();
const [filter, setFilter] = useState('');
const groups = useMemo(() => {
if (!agents) return [];
const map = new Map<string, GroupInfo>();
for (const agent of agents) {
const key = agent.group ?? 'default';
let entry = map.get(key);
if (!entry) {
entry = { group: key, agents: [], liveCount: 0, staleCount: 0, deadCount: 0 };
map.set(key, entry);
}
entry.agents.push(agent);
if (agent.status === 'LIVE') entry.liveCount++;
else if (agent.status === 'STALE') entry.staleCount++;
else entry.deadCount++;
}
return Array.from(map.values()).sort((a, b) => a.group.localeCompare(b.group));
}, [agents]);
const filtered = useMemo(() => {
if (!filter) return groups;
const lower = filter.toLowerCase();
return groups.filter((g) => g.group.toLowerCase().includes(lower));
}, [groups, filter]);
const sidebarClass = `${styles.sidebar} ${collapsed ? styles.sidebarCollapsed : ''}`;
return (
<aside className={sidebarClass}>
{/* Search */}
<div className={styles.search}>
<input
className={styles.searchInput}
type="text"
placeholder="Filter apps..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
{/* App List */}
<div className={styles.appList}>
{/* All (unscoped) */}
<NavLink
to="/executions"
className={({ isActive }) =>
`${styles.appItem} ${isActive && !activeGroup ? styles.appItemActive : ''}`
}
title="All Applications"
>
<span className={styles.allIcon}>*</span>
<span className={styles.appInfo}>
<span className={styles.appName}>All</span>
</span>
</NavLink>
<div className={styles.divider} />
{/* App entries */}
{filtered.map((g) => {
const status = healthStatus(g);
const isActive = activeGroup === g.group;
return (
<NavLink
key={g.group}
to={`/apps/${encodeURIComponent(g.group)}`}
className={`${styles.appItem} ${isActive ? styles.appItemActive : ''}`}
title={g.group}
>
<span className={`${styles.healthDot} ${styles[`dot${status.charAt(0).toUpperCase()}${status.slice(1)}`]}`} />
<span className={styles.appInfo}>
<span className={styles.appName}>{g.group}</span>
<span className={styles.appMeta}>
{g.agents.length} agent{g.agents.length !== 1 ? 's' : ''}
</span>
</span>
</NavLink>
);
})}
</div>
{/* Bottom: Admin */}
{roles.includes('ADMIN') && (
<div className={styles.bottom}>
<AdminSubMenu collapsed={collapsed} />
</div>
)}
</aside>
);
}
const ADMIN_LINKS = [
{ to: '/admin/database', label: 'Database' },
{ to: '/admin/opensearch', label: 'OpenSearch' },
{ to: '/admin/audit', label: 'Audit Log' },
{ to: '/admin/oidc', label: 'OIDC' },
{ to: '/admin/rbac', label: 'User Management' },
];
function AdminSubMenu({ collapsed: sidebarCollapsed }: { collapsed: boolean }) {
const location = useLocation();
const isAdminActive = location.pathname.startsWith('/admin');
const [open, setOpen] = useState(() => {
try {
return localStorage.getItem('cameleer-admin-sidebar-open') === 'true';
} catch {
return false;
}
});
function toggle() {
const next = !open;
setOpen(next);
try {
localStorage.setItem('cameleer-admin-sidebar-open', String(next));
} catch { /* ignore */ }
}
return (
<>
<button
type="button"
className={`${styles.bottomItem} ${isAdminActive ? styles.bottomItemActive : ''}`}
onClick={toggle}
title="Admin"
>
<span className={styles.bottomIcon}>&#9881;</span>
<span className={styles.bottomLabel}>
Admin
{!sidebarCollapsed && (
<span className={styles.adminChevron}>
{open ? '\u25BC' : '\u25B6'}
</span>
)}
</span>
</button>
{open && !sidebarCollapsed && (
<div className={styles.adminSubMenu}>
{ADMIN_LINKS.map((link) => (
<NavLink
key={link.to}
to={link.to}
className={({ isActive }) =>
`${styles.adminSubItem} ${isActive ? styles.adminSubItemActive : ''}`
}
>
{link.label}
</NavLink>
))}
</div>
)}
</>
);
}

View File

@@ -1,185 +0,0 @@
.topnav {
position: sticky;
top: 0;
z-index: 100;
background: var(--topnav-bg);
backdrop-filter: blur(20px) saturate(1.2);
border-bottom: 1px solid var(--border-subtle);
padding: 0 16px;
display: flex;
align-items: center;
height: 56px;
gap: 16px;
}
.hamburger {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: none;
color: var(--text-muted);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
}
.hamburger:hover {
color: var(--text-primary);
border-color: var(--text-muted);
background: var(--bg-raised);
}
.logo {
font-family: var(--font-mono);
font-weight: 600;
font-size: 16px;
color: var(--amber);
letter-spacing: -0.5px;
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
text-decoration: none;
}
.logo:hover { color: var(--amber); }
/* ─── Search Bar ─── */
.searchBar {
flex: 1;
max-width: 480px;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
cursor: pointer;
transition: all 0.15s;
}
.searchBar:hover {
border-color: var(--text-muted);
}
.searchIcon {
color: var(--text-muted);
flex-shrink: 0;
}
.searchPlaceholder {
flex: 1;
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.searchKbd {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 3px;
padding: 1px 5px;
flex-shrink: 0;
}
/* ─── Right Section ─── */
.navRight {
margin-left: auto;
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.utilLink {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-decoration: none;
padding: 4px 10px;
border-radius: 99px;
border: 1px solid var(--border);
transition: all 0.15s;
}
.utilLink:hover {
color: var(--text-primary);
border-color: var(--text-muted);
}
.utilLinkActive {
composes: utilLink;
color: var(--amber);
border-color: rgba(245, 158, 11, 0.3);
background: var(--amber-glow);
}
.envBadge {
font-family: var(--font-mono);
font-size: 11px;
padding: 4px 10px;
border-radius: 99px;
background: var(--green-glow);
color: var(--green);
border: 1px solid rgba(16, 185, 129, 0.2);
font-weight: 500;
}
.themeToggle {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 8px;
cursor: pointer;
color: var(--text-muted);
font-size: 16px;
display: flex;
align-items: center;
transition: all 0.15s;
}
.themeToggle:hover {
border-color: var(--text-muted);
color: var(--text-primary);
}
.userInfo {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
display: flex;
align-items: center;
gap: 8px;
}
.logoutBtn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 12px;
padding: 4px;
transition: color 0.15s;
}
.logoutBtn:hover {
color: var(--rose);
}
/* ─── Responsive ─── */
@media (max-width: 768px) {
.searchBar {
display: none;
}
}

View File

@@ -1,61 +0,0 @@
import { NavLink } from 'react-router';
import { useThemeStore } from '../../theme/theme-store';
import { useAuthStore } from '../../auth/auth-store';
import { useCommandPalette } from '../command-palette/use-command-palette';
import styles from './TopNav.module.css';
interface TopNavProps {
onToggleSidebar: () => void;
}
export function TopNav({ onToggleSidebar }: TopNavProps) {
const { theme, toggle } = useThemeStore();
const { username, logout } = useAuthStore();
const openPalette = useCommandPalette((s) => s.open);
return (
<nav className={styles.topnav}>
<button className={styles.hamburger} onClick={onToggleSidebar} title="Toggle sidebar">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M3 12h18M3 6h18M3 18h18" />
</svg>
</button>
<NavLink to="/" className={styles.logo}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2" />
<path d="M12 6v6l4 2" />
</svg>
cameleer3
</NavLink>
{/* Visible search bar */}
<div className={styles.searchBar} onClick={openPalette} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') openPalette(); }}>
<svg className={styles.searchIcon} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<span className={styles.searchPlaceholder}>Search executions, orders...</span>
<kbd className={styles.searchKbd}>&#8984;K</kbd>
</div>
<div className={styles.navRight}>
<NavLink to="/swagger" className={({ isActive }) => isActive ? styles.utilLinkActive : styles.utilLink} title="API Documentation">
API
</NavLink>
<span className={styles.envBadge}>{import.meta.env.VITE_ENV_NAME || 'DEV'}</span>
<button className={styles.themeToggle} onClick={toggle} title="Toggle theme">
{theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19'}
</button>
{username && (
<span className={styles.userInfo}>
{username}
<button className={styles.logoutBtn} onClick={logout} title="Sign out">
&#x2715;
</button>
</span>
)}
</div>
</nav>
);
}

View File

@@ -1,20 +0,0 @@
import styles from './shared.module.css';
const COLORS = ['#3b82f6', '#f0b429', '#10b981', '#a855f7', '#f43f5e', '#22d3ee', '#ec4899'];
function hashColor(name: string) {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return COLORS[Math.abs(hash) % COLORS.length];
}
export function AppBadge({ name }: { name: string }) {
return (
<span className={styles.appBadge}>
<span className={styles.appDot} style={{ background: hashColor(name) }} />
{name}
</span>
);
}

View File

@@ -1,30 +0,0 @@
import styles from './shared.module.css';
function durationClass(ms: number) {
if (ms < 100) return styles.barFast;
if (ms < 1000) return styles.barMedium;
return styles.barSlow;
}
function durationColor(ms: number) {
if (ms < 100) return 'var(--green)';
if (ms < 1000) return 'var(--amber)';
return 'var(--rose)';
}
export function DurationBar({ duration }: { duration: number }) {
const widthPct = Math.min(100, (duration / 5000) * 100);
return (
<div className={styles.durationBar}>
<span className="mono" style={{ color: durationColor(duration) }}>
{duration.toLocaleString()}ms
</span>
<div className={styles.bar}>
<div
className={`${styles.barFill} ${durationClass(duration)}`}
style={{ width: `${widthPct}%` }}
/>
</div>
</div>
);
}

View File

@@ -1,23 +0,0 @@
import styles from './shared.module.css';
interface FilterChipProps {
label: string;
active: boolean;
accent?: 'green' | 'rose' | 'blue';
count?: number;
onClick: () => void;
}
export function FilterChip({ label, active, accent, count, onClick }: FilterChipProps) {
const accentClass = accent ? styles[`chip${accent.charAt(0).toUpperCase()}${accent.slice(1)}`] : '';
return (
<span
className={`${styles.chip} ${active ? styles.chipActive : ''} ${accentClass}`}
onClick={onClick}
>
{accent && <span className={styles.chipDot} />}
{label}
{count !== undefined && <span className={styles.chipCount}>{count.toLocaleString()}</span>}
</span>
);
}

View File

@@ -1,60 +0,0 @@
import styles from './shared.module.css';
interface PaginationProps {
total: number;
offset: number;
limit: number;
onChange: (offset: number) => void;
}
export function Pagination({ total, offset, limit, onChange }: PaginationProps) {
const currentPage = Math.floor(offset / limit) + 1;
const totalPages = Math.max(1, Math.ceil(total / limit));
if (totalPages <= 1) return null;
const pages: (number | '...')[] = [];
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
if (currentPage > 3) pages.push('...');
for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) {
pages.push(i);
}
if (currentPage < totalPages - 2) pages.push('...');
pages.push(totalPages);
}
return (
<div className={styles.pagination}>
<button
className={`${styles.pageBtn} ${currentPage === 1 ? styles.pageBtnDisabled : ''}`}
onClick={() => currentPage > 1 && onChange((currentPage - 2) * limit)}
disabled={currentPage === 1}
>
&#8249;
</button>
{pages.map((p, i) =>
p === '...' ? (
<span key={`e${i}`} className={styles.pageEllipsis}>&hellip;</span>
) : (
<button
key={p}
className={`${styles.pageBtn} ${p === currentPage ? styles.pageBtnActive : ''}`}
onClick={() => onChange((p - 1) * limit)}
>
{p}
</button>
),
)}
<button
className={`${styles.pageBtn} ${currentPage === totalPages ? styles.pageBtnDisabled : ''}`}
onClick={() => currentPage < totalPages && onChange(currentPage * limit)}
disabled={currentPage === totalPages}
>
&#8250;
</button>
</div>
);
}

View File

@@ -1,73 +0,0 @@
import { useCallback, useRef, useEffect } from 'react';
interface ResizableDividerProps {
/** Current panel width in pixels */
panelWidth: number;
/** Called with new width */
onResize: (width: number) => void;
/** Min panel width */
minWidth?: number;
/** Max panel width */
maxWidth?: number;
}
export function ResizableDivider({
panelWidth,
onResize,
minWidth = 200,
maxWidth = 600,
}: ResizableDividerProps) {
const dragging = useRef(false);
const startX = useRef(0);
const startWidth = useRef(0);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragging.current = true;
startX.current = e.clientX;
startWidth.current = panelWidth;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [panelWidth]);
useEffect(() => {
function handleMouseMove(e: MouseEvent) {
if (!dragging.current) return;
// Dragging left increases panel width (panel is on the right)
const delta = startX.current - e.clientX;
const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidth.current + delta));
onResize(newWidth);
}
function handleMouseUp() {
if (!dragging.current) return;
dragging.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [onResize, minWidth, maxWidth]);
return (
<div
onMouseDown={handleMouseDown}
style={{
width: 6,
cursor: 'col-resize',
background: 'var(--border-subtle)',
flexShrink: 0,
position: 'relative',
zIndex: 5,
transition: 'background 0.15s',
}}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = 'var(--amber)'; }}
onMouseLeave={(e) => { if (!dragging.current) (e.currentTarget as HTMLElement).style.background = 'var(--border-subtle)'; }}
/>
);
}

View File

@@ -1,34 +0,0 @@
import styles from './shared.module.css';
import { MiniChart } from '../charts/MiniChart';
const ACCENT_COLORS: Record<string, string> = {
amber: 'var(--amber)',
cyan: 'var(--cyan)',
rose: 'var(--rose)',
green: 'var(--green)',
blue: 'var(--blue)',
};
interface StatCardProps {
label: string;
value: string;
accent: 'amber' | 'cyan' | 'rose' | 'green' | 'blue';
change?: string;
changeDirection?: 'up' | 'down' | 'neutral';
sparkData?: number[];
}
export function StatCard({ label, value, accent, change, changeDirection = 'neutral', sparkData }: StatCardProps) {
return (
<div className={`${styles.statCard} ${styles[accent]}`}>
<div className={styles.statLabel}>{label}</div>
<div className={styles.statValue}>{value}</div>
{change && (
<div className={`${styles.statChange} ${styles[changeDirection]}`}>{change}</div>
)}
{sparkData && sparkData.length >= 2 && (
<MiniChart data={sparkData} color={ACCENT_COLORS[accent] ?? ACCENT_COLORS.amber} />
)}
</div>
);
}

View File

@@ -1,17 +0,0 @@
import styles from './shared.module.css';
const STATUS_MAP = {
COMPLETED: { className: styles.pillCompleted, label: 'Completed' },
FAILED: { className: styles.pillFailed, label: 'Failed' },
RUNNING: { className: styles.pillRunning, label: 'Running' },
} as const;
export function StatusPill({ status }: { status: string }) {
const info = STATUS_MAP[status as keyof typeof STATUS_MAP] ?? STATUS_MAP.COMPLETED;
return (
<span className={`${styles.statusPill} ${info.className}`}>
<span className={styles.statusDot} />
{info.label}
</span>
);
}

View File

@@ -1,201 +0,0 @@
/* ─── Status Pill ─── */
.statusPill {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 99px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
}
.statusDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.pillCompleted { background: var(--green-glow); color: var(--green); }
.pillFailed { background: var(--rose-glow); color: var(--rose); }
.pillRunning { background: rgba(59, 130, 246, 0.12); color: var(--blue); }
.pillRunning .statusDot { animation: livePulse 1.5s ease-in-out infinite; }
/* ─── Duration Bar ─── */
.durationBar {
display: flex;
align-items: center;
gap: 8px;
}
.bar {
width: 60px;
height: 4px;
background: var(--bg-base);
border-radius: 2px;
overflow: hidden;
}
.barFill {
height: 100%;
border-radius: 2px;
transition: width 0.3s;
}
.barFast { background: var(--green); }
.barMedium { background: var(--amber); }
.barSlow { background: var(--rose); }
/* ─── Stat Card ─── */
.statCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 16px 20px;
position: relative;
overflow: hidden;
transition: border-color 0.2s;
}
.statCard:hover { border-color: var(--border); }
.statCard::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
}
.amber::before { background: linear-gradient(90deg, var(--amber), transparent); }
.cyan::before { background: linear-gradient(90deg, var(--cyan), transparent); }
.rose::before { background: linear-gradient(90deg, var(--rose), transparent); }
.green::before { background: linear-gradient(90deg, var(--green), transparent); }
.blue::before { background: linear-gradient(90deg, var(--blue), transparent); }
.statLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 8px;
}
.statValue {
font-family: var(--font-mono);
font-size: 26px;
font-weight: 600;
letter-spacing: -1px;
}
.amber .statValue { color: var(--amber); }
.cyan .statValue { color: var(--cyan); }
.rose .statValue { color: var(--rose); }
.green .statValue { color: var(--green); }
.blue .statValue { color: var(--blue); }
.statChange {
font-size: 11px;
font-family: var(--font-mono);
margin-top: 4px;
}
.up { color: var(--rose); }
.down { color: var(--green); }
.neutral { color: var(--text-muted); }
/* ─── App Badge ─── */
.appBadge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 8px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-secondary);
}
.appDot {
width: 6px;
height: 6px;
border-radius: 50%;
}
/* ─── Filter Chip ─── */
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 5px 12px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 99px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
user-select: none;
}
.chip:hover { border-color: var(--text-muted); color: var(--text-primary); }
.chipActive { background: var(--amber-glow); border-color: var(--amber-dim); color: var(--amber); }
.chipActive.chipGreen { background: var(--green-glow); border-color: rgba(16, 185, 129, 0.3); color: var(--green); }
.chipActive.chipRose { background: var(--rose-glow); border-color: rgba(244, 63, 94, 0.3); color: var(--rose); }
.chipActive.chipBlue { background: rgba(59, 130, 246, 0.12); border-color: rgba(59, 130, 246, 0.3); color: var(--blue); }
.chipDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
display: inline-block;
}
.chipCount {
font-family: var(--font-mono);
font-size: 10px;
opacity: 0.7;
}
/* ─── Pagination ─── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin-top: 20px;
}
.pageBtn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.pageBtn:hover:not(:disabled) { border-color: var(--border); background: var(--bg-raised); }
.pageBtnActive { background: var(--amber-glow); border-color: var(--amber-dim); color: var(--amber); }
.pageBtnDisabled { opacity: 0.3; cursor: default; }
.pageEllipsis {
color: var(--text-muted);
padding: 0 4px;
font-family: var(--font-mono);
}

View File

@@ -1,135 +0,0 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import type { ExecutionDetail, ProcessorNode } from '../api/types';
export interface IterationData {
count: number;
current: number;
}
export interface OverlayState {
isActive: boolean;
toggle: () => void;
executedNodes: Set<string>;
executedEdges: Set<string>;
durations: Map<string, number>;
sequences: Map<string, number>;
statuses: Map<string, string>;
iterationData: Map<string, IterationData>;
selectedNodeId: string | null;
selectNode: (nodeId: string | null) => void;
setIteration: (nodeId: string, iteration: number) => void;
}
/** Walk the processor tree and collect execution data keyed by diagramNodeId */
function collectProcessorData(
processors: ProcessorNode[],
executedNodes: Set<string>,
durations: Map<string, number>,
sequences: Map<string, number>,
statuses: Map<string, string>,
counter: { seq: number },
) {
for (const proc of processors) {
const nodeId = proc.diagramNodeId;
if (nodeId) {
executedNodes.add(nodeId);
durations.set(nodeId, proc.durationMs ?? 0);
sequences.set(nodeId, ++counter.seq);
if (proc.status) statuses.set(nodeId, proc.status);
}
if (proc.children && proc.children.length > 0) {
collectProcessorData(proc.children, executedNodes, durations, sequences, statuses, counter);
}
}
}
/** Determine which edges are executed (both source and target are executed) */
function computeExecutedEdges(
executedNodes: Set<string>,
edges: Array<{ sourceId?: string; targetId?: string }>,
): Set<string> {
const result = new Set<string>();
for (const edge of edges) {
if (edge.sourceId && edge.targetId
&& executedNodes.has(edge.sourceId) && executedNodes.has(edge.targetId)) {
result.add(`${edge.sourceId}->${edge.targetId}`);
}
}
return result;
}
export function useExecutionOverlay(
execution: ExecutionDetail | null | undefined,
edges: Array<{ sourceId?: string; targetId?: string }> = [],
): OverlayState {
const [isActive, setIsActive] = useState(!!execution);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [iterations, setIterations] = useState<Map<string, number>>(new Map());
// Activate overlay when an execution is loaded
useEffect(() => {
if (execution) setIsActive(true);
}, [execution]);
const { executedNodes, durations, sequences, statuses, iterationData } = useMemo(() => {
const en = new Set<string>();
const dur = new Map<string, number>();
const seq = new Map<string, number>();
const st = new Map<string, string>();
const iter = new Map<string, IterationData>();
if (!execution?.processors) {
return { executedNodes: en, durations: dur, sequences: seq, statuses: st, iterationData: iter };
}
collectProcessorData(execution.processors, en, dur, seq, st, { seq: 0 });
return { executedNodes: en, durations: dur, sequences: seq, statuses: st, iterationData: iter };
}, [execution]);
const executedEdges = useMemo(
() => computeExecutedEdges(executedNodes, edges),
[executedNodes, edges],
);
const toggle = useCallback(() => setIsActive((v) => !v), []);
const selectNode = useCallback((nodeId: string | null) => setSelectedNodeId(nodeId), []);
const setIteration = useCallback((nodeId: string, iteration: number) => {
setIterations((prev) => {
const next = new Map(prev);
next.set(nodeId, iteration);
return next;
});
}, []);
// Keyboard shortcut: E to toggle overlay
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === 'e' || e.key === 'E') {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
e.preventDefault();
setIsActive((v) => !v);
}
}
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, []);
return {
isActive,
toggle,
executedNodes,
executedEdges,
durations,
sequences,
statuses,
iterationData: new Map([...iterationData].map(([k, v]) => {
const current = iterations.get(k) ?? v.current;
return [k, { ...v, current }];
})),
selectedNodeId,
selectNode,
setIteration,
};
}

18
ui/src/index.css Normal file
View File

@@ -0,0 +1,18 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:wght@400;500;600&display=swap');
:root {
font-family: 'DM Sans', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
}
html, body, #root {
height: 100%;
}

View File

@@ -1,11 +1,11 @@
import '@cameleer/design-system/style.css';
import './index.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from './theme/ThemeProvider';
import { ThemeProvider } from '@cameleer/design-system';
import { router } from './router';
import './theme/fonts.css';
import './theme/tokens.css';
const queryClient = new QueryClient({
defaultOptions: {

View File

@@ -0,0 +1,93 @@
import { useMemo } from 'react';
import { useParams, useNavigate } from 'react-router';
import {
StatCard, StatusDot, Badge, MonoText,
GroupCard, EventFeed,
} from '@cameleer/design-system';
import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useRouteCatalog } from '../../api/queries/catalog';
export default function AgentHealth() {
const { appId } = useParams();
const navigate = useNavigate();
const { data: agents } = useAgents(undefined, appId);
const { data: catalog } = useRouteCatalog();
const { data: events } = useAgentEvents(appId);
const agentsByApp = useMemo(() => {
const map: Record<string, any[]> = {};
(agents || []).forEach((a: any) => {
const g = a.group;
if (!map[g]) map[g] = [];
map[g].push(a);
});
return map;
}, [agents]);
const totalAgents = agents?.length ?? 0;
const liveCount = (agents || []).filter((a: any) => a.status === 'LIVE').length;
const staleCount = (agents || []).filter((a: any) => a.status === 'STALE').length;
const deadCount = (agents || []).filter((a: any) => a.status === 'DEAD').length;
const feedEvents = useMemo(() =>
(events || []).map((e: any) => ({
id: String(e.id),
severity: e.eventType === 'WENT_DEAD' ? 'error' as const
: e.eventType === 'WENT_STALE' ? 'warning' as const
: e.eventType === 'RECOVERED' ? 'success' as const
: 'running' as const,
message: `${e.agentId}: ${e.eventType}${e.detail ? ' — ' + e.detail : ''}`,
timestamp: new Date(e.timestamp),
})),
[events],
);
const apps = appId ? { [appId]: agentsByApp[appId] || [] } : agentsByApp;
return (
<div>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<StatCard label="Total Agents" value={totalAgents} />
<StatCard label="Live" value={liveCount} accent="success" />
<StatCard label="Stale" value={staleCount} accent="warning" />
<StatCard label="Dead" value={deadCount} accent="error" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(400px, 1fr))', gap: '1rem', marginBottom: '1.5rem' }}>
{Object.entries(apps).map(([group, groupAgents]) => (
<GroupCard
key={group}
title={group}
headerRight={<Badge label={`${groupAgents?.length ?? 0} instances`} />}
accent={
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
: 'success'
}
onClick={() => navigate(`/agents/${group}`)}
>
{(groupAgents || []).map((agent: any) => (
<div
key={agent.id}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', padding: '0.5rem 0', cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); navigate(`/agents/${group}/${agent.id}`); }}
>
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
<MonoText size="sm">{agent.name}</MonoText>
<Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} />
{agent.tps > 0 && <span style={{ marginLeft: 'auto', fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>{agent.tps.toFixed(1)} tps</span>}
</div>
))}
</GroupCard>
))}
</div>
{feedEvents.length > 0 && (
<div>
<h3 style={{ marginBottom: '0.75rem' }}>Event Log</h3>
<EventFeed events={feedEvents} maxItems={100} />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { useMemo } from 'react';
import { useParams } from 'react-router';
import {
StatCard, StatusDot, Badge, MonoText, Card,
LineChart, AreaChart, EventFeed, Breadcrumb, Spinner,
SectionHeader, CodeBlock,
} from '@cameleer/design-system';
import { useAgents, useAgentEvents } from '../../api/queries/agents';
import { useStatsTimeseries } from '../../api/queries/executions';
import { useGlobalFilters } from '@cameleer/design-system';
export default function AgentInstance() {
const { appId, instanceId } = useParams();
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const { data: agents, isLoading } = useAgents(undefined, appId);
const { data: events } = useAgentEvents(appId, instanceId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
const agent = useMemo(() =>
(agents || []).find((a: any) => a.id === instanceId),
[agents, instanceId],
);
const chartData = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => ({
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
throughput: b.totalCount,
latency: b.avgDurationMs,
errors: b.failedCount,
})),
[timeseries],
);
const feedEvents = useMemo(() =>
(events || []).filter((e: any) => !instanceId || e.agentId === instanceId).map((e: any) => ({
id: String(e.id),
severity: e.eventType === 'WENT_DEAD' ? 'error' as const
: e.eventType === 'WENT_STALE' ? 'warning' as const
: e.eventType === 'RECOVERED' ? 'success' as const
: 'running' as const,
message: `${e.eventType}${e.detail ? ' — ' + e.detail : ''}`,
timestamp: new Date(e.timestamp),
})),
[events, instanceId],
);
if (isLoading) return <Spinner size="lg" />;
return (
<div>
<Breadcrumb items={[
{ label: 'Agents', href: '/agents' },
{ label: appId || '', href: `/agents/${appId}` },
{ label: agent?.name || instanceId || '' },
]} />
{agent && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', margin: '1rem 0' }}>
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
<h2>{agent.name}</h2>
<Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} />
</div>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<StatCard label="TPS" value={agent.tps?.toFixed(1) ?? '0'} />
<StatCard label="Error Rate" value={agent.errorRate ? `${(agent.errorRate * 100).toFixed(1)}%` : '0%'} accent={agent.errorRate > 0.05 ? 'error' : undefined} />
<StatCard label="Active Routes" value={`${agent.activeRoutes ?? 0}/${agent.totalRoutes ?? 0}`} />
<StatCard label="Uptime" value={formatUptime(agent.uptimeSeconds ?? 0)} />
</div>
<SectionHeader>Routes</SectionHeader>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1.5rem' }}>
{(agent.routeIds || []).map((r: string) => (
<Badge key={r} label={r} color="auto" />
))}
</div>
</>
)}
{chartData.length > 0 && (
<>
<SectionHeader>Performance</SectionHeader>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginBottom: '1.5rem' }}>
<AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} height={200} />
<LineChart series={[{ label: 'Latency', data: chartData.map((d: any, i: number) => ({ x: i, y: d.latency })) }]} height={200} />
</div>
</>
)}
{feedEvents.length > 0 && (
<>
<SectionHeader>Events</SectionHeader>
<EventFeed events={feedEvents} maxItems={50} />
</>
)}
{agent && (
<>
<SectionHeader>Agent Info</SectionHeader>
<Card>
<div style={{ padding: '1rem' }}>
<CodeBlock content={JSON.stringify({
id: agent.id,
name: agent.name,
group: agent.group,
registeredAt: agent.registeredAt,
lastHeartbeat: agent.lastHeartbeat,
routeIds: agent.routeIds,
}, null, 2)} />
</div>
</Card>
</>
)}
</div>
);
}
function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
}

View File

@@ -0,0 +1,131 @@
import { useState, useMemo } from 'react';
import { useParams } from 'react-router';
import {
StatCard, StatusDot, Badge, MonoText, Sparkline,
DataTable, DetailPanel, ProcessorTimeline, RouteFlow,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
import { useGlobalFilters } from '@cameleer/design-system';
import type { ExecutionSummary } from '../../api/types';
interface Row extends ExecutionSummary { id: string }
export default function Dashboard() {
const { appId, routeId } = useParams();
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detailTab, setDetailTab] = useState('overview');
const [processorIdx, setProcessorIdx] = useState<number | null>(null);
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
const { data: searchResult } = useSearchExecutions({
timeFrom, timeTo,
routeId: routeId || undefined,
group: appId || undefined,
page: 0, size: 50,
}, true);
const { data: detail } = useExecutionDetail(selectedId);
const { data: snapshot } = useProcessorSnapshot(selectedId, processorIdx);
const rows: Row[] = useMemo(() =>
(searchResult?.items || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
[searchResult],
);
const sparklineData = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => b.totalCount as number),
[timeseries],
);
const columns: Column<Row>[] = [
{
key: 'status', header: 'Status', width: '80px',
render: (v) => <StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />,
},
{ key: 'routeId', header: 'Route', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
{ key: 'groupName', header: 'App', render: (v) => <Badge label={String(v)} color="auto" /> },
{ key: 'executionId', header: 'Exchange ID', render: (v) => <MonoText size="xs">{String(v).slice(0, 12)}</MonoText> },
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => new Date(v as string).toLocaleTimeString() },
{
key: 'durationMs', header: 'Duration', sortable: true,
render: (v) => `${v}ms`,
},
];
const detailTabs = detail ? [
{
label: 'Overview', value: 'overview',
content: (
<div style={{ display: 'grid', gap: '0.75rem', padding: '1rem' }}>
<div><strong>Execution ID:</strong> <MonoText size="sm">{detail.executionId}</MonoText></div>
<div><strong>Status:</strong> <Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} /></div>
<div><strong>Route:</strong> {detail.routeId}</div>
<div><strong>Duration:</strong> {detail.durationMs}ms</div>
{detail.errorMessage && <div><strong>Error:</strong> {detail.errorMessage}</div>}
</div>
),
},
{
label: 'Processors', value: 'processors',
content: detail.children ? (
<ProcessorTimeline
processors={flattenProcessors(detail.children)}
totalMs={detail.durationMs}
onProcessorClick={(_p, i) => setProcessorIdx(i)}
selectedIndex={processorIdx ?? undefined}
/>
) : <div style={{ padding: '1rem' }}>No processor data</div>,
},
] : [];
return (
<div>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<StatCard label="Total Exchanges" value={stats?.totalCount ?? 0} sparkline={sparklineData} />
<StatCard label="Failed" value={stats?.failedCount ?? 0} accent="error" />
<StatCard label="Avg Duration" value={`${stats?.avgDurationMs ?? 0}ms`} />
<StatCard label="P99 Duration" value={`${stats?.p99DurationMs ?? 0}ms`} accent="warning" />
<StatCard label="Active" value={stats?.activeCount ?? 0} accent="running" />
</div>
<DataTable
columns={columns}
data={rows}
onRowClick={(row) => { setSelectedId(row.id); setProcessorIdx(null); }}
selectedId={selectedId ?? undefined}
sortable
pageSize={25}
/>
<DetailPanel
open={!!selectedId}
onClose={() => setSelectedId(null)}
title={selectedId ? `Exchange ${selectedId.slice(0, 12)}...` : ''}
tabs={detailTabs}
/>
</div>
);
}
function flattenProcessors(nodes: any[]): any[] {
const result: any[] = [];
let offset = 0;
function walk(node: any) {
result.push({
name: node.processorId || node.processorType,
type: node.processorType,
durationMs: node.durationMs ?? 0,
status: node.status === 'COMPLETED' ? 'ok' : node.status === 'FAILED' ? 'fail' : 'ok',
startMs: offset,
});
offset += node.durationMs ?? 0;
if (node.children) node.children.forEach(walk);
}
nodes.forEach(walk);
return result;
}

View File

@@ -0,0 +1,131 @@
import { useState, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router';
import {
Card, Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
ProcessorTimeline, Breadcrumb, Spinner,
} from '@cameleer/design-system';
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
export default function ExchangeDetail() {
const { id } = useParams();
const navigate = useNavigate();
const { data: detail, isLoading } = useExecutionDetail(id ?? null);
const [selectedProcessor, setSelectedProcessor] = useState<number | null>(null);
const { data: snapshot } = useProcessorSnapshot(id ?? null, selectedProcessor);
const processors = useMemo(() => {
if (!detail?.children) return [];
const result: any[] = [];
let offset = 0;
function walk(node: any) {
result.push({
name: node.processorId || node.processorType,
type: node.processorType,
durationMs: node.durationMs ?? 0,
status: node.status === 'COMPLETED' ? 'ok' : node.status === 'FAILED' ? 'fail' : 'ok',
startMs: offset,
});
offset += node.durationMs ?? 0;
if (node.children) node.children.forEach(walk);
}
detail.children.forEach(walk);
return result;
}, [detail]);
if (isLoading) return <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
if (!detail) return <InfoCallout variant="warning">Exchange not found</InfoCallout>;
return (
<div>
<Breadcrumb items={[
{ label: 'Dashboard', href: '/apps' },
{ label: detail.groupName || 'App', href: `/apps/${detail.groupName}` },
{ label: id?.slice(0, 12) || '' },
]} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem', margin: '1.5rem 0' }}>
<Card>
<div style={{ padding: '1rem' }}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>Status</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.25rem' }}>
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : detail.status === 'FAILED' ? 'error' : 'running'} />
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} />
</div>
</div>
</Card>
<Card>
<div style={{ padding: '1rem' }}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>Duration</div>
<div style={{ fontSize: '1.25rem', fontWeight: 600 }}>{detail.durationMs}ms</div>
</div>
</Card>
<Card>
<div style={{ padding: '1rem' }}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>Route</div>
<MonoText>{detail.routeId}</MonoText>
</div>
</Card>
<Card>
<div style={{ padding: '1rem' }}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)' }}>Application</div>
<Badge label={detail.groupName || 'unknown'} color="auto" />
</div>
</Card>
</div>
{detail.errorMessage && (
<div style={{ marginBottom: '1.5rem' }}>
<InfoCallout variant="error">
{detail.errorMessage}
</InfoCallout>
</div>
)}
<h3 style={{ marginBottom: '0.75rem' }}>Processor Timeline</h3>
{processors.length > 0 ? (
<ProcessorTimeline
processors={processors}
totalMs={detail.durationMs}
onProcessorClick={(_p, i) => setSelectedProcessor(i)}
selectedIndex={selectedProcessor ?? undefined}
/>
) : (
<InfoCallout>No processor data available</InfoCallout>
)}
{snapshot && (
<div style={{ marginTop: '1.5rem' }}>
<h3 style={{ marginBottom: '0.75rem' }}>Exchange Snapshot</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
<Card>
<div style={{ padding: '1rem' }}>
<h4 style={{ marginBottom: '0.5rem' }}>Input Body</h4>
<CodeBlock content={String(snapshot.inputBody ?? 'null')} />
</div>
</Card>
<Card>
<div style={{ padding: '1rem' }}>
<h4 style={{ marginBottom: '0.5rem' }}>Output Body</h4>
<CodeBlock content={String(snapshot.outputBody ?? 'null')} />
</div>
</Card>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginTop: '1rem' }}>
<Card>
<div style={{ padding: '1rem' }}>
<h4 style={{ marginBottom: '0.5rem' }}>Input Headers</h4>
<CodeBlock content={JSON.stringify(snapshot.inputHeaders ?? {}, null, 2)} />
</div>
</Card>
<Card>
<div style={{ padding: '1rem' }}>
<h4 style={{ marginBottom: '0.5rem' }}>Output Headers</h4>
<CodeBlock content={JSON.stringify(snapshot.outputHeaders ?? {}, null, 2)} />
</div>
</Card>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { useMemo } from 'react';
import { useParams } from 'react-router';
import {
StatCard, Sparkline, MonoText, Badge,
DataTable, AreaChart, LineChart, BarChart,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useRouteMetrics } from '../../api/queries/catalog';
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import { useGlobalFilters } from '@cameleer/design-system';
interface RouteRow {
id: string;
routeId: string;
appId: string;
exchangeCount: number;
successRate: number;
avgDurationMs: number;
p99DurationMs: number;
errorRate: number;
throughputPerSec: number;
sparkline: number[];
}
export default function RoutesMetrics() {
const { appId, routeId } = useParams();
const { timeRange } = useGlobalFilters();
const timeFrom = timeRange.start.toISOString();
const timeTo = timeRange.end.toISOString();
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId);
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
const rows: RouteRow[] = useMemo(() =>
(metrics || []).map((m: any) => ({
id: `${m.appId}/${m.routeId}`,
...m,
})),
[metrics],
);
const sparklineData = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => b.totalCount as number),
[timeseries],
);
const chartData = useMemo(() =>
(timeseries?.buckets || []).map((b: any) => ({
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
throughput: b.totalCount,
latency: b.avgDurationMs,
errors: b.failedCount,
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
})),
[timeseries],
);
const columns: Column<RouteRow>[] = [
{ key: 'routeId', header: 'Route', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
{ key: 'appId', header: 'App', render: (v) => <Badge label={String(v)} color="auto" /> },
{ key: 'exchangeCount', header: 'Exchanges', sortable: true },
{
key: 'successRate', header: 'Success', sortable: true,
render: (v) => `${((v as number) * 100).toFixed(1)}%`,
},
{ key: 'avgDurationMs', header: 'Avg Duration', sortable: true, render: (v) => `${(v as number).toFixed(0)}ms` },
{ key: 'p99DurationMs', header: 'P99', sortable: true, render: (v) => `${(v as number).toFixed(0)}ms` },
{
key: 'errorRate', header: 'Error Rate', sortable: true,
render: (v) => <span style={{ color: (v as number) > 0.05 ? 'var(--error)' : undefined }}>{((v as number) * 100).toFixed(1)}%</span>,
},
{
key: 'sparkline', header: 'Trend', width: '80px',
render: (v) => <Sparkline data={v as number[]} />,
},
];
return (
<div>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<StatCard label="Total Throughput" value={stats?.totalCount ?? 0} sparkline={sparklineData} />
<StatCard label="Error Rate" value={stats?.totalCount ? `${(((stats.failedCount ?? 0) / stats.totalCount) * 100).toFixed(1)}%` : '0%'} accent="error" />
<StatCard label="P99 Latency" value={`${stats?.p99DurationMs ?? 0}ms`} accent="warning" />
<StatCard label="Success Rate" value={stats?.totalCount ? `${(((stats.totalCount - (stats.failedCount ?? 0)) / stats.totalCount) * 100).toFixed(1)}%` : '100%'} accent="success" />
</div>
<DataTable
columns={columns}
data={rows}
sortable
pageSize={20}
/>
{chartData.length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem', marginTop: '1.5rem' }}>
<AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} height={200} />
<LineChart series={[{ label: 'Latency', data: chartData.map((d: any, i: number) => ({ x: i, y: d.latency })) }]} height={200} />
<BarChart series={[{ label: 'Errors', data: chartData.map((d: any) => ({ x: d.time as string, y: d.errors })) }]} height={200} />
<AreaChart series={[{ label: 'Success Rate', data: chartData.map((d: any, i: number) => ({ x: i, y: d.successRate })) }]} height={200} />
</div>
)}
</div>
);
}

View File

@@ -1,292 +0,0 @@
/* ─── Filter Bar ─── */
.filterBar {
display: flex;
align-items: flex-end;
gap: 10px;
padding: 10px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
flex-wrap: wrap;
}
.filterGroup {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.filterGroupGrow {
flex: 1;
min-width: 140px;
}
.filterLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.filterInput {
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
color: var(--text-primary);
font-size: 12px;
font-family: var(--font-body);
outline: none;
transition: border-color 0.15s;
}
.filterInput:focus {
border-color: var(--amber-dim);
}
.filterInput::placeholder {
color: var(--text-muted);
}
.filterSelect {
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
color: var(--text-primary);
font-size: 12px;
font-family: var(--font-body);
outline: none;
cursor: pointer;
}
/* ─── Table Area ─── */
.tableArea {
flex: 1;
overflow-y: auto;
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th {
position: sticky;
top: 0;
z-index: 1;
text-align: left;
padding: 10px 14px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
background: var(--bg-surface);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.thTimestamp {
width: 170px;
}
.thResult {
width: 90px;
}
.table td {
padding: 8px 14px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-subtle);
vertical-align: middle;
}
/* ─── Event Rows ─── */
.eventRow {
cursor: pointer;
transition: background 0.1s;
}
.eventRow:hover {
background: var(--bg-hover);
}
.eventRowExpanded {
background: var(--bg-hover);
}
.cellTimestamp {
font-family: var(--font-mono);
font-size: 11px;
white-space: nowrap;
color: var(--text-muted);
}
.cellUser {
font-weight: 500;
color: var(--text-primary);
}
.cellTarget {
font-family: var(--font-mono);
font-size: 11px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ─── Badges ─── */
.categoryBadge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
background: var(--bg-raised);
border: 1px solid var(--border);
color: var(--text-secondary);
}
.resultBadge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.resultSuccess {
background: rgba(16, 185, 129, 0.12);
color: var(--green);
}
.resultFailure {
background: rgba(244, 63, 94, 0.12);
color: var(--rose);
}
/* ─── Expanded Detail Row ─── */
.detailRow td {
padding: 0 14px 14px;
background: var(--bg-hover);
border-bottom: 1px solid var(--border);
}
.detailContent {
display: flex;
flex-direction: column;
gap: 10px;
}
.detailMeta {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.detailField {
display: flex;
align-items: baseline;
gap: 8px;
}
.detailLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
white-space: nowrap;
}
.detailValue {
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-mono);
word-break: break-all;
}
.detailJson {
margin: 0;
padding: 12px;
background: var(--bg-base);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* ─── Pagination ─── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 10px 20px;
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.pageBtn {
padding: 5px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-secondary);
font-size: 11px;
font-family: var(--font-body);
cursor: pointer;
transition: all 0.15s;
}
.pageBtn:hover:not(:disabled) {
border-color: var(--amber-dim);
color: var(--text-primary);
}
.pageBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pageInfo {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* ─── Empty State ─── */
.emptyState {
text-align: center;
padding: 48px 16px;
color: var(--text-muted);
font-size: 13px;
}
@media (max-width: 768px) {
.filterBar {
flex-direction: column;
align-items: stretch;
}
.filterGroupGrow {
min-width: unset;
}
.cellTarget {
max-width: 120px;
}
}

View File

@@ -1,277 +1,59 @@
import { useState } from 'react';
import { useAuthStore } from '../../auth/auth-store';
import { useAuditLog, type AuditLogParams } from '../../api/queries/admin/audit';
import layout from '../../styles/AdminLayout.module.css';
import styles from './AuditLogPage.module.css';
import { useState, useMemo } from 'react';
import { DataTable, Badge, Input, Select, MonoText, CodeBlock } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useAuditLog } from '../../api/queries/admin/audit';
function defaultFrom(): string {
const d = new Date();
d.setDate(d.getDate() - 7);
return d.toISOString().slice(0, 10);
}
function defaultTo(): string {
return new Date().toISOString().slice(0, 10);
}
export function AuditLogPage() {
const roles = useAuthStore((s) => s.roles);
if (!roles.includes('ADMIN')) {
return (
<div className={layout.page}>
<div className={layout.accessDenied}>
Access Denied -- this page requires the ADMIN role.
</div>
</div>
);
}
return <AuditLogContent />;
}
function AuditLogContent() {
const [from, setFrom] = useState(defaultFrom);
const [to, setTo] = useState(defaultTo);
const [username, setUsername] = useState('');
const [category, setCategory] = useState('');
export default function AuditLogPage() {
const [search, setSearch] = useState('');
const [category, setCategory] = useState('');
const [page, setPage] = useState(0);
const [expandedRow, setExpandedRow] = useState<number | null>(null);
const pageSize = 25;
const params: AuditLogParams = {
from: from || undefined,
to: to || undefined,
username: username || undefined,
category: category || undefined,
search: search || undefined,
page,
size: pageSize,
};
const { data, isLoading } = useAuditLog({ search, category: category || undefined, page, size: 25 });
const audit = useAuditLog(params);
const data = audit.data;
const totalPages = data?.totalPages ?? 0;
const showingFrom = data && data.totalCount > 0 ? page * pageSize + 1 : 0;
const showingTo = data ? Math.min((page + 1) * pageSize, data.totalCount) : 0;
const columns: Column<any>[] = [
{ key: 'timestamp', header: 'Time', sortable: true, render: (v) => new Date(v as string).toLocaleString() },
{ key: 'username', header: 'User', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
{ key: 'action', header: 'Action' },
{ key: 'category', header: 'Category', render: (v) => <Badge label={String(v)} color="auto" /> },
{ key: 'target', header: 'Target', render: (v) => v ? <MonoText size="sm">{String(v)}</MonoText> : null },
{ key: 'result', header: 'Result', render: (v) => <Badge label={String(v)} color={v === 'SUCCESS' ? 'success' : 'error'} /> },
];
const rows = useMemo(() =>
(data?.items || []).map((item: any) => ({ ...item, id: String(item.id) })),
[data],
);
return (
<div className={layout.page}>
{/* Header */}
<div className={layout.panelHeader}>
<div>
<div className={layout.panelTitle}>Audit Log</div>
<div className={layout.panelSubtitle}>
{data
? `${data.totalCount.toLocaleString()} events`
: 'Loading...'}
</div>
</div>
<div>
<h2 style={{ marginBottom: '1rem' }}>Audit Log</h2>
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem' }}>
<Input placeholder="Search..." value={search} onChange={(e) => setSearch(e.target.value)} />
<Select
options={[
{ value: '', label: 'All Categories' },
{ value: 'AUTH', label: 'Auth' },
{ value: 'CONFIG', label: 'Config' },
{ value: 'RBAC', label: 'RBAC' },
{ value: 'INFRA', label: 'Infra' },
]}
value={category}
onChange={(e) => setCategory(e.target.value)}
/>
</div>
{/* Filter bar */}
<div className={styles.filterBar}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>From</label>
<input
type="date"
className={styles.filterInput}
value={from}
onChange={(e) => { setFrom(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>To</label>
<input
type="date"
className={styles.filterInput}
value={to}
onChange={(e) => { setTo(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>User</label>
<input
type="text"
className={styles.filterInput}
placeholder="Username..."
value={username}
onChange={(e) => { setUsername(e.target.value); setPage(0); }}
/>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Category</label>
<select
className={styles.filterSelect}
value={category}
onChange={(e) => { setCategory(e.target.value); setPage(0); }}
>
<option value="">All</option>
<option value="INFRA">INFRA</option>
<option value="AUTH">AUTH</option>
<option value="USER_MGMT">USER_MGMT</option>
<option value="CONFIG">CONFIG</option>
</select>
</div>
<div className={`${styles.filterGroup} ${styles.filterGroupGrow}`}>
<label className={styles.filterLabel}>Search</label>
<input
type="text"
className={styles.filterInput}
placeholder="Search actions, targets..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
</div>
</div>
{/* Table area */}
<div className={styles.tableArea}>
{audit.isLoading ? (
<div className={layout.loading}>Loading...</div>
) : !data || data.items.length === 0 ? (
<div className={styles.emptyState}>
No audit events found for the selected filters.
<DataTable
columns={columns}
data={rows}
sortable
pageSize={25}
expandedContent={(row) => (
<div style={{ padding: '0.75rem' }}>
<CodeBlock content={JSON.stringify(row.detail, null, 2)} />
</div>
) : (
<table className={styles.table}>
<thead>
<tr>
<th className={styles.thTimestamp}>Timestamp</th>
<th>User</th>
<th>Category</th>
<th>Action</th>
<th>Target</th>
<th className={styles.thResult}>Result</th>
</tr>
</thead>
<tbody>
{data.items.map((event) => (
<EventRow
key={event.id}
event={event}
isExpanded={expandedRow === event.id}
onToggle={() =>
setExpandedRow((prev) => (prev === event.id ? null : event.id))
}
/>
))}
</tbody>
</table>
)}
</div>
{/* Pagination */}
{data && data.totalCount > 0 && (
<div className={styles.pagination}>
<button
type="button"
className={styles.pageBtn}
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</button>
<span className={styles.pageInfo}>
{showingFrom}--{showingTo} of {data.totalCount.toLocaleString()}
</span>
<button
type="button"
className={styles.pageBtn}
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
)}
/>
</div>
);
}
function EventRow({
event,
isExpanded,
onToggle,
}: {
event: {
id: number;
timestamp: string;
username: string;
category: string;
action: string;
target: string;
result: string;
detail: Record<string, unknown>;
ipAddress: string;
userAgent: string;
};
isExpanded: boolean;
onToggle: () => void;
}) {
return (
<>
<tr
className={`${styles.eventRow} ${isExpanded ? styles.eventRowExpanded : ''}`}
onClick={onToggle}
>
<td className={styles.cellTimestamp}>{formatTimestamp(event.timestamp)}</td>
<td className={styles.cellUser}>{event.username}</td>
<td>
<span className={styles.categoryBadge}>{event.category}</span>
</td>
<td>{event.action}</td>
<td className={styles.cellTarget}>{event.target}</td>
<td>
<span
className={`${styles.resultBadge} ${
event.result === 'SUCCESS' ? styles.resultSuccess : styles.resultFailure
}`}
>
{event.result}
</span>
</td>
</tr>
{isExpanded && (
<tr className={styles.detailRow}>
<td colSpan={6}>
<div className={styles.detailContent}>
<div className={styles.detailMeta}>
<div className={styles.detailField}>
<span className={styles.detailLabel}>IP Address</span>
<span className={styles.detailValue}>{event.ipAddress}</span>
</div>
<div className={styles.detailField}>
<span className={styles.detailLabel}>User Agent</span>
<span className={styles.detailValue}>{event.userAgent}</span>
</div>
</div>
{event.detail && Object.keys(event.detail).length > 0 && (
<pre className={styles.detailJson}>
{JSON.stringify(event.detail, null, 2)}
</pre>
)}
</div>
</td>
</tr>
)}
</>
);
}
function formatTimestamp(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} catch {
return iso;
}
}

View File

@@ -1,249 +0,0 @@
/* ─── Meta ─── */
.metaItem {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
/* ─── Progress Bar ─── */
.progressContainer {
margin-bottom: 16px;
}
.progressLabel {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.progressPct {
font-weight: 600;
font-family: var(--font-mono);
}
.progressBar {
height: 8px;
background: var(--bg-raised);
border-radius: 4px;
overflow: hidden;
}
.progressFill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
/* ─── Metrics Grid ─── */
.metricsGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.metric {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px;
background: var(--bg-raised);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
}
.metricValue {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-mono);
}
.metricLabel {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
/* ─── Tables ─── */
.tableWrapper {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th {
text-align: left;
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
border-bottom: 1px solid var(--border-subtle);
white-space: nowrap;
}
.table td {
padding: 8px 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-subtle);
}
.table tbody tr:hover {
background: var(--bg-hover);
}
.mono {
font-family: var(--font-mono);
font-size: 12px;
}
.queryCell {
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: 11px;
}
.rowWarning {
background: rgba(234, 179, 8, 0.06);
}
.killBtn {
padding: 4px 10px;
border-radius: var(--radius-sm);
background: transparent;
border: 1px solid var(--rose-dim);
color: var(--rose);
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
}
.killBtn:hover {
background: var(--rose-glow);
}
.emptyState {
text-align: center;
padding: 24px;
color: var(--text-muted);
font-size: 13px;
}
/* ─── Maintenance ─── */
.maintenanceGrid {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.maintenanceBtn {
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-muted);
font-size: 13px;
cursor: not-allowed;
opacity: 0.5;
}
/* ─── Thresholds ─── */
.thresholdGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.thresholdField {
display: flex;
flex-direction: column;
gap: 4px;
}
.thresholdLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.thresholdInput {
width: 100%;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.thresholdInput:focus {
border-color: var(--amber-dim);
box-shadow: 0 0 0 3px var(--amber-glow);
}
.thresholdActions {
display: flex;
align-items: center;
gap: 12px;
}
.btnPrimary {
padding: 8px 20px;
border-radius: var(--radius-sm);
border: 1px solid var(--amber);
background: var(--amber);
color: #0a0e17;
font-family: var(--font-body);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.btnPrimary:hover {
background: var(--amber-hover);
border-color: var(--amber-hover);
}
.btnPrimary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.successMsg {
font-size: 12px;
color: var(--green);
}
.errorMsg {
font-size: 12px;
color: var(--rose);
}
@media (max-width: 640px) {
.metricsGrid {
grid-template-columns: repeat(2, 1fr);
}
.thresholdGrid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,437 +1,67 @@
import { useState } from 'react';
import { useAuthStore } from '../../auth/auth-store';
import { StatusBadge } from '../../components/admin/StatusBadge';
import { ConfirmDeleteDialog } from '../../components/admin/ConfirmDeleteDialog';
import {
useDatabaseStatus,
useDatabasePool,
useDatabaseTables,
useDatabaseQueries,
useKillQuery,
} from '../../api/queries/admin/database';
import { useThresholds, useSaveThresholds, type ThresholdConfig } from '../../api/queries/admin/thresholds';
import layout from '../../styles/AdminLayout.module.css';
import styles from './DatabaseAdminPage.module.css';
import { StatCard, Card, DataTable, Badge, Button, ProgressBar, Spinner } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useDatabaseStatus, useConnectionPool, useDatabaseTables, useActiveQueries, useKillQuery } from '../../api/queries/admin/database';
type Section = 'pool' | 'tables' | 'queries' | 'maintenance' | 'thresholds';
export default function DatabaseAdminPage() {
const { data: status } = useDatabaseStatus();
const { data: pool } = useConnectionPool();
const { data: tables } = useDatabaseTables();
const { data: queries } = useActiveQueries();
const killQuery = useKillQuery();
interface SectionDef {
id: Section;
label: string;
icon: string;
}
const poolPct = pool ? (pool.activeConnections / pool.maximumPoolSize) * 100 : 0;
const SECTIONS: SectionDef[] = [
{ id: 'pool', label: 'Connection Pool', icon: 'CP' },
{ id: 'tables', label: 'Table Sizes', icon: 'TS' },
{ id: 'queries', label: 'Active Queries', icon: 'AQ' },
{ id: 'maintenance', label: 'Maintenance', icon: 'MN' },
{ id: 'thresholds', label: 'Thresholds', icon: 'TH' },
];
const tableColumns: Column<any>[] = [
{ key: 'tableName', header: 'Table' },
{ key: 'rowCount', header: 'Rows', sortable: true },
{ key: 'dataSize', header: 'Data Size' },
{ key: 'indexSize', header: 'Index Size' },
];
export function DatabaseAdminPage() {
const roles = useAuthStore((s) => s.roles);
if (!roles.includes('ADMIN')) {
return (
<div className={layout.page}>
<div className={layout.accessDenied}>
Access Denied -- this page requires the ADMIN role.
</div>
</div>
);
}
return <DatabaseAdminContent />;
}
function DatabaseAdminContent() {
const [selectedSection, setSelectedSection] = useState<Section>('pool');
const status = useDatabaseStatus();
const pool = useDatabasePool();
const tables = useDatabaseTables();
const queries = useDatabaseQueries();
const thresholds = useThresholds();
if (status.isLoading) {
return (
<div className={layout.page}>
<div className={layout.loading}>Loading...</div>
</div>
);
}
const db = status.data;
function getMiniStatus(section: Section): string {
switch (section) {
case 'pool': {
const d = pool.data;
if (!d) return '--';
const pct = d.maxPoolSize > 0 ? Math.round((d.activeConnections / d.maxPoolSize) * 100) : 0;
return `${pct}%`;
}
case 'tables':
return tables.data ? `${tables.data.length}` : '--';
case 'queries':
return queries.data ? `${queries.data.length}` : '--';
case 'maintenance':
return 'Coming soon';
case 'thresholds':
return thresholds.data ? 'Configured' : '--';
}
}
const queryColumns: Column<any>[] = [
{ key: 'pid', header: 'PID' },
{ key: 'durationSeconds', header: 'Duration', render: (v) => `${v}s` },
{ key: 'state', header: 'State', render: (v) => <Badge label={String(v)} /> },
{ key: 'query', header: 'Query', render: (v) => <span style={{ fontSize: '0.75rem', fontFamily: 'var(--font-mono)' }}>{String(v).slice(0, 80)}</span> },
{
key: 'pid', header: '', width: '80px',
render: (v) => <Button variant="danger" size="sm" onClick={() => killQuery.mutate(v as number)}>Kill</Button>,
},
];
return (
<div className={layout.page}>
<div className={layout.panelHeader}>
<div>
<div className={layout.panelTitle}>Database</div>
<div className={layout.panelSubtitle}>
<StatusBadge
status={db?.connected ? 'healthy' : 'critical'}
label={db?.connected ? 'Connected' : 'Disconnected'}
/>
{db?.version && <span className={styles.metaItem}>{db.version}</span>}
{db?.host && <span className={styles.metaItem}>{db.host}</span>}
{db?.schema && <span className={styles.metaItem}>Schema: {db.schema}</span>}
</div>
</div>
<button
type="button"
className={layout.btnAction}
onClick={() => {
status.refetch();
pool.refetch();
tables.refetch();
queries.refetch();
}}
>
Refresh All
</button>
<div>
<h2 style={{ marginBottom: '1rem' }}>Database Administration</h2>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<StatCard label="Status" value={status?.connected ? 'Connected' : 'Disconnected'} accent={status?.connected ? 'success' : 'error'} />
<StatCard label="Version" value={status?.version ?? '—'} />
<StatCard label="TimescaleDB" value={status?.timescaleDb ? 'Enabled' : 'Disabled'} />
</div>
<div className={layout.split}>
<div className={layout.listPane}>
<div className={layout.entityList}>
{SECTIONS.map((sec) => (
<div
key={sec.id}
className={`${layout.entityItem} ${selectedSection === sec.id ? layout.entityItemSelected : ''}`}
onClick={() => setSelectedSection(sec.id)}
>
<div className={layout.sectionIcon}>{sec.icon}</div>
<div className={layout.entityInfo}>
<div className={layout.entityName}>{sec.label}</div>
</div>
<div className={layout.miniStatus}>{getMiniStatus(sec.id)}</div>
</div>
))}
{pool && (
<Card>
<div style={{ padding: '1rem' }}>
<h3 style={{ marginBottom: '0.5rem' }}>Connection Pool</h3>
<ProgressBar value={poolPct} />
<div style={{ display: 'flex', gap: '2rem', marginTop: '0.5rem', fontSize: '0.875rem' }}>
<span>Active: {pool.activeConnections}</span>
<span>Idle: {pool.idleConnections}</span>
<span>Max: {pool.maximumPoolSize}</span>
</div>
</div>
</div>
</Card>
)}
<div className={layout.detailPane}>
{selectedSection === 'pool' && (
<PoolSection
pool={pool}
warningPct={thresholds.data?.database?.connectionPoolWarning}
criticalPct={thresholds.data?.database?.connectionPoolCritical}
/>
)}
{selectedSection === 'tables' && <TablesSection tables={tables} />}
{selectedSection === 'queries' && (
<QueriesSection
queries={queries}
warningSeconds={thresholds.data?.database?.queryDurationWarning}
/>
)}
{selectedSection === 'maintenance' && <MaintenanceSection />}
{selectedSection === 'thresholds' && (
<ThresholdsSection thresholds={thresholds.data} />
)}
</div>
<div style={{ marginTop: '1.5rem' }}>
<h3 style={{ marginBottom: '0.75rem' }}>Tables</h3>
<DataTable columns={tableColumns} data={(tables || []).map((t: any) => ({ ...t, id: t.tableName }))} sortable pageSize={20} />
</div>
<div style={{ marginTop: '1.5rem' }}>
<h3 style={{ marginBottom: '0.75rem' }}>Active Queries</h3>
<DataTable columns={queryColumns} data={(queries || []).map((q: any) => ({ ...q, id: String(q.pid) }))} />
</div>
</div>
);
}
function PoolSection({
pool,
warningPct,
criticalPct,
}: {
pool: ReturnType<typeof useDatabasePool>;
warningPct?: number;
criticalPct?: number;
}) {
const data = pool.data;
if (!data) return null;
const usagePct = data.maxPoolSize > 0
? Math.round((data.activeConnections / data.maxPoolSize) * 100)
: 0;
const barColor =
criticalPct && usagePct >= criticalPct ? '#ef4444'
: warningPct && usagePct >= warningPct ? '#eab308'
: '#22c55e';
return (
<>
<div className={layout.detailSectionTitle}>Connection Pool</div>
<div className={styles.progressContainer}>
<div className={styles.progressLabel}>
{data.activeConnections} / {data.maxPoolSize} connections
<span className={styles.progressPct}>{usagePct}%</span>
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${usagePct}%`, background: barColor }}
/>
</div>
</div>
<div className={styles.metricsGrid}>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.activeConnections}</span>
<span className={styles.metricLabel}>Active</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.idleConnections}</span>
<span className={styles.metricLabel}>Idle</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.pendingThreads}</span>
<span className={styles.metricLabel}>Pending</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.maxWaitMs}ms</span>
<span className={styles.metricLabel}>Max Wait</span>
</div>
</div>
</>
);
}
function TablesSection({ tables }: { tables: ReturnType<typeof useDatabaseTables> }) {
const data = tables.data;
return (
<>
<div className={layout.detailSectionTitle}>Table Sizes</div>
{!data ? (
<div className={layout.loading}>Loading...</div>
) : (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>Table</th>
<th>Rows</th>
<th>Data Size</th>
<th>Index Size</th>
</tr>
</thead>
<tbody>
{data.map((t) => (
<tr key={t.tableName}>
<td className={styles.mono}>{t.tableName}</td>
<td>{t.rowCount.toLocaleString()}</td>
<td>{t.dataSize}</td>
<td>{t.indexSize}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}
function QueriesSection({
queries,
warningSeconds,
}: {
queries: ReturnType<typeof useDatabaseQueries>;
warningSeconds?: number;
}) {
const [killTarget, setKillTarget] = useState<number | null>(null);
const killMutation = useKillQuery();
const data = queries.data;
const warningSec = warningSeconds ?? 30;
return (
<>
<div className={layout.detailSectionTitle}>Active Queries</div>
{!data || data.length === 0 ? (
<div className={styles.emptyState}>No active queries</div>
) : (
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>PID</th>
<th>Duration</th>
<th>State</th>
<th>Query</th>
<th></th>
</tr>
</thead>
<tbody>
{data.map((q) => (
<tr
key={q.pid}
className={q.durationSeconds > warningSec ? styles.rowWarning : undefined}
>
<td className={styles.mono}>{q.pid}</td>
<td>{formatDuration(q.durationSeconds)}</td>
<td>{q.state}</td>
<td className={styles.queryCell} title={q.query}>
{q.query.length > 100 ? `${q.query.slice(0, 100)}...` : q.query}
</td>
<td>
<button
type="button"
className={styles.killBtn}
onClick={() => setKillTarget(q.pid)}
>
Kill
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<ConfirmDeleteDialog
isOpen={killTarget !== null}
onClose={() => setKillTarget(null)}
onConfirm={() => {
if (killTarget !== null) {
killMutation.mutate(killTarget);
setKillTarget(null);
}
}}
resourceName={String(killTarget ?? '')}
resourceType="query (PID)"
/>
</>
);
}
function MaintenanceSection() {
return (
<>
<div className={layout.detailSectionTitle}>Maintenance</div>
<div className={styles.maintenanceGrid}>
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
VACUUM ANALYZE
</button>
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
REINDEX
</button>
<button type="button" className={styles.maintenanceBtn} disabled title="Coming soon">
Refresh Aggregates
</button>
</div>
</>
);
}
function ThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
const [form, setForm] = useState<ThresholdConfig | null>(null);
const saveMutation = useSaveThresholds();
const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
const current = form ?? thresholds;
if (!current) return null;
function updateDb(key: keyof ThresholdConfig['database'], value: number) {
setForm((prev) => {
const base = prev ?? thresholds!;
return { ...base, database: { ...base.database, [key]: value } };
});
}
async function handleSave() {
if (!form && !thresholds) return;
const data = form ?? thresholds!;
try {
await saveMutation.mutateAsync(data);
setStatus({ type: 'success', msg: 'Thresholds saved.' });
setTimeout(() => setStatus(null), 3000);
} catch {
setStatus({ type: 'error', msg: 'Failed to save thresholds.' });
}
}
return (
<>
<div className={layout.detailSectionTitle}>Thresholds</div>
<div className={styles.thresholdGrid}>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Pool Warning %</label>
<input
type="number"
className={styles.thresholdInput}
value={current.database.connectionPoolWarning}
onChange={(e) => updateDb('connectionPoolWarning', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Pool Critical %</label>
<input
type="number"
className={styles.thresholdInput}
value={current.database.connectionPoolCritical}
onChange={(e) => updateDb('connectionPoolCritical', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Query Warning (s)</label>
<input
type="number"
className={styles.thresholdInput}
value={current.database.queryDurationWarning}
onChange={(e) => updateDb('queryDurationWarning', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Query Critical (s)</label>
<input
type="number"
className={styles.thresholdInput}
value={current.database.queryDurationCritical}
onChange={(e) => updateDb('queryDurationCritical', Number(e.target.value))}
/>
</div>
</div>
<div className={styles.thresholdActions}>
<button
type="button"
className={styles.btnPrimary}
onClick={handleSave}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? 'Saving...' : 'Save Thresholds'}
</button>
{status && (
<span className={status.type === 'success' ? styles.successMsg : styles.errorMsg}>
{status.msg}
</span>
)}
</div>
</>
);
}
function formatDuration(seconds: number): string {
if (seconds < 1) return `${Math.round(seconds * 1000)}ms`;
const s = Math.floor(seconds);
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
return `${m}m ${s % 60}s`;
}

View File

@@ -1,279 +0,0 @@
/* ─── Toggle ─── */
.toggleRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid var(--border-subtle);
}
.toggleInfo {
flex: 1;
margin-right: 16px;
}
.toggleLabel {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.toggleDesc {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
line-height: 1.4;
}
.toggle {
position: relative;
width: 44px;
height: 24px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 12px;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
flex-shrink: 0;
}
.toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
background: var(--text-muted);
border-radius: 50%;
transition: transform 0.2s, background 0.2s;
}
.toggleOn {
background: var(--amber);
border-color: var(--amber);
}
.toggleOn::after {
transform: translateX(20px);
background: #0a0e17;
}
/* ─── Form Fields ─── */
.field {
margin-top: 16px;
}
.label {
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 6px;
}
.hint {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
font-style: italic;
}
.input {
width: 100%;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input:focus {
border-color: var(--amber-dim);
box-shadow: 0 0 0 3px var(--amber-glow);
}
/* ─── Tags ─── */
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 99px;
padding: 4px 10px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
}
.tagRemove {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 14px;
padding: 0;
line-height: 1;
}
.tagRemove:hover {
color: var(--rose);
}
.tagInput {
display: flex;
gap: 8px;
}
.tagInput .input {
flex: 1;
}
.tagAddBtn {
padding: 10px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.tagAddBtn:hover {
border-color: var(--amber-dim);
color: var(--text-primary);
}
/* ─── Header Action Button Variants ─── */
.btnPrimary {
border-color: var(--amber) !important;
background: var(--amber) !important;
color: #0a0e17 !important;
font-weight: 600;
}
.btnPrimary:hover:not(:disabled) {
background: var(--amber-hover) !important;
border-color: var(--amber-hover) !important;
}
.btnPrimary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btnOutline {
background: transparent;
border-color: var(--border);
color: var(--text-secondary);
}
.btnOutline:hover:not(:disabled) {
border-color: var(--amber-dim);
color: var(--text-primary);
}
.btnOutline:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btnDanger {
border-color: var(--rose-dim) !important;
color: var(--rose) !important;
background: transparent !important;
}
.btnDanger:hover:not(:disabled) {
background: var(--rose-glow) !important;
}
.btnDanger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ─── Confirm Bar ─── */
.confirmBar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
padding: 12px 16px;
background: var(--rose-glow);
border: 1px solid rgba(244, 63, 94, 0.2);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--rose);
}
.confirmBar button {
font-size: 13px;
cursor: pointer;
}
.confirmActions {
display: flex;
gap: 8px;
}
/* ─── Status Messages ─── */
.successMsg {
margin-top: 16px;
padding: 10px 12px;
background: rgba(16, 185, 129, 0.08);
border: 1px solid rgba(16, 185, 129, 0.2);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--green);
}
.errorMsg {
margin-top: 16px;
padding: 10px 12px;
background: var(--rose-glow);
border: 1px solid rgba(244, 63, 94, 0.2);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--rose);
}
/* ─── Skeleton Loading ─── */
.skeleton {
animation: pulse 1.5s ease-in-out infinite;
background: var(--bg-raised);
border-radius: var(--radius-sm);
height: 20px;
margin-bottom: 12px;
}
.skeletonWide {
composes: skeleton;
width: 100%;
height: 40px;
}
.skeletonMedium {
composes: skeleton;
width: 60%;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.8; }
}

View File

@@ -1,373 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useAuthStore } from '../../auth/auth-store';
import {
useOidcConfig,
useSaveOidcConfig,
useTestOidcConnection,
useDeleteOidcConfig,
} from '../../api/queries/oidc-admin';
import type { OidcAdminConfigRequest } from '../../api/types';
import layout from '../../styles/AdminLayout.module.css';
import styles from './OidcAdminPage.module.css';
interface FormData {
enabled: boolean;
autoSignup: boolean;
issuerUri: string;
clientId: string;
clientSecret: string;
rolesClaim: string;
defaultRoles: string[];
displayNameClaim: string;
}
const emptyForm: FormData = {
enabled: false,
autoSignup: true,
issuerUri: '',
clientId: '',
clientSecret: '',
rolesClaim: 'realm_access.roles',
defaultRoles: ['VIEWER'],
displayNameClaim: 'name',
};
export function OidcAdminPage() {
const roles = useAuthStore((s) => s.roles);
if (!roles.includes('ADMIN')) {
return (
<div className={layout.page}>
<div className={layout.accessDenied}>
Access Denied -- this page requires the ADMIN role.
</div>
</div>
);
}
return <OidcAdminForm />;
}
function OidcAdminForm() {
const { data, isLoading } = useOidcConfig();
const saveMutation = useSaveOidcConfig();
const testMutation = useTestOidcConnection();
const deleteMutation = useDeleteOidcConfig();
const [form, setForm] = useState<FormData>(emptyForm);
const [secretTouched, setSecretTouched] = useState(false);
const [newRole, setNewRole] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [status, setStatus] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const statusTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
if (!data) return;
if (data.configured) {
setForm({
enabled: data.enabled ?? false,
autoSignup: data.autoSignup ?? true,
issuerUri: data.issuerUri ?? '',
clientId: data.clientId ?? '',
clientSecret: '',
rolesClaim: data.rolesClaim ?? 'realm_access.roles',
defaultRoles: data.defaultRoles ?? ['VIEWER'],
displayNameClaim: data.displayNameClaim ?? 'name',
});
setSecretTouched(false);
} else {
setForm(emptyForm);
}
}, [data]);
function showStatus(type: 'success' | 'error', message: string) {
setStatus({ type, message });
clearTimeout(statusTimer.current);
statusTimer.current = setTimeout(() => setStatus(null), 5000);
}
function updateField<K extends keyof FormData>(key: K, value: FormData[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
}
async function handleSave() {
const payload: OidcAdminConfigRequest = {
...form,
clientSecret: secretTouched ? form.clientSecret : '********',
};
try {
await saveMutation.mutateAsync(payload);
showStatus('success', 'Configuration saved.');
} catch (e) {
showStatus('error', e instanceof Error ? e.message : 'Failed to save.');
}
}
async function handleTest() {
try {
const result = await testMutation.mutateAsync();
showStatus('success', `Provider reachable. Authorization endpoint: ${result.authorizationEndpoint}`);
} catch (e) {
showStatus('error', e instanceof Error ? e.message : 'Test failed.');
}
}
async function handleDelete() {
try {
await deleteMutation.mutateAsync();
setForm(emptyForm);
setSecretTouched(false);
setShowDeleteConfirm(false);
showStatus('success', 'Configuration deleted.');
} catch (e) {
showStatus('error', e instanceof Error ? e.message : 'Failed to delete.');
}
}
function addRole() {
const role = newRole.trim().toUpperCase();
if (role && !form.defaultRoles.includes(role)) {
updateField('defaultRoles', [...form.defaultRoles, role]);
}
setNewRole('');
}
function removeRole(role: string) {
updateField('defaultRoles', form.defaultRoles.filter((r) => r !== role));
}
if (isLoading) {
return (
<div className={layout.page}>
<div className={layout.panelHeader}>
<div>
<div className={layout.panelTitle}>OIDC Configuration</div>
<div className={layout.panelSubtitle}>Configure external identity provider</div>
</div>
</div>
<div className={layout.detailOnly}>
<div className={styles.skeletonWide} />
<div className={styles.skeletonMedium} />
<div className={styles.skeletonWide} />
<div className={styles.skeletonWide} />
<div className={styles.skeletonMedium} />
</div>
</div>
);
}
const isConfigured = data?.configured ?? false;
return (
<div className={layout.page}>
<div className={layout.panelHeader}>
<div>
<div className={layout.panelTitle}>OIDC Configuration</div>
<div className={layout.panelSubtitle}>Configure external identity provider</div>
</div>
<div className={layout.headerActions}>
<button
type="button"
className={`${layout.btnAction} ${styles.btnPrimary}`}
onClick={handleSave}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? 'Saving...' : 'Save'}
</button>
<button
type="button"
className={`${layout.btnAction} ${styles.btnOutline}`}
onClick={handleTest}
disabled={!isConfigured || testMutation.isPending}
>
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
</button>
<button
type="button"
className={`${layout.btnAction} ${styles.btnDanger}`}
onClick={() => setShowDeleteConfirm(true)}
disabled={!isConfigured || deleteMutation.isPending}
>
Delete
</button>
</div>
</div>
<div className={layout.detailOnly}>
<div className={layout.detailSection}>
<div className={layout.detailSectionTitle}>Behavior</div>
<div className={styles.toggleRow}>
<div className={styles.toggleInfo}>
<div className={styles.toggleLabel}>Enabled</div>
<div className={styles.toggleDesc}>
Allow users to sign in with the configured OIDC identity provider
</div>
</div>
<button
type="button"
className={`${styles.toggle} ${form.enabled ? styles.toggleOn : ''}`}
onClick={() => updateField('enabled', !form.enabled)}
aria-label="Toggle OIDC enabled"
/>
</div>
<div className={styles.toggleRow}>
<div className={styles.toggleInfo}>
<div className={styles.toggleLabel}>Auto Sign-Up</div>
<div className={styles.toggleDesc}>
Automatically create accounts for new OIDC users. When disabled, an admin must
pre-create the user before they can sign in.
</div>
</div>
<button
type="button"
className={`${styles.toggle} ${form.autoSignup ? styles.toggleOn : ''}`}
onClick={() => updateField('autoSignup', !form.autoSignup)}
aria-label="Toggle auto sign-up"
/>
</div>
</div>
<div className={layout.detailSection}>
<div className={layout.detailSectionTitle}>Provider Settings</div>
<div className={styles.field}>
<label className={styles.label}>Issuer URI</label>
<input
className={styles.input}
type="url"
value={form.issuerUri}
onChange={(e) => updateField('issuerUri', e.target.value)}
placeholder="https://auth.example.com/realms/main/.well-known/openid-configuration"
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Client ID</label>
<input
className={styles.input}
type="text"
value={form.clientId}
onChange={(e) => updateField('clientId', e.target.value)}
placeholder="cameleer3"
/>
</div>
<div className={styles.field}>
<label className={styles.label}>Client Secret</label>
<input
className={styles.input}
type="password"
value={form.clientSecret}
onChange={(e) => {
updateField('clientSecret', e.target.value);
setSecretTouched(true);
}}
placeholder={data?.clientSecretSet ? 'Secret is configured' : 'Enter client secret'}
/>
</div>
</div>
<div className={layout.detailSection}>
<div className={layout.detailSectionTitle}>Claim Mapping</div>
<div className={styles.field}>
<label className={styles.label}>Roles Claim</label>
<input
className={styles.input}
type="text"
value={form.rolesClaim}
onChange={(e) => updateField('rolesClaim', e.target.value)}
placeholder="realm_access.roles"
/>
<div className={styles.hint}>
Dot-separated path to roles array in the ID token
</div>
</div>
<div className={styles.field}>
<label className={styles.label}>Display Name Claim</label>
<input
className={styles.input}
type="text"
value={form.displayNameClaim}
onChange={(e) => updateField('displayNameClaim', e.target.value)}
placeholder="name"
/>
<div className={styles.hint}>
Dot-separated path to the user's display name in the ID token (e.g. name, preferred_username, profile.display_name)
</div>
</div>
</div>
<div className={layout.detailSection}>
<div className={layout.detailSectionTitle}>Default Roles</div>
<div className={styles.tags}>
{form.defaultRoles.map((role) => (
<span key={role} className={styles.tag}>
{role}
<button
type="button"
className={styles.tagRemove}
onClick={() => removeRole(role)}
aria-label={`Remove ${role}`}
>
&#x2715;
</button>
</span>
))}
</div>
<div className={styles.tagInput}>
<input
className={styles.input}
type="text"
value={newRole}
onChange={(e) => setNewRole(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
addRole();
}
}}
placeholder="Add role..."
/>
<button type="button" className={styles.tagAddBtn} onClick={addRole}>
Add
</button>
</div>
</div>
{showDeleteConfirm && (
<div className={styles.confirmBar}>
<span>Delete OIDC configuration? This cannot be undone.</span>
<div className={styles.confirmActions}>
<button
type="button"
className={styles.btnOutline}
onClick={() => setShowDeleteConfirm(false)}
>
Cancel
</button>
<button
type="button"
className={styles.btnDanger}
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Deleting...' : 'Confirm'}
</button>
</div>
</div>
)}
{status && (
<div className={status.type === 'success' ? styles.successMsg : styles.errorMsg}>
{status.message}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
import { useEffect, useState } from 'react';
import { Button, Input, Toggle, FormField, Card, Alert, SectionHeader } from '@cameleer/design-system';
import { adminFetch } from '../../api/queries/admin/admin-api';
interface OidcConfig {
enabled: boolean;
issuerUri: string;
clientId: string;
clientSecret: string;
rolesClaim: string;
defaultRoles: string[];
autoSignup: boolean;
displayNameClaim: string;
}
export default function OidcConfigPage() {
const [config, setConfig] = useState<OidcConfig | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
useEffect(() => {
adminFetch<OidcConfig>('/oidc')
.then(setConfig)
.catch(() => setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' }));
}, []);
const handleSave = async () => {
if (!config) return;
setSaving(true);
setError(null);
try {
await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(config) });
setSuccess(true);
setTimeout(() => setSuccess(false), 3000);
} catch (e: any) {
setError(e.message);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
try {
await adminFetch('/oidc', { method: 'DELETE' });
setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' });
} catch (e: any) {
setError(e.message);
}
};
if (!config) return null;
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>OIDC Configuration</h2>
<Card>
<div style={{ padding: '1.5rem', display: 'grid', gap: '1rem' }}>
<Toggle checked={config.enabled} onChange={(e) => setConfig({ ...config, enabled: e.target.checked })} label="Enable OIDC" />
<FormField label="Issuer URI"><Input value={config.issuerUri} onChange={(e) => setConfig({ ...config, issuerUri: e.target.value })} /></FormField>
<FormField label="Client ID"><Input value={config.clientId} onChange={(e) => setConfig({ ...config, clientId: e.target.value })} /></FormField>
<FormField label="Client Secret"><Input type="password" value={config.clientSecret} onChange={(e) => setConfig({ ...config, clientSecret: e.target.value })} /></FormField>
<FormField label="Roles Claim"><Input value={config.rolesClaim} onChange={(e) => setConfig({ ...config, rolesClaim: e.target.value })} /></FormField>
<FormField label="Display Name Claim"><Input value={config.displayNameClaim} onChange={(e) => setConfig({ ...config, displayNameClaim: e.target.value })} /></FormField>
<Toggle checked={config.autoSignup} onChange={(e) => setConfig({ ...config, autoSignup: e.target.checked })} label="Auto Signup" />
<div style={{ display: 'flex', gap: '0.75rem' }}>
<Button variant="primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
<Button variant="danger" onClick={handleDelete}>Remove Config</Button>
</div>
{error && <Alert variant="error">{error}</Alert>}
{success && <Alert variant="success">Configuration saved</Alert>}
</div>
</Card>
</div>
);
}

View File

@@ -1,356 +0,0 @@
/* ─── Progress Bar ─── */
.progressContainer {
margin-bottom: 16px;
}
.progressLabel {
display: flex;
justify-content: space-between;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 6px;
}
.progressPct {
font-weight: 600;
font-family: var(--font-mono);
}
.progressBar {
height: 8px;
background: var(--bg-raised);
border-radius: 4px;
overflow: hidden;
}
.progressFill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
/* ─── Metrics Grid ─── */
.metricsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
}
.metric {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px;
background: var(--bg-raised);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
}
.metricValue {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-mono);
}
.metricLabel {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 4px;
}
/* ─── Filter Row ─── */
.filterRow {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.filterInput {
flex: 1;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text-primary);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.filterInput:focus {
border-color: var(--amber-dim);
}
.filterInput::placeholder {
color: var(--text-muted);
}
.filterSelect {
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text-primary);
font-size: 13px;
outline: none;
cursor: pointer;
}
/* ─── Tables ─── */
.tableWrapper {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th {
text-align: left;
padding: 8px 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
border-bottom: 1px solid var(--border-subtle);
white-space: nowrap;
}
.sortableHeader {
cursor: pointer;
user-select: none;
}
.sortableHeader:hover {
color: var(--text-primary);
}
.sortArrow {
font-size: 9px;
}
.table td {
padding: 8px 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border-subtle);
}
.table tbody tr:hover {
background: var(--bg-hover);
}
.mono {
font-family: var(--font-mono);
font-size: 12px;
}
.healthBadge {
display: inline-block;
padding: 2px 8px;
border-radius: 99px;
font-size: 11px;
font-weight: 500;
text-transform: capitalize;
}
.healthGreen {
background: rgba(34, 197, 94, 0.1);
color: #22c55e;
}
.healthYellow {
background: rgba(234, 179, 8, 0.1);
color: #eab308;
}
.healthRed {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.deleteBtn {
padding: 4px 10px;
border-radius: var(--radius-sm);
background: transparent;
border: 1px solid var(--rose-dim);
color: var(--rose);
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
}
.deleteBtn:hover {
background: var(--rose-glow);
}
.emptyState {
text-align: center;
padding: 24px;
color: var(--text-muted);
font-size: 13px;
}
/* ─── Pagination ─── */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border-subtle);
}
.pageBtn {
padding: 6px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.pageBtn:hover:not(:disabled) {
border-color: var(--amber-dim);
color: var(--text-primary);
}
.pageBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pageInfo {
font-size: 12px;
color: var(--text-muted);
}
/* ─── Heap Section ─── */
.heapSection {
margin-top: 16px;
}
/* ─── Operations ─── */
.operationsGrid {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.operationBtn {
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-muted);
font-size: 13px;
cursor: not-allowed;
opacity: 0.5;
}
/* ─── Thresholds ─── */
.thresholdGrid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-bottom: 16px;
}
.thresholdField {
display: flex;
flex-direction: column;
gap: 4px;
}
.thresholdLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.thresholdInput {
width: 100%;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.thresholdInput:focus {
border-color: var(--amber-dim);
box-shadow: 0 0 0 3px var(--amber-glow);
}
.thresholdActions {
display: flex;
align-items: center;
gap: 12px;
}
.btnPrimary {
padding: 8px 20px;
border-radius: var(--radius-sm);
border: 1px solid var(--amber);
background: var(--amber);
color: #0a0e17;
font-family: var(--font-body);
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.btnPrimary:hover {
background: var(--amber-hover);
border-color: var(--amber-hover);
}
.btnPrimary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.successMsg {
font-size: 12px;
color: var(--green);
}
.errorMsg {
font-size: 12px;
color: var(--rose);
}
.metaItem {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
@media (max-width: 640px) {
.metricsGrid {
grid-template-columns: repeat(2, 1fr);
}
.thresholdGrid {
grid-template-columns: 1fr;
}
.filterRow {
flex-direction: column;
}
}

View File

@@ -1,488 +1,58 @@
import { StatCard, Card, DataTable, Badge, ProgressBar, Spinner } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import { useOpenSearchStatus, usePipelineStats, useOpenSearchIndices, useOpenSearchPerformance, useDeleteIndex } from '../../api/queries/admin/opensearch';
import { useState } from 'react';
import { useAuthStore } from '../../auth/auth-store';
import { StatusBadge, type Status } from '../../components/admin/StatusBadge';
import { ConfirmDeleteDialog } from '../../components/admin/ConfirmDeleteDialog';
import {
useOpenSearchStatus,
usePipelineStats,
useIndices,
usePerformanceStats,
useDeleteIndex,
type IndicesParams,
} from '../../api/queries/admin/opensearch';
import { useThresholds, useSaveThresholds, type ThresholdConfig } from '../../api/queries/admin/thresholds';
import layout from '../../styles/AdminLayout.module.css';
import styles from './OpenSearchAdminPage.module.css';
type Section = 'pipeline' | 'indices' | 'performance' | 'operations' | 'thresholds';
export default function OpenSearchAdminPage() {
const { data: status } = useOpenSearchStatus();
const { data: pipeline } = usePipelineStats();
const { data: perf } = useOpenSearchPerformance();
const { data: indicesData } = useOpenSearchIndices();
const deleteIndex = useDeleteIndex();
function clusterHealthToStatus(health: string | undefined): Status {
switch (health?.toLowerCase()) {
case 'green': return 'healthy';
case 'yellow': return 'warning';
case 'red': return 'critical';
default: return 'unknown';
}
}
const SECTIONS: { key: Section; label: string; icon: string }[] = [
{ key: 'pipeline', label: 'Indexing Pipeline', icon: '>' },
{ key: 'indices', label: 'Indices', icon: '#' },
{ key: 'performance', label: 'Performance', icon: '~' },
{ key: 'operations', label: 'Operations', icon: '*' },
{ key: 'thresholds', label: 'Thresholds', icon: '=' },
];
export function OpenSearchAdminPage() {
const roles = useAuthStore((s) => s.roles);
if (!roles.includes('ADMIN')) {
return (
<div className={layout.page}>
<div className={layout.accessDenied}>
Access Denied -- this page requires the ADMIN role.
</div>
</div>
);
}
return <OpenSearchAdminContent />;
}
function OpenSearchAdminContent() {
const [selectedSection, setSelectedSection] = useState<Section>('pipeline');
const status = useOpenSearchStatus();
const pipeline = usePipelineStats();
const performance = usePerformanceStats();
const thresholds = useThresholds();
if (status.isLoading) {
return (
<div className={layout.page}>
<div className={layout.loading}>Loading...</div>
</div>
);
}
const os = status.data;
function getMiniStatus(key: Section): string {
switch (key) {
case 'pipeline':
return pipeline.data ? `Queue: ${pipeline.data.queueDepth}` : '--';
case 'indices':
return '--';
case 'performance':
return performance.data
? `${(performance.data.queryCacheHitRate * 100).toFixed(0)}% hit`
: '--';
case 'operations':
return 'Coming soon';
case 'thresholds':
return 'Configured';
}
}
const indexColumns: Column<any>[] = [
{ key: 'name', header: 'Index' },
{ key: 'health', header: 'Health', render: (v) => <Badge label={String(v)} color={v === 'green' ? 'success' : v === 'yellow' ? 'warning' : 'error'} /> },
{ key: 'docCount', header: 'Documents', sortable: true },
{ key: 'size', header: 'Size' },
{ key: 'primaryShards', header: 'Shards' },
];
return (
<div className={layout.page}>
<div className={layout.panelHeader}>
<div>
<div className={layout.panelTitle}>OpenSearch</div>
<div className={layout.panelSubtitle}>
<StatusBadge
status={clusterHealthToStatus(os?.clusterHealth)}
label={os?.clusterHealth ?? 'Unknown'}
/>
{os?.version && <span>v{os.version}</span>}
{os?.nodeCount !== undefined && <span>{os.nodeCount} node(s)</span>}
</div>
</div>
<button
type="button"
className={layout.btnAction}
onClick={() => {
status.refetch();
pipeline.refetch();
performance.refetch();
}}
>
Refresh All
</button>
<div>
<h2 style={{ marginBottom: '1rem' }}>OpenSearch Administration</h2>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<StatCard label="Status" value={status?.connected ? 'Connected' : 'Disconnected'} accent={status?.connected ? 'success' : 'error'} />
<StatCard label="Health" value={status?.clusterHealth ?? '—'} accent={status?.clusterHealth === 'green' ? 'success' : 'warning'} />
<StatCard label="Version" value={status?.version ?? '—'} />
<StatCard label="Nodes" value={status?.numberOfNodes ?? 0} />
</div>
<div className={layout.split}>
<div className={layout.listPane}>
<div className={layout.entityList}>
{SECTIONS.map((s) => (
<div
key={s.key}
className={`${layout.entityItem} ${selectedSection === s.key ? layout.entityItemSelected : ''}`}
onClick={() => setSelectedSection(s.key)}
>
<div className={layout.sectionIcon}>{s.icon}</div>
<div className={layout.entityInfo}>
<div className={layout.entityName}>{s.label}</div>
</div>
<div className={layout.miniStatus}>{getMiniStatus(s.key)}</div>
</div>
))}
{pipeline && (
<Card>
<div style={{ padding: '1rem' }}>
<h3 style={{ marginBottom: '0.5rem' }}>Indexing Pipeline</h3>
<ProgressBar value={(pipeline.queueDepth / pipeline.maxQueueSize) * 100} />
<div style={{ display: 'flex', gap: '2rem', marginTop: '0.5rem', fontSize: '0.875rem' }}>
<span>Queue: {pipeline.queueDepth}/{pipeline.maxQueueSize}</span>
<span>Indexed: {pipeline.indexedCount}</span>
<span>Failed: {pipeline.failedCount}</span>
<span>Rate: {pipeline.indexingRate}/s</span>
</div>
</div>
</div>
</Card>
)}
<div className={layout.detailPane}>
{selectedSection === 'pipeline' && (
<PipelineSection pipeline={pipeline} thresholds={thresholds.data} />
)}
{selectedSection === 'indices' && <IndicesSection />}
{selectedSection === 'performance' && (
<PerformanceSection performance={performance} thresholds={thresholds.data} />
)}
{selectedSection === 'operations' && <OperationsSection />}
{selectedSection === 'thresholds' && (
<OsThresholdsSection thresholds={thresholds.data} />
)}
</div>
<div style={{ marginTop: '1.5rem' }}>
<h3 style={{ marginBottom: '0.75rem' }}>Indices</h3>
<DataTable
columns={indexColumns}
data={(indicesData?.indices || []).map((i: any) => ({ ...i, id: i.name }))}
sortable
pageSize={20}
/>
</div>
</div>
);
}
function PipelineSection({
pipeline,
thresholds,
}: {
pipeline: ReturnType<typeof usePipelineStats>;
thresholds?: ThresholdConfig;
}) {
const data = pipeline.data;
if (!data) return null;
const queuePct = data.maxQueueSize > 0
? Math.round((data.queueDepth / data.maxQueueSize) * 100)
: 0;
const barColor =
thresholds?.opensearch?.queueDepthCritical && data.queueDepth >= thresholds.opensearch.queueDepthCritical ? '#ef4444'
: thresholds?.opensearch?.queueDepthWarning && data.queueDepth >= thresholds.opensearch.queueDepthWarning ? '#eab308'
: '#22c55e';
return (
<>
<div className={layout.detailSectionTitle}>Indexing Pipeline</div>
<div className={styles.progressContainer}>
<div className={styles.progressLabel}>
Queue: {data.queueDepth} / {data.maxQueueSize}
<span className={styles.progressPct}>{queuePct}%</span>
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${queuePct}%`, background: barColor }}
/>
</div>
</div>
<div className={styles.metricsGrid}>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.indexedCount.toLocaleString()}</span>
<span className={styles.metricLabel}>Total Indexed</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.failedCount.toLocaleString()}</span>
<span className={styles.metricLabel}>Total Failed</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.indexingRate.toFixed(1)}/s</span>
<span className={styles.metricLabel}>Indexing Rate</span>
</div>
</div>
</>
);
}
function IndicesSection() {
const [search, setSearch] = useState('');
const [page, setPage] = useState(0);
const pageSize = 10;
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const params: IndicesParams = {
search: search || undefined,
page,
size: pageSize,
};
const indices = useIndices(params);
const deleteMutation = useDeleteIndex();
const data = indices.data;
const totalPages = data?.totalPages ?? 0;
return (
<>
<div className={layout.detailSectionTitle}>Indices</div>
<div className={styles.filterRow}>
<input
className={styles.filterInput}
type="text"
placeholder="Search indices..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
/>
</div>
{!data ? (
<div className={layout.loading}>Loading...</div>
) : (
<>
<div className={styles.tableWrapper}>
<table className={styles.table}>
<thead>
<tr>
<th>Name</th>
<th>Health</th>
<th>Docs</th>
<th>Size</th>
<th>Shards</th>
<th></th>
</tr>
</thead>
<tbody>
{data.indices.map((idx) => (
<tr key={idx.name}>
<td className={styles.mono}>{idx.name}</td>
<td>
<span className={`${styles.healthBadge} ${styles[`health${idx.health.charAt(0).toUpperCase()}${idx.health.slice(1)}`]}`}>
{idx.health}
</span>
</td>
<td>{idx.docCount.toLocaleString()}</td>
<td>{idx.size}</td>
<td>{idx.primaryShards}p / {idx.replicaShards}r</td>
<td>
<button
type="button"
className={styles.deleteBtn}
onClick={() => setDeleteTarget(idx.name)}
>
Delete
</button>
</td>
</tr>
))}
{data.indices.length === 0 && (
<tr>
<td colSpan={6} className={styles.emptyState}>No indices found</td>
</tr>
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className={styles.pagination}>
<button
type="button"
className={styles.pageBtn}
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</button>
<span className={styles.pageInfo}>
Page {page + 1} of {totalPages}
</span>
<button
type="button"
className={styles.pageBtn}
disabled={page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
)}
</>
)}
<ConfirmDeleteDialog
isOpen={deleteTarget !== null}
onClose={() => setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) {
deleteMutation.mutate(deleteTarget);
setDeleteTarget(null);
}
}}
resourceName={deleteTarget ?? ''}
resourceType="index"
/>
</>
);
}
function PerformanceSection({
performance,
thresholds,
}: {
performance: ReturnType<typeof usePerformanceStats>;
thresholds?: ThresholdConfig;
}) {
const data = performance.data;
if (!data) return null;
const heapPct = data.jvmHeapMaxBytes > 0
? Math.round((data.jvmHeapUsedBytes / data.jvmHeapMaxBytes) * 100)
: 0;
const heapColor =
thresholds?.opensearch?.jvmHeapCritical && heapPct >= thresholds.opensearch.jvmHeapCritical ? '#ef4444'
: thresholds?.opensearch?.jvmHeapWarning && heapPct >= thresholds.opensearch.jvmHeapWarning ? '#eab308'
: '#22c55e';
return (
<>
<div className={layout.detailSectionTitle}>Performance</div>
<div className={styles.metricsGrid}>
<div className={styles.metric}>
<span className={styles.metricValue}>{(data.queryCacheHitRate * 100).toFixed(1)}%</span>
<span className={styles.metricLabel}>Query Cache Hit</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{(data.requestCacheHitRate * 100).toFixed(1)}%</span>
<span className={styles.metricLabel}>Request Cache Hit</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.searchLatencyMs.toFixed(1)}ms</span>
<span className={styles.metricLabel}>Query Latency</span>
</div>
<div className={styles.metric}>
<span className={styles.metricValue}>{data.indexingLatencyMs.toFixed(1)}ms</span>
<span className={styles.metricLabel}>Index Latency</span>
</div>
</div>
<div className={styles.heapSection}>
<div className={styles.progressLabel}>
JVM Heap: {formatBytes(data.jvmHeapUsedBytes)} / {formatBytes(data.jvmHeapMaxBytes)}
<span className={styles.progressPct}>{heapPct}%</span>
</div>
<div className={styles.progressBar}>
<div
className={styles.progressFill}
style={{ width: `${heapPct}%`, background: heapColor }}
/>
</div>
</div>
</>
);
}
function OperationsSection() {
return (
<>
<div className={layout.detailSectionTitle}>Operations</div>
<div className={styles.operationsGrid}>
<button type="button" className={styles.operationBtn} disabled title="Coming soon">
Force Merge
</button>
<button type="button" className={styles.operationBtn} disabled title="Coming soon">
Flush
</button>
<button type="button" className={styles.operationBtn} disabled title="Coming soon">
Clear Cache
</button>
</div>
</>
);
}
function OsThresholdsSection({ thresholds }: { thresholds?: ThresholdConfig }) {
const [form, setForm] = useState<ThresholdConfig | null>(null);
const saveMutation = useSaveThresholds();
const [status, setStatus] = useState<{ type: 'success' | 'error'; msg: string } | null>(null);
const current = form ?? thresholds;
if (!current) return null;
function updateOs(key: keyof ThresholdConfig['opensearch'], value: number | string) {
setForm((prev) => {
const base = prev ?? thresholds!;
return { ...base, opensearch: { ...base.opensearch, [key]: value } };
});
}
async function handleSave() {
const data = form ?? thresholds!;
try {
await saveMutation.mutateAsync(data);
setStatus({ type: 'success', msg: 'Thresholds saved.' });
setTimeout(() => setStatus(null), 3000);
} catch {
setStatus({ type: 'error', msg: 'Failed to save thresholds.' });
}
}
return (
<>
<div className={layout.detailSectionTitle}>Thresholds</div>
<div className={styles.thresholdGrid}>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Queue Warning</label>
<input
type="number"
className={styles.thresholdInput}
value={current.opensearch.queueDepthWarning}
onChange={(e) => updateOs('queueDepthWarning', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Queue Critical</label>
<input
type="number"
className={styles.thresholdInput}
value={current.opensearch.queueDepthCritical}
onChange={(e) => updateOs('queueDepthCritical', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Heap Warning %</label>
<input
type="number"
className={styles.thresholdInput}
value={current.opensearch.jvmHeapWarning}
onChange={(e) => updateOs('jvmHeapWarning', Number(e.target.value))}
/>
</div>
<div className={styles.thresholdField}>
<label className={styles.thresholdLabel}>Heap Critical %</label>
<input
type="number"
className={styles.thresholdInput}
value={current.opensearch.jvmHeapCritical}
onChange={(e) => updateOs('jvmHeapCritical', Number(e.target.value))}
/>
</div>
</div>
<div className={styles.thresholdActions}>
<button
type="button"
className={styles.btnPrimary}
onClick={handleSave}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? 'Saving...' : 'Save Thresholds'}
</button>
{status && (
<span className={status.type === 'success' ? styles.successMsg : styles.errorMsg}>
{status.msg}
</span>
)}
</div>
</>
);
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
}

View File

@@ -0,0 +1,178 @@
import { useState, useMemo } from 'react';
import {
Tabs, DataTable, Badge, Avatar, Button, Input, Modal, FormField,
Select, AlertDialog, StatCard, Spinner,
} from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import {
useUsers, useUser, useGroups, useGroup, useRoles, useRole, useRbacStats,
useCreateUser, useUpdateUser, useDeleteUser,
useAssignRoleToUser, useRemoveRoleFromUser,
useAddUserToGroup, useRemoveUserFromGroup,
useCreateGroup, useUpdateGroup, useDeleteGroup,
useCreateRole, useUpdateRole, useDeleteRole,
useAssignRoleToGroup, useRemoveRoleFromGroup,
} from '../../api/queries/admin/rbac';
export default function RbacPage() {
const [tab, setTab] = useState('users');
const { data: stats } = useRbacStats();
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>RBAC Management</h2>
<div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<StatCard label="Users" value={stats?.userCount ?? 0} />
<StatCard label="Groups" value={stats?.groupCount ?? 0} />
<StatCard label="Roles" value={stats?.roleCount ?? 0} />
</div>
<Tabs
tabs={[
{ label: 'Users', value: 'users', count: stats?.userCount },
{ label: 'Groups', value: 'groups', count: stats?.groupCount },
{ label: 'Roles', value: 'roles', count: stats?.roleCount },
]}
active={tab}
onChange={setTab}
/>
<div style={{ marginTop: '1rem' }}>
{tab === 'users' && <UsersTab />}
{tab === 'groups' && <GroupsTab />}
{tab === 'roles' && <RolesTab />}
</div>
</div>
);
}
function UsersTab() {
const { data: users, isLoading } = useUsers();
const [createOpen, setCreateOpen] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [form, setForm] = useState({ username: '', displayName: '', email: '', password: '' });
const createUser = useCreateUser();
const deleteUser = useDeleteUser();
const columns: Column<any>[] = [
{ key: 'userId', header: 'Username', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
{ key: 'displayName', header: 'Display Name' },
{ key: 'email', header: 'Email' },
{ key: 'provider', header: 'Provider', render: (v) => <Badge label={String(v)} /> },
{
key: 'effectiveRoles', header: 'Roles',
render: (v) => (
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
{(v as any[] || []).map((r: any) => <Badge key={r.id || r.name} label={r.name} color="auto" />)}
</div>
),
},
];
if (isLoading) return <Spinner />;
const rows = (users || []).map((u: any) => ({ ...u, id: u.userId }));
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create User</Button>
</div>
<DataTable columns={columns} data={rows} pageSize={20} />
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create User">
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
<FormField label="Username" required><Input value={form.username} onChange={(e) => setForm({ ...form, username: e.target.value })} /></FormField>
<FormField label="Display Name"><Input value={form.displayName} onChange={(e) => setForm({ ...form, displayName: e.target.value })} /></FormField>
<FormField label="Email"><Input value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} /></FormField>
<FormField label="Password"><Input type="password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} /></FormField>
<Button variant="primary" onClick={() => { createUser.mutate(form); setCreateOpen(false); setForm({ username: '', displayName: '', email: '', password: '' }); }}>Create</Button>
</div>
</Modal>
<AlertDialog
open={!!deleteId}
onClose={() => setDeleteId(null)}
onConfirm={() => { if (deleteId) deleteUser.mutate(deleteId); setDeleteId(null); }}
title="Delete User"
description={`Are you sure you want to delete user "${deleteId}"?`}
confirmLabel="Delete"
variant="danger"
/>
</div>
);
}
function GroupsTab() {
const { data: groups, isLoading } = useGroups();
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: '' });
const createGroup = useCreateGroup();
const columns: Column<any>[] = [
{ key: 'name', header: 'Name', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
{ key: 'members', header: 'Members', render: (v) => String((v as any[])?.length ?? 0) },
{
key: 'effectiveRoles', header: 'Roles',
render: (v) => (
<div style={{ display: 'flex', gap: '0.25rem', flexWrap: 'wrap' }}>
{(v as any[] || []).map((r: any) => <Badge key={r.id || r.name} label={r.name} color="auto" />)}
</div>
),
},
];
if (isLoading) return <Spinner />;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create Group</Button>
</div>
<DataTable columns={columns} data={groups || []} pageSize={20} />
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create Group">
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
<FormField label="Name" required><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></FormField>
<Button variant="primary" onClick={() => { createGroup.mutate(form); setCreateOpen(false); setForm({ name: '' }); }}>Create</Button>
</div>
</Modal>
</div>
);
}
function RolesTab() {
const { data: roles, isLoading } = useRoles();
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: '', description: '', scope: '' });
const createRole = useCreateRole();
const columns: Column<any>[] = [
{ key: 'name', header: 'Name', render: (v) => <span style={{ fontWeight: 500 }}>{String(v)}</span> },
{ key: 'description', header: 'Description' },
{ key: 'scope', header: 'Scope', render: (v) => v ? <Badge label={String(v)} /> : null },
{ key: 'system', header: 'System', render: (v) => v ? <Badge label="System" color="warning" /> : null },
{ key: 'effectivePrincipals', header: 'Users', render: (v) => String((v as any[])?.length ?? 0) },
];
if (isLoading) return <Spinner />;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1rem' }}>
<Button variant="primary" onClick={() => setCreateOpen(true)}>Create Role</Button>
</div>
<DataTable columns={columns} data={roles || []} pageSize={20} />
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title="Create Role">
<div style={{ display: 'grid', gap: '1rem', padding: '1rem' }}>
<FormField label="Name" required><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></FormField>
<FormField label="Description"><Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></FormField>
<FormField label="Scope"><Input value={form.scope} onChange={(e) => setForm({ ...form, scope: e.target.value })} /></FormField>
<Button variant="primary" onClick={() => { createRole.mutate(form); setCreateOpen(false); setForm({ name: '', description: '', scope: '' }); }}>Create</Button>
</div>
</Modal>
</div>
);
}

View File

@@ -1,151 +0,0 @@
import { useMemo } from 'react';
import { useRbacStats, useGroups } from '../../../api/queries/admin/rbac';
import type { GroupDetail } from '../../../api/queries/admin/rbac';
import styles from './RbacPage.module.css';
export function DashboardTab() {
const stats = useRbacStats();
const groups = useGroups();
const groupList: GroupDetail[] = groups.data ?? [];
// Build inheritance diagram data: top-level groups sorted alphabetically,
// children sorted alphabetically and indented below their parent.
const { topLevelGroups, childMap } = useMemo(() => {
const sorted = [...groupList].sort((a, b) => a.name.localeCompare(b.name));
const top = sorted.filter((g) => !g.parentGroupId);
const cMap = new Map<string, GroupDetail[]>();
for (const g of sorted) {
if (g.parentGroupId) {
const children = cMap.get(g.parentGroupId) ?? [];
children.push(g);
cMap.set(g.parentGroupId, children);
}
}
return { topLevelGroups: top, childMap: cMap };
}, [groupList]);
// Derive roles from groups in tree order (top-level then children), collecting
// each group's directRoles, deduplicating by id and preserving first-seen order.
const roleList = useMemo(() => {
const seen = new Set<string>();
const result: { id: string; name: string }[] = [];
for (const g of topLevelGroups) {
for (const r of g.directRoles) {
if (!seen.has(r.id)) {
seen.add(r.id);
result.push(r);
}
}
for (const child of childMap.get(g.id) ?? []) {
for (const r of child.directRoles) {
if (!seen.has(r.id)) {
seen.add(r.id);
result.push(r);
}
}
}
}
return result;
}, [topLevelGroups, childMap]);
// Collect unique users from all groups, sorted alphabetically by displayName.
const allUsers = useMemo(() => {
const userMap = new Map<string, string>();
for (const g of groupList) {
for (const m of g.members) {
userMap.set(m.userId, m.displayName);
}
}
return new Map(
[...userMap.entries()].sort((a, b) => a[1].localeCompare(b[1]))
);
}, [groupList]);
if (stats.isLoading) {
return <div className={styles.loading}>Loading...</div>;
}
const s = stats.data;
return (
<div>
<div className={styles.panelHeader}>
<div>
<div className={styles.panelTitle}>RBAC Overview</div>
<div className={styles.panelSubtitle}>Inheritance model and system summary</div>
</div>
</div>
<div className={styles.overviewGrid}>
<div className={styles.statCard}>
<div className={styles.statLabel}>Users</div>
<div className={styles.statValue}>{s?.userCount ?? 0}</div>
<div className={styles.statSub}>{s?.activeUserCount ?? 0} active</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>Groups</div>
<div className={styles.statValue}>{s?.groupCount ?? 0}</div>
<div className={styles.statSub}>Nested up to {s?.maxGroupDepth ?? 0} levels</div>
</div>
<div className={styles.statCard}>
<div className={styles.statLabel}>Roles</div>
<div className={styles.statValue}>{s?.roleCount ?? 0}</div>
<div className={styles.statSub}>Direct + inherited</div>
</div>
</div>
<div className={styles.inhDiagram}>
<div className={styles.inhTitle}>Inheritance model</div>
<div className={styles.inhRow}>
<div className={styles.inhCol}>
<div className={styles.inhColTitle}>Groups</div>
{topLevelGroups.map((g) => (
<div key={g.id}>
<div className={`${styles.inhItem} ${styles.inhItemGroup}`}>{g.name}</div>
{(childMap.get(g.id) ?? []).map((child) => (
<div
key={child.id}
className={`${styles.inhItem} ${styles.inhItemGroup} ${styles.inhItemChild}`}
>
{child.name}
</div>
))}
</div>
))}
</div>
<div className={styles.inhArrow}>&rarr;</div>
<div className={styles.inhCol}>
<div className={styles.inhColTitle}>Roles on groups</div>
{roleList.map((r) => (
<div key={r.id} className={`${styles.inhItem} ${styles.inhItemRole}`}>
{r.name}
</div>
))}
</div>
<div className={styles.inhArrow}>&rarr;</div>
<div className={styles.inhCol}>
<div className={styles.inhColTitle}>Users inherit</div>
{Array.from(allUsers.entries())
.slice(0, 5)
.map(([id, name]) => (
<div key={id} className={`${styles.inhItem} ${styles.inhItemUser}`}>
{name}
</div>
))}
{allUsers.size > 5 && (
<div className={styles.inhItem} style={{ fontSize: 10, color: 'var(--text-muted)' }}>
+ {allUsers.size - 5} more...
</div>
)}
</div>
</div>
<div className={styles.inheritNote} style={{ marginTop: 12 }}>
Users inherit all roles from every group they belong to and transitively from parent
groups. Roles can also be assigned directly to users, overriding or extending inherited
permissions.
</div>
</div>
</div>
);
}

View File

@@ -1,428 +0,0 @@
import { useState, useMemo } from 'react';
import {
useGroups,
useGroup,
useCreateGroup,
useDeleteGroup,
useUpdateGroup,
useAssignRoleToGroup,
useRemoveRoleFromGroup,
useRoles,
} from '../../../api/queries/admin/rbac';
import type { GroupDetail } from '../../../api/queries/admin/rbac';
import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog';
import { MultiSelectDropdown } from './components/MultiSelectDropdown';
import { hashColor } from './avatar-colors';
import styles from './RbacPage.module.css';
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
function getGroupMeta(group: GroupDetail, groupMap: Map<string, GroupDetail>): string {
const parts: string[] = [];
if (group.parentGroupId) {
const parent = groupMap.get(group.parentGroupId);
parts.push(`Child of ${parent?.name ?? 'unknown'}`);
} else {
parts.push('Top-level');
}
if (group.childGroups.length > 0) {
parts.push(`${group.childGroups.length} child group${group.childGroups.length !== 1 ? 's' : ''}`);
}
parts.push(`${group.members.length} member${group.members.length !== 1 ? 's' : ''}`);
return parts.join(' · ');
}
function getDescendantIds(groupId: string, allGroups: GroupDetail[]): Set<string> {
const ids = new Set<string>();
function walk(id: string) {
const g = allGroups.find(x => x.id === id);
if (!g) return;
for (const child of g.childGroups) {
if (!ids.has(child.id)) {
ids.add(child.id);
walk(child.id);
}
}
}
walk(groupId);
return ids;
}
export function GroupsTab() {
const groups = useGroups();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [newName, setNewName] = useState('');
const [newParentId, setNewParentId] = useState('');
const [createError, setCreateError] = useState('');
const createGroup = useCreateGroup();
const { data: allRoles } = useRoles();
const groupDetail = useGroup(selectedId);
const groupMap = useMemo(() => {
const map = new Map<string, GroupDetail>();
for (const g of groups.data ?? []) {
map.set(g.id, g);
}
return map;
}, [groups.data]);
const filtered = useMemo(() => {
const list = groups.data ?? [];
if (!filter) return list;
const lower = filter.toLowerCase();
return list.filter((g) => g.name.toLowerCase().includes(lower));
}, [groups.data, filter]);
if (groups.isLoading) {
return <div className={styles.loading}>Loading...</div>;
}
const detail = groupDetail.data;
return (
<>
<div className={styles.panelHeader}>
<div>
<div className={styles.panelTitle}>Groups</div>
<div className={styles.panelSubtitle}>
Organise users in nested hierarchies; roles propagate to all members
</div>
</div>
<button type="button" className={styles.btnAdd} onClick={() => setShowCreateForm(true)}>+ Add group</button>
</div>
<div className={styles.split}>
<div className={styles.listPane}>
<div className={styles.searchBar}>
<input
className={styles.searchInput}
placeholder="Search groups..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
{showCreateForm && (
<div className={styles.createForm}>
<div className={styles.createFormRow}>
<label className={styles.createFormLabel}>Name</label>
<input className={styles.createFormInput} value={newName}
onChange={e => { setNewName(e.target.value); setCreateError(''); }}
placeholder="Group name" autoFocus />
</div>
<div className={styles.createFormRow}>
<label className={styles.createFormLabel}>Parent</label>
<select className={styles.createFormSelect} value={newParentId}
onChange={e => setNewParentId(e.target.value)}>
<option value="">(Top-level)</option>
{(groups.data || []).map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
</div>
{createError && <div className={styles.createFormError}>{createError}</div>}
<div className={styles.createFormActions}>
<button type="button" className={styles.createFormBtn}
onClick={() => { setShowCreateForm(false); setNewName(''); setNewParentId(''); setCreateError(''); }}>Cancel</button>
<button type="button" className={styles.createFormBtnPrimary}
disabled={!newName.trim() || createGroup.isPending}
onClick={() => {
createGroup.mutate({ name: newName.trim(), parentGroupId: newParentId || undefined }, {
onSuccess: () => { setShowCreateForm(false); setNewName(''); setNewParentId(''); setCreateError(''); },
onError: (err) => setCreateError(err instanceof Error ? err.message : 'Failed to create group'),
});
}}>Create</button>
</div>
</div>
)}
<div className={styles.entityList}>
{filtered.map((group) => {
const isSelected = group.id === selectedId;
const color = hashColor(group.name);
return (
<div
key={group.id}
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
onClick={() => setSelectedId(group.id)}
>
<div className={styles.avatar} style={{ background: color.bg, color: color.fg, borderRadius: 8 }}>
{getInitials(group.name)}
</div>
<div className={styles.entityInfo}>
<div className={styles.entityName}>{group.name}</div>
<div className={styles.entityMeta}>{getGroupMeta(group, groupMap)}</div>
<div className={styles.tagList}>
{group.directRoles.map((r) => (
<span key={r.id} className={`${styles.tag} ${styles.tagRole}`}>
{r.name}
</span>
))}
{group.effectiveRoles
.filter((er) => !group.directRoles.some((dr) => dr.id === er.id))
.map((r) => (
<span
key={r.id}
className={`${styles.tag} ${styles.tagRole} ${styles.tagInherited}`}
>
{r.name}
</span>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
<div className={styles.detailPane}>
{!detail ? (
<div className={styles.detailEmpty}>
<span>Select a group to view details</span>
</div>
) : (
<GroupDetailView
group={detail}
groupMap={groupMap}
allGroups={groups.data || []}
allRoles={allRoles || []}
onDeselect={() => setSelectedId(null)}
/>
)}
</div>
</div>
</>
);
}
const ADMINS_GROUP_ID = '00000000-0000-0000-0000-000000000010';
function GroupDetailView({
group,
groupMap,
allGroups,
allRoles,
onDeselect,
}: {
group: GroupDetail;
groupMap: Map<string, GroupDetail>;
allGroups: GroupDetail[];
allRoles: Array<{ id: string; name: string }>;
onDeselect: () => void;
}) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [editingName, setEditingName] = useState(false);
const [nameValue, setNameValue] = useState(group.name);
const [editingParent, setEditingParent] = useState(false);
const [parentValue, setParentValue] = useState(group.parentGroupId || '');
const deleteGroup = useDeleteGroup();
const updateGroup = useUpdateGroup();
const assignRole = useAssignRoleToGroup();
const removeRole = useRemoveRoleFromGroup();
const isBuiltIn = group.id === ADMINS_GROUP_ID;
// Reset editing state when group changes
const [prevGroupId, setPrevGroupId] = useState(group.id);
if (prevGroupId !== group.id) {
setPrevGroupId(group.id);
setEditingName(false);
setNameValue(group.name);
setEditingParent(false);
setParentValue(group.parentGroupId || '');
}
const hierarchyLabel = group.parentGroupId
? `Child of ${groupMap.get(group.parentGroupId)?.name ?? 'unknown'}`
: 'Top-level group';
const inheritedRoles = group.effectiveRoles.filter(
(er) => !group.directRoles.some((dr) => dr.id === er.id)
);
const availableRoles = (allRoles || [])
.filter(r => !group.directRoles.some(dr => dr.id === r.id))
.map(r => ({ id: r.id, label: r.name }));
const descendantIds = getDescendantIds(group.id, allGroups);
const parentOptions = allGroups.filter(g => g.id !== group.id && !descendantIds.has(g.id));
// Build hierarchy tree
const tree = useMemo(() => {
const rows: { name: string; depth: number }[] = [];
// Walk up to find root
const ancestors: GroupDetail[] = [];
let current: GroupDetail | undefined = group;
while (current?.parentGroupId) {
const parent = groupMap.get(current.parentGroupId);
if (parent) ancestors.unshift(parent);
current = parent;
}
for (let i = 0; i < ancestors.length; i++) {
rows.push({ name: ancestors[i].name, depth: i });
}
rows.push({ name: group.name, depth: ancestors.length });
for (const child of group.childGroups) {
rows.push({ name: child.name, depth: ancestors.length + 1 });
}
return rows;
}, [group, groupMap]);
const color = hashColor(group.name);
return (
<>
<div className={styles.detailHeader}>
<div className={styles.detailHeaderInfo}>
<div className={styles.detailAvatar} style={{ background: color.bg, color: color.fg, borderRadius: 10 }}>
{getInitials(group.name)}
</div>
{editingName ? (
<input
className={styles.editNameInput}
value={nameValue}
onChange={e => setNameValue(e.target.value)}
onBlur={() => {
if (nameValue.trim() && nameValue !== group.name) {
updateGroup.mutate({ id: group.id, name: nameValue.trim(), parentGroupId: group.parentGroupId });
}
setEditingName(false);
}}
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { setNameValue(group.name); setEditingName(false); } }}
autoFocus
/>
) : (
<div className={styles.detailName}
onClick={() => !isBuiltIn && setEditingName(true)}
style={{ cursor: isBuiltIn ? 'default' : 'pointer' }}
title={isBuiltIn ? undefined : 'Click to edit'}>
{group.name}
</div>
)}
<div className={styles.detailEmail}>{hierarchyLabel}</div>
</div>
<button type="button" className={styles.btnDelete}
onClick={() => setShowDeleteDialog(true)}
disabled={isBuiltIn || deleteGroup.isPending}
title={isBuiltIn ? 'Built-in group cannot be deleted' : 'Delete group'}>Delete</button>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>ID</span>
<span className={`${styles.fieldVal} ${styles.fieldMono}`}>{group.id}</span>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>Parent</span>
{editingParent ? (
<div className={styles.parentEditRow}>
<select className={styles.parentSelect} value={parentValue}
onChange={e => setParentValue(e.target.value)}>
<option value="">(Top-level)</option>
{parentOptions.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
<button type="button" className={styles.parentEditBtn}
onClick={() => {
updateGroup.mutate(
{ id: group.id, name: group.name, parentGroupId: parentValue || null },
{ onSuccess: () => setEditingParent(false) }
);
}}
disabled={updateGroup.isPending}>Save</button>
<button type="button" className={styles.parentEditBtn}
onClick={() => { setParentValue(group.parentGroupId || ''); setEditingParent(false); }}>Cancel</button>
</div>
) : (
<span className={styles.fieldVal}>
{hierarchyLabel}
{!isBuiltIn && (
<button type="button" className={styles.fieldEditBtn}
onClick={() => setEditingParent(true)}>Edit</button>
)}
</span>
)}
</div>
<hr className={styles.divider} />
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>
Members <span>direct</span>
</div>
{group.members.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No direct members</span>
) : (
group.members.map((m) => (
<span key={m.userId} className={styles.chip}>
{m.displayName}
</span>
))
)}
{group.childGroups.length > 0 && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 6 }}>
+ all members of {group.childGroups.map((c) => c.name).join(', ')}
</div>
)}
</div>
{group.childGroups.length > 0 && (
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>Child groups</div>
{group.childGroups.map((c) => (
<span key={c.id} className={`${styles.chip} ${styles.chipGroup}`}>
{c.name}
</span>
))}
</div>
)}
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>
Assigned roles <span>on this group</span>
</div>
{group.directRoles.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No roles assigned</span>
) : (
group.directRoles.map((r) => (
<span key={r.id} className={`${styles.chip} ${styles.chipRole}`}>
{r.name}
<button type="button" className={styles.chipRemove}
onClick={() => removeRole.mutate({ groupId: group.id, roleId: r.id })}
disabled={removeRole.isPending} title="Remove role">x</button>
</span>
))
)}
<MultiSelectDropdown items={availableRoles}
onApply={async (ids) => { await Promise.allSettled(ids.map(rid => assignRole.mutateAsync({ groupId: group.id, roleId: rid }))); }}
placeholder="Search roles..." />
{inheritedRoles.length > 0 && (
<div className={styles.inheritNote}>
{group.childGroups.length > 0
? `Child groups ${group.childGroups.map((c) => c.name).join(' and ')} inherit these roles, and may additionally carry their own.`
: 'Roles are inherited from parent groups in the hierarchy.'}
</div>
)}
</div>
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>Group hierarchy</div>
{tree.map((node, i) => (
<div key={i} className={styles.treeRow}>
{node.depth > 0 && (
<div className={styles.treeIndent}>
<div className={styles.treeCorner} />
</div>
)}
{node.name}
</div>
))}
</div>
<ConfirmDeleteDialog isOpen={showDeleteDialog} onClose={() => setShowDeleteDialog(false)}
onConfirm={() => { deleteGroup.mutate(group.id, { onSuccess: () => { setShowDeleteDialog(false); onDeselect(); } }); }}
resourceName={group.name} resourceType="group" />
</>
);
}

View File

@@ -1,894 +0,0 @@
/* ─── Page Layout ─── */
.page {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.accessDenied {
text-align: center;
padding: 64px 16px;
color: var(--text-muted);
font-size: 14px;
}
/* ─── Tabs ─── */
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.tab {
font-size: 13px;
padding: 10px 18px;
cursor: pointer;
color: var(--text-secondary);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: none;
border-top: none;
border-left: none;
border-right: none;
font-family: var(--font-body);
transition: color 0.15s;
}
.tab:hover {
color: var(--text-primary);
}
.tabActive {
color: var(--text-primary);
border-bottom-color: var(--green);
font-weight: 500;
}
/* ─── Split Layout ─── */
.split {
display: flex;
flex: 1;
overflow: hidden;
}
.listPane {
width: 52%;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.detailPane {
flex: 1;
overflow-y: auto;
padding: 20px;
}
/* ─── Panel Header ─── */
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px 12px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.panelTitle {
font-size: 15px;
font-weight: 500;
color: var(--text-primary);
}
.panelSubtitle {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.btnAdd {
font-size: 12px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: transparent;
color: var(--text-primary);
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-body);
}
.btnAdd:hover {
background: var(--bg-hover);
}
/* ─── Search Bar ─── */
.searchBar {
padding: 10px 20px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.searchInput {
width: 100%;
padding: 7px 10px;
font-size: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-base);
color: var(--text-primary);
outline: none;
font-family: var(--font-body);
transition: border-color 0.15s;
}
.searchInput:focus {
border-color: var(--amber-dim);
}
.searchInput::placeholder {
color: var(--text-muted);
}
/* ─── Entity List ─── */
.entityList {
flex: 1;
overflow-y: auto;
}
.entityItem {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
border-bottom: 1px solid var(--border-subtle);
cursor: pointer;
transition: background 0.1s;
}
.entityItem:hover {
background: var(--bg-hover);
}
.entityItemSelected {
background: var(--bg-raised);
}
/* ─── Avatars ─── */
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 500;
flex-shrink: 0;
}
.avatarUser {
background: rgba(59, 130, 246, 0.15);
color: var(--blue);
}
.avatarGroup {
background: rgba(16, 185, 129, 0.15);
color: var(--green);
border-radius: 8px;
}
.avatarRole {
background: rgba(240, 180, 41, 0.15);
color: var(--amber);
border-radius: 6px;
}
/* ─── Entity Info ─── */
.entityInfo {
flex: 1;
min-width: 0;
}
.entityName {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entityMeta {
font-size: 11px;
color: var(--text-muted);
margin-top: 1px;
}
/* ─── Tags ─── */
.tagList {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 4px;
}
.tag {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
}
.tagRole {
background: var(--amber-glow);
color: var(--amber);
}
.tagGroup {
background: var(--green-glow);
color: var(--green);
}
.tagInherited {
opacity: 0.65;
font-style: italic;
}
/* ─── Status Dot ─── */
.statusDot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.statusActive {
background: var(--green);
}
.statusInactive {
background: var(--text-muted);
}
/* ─── OIDC Badge ─── */
.oidcBadge {
font-size: 10px;
padding: 1px 6px;
border-radius: 4px;
background: var(--cyan-glow);
color: var(--cyan);
margin-left: 6px;
}
/* ─── Lock Icon (system role) ─── */
.lockIcon {
font-size: 11px;
color: var(--text-muted);
margin-left: 4px;
}
/* ─── Detail Pane ─── */
.detailEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 13px;
gap: 8px;
}
.detailAvatar {
width: 44px;
height: 44px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 500;
margin-bottom: 12px;
}
.detailName {
font-size: 16px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.detailEmail {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 12px;
}
.divider {
border: none;
border-top: 1px solid var(--border-subtle);
margin: 12px 0;
}
.detailSection {
margin-bottom: 20px;
}
.detailSectionTitle {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-muted);
margin-bottom: 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
.detailSectionTitle span {
font-size: 10px;
color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
}
/* ─── Chips ─── */
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
padding: 3px 8px;
border-radius: 20px;
border: 1px solid var(--border);
color: var(--text-secondary);
background: var(--bg-raised);
margin: 2px;
}
.chipRole {
border-color: var(--amber-dim);
color: var(--amber);
background: var(--amber-glow);
}
.chipGroup {
border-color: var(--green);
color: var(--green);
background: var(--green-glow);
}
.chipUser {
border-color: var(--blue);
color: var(--blue);
background: rgba(59, 130, 246, 0.1);
}
.chipInherited {
border-style: dashed;
opacity: 0.75;
}
.chipSource {
font-size: 9px;
opacity: 0.6;
margin-left: 2px;
}
/* ─── Inherit Note ─── */
.inheritNote {
font-size: 11px;
color: var(--text-secondary);
font-style: italic;
margin-top: 6px;
padding: 8px 10px;
background: var(--bg-surface);
border-radius: var(--radius-sm);
border-left: 2px solid var(--green);
}
/* ─── Field Rows ─── */
.fieldRow {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.fieldLabel {
font-size: 11px;
color: var(--text-muted);
width: 70px;
flex-shrink: 0;
}
.fieldVal {
font-size: 12px;
color: var(--text-primary);
}
.fieldMono {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
/* ─── Tree ─── */
.treeRow {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 0;
font-size: 12px;
color: var(--text-secondary);
}
.treeIndent {
width: 16px;
flex-shrink: 0;
display: flex;
justify-content: center;
}
.treeCorner {
width: 10px;
height: 10px;
border-left: 1px solid var(--border);
border-bottom: 1px solid var(--border);
border-bottom-left-radius: 2px;
}
/* ─── Overview / Dashboard ─── */
.overviewGrid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 12px;
padding: 16px 20px;
}
.statCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 14px;
}
.statLabel {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 6px;
}
.statValue {
font-size: 22px;
font-weight: 500;
color: var(--text-primary);
line-height: 1;
}
.statSub {
font-size: 11px;
color: var(--text-muted);
margin-top: 4px;
}
/* ─── Inheritance Diagram ─── */
.inhDiagram {
margin: 16px 20px 0;
}
.inhTitle {
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-muted);
margin-bottom: 10px;
}
.inhRow {
display: flex;
align-items: flex-start;
gap: 0;
}
.inhCol {
flex: 1;
}
.inhColTitle {
font-size: 11px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
text-align: center;
}
.inhArrow {
width: 40px;
display: flex;
align-items: center;
justify-content: center;
padding-top: 22px;
color: var(--text-muted);
font-size: 14px;
}
.inhItem {
font-size: 11px;
padding: 4px 8px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
margin-bottom: 4px;
color: var(--text-secondary);
background: var(--bg-raised);
text-align: center;
}
.inhItemGroup {
border-color: var(--green);
color: var(--green);
background: var(--green-glow);
}
.inhItemRole {
border-color: var(--amber-dim);
color: var(--amber);
background: var(--amber-glow);
}
.inhItemUser {
border-color: var(--blue);
color: var(--blue);
background: rgba(59, 130, 246, 0.1);
}
.inhItemChild {
margin-left: 10px;
font-size: 10px;
}
/* ─── Loading / Error ─── */
.loading {
text-align: center;
padding: 32px;
color: var(--text-muted);
font-size: 14px;
}
.tabContent {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ─── Multi-Select Dropdown ─── */
.multiSelectWrapper {
position: relative;
display: inline-block;
}
.addChip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
padding: 3px 10px;
border-radius: 20px;
border: 1px dashed var(--amber);
color: var(--amber);
background: rgba(240, 180, 41, 0.08);
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.addChip:hover {
background: rgba(240, 180, 41, 0.18);
color: var(--text-primary);
}
.dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 10;
min-width: 220px;
max-height: 300px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
margin-top: 4px;
}
.dropdownSearch {
padding: 8px;
border-bottom: 1px solid var(--border);
}
.dropdownSearchInput {
width: 100%;
padding: 5px 8px;
font-size: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-surface);
color: var(--text-primary);
outline: none;
}
.dropdownSearchInput:focus {
border-color: var(--amber);
}
.dropdownList {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.dropdownItem {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
transition: background 0.1s;
}
.dropdownItem:hover {
background: var(--bg-hover);
}
.dropdownItemCheckbox {
accent-color: var(--amber);
}
.dropdownFooter {
padding: 8px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
}
.dropdownApply {
font-size: 11px;
padding: 4px 12px;
border: none;
border-radius: var(--radius-sm);
background: var(--amber);
color: #000;
cursor: pointer;
font-weight: 500;
}
.dropdownApply:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.dropdownEmpty {
padding: 12px;
text-align: center;
font-size: 12px;
color: var(--text-muted);
}
/* ─── Remove button on chips ─── */
.chipRemove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
opacity: 0.4;
font-size: 10px;
padding: 0;
margin-left: 2px;
border-radius: 50%;
transition: opacity 0.1s;
}
.chipRemove:hover {
opacity: 0.9;
}
.chipRemove:disabled {
cursor: not-allowed;
opacity: 0.2;
}
/* ─── Delete button ─── */
.btnDelete {
font-size: 11px;
padding: 4px 10px;
border: 1px solid var(--rose);
border-radius: var(--radius-sm);
background: transparent;
color: var(--rose);
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
transition: background 0.1s;
}
.btnDelete:hover {
background: rgba(244, 63, 94, 0.1);
}
.btnDelete:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ─── Inline Create Form ─── */
.createForm {
padding: 12px 20px;
border-bottom: 1px solid var(--border);
background: var(--bg-surface);
display: flex;
flex-direction: column;
gap: 8px;
}
.createFormRow {
display: flex;
align-items: center;
gap: 8px;
}
.createFormLabel {
font-size: 11px;
color: var(--text-muted);
width: 60px;
flex-shrink: 0;
}
.createFormInput {
flex: 1;
padding: 5px 8px;
font-size: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-primary);
outline: none;
}
.createFormInput:focus {
border-color: var(--amber);
}
.createFormSelect {
flex: 1;
padding: 5px 8px;
font-size: 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-primary);
outline: none;
}
.createFormActions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.createFormBtn {
font-size: 11px;
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-primary);
cursor: pointer;
}
.createFormBtnPrimary {
composes: createFormBtn;
background: var(--amber);
border-color: var(--amber);
color: #000;
font-weight: 500;
}
.createFormBtnPrimary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.createFormError {
font-size: 11px;
color: var(--rose);
}
/* ─── Detail header with actions ─── */
.detailHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.detailHeaderInfo {
flex: 1;
}
/* ─── Parent group dropdown ─── */
.parentSelect {
padding: 3px 6px;
font-size: 11px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-primary);
outline: none;
max-width: 200px;
}
/* ─── Parent Edit Mode ─── */
.parentEditRow {
display: flex;
gap: 6px;
align-items: center;
flex: 1;
}
.parentEditBtn {
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: 2px 8px;
font-size: 11px;
cursor: pointer;
}
.parentEditBtn:hover {
background: var(--bg-hover);
}
.fieldEditBtn {
background: none;
border: none;
color: var(--amber);
font-size: 11px;
cursor: pointer;
margin-left: 8px;
padding: 0;
}
.fieldEditBtn:hover {
text-decoration: underline;
}
/* ─── Editable Name Input ─── */
.editNameInput {
font-size: 16px;
font-weight: 500;
color: var(--text-primary);
background: var(--bg-raised);
border: 1px solid var(--amber);
border-radius: var(--radius-sm);
padding: 2px 6px;
outline: none;
width: 100%;
max-width: 300px;
}

View File

@@ -1,66 +0,0 @@
import { useSearchParams } from 'react-router';
import { useAuthStore } from '../../../auth/auth-store';
import { DashboardTab } from './DashboardTab';
import { UsersTab } from './UsersTab';
import { GroupsTab } from './GroupsTab';
import { RolesTab } from './RolesTab';
import styles from './RbacPage.module.css';
const TABS = ['dashboard', 'users', 'groups', 'roles'] as const;
type TabKey = (typeof TABS)[number];
const TAB_LABELS: Record<TabKey, string> = {
dashboard: 'Dashboard',
users: 'Users',
groups: 'Groups',
roles: 'Roles',
};
export function RbacPage() {
const roles = useAuthStore((s) => s.roles);
if (!roles.includes('ADMIN')) {
return (
<div className={styles.page}>
<div className={styles.accessDenied}>
Access Denied this page requires the ADMIN role.
</div>
</div>
);
}
return <RbacContent />;
}
function RbacContent() {
const [searchParams, setSearchParams] = useSearchParams();
const rawTab = searchParams.get('tab');
const activeTab: TabKey = TABS.includes(rawTab as TabKey) ? (rawTab as TabKey) : 'dashboard';
function setTab(tab: TabKey) {
setSearchParams({ tab }, { replace: true });
}
return (
<div className={styles.page}>
<div className={styles.tabs}>
{TABS.map((tab) => (
<button
key={tab}
type="button"
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
onClick={() => setTab(tab)}
>
{TAB_LABELS[tab]}
</button>
))}
</div>
<div className={styles.tabContent}>
{activeTab === 'dashboard' && <DashboardTab />}
{activeTab === 'users' && <UsersTab />}
{activeTab === 'groups' && <GroupsTab />}
{activeTab === 'roles' && <RolesTab />}
</div>
</div>
);
}

View File

@@ -1,295 +0,0 @@
import { useState, useMemo } from 'react';
import { useRoles, useRole, useCreateRole, useDeleteRole, useUpdateRole } from '../../../api/queries/admin/rbac';
import type { RoleDetail } from '../../../api/queries/admin/rbac';
import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog';
import { hashColor } from './avatar-colors';
import styles from './RbacPage.module.css';
function getInitials(name: string): string {
return name.slice(0, 2).toUpperCase();
}
function getRoleMeta(role: RoleDetail): string {
const parts: string[] = [];
if (role.description) parts.push(role.description);
const total = role.assignedGroups.length + role.directUsers.length;
parts.push(`${total} assignment${total !== 1 ? 's' : ''}`);
return parts.join(' · ');
}
export function RolesTab() {
const roles = useRoles();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const [newScope, setNewScope] = useState('custom');
const [createError, setCreateError] = useState('');
const createRole = useCreateRole();
const roleDetail = useRole(selectedId);
const filtered = useMemo(() => {
const list = roles.data ?? [];
if (!filter) return list;
const lower = filter.toLowerCase();
return list.filter(
(r) =>
r.name.toLowerCase().includes(lower) ||
r.description.toLowerCase().includes(lower)
);
}, [roles.data, filter]);
if (roles.isLoading) {
return <div className={styles.loading}>Loading...</div>;
}
const detail = roleDetail.data;
return (
<>
<div className={styles.panelHeader}>
<div>
<div className={styles.panelTitle}>Roles</div>
<div className={styles.panelSubtitle}>
Define permission scopes; assign to users or groups
</div>
</div>
<button type="button" className={styles.btnAdd} onClick={() => setShowCreateForm(true)}>+ Add role</button>
</div>
<div className={styles.split}>
<div className={styles.listPane}>
<div className={styles.searchBar}>
<input
className={styles.searchInput}
placeholder="Search roles..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
{showCreateForm && (
<div className={styles.createForm}>
<div className={styles.createFormRow}>
<label className={styles.createFormLabel}>Name</label>
<input className={styles.createFormInput} value={newName}
onChange={e => { setNewName(e.target.value); setCreateError(''); }}
placeholder="Role name" autoFocus />
</div>
<div className={styles.createFormRow}>
<label className={styles.createFormLabel}>Desc</label>
<input className={styles.createFormInput} value={newDesc}
onChange={e => setNewDesc(e.target.value)} placeholder="Optional description" />
</div>
<div className={styles.createFormRow}>
<label className={styles.createFormLabel}>Scope</label>
<input className={styles.createFormInput} value={newScope}
onChange={e => setNewScope(e.target.value)} placeholder="custom" />
</div>
{createError && <div className={styles.createFormError}>{createError}</div>}
<div className={styles.createFormActions}>
<button type="button" className={styles.createFormBtn}
onClick={() => { setShowCreateForm(false); setNewName(''); setNewDesc(''); setNewScope('custom'); setCreateError(''); }}>Cancel</button>
<button type="button" className={styles.createFormBtnPrimary}
disabled={!newName.trim() || createRole.isPending}
onClick={() => {
createRole.mutate({ name: newName.trim(), description: newDesc, scope: newScope || undefined }, {
onSuccess: () => { setShowCreateForm(false); setNewName(''); setNewDesc(''); setNewScope('custom'); setCreateError(''); },
onError: (err) => setCreateError(err instanceof Error ? err.message : 'Failed to create role'),
});
}}>Create</button>
</div>
</div>
)}
<div className={styles.entityList}>
{filtered.map((role) => {
const isSelected = role.id === selectedId;
const color = hashColor(role.name);
return (
<div
key={role.id}
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
onClick={() => setSelectedId(role.id)}
>
<div className={styles.avatar} style={{ background: color.bg, color: color.fg, borderRadius: 6 }}>
{getInitials(role.name)}
</div>
<div className={styles.entityInfo}>
<div className={styles.entityName}>
{role.name}
{role.system && <span className={styles.lockIcon}>&#128274;</span>}
</div>
<div className={styles.entityMeta}>{getRoleMeta(role)}</div>
<div className={styles.tagList}>
{role.assignedGroups.map((g) => (
<span key={g.id} className={`${styles.tag} ${styles.tagGroup}`}>
{g.name}
</span>
))}
{role.directUsers.map((u) => (
<span key={u.userId} className={`${styles.tag} ${styles.tagGroup}`}>
{u.displayName}
</span>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
<div className={styles.detailPane}>
{!detail ? (
<div className={styles.detailEmpty}>
<span>Select a role to view details</span>
</div>
) : (
<RoleDetailView role={detail} onDeselect={() => setSelectedId(null)} />
)}
</div>
</div>
</>
);
}
function RoleDetailView({ role, onDeselect }: { role: RoleDetail; onDeselect: () => void }) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [editingName, setEditingName] = useState(false);
const [nameValue, setNameValue] = useState(role.name);
const deleteRole = useDeleteRole();
const updateRole = useUpdateRole();
const isBuiltIn = role.system;
// Reset editing state when role changes
const [prevRoleId, setPrevRoleId] = useState(role.id);
if (prevRoleId !== role.id) {
setPrevRoleId(role.id);
setEditingName(false);
setNameValue(role.name);
}
const color = hashColor(role.name);
return (
<>
<div className={styles.detailHeader}>
<div className={styles.detailHeaderInfo}>
<div className={styles.detailAvatar} style={{ background: color.bg, color: color.fg, borderRadius: 8 }}>
{getInitials(role.name)}
</div>
{editingName ? (
<input
className={styles.editNameInput}
value={nameValue}
onChange={e => setNameValue(e.target.value)}
onBlur={() => {
if (nameValue.trim() && nameValue !== role.name) {
updateRole.mutate({ id: role.id, name: nameValue.trim() });
}
setEditingName(false);
}}
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { setNameValue(role.name); setEditingName(false); } }}
autoFocus
/>
) : (
<div className={styles.detailName}
onClick={() => !isBuiltIn && setEditingName(true)}
style={{ cursor: isBuiltIn ? 'default' : 'pointer' }}
title={isBuiltIn ? undefined : 'Click to edit'}>
{role.name}
{role.system && <span className={styles.lockIcon}>&#128274;</span>}
</div>
)}
</div>
{!role.system && (
<button type="button" className={styles.btnDelete}
onClick={() => setShowDeleteDialog(true)} disabled={deleteRole.isPending}>Delete</button>
)}
</div>
<div className={styles.detailEmail}>{role.description || 'No description'}</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>ID</span>
<span className={`${styles.fieldVal} ${styles.fieldMono}`}>{role.id}</span>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>Scope</span>
<span className={styles.fieldVal}>{role.scope || 'system-wide'}</span>
</div>
{role.system && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>Type</span>
<span className={styles.fieldVal} style={{ color: 'var(--text-muted)' }}>
System role (read-only)
</span>
</div>
)}
<hr className={styles.divider} />
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>Assigned to groups</div>
{role.assignedGroups.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>Not assigned to any groups</span>
) : (
role.assignedGroups.map((g) => (
<span key={g.id} className={`${styles.chip} ${styles.chipGroup}`}>
{g.name}
</span>
))
)}
</div>
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>Assigned to users (direct)</div>
{role.directUsers.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No direct user assignments</span>
) : (
role.directUsers.map((u) => (
<span key={u.userId} className={`${styles.chip} ${styles.chipUser}`}>
{u.displayName}
</span>
))
)}
</div>
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>
Effective principals <span>via inheritance</span>
</div>
{role.effectivePrincipals.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No effective principals</span>
) : (
<>
{role.effectivePrincipals.map((u) => {
const isDirect = role.directUsers.some((du) => du.userId === u.userId);
return (
<span
key={u.userId}
className={`${styles.chip} ${!isDirect ? styles.chipInherited : ''}`}
>
{u.displayName}
</span>
);
})}
{role.effectivePrincipals.some(
(u) => !role.directUsers.some((du) => du.userId === u.userId)
) && (
<div className={styles.inheritNote}>
Some principals inherit this role through group membership rather than direct
assignment.
</div>
)}
</>
)}
</div>
{!role.system && (
<ConfirmDeleteDialog isOpen={showDeleteDialog} onClose={() => setShowDeleteDialog(false)}
onConfirm={() => { deleteRole.mutate(role.id, { onSuccess: () => { setShowDeleteDialog(false); onDeselect(); } }); }}
resourceName={role.name} resourceType="role" />
)}
</>
);
}

View File

@@ -1,455 +0,0 @@
import { useState, useMemo } from 'react';
import { useUsers, useGroups, useRoles, useDeleteUser, useCreateUser, useUpdateUser, useAddUserToGroup, useRemoveUserFromGroup, useAssignRoleToUser, useRemoveRoleFromUser } from '../../../api/queries/admin/rbac';
import type { UserDetail, GroupDetail, RoleDetail } from '../../../api/queries/admin/rbac';
import { ConfirmDeleteDialog } from '../../../components/admin/ConfirmDeleteDialog';
import { MultiSelectDropdown } from './components/MultiSelectDropdown';
import { useAuthStore } from '../../../auth/auth-store';
import { hashColor } from './avatar-colors';
import styles from './RbacPage.module.css';
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
}
function buildGroupPath(user: UserDetail, groupMap: Map<string, GroupDetail>): string {
if (user.directGroups.length === 0) return '(no groups)';
const names = user.directGroups.map((g) => g.name);
// Try to find a parent -> child path
for (const g of user.directGroups) {
const detail = groupMap.get(g.id);
if (detail?.parentGroupId) {
const parent = groupMap.get(detail.parentGroupId);
if (parent) return `${parent.name} > ${g.name}`;
}
}
return names.join(', ');
}
export function UsersTab() {
const users = useUsers();
const groups = useGroups();
const { data: allRoles } = useRoles();
const [selected, setSelected] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [newUsername, setNewUsername] = useState('');
const [newDisplayName, setNewDisplayName] = useState('');
const [newEmail, setNewEmail] = useState('');
const [newPassword, setNewPassword] = useState('');
const [createError, setCreateError] = useState('');
const createUser = useCreateUser();
const groupMap = useMemo(() => {
const map = new Map<string, GroupDetail>();
for (const g of groups.data ?? []) {
map.set(g.id, g);
}
return map;
}, [groups.data]);
const filtered = useMemo(() => {
const list = users.data ?? [];
if (!filter) return list;
const lower = filter.toLowerCase();
return list.filter(
(u) =>
u.displayName.toLowerCase().includes(lower) ||
u.email.toLowerCase().includes(lower) ||
u.userId.toLowerCase().includes(lower)
);
}, [users.data, filter]);
const selectedUser = useMemo(
() => (users.data ?? []).find((u) => u.userId === selected) ?? null,
[users.data, selected]
);
if (users.isLoading) {
return <div className={styles.loading}>Loading...</div>;
}
return (
<>
<div className={styles.panelHeader}>
<div>
<div className={styles.panelTitle}>Users</div>
<div className={styles.panelSubtitle}>
Manage identities, group membership and direct roles
</div>
</div>
<button type="button" className={styles.btnAdd} onClick={() => setShowCreateForm(true)}>+ Add user</button>
</div>
<div className={styles.split}>
<div className={styles.listPane}>
<div className={styles.searchBar}>
<input
className={styles.searchInput}
placeholder="Search users..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
{showCreateForm && (
<div className={styles.createForm}>
<div className={styles.createFormRow}>
<label className={styles.createFormLabel}>Username</label>
<input className={styles.createFormInput} value={newUsername}
onChange={e => { setNewUsername(e.target.value); setCreateError(''); }}
placeholder="Username (required)" autoFocus />
</div>
<div className={styles.createFormRow}>
<label className={styles.createFormLabel}>Display</label>
<input className={styles.createFormInput} value={newDisplayName}
onChange={e => setNewDisplayName(e.target.value)}
placeholder="Display name (optional)" />
</div>
<div className={styles.createFormRow}>
<label className={styles.createFormLabel}>Email</label>
<input className={styles.createFormInput} value={newEmail}
onChange={e => setNewEmail(e.target.value)}
placeholder="Email (optional)" />
</div>
<div className={styles.createFormRow}>
<label className={styles.createFormLabel}>Password</label>
<input className={styles.createFormInput} type="password" value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="Password (required for local login)" />
</div>
{createError && <div className={styles.createFormError}>{createError}</div>}
<div className={styles.createFormActions}>
<button type="button" className={styles.createFormBtn}
onClick={() => { setShowCreateForm(false); setNewUsername(''); setNewDisplayName(''); setNewEmail(''); setNewPassword(''); setCreateError(''); }}>Cancel</button>
<button type="button" className={styles.createFormBtnPrimary}
disabled={!newUsername.trim() || createUser.isPending}
onClick={() => {
createUser.mutate({
username: newUsername.trim(),
displayName: newDisplayName.trim() || undefined,
email: newEmail.trim() || undefined,
password: newPassword || undefined,
}, {
onSuccess: () => { setShowCreateForm(false); setNewUsername(''); setNewDisplayName(''); setNewEmail(''); setNewPassword(''); setCreateError(''); },
onError: (err) => setCreateError(err instanceof Error ? err.message : 'Failed to create user'),
});
}}>Create</button>
</div>
</div>
)}
<div className={styles.entityList}>
{filtered.map((user) => {
const isSelected = user.userId === selected;
const color = hashColor(user.displayName || user.userId);
return (
<div
key={user.userId}
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
onClick={() => setSelected(user.userId)}
>
<div className={styles.avatar} style={{ background: color.bg, color: color.fg }}>
{getInitials(user.displayName || user.userId)}
</div>
<div className={styles.entityInfo}>
<div className={styles.entityName}>
{user.displayName}
{user.provider !== 'local' && (
<span className={styles.oidcBadge}>{user.provider}</span>
)}
</div>
<div className={styles.entityMeta}>
{user.email} · {buildGroupPath(user, groupMap)}
</div>
<div className={styles.tagList}>
{user.directRoles.map((r) => (
<span key={r.id} className={`${styles.tag} ${styles.tagRole}`}>
{r.name}
</span>
))}
{user.effectiveRoles
.filter((er) => !user.directRoles.some((dr) => dr.id === er.id))
.map((r) => (
<span
key={r.id}
className={`${styles.tag} ${styles.tagRole} ${styles.tagInherited}`}
>
{r.name}
</span>
))}
{user.directGroups.map((g) => (
<span key={g.id} className={`${styles.tag} ${styles.tagGroup}`}>
{g.name}
</span>
))}
</div>
</div>
<div
className={`${styles.statusDot} ${styles.statusActive}`}
/>
</div>
);
})}
</div>
</div>
<div className={styles.detailPane}>
{!selectedUser ? (
<div className={styles.detailEmpty}>
<span>Select a user to view details</span>
</div>
) : (
<UserDetailView
user={selectedUser}
groupMap={groupMap}
allGroups={groups.data || []}
allRoles={allRoles || []}
onDeselect={() => setSelected(null)}
/>
)}
</div>
</div>
</>
);
}
function UserDetailView({
user,
groupMap,
allGroups,
allRoles,
onDeselect,
}: {
user: UserDetail;
groupMap: Map<string, GroupDetail>;
allGroups: GroupDetail[];
allRoles: RoleDetail[];
onDeselect: () => void;
}) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [editingName, setEditingName] = useState(false);
const [nameValue, setNameValue] = useState(user.displayName);
const deleteUserMut = useDeleteUser();
const updateUser = useUpdateUser();
const addToGroup = useAddUserToGroup();
const removeFromGroup = useRemoveUserFromGroup();
const assignRole = useAssignRoleToUser();
const removeRole = useRemoveRoleFromUser();
const accessToken = useAuthStore((s) => s.accessToken);
const currentUserId = accessToken ? JSON.parse(atob(accessToken.split('.')[1])).sub : null;
const isSelf = currentUserId === user.userId;
// Reset editing state when user changes
const [prevUserId, setPrevUserId] = useState(user.userId);
if (prevUserId !== user.userId) {
setPrevUserId(user.userId);
setEditingName(false);
setNameValue(user.displayName);
}
// Build group tree for this user
const groupTree = useMemo(() => {
const tree: { name: string; depth: number; annotation: string }[] = [];
for (const g of user.directGroups) {
const detail = groupMap.get(g.id);
if (detail?.parentGroupId) {
const parent = groupMap.get(detail.parentGroupId);
if (parent && !tree.some((t) => t.name === parent.name)) {
tree.push({ name: parent.name, depth: 0, annotation: '' });
}
tree.push({ name: g.name, depth: 1, annotation: 'child group' });
} else {
tree.push({ name: g.name, depth: 0, annotation: '' });
}
}
return tree;
}, [user, groupMap]);
const inheritedRoles = user.effectiveRoles.filter(
(er) => !user.directRoles.some((dr) => dr.id === er.id)
);
const availableGroups = allGroups
.filter((g) => !user.directGroups.some((dg) => dg.id === g.id))
.map((g) => ({ id: g.id, label: g.name }));
const availableRoles = allRoles
.filter((r) => !user.directRoles.some((dr) => dr.id === r.id))
.map((r) => ({ id: r.id, label: r.name }));
const color = hashColor(user.displayName || user.userId);
return (
<>
<div className={styles.detailHeader}>
<div className={styles.detailHeaderInfo}>
<div className={styles.detailAvatar} style={{ background: color.bg, color: color.fg }}>
{getInitials(user.displayName || user.userId)}
</div>
{editingName ? (
<input
className={styles.editNameInput}
value={nameValue}
onChange={e => setNameValue(e.target.value)}
onBlur={() => {
if (nameValue.trim() && nameValue !== user.displayName) {
updateUser.mutate({ userId: user.userId, displayName: nameValue.trim() });
}
setEditingName(false);
}}
onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Escape') { setNameValue(user.displayName); setEditingName(false); } }}
autoFocus
/>
) : (
<div className={styles.detailName}
onClick={() => setEditingName(true)}
style={{ cursor: 'pointer' }}
title="Click to edit">
{user.displayName}
{user.provider !== 'local' && (
<span className={styles.oidcBadge}>{user.provider}</span>
)}
</div>
)}
<div className={styles.detailEmail}>{user.email}</div>
</div>
<button
type="button"
className={styles.btnDelete}
onClick={() => setShowDeleteDialog(true)}
disabled={isSelf || deleteUserMut.isPending}
title={isSelf ? 'Cannot delete your own account' : 'Delete user'}
>
Delete
</button>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>Status</span>
<span className={styles.fieldVal} style={{ color: 'var(--green)', fontSize: 12 }}>
Active
</span>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>ID</span>
<span className={`${styles.fieldVal} ${styles.fieldMono}`}>{user.userId}</span>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>Created</span>
<span className={styles.fieldVal}>{new Date(user.createdAt).toLocaleString()}</span>
</div>
<hr className={styles.divider} />
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>
Group membership <span>direct only</span>
</div>
{user.directGroups.length === 0 ? (
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>No group membership</span>
) : (
user.directGroups.map((g) => (
<span key={g.id} className={`${styles.chip} ${styles.chipGroup}`}>
{g.name}
<button
type="button"
className={styles.chipRemove}
onClick={() => removeFromGroup.mutate({ userId: user.userId, groupId: g.id })}
disabled={removeFromGroup.isPending}
title="Remove from group"
>
x
</button>
</span>
))
)}
<MultiSelectDropdown
items={availableGroups}
onApply={async (ids) => {
await Promise.allSettled(
ids.map((gid) => addToGroup.mutateAsync({ userId: user.userId, groupId: gid }))
);
}}
placeholder="Search groups..."
/>
</div>
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>
Effective roles <span>direct + inherited</span>
</div>
{user.directRoles.map((r) => (
<span key={r.id} className={`${styles.chip} ${styles.chipRole}`}>
{r.name}
<button
type="button"
className={styles.chipRemove}
onClick={() => removeRole.mutate({ userId: user.userId, roleId: r.id })}
disabled={removeRole.isPending}
title="Remove role"
>
x
</button>
</span>
))}
{inheritedRoles.map((r) => (
<span key={r.id} className={`${styles.chip} ${styles.chipRole} ${styles.chipInherited}`}>
{r.name}
<span className={styles.chipSource}>
{r.source ? `\u2191 ${r.source}` : ''}
</span>
</span>
))}
{inheritedRoles.length > 0 && (
<div className={styles.inheritNote}>
Dashed roles are inherited transitively through group membership.
</div>
)}
<MultiSelectDropdown
items={availableRoles}
onApply={async (ids) => {
await Promise.allSettled(
ids.map((rid) => assignRole.mutateAsync({ userId: user.userId, roleId: rid }))
);
}}
placeholder="Search roles..."
/>
</div>
{groupTree.length > 0 && (
<div className={styles.detailSection}>
<div className={styles.detailSectionTitle}>Group tree</div>
{groupTree.map((node, i) => (
<div key={i} className={styles.treeRow}>
{node.depth > 0 && (
<div className={styles.treeIndent}>
<div className={styles.treeCorner} />
</div>
)}
{node.name}
{node.annotation && (
<span style={{ fontSize: 10, color: 'var(--text-muted)', marginLeft: 4 }}>
{node.annotation}
</span>
)}
</div>
))}
</div>
)}
<ConfirmDeleteDialog
isOpen={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
onConfirm={() => {
deleteUserMut.mutate(user.userId, {
onSuccess: () => {
setShowDeleteDialog(false);
onDeselect();
},
});
}}
resourceName={user.displayName || user.userId}
resourceType="user"
/>
</>
);
}

View File

@@ -1,18 +0,0 @@
const AVATAR_COLORS = [
{ bg: 'rgba(59, 130, 246, 0.15)', fg: '#3B82F6' }, // blue
{ bg: 'rgba(16, 185, 129, 0.15)', fg: '#10B981' }, // green
{ bg: 'rgba(240, 180, 41, 0.15)', fg: '#F0B429' }, // amber
{ bg: 'rgba(168, 85, 247, 0.15)', fg: '#A855F7' }, // purple
{ bg: 'rgba(244, 63, 94, 0.15)', fg: '#F43F5E' }, // rose
{ bg: 'rgba(34, 211, 238, 0.15)', fg: '#22D3EE' }, // cyan
{ bg: 'rgba(251, 146, 60, 0.15)', fg: '#FB923C' }, // orange
{ bg: 'rgba(132, 204, 22, 0.15)', fg: '#84CC16' }, // lime
];
export function hashColor(str: string): { bg: string; fg: string } {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
}

View File

@@ -1,120 +0,0 @@
import { useState, useRef, useEffect } from 'react';
import styles from '../RbacPage.module.css';
interface MultiSelectItem {
id: string;
label: string;
}
interface MultiSelectDropdownProps {
items: MultiSelectItem[];
onApply: (selectedIds: string[]) => void;
placeholder?: string;
label?: string;
}
export function MultiSelectDropdown({ items, onApply, placeholder = 'Search...', label = '+ Add' }: MultiSelectDropdownProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
setSearch('');
setSelected(new Set());
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') {
setOpen(false);
setSearch('');
setSelected(new Set());
}
}
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
}, [open]);
const filtered = items.filter(item =>
item.label.toLowerCase().includes(search.toLowerCase())
);
function toggle(id: string) {
setSelected(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function handleApply() {
onApply(Array.from(selected));
setOpen(false);
setSearch('');
setSelected(new Set());
}
if (items.length === 0) return null;
return (
<div className={styles.multiSelectWrapper} ref={ref}>
<button
type="button"
className={styles.addChip}
onClick={() => setOpen(!open)}
>
{label}
</button>
{open && (
<div className={styles.dropdown}>
<div className={styles.dropdownSearch}>
<input
type="text"
className={styles.dropdownSearchInput}
placeholder={placeholder}
value={search}
onChange={e => setSearch(e.target.value)}
autoFocus
/>
</div>
<div className={styles.dropdownList}>
{filtered.length === 0 ? (
<div className={styles.dropdownEmpty}>No items found</div>
) : (
filtered.map(item => (
<label key={item.id} className={styles.dropdownItem}>
<input
type="checkbox"
className={styles.dropdownItemCheckbox}
checked={selected.has(item.id)}
onChange={() => toggle(item.id)}
/>
{item.label}
</label>
))
)}
</div>
<div className={styles.dropdownFooter}>
<button
type="button"
className={styles.dropdownApply}
disabled={selected.size === 0}
onClick={handleApply}
>
Apply{selected.size > 0 ? ` (${selected.size})` : ''}
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,214 +0,0 @@
/* ─── Breadcrumb ─── */
.breadcrumb {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 16px;
font-size: 12px;
}
.breadcrumbLink {
color: var(--text-muted);
text-decoration: none;
transition: color 0.15s;
}
.breadcrumbLink:hover {
color: var(--amber);
}
.breadcrumbSep {
color: var(--text-muted);
opacity: 0.5;
}
.breadcrumbCurrent {
color: var(--text-primary);
font-family: var(--font-mono);
font-weight: 500;
}
/* ─── App Header ─── */
.appHeader {
position: relative;
margin-bottom: 20px;
padding: 16px 20px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
}
.appHeader::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--amber), var(--cyan));
}
.appTitle {
font-family: var(--font-mono);
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.5px;
}
.agentSummary {
display: flex;
gap: 12px;
margin-top: 6px;
font-size: 12px;
}
.agentLive { color: var(--green); }
.agentStale { color: var(--amber); }
.agentDead { color: var(--text-muted); }
/* ─── Stats Bar ─── */
.statsBar {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 20px;
}
/* ─── Route Chips ─── */
.routeChips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px;
}
.routeChip {
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: 99px;
background: none;
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-mono);
cursor: pointer;
transition: all 0.15s;
}
.routeChip:hover {
background: var(--bg-raised);
color: var(--text-primary);
border-color: var(--text-muted);
}
.routeChipActive {
background: var(--amber-glow);
color: var(--amber);
border-color: rgba(245, 158, 11, 0.3);
}
/* ─── Results Header ─── */
.resultsHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding: 0 4px;
}
.resultsCount {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.resultsCount strong {
color: var(--text-secondary);
}
/* ─── Filter Bar ─── */
.filterBar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.filterGroup {
display: flex;
align-items: center;
gap: 6px;
}
.filterLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
/* ─── Live Toggle ─── */
.liveToggle {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-family: var(--font-mono);
font-weight: 500;
background: none;
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 14px;
cursor: pointer;
transition: all 0.15s ease;
margin-left: auto;
}
.liveOn {
color: var(--green);
border-color: var(--green);
}
.liveOff {
color: var(--text-muted);
}
.liveDot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.liveOn .liveDot {
background: var(--green);
animation: livePulse 2s ease-in-out infinite;
}
.liveOff .liveDot {
background: var(--text-muted);
}
@keyframes livePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); }
50% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
}
/* ─── Loading / Empty ─── */
.loading {
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
font-size: 14px;
}
/* ─── Responsive ─── */
@media (max-width: 1200px) {
.statsBar { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 768px) {
.statsBar { grid-template-columns: 1fr 1fr; }
}

View File

@@ -1,183 +0,0 @@
import { useState, useMemo, useCallback } from 'react';
import { useParams, NavLink } from 'react-router';
import { useAgents } from '../../api/queries/agents';
import { useSearchExecutions, useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import { StatCard } from '../../components/shared/StatCard';
import { ResultsTable } from '../executions/ResultsTable';
import { Pagination } from '../../components/shared/Pagination';
import { FilterChip } from '../../components/shared/FilterChip';
import type { SearchRequest } from '../../api/types';
import styles from './AppScopedView.module.css';
function todayMidnight(): string {
const d = new Date();
d.setHours(0, 0, 0, 0);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00`;
}
function formatCompact(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toLocaleString();
}
function pctChange(current: number, previous: number): { text: string; direction: 'up' | 'down' | 'neutral' } {
if (previous === 0) return { text: 'no prior data', direction: 'neutral' };
const pct = ((current - previous) / previous) * 100;
if (Math.abs(pct) < 0.5) return { text: '~0% vs yesterday', direction: 'neutral' };
const arrow = pct > 0 ? '\u2191' : '\u2193';
return { text: `${arrow} ${Math.abs(pct).toFixed(1)}% vs yesterday`, direction: pct > 0 ? 'up' : 'down' };
}
export function AppScopedView() {
const { group } = useParams<{ group: string }>();
const { data: agents } = useAgents();
const [selectedRoute, setSelectedRoute] = useState<string | null>(null);
const [status, setStatus] = useState<string[]>(['COMPLETED', 'FAILED']);
const [live, setLive] = useState(true);
const [offset, setOffset] = useState(0);
const limit = 25;
// Find agents belonging to this group
const groupAgents = useMemo(() => {
if (!agents || !group) return [];
return agents.filter((a) => (a.group ?? 'default') === group);
}, [agents, group]);
const liveCount = groupAgents.filter((a) => a.status === 'LIVE').length;
const staleCount = groupAgents.filter((a) => a.status === 'STALE').length;
const deadCount = groupAgents.filter((a) => a.status === 'DEAD').length;
// Collect unique routes from agents
const routeIds = useMemo(() => {
const set = new Set<string>();
for (const a of groupAgents) {
if (a.routeIds) for (const rid of a.routeIds) set.add(rid);
}
return Array.from(set).sort();
}, [groupAgents]);
// Build search request scoped to this group
const timeFrom = todayMidnight();
const timeFromIso = new Date(timeFrom).toISOString();
const searchRequest: SearchRequest = useMemo(() => ({
group: group || undefined,
routeId: selectedRoute || undefined,
status: status.length > 0 && status.length < 3 ? status.join(',') : undefined,
timeFrom: timeFromIso,
offset,
limit,
sortField: 'startTime',
sortDir: 'desc',
}), [group, selectedRoute, status, timeFromIso, offset, limit]);
const { data, isLoading, isFetching } = useSearchExecutions(searchRequest, live);
const { data: stats } = useExecutionStats(timeFromIso, undefined, selectedRoute || undefined, group);
const { data: timeseries } = useStatsTimeseries(timeFromIso, undefined, selectedRoute || undefined, group);
const sparkTotal = timeseries?.buckets.map((b) => b.totalCount) ?? [];
const sparkFailed = timeseries?.buckets.map((b) => b.failedCount) ?? [];
const sparkAvgDuration = timeseries?.buckets.map((b) => b.avgDurationMs) ?? [];
const sparkP99 = timeseries?.buckets.map((b) => b.p99DurationMs) ?? [];
const sparkActive = timeseries?.buckets.map((b) => b.activeCount) ?? [];
const total = data?.total ?? 0;
const results = data?.data ?? [];
const failureRate = stats && stats.totalCount > 0
? (stats.failedCount / stats.totalCount) * 100 : 0;
const prevFailureRate = stats && stats.prevTotalCount > 0
? (stats.prevFailedCount / stats.prevTotalCount) * 100 : 0;
const avgChange = stats ? pctChange(stats.avgDurationMs, stats.prevAvgDurationMs) : null;
const failRateChange = stats ? pctChange(failureRate, prevFailureRate) : null;
const p99Change = stats ? pctChange(stats.p99LatencyMs, stats.prevP99LatencyMs) : null;
const showFrom = total > 0 ? offset + 1 : 0;
const showTo = Math.min(offset + limit, total);
const toggleRoute = useCallback((rid: string) => {
setSelectedRoute((prev) => prev === rid ? null : rid);
setOffset(0);
}, []);
if (!group) {
return <div className={styles.loading}>Missing group parameter</div>;
}
return (
<>
{/* Breadcrumb */}
<nav className={styles.breadcrumb}>
<NavLink to="/executions" className={styles.breadcrumbLink}>All</NavLink>
<span className={styles.breadcrumbSep}>/</span>
<span className={styles.breadcrumbCurrent}>{group}</span>
</nav>
{/* App Header */}
<div className={styles.appHeader}>
<div className={styles.appTitle}>{group}</div>
<div className={styles.agentSummary}>
{liveCount > 0 && <span className={styles.agentLive}>{liveCount} live</span>}
{staleCount > 0 && <span className={styles.agentStale}>{staleCount} stale</span>}
{deadCount > 0 && <span className={styles.agentDead}>{deadCount} dead</span>}
{groupAgents.length === 0 && <span className={styles.agentDead}>no agents</span>}
</div>
</div>
{/* Stats Bar */}
<div className={styles.statsBar}>
<StatCard label="Total Matches" value={total.toLocaleString()} accent="amber" change={stats ? `of ${formatCompact(stats.totalToday)} today` : 'from current search'} sparkData={sparkTotal} />
<StatCard label="Avg Duration" value={stats ? `${stats.avgDurationMs.toLocaleString()}ms` : '--'} accent="cyan" change={avgChange?.text} changeDirection={avgChange?.direction} sparkData={sparkAvgDuration} />
<StatCard label="Failure Rate" value={stats ? `${failureRate.toFixed(1)}%` : '--'} accent="rose" change={failRateChange?.text} changeDirection={failRateChange?.direction} sparkData={sparkFailed} />
<StatCard label="P99 Latency" value={stats ? `${stats.p99LatencyMs.toLocaleString()}ms` : '--'} accent="green" change={p99Change?.text} changeDirection={p99Change?.direction} sparkData={sparkP99} />
<StatCard label="In-Flight" value={stats ? stats.activeCount.toLocaleString() : '--'} accent="blue" change="running executions" sparkData={sparkActive} />
</div>
{/* Route Chips + Status Filters */}
<div className={styles.filterBar}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Status</label>
<FilterChip label="Completed" accent="green" active={status.includes('COMPLETED')} onClick={() => setStatus((s) => s.includes('COMPLETED') ? s.filter((x) => x !== 'COMPLETED') : [...s, 'COMPLETED'])} />
<FilterChip label="Failed" accent="rose" active={status.includes('FAILED')} onClick={() => setStatus((s) => s.includes('FAILED') ? s.filter((x) => x !== 'FAILED') : [...s, 'FAILED'])} />
<FilterChip label="Running" accent="blue" active={status.includes('RUNNING')} onClick={() => setStatus((s) => s.includes('RUNNING') ? s.filter((x) => x !== 'RUNNING') : [...s, 'RUNNING'])} />
</div>
<button className={`${styles.liveToggle} ${live ? styles.liveOn : styles.liveOff}`} onClick={() => setLive(!live)}>
<span className={styles.liveDot} />
{live ? 'LIVE' : 'PAUSED'}
</button>
</div>
{/* Route Chips */}
{routeIds.length > 0 && (
<div className={styles.routeChips}>
{routeIds.map((rid) => (
<button
key={rid}
className={`${styles.routeChip} ${selectedRoute === rid ? styles.routeChipActive : ''}`}
onClick={() => toggleRoute(rid)}
>
{rid}
</button>
))}
</div>
)}
{/* Results Header */}
<div className={styles.resultsHeader}>
<span className={styles.resultsCount}>
Showing <strong>{showFrom}{showTo}</strong> of <strong>{total.toLocaleString()}</strong> results
{isFetching && !isLoading && ' · updating...'}
</span>
</div>
{/* Results Table */}
<ResultsTable results={results} loading={isLoading} />
{/* Pagination */}
<Pagination total={total} offset={offset} limit={limit} onChange={setOffset} />
</>
);
}

View File

@@ -1,75 +0,0 @@
.sidebar {
width: 280px;
flex-shrink: 0;
}
.title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 12px;
}
.kv {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
font-size: 12px;
}
.kvKey {
color: var(--text-muted);
font-weight: 500;
}
.kvValue {
font-family: var(--font-mono);
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
}
.bodyPreview {
margin-top: 16px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 12px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
max-height: 120px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
.bodyLabel {
font-family: var(--font-body);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
display: block;
margin-bottom: 6px;
}
.errorPreview {
margin-top: 12px;
background: var(--rose-glow);
border: 1px solid rgba(244, 63, 94, 0.2);
border-radius: var(--radius-sm);
padding: 10px 12px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--rose);
max-height: 80px;
overflow: auto;
}
@media (max-width: 1200px) {
.sidebar { width: 100%; }
}

View File

@@ -1,45 +0,0 @@
import { useProcessorSnapshot } from '../../api/queries/executions';
import type { ExecutionSummary } from '../../api/types';
import styles from './ExchangeDetail.module.css';
interface ExchangeDetailProps {
execution: ExecutionSummary;
}
export function ExchangeDetail({ execution }: ExchangeDetailProps) {
// Fetch the first processor's snapshot (index 0) — returns Record<string, string>
const { data: snapshot } = useProcessorSnapshot(execution.executionId, 0);
const body = snapshot?.['body'];
return (
<div className={styles.sidebar}>
<h4 className={styles.title}>Exchange Details</h4>
<dl className={styles.kv}>
<dt className={styles.kvKey}>Execution ID</dt>
<dd className={styles.kvValue}>{execution.executionId}</dd>
<dt className={styles.kvKey}>Correlation</dt>
<dd className={styles.kvValue}>{execution.correlationId ?? '-'}</dd>
<dt className={styles.kvKey}>Application</dt>
<dd className={styles.kvValue}>{execution.agentId}</dd>
<dt className={styles.kvKey}>Route</dt>
<dd className={styles.kvValue}>{execution.routeId}</dd>
<dt className={styles.kvKey}>Timestamp</dt>
<dd className={styles.kvValue}>{new Date(execution.startTime).toISOString()}</dd>
<dt className={styles.kvKey}>Duration</dt>
<dd className={styles.kvValue}>{execution.durationMs}ms</dd>
</dl>
{body && (
<div className={styles.bodyPreview}>
<span className={styles.bodyLabel}>Input Body</span>
{body}
</div>
)}
{execution.errorMessage && (
<div className={styles.errorPreview}>{execution.errorMessage}</div>
)}
</div>
);
}

View File

@@ -1,98 +0,0 @@
.pageHeader {
margin-bottom: 24px;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.pageHeader h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.5px;
color: var(--text-primary);
}
.subtitle {
font-size: 13px;
color: var(--text-muted);
margin-top: 2px;
}
.liveToggle {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-family: var(--font-mono);
font-weight: 500;
background: none;
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 14px;
cursor: pointer;
transition: all 0.15s ease;
}
.liveToggle:hover {
background: var(--surface-hover);
}
.liveOn {
color: var(--green);
border-color: var(--green);
}
.liveOff {
color: var(--text-muted);
}
.liveOn .liveDot {
background: var(--green);
animation: livePulse 2s ease-in-out infinite;
}
.liveOff .liveDot {
background: var(--text-muted);
animation: none;
}
.liveDot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.statsBar {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.resultsHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding: 0 4px;
}
.resultsCount {
font-size: 12px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.resultsCount strong {
color: var(--text-secondary);
}
/* ─── Responsive ─── */
@media (max-width: 1200px) {
.statsBar { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 768px) {
.statsBar { grid-template-columns: 1fr 1fr; }
}

View File

@@ -1,98 +0,0 @@
import { useSearchExecutions, useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import { useExecutionSearch } from './use-execution-search';
import { useSearchParamsSync } from './use-search-params-sync';
import { StatCard } from '../../components/shared/StatCard';
import { Pagination } from '../../components/shared/Pagination';
import { SearchFilters } from './SearchFilters';
import { ResultsTable } from './ResultsTable';
import styles from './ExecutionExplorer.module.css';
function formatCompact(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toLocaleString();
}
function pctChange(current: number, previous: number): { text: string; direction: 'up' | 'down' | 'neutral' } {
if (previous === 0) return { text: 'no prior data', direction: 'neutral' };
const pct = ((current - previous) / previous) * 100;
if (Math.abs(pct) < 0.5) return { text: '~0% vs yesterday', direction: 'neutral' };
const arrow = pct > 0 ? '\u2191' : '\u2193';
return { text: `${arrow} ${Math.abs(pct).toFixed(1)}% vs yesterday`, direction: pct > 0 ? 'up' : 'down' };
}
export function ExecutionExplorer() {
useSearchParamsSync();
const { toSearchRequest, offset, limit, setOffset, live, toggleLive } = useExecutionSearch();
const searchRequest = toSearchRequest();
const { data, isLoading, isFetching } = useSearchExecutions(searchRequest, live);
const timeFrom = searchRequest.timeFrom ?? undefined;
const timeTo = searchRequest.timeTo ?? undefined;
const { data: stats } = useExecutionStats(timeFrom, timeTo);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo);
const sparkTotal = timeseries?.buckets.map((b) => b.totalCount) ?? [];
const sparkFailed = timeseries?.buckets.map((b) => b.failedCount) ?? [];
const sparkAvgDuration = timeseries?.buckets.map((b) => b.avgDurationMs) ?? [];
const sparkP99 = timeseries?.buckets.map((b) => b.p99DurationMs) ?? [];
const sparkActive = timeseries?.buckets.map((b) => b.activeCount) ?? [];
const total = data?.total ?? 0;
const results = data?.data ?? [];
// Failure rate as percentage
const failureRate = stats && stats.totalCount > 0
? (stats.failedCount / stats.totalCount) * 100 : 0;
const prevFailureRate = stats && stats.prevTotalCount > 0
? (stats.prevFailedCount / stats.prevTotalCount) * 100 : 0;
// Comparison vs yesterday
const avgChange = stats ? pctChange(stats.avgDurationMs, stats.prevAvgDurationMs) : null;
const failRateChange = stats ? pctChange(failureRate, prevFailureRate) : null;
const p99Change = stats ? pctChange(stats.p99LatencyMs, stats.prevP99LatencyMs) : null;
const showFrom = total > 0 ? offset + 1 : 0;
const showTo = Math.min(offset + limit, total);
return (
<>
{/* Page Header */}
<div className={`${styles.pageHeader} animate-in`}>
<div>
<h1>Route Explorer</h1>
<div className={styles.subtitle}>Search and analyze route executions</div>
</div>
<button className={`${styles.liveToggle} ${live ? styles.liveOn : styles.liveOff}`} onClick={toggleLive}>
<span className={styles.liveDot} />
{live ? 'LIVE' : 'PAUSED'}
</button>
</div>
{/* Stats Bar */}
<div className={styles.statsBar}>
<StatCard label="Total Matches" value={total.toLocaleString()} accent="amber" change={stats ? `of ${formatCompact(stats.totalToday)} today` : 'from current search'} sparkData={sparkTotal} />
<StatCard label="Avg Duration" value={stats ? `${stats.avgDurationMs.toLocaleString()}ms` : '--'} accent="cyan" change={avgChange?.text} changeDirection={avgChange?.direction} sparkData={sparkAvgDuration} />
<StatCard label="Failure Rate" value={stats ? `${failureRate.toFixed(1)}%` : '--'} accent="rose" change={failRateChange?.text} changeDirection={failRateChange?.direction} sparkData={sparkFailed} />
<StatCard label="P99 Latency" value={stats ? `${stats.p99LatencyMs.toLocaleString()}ms` : '--'} accent="green" change={p99Change?.text} changeDirection={p99Change?.direction} sparkData={sparkP99} />
<StatCard label="In-Flight" value={stats ? stats.activeCount.toLocaleString() : '--'} accent="blue" change="running executions" sparkData={sparkActive} />
</div>
{/* Filters */}
<SearchFilters />
{/* Results Header */}
<div className={`${styles.resultsHeader} animate-in delay-4`}>
<span className={styles.resultsCount}>
Showing <strong>{showFrom}{showTo}</strong> of <strong>{total.toLocaleString()}</strong> results
{isFetching && !isLoading && ' · updating...'}
</span>
</div>
{/* Results Table */}
<ResultsTable results={results} loading={isLoading} />
{/* Pagination */}
<Pagination total={total} offset={offset} limit={limit} onChange={setOffset} />
</>
);
}

View File

@@ -1,97 +0,0 @@
.tree {
flex: 1;
min-width: 0;
}
.title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 12px;
}
.procNode {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px 12px;
border-radius: var(--radius-sm);
margin-bottom: 2px;
transition: background 0.1s;
position: relative;
}
.procNode:hover { background: var(--bg-surface); }
.procConnector {
position: absolute;
left: 22px;
top: 28px;
bottom: -4px;
width: 1px;
background: var(--border);
}
.procNode:last-child .procConnector { display: none; }
.procIcon {
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
z-index: 1;
font-family: var(--font-mono);
}
.iconEndpoint { background: rgba(59, 130, 246, 0.15); color: var(--blue); border: 1px solid rgba(59, 130, 246, 0.3); }
.iconProcessor { background: var(--green-glow); color: var(--green); border: 1px solid rgba(16, 185, 129, 0.3); }
.iconEip { background: rgba(168, 85, 247, 0.12); color: #a855f7; border: 1px solid rgba(168, 85, 247, 0.3); }
.iconError { background: var(--rose-glow); color: var(--rose); border: 1px solid rgba(244, 63, 94, 0.3); }
.procInfo { flex: 1; min-width: 0; }
.procType {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
}
.procUri {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.procTiming {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
flex-shrink: 0;
text-align: right;
}
.procDuration {
font-weight: 600;
color: var(--text-secondary);
}
.nested {
margin-left: 24px;
}
.loading {
color: var(--text-muted);
font-size: 12px;
font-family: var(--font-mono);
padding: 12px;
}

View File

@@ -1,69 +0,0 @@
import { useExecutionDetail } from '../../api/queries/executions';
import type { ProcessorNode as ProcessorNodeType } from '../../api/types';
import styles from './ProcessorTree.module.css';
const ICON_MAP: Record<string, { label: string; className: string }> = {
from: { label: 'EP', className: styles.iconEndpoint },
to: { label: 'EP', className: styles.iconEndpoint },
toD: { label: 'EP', className: styles.iconEndpoint },
choice: { label: 'CB', className: styles.iconEip },
when: { label: 'CB', className: styles.iconEip },
otherwise: { label: 'CB', className: styles.iconEip },
split: { label: 'CB', className: styles.iconEip },
aggregate: { label: 'CB', className: styles.iconEip },
filter: { label: 'CB', className: styles.iconEip },
multicast: { label: 'CB', className: styles.iconEip },
recipientList: { label: 'CB', className: styles.iconEip },
routingSlip: { label: 'CB', className: styles.iconEip },
dynamicRouter: { label: 'CB', className: styles.iconEip },
exception: { label: '!!', className: styles.iconError },
onException: { label: '!!', className: styles.iconError },
};
function getIcon(type: string, status: string) {
if (status === 'FAILED') return { label: '!!', className: styles.iconError };
const key = type.toLowerCase();
return ICON_MAP[key] ?? { label: 'PR', className: styles.iconProcessor };
}
export function ProcessorTree({ executionId }: { executionId: string }) {
const { data, isLoading } = useExecutionDetail(executionId);
if (isLoading) return <div className={styles.tree}><div className={styles.loading}>Loading processor tree...</div></div>;
if (!data) return null;
return (
<div className={styles.tree}>
<h4 className={styles.title}>Processor Execution Tree</h4>
{(data.processors as ProcessorNodeType[])?.map((proc, i) => (
<ProcessorNodeView key={proc.processorId ?? i} node={proc} />
))}
</div>
);
}
function ProcessorNodeView({ node }: { node: ProcessorNodeType }) {
const icon = getIcon(node.processorType, node.status);
return (
<div>
<div className={styles.procNode}>
<div className={styles.procConnector} />
<div className={`${styles.procIcon} ${icon.className}`}>{icon.label}</div>
<div className={styles.procInfo}>
<div className={styles.procType}>{node.processorType}</div>
</div>
<div className={styles.procTiming}>
<span className={styles.procDuration}>{node.durationMs}ms</span>
</div>
</div>
{node.children && node.children.length > 0 && (
<div className={styles.nested}>
{node.children.map((child, i) => (
<ProcessorNodeView key={child.processorId ?? i} node={child} />
))}
</div>
)}
</div>
);
}

View File

@@ -1,117 +0,0 @@
.tableWrap {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
overflow: hidden;
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.thead {
background: var(--bg-raised);
border-bottom: 1px solid var(--border);
}
.th {
padding: 12px 16px;
text-align: left;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
user-select: none;
white-space: nowrap;
}
.thSortable {
cursor: pointer;
transition: color 0.15s;
}
.thSortable:hover {
color: var(--text-secondary);
}
.thActive {
color: var(--amber);
}
.sortArrow {
display: inline-block;
margin-left: 4px;
font-size: 9px;
opacity: 0.3;
transition: opacity 0.15s;
}
.thSortable:hover .sortArrow {
opacity: 0.6;
}
.thActive .sortArrow {
opacity: 1;
}
.row {
border-bottom: 1px solid var(--border-subtle);
transition: background 0.1s;
cursor: pointer;
}
.row:last-child { border-bottom: none; }
.row:hover { background: var(--bg-raised); }
.td {
padding: 12px 16px;
vertical-align: middle;
white-space: nowrap;
}
.correlationId {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
}
/* ─── Route Link ─── */
.routeLink {
color: inherit;
text-decoration: none;
transition: color 0.15s;
}
.routeLink:hover {
color: var(--amber);
text-decoration: underline;
}
/* ─── Highlighted Row (back-nav flash) ─── */
@keyframes flash {
0% { background: var(--amber-glow); }
100% { background: transparent; }
}
.highlighted {
animation: flash 2s ease-out;
}
/* ─── Loading / Empty ─── */
.emptyState {
text-align: center;
padding: 48px 24px;
color: var(--text-muted);
font-size: 14px;
}
.loadingOverlay {
text-align: center;
padding: 48px 24px;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 13px;
}

View File

@@ -1,181 +0,0 @@
import { useEffect, useRef, useMemo } from 'react';
import { useNavigate, Link } from 'react-router';
import type { ExecutionSummary } from '../../api/types';
import { useAgents } from '../../api/queries/agents';
import { StatusPill } from '../../components/shared/StatusPill';
import { DurationBar } from '../../components/shared/DurationBar';
import { AppBadge } from '../../components/shared/AppBadge';
import { useExecutionSearch } from './use-execution-search';
import styles from './ResultsTable.module.css';
interface ResultsTableProps {
results: ExecutionSummary[];
loading: boolean;
}
type SortColumn = 'startTime' | 'status' | 'agentId' | 'routeId' | 'correlationId' | 'durationMs';
type SortDir = 'asc' | 'desc';
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString('en-GB', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
});
}
interface SortableThProps {
label: string;
column: SortColumn;
activeColumn: SortColumn | null;
direction: SortDir;
onSort: (col: SortColumn) => void;
style?: React.CSSProperties;
}
function SortableTh({ label, column, activeColumn, direction, onSort, style }: SortableThProps) {
const isActive = activeColumn === column;
return (
<th
className={`${styles.th} ${styles.thSortable} ${isActive ? styles.thActive : ''}`}
style={style}
onClick={() => onSort(column)}
>
{label}
<span className={styles.sortArrow}>
{isActive ? (direction === 'asc' ? '\u25B2' : '\u25BC') : '\u25B4'}
</span>
</th>
);
}
export function ResultsTable({ results, loading }: ResultsTableProps) {
const sortColumn = useExecutionSearch((s) => s.sortField);
const sortDir = useExecutionSearch((s) => s.sortDir);
const setSort = useExecutionSearch((s) => s.setSort);
const navigate = useNavigate();
const { data: agents } = useAgents();
const groupByAgent = useMemo(
() => new Map(agents?.map((a) => [a.id, a.group]) ?? []),
[agents],
);
// Highlight previously-visited row on back-nav
const highlightRef = useRef<string | null>(null);
useEffect(() => {
const lastId = sessionStorage.getItem('lastExecId');
if (lastId) {
highlightRef.current = lastId;
sessionStorage.removeItem('lastExecId');
const timer = setTimeout(() => { highlightRef.current = null; }, 2000);
return () => clearTimeout(timer);
}
}, []);
function handleSort(col: SortColumn) {
setSort(col);
}
/** Navigate to route diagram page with execution overlay */
function handleDiagramNav(exec: ExecutionSummary) {
const group = groupByAgent.get(exec.agentId) ?? 'default';
sessionStorage.setItem('lastExecId', exec.executionId);
const url = `/apps/${encodeURIComponent(group)}/routes/${encodeURIComponent(exec.routeId)}?exec=${encodeURIComponent(exec.executionId)}`;
const doc = document as Document & { startViewTransition?: (cb: () => void) => void };
if (doc.startViewTransition) {
doc.startViewTransition(() => navigate(url));
} else {
navigate(url);
}
}
if (loading && results.length === 0) {
return (
<div className={styles.tableWrap}>
<div className={styles.loadingOverlay}>Loading executions...</div>
</div>
);
}
if (results.length === 0) {
return (
<div className={styles.tableWrap}>
<div className={styles.emptyState}>No executions found matching your filters.</div>
</div>
);
}
return (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead className={styles.thead}>
<tr>
<SortableTh label="Timestamp" column="startTime" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
<SortableTh label="Status" column="status" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
<SortableTh label="Application" column="agentId" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
<SortableTh label="Route" column="routeId" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
<SortableTh label="Correlation ID" column="correlationId" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
<SortableTh label="Duration" column="durationMs" activeColumn={sortColumn} direction={sortDir} onSort={handleSort} />
</tr>
</thead>
<tbody>
{results.map((exec) => (
<ResultRow
key={exec.executionId}
exec={exec}
groupByAgent={groupByAgent}
highlighted={highlightRef.current === exec.executionId}
onClick={() => handleDiagramNav(exec)}
/>
))}
</tbody>
</table>
</div>
);
}
function ResultRow({
exec,
groupByAgent,
highlighted,
onClick,
}: {
exec: ExecutionSummary;
groupByAgent: Map<string, string>;
highlighted: boolean;
onClick: () => void;
}) {
const group = groupByAgent.get(exec.agentId) ?? 'default';
return (
<tr
className={`${styles.row} ${highlighted ? styles.highlighted : ''}`}
onClick={onClick}
>
<td className={`${styles.td} mono`}>{formatTime(exec.startTime)}</td>
<td className={styles.td}>
<StatusPill status={exec.status} />
</td>
<td className={styles.td}>
<AppBadge name={exec.agentId} />
</td>
<td className={`${styles.td} mono text-secondary`}>
<Link
to={`/apps/${encodeURIComponent(group)}/routes/${encodeURIComponent(exec.routeId)}`}
className={styles.routeLink}
onClick={(e) => e.stopPropagation()}
>
{exec.routeId}
</Link>
</td>
<td className={`${styles.td} mono text-muted ${styles.correlationId}`} title={exec.correlationId ?? ''}>
{exec.correlationId ?? '-'}
</td>
<td className={styles.td}>
<DurationBar duration={exec.durationMs} />
</td>
</tr>
);
}

View File

@@ -1,214 +0,0 @@
.filterBar {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 16px 20px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.filterRow {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.searchInputWrap {
flex: 1;
min-width: 300px;
position: relative;
display: flex;
align-items: center;
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.searchInputWrap:hover {
border-color: var(--amber-dim);
box-shadow: 0 0 0 3px var(--amber-glow);
}
.searchIcon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: var(--text-muted);
}
.searchPlaceholder {
flex: 1;
padding: 10px 14px 10px 40px;
color: var(--text-muted);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.searchInputWrap:hover .searchPlaceholder {
color: var(--text-secondary);
}
.searchHint {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
font-family: var(--font-mono);
font-size: 10px;
padding: 3px 8px;
background: var(--bg-raised);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-muted);
}
.filterGroup {
display: flex;
align-items: center;
gap: 6px;
}
.filterLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
white-space: nowrap;
}
.filterChips {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.separator {
width: 1px;
height: 24px;
background: var(--border);
margin: 0 4px;
}
.dateInput {
background: var(--bg-base);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
outline: none;
width: 180px;
transition: border-color 0.2s;
color-scheme: light dark;
}
.dateInput:focus { border-color: var(--amber-dim); }
.dateArrow {
color: var(--text-muted);
font-size: 12px;
}
.durationRange {
display: flex;
align-items: center;
gap: 8px;
}
.rangeInput {
width: 100px;
accent-color: var(--amber);
cursor: pointer;
}
.rangeLabel {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
min-width: 50px;
}
.filterTags {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.filterTag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: var(--amber-glow);
border: 1px solid rgba(240, 180, 41, 0.2);
border-radius: 99px;
font-size: 12px;
color: var(--amber);
font-family: var(--font-mono);
}
.filterTagRemove {
cursor: pointer;
opacity: 0.5;
font-size: 14px;
line-height: 1;
background: none;
border: none;
color: inherit;
}
.filterTagRemove:hover { opacity: 1; }
.clearAll {
font-size: 11px;
color: var(--text-muted);
cursor: pointer;
padding: 4px 8px;
background: none;
border: none;
}
.clearAll:hover { color: var(--rose); }
/* ── Inline Palette ── */
.searchAnchor {
flex: 1;
min-width: 300px;
position: relative;
}
.paletteInline {
background: var(--bg-base);
border: 1px solid var(--amber-dim);
border-radius: var(--radius-sm);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25), 0 0 0 3px var(--amber-glow);
display: flex;
flex-direction: column;
max-height: 480px;
overflow: hidden;
animation: paletteExpand 0.15s ease-out;
}
@keyframes paletteExpand {
from { opacity: 0; max-height: 44px; }
to { opacity: 1; max-height: 480px; }
}
@media (max-width: 768px) {
.filterRow { flex-direction: column; align-items: stretch; }
.searchInputWrap { min-width: unset; }
.searchAnchor { min-width: unset; }
}

View File

@@ -1,227 +0,0 @@
import { useRef, useEffect, useCallback } from 'react';
import { useExecutionSearch } from './use-execution-search';
import { useCommandPalette } from '../../components/command-palette/use-command-palette';
import { usePaletteSearch, type PaletteResult, type RouteInfo } from '../../components/command-palette/use-palette-search';
import { PaletteInput } from '../../components/command-palette/PaletteInput';
import { ScopeTabs } from '../../components/command-palette/ScopeTabs';
import { ResultsList } from '../../components/command-palette/ResultsList';
import { PaletteFooter } from '../../components/command-palette/PaletteFooter';
import { FilterChip } from '../../components/shared/FilterChip';
import type { ExecutionSummary, AgentInstance } from '../../api/types';
import styles from './SearchFilters.module.css';
export function SearchFilters() {
const {
status, toggleStatus,
timeFrom, setTimeFrom,
timeTo, setTimeTo,
durationMax, setDurationMax,
text, setText,
routeId, setRouteId,
agentId, setAgentId,
processorType, setProcessorType,
clearAll,
} = useExecutionSearch();
const execSearch = useExecutionSearch();
const { isOpen, close, scope, setScope, selectedIndex, setSelectedIndex, reset, filters } =
useCommandPalette();
const openPalette = useCommandPalette((s) => s.open);
const { results, executionCount, applicationCount, routeCount, isLoading } = usePaletteSearch();
const dropdownRef = useRef<HTMLDivElement>(null);
const handleSelect = useCallback(
(result: PaletteResult) => {
if (result.type === 'execution') {
const exec = result.data as ExecutionSummary;
execSearch.setStatus(['COMPLETED', 'FAILED', 'RUNNING']);
execSearch.setText(exec.executionId);
execSearch.setRouteId('');
execSearch.setAgentId('');
execSearch.setProcessorType('');
} else if (result.type === 'application') {
const agent = result.data as AgentInstance;
execSearch.setStatus(['COMPLETED', 'FAILED', 'RUNNING']);
execSearch.setAgentId(agent.id);
execSearch.setText('');
execSearch.setRouteId('');
execSearch.setProcessorType('');
} else if (result.type === 'route') {
const route = result.data as RouteInfo;
execSearch.setStatus(['COMPLETED', 'FAILED', 'RUNNING']);
execSearch.setRouteId(route.routeId);
execSearch.setText('');
execSearch.setAgentId('');
execSearch.setProcessorType('');
}
for (const f of filters) {
if (f.key === 'status') execSearch.setStatus([f.value.toUpperCase()]);
if (f.key === 'route') execSearch.setRouteId(f.value);
if (f.key === 'agent') execSearch.setAgentId(f.value);
if (f.key === 'processor') execSearch.setProcessorType(f.value);
}
close();
reset();
},
[close, reset, execSearch, filters],
);
// Close on click outside
useEffect(() => {
if (!isOpen) return;
function onClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
close();
reset();
}
}
document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, [isOpen, close, reset]);
// Keyboard handling when open
useEffect(() => {
if (!isOpen) return;
const SCOPES = ['all', 'executions', 'applications', 'routes'] as const;
function handleKeyDown(e: KeyboardEvent) {
switch (e.key) {
case 'Escape':
e.preventDefault();
close();
reset();
break;
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(results.length > 0 ? (selectedIndex + 1) % results.length : 0);
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(results.length > 0 ? (selectedIndex - 1 + results.length) % results.length : 0);
break;
case 'Enter':
e.preventDefault();
if (results[selectedIndex]) {
handleSelect(results[selectedIndex]);
}
break;
case 'Tab':
e.preventDefault();
const idx = SCOPES.indexOf(scope as typeof SCOPES[number]);
setScope(SCOPES[(idx + 1) % SCOPES.length]);
break;
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, close, reset, selectedIndex, setSelectedIndex, results, handleSelect, scope, setScope]);
const activeTags: { label: string; onRemove: () => void }[] = [];
if (text) activeTags.push({ label: `text:"${text}"`, onRemove: () => setText('') });
if (routeId) activeTags.push({ label: `route:${routeId}`, onRemove: () => setRouteId('') });
if (agentId) activeTags.push({ label: `agent:${agentId}`, onRemove: () => setAgentId('') });
if (processorType) activeTags.push({ label: `processor:${processorType}`, onRemove: () => setProcessorType('') });
if (timeFrom) activeTags.push({ label: `from:${timeFrom}`, onRemove: () => setTimeFrom('') });
if (timeTo) activeTags.push({ label: `to:${timeTo}`, onRemove: () => setTimeTo('') });
if (durationMax && durationMax < 5000) {
activeTags.push({ label: `duration:≤${durationMax}ms`, onRemove: () => setDurationMax(null) });
}
return (
<div className={`${styles.filterBar} animate-in delay-3`}>
{/* Row 1: Search bar with inline palette */}
<div className={styles.filterRow}>
<div className={styles.searchAnchor} ref={dropdownRef}>
{isOpen ? (
<div className={styles.paletteInline}>
<PaletteInput />
<ScopeTabs executionCount={executionCount} applicationCount={applicationCount} routeCount={routeCount} />
<ResultsList results={results} isLoading={isLoading} onSelect={handleSelect} />
<PaletteFooter />
</div>
) : (
<div className={styles.searchInputWrap} onClick={openPalette} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') openPalette(); }}>
<svg className={styles.searchIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="8" />
<path d="M21 21l-4.35-4.35" />
</svg>
<span className={styles.searchPlaceholder}>
{text || routeId || agentId || processorType
? [text, routeId && `route:${routeId}`, agentId && `agent:${agentId}`, processorType && `processor:${processorType}`].filter(Boolean).join(' ')
: 'Search by correlation ID, error message, route ID...'}
</span>
<span className={styles.searchHint}>&#8984;K</span>
</div>
)}
</div>
</div>
{/* Row 2: Status chips + date + duration */}
<div className={styles.filterRow}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Status</label>
<div className={styles.filterChips}>
<FilterChip label="Completed" accent="green" active={status.includes('COMPLETED')} onClick={() => toggleStatus('COMPLETED')} />
<FilterChip label="Failed" accent="rose" active={status.includes('FAILED')} onClick={() => toggleStatus('FAILED')} />
<FilterChip label="Running" accent="blue" active={status.includes('RUNNING')} onClick={() => toggleStatus('RUNNING')} />
</div>
</div>
<div className={styles.separator} />
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Date</label>
<input
className={styles.dateInput}
type="datetime-local"
value={timeFrom}
onChange={(e) => setTimeFrom(e.target.value)}
/>
<span className={styles.dateArrow}>&rarr;</span>
<input
className={styles.dateInput}
type="datetime-local"
value={timeTo}
onChange={(e) => setTimeTo(e.target.value)}
/>
</div>
<div className={styles.separator} />
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Duration</label>
<div className={styles.durationRange}>
<span className={styles.rangeLabel}>0ms</span>
<input
className={styles.rangeInput}
type="range"
min="0"
max="5000"
step="100"
value={durationMax ?? 5000}
onChange={(e) => {
const v = Number(e.target.value);
setDurationMax(v >= 5000 ? null : v);
}}
/>
<span className={styles.rangeLabel}>&le; {durationMax ?? 5000}ms</span>
</div>
</div>
</div>
{/* Row 3: Active filter tags */}
{activeTags.length > 0 && (
<div className={styles.filterRow}>
<div className={styles.filterTags}>
{activeTags.map((tag) => (
<span key={tag.label} className={styles.filterTag}>
{tag.label}
<button className={styles.filterTagRemove} onClick={tag.onRemove}>&times;</button>
</span>
))}
<button className={styles.clearAll} onClick={clearAll}>Clear all</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,125 +0,0 @@
import { create } from 'zustand';
import type { SearchRequest } from '../../api/types';
function todayMidnight(): string {
const d = new Date();
d.setHours(0, 0, 0, 0);
// Format as datetime-local value: YYYY-MM-DDTHH:mm
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T00:00`;
}
type SortColumn = 'startTime' | 'status' | 'agentId' | 'routeId' | 'correlationId' | 'durationMs';
type SortDir = 'asc' | 'desc';
interface ExecutionSearchState {
status: string[];
timeFrom: string;
timeTo: string;
durationMin: number | null;
durationMax: number | null;
text: string;
routeId: string;
agentId: string;
processorType: string;
live: boolean;
offset: number;
limit: number;
sortField: SortColumn;
sortDir: SortDir;
toggleLive: () => void;
setStatus: (statuses: string[]) => void;
toggleStatus: (s: string) => void;
setTimeFrom: (v: string) => void;
setTimeTo: (v: string) => void;
setDurationMin: (v: number | null) => void;
setDurationMax: (v: number | null) => void;
setText: (v: string) => void;
setRouteId: (v: string) => void;
setAgentId: (v: string) => void;
setProcessorType: (v: string) => void;
setOffset: (v: number) => void;
setSort: (col: SortColumn) => void;
clearAll: () => void;
toSearchRequest: () => SearchRequest;
}
export const useExecutionSearch = create<ExecutionSearchState>((set, get) => ({
status: ['COMPLETED', 'FAILED'],
timeFrom: todayMidnight(),
timeTo: '',
durationMin: null,
durationMax: null,
text: '',
routeId: '',
agentId: '',
processorType: '',
live: true,
offset: 0,
limit: 25,
sortField: 'startTime',
sortDir: 'desc',
toggleLive: () => set((state) => ({ live: !state.live })),
setStatus: (statuses) => set({ status: statuses, offset: 0 }),
toggleStatus: (s) =>
set((state) => ({
status: state.status.includes(s)
? state.status.filter((x) => x !== s)
: [...state.status, s],
offset: 0,
})),
setTimeFrom: (v) => set({ timeFrom: v, offset: 0 }),
setTimeTo: (v) => set({ timeTo: v, offset: 0 }),
setDurationMin: (v) => set({ durationMin: v, offset: 0 }),
setDurationMax: (v) => set({ durationMax: v, offset: 0 }),
setText: (v) => set({ text: v, offset: 0 }),
setRouteId: (v) => set({ routeId: v, offset: 0 }),
setAgentId: (v) => set({ agentId: v, offset: 0 }),
setProcessorType: (v) => set({ processorType: v, offset: 0 }),
setOffset: (v) => set({ offset: v }),
setSort: (col) =>
set((state) => ({
sortField: col,
sortDir: state.sortField === col && state.sortDir === 'desc' ? 'asc' : 'desc',
offset: 0,
})),
clearAll: () =>
set({
status: ['COMPLETED', 'FAILED', 'RUNNING'],
timeFrom: todayMidnight(),
timeTo: '',
durationMin: null,
durationMax: null,
text: '',
routeId: '',
agentId: '',
processorType: '',
offset: 0,
sortField: 'startTime',
sortDir: 'desc',
}),
toSearchRequest: (): SearchRequest => {
const s = get();
const statusStr = s.status.length > 0 && s.status.length < 3
? s.status.join(',')
: undefined;
return {
status: statusStr ?? undefined,
timeFrom: s.timeFrom ? new Date(s.timeFrom).toISOString() : undefined,
timeTo: s.timeTo ? new Date(s.timeTo).toISOString() : undefined,
durationMin: s.durationMin ?? undefined,
durationMax: s.durationMax ?? undefined,
text: s.text || undefined,
routeId: s.routeId || undefined,
agentId: s.agentId || undefined,
processorType: s.processorType || undefined,
offset: s.offset,
limit: s.limit,
sortField: s.sortField,
sortDir: s.sortDir,
};
},
}));

View File

@@ -1,80 +0,0 @@
import { useEffect, useRef } from 'react';
import { useExecutionSearch } from './use-execution-search';
const DEFAULTS = {
status: 'COMPLETED,FAILED',
sortField: 'startTime',
sortDir: 'desc',
offset: '0',
};
/**
* Two-way sync between Zustand execution-search store and URL search params.
* - On mount: hydrates store from URL (if non-default values present).
* - On store change: serializes non-default state to URL via replaceState (no history pollution).
*/
export function useSearchParamsSync() {
const hydrated = useRef(false);
// Hydrate store from URL on mount
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const store = useExecutionSearch.getState();
const status = params.get('status');
if (status) store.setStatus(status.split(','));
const text = params.get('text');
if (text) store.setText(text);
const routeId = params.get('routeId');
if (routeId) store.setRouteId(routeId);
const agentId = params.get('agentId');
if (agentId) store.setAgentId(agentId);
const sort = params.get('sort');
if (sort) {
const [field, dir] = sort.split(':');
if (field && dir) {
// Set sortField and sortDir directly via the store
useExecutionSearch.setState({
sortField: field as 'startTime' | 'status' | 'agentId' | 'routeId' | 'correlationId' | 'durationMs',
sortDir: dir as 'asc' | 'desc',
});
}
}
const offset = params.get('offset');
if (offset) store.setOffset(Number(offset));
hydrated.current = true;
}, []);
// Sync store → URL on changes
useEffect(() => {
const unsub = useExecutionSearch.subscribe((state) => {
if (!hydrated.current) return;
const params = new URLSearchParams();
const statusStr = state.status.join(',');
if (statusStr !== DEFAULTS.status) params.set('status', statusStr);
if (state.text) params.set('text', state.text);
if (state.routeId) params.set('routeId', state.routeId);
if (state.agentId) params.set('agentId', state.agentId);
const sortStr = `${state.sortField}:${state.sortDir}`;
if (sortStr !== `${DEFAULTS.sortField}:${DEFAULTS.sortDir}`) params.set('sort', sortStr);
if (state.offset > 0) params.set('offset', String(state.offset));
const qs = params.toString();
const newUrl = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
window.history.replaceState(null, '', newUrl);
});
return unsub;
}, []);
}

View File

@@ -1,88 +0,0 @@
import { useState, useCallback } from 'react';
import type { DiagramLayout, ExecutionDetail } from '../../api/types';
import type { OverlayState } from '../../hooks/useExecutionOverlay';
import { DiagramCanvas } from './diagram/DiagramCanvas';
import { ProcessorDetailPanel } from './diagram/ProcessorDetailPanel';
import { ProcessorTree } from '../executions/ProcessorTree';
import { ResizableDivider } from '../../components/shared/ResizableDivider';
import styles from './diagram/diagram.module.css';
const PANEL_WIDTH_KEY = 'cameleer-diagram-panel-width';
const DEFAULT_WIDTH = 340;
type DetailMode = 'inspector' | 'tree';
interface DiagramTabProps {
layout: DiagramLayout;
overlay: OverlayState;
execution: ExecutionDetail | null | undefined;
executionId?: string | null;
}
export function DiagramTab({ layout, overlay, execution, executionId }: DiagramTabProps) {
const [panelWidth, setPanelWidth] = useState(() => {
try {
const saved = localStorage.getItem(PANEL_WIDTH_KEY);
return saved ? Number(saved) : DEFAULT_WIDTH;
} catch { return DEFAULT_WIDTH; }
});
const [detailMode, setDetailMode] = useState<DetailMode>('inspector');
const handleResize = useCallback((width: number) => {
setPanelWidth(width);
try { localStorage.setItem(PANEL_WIDTH_KEY, String(width)); }
catch { /* ignore */ }
}, []);
const showPanel = overlay.isActive && execution;
return (
<div className={styles.splitLayout}>
<div className={styles.diagramSide}>
<DiagramCanvas layout={layout} overlay={overlay} />
</div>
{showPanel && (
<>
<ResizableDivider
panelWidth={panelWidth}
onResize={handleResize}
minWidth={240}
maxWidth={600}
/>
<div className={styles.sidePanel} style={{ width: panelWidth }}>
{/* Mode toggle */}
<div className={styles.detailModeTabs}>
<button
className={`${styles.detailModeTab} ${detailMode === 'inspector' ? styles.detailModeTabActive : ''}`}
onClick={() => setDetailMode('inspector')}
>
Inspector
</button>
<button
className={`${styles.detailModeTab} ${detailMode === 'tree' ? styles.detailModeTabActive : ''}`}
onClick={() => setDetailMode('tree')}
>
Tree
</button>
</div>
{detailMode === 'inspector' ? (
<ProcessorDetailPanel
execution={execution}
selectedNodeId={overlay.selectedNodeId}
/>
) : (
executionId ? (
<div className={styles.treeContainer}>
<ProcessorTree executionId={executionId} />
</div>
) : (
<div className={styles.detailEmpty}>Select an execution to view the processor tree</div>
)
)}
</div>
</>
)}
</div>
);
}

View File

@@ -1,86 +0,0 @@
.wrap {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 24px;
max-width: 720px;
}
.heading {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 16px;
}
.grid {
display: grid;
grid-template-columns: 140px 1fr;
gap: 6px 16px;
font-size: 13px;
margin-bottom: 20px;
}
.key {
color: var(--text-muted);
font-weight: 500;
}
.value {
font-family: var(--font-mono);
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
}
.section {
margin-top: 16px;
}
.sectionLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
display: block;
margin-bottom: 8px;
}
.bodyPre {
background: var(--bg-base);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 12px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-secondary);
max-height: 300px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
.errorPanel {
background: var(--rose-glow);
border: 1px solid rgba(244, 63, 94, 0.2);
border-radius: var(--radius-sm);
padding: 12px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--rose);
max-height: 200px;
overflow: auto;
}
.loading,
.empty {
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
font-size: 14px;
}

View File

@@ -1,64 +0,0 @@
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
import styles from './ExchangeTab.module.css';
interface ExchangeTabProps {
executionId: string;
}
export function ExchangeTab({ executionId }: ExchangeTabProps) {
const { data: execution, isLoading } = useExecutionDetail(executionId);
const { data: snapshot } = useProcessorSnapshot(executionId, 0);
const body = snapshot?.['body'];
if (isLoading) {
return <div className={styles.loading}>Loading exchange details...</div>;
}
if (!execution) {
return <div className={styles.empty}>Execution not found</div>;
}
return (
<div className={styles.wrap}>
<h3 className={styles.heading}>Exchange Details</h3>
<dl className={styles.grid}>
<dt className={styles.key}>Execution ID</dt>
<dd className={styles.value}>{execution.executionId}</dd>
<dt className={styles.key}>Correlation ID</dt>
<dd className={styles.value}>{execution.correlationId ?? '-'}</dd>
<dt className={styles.key}>Application</dt>
<dd className={styles.value}>{execution.agentId}</dd>
<dt className={styles.key}>Route</dt>
<dd className={styles.value}>{execution.routeId}</dd>
<dt className={styles.key}>Timestamp</dt>
<dd className={styles.value}>{new Date(execution.startTime).toISOString()}</dd>
<dt className={styles.key}>Duration</dt>
<dd className={styles.value}>{execution.durationMs}ms</dd>
<dt className={styles.key}>Status</dt>
<dd className={styles.value}>{execution.status}</dd>
</dl>
{body && (
<div className={styles.section}>
<span className={styles.sectionLabel}>Input Body</span>
<pre className={styles.bodyPre}>{body}</pre>
</div>
)}
{execution.errorMessage && (
<div className={styles.section}>
<span className={styles.sectionLabel}>Error</span>
<div className={styles.errorPanel}>{execution.errorMessage}</div>
</div>
)}
</div>
);
}

View File

@@ -1,106 +0,0 @@
import { useMemo } from 'react';
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import { StatCard } from '../../components/shared/StatCard';
import { ThroughputChart } from '../../components/charts/ThroughputChart';
import { DurationHistogram } from '../../components/charts/DurationHistogram';
import { LatencyHeatmap } from '../../components/charts/LatencyHeatmap';
import styles from './RoutePage.module.css';
interface PerformanceTabProps {
group: string;
routeId: string;
}
function pctChange(current: number, previous: number): { text: string; direction: 'up' | 'down' | 'neutral' } {
if (previous === 0) return { text: 'no prior data', direction: 'neutral' };
const pct = ((current - previous) / previous) * 100;
if (Math.abs(pct) < 0.5) return { text: '~0% vs yesterday', direction: 'neutral' };
const arrow = pct > 0 ? '\u2191' : '\u2193';
return { text: `${arrow} ${Math.abs(pct).toFixed(1)}% vs yesterday`, direction: pct > 0 ? 'up' : 'down' };
}
/** Round epoch-ms down to the nearest 10 s so the query key stays stable between renders. */
function stableIso(epochMs: number): string {
return new Date(Math.floor(epochMs / 10_000) * 10_000).toISOString();
}
export function PerformanceTab({ group, routeId }: PerformanceTabProps) {
const [timeFrom, timeTo] = useMemo(() => {
const now = Date.now();
return [stableIso(now - 24 * 60 * 60 * 1000), stableIso(now)];
}, [Math.floor(Date.now() / 10_000)]);
// Use scoped stats/timeseries via group+routeId query params
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, group);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, group);
const buckets = timeseries?.buckets ?? [];
const sparkTotal = buckets.map((b) => b.totalCount ?? 0);
const sparkP99 = buckets.map((b) => b.p99DurationMs ?? 0);
const sparkFailed = buckets.map((b) => b.failedCount ?? 0);
const sparkAvg = buckets.map((b) => b.avgDurationMs ?? 0);
const failureRate = stats && stats.totalCount > 0
? (stats.failedCount / stats.totalCount) * 100 : 0;
const prevFailureRate = stats && stats.prevTotalCount > 0
? (stats.prevFailedCount / stats.prevTotalCount) * 100 : 0;
const avgChange = stats ? pctChange(stats.avgDurationMs, stats.prevAvgDurationMs) : null;
const p99Change = stats ? pctChange(stats.p99LatencyMs, stats.prevP99LatencyMs) : null;
const failChange = stats ? pctChange(failureRate, prevFailureRate) : null;
return (
<div className={styles.performanceTab}>
{/* Stats cards row */}
<div className={styles.perfStatsRow}>
<StatCard
label="Executions Today"
value={stats ? stats.totalToday.toLocaleString() : '--'}
accent="amber"
change={`for ${group}/${routeId}`}
sparkData={sparkTotal}
/>
<StatCard
label="Avg Duration"
value={stats ? `${stats.avgDurationMs}ms` : '--'}
accent="cyan"
change={avgChange?.text}
changeDirection={avgChange?.direction}
sparkData={sparkAvg}
/>
<StatCard
label="P99 Latency"
value={stats ? `${stats.p99LatencyMs}ms` : '--'}
accent="green"
change={p99Change?.text}
changeDirection={p99Change?.direction}
sparkData={sparkP99}
/>
<StatCard
label="Failure Rate"
value={stats ? `${failureRate.toFixed(1)}%` : '--'}
accent="rose"
change={failChange?.text}
changeDirection={failChange?.direction}
sparkData={sparkFailed}
/>
</div>
{/* Charts */}
<div className={styles.chartGrid}>
<div className={styles.chartCard}>
<h4 className={styles.chartTitle}>Throughput</h4>
<ThroughputChart buckets={buckets} />
</div>
<div className={styles.chartCard}>
<h4 className={styles.chartTitle}>Duration Distribution</h4>
<DurationHistogram buckets={buckets} />
</div>
<div className={`${styles.chartCard} ${styles.chartFull}`}>
<h4 className={styles.chartTitle}>Latency Over Time</h4>
<LatencyHeatmap buckets={buckets} />
</div>
</div>
</div>
);
}

View File

@@ -1,66 +0,0 @@
import { useMemo } from 'react';
import type { DiagramLayout } from '../../api/types';
import { useExecutionStats } from '../../api/queries/executions';
import styles from './RoutePage.module.css';
interface RouteHeaderProps {
group: string;
routeId: string;
layout: DiagramLayout | undefined;
}
export function RouteHeader({ group, routeId, layout }: RouteHeaderProps) {
const nodeCount = layout?.nodes?.length ?? 0;
const timeFrom = useMemo(
() => new Date(Math.floor(Date.now() / 10_000) * 10_000 - 24 * 60 * 60 * 1000).toISOString(),
[Math.floor(Date.now() / 10_000)],
);
const { data: stats } = useExecutionStats(timeFrom, undefined, routeId, group);
const successRate = stats && stats.totalCount > 0
? ((1 - stats.failedCount / stats.totalCount) * 100).toFixed(1)
: null;
return (
<div className={styles.routeHeader}>
<div className={styles.routeTitle}>
<span className={styles.routeId}>{routeId}</span>
<div className={styles.routeMeta}>
<span className={styles.routeMetaItem}>
<span className={styles.routeMetaDot} />
{group}
</span>
{nodeCount > 0 && (
<span className={styles.routeMetaItem}>{nodeCount} nodes</span>
)}
</div>
</div>
{stats && (
<div className={styles.headerStatsRow}>
<div className={styles.headerStat}>
<span className={styles.headerStatValue}>{stats.totalToday.toLocaleString()}</span>
<span className={styles.headerStatLabel}>Executions Today</span>
</div>
<div className={styles.headerStat}>
<span className={`${styles.headerStatValue} ${styles.headerStatGreen}`}>
{successRate ? `${successRate}%` : '--'}
</span>
<span className={styles.headerStatLabel}>Success Rate</span>
</div>
<div className={styles.headerStat}>
<span className={`${styles.headerStatValue} ${styles.headerStatCyan}`}>
{stats.avgDurationMs != null ? `${stats.avgDurationMs}ms` : '--'}
</span>
<span className={styles.headerStatLabel}>Avg Duration</span>
</div>
<div className={styles.headerStat}>
<span className={`${styles.headerStatValue} ${styles.headerStatAmber}`}>
{stats.p99LatencyMs != null ? `${stats.p99LatencyMs}ms` : '--'}
</span>
<span className={styles.headerStatLabel}>P99 Latency</span>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,326 +0,0 @@
/* ─── Breadcrumb ─── */
.breadcrumb {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 16px;
font-size: 12px;
}
.backBtn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-muted);
font-size: 14px;
cursor: pointer;
transition: all 0.15s;
margin-right: 4px;
}
.backBtn:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: var(--amber);
}
.breadcrumbLink {
color: var(--text-muted);
text-decoration: none;
transition: color 0.15s;
}
.breadcrumbLink:hover {
color: var(--amber);
}
.breadcrumbSep {
color: var(--text-muted);
opacity: 0.5;
}
.breadcrumbText {
color: var(--text-secondary);
}
.breadcrumbCurrent {
color: var(--text-primary);
font-family: var(--font-mono);
font-weight: 500;
}
/* ─── Route Header ─── */
.routeHeader {
position: relative;
margin-bottom: 20px;
padding: 20px 24px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
}
.routeHeader::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--amber), var(--cyan));
}
.routeTitle {
display: flex;
align-items: baseline;
gap: 16px;
flex-wrap: wrap;
}
.routeId {
font-family: var(--font-mono);
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.5px;
}
.routeMeta {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: var(--text-muted);
}
.routeMetaItem {
display: inline-flex;
align-items: center;
gap: 6px;
}
.routeMetaDot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
}
.headerStatsRow {
display: flex;
gap: 24px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--border-subtle);
}
.headerStat {
display: flex;
flex-direction: column;
gap: 2px;
}
.headerStatValue {
font-family: var(--font-mono);
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.5px;
}
.headerStatGreen {
color: var(--green);
}
.headerStatCyan {
color: var(--cyan);
}
.headerStatAmber {
color: var(--amber);
}
.headerStatLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
}
/* ─── Toolbar & Tabs ─── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 12px;
flex-wrap: wrap;
}
.tabBar {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border-subtle);
}
.tab {
padding: 8px 20px;
border: none;
background: none;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.15s;
}
.tab:hover {
color: var(--text-secondary);
}
.tabActive {
color: var(--amber);
border-bottom-color: var(--amber);
}
.toolbarRight {
display: flex;
align-items: center;
gap: 10px;
}
.overlayToggle {
padding: 6px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-mono);
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.overlayToggle:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.overlayOn {
background: var(--green-glow);
border-color: rgba(16, 185, 129, 0.3);
color: var(--green);
}
.execBadge {
padding: 4px 10px;
border-radius: 99px;
font-size: 11px;
font-family: var(--font-mono);
font-weight: 600;
letter-spacing: 0.3px;
}
.execBadgeOk {
background: var(--green-glow);
color: var(--green);
}
.execBadgeFailed {
background: var(--rose-glow);
color: var(--rose);
}
/* ─── States ─── */
.loading {
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
font-size: 14px;
}
.emptyState {
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
font-size: 14px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.error {
color: var(--rose);
text-align: center;
padding: 60px 20px;
}
/* ─── Performance Tab ─── */
.performanceTab {
display: flex;
flex-direction: column;
gap: 20px;
}
.perfStatsRow {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.chartGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.chartCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 16px;
}
.chartFull {
grid-column: 1 / -1;
}
.chartTitle {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 12px;
}
/* ─── Responsive ─── */
@media (max-width: 1200px) {
.perfStatsRow {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.perfStatsRow {
grid-template-columns: 1fr;
}
.chartGrid {
grid-template-columns: 1fr;
}
.toolbar {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -1,145 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useSearchParams, NavLink, useNavigate } from 'react-router';
import { useDiagramByRoute } from '../../api/queries/diagrams';
import { useExecutionDetail } from '../../api/queries/executions';
import { useExecutionOverlay } from '../../hooks/useExecutionOverlay';
import { RouteHeader } from './RouteHeader';
import { DiagramTab } from './DiagramTab';
import { PerformanceTab } from './PerformanceTab';
import { ExchangeTab } from './ExchangeTab';
import { ExecutionPicker } from './diagram/ExecutionPicker';
import styles from './RoutePage.module.css';
type Tab = 'diagram' | 'performance' | 'exchange';
export function RoutePage() {
const { group, routeId } = useParams<{ group: string; routeId: string }>();
const [searchParams] = useSearchParams();
const execId = searchParams.get('exec');
const [activeTab, setActiveTab] = useState<Tab>('diagram');
const navigate = useNavigate();
const goBack = useCallback(() => {
const doc = document as Document & { startViewTransition?: (cb: () => void) => void };
if (doc.startViewTransition) {
doc.startViewTransition(() => navigate(-1));
} else {
navigate(-1);
}
}, [navigate]);
// Backspace navigates back (unless user is in an input)
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key !== 'Backspace') return;
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
e.preventDefault();
goBack();
}
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [goBack]);
const { data: layout, isLoading: layoutLoading } = useDiagramByRoute(group, routeId);
const { data: execution } = useExecutionDetail(execId);
const overlay = useExecutionOverlay(
execution ?? null,
layout?.edges ?? [],
);
if (!group || !routeId) {
return <div className={styles.error}>Missing group or routeId parameters</div>;
}
const needsExecPicker = activeTab === 'diagram' || activeTab === 'exchange';
return (
<>
{/* Breadcrumb */}
<nav className={styles.breadcrumb}>
<button className={styles.backBtn} onClick={goBack} title="Back (Backspace)">&larr;</button>
<NavLink to="/executions" className={styles.breadcrumbLink}>All</NavLink>
<span className={styles.breadcrumbSep}>/</span>
<NavLink to={`/apps/${encodeURIComponent(group)}`} className={styles.breadcrumbLink}>{group}</NavLink>
<span className={styles.breadcrumbSep}>/</span>
<span className={styles.breadcrumbCurrent}>{routeId}</span>
</nav>
{/* Route Header */}
<RouteHeader group={group} routeId={routeId} layout={layout} />
{/* Toolbar */}
<div className={styles.toolbar}>
<div className={styles.tabBar}>
<button
className={`${styles.tab} ${activeTab === 'diagram' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('diagram')}
>
Diagram
</button>
<button
className={`${styles.tab} ${activeTab === 'performance' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('performance')}
>
Performance
</button>
<button
className={`${styles.tab} ${activeTab === 'exchange' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('exchange')}
>
Exchange
</button>
</div>
{needsExecPicker && (
<div className={styles.toolbarRight}>
<ExecutionPicker group={group} routeId={routeId} />
{activeTab === 'diagram' && (
<>
<button
className={`${styles.overlayToggle} ${overlay.isActive ? styles.overlayOn : ''}`}
onClick={overlay.toggle}
title="Toggle execution overlay (E)"
>
{overlay.isActive ? 'Hide' : 'Show'} Execution
</button>
{execution && (
<span className={`${styles.execBadge} ${execution.status === 'FAILED' ? styles.execBadgeFailed : styles.execBadgeOk}`}>
{execution.status} &middot; {execution.durationMs}ms
</span>
)}
</>
)}
</div>
)}
</div>
{/* Tab Content */}
{activeTab === 'diagram' && (
layoutLoading ? (
<div className={styles.loading}>Loading diagram...</div>
) : layout ? (
<DiagramTab layout={layout} overlay={overlay} execution={execution} executionId={execId} />
) : (
<div className={styles.emptyState}>No diagram available for this route</div>
)
)}
{activeTab === 'performance' && (
<PerformanceTab group={group} routeId={routeId} />
)}
{activeTab === 'exchange' && execId && (
<ExchangeTab executionId={execId} />
)}
{activeTab === 'exchange' && !execId && (
<div className={styles.emptyState}>
Select an execution to view exchange details
</div>
)}
</>
);
}

View File

@@ -1,151 +0,0 @@
import { useRef, useEffect, useState, useCallback } from 'react';
import panzoom, { type PanZoom } from 'panzoom';
import type { DiagramLayout } from '../../../api/types';
import type { OverlayState } from '../../../hooks/useExecutionOverlay';
import { RouteDiagramSvg } from './RouteDiagramSvg';
import { DiagramMinimap } from './DiagramMinimap';
import { DiagramLegend } from './DiagramLegend';
import type { TooltipData } from './DiagramNode';
import styles from './diagram.module.css';
interface DiagramCanvasProps {
layout: DiagramLayout;
overlay: OverlayState;
}
export function DiagramCanvas({ layout, overlay }: DiagramCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const svgWrapRef = useRef<HTMLDivElement>(null);
const panzoomRef = useRef<PanZoom | null>(null);
const [viewBox, setViewBox] = useState({ x: 0, y: 0, w: 800, h: 600 });
const [tooltip, setTooltip] = useState<{ data: TooltipData; x: number; y: number } | null>(null);
const handleNodeHover = useCallback((data: TooltipData | null, x: number, y: number) => {
if (!data) {
setTooltip(null);
} else {
setTooltip({ data, x, y });
}
}, []);
useEffect(() => {
if (!svgWrapRef.current) return;
const instance = panzoom(svgWrapRef.current, {
smoothScroll: false,
zoomDoubleClickSpeed: 1,
minZoom: 0.1,
maxZoom: 5,
bounds: true,
boundsPadding: 0.2,
});
panzoomRef.current = instance;
const updateViewBox = () => {
if (!containerRef.current) return;
const transform = instance.getTransform();
const rect = containerRef.current.getBoundingClientRect();
setViewBox({
x: -transform.x / transform.scale,
y: -transform.y / transform.scale,
w: rect.width / transform.scale,
h: rect.height / transform.scale,
});
};
instance.on('transform', updateViewBox);
updateViewBox();
return () => {
instance.dispose();
panzoomRef.current = null;
};
}, [layout]);
const handleFit = useCallback(() => {
if (!panzoomRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const padding = 80;
const w = (layout.width ?? 600) + padding;
const h = (layout.height ?? 400) + padding;
const scale = Math.min(rect.width / w, rect.height / h, 1);
const cx = (rect.width - w * scale) / 2;
const cy = (rect.height - h * scale) / 2;
panzoomRef.current.moveTo(cx, cy);
panzoomRef.current.zoomAbs(0, 0, scale);
}, [layout]);
const handleZoomIn = useCallback(() => {
if (!panzoomRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
panzoomRef.current.smoothZoom(rect.width / 2, rect.height / 2, 1.3);
}, []);
const handleZoomOut = useCallback(() => {
if (!panzoomRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
panzoomRef.current.smoothZoom(rect.width / 2, rect.height / 2, 0.7);
}, []);
// Fit on initial load
useEffect(() => {
const t = setTimeout(handleFit, 100);
return () => clearTimeout(t);
}, [handleFit]);
return (
<div className={styles.canvasContainer}>
{/* Zoom controls */}
<div className={styles.zoomControls}>
<button className={styles.zoomBtn} onClick={handleFit} title="Fit to view">Fit</button>
<button className={styles.zoomBtn} onClick={handleZoomIn} title="Zoom in">+</button>
<button className={styles.zoomBtn} onClick={handleZoomOut} title="Zoom out">&minus;</button>
</div>
<div ref={containerRef} className={styles.canvas}>
<div ref={svgWrapRef}>
<RouteDiagramSvg layout={layout} overlay={overlay} onNodeHover={handleNodeHover} />
</div>
</div>
<DiagramLegend />
{/* Node tooltip */}
{tooltip && (
<div
className={styles.nodeTooltip}
style={{
left: tooltip.x,
top: tooltip.y,
}}
>
<div className={styles.tooltipHeader}>
<span className={styles.tooltipDot} style={{ background: tooltip.data.color }} />
<span className={styles.tooltipType}>{tooltip.data.nodeType}</span>
</div>
<div className={styles.tooltipLabel}>{tooltip.data.label}</div>
{tooltip.data.isExecuted && (
<div className={styles.tooltipMeta}>
<span className={tooltip.data.isError ? styles.tooltipStatusFailed : styles.tooltipStatusOk}>
{tooltip.data.isError ? 'FAILED' : 'OK'}
</span>
{tooltip.data.duration != null && (
<span className={styles.tooltipDuration}>{tooltip.data.duration}ms</span>
)}
</div>
)}
</div>
)}
<DiagramMinimap
nodes={layout.nodes ?? []}
edges={layout.edges ?? []}
diagramWidth={layout.width ?? 600}
diagramHeight={layout.height ?? 400}
viewBox={viewBox}
panzoomRef={panzoomRef}
/>
</div>
);
}

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