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

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>
);
}

View File

@@ -1,95 +0,0 @@
import { useState } from 'react';
import styles from './diagram.module.css';
interface LegendItem {
label: string;
color: string;
dashed?: boolean;
shape?: 'circle' | 'line';
}
const NODE_TYPES: LegendItem[] = [
{ label: 'Endpoint', color: '#58a6ff', shape: 'circle' },
{ label: 'EIP Pattern', color: '#b87aff', shape: 'circle' },
{ label: 'Processor', color: '#3fb950', shape: 'circle' },
{ label: 'Error Handler', color: '#f85149', shape: 'circle' },
{ label: 'Cross-route', color: '#39d2e0', shape: 'circle', dashed: true },
];
const EDGE_TYPES: LegendItem[] = [
{ label: 'Flow', color: '#4a5e7a', shape: 'line' },
{ label: 'Error', color: '#f85149', shape: 'line', dashed: true },
{ label: 'Cross-route', color: '#39d2e0', shape: 'line', dashed: true },
];
const OVERLAY_TYPES: LegendItem[] = [
{ label: 'Executed', color: '#3fb950', shape: 'circle' },
{ label: 'Execution path', color: '#3fb950', shape: 'line' },
{ label: 'Not executed', color: '#4a5e7a', shape: 'circle' },
];
function LegendRow({ item }: { item: LegendItem }) {
return (
<div className={styles.legendRow}>
{item.shape === 'circle' ? (
<span
className={styles.legendDot}
style={{
background: item.color,
border: item.dashed ? `1px dashed ${item.color}` : undefined,
opacity: item.label === 'Not executed' ? 0.3 : 1,
}}
/>
) : (
<span
className={styles.legendLine}
style={{
background: item.color,
borderStyle: item.dashed ? 'dashed' : 'solid',
}}
/>
)}
<span className={styles.legendLabel}>{item.label}</span>
</div>
);
}
export function DiagramLegend() {
const [expanded, setExpanded] = useState(false);
if (!expanded) {
return (
<button
className={styles.legendToggle}
onClick={() => setExpanded(true)}
title="Show legend"
>
Legend
</button>
);
}
return (
<div className={styles.legend}>
<button
className={styles.legendCloseBtn}
onClick={() => setExpanded(false)}
title="Hide legend"
>
&times;
</button>
<div className={styles.legendSection}>
<span className={styles.legendTitle}>Nodes</span>
{NODE_TYPES.map((t) => <LegendRow key={t.label} item={t} />)}
</div>
<div className={styles.legendSection}>
<span className={styles.legendTitle}>Edges</span>
{EDGE_TYPES.map((t) => <LegendRow key={t.label} item={t} />)}
</div>
<div className={styles.legendSection}>
<span className={styles.legendTitle}>Overlay</span>
{OVERLAY_TYPES.map((t) => <LegendRow key={t.label} item={t} />)}
</div>
</div>
);
}

View File

@@ -1,114 +0,0 @@
import { useMemo, useCallback, useRef, type MutableRefObject } from 'react';
import type { PanZoom } from 'panzoom';
import type { PositionedNode, PositionedEdge } from '../../../api/types';
import { getNodeStyle } from './nodeStyles';
import styles from './diagram.module.css';
interface DiagramMinimapProps {
nodes: PositionedNode[];
edges: PositionedEdge[];
diagramWidth: number;
diagramHeight: number;
viewBox: { x: number; y: number; w: number; h: number };
panzoomRef: MutableRefObject<PanZoom | null>;
}
const MINIMAP_W = 160;
const MINIMAP_H = 100;
export function DiagramMinimap({ nodes, edges, diagramWidth, diagramHeight, viewBox, panzoomRef }: DiagramMinimapProps) {
const dragging = useRef(false);
const scale = useMemo(() => {
if (diagramWidth === 0 || diagramHeight === 0) return 1;
return Math.min(MINIMAP_W / diagramWidth, MINIMAP_H / diagramHeight);
}, [diagramWidth, diagramHeight]);
const vpRect = useMemo(() => ({
x: viewBox.x * scale,
y: viewBox.y * scale,
w: viewBox.w * scale,
h: viewBox.h * scale,
}), [viewBox, scale]);
const panTo = useCallback((clientX: number, clientY: number, svg: SVGSVGElement) => {
const pz = panzoomRef.current;
if (!pz) return;
const rect = svg.getBoundingClientRect();
// Convert minimap mouse coords to diagram coords
const mx = (clientX - rect.left) / scale;
const my = (clientY - rect.top) / scale;
// Center viewport on clicked point
const t = pz.getTransform();
const targetX = -(mx - viewBox.w / 2) * t.scale;
const targetY = -(my - viewBox.h / 2) * t.scale;
pz.moveTo(targetX, targetY);
}, [panzoomRef, scale, viewBox.w, viewBox.h]);
const handleMouseDown = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
e.preventDefault();
dragging.current = true;
panTo(e.clientX, e.clientY, e.currentTarget);
}, [panTo]);
const handleMouseMove = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
if (!dragging.current) return;
e.preventDefault();
panTo(e.clientX, e.clientY, e.currentTarget);
}, [panTo]);
const handleMouseUp = useCallback(() => {
dragging.current = false;
}, []);
return (
<div className={styles.minimap}>
<svg
width={MINIMAP_W}
height={MINIMAP_H}
viewBox={`0 0 ${MINIMAP_W} ${MINIMAP_H}`}
style={{ cursor: 'pointer' }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<rect width={MINIMAP_W} height={MINIMAP_H} fill="#0d1117" rx={4} />
{/* Edges */}
{edges.map((e) => {
const pts = e.points;
if (!pts || pts.length < 2) return null;
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0] * scale},${p[1] * scale}`).join(' ');
return <path key={`${e.sourceId}-${e.targetId}`} d={d} fill="none" stroke="#30363d" strokeWidth={0.5} />;
})}
{/* Nodes */}
{nodes.map((n) => {
const ns = getNodeStyle(n.type ?? '');
return (
<rect
key={n.id}
x={(n.x ?? 0) * scale}
y={(n.y ?? 0) * scale}
width={Math.max((n.width ?? 0) * scale, 2)}
height={Math.max((n.height ?? 0) * scale, 2)}
fill={ns.border}
opacity={0.6}
rx={1}
/>
);
})}
{/* Viewport rect */}
<rect
x={vpRect.x}
y={vpRect.y}
width={vpRect.w}
height={vpRect.h}
fill="rgba(240, 180, 41, 0.1)"
stroke="#f0b429"
strokeWidth={1}
rx={1}
/>
</svg>
</div>
);
}

View File

@@ -1,205 +0,0 @@
import type { PositionedNode } from '../../../api/types';
import { getNodeStyle, isCompoundType } from './nodeStyles';
import styles from './diagram.module.css';
const FIXED_W = 200;
const FIXED_H = 40;
const MAX_LABEL = 22;
function truncateLabel(label: string | undefined): string {
if (!label) return '';
return label.length > MAX_LABEL ? label.slice(0, MAX_LABEL - 1) + '\u2026' : label;
}
export interface TooltipData {
nodeType: string;
label: string;
color: string;
isExecuted: boolean;
isError: boolean;
duration?: number;
}
interface DiagramNodeProps {
node: PositionedNode;
isExecuted: boolean;
isError: boolean;
isOverlayActive: boolean;
duration?: number;
sequence?: number;
isSelected: boolean;
onClick: (nodeId: string) => void;
onHover?: (data: TooltipData | null, x: number, y: number) => void;
}
export function DiagramNode({
node,
isExecuted,
isError,
isOverlayActive,
duration,
sequence,
isSelected,
onClick,
onHover,
}: DiagramNodeProps) {
const style = getNodeStyle(node.type ?? 'PROCESSOR');
const isCompound = isCompoundType(node.type ?? '');
const dimmed = isOverlayActive && !isExecuted;
const glowFilter = isOverlayActive && isExecuted
? (isError ? 'url(#glow-red)' : 'url(#glow-green)')
: undefined;
const borderColor = isOverlayActive && isExecuted
? (isError ? '#f85149' : '#3fb950')
: style.border;
const handleMouseEnter = (e: React.MouseEvent) => {
onHover?.({
nodeType: node.type ?? 'PROCESSOR',
label: node.label ?? '',
color: style.border,
isExecuted: isOverlayActive && isExecuted,
isError,
duration,
}, e.clientX, e.clientY);
};
const handleMouseLeave = () => {
onHover?.(null, 0, 0);
};
if (isCompound) {
return (
<g
className={`${styles.nodeGroup} ${dimmed ? styles.dimmed : ''}`}
opacity={dimmed ? 0.15 : 1}
role="img"
aria-label={`${node.type} container: ${node.label}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<rect
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rx={8}
fill={`${style.bg}80`}
stroke={borderColor}
strokeWidth={1}
strokeDasharray={style.category === 'crossRoute' ? '5,3' : undefined}
filter={glowFilter}
/>
<text
x={node.x! + 8}
y={node.y! + 14}
fill={style.border}
fontSize={10}
fontFamily="JetBrains Mono, monospace"
fontWeight={500}
opacity={0.7}
>
{node.label}
</text>
{/* Children rendered by parent layer */}
</g>
);
}
// Uniform dimensions for leaf nodes — use fixed size, centered on ELK position
const cx = (node.x ?? 0) + (node.width ?? FIXED_W) / 2;
const cy = (node.y ?? 0) + (node.height ?? FIXED_H) / 2;
const rx = cx - FIXED_W / 2;
const ry = cy - FIXED_H / 2;
return (
<g
className={`${styles.nodeGroup} ${dimmed ? styles.dimmed : ''} ${isSelected ? styles.selected : ''}`}
opacity={dimmed ? 0.15 : 1}
onClick={() => node.id && onClick(node.id)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={{ cursor: 'pointer' }}
role="img"
aria-label={`${node.type}: ${node.label}${duration != null ? `, ${duration}ms` : ''}`}
tabIndex={0}
>
<rect
x={rx}
y={ry}
width={FIXED_W}
height={FIXED_H}
rx={8}
fill={style.bg}
stroke={isSelected ? '#f0b429' : borderColor}
strokeWidth={isSelected ? 2 : 1.5}
strokeDasharray={style.category === 'crossRoute' ? '5,3' : undefined}
filter={glowFilter}
/>
<text
x={cx}
y={cy + 4}
fill="#fff"
fontSize={12}
fontFamily="JetBrains Mono, monospace"
fontWeight={500}
textAnchor="middle"
>
{truncateLabel(node.label)}
</text>
{/* Duration badge */}
{isOverlayActive && isExecuted && duration != null && (
<g>
<rect
x={rx + FIXED_W - 28}
y={ry - 8}
width={36}
height={16}
rx={8}
fill={isError ? '#f85149' : '#3fb950'}
opacity={0.9}
/>
<text
x={rx + FIXED_W - 10}
y={ry + 4}
fill="#fff"
fontSize={9}
fontFamily="JetBrains Mono, monospace"
fontWeight={600}
textAnchor="middle"
>
{duration}ms
</text>
</g>
)}
{/* Sequence badge */}
{isOverlayActive && isExecuted && sequence != null && (
<g>
<circle
cx={rx + 8}
cy={ry - 4}
r={8}
fill="#21262d"
stroke={isError ? '#f85149' : '#3fb950'}
strokeWidth={1.5}
/>
<text
x={rx + 8}
y={ry - 1}
fill="#fff"
fontSize={8}
fontFamily="JetBrains Mono, monospace"
fontWeight={600}
textAnchor="middle"
>
{sequence}
</text>
</g>
)}
</g>
);
}

View File

@@ -1,91 +0,0 @@
import type { PositionedEdge } from '../../../api/types';
import styles from './diagram.module.css';
interface EdgeLayerProps {
edges: PositionedEdge[];
executedEdges: Set<string>;
isOverlayActive: boolean;
}
function edgeKey(e: PositionedEdge): string {
return `${e.sourceId}->${e.targetId}`;
}
/** Convert waypoints to a smooth cubic bezier SVG path */
function pointsToPath(points: number[][]): string {
if (!points || points.length === 0) return '';
if (points.length === 1) return `M${points[0][0]},${points[0][1]}`;
let d = `M${points[0][0]},${points[0][1]}`;
if (points.length === 2) {
d += ` L${points[1][0]},${points[1][1]}`;
return d;
}
// Catmull-Rom → cubic bezier approximation for smooth curves
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[Math.max(i - 1, 0)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(i + 2, points.length - 1)];
const cp1x = p1[0] + (p2[0] - p0[0]) / 6;
const cp1y = p1[1] + (p2[1] - p0[1]) / 6;
const cp2x = p2[0] - (p3[0] - p1[0]) / 6;
const cp2y = p2[1] - (p3[1] - p1[1]) / 6;
d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`;
}
return d;
}
export function EdgeLayer({ edges, executedEdges, isOverlayActive }: EdgeLayerProps) {
return (
<g className={styles.edgeLayer}>
{edges.map((edge) => {
const key = edgeKey(edge);
const executed = executedEdges.has(key);
const dimmed = isOverlayActive && !executed;
const path = pointsToPath(edge.points ?? []);
return (
<g key={key} opacity={dimmed ? 0.1 : 1}>
{/* Glow under-layer for executed edges */}
{isOverlayActive && executed && (
<path
d={path}
fill="none"
stroke="#3fb950"
strokeWidth={6}
strokeOpacity={0.2}
strokeLinecap="round"
/>
)}
<path
d={path}
fill="none"
stroke={isOverlayActive && executed ? '#3fb950' : '#4a5e7a'}
strokeWidth={isOverlayActive && executed ? 2.5 : 1.5}
strokeLinecap="round"
markerEnd={executed ? 'url(#arrowhead-executed)' : 'url(#arrowhead)'}
/>
{edge.label && edge.points && edge.points.length > 1 && (
<text
x={(edge.points[0][0] + edge.points[edge.points.length - 1][0]) / 2}
y={(edge.points[0][1] + edge.points[edge.points.length - 1][1]) / 2 - 4}
fill="#7d8590"
fontSize={9}
fontFamily="JetBrains Mono, monospace"
textAnchor="middle"
>
{edge.label}
</text>
)}
</g>
);
})}
</g>
);
}

View File

@@ -1,60 +0,0 @@
import { useState } from 'react';
import styles from './diagram.module.css';
interface ExchangeInspectorProps {
snapshot: Record<string, string>;
}
type Tab = 'input' | 'output';
function tryFormatJson(value: string): string {
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
export function ExchangeInspector({ snapshot }: ExchangeInspectorProps) {
const [tab, setTab] = useState<Tab>('input');
const body = tab === 'input' ? snapshot.inputBody : snapshot.outputBody;
const headers = tab === 'input' ? snapshot.inputHeaders : snapshot.outputHeaders;
return (
<div className={styles.exchangeInspector}>
<div className={styles.exchangeTabs}>
<button
className={`${styles.exchangeTab} ${tab === 'input' ? styles.exchangeTabActive : ''}`}
onClick={() => setTab('input')}
>
Input
</button>
<button
className={`${styles.exchangeTab} ${tab === 'output' ? styles.exchangeTabActive : ''}`}
onClick={() => setTab('output')}
>
Output
</button>
</div>
{body && (
<div className={styles.exchangeSection}>
<div className={styles.exchangeSectionLabel}>Body</div>
<pre className={styles.exchangeBody}>{tryFormatJson(body)}</pre>
</div>
)}
{headers && (
<div className={styles.exchangeSection}>
<div className={styles.exchangeSectionLabel}>Headers</div>
<pre className={styles.exchangeBody}>{tryFormatJson(headers)}</pre>
</div>
)}
{!body && !headers && (
<div className={styles.exchangeEmpty}>No exchange data available</div>
)}
</div>
);
}

View File

@@ -1,75 +0,0 @@
import { useState, useRef, useEffect } from 'react';
import { useSearchParams } from 'react-router';
import { useSearchExecutions } from '../../../api/queries/executions';
import styles from './diagram.module.css';
interface ExecutionPickerProps {
group: string;
routeId: string;
}
export function ExecutionPicker({ group, routeId }: ExecutionPickerProps) {
const [searchParams, setSearchParams] = useSearchParams();
const currentExecId = searchParams.get('exec');
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const { data } = useSearchExecutions({
group,
routeId,
sortField: 'startTime',
sortDir: 'DESC',
offset: 0,
limit: 20,
});
// Close on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
const executions = data?.data ?? [];
const select = (execId: string) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('exec', execId);
return next;
});
setOpen(false);
};
return (
<div className={styles.execPicker} ref={ref}>
<button className={styles.execPickerBtn} onClick={() => setOpen(!open)}>
{currentExecId ? `Exec: ${currentExecId.slice(0, 8)}...` : 'Select Execution'}
<span className={styles.execPickerChevron}>{open ? '\u25B4' : '\u25BE'}</span>
</button>
{open && (
<div className={styles.execPickerDropdown}>
{executions.length === 0 && (
<div className={styles.execPickerEmpty}>No recent executions</div>
)}
{executions.map((ex) => (
<button
key={ex.executionId}
className={`${styles.execPickerItem} ${ex.executionId === currentExecId ? styles.execPickerItemActive : ''}`}
onClick={() => select(ex.executionId)}
>
<span className={`${styles.execPickerStatus} ${ex.status === 'FAILED' ? styles.execPickerFailed : styles.execPickerOk}`} />
<span className={styles.execPickerTime}>
{new Date(ex.startTime).toLocaleTimeString()}
</span>
<span className={styles.execPickerDuration}>{ex.durationMs}ms</span>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -1,61 +0,0 @@
import { useMemo } from 'react';
import type { PositionedEdge } from '../../../api/types';
import styles from './diagram.module.css';
interface FlowParticlesProps {
edges: PositionedEdge[];
executedEdges: Set<string>;
isActive: boolean;
}
function pointsToPath(points: number[][]): string {
if (!points || points.length < 2) return '';
let d = `M${points[0][0]},${points[0][1]}`;
for (let i = 1; i < points.length; i++) {
d += ` L${points[i][0]},${points[i][1]}`;
}
return d;
}
export function FlowParticles({ edges, executedEdges, isActive }: FlowParticlesProps) {
const paths = useMemo(() => {
if (!isActive) return [];
return edges
.filter((e) => executedEdges.has(`${e.sourceId}->${e.targetId}`))
.map((e, i) => ({
id: `particle-${e.sourceId}-${e.targetId}`,
d: pointsToPath(e.points ?? []),
delay: (i * 0.3) % 1.5,
}))
.filter((p) => p.d);
}, [edges, executedEdges, isActive]);
if (!isActive || paths.length === 0) return null;
return (
<g className={styles.flowParticles}>
{paths.map((p) => (
<g key={p.id}>
<path id={p.id} d={p.d} fill="none" stroke="none" />
<circle r={3} fill="url(#particle-gradient)">
<animateMotion
dur="1.5s"
repeatCount="indefinite"
begin={`${p.delay}s`}
>
<mpath href={`#${p.id}`} />
</animateMotion>
<animate
attributeName="opacity"
values="0;1;1;0"
keyTimes="0;0.1;0.8;1"
dur="1.5s"
repeatCount="indefinite"
begin={`${p.delay}s`}
/>
</circle>
</g>
))}
</g>
);
}

View File

@@ -1,102 +0,0 @@
import { useMemo } from 'react';
import type { ExecutionDetail, ProcessorNode } from '../../../api/types';
import { useProcessorSnapshot } from '../../../api/queries/executions';
import { ExchangeInspector } from './ExchangeInspector';
import styles from './diagram.module.css';
interface ProcessorDetailPanelProps {
execution: ExecutionDetail;
selectedNodeId: string | null;
}
/** Find the processor node matching a diagramNodeId, return its flat index too */
function findProcessor(
processors: ProcessorNode[],
nodeId: string,
indexRef: { idx: number },
): ProcessorNode | null {
for (const proc of processors) {
const currentIdx = indexRef.idx;
indexRef.idx++;
if (proc.diagramNodeId === nodeId) {
return { ...proc, _flatIndex: currentIdx } as ProcessorNode & { _flatIndex: number };
}
if (proc.children && proc.children.length > 0) {
const found = findProcessor(proc.children, nodeId, indexRef);
if (found) return found;
}
}
return null;
}
export function ProcessorDetailPanel({ execution, selectedNodeId }: ProcessorDetailPanelProps) {
const processor = useMemo(() => {
if (!selectedNodeId || !execution.processors) return null;
return findProcessor(execution.processors, selectedNodeId, { idx: 0 });
}, [execution, selectedNodeId]);
// Get flat index for snapshot lookup
const flatIndex = useMemo(() => {
if (!processor) return null;
return (processor as ProcessorNode & { _flatIndex?: number })._flatIndex ?? null;
}, [processor]);
const { data: snapshot } = useProcessorSnapshot(
flatIndex != null ? execution.executionId ?? null : null,
flatIndex,
);
if (!selectedNodeId || !processor) {
return (
<div className={styles.detailPanel}>
<div className={styles.detailEmpty}>
Click a node to view processor details
</div>
</div>
);
}
return (
<div className={styles.detailPanel}>
{/* Processor identity */}
<div className={styles.detailHeader}>
<div className={styles.detailType}>{processor.processorType}</div>
<div className={styles.detailId}>{processor.processorId}</div>
</div>
<div className={styles.detailMeta}>
<div className={styles.detailMetaItem}>
<span className={styles.detailMetaLabel}>Status</span>
<span className={`${styles.detailMetaValue} ${processor.status === 'FAILED' ? styles.statusFailed : styles.statusOk}`}>
{processor.status}
</span>
</div>
<div className={styles.detailMetaItem}>
<span className={styles.detailMetaLabel}>Duration</span>
<span className={styles.detailMetaValue}>{processor.durationMs}ms</span>
</div>
</div>
{/* Error info */}
{processor.errorMessage && (
<div className={styles.detailError}>
<div className={styles.detailErrorLabel}>Error</div>
<div className={styles.detailErrorMessage}>{processor.errorMessage}</div>
</div>
)}
{/* Exchange data */}
{snapshot && <ExchangeInspector snapshot={snapshot} />}
{/* Actions (future) */}
<div className={styles.detailActions}>
<button className={styles.detailActionBtn} disabled title="Coming soon">
Collect Trace Data
</button>
<button className={styles.detailActionBtn} disabled title="Coming soon">
View Logs
</button>
</div>
</div>
);
}

View File

@@ -1,128 +0,0 @@
import type { DiagramLayout } from '../../../api/types';
import type { OverlayState } from '../../../hooks/useExecutionOverlay';
import { SvgDefs } from './SvgDefs';
import { EdgeLayer } from './EdgeLayer';
import { DiagramNode } from './DiagramNode';
import type { TooltipData } from './DiagramNode';
import { FlowParticles } from './FlowParticles';
import { isCompoundType } from './nodeStyles';
import type { PositionedNode } from '../../../api/types';
interface RouteDiagramSvgProps {
layout: DiagramLayout;
overlay: OverlayState;
onNodeHover?: (data: TooltipData | null, x: number, y: number) => void;
}
/** Recursively flatten all nodes (including compound children) for rendering */
function flattenNodes(nodes: PositionedNode[]): PositionedNode[] {
const result: PositionedNode[] = [];
for (const node of nodes) {
result.push(node);
if (node.children && node.children.length > 0) {
result.push(...flattenNodes(node.children));
}
}
return result;
}
export function RouteDiagramSvg({ layout, overlay, onNodeHover }: RouteDiagramSvgProps) {
const padding = 40;
const width = (layout.width ?? 600) + padding * 2;
const height = (layout.height ?? 400) + padding * 2;
const allNodes = flattenNodes(layout.nodes ?? []);
// Render compound nodes first (background), then regular nodes on top
const compoundNodes = allNodes.filter((n) => isCompoundType(n.type ?? ''));
const leafNodes = allNodes.filter((n) => !isCompoundType(n.type ?? ''));
return (
<svg
width={width}
height={height}
viewBox={`-${padding} -${padding} ${width} ${height}`}
xmlns="http://www.w3.org/2000/svg"
style={{ display: 'block' }}
>
<SvgDefs />
{/* Compound container nodes (background) */}
{compoundNodes.map((node) => {
const iterData = node.id ? overlay.iterationData.get(node.id) : undefined;
return (
<g key={node.id}>
<DiagramNode
node={node}
isExecuted={!!node.id && overlay.executedNodes.has(node.id)}
isError={!!node.id && overlay.statuses.get(node.id) === 'FAILED'}
isOverlayActive={overlay.isActive}
duration={node.id ? overlay.durations.get(node.id) : undefined}
sequence={undefined}
isSelected={overlay.selectedNodeId === node.id}
onClick={overlay.selectNode}
onHover={onNodeHover}
/>
{/* Iteration count badge */}
{overlay.isActive && iterData && iterData.count > 1 && (
<g>
<rect
x={(node.x ?? 0) + (node.width ?? 0) - 32}
y={(node.y ?? 0) + 2}
width={28}
height={16}
rx={8}
fill="#b87aff"
opacity={0.9}
/>
<text
x={(node.x ?? 0) + (node.width ?? 0) - 18}
y={(node.y ?? 0) + 13}
fill="#fff"
fontSize={9}
fontFamily="JetBrains Mono, monospace"
fontWeight={600}
textAnchor="middle"
>
{'\u00D7'}{iterData.count}
</text>
</g>
)}
</g>
);
})}
{/* Edges */}
<EdgeLayer
edges={layout.edges ?? []}
executedEdges={overlay.executedEdges}
isOverlayActive={overlay.isActive}
/>
{/* Flow particles */}
<FlowParticles
edges={layout.edges ?? []}
executedEdges={overlay.executedEdges}
isActive={overlay.isActive}
/>
{/* Leaf nodes (on top of edges) */}
{leafNodes.map((node) => {
const nodeId = node.id ?? '';
return (
<DiagramNode
key={nodeId}
node={node}
isExecuted={overlay.executedNodes.has(nodeId)}
isError={overlay.statuses.get(nodeId) === 'FAILED'}
isOverlayActive={overlay.isActive}
duration={overlay.durations.get(nodeId)}
sequence={overlay.sequences.get(nodeId)}
isSelected={overlay.selectedNodeId === nodeId}
onClick={overlay.selectNode}
onHover={onNodeHover}
/>
);
})}
</svg>
);
}

View File

@@ -1,64 +0,0 @@
/** SVG definitions: arrow markers, glow filters, gradient fills */
export function SvgDefs() {
return (
<defs>
{/* Arrow marker for edges */}
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3"
orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L8,3 L0,6" fill="#4a5e7a" />
</marker>
<marker id="arrowhead-executed" markerWidth="8" markerHeight="6" refX="8" refY="3"
orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L8,3 L0,6" fill="#3fb950" />
</marker>
<marker id="arrowhead-error" markerWidth="8" markerHeight="6" refX="8" refY="3"
orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L8,3 L0,6" fill="#f85149" />
</marker>
{/* Glow filters */}
<filter id="glow-green" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feFlood floodColor="#3fb950" floodOpacity="0.6" result="color" />
<feComposite in="color" in2="blur" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glow-red" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feFlood floodColor="#f85149" floodOpacity="0.6" result="color" />
<feComposite in="color" in2="blur" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glow-blue" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feFlood floodColor="#58a6ff" floodOpacity="0.6" result="color" />
<feComposite in="color" in2="blur" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glow-purple" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="6" result="blur" />
<feFlood floodColor="#b87aff" floodOpacity="0.6" result="color" />
<feComposite in="color" in2="blur" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Flow particle gradient */}
<radialGradient id="particle-gradient">
<stop offset="0%" stopColor="#3fb950" stopOpacity="1" />
<stop offset="100%" stopColor="#3fb950" stopOpacity="0" />
</radialGradient>
</defs>
);
}

View File

@@ -1,654 +0,0 @@
/* ─── Diagram Canvas ─── */
.canvasContainer {
position: relative;
flex: 1;
min-height: 0;
background:
radial-gradient(ellipse at 20% 50%, rgba(240, 180, 41, 0.04) 0%, transparent 60%),
radial-gradient(ellipse at 80% 50%, rgba(34, 211, 238, 0.04) 0%, transparent 60%),
var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
}
.canvas {
width: 100%;
height: 100%;
min-height: 500px;
overflow: hidden;
cursor: grab;
}
.canvas:active {
cursor: grabbing;
}
/* ─── Zoom Controls ─── */
.zoomControls {
position: absolute;
top: 12px;
right: 12px;
display: flex;
gap: 4px;
z-index: 10;
}
.zoomBtn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.zoomBtn:hover {
background: var(--bg-raised);
color: var(--text-primary);
}
/* ─── Minimap ─── */
.minimap {
position: absolute;
bottom: 12px;
right: 12px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 4px;
z-index: 10;
opacity: 0.85;
transition: opacity 0.2s;
}
.minimap:hover {
opacity: 1;
}
/* ─── Node Styles ─── */
.nodeGroup {
transition: opacity 0.3s;
}
.dimmed {
opacity: 0.15 !important;
}
.selected rect {
stroke-width: 2.5;
}
/* ─── Edge Layer ─── */
.edgeLayer path {
transition: opacity 0.3s, stroke 0.3s;
}
/* ─── Flow Particles ─── */
.flowParticles circle {
pointer-events: none;
}
/* ─── Split Layout (Diagram + Detail Panel) ─── */
.splitLayout {
display: flex;
gap: 0;
height: calc(100vh - 56px - 200px);
min-height: 500px;
}
.diagramSide {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
/* ─── Side Panel (wraps mode tabs + detail/tree) ─── */
.sidePanel {
flex-shrink: 0;
background: var(--bg-surface);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ─── Processor Detail Panel ─── */
.detailPanel {
flex: 1;
min-height: 0;
background: var(--bg-surface);
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
/* ─── Detail Mode Tabs ─── */
.detailModeTabs {
display: flex;
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.detailModeTab {
flex: 1;
padding: 8px 16px;
border: none;
background: none;
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.detailModeTab:hover {
color: var(--text-secondary);
}
.detailModeTabActive {
color: var(--amber);
border-bottom-color: var(--amber);
}
.treeContainer {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.detailEmpty {
color: var(--text-muted);
font-size: 13px;
text-align: center;
padding: 40px 16px;
}
.detailHeader {
border-bottom: 1px solid var(--border-subtle);
padding-bottom: 12px;
}
.detailType {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--amber);
margin-bottom: 4px;
}
.detailId {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
word-break: break-all;
}
.detailMeta {
display: flex;
gap: 16px;
}
.detailMetaItem {
display: flex;
flex-direction: column;
gap: 2px;
}
.detailMetaLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.detailMetaValue {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
}
.statusFailed {
color: var(--rose);
}
.statusOk {
color: var(--green);
}
.detailError {
background: var(--rose-glow);
border: 1px solid rgba(244, 63, 94, 0.2);
border-radius: var(--radius-sm);
padding: 10px 12px;
}
.detailErrorLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--rose);
margin-bottom: 4px;
}
.detailErrorMessage {
font-family: var(--font-mono);
font-size: 11px;
color: var(--rose);
max-height: 80px;
overflow: auto;
}
/* ─── Exchange Inspector ─── */
.exchangeInspector {
flex: 1;
min-height: 0;
}
.exchangeTabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-subtle);
margin-bottom: 12px;
}
.exchangeTab {
padding: 6px 16px;
border: none;
background: none;
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.exchangeTab:hover {
color: var(--text-secondary);
}
.exchangeTabActive {
color: var(--amber);
border-bottom-color: var(--amber);
}
.exchangeSection {
margin-bottom: 12px;
}
.exchangeSectionLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 6px;
}
.exchangeBody {
background: var(--bg-base);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
max-height: 200px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
.exchangeEmpty {
color: var(--text-muted);
font-size: 12px;
text-align: center;
padding: 20px;
}
/* ─── Detail Actions ─── */
.detailActions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--border-subtle);
}
.detailActionBtn {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.detailActionBtn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.detailActionBtn:disabled {
opacity: 0.4;
cursor: default;
}
/* ─── Execution Picker ─── */
.execPicker {
position: relative;
}
.execPickerBtn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.execPickerBtn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.execPickerChevron {
font-size: 10px;
color: var(--text-muted);
}
.execPickerDropdown {
position: absolute;
top: calc(100% + 4px);
right: 0;
width: 260px;
max-height: 300px;
overflow-y: auto;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 50;
}
.execPickerEmpty {
padding: 16px;
text-align: center;
color: var(--text-muted);
font-size: 12px;
}
.execPickerItem {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
background: none;
color: var(--text-secondary);
font-size: 11px;
font-family: var(--font-mono);
cursor: pointer;
transition: background 0.1s;
}
.execPickerItem:hover {
background: var(--bg-hover);
}
.execPickerItemActive {
background: var(--bg-raised);
color: var(--text-primary);
}
.execPickerStatus {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.execPickerOk {
background: var(--green);
}
.execPickerFailed {
background: var(--rose);
}
.execPickerTime {
flex: 1;
text-align: left;
}
.execPickerDuration {
color: var(--text-muted);
}
/* ─── Node Tooltip ─── */
.nodeTooltip {
position: fixed;
transform: translate(12px, -50%);
background: rgba(13, 17, 23, 0.95);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
z-index: 100;
pointer-events: none;
backdrop-filter: blur(8px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
min-width: 140px;
max-width: 280px;
}
.tooltipHeader {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.tooltipDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.tooltipType {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
}
.tooltipLabel {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-primary);
word-break: break-all;
}
.tooltipMeta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border-subtle);
font-family: var(--font-mono);
font-size: 11px;
}
.tooltipStatusOk {
color: var(--green);
font-weight: 600;
}
.tooltipStatusFailed {
color: var(--rose);
font-weight: 600;
}
.tooltipDuration {
color: var(--text-secondary);
}
/* ─── Legend ─── */
.legendToggle {
position: absolute;
bottom: 12px;
left: 12px;
padding: 5px 12px;
background: rgba(13, 17, 23, 0.85);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
color: var(--text-muted);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
cursor: pointer;
z-index: 10;
backdrop-filter: blur(4px);
transition: all 0.15s;
}
.legendToggle:hover {
background: rgba(13, 17, 23, 0.95);
color: var(--text-secondary);
border-color: var(--border);
}
.legend {
position: absolute;
bottom: 12px;
left: 12px;
display: flex;
gap: 16px;
background: rgba(13, 17, 23, 0.85);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 10px 14px;
z-index: 10;
backdrop-filter: blur(4px);
}
.legendCloseBtn {
position: absolute;
top: 4px;
right: 6px;
background: none;
border: none;
color: var(--text-muted);
font-size: 14px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
transition: color 0.15s;
}
.legendCloseBtn:hover {
color: var(--text-primary);
}
.legendSection {
display: flex;
flex-direction: column;
gap: 4px;
}
.legendTitle {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 2px;
}
.legendRow {
display: flex;
align-items: center;
gap: 6px;
}
.legendDot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.legendLine {
width: 16px;
height: 2px;
flex-shrink: 0;
border-radius: 1px;
}
.legendLabel {
font-size: 10px;
color: var(--text-secondary);
white-space: nowrap;
}
/* ─── Responsive ─── */
@media (max-width: 768px) {
.splitLayout {
flex-direction: column;
}
.sidePanel {
width: 100% !important;
max-height: 300px;
border-top: 1px solid var(--border-subtle);
}
.detailPanel {
width: 100%;
max-height: 300px;
border-left: none;
border-top: 1px solid var(--border-subtle);
}
.minimap {
display: none;
}
.legend {
display: none;
}
.legendToggle {
display: none;
}
}

View File

@@ -1,52 +0,0 @@
/** Node type styling: border color, background, glow filter */
const ENDPOINT_TYPES = new Set([
'ENDPOINT', 'DIRECT', 'SEDA', 'TO', 'TO_DYNAMIC', 'FROM',
]);
const EIP_TYPES = new Set([
'CHOICE', 'SPLIT', 'MULTICAST', 'FILTER', 'AGGREGATE',
'RECIPIENT_LIST', 'ROUTING_SLIP', 'DYNAMIC_ROUTER',
'CIRCUIT_BREAKER', 'WHEN', 'OTHERWISE', 'LOOP',
]);
const ERROR_TYPES = new Set([
'ON_EXCEPTION', 'TRY_CATCH', 'DO_CATCH', 'DO_FINALLY',
'ERROR_HANDLER',
]);
const CROSS_ROUTE_TYPES = new Set([
'WIRE_TAP', 'ENRICH', 'POLL_ENRICH',
]);
export interface NodeStyle {
border: string;
bg: string;
glowFilter: string;
category: 'endpoint' | 'eip' | 'processor' | 'error' | 'crossRoute';
}
export function getNodeStyle(type: string): NodeStyle {
const upper = type.toUpperCase();
if (ERROR_TYPES.has(upper)) {
return { border: '#f85149', bg: '#3d1418', glowFilter: 'url(#glow-red)', category: 'error' };
}
if (ENDPOINT_TYPES.has(upper)) {
return { border: '#58a6ff', bg: '#1a3a5c', glowFilter: 'url(#glow-blue)', category: 'endpoint' };
}
if (CROSS_ROUTE_TYPES.has(upper)) {
return { border: '#39d2e0', bg: 'transparent', glowFilter: 'url(#glow-blue)', category: 'crossRoute' };
}
if (EIP_TYPES.has(upper)) {
return { border: '#b87aff', bg: '#2d1b4e', glowFilter: 'url(#glow-purple)', category: 'eip' };
}
// Default: Processor
return { border: '#3fb950', bg: '#0d2818', glowFilter: 'url(#glow-green)', category: 'processor' };
}
/** Compound node types that can contain children */
export const COMPOUND_TYPES = new Set([
'CHOICE', 'SPLIT', 'TRY_CATCH', 'LOOP', 'MULTICAST', 'AGGREGATE',
'ON_EXCEPTION', 'DO_CATCH', 'DO_FINALLY',
]);
export function isCompoundType(type: string): boolean {
return COMPOUND_TYPES.has(type.toUpperCase());
}

View File

@@ -1,4 +0,0 @@
.container {
margin: 0;
padding: 24px;
}

View File

@@ -1,29 +1,29 @@
import { useEffect, useRef } from 'react';
import { useAuthStore } from '../../auth/auth-store';
import SwaggerUI from 'swagger-ui-dist/swagger-ui-es-bundle.js';
import 'swagger-ui-dist/swagger-ui.css';
import styles from './SwaggerPage.module.css';
import { config } from '../../config';
export function SwaggerPage() {
export default function SwaggerPage() {
const containerRef = useRef<HTMLDivElement>(null);
const token = useAuthStore((s) => s.accessToken);
useEffect(() => {
if (!containerRef.current) return;
containerRef.current.innerHTML = '';
let cleanup: (() => void) | undefined;
SwaggerUI({
url: '/api/v1/api-docs',
domNode: containerRef.current,
deepLinking: true,
requestInterceptor: (req: Record<string, unknown>) => {
if (token) {
(req.headers as Record<string, string>)['Authorization'] = `Bearer ${token}`;
}
return req;
},
import('swagger-ui-dist/swagger-ui-bundle').then(({ default: SwaggerUIBundle }) => {
if (!containerRef.current) return;
SwaggerUIBundle({
url: `${config.apiBaseUrl}/api-docs`,
domNode: containerRef.current,
presets: [],
layout: 'BaseLayout',
});
});
}, [token]);
return <div ref={containerRef} className={styles.container} />;
return () => cleanup?.();
}, []);
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>API Documentation</h2>
<div ref={containerRef} />
</div>
);
}