feat: replace UI with design system example pages wired to real API
Migrate all page components from the @cameleer/design-system v0.0.3 example UI, replacing mock data with real backend API hooks. This brings richer visuals (KpiStrip, GroupCard, RouteFlow, ProcessorTimeline, DateRangePicker, expandable rows) while preserving all existing API integration, auth, and routing infrastructure. Pages migrated: Dashboard, RoutesMetrics, RouteDetail, ExchangeDetail, AgentHealth, AgentInstance, OidcConfig, AuditLog, RBAC (Users/Groups/Roles). Also enhanced LayoutShell CommandPalette with real search data from catalog. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,72 @@
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router';
|
||||
import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, useCommandPalette } from '@cameleer/design-system';
|
||||
import type { SidebarApp, SearchResult } from '@cameleer/design-system';
|
||||
import { useRouteCatalog } from '../api/queries/catalog';
|
||||
import { useAgents } from '../api/queries/agents';
|
||||
import { useAuthStore } from '../auth/auth-store';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import type { SidebarApp } from '@cameleer/design-system';
|
||||
|
||||
function healthToColor(health: string): string {
|
||||
switch (health) {
|
||||
case 'live': return 'success';
|
||||
case 'stale': return 'warning';
|
||||
case 'dead': return 'error';
|
||||
default: return 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
function buildSearchData(
|
||||
catalog: any[] | undefined,
|
||||
agents: any[] | undefined,
|
||||
): SearchResult[] {
|
||||
if (!catalog) return [];
|
||||
const results: SearchResult[] = [];
|
||||
|
||||
for (const app of catalog) {
|
||||
const liveAgents = (app.agents || []).filter((a: any) => a.status === 'live').length;
|
||||
results.push({
|
||||
id: app.appId,
|
||||
category: 'application',
|
||||
title: app.appId,
|
||||
badges: [{ label: (app.health || 'unknown').toUpperCase(), color: healthToColor(app.health) }],
|
||||
meta: `${(app.routes || []).length} routes · ${(app.agents || []).length} agents (${liveAgents} live) · ${(app.exchangeCount ?? 0).toLocaleString()} exchanges`,
|
||||
path: `/apps/${app.appId}`,
|
||||
});
|
||||
|
||||
for (const route of (app.routes || [])) {
|
||||
results.push({
|
||||
id: route.routeId,
|
||||
category: 'route',
|
||||
title: route.routeId,
|
||||
badges: [{ label: app.appId }],
|
||||
meta: `${(route.exchangeCount ?? 0).toLocaleString()} exchanges`,
|
||||
path: `/apps/${app.appId}/${route.routeId}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (agents) {
|
||||
for (const agent of agents) {
|
||||
results.push({
|
||||
id: agent.id,
|
||||
category: 'agent',
|
||||
title: agent.name,
|
||||
badges: [{ label: (agent.state || 'unknown').toUpperCase(), color: healthToColor((agent.state || '').toLowerCase()) }],
|
||||
meta: `${agent.application} · ${agent.version || ''}${agent.agentTps != null ? ` · ${agent.agentTps.toFixed(1)} msg/s` : ''}`,
|
||||
path: `/agents/${agent.application}/${agent.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function LayoutContent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { data: catalog } = useRouteCatalog();
|
||||
const { username, roles, logout } = useAuthStore();
|
||||
const { data: agents } = useAgents();
|
||||
const { username, logout } = useAuthStore();
|
||||
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
||||
|
||||
const sidebarApps: SidebarApp[] = useMemo(() => {
|
||||
@@ -33,6 +90,11 @@ function LayoutContent() {
|
||||
}));
|
||||
}, [catalog]);
|
||||
|
||||
const searchData = useMemo(
|
||||
() => buildSearchData(catalog, agents as any[]),
|
||||
[catalog, agents],
|
||||
);
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
const parts = location.pathname.split('/').filter(Boolean);
|
||||
return parts.map((part, i) => ({
|
||||
@@ -47,12 +109,12 @@ function LayoutContent() {
|
||||
}, [logout, navigate]);
|
||||
|
||||
const handlePaletteSelect = useCallback((result: any) => {
|
||||
if (result.path) navigate(result.path);
|
||||
if (result.path) {
|
||||
navigate(result.path, { state: result.path ? { sidebarReveal: result.path } : undefined });
|
||||
}
|
||||
setPaletteOpen(false);
|
||||
}, [navigate, setPaletteOpen]);
|
||||
|
||||
const isAdmin = roles.includes('ADMIN');
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
sidebar={
|
||||
@@ -70,7 +132,7 @@ function LayoutContent() {
|
||||
open={paletteOpen}
|
||||
onClose={() => setPaletteOpen(false)}
|
||||
onSelect={handlePaletteSelect}
|
||||
data={[]}
|
||||
data={searchData}
|
||||
/>
|
||||
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
|
||||
<Outlet />
|
||||
|
||||
@@ -20,7 +20,9 @@ export default function AdminLayout() {
|
||||
active={location.pathname}
|
||||
onChange={(path) => navigate(path)}
|
||||
/>
|
||||
<Outlet />
|
||||
<div style={{ padding: '20px 24px 40px' }}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
86
ui/src/pages/Admin/AuditLogPage.module.css
Normal file
86
ui/src/pages/Admin/AuditLogPage.module.css
Normal file
@@ -0,0 +1,86 @@
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.filterInput {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.tableSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.tableTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tableRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tableMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.target {
|
||||
display: inline-block;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.expandedDetail {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.detailGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detailField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detailLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.detailValue {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@@ -1,59 +1,148 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { DataTable, Badge, Input, Select, MonoText, CodeBlock } from '@cameleer/design-system';
|
||||
import {
|
||||
Badge, DateRangePicker, Input, Select, MonoText, CodeBlock, DataTable,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useAuditLog } from '../../api/queries/admin/audit';
|
||||
import { useAuditLog, type AuditEvent } from '../../api/queries/admin/audit';
|
||||
import styles from './AuditLogPage.module.css';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: '', label: 'All categories' },
|
||||
{ value: 'INFRA', label: 'INFRA' },
|
||||
{ value: 'AUTH', label: 'AUTH' },
|
||||
{ value: 'USER_MGMT', label: 'USER_MGMT' },
|
||||
{ value: 'CONFIG', label: 'CONFIG' },
|
||||
{ value: 'RBAC', label: 'RBAC' },
|
||||
];
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
return new Date(iso).toLocaleString('en-GB', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
type AuditRow = Omit<AuditEvent, 'id'> & { id: string };
|
||||
|
||||
const COLUMNS: Column<AuditRow>[] = [
|
||||
{
|
||||
key: 'timestamp', header: 'Timestamp', width: '170px', sortable: true,
|
||||
render: (_, row) => <MonoText size="xs">{formatTimestamp(row.timestamp)}</MonoText>,
|
||||
},
|
||||
{
|
||||
key: 'username', header: 'User', sortable: true,
|
||||
render: (_, row) => <span style={{ fontWeight: 500 }}>{row.username}</span>,
|
||||
},
|
||||
{
|
||||
key: 'category', header: 'Category', width: '110px', sortable: true,
|
||||
render: (_, row) => <Badge label={row.category} color="auto" />,
|
||||
},
|
||||
{ key: 'action', header: 'Action' },
|
||||
{
|
||||
key: 'target', header: 'Target',
|
||||
render: (_, row) => <span className={styles.target}>{row.target}</span>,
|
||||
},
|
||||
{
|
||||
key: 'result', header: 'Result', width: '90px', sortable: true,
|
||||
render: (_, row) => (
|
||||
<Badge label={row.result} color={row.result === 'SUCCESS' ? 'success' : 'error'} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [dateRange, setDateRange] = useState({
|
||||
start: new Date(Date.now() - 7 * 24 * 3600_000),
|
||||
end: new Date(),
|
||||
});
|
||||
const [userFilter, setUserFilter] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('');
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const { data, isLoading } = useAuditLog({ search, category: category || undefined, page, size: 25 });
|
||||
const { data } = useAuditLog({
|
||||
username: userFilter || undefined,
|
||||
category: categoryFilter || undefined,
|
||||
search: searchFilter || undefined,
|
||||
from: dateRange.start.toISOString(),
|
||||
to: dateRange.end.toISOString(),
|
||||
page,
|
||||
size: 25,
|
||||
});
|
||||
|
||||
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) })),
|
||||
const rows: AuditRow[] = useMemo(
|
||||
() => (data?.items || []).map((item) => ({ ...item, id: String(item.id) })),
|
||||
[data],
|
||||
);
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
|
||||
return (
|
||||
<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)} />
|
||||
<div className={styles.filters}>
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={(range) => { setDateRange(range); setPage(0); }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Filter by user..."
|
||||
value={userFilter}
|
||||
onChange={(e) => { setUserFilter(e.target.value); setPage(0); }}
|
||||
onClear={() => { setUserFilter(''); setPage(0); }}
|
||||
className={styles.filterInput}
|
||||
/>
|
||||
<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)}
|
||||
options={CATEGORIES}
|
||||
value={categoryFilter}
|
||||
onChange={(e) => { setCategoryFilter(e.target.value); setPage(0); }}
|
||||
className={styles.filterSelect}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search action or target..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => { setSearchFilter(e.target.value); setPage(0); }}
|
||||
onClear={() => { setSearchFilter(''); setPage(0); }}
|
||||
className={styles.filterInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
sortable
|
||||
pageSize={25}
|
||||
expandedContent={(row) => (
|
||||
<div style={{ padding: '0.75rem' }}>
|
||||
<CodeBlock content={JSON.stringify(row.detail, null, 2)} />
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Audit Log</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>
|
||||
{totalCount} events
|
||||
</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={COLUMNS}
|
||||
data={rows}
|
||||
sortable
|
||||
flush
|
||||
pageSize={25}
|
||||
rowAccent={(row) => row.result === 'FAILURE' ? 'error' : undefined}
|
||||
expandedContent={(row) => (
|
||||
<div className={styles.expandedDetail}>
|
||||
<div className={styles.detailGrid}>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>IP Address</span>
|
||||
<MonoText size="xs">{row.ipAddress}</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>User Agent</span>
|
||||
<span className={styles.detailValue}>{row.userAgent}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailField}>
|
||||
<span className={styles.detailLabel}>Detail</span>
|
||||
<CodeBlock content={JSON.stringify(row.detail, null, 2)} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
MonoText,
|
||||
Tag,
|
||||
Select,
|
||||
ConfirmDialog,
|
||||
Spinner,
|
||||
MonoText,
|
||||
SectionHeader,
|
||||
Tag,
|
||||
InlineEdit,
|
||||
MultiSelect,
|
||||
ConfirmDialog,
|
||||
AlertDialog,
|
||||
SplitPane,
|
||||
EntityList,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import {
|
||||
@@ -25,26 +30,31 @@ import {
|
||||
useUsers,
|
||||
useRoles,
|
||||
} from '../../api/queries/admin/rbac';
|
||||
import type { GroupDetail } from '../../api/queries/admin/rbac';
|
||||
import styles from './UserManagement.module.css';
|
||||
|
||||
const BUILTIN_ADMINS_ID = '00000000-0000-0000-0000-000000000010';
|
||||
|
||||
export default function GroupsTab() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [newGroupParentId, setNewGroupParentId] = useState<string>('');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [addMemberUserId, setAddMemberUserId] = useState<string>('');
|
||||
const [addRoleId, setAddRoleId] = useState<string>('');
|
||||
|
||||
const { toast } = useToast();
|
||||
const { data: groups = [], isLoading: groupsLoading } = useGroups();
|
||||
const { data: selectedGroup, isLoading: detailLoading } = useGroup(selectedGroupId);
|
||||
const { data: users = [] } = useUsers();
|
||||
const { data: roles = [] } = useRoles();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<GroupDetail | null>(null);
|
||||
const [removeRoleTarget, setRemoveRoleTarget] = useState<string | null>(null);
|
||||
|
||||
// Create form state
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newParent, setNewParent] = useState('');
|
||||
|
||||
// Detail query
|
||||
const { data: selectedGroup, isLoading: detailLoading } = useGroup(selectedId);
|
||||
|
||||
// Mutations
|
||||
const createGroup = useCreateGroup();
|
||||
const updateGroup = useUpdateGroup();
|
||||
const deleteGroup = useDeleteGroup();
|
||||
@@ -53,350 +63,385 @@ export default function GroupsTab() {
|
||||
const addUserToGroup = useAddUserToGroup();
|
||||
const removeUserFromGroup = useRemoveUserFromGroup();
|
||||
|
||||
const filteredGroups = groups.filter((g) =>
|
||||
g.name.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
const filtered = useMemo(() => {
|
||||
if (!search) return groups;
|
||||
const q = search.toLowerCase();
|
||||
return groups.filter((g) => g.name.toLowerCase().includes(q));
|
||||
}, [groups, search]);
|
||||
|
||||
const isBuiltinAdmins = selectedGroup?.id === BUILTIN_ADMINS_ID;
|
||||
|
||||
const parentOptions = [
|
||||
{ value: '', label: 'Top-level' },
|
||||
...groups.map((g) => ({ value: g.id, label: g.name })),
|
||||
...groups
|
||||
.filter((g) => g.id !== selectedId)
|
||||
.map((g) => ({ value: g.id, label: g.name })),
|
||||
];
|
||||
|
||||
const parentName = (parentGroupId: string | null) => {
|
||||
const duplicateGroupName =
|
||||
newName.trim() !== '' &&
|
||||
groups.some(
|
||||
(g) => g.name.toLowerCase() === newName.trim().toLowerCase(),
|
||||
);
|
||||
|
||||
// Derived data for the detail pane
|
||||
const children = selectedGroup?.childGroups ?? [];
|
||||
const members = selectedGroup?.members ?? [];
|
||||
const parentGroup = selectedGroup?.parentGroupId
|
||||
? groups.find((g) => g.id === selectedGroup.parentGroupId)
|
||||
: null;
|
||||
|
||||
const memberUserIds = new Set(members.map((m) => m.userId));
|
||||
const assignedRoleIds = new Set(
|
||||
(selectedGroup?.directRoles ?? []).map((r) => r.id),
|
||||
);
|
||||
|
||||
const availableRoles = roles
|
||||
.filter((r) => !assignedRoleIds.has(r.id))
|
||||
.map((r) => ({ value: r.id, label: r.name }));
|
||||
|
||||
const availableMembers = users
|
||||
.filter((u) => !memberUserIds.has(u.userId))
|
||||
.map((u) => ({ value: u.userId, label: u.displayName }));
|
||||
|
||||
function parentName(parentGroupId: string | null): string {
|
||||
if (!parentGroupId) return 'Top-level';
|
||||
const parent = groups.find((g) => g.id === parentGroupId);
|
||||
return parent ? parent.name : parentGroupId;
|
||||
};
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
const name = newGroupName.trim();
|
||||
if (!name) return;
|
||||
async function handleCreate() {
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
await createGroup.mutateAsync({
|
||||
name,
|
||||
parentGroupId: newGroupParentId || null,
|
||||
name: newName.trim(),
|
||||
parentGroupId: newParent || null,
|
||||
});
|
||||
toast({ title: 'Group created', variant: 'success' });
|
||||
setNewGroupName('');
|
||||
setNewGroupParentId('');
|
||||
setShowCreate(false);
|
||||
toast({ title: 'Group created', description: newName.trim(), variant: 'success' });
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
setNewParent('');
|
||||
} catch {
|
||||
toast({ title: 'Failed to create group', variant: 'error' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleRename = async (newName: string) => {
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteGroup.mutateAsync(deleteTarget.id);
|
||||
toast({
|
||||
title: 'Group deleted',
|
||||
description: deleteTarget.name,
|
||||
variant: 'warning',
|
||||
});
|
||||
if (selectedId === deleteTarget.id) setSelectedId(null);
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
toast({ title: 'Failed to delete group', variant: 'error' });
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRename(newNameVal: string) {
|
||||
if (!selectedGroup) return;
|
||||
try {
|
||||
await updateGroup.mutateAsync({
|
||||
id: selectedGroup.id,
|
||||
name: newName,
|
||||
name: newNameVal,
|
||||
parentGroupId: selectedGroup.parentGroupId,
|
||||
});
|
||||
toast({ title: 'Group renamed', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to rename group', variant: 'error' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
async function handleRemoveMember(userId: string) {
|
||||
if (!selectedGroup) return;
|
||||
try {
|
||||
await deleteGroup.mutateAsync(selectedGroup.id);
|
||||
toast({ title: 'Group deleted', variant: 'success' });
|
||||
setSelectedGroupId(null);
|
||||
setDeleteOpen(false);
|
||||
} catch {
|
||||
toast({ title: 'Failed to delete group', variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async () => {
|
||||
if (!selectedGroup || !addMemberUserId) return;
|
||||
try {
|
||||
await addUserToGroup.mutateAsync({
|
||||
userId: addMemberUserId,
|
||||
await removeUserFromGroup.mutateAsync({
|
||||
userId,
|
||||
groupId: selectedGroup.id,
|
||||
});
|
||||
toast({ title: 'Member added', variant: 'success' });
|
||||
setAddMemberUserId('');
|
||||
} catch {
|
||||
toast({ title: 'Failed to add member', variant: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (userId: string) => {
|
||||
if (!selectedGroup) return;
|
||||
try {
|
||||
await removeUserFromGroup.mutateAsync({ userId, groupId: selectedGroup.id });
|
||||
toast({ title: 'Member removed', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to remove member', variant: 'error' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleAddRole = async () => {
|
||||
if (!selectedGroup || !addRoleId) return;
|
||||
try {
|
||||
await assignRoleToGroup.mutateAsync({
|
||||
groupId: selectedGroup.id,
|
||||
roleId: addRoleId,
|
||||
});
|
||||
toast({ title: 'Role assigned', variant: 'success' });
|
||||
setAddRoleId('');
|
||||
} catch {
|
||||
toast({ title: 'Failed to assign role', variant: 'error' });
|
||||
async function handleAddMembers(userIds: string[]) {
|
||||
if (!selectedGroup) return;
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
await addUserToGroup.mutateAsync({
|
||||
userId,
|
||||
groupId: selectedGroup.id,
|
||||
});
|
||||
toast({ title: 'Member added', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to add member', variant: 'error' });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleRemoveRole = async (roleId: string) => {
|
||||
async function handleAddRoles(roleIds: string[]) {
|
||||
if (!selectedGroup) return;
|
||||
for (const roleId of roleIds) {
|
||||
try {
|
||||
await assignRoleToGroup.mutateAsync({
|
||||
groupId: selectedGroup.id,
|
||||
roleId,
|
||||
});
|
||||
toast({ title: 'Role assigned', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to assign role', variant: 'error' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveRole(roleId: string) {
|
||||
if (!selectedGroup) return;
|
||||
try {
|
||||
await removeRoleFromGroup.mutateAsync({ groupId: selectedGroup.id, roleId });
|
||||
await removeRoleFromGroup.mutateAsync({
|
||||
groupId: selectedGroup.id,
|
||||
roleId,
|
||||
});
|
||||
toast({ title: 'Role removed', variant: 'success' });
|
||||
} catch {
|
||||
toast({ title: 'Failed to remove role', variant: 'error' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const isBuiltinAdmins = selectedGroup?.id === BUILTIN_ADMINS_ID;
|
||||
|
||||
// Build sets for quick lookup of already-assigned items
|
||||
const memberUserIds = new Set((selectedGroup?.members ?? []).map((m) => m.userId));
|
||||
const assignedRoleIds = new Set((selectedGroup?.directRoles ?? []).map((r) => r.id));
|
||||
|
||||
const availableUsers = users.filter((u) => !memberUserIds.has(u.userId));
|
||||
const availableRoles = roles.filter((r) => !assignedRoleIds.has(r.id));
|
||||
if (groupsLoading) return <Spinner size="md" />;
|
||||
|
||||
return (
|
||||
<div className={styles.splitPane}>
|
||||
{/* Left pane */}
|
||||
<div className={styles.listPane}>
|
||||
<div className={styles.listHeader}>
|
||||
<Input
|
||||
placeholder="Search groups..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onClear={() => setSearch('')}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setShowCreate((v) => !v)}
|
||||
>
|
||||
+ Add Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
<div className={styles.createForm}>
|
||||
<Input
|
||||
placeholder="Group name"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
/>
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Select
|
||||
options={parentOptions}
|
||||
value={newGroupParentId}
|
||||
onChange={(e) => setNewGroupParentId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.createFormActions}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setShowCreate(false);
|
||||
setNewGroupName('');
|
||||
setNewGroupParentId('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
loading={createGroup.isPending}
|
||||
onClick={handleCreate}
|
||||
disabled={!newGroupName.trim()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupsLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className={styles.entityList} role="listbox">
|
||||
{filteredGroups.map((group) => {
|
||||
const isSelected = group.id === selectedGroupId;
|
||||
return (
|
||||
<div
|
||||
key={group.id}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={
|
||||
styles.entityItem +
|
||||
(isSelected ? ' ' + styles.entityItemSelected : '')
|
||||
}
|
||||
onClick={() => setSelectedGroupId(group.id)}
|
||||
>
|
||||
<Avatar name={group.name} size="sm" />
|
||||
<div className={styles.entityInfo}>
|
||||
<div className={styles.entityName}>{group.name}</div>
|
||||
<div className={styles.entityMeta}>
|
||||
{group.parentGroupId
|
||||
? `Child of ${parentName(group.parentGroupId)}`
|
||||
: 'Top-level'}
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<SplitPane
|
||||
list={
|
||||
<>
|
||||
{creating && (
|
||||
<div className={styles.createForm}>
|
||||
<Input
|
||||
placeholder="Group name *"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
{duplicateGroupName && (
|
||||
<span style={{ color: 'var(--error)', fontSize: 11 }}>
|
||||
Group name already exists
|
||||
</span>
|
||||
)}
|
||||
<Select
|
||||
options={parentOptions}
|
||||
value={newParent}
|
||||
onChange={(e) => setNewParent(e.target.value)}
|
||||
/>
|
||||
<div className={styles.createFormActions}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setCreating(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={handleCreate}
|
||||
loading={createGroup.isPending}
|
||||
disabled={!newName.trim() || duplicateGroupName}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right pane */}
|
||||
<div className={styles.detailPane}>
|
||||
{!selectedGroupId ? (
|
||||
<div className={styles.emptyDetail}>Select a group to view details</div>
|
||||
) : detailLoading ? (
|
||||
<Spinner />
|
||||
) : selectedGroup ? (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className={styles.detailHeader}>
|
||||
<Avatar name={selectedGroup.name} size="md" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<InlineEdit
|
||||
value={selectedGroup.name}
|
||||
onSave={handleRename}
|
||||
disabled={isBuiltinAdmins}
|
||||
/>
|
||||
<div className={styles.entityMeta}>
|
||||
{selectedGroup.parentGroupId
|
||||
? `Child of ${parentName(selectedGroup.parentGroupId)}`
|
||||
: 'Top-level'}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={isBuiltinAdmins}
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className={styles.metaGrid}>
|
||||
<span className={styles.metaLabel}>Group ID</span>
|
||||
<MonoText size="xs">{selectedGroup.id}</MonoText>
|
||||
<span className={styles.metaLabel}>Parent</span>
|
||||
<span>{parentName(selectedGroup.parentGroupId)}</span>
|
||||
</div>
|
||||
|
||||
{/* Members */}
|
||||
<div className={styles.sectionTitle}>Members</div>
|
||||
<div className={styles.sectionTags}>
|
||||
{(selectedGroup.members ?? []).map((member) => (
|
||||
<Tag
|
||||
key={member.userId}
|
||||
label={member.displayName}
|
||||
onRemove={() => handleRemoveMember(member.userId)}
|
||||
/>
|
||||
))}
|
||||
{(selectedGroup.members ?? []).length === 0 && (
|
||||
<span className={styles.inheritedNote}>No members</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Add member...' },
|
||||
...availableUsers.map((u) => ({
|
||||
value: u.userId,
|
||||
label: u.displayName,
|
||||
})),
|
||||
]}
|
||||
value={addMemberUserId}
|
||||
onChange={(e) => setAddMemberUserId(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleAddMember}
|
||||
disabled={!addMemberUserId || addUserToGroup.isPending}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Assigned roles */}
|
||||
<div className={styles.sectionTitle}>Assigned Roles</div>
|
||||
<div className={styles.sectionTags}>
|
||||
{(selectedGroup.directRoles ?? []).map((role) => (
|
||||
<Badge
|
||||
key={role.id}
|
||||
label={role.name}
|
||||
variant="outlined"
|
||||
onRemove={() => handleRemoveRole(role.id)}
|
||||
/>
|
||||
))}
|
||||
{(selectedGroup.directRoles ?? []).length === 0 && (
|
||||
<span className={styles.inheritedNote}>No roles assigned</span>
|
||||
)}
|
||||
</div>
|
||||
{(selectedGroup.effectiveRoles ?? []).length >
|
||||
(selectedGroup.directRoles ?? []).length && (
|
||||
<div className={styles.inheritedNote}>
|
||||
+
|
||||
{(selectedGroup.effectiveRoles ?? []).length -
|
||||
(selectedGroup.directRoles ?? []).length}{' '}
|
||||
inherited role(s)
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: 'Assign role...' },
|
||||
...availableRoles.map((r) => ({
|
||||
value: r.id,
|
||||
label: r.name,
|
||||
})),
|
||||
]}
|
||||
value={addRoleId}
|
||||
onChange={(e) => setAddRoleId(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleAddRole}
|
||||
disabled={!addRoleId || assignRoleToGroup.isPending}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<EntityList
|
||||
items={filtered}
|
||||
renderItem={(group) => {
|
||||
const groupChildren = groups.filter(
|
||||
(g) => g.parentGroupId === group.id,
|
||||
);
|
||||
const groupParent = group.parentGroupId
|
||||
? groups.find((g) => g.id === group.parentGroupId)
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
<Avatar name={group.name} size="sm" />
|
||||
<div className={styles.entityInfo}>
|
||||
<div className={styles.entityName}>{group.name}</div>
|
||||
<div className={styles.entityMeta}>
|
||||
{groupParent
|
||||
? `Child of ${groupParent.name}`
|
||||
: 'Top-level'}
|
||||
{' \u00b7 '}
|
||||
{groupChildren.length} children
|
||||
{' \u00b7 '}
|
||||
{(group.members ?? []).length} members
|
||||
</div>
|
||||
<div className={styles.entityTags}>
|
||||
{(group.directRoles ?? []).map((r) => (
|
||||
<Badge key={r.id} label={r.name} color="warning" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
getItemId={(group) => group.id}
|
||||
selectedId={selectedId ?? undefined}
|
||||
onSelect={setSelectedId}
|
||||
searchPlaceholder="Search groups..."
|
||||
onSearch={setSearch}
|
||||
addLabel="+ Add group"
|
||||
onAdd={() => setCreating(true)}
|
||||
emptyMessage="No groups match your search"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
detail={
|
||||
selectedId && detailLoading ? (
|
||||
<Spinner size="md" />
|
||||
) : selectedGroup ? (
|
||||
<>
|
||||
<div className={styles.detailHeader}>
|
||||
<Avatar name={selectedGroup.name} size="lg" />
|
||||
<div className={styles.detailHeaderInfo}>
|
||||
<div className={styles.detailName}>
|
||||
{isBuiltinAdmins ? (
|
||||
selectedGroup.name
|
||||
) : (
|
||||
<InlineEdit
|
||||
value={selectedGroup.name}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.detailEmail}>
|
||||
{parentGroup
|
||||
? `${parentGroup.name} > ${selectedGroup.name}`
|
||||
: 'Top-level group'}
|
||||
{isBuiltinAdmins && ' (built-in)'}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => setDeleteTarget(selectedGroup)}
|
||||
disabled={isBuiltinAdmins}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.metaGrid}>
|
||||
<span className={styles.metaLabel}>ID</span>
|
||||
<MonoText size="xs">{selectedGroup.id}</MonoText>
|
||||
</div>
|
||||
|
||||
{parentGroup && (
|
||||
<>
|
||||
<SectionHeader>Member of</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
<Tag label={parentGroup.name} color="auto" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SectionHeader>Members (direct)</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
{members.map((u) => (
|
||||
<Tag
|
||||
key={u.userId}
|
||||
label={u.displayName}
|
||||
color="auto"
|
||||
onRemove={() => handleRemoveMember(u.userId)}
|
||||
/>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<span className={styles.inheritedNote}>(no members)</span>
|
||||
)}
|
||||
<MultiSelect
|
||||
options={availableMembers}
|
||||
value={[]}
|
||||
onChange={handleAddMembers}
|
||||
placeholder="+ Add"
|
||||
/>
|
||||
</div>
|
||||
{children.length > 0 && (
|
||||
<span className={styles.inheritedNote}>
|
||||
+ all members of {children.map((c) => c.name).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<SectionHeader>Child groups</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
{children.map((c) => (
|
||||
<Tag key={c.id} label={c.name} color="success" />
|
||||
))}
|
||||
{children.length === 0 && (
|
||||
<span className={styles.inheritedNote}>
|
||||
(no child groups)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionHeader>Assigned roles</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
{(selectedGroup.directRoles ?? []).map((r) => (
|
||||
<Tag
|
||||
key={r.id}
|
||||
label={r.name}
|
||||
color="warning"
|
||||
onRemove={() => {
|
||||
if (members.length > 0) {
|
||||
setRemoveRoleTarget(r.id);
|
||||
} else {
|
||||
handleRemoveRole(r.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{(selectedGroup.directRoles ?? []).length === 0 && (
|
||||
<span className={styles.inheritedNote}>(no roles)</span>
|
||||
)}
|
||||
<MultiSelect
|
||||
options={availableRoles}
|
||||
value={[]}
|
||||
onChange={handleAddRoles}
|
||||
placeholder="+ Add"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
emptyMessage="Select a group to view details"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
open={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Group"
|
||||
message={`Delete group "${selectedGroup?.name}"? This action cannot be undone.`}
|
||||
confirmText="DELETE"
|
||||
variant="danger"
|
||||
message={`Delete group "${deleteTarget?.name}"? This cannot be undone.`}
|
||||
confirmText={deleteTarget?.name ?? ''}
|
||||
loading={deleteGroup.isPending}
|
||||
/>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={removeRoleTarget !== null}
|
||||
onClose={() => setRemoveRoleTarget(null)}
|
||||
onConfirm={() => {
|
||||
if (removeRoleTarget && selectedGroup) {
|
||||
handleRemoveRole(removeRoleTarget);
|
||||
}
|
||||
setRemoveRoleTarget(null);
|
||||
}}
|
||||
title="Remove role from group"
|
||||
description={`Removing this role will affect ${members.length} member(s) who inherit it. Continue?`}
|
||||
confirmLabel="Remove"
|
||||
variant="warning"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,53 @@
|
||||
.page {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
.toggleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tagRow {
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.tagList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
min-height: 2rem;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.addRow {
|
||||
.noRoles {
|
||||
font-size: 12px;
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.addRoleRow {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.addRow input {
|
||||
flex: 1;
|
||||
.roleInput {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
@@ -1,110 +1,226 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Input, Toggle, FormField, Card, Alert, SectionHeader, Tag, ConfirmDialog } from '@cameleer/design-system';
|
||||
import {
|
||||
Button, Input, Toggle, FormField, SectionHeader, Tag, ConfirmDialog, Alert,
|
||||
} from '@cameleer/design-system';
|
||||
import { useToast } from '@cameleer/design-system';
|
||||
import { adminFetch } from '../../api/queries/admin/admin-api';
|
||||
import styles from './OidcConfigPage.module.css';
|
||||
|
||||
interface OidcConfig {
|
||||
interface OidcFormData {
|
||||
enabled: boolean;
|
||||
autoSignup: boolean;
|
||||
issuerUri: string;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
rolesClaim: string;
|
||||
defaultRoles: string[];
|
||||
autoSignup: boolean;
|
||||
displayNameClaim: string;
|
||||
defaultRoles: string[];
|
||||
}
|
||||
|
||||
const EMPTY_CONFIG: OidcFormData = {
|
||||
enabled: false,
|
||||
autoSignup: true,
|
||||
issuerUri: '',
|
||||
clientId: '',
|
||||
clientSecret: '',
|
||||
rolesClaim: 'roles',
|
||||
displayNameClaim: 'name',
|
||||
defaultRoles: ['VIEWER'],
|
||||
};
|
||||
|
||||
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);
|
||||
const [form, setForm] = useState<OidcFormData | null>(null);
|
||||
const [newRole, setNewRole] = useState('');
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
adminFetch<OidcConfig>('/oidc')
|
||||
.then(setConfig)
|
||||
.catch(() => setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' }));
|
||||
adminFetch<OidcFormData>('/oidc')
|
||||
.then(setForm)
|
||||
.catch(() => setForm(EMPTY_CONFIG));
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
function update<K extends keyof OidcFormData>(key: K, value: OidcFormData[K]) {
|
||||
setForm((prev) => prev ? { ...prev, [key]: value } : prev);
|
||||
}
|
||||
|
||||
function addRole() {
|
||||
if (!form) return;
|
||||
const role = newRole.trim().toUpperCase();
|
||||
if (role && !form.defaultRoles.includes(role)) {
|
||||
update('defaultRoles', [...form.defaultRoles, role]);
|
||||
setNewRole('');
|
||||
}
|
||||
}
|
||||
|
||||
function removeRole(role: string) {
|
||||
if (!form) return;
|
||||
update('defaultRoles', form.defaultRoles.filter((r) => r !== role));
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(config) });
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 3000);
|
||||
await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(form) });
|
||||
toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' });
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
toast({ title: 'Save failed', description: e.message, variant: 'error' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
async function handleTest() {
|
||||
if (!form) return;
|
||||
setTesting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await adminFetch('/oidc', { method: 'DELETE' });
|
||||
setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' });
|
||||
const result = await adminFetch<{ status: string; authorizationEndpoint?: string }>('/oidc/test', { method: 'POST' });
|
||||
toast({ title: 'Connection test', description: `OIDC provider responded: ${result.status}`, variant: 'success' });
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
toast({ title: 'Connection test failed', description: e.message, variant: 'error' });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!config) return null;
|
||||
async function handleDelete() {
|
||||
setDeleteOpen(false);
|
||||
setError(null);
|
||||
try {
|
||||
await adminFetch('/oidc', { method: 'DELETE' });
|
||||
setForm(EMPTY_CONFIG);
|
||||
toast({ title: 'Configuration deleted', description: 'OIDC configuration has been removed.', variant: 'warning' });
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
toast({ title: 'Delete failed', description: e.message, variant: 'error' });
|
||||
}
|
||||
}
|
||||
|
||||
if (!form) 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 className={styles.page}>
|
||||
<div className={styles.toolbar}>
|
||||
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri || testing}>
|
||||
{testing ? 'Testing...' : 'Test Connection'}
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.section}>
|
||||
<h3>Default Roles</h3>
|
||||
<div className={styles.tagRow}>
|
||||
{(config.defaultRoles || []).map(role => (
|
||||
<Tag key={role} label={role} onRemove={() => {
|
||||
setConfig(prev => ({ ...prev!, defaultRoles: prev!.defaultRoles.filter(r => r !== role) }));
|
||||
}} />
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.addRow}>
|
||||
<Input placeholder="Add role..." value={newRole} onChange={e => setNewRole(e.target.value)} />
|
||||
<Button onClick={() => {
|
||||
if (newRole.trim() && !config.defaultRoles?.includes(newRole.trim())) {
|
||||
setConfig(prev => ({ ...prev!, defaultRoles: [...(prev!.defaultRoles || []), newRole.trim()] }));
|
||||
setNewRole('');
|
||||
}
|
||||
}}>Add</Button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div style={{ marginBottom: 16 }}><Alert variant="error">{error}</Alert></div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||
<Button variant="primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||
<Button variant="danger" onClick={() => setDeleteOpen(true)}>Delete Configuration</Button>
|
||||
</div>
|
||||
|
||||
{error && <Alert variant="error">{error}</Alert>}
|
||||
{success && <Alert variant="success">Configuration saved</Alert>}
|
||||
<section className={styles.section}>
|
||||
<SectionHeader>Behavior</SectionHeader>
|
||||
<div className={styles.toggleRow}>
|
||||
<Toggle
|
||||
label="Enabled"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => update('enabled', e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<div className={styles.toggleRow}>
|
||||
<Toggle
|
||||
label="Auto Sign-Up"
|
||||
checked={form.autoSignup}
|
||||
onChange={(e) => update('autoSignup', e.target.checked)}
|
||||
/>
|
||||
<span className={styles.hint}>Automatically create accounts for new OIDC users</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete OIDC Configuration"
|
||||
message="Delete OIDC configuration? All OIDC users will lose access."
|
||||
confirmText="DELETE"
|
||||
/>
|
||||
<section className={styles.section}>
|
||||
<SectionHeader>Provider Settings</SectionHeader>
|
||||
<FormField label="Issuer URI" htmlFor="issuer">
|
||||
<Input
|
||||
id="issuer"
|
||||
type="url"
|
||||
placeholder="https://idp.example.com/realms/my-realm"
|
||||
value={form.issuerUri}
|
||||
onChange={(e) => update('issuerUri', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Client ID" htmlFor="client-id">
|
||||
<Input
|
||||
id="client-id"
|
||||
value={form.clientId}
|
||||
onChange={(e) => update('clientId', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Client Secret" htmlFor="client-secret">
|
||||
<Input
|
||||
id="client-secret"
|
||||
type="password"
|
||||
value={form.clientSecret}
|
||||
onChange={(e) => update('clientSecret', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<SectionHeader>Claim Mapping</SectionHeader>
|
||||
<FormField label="Roles Claim" htmlFor="roles-claim" hint="JSON path to roles in the ID token">
|
||||
<Input
|
||||
id="roles-claim"
|
||||
value={form.rolesClaim}
|
||||
onChange={(e) => update('rolesClaim', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Display Name Claim" htmlFor="name-claim" hint="Claim used for user display name">
|
||||
<Input
|
||||
id="name-claim"
|
||||
value={form.displayNameClaim}
|
||||
onChange={(e) => update('displayNameClaim', e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<SectionHeader>Default Roles</SectionHeader>
|
||||
<div className={styles.tagList}>
|
||||
{form.defaultRoles.map((role) => (
|
||||
<Tag key={role} label={role} color="primary" onRemove={() => removeRole(role)} />
|
||||
))}
|
||||
{form.defaultRoles.length === 0 && (
|
||||
<span className={styles.noRoles}>No default roles configured</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.addRoleRow}>
|
||||
<Input
|
||||
placeholder="Add role..."
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addRole(); } }}
|
||||
className={styles.roleInput}
|
||||
/>
|
||||
<Button size="sm" variant="secondary" onClick={addRole} disabled={!newRole.trim()}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.section}>
|
||||
<SectionHeader>Danger Zone</SectionHeader>
|
||||
<Button size="sm" variant="danger" onClick={() => setDeleteOpen(true)}>
|
||||
Delete OIDC Configuration
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
open={deleteOpen}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={handleDelete}
|
||||
message="Delete OIDC configuration? All users signed in via OIDC will lose access."
|
||||
confirmText="delete oidc"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,30 +6,29 @@ import UsersTab from './UsersTab';
|
||||
import GroupsTab from './GroupsTab';
|
||||
import RolesTab from './RolesTab';
|
||||
|
||||
const TABS = [
|
||||
{ label: 'Users', value: 'users' },
|
||||
{ label: 'Groups', value: 'groups' },
|
||||
{ label: 'Roles', value: 'roles' },
|
||||
];
|
||||
|
||||
export default function RbacPage() {
|
||||
const { data: stats } = useRbacStats();
|
||||
const [tab, setTab] = useState('users');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 style={{ margin: '0 0 16px' }}>User Management</h2>
|
||||
<div className={styles.statStrip}>
|
||||
<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' },
|
||||
{ label: 'Groups', value: 'groups' },
|
||||
{ label: 'Roles', value: 'roles' },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
{tab === 'users' && <UsersTab />}
|
||||
{tab === 'groups' && <GroupsTab />}
|
||||
{tab === 'roles' && <RolesTab />}
|
||||
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||||
<div className={styles.tabContent}>
|
||||
{tab === 'users' && <UsersTab />}
|
||||
{tab === 'groups' && <GroupsTab />}
|
||||
{tab === 'roles' && <RolesTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
ConfirmDialog,
|
||||
Input,
|
||||
MonoText,
|
||||
Spinner,
|
||||
SectionHeader,
|
||||
Tag,
|
||||
ConfirmDialog,
|
||||
SplitPane,
|
||||
EntityList,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
import {
|
||||
@@ -20,33 +23,54 @@ import type { RoleDetail } from '../../api/queries/admin/rbac';
|
||||
import styles from './UserManagement.module.css';
|
||||
|
||||
export default function RolesTab() {
|
||||
const { toast } = useToast();
|
||||
const { data: roles, isLoading } = useRoles();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDescription, setNewDescription] = useState('');
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RoleDetail | null>(null);
|
||||
|
||||
// Create form state
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
|
||||
// Detail query
|
||||
const { data: detail, isLoading: detailLoading } = useRole(selectedId);
|
||||
|
||||
// Mutations
|
||||
const createRole = useCreateRole();
|
||||
const deleteRole = useDeleteRole();
|
||||
const { toast } = useToast();
|
||||
|
||||
const filtered = (roles ?? []).filter((r) =>
|
||||
r.name.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
const filtered = useMemo(() => {
|
||||
const list = roles ?? [];
|
||||
if (!search) return list;
|
||||
const q = search.toLowerCase();
|
||||
return list.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
r.description.toLowerCase().includes(q),
|
||||
);
|
||||
}, [roles, search]);
|
||||
|
||||
const duplicateRoleName =
|
||||
newName.trim() !== '' &&
|
||||
(roles ?? []).some((r) => r.name === newName.trim().toUpperCase());
|
||||
|
||||
function handleCreate() {
|
||||
if (!newName.trim()) return;
|
||||
createRole.mutate(
|
||||
{ name: newName.trim(), description: newDescription.trim() || undefined },
|
||||
{ name: newName.trim().toUpperCase(), description: newDesc.trim() || undefined },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Role created', variant: 'success' });
|
||||
setShowCreate(false);
|
||||
toast({
|
||||
title: 'Role created',
|
||||
description: newName.trim().toUpperCase(),
|
||||
variant: 'success',
|
||||
});
|
||||
setCreating(false);
|
||||
setNewName('');
|
||||
setNewDescription('');
|
||||
setNewDesc('');
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to create role', variant: 'error' });
|
||||
@@ -56,152 +80,144 @@ export default function RolesTab() {
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!selectedId) return;
|
||||
deleteRole.mutate(selectedId, {
|
||||
if (!deleteTarget) return;
|
||||
deleteRole.mutate(deleteTarget.id, {
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Role deleted', variant: 'success' });
|
||||
setSelectedId(null);
|
||||
setConfirmDelete(false);
|
||||
toast({
|
||||
title: 'Role deleted',
|
||||
description: deleteTarget.name,
|
||||
variant: 'warning',
|
||||
});
|
||||
if (selectedId === deleteTarget.id) setSelectedId(null);
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: 'Failed to delete role', variant: 'error' });
|
||||
setConfirmDelete(false);
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getAssignmentCount(role: RoleDetail): number {
|
||||
return (
|
||||
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) return <Spinner size="md" />;
|
||||
|
||||
return (
|
||||
<div className={styles.splitPane}>
|
||||
{/* Left pane — list */}
|
||||
<div className={styles.listPane}>
|
||||
<div className={styles.listHeader}>
|
||||
<Input
|
||||
placeholder="Search roles…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setShowCreate((v) => !v)}
|
||||
>
|
||||
+ Add Role
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<SplitPane
|
||||
list={
|
||||
<>
|
||||
{creating && (
|
||||
<div className={styles.createForm}>
|
||||
<Input
|
||||
placeholder="Role name *"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
{duplicateRoleName && (
|
||||
<span style={{ color: 'var(--error)', fontSize: 11 }}>
|
||||
Role name already exists
|
||||
</span>
|
||||
)}
|
||||
<Input
|
||||
placeholder="Description"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
/>
|
||||
<div className={styles.createFormActions}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setCreating(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={handleCreate}
|
||||
loading={createRole.isPending}
|
||||
disabled={!newName.trim() || duplicateRoleName}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<div className={styles.createForm}>
|
||||
<Input
|
||||
placeholder="Role name (e.g. EDITOR)"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value.toUpperCase())}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Description (optional)"
|
||||
value={newDescription}
|
||||
onChange={(e) => setNewDescription(e.target.value)}
|
||||
/>
|
||||
<div className={styles.createFormActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowCreate(false);
|
||||
setNewName('');
|
||||
setNewDescription('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
loading={createRole.isPending}
|
||||
disabled={!newName.trim()}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div className={styles.entityList} role="listbox">
|
||||
{filtered.map((role) => {
|
||||
const assignmentCount =
|
||||
(role.assignedGroups?.length ?? 0) + (role.directUsers?.length ?? 0);
|
||||
return (
|
||||
<div
|
||||
key={role.id}
|
||||
className={
|
||||
styles.entityItem +
|
||||
(selectedId === role.id ? ' ' + styles.entityItemSelected : '')
|
||||
}
|
||||
role="option"
|
||||
aria-selected={selectedId === role.id}
|
||||
onClick={() => setSelectedId(role.id)}
|
||||
>
|
||||
<EntityList
|
||||
items={filtered}
|
||||
renderItem={(role) => (
|
||||
<>
|
||||
<Avatar name={role.name} size="sm" />
|
||||
<div className={styles.entityInfo}>
|
||||
<div className={styles.entityName}>
|
||||
{role.name}
|
||||
{role.system && <Badge label="system" variant="outlined" />}
|
||||
{role.system && (
|
||||
<Badge
|
||||
label="system"
|
||||
color="auto"
|
||||
variant="outlined"
|
||||
className={styles.providerBadge}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.entityMeta}>
|
||||
{role.description || '—'} · {assignmentCount} assignment
|
||||
{assignmentCount !== 1 ? 's' : ''}
|
||||
{role.description || '\u2014'} \u00b7{' '}
|
||||
{getAssignmentCount(role)} assignments
|
||||
</div>
|
||||
<div className={styles.entityTags}>
|
||||
{(role.assignedGroups ?? []).map((g) => (
|
||||
<Badge key={g.id} label={g.name} color="success" />
|
||||
))}
|
||||
{(role.directUsers ?? []).map((u) => (
|
||||
<Badge
|
||||
key={u.userId}
|
||||
label={u.displayName}
|
||||
color="auto"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{((role.assignedGroups?.length ?? 0) > 0 ||
|
||||
(role.directUsers?.length ?? 0) > 0) && (
|
||||
<div className={styles.entityTags}>
|
||||
{(role.assignedGroups ?? []).map((g) => (
|
||||
<Tag key={g.id} label={g.name} color="success" />
|
||||
))}
|
||||
{(role.directUsers ?? []).map((u) => (
|
||||
<Tag key={u.userId} label={u.displayName} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
getItemId={(role) => role.id}
|
||||
selectedId={selectedId ?? undefined}
|
||||
onSelect={setSelectedId}
|
||||
searchPlaceholder="Search roles..."
|
||||
onSearch={setSearch}
|
||||
addLabel="+ Add role"
|
||||
onAdd={() => setCreating(true)}
|
||||
emptyMessage="No roles match your search"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
detail={
|
||||
selectedId && (detailLoading || !detail) ? (
|
||||
<Spinner size="md" />
|
||||
) : detail ? (
|
||||
<RoleDetailPanel
|
||||
role={detail}
|
||||
onDeleteRequest={() => setDeleteTarget(detail)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
emptyMessage="Select a role to view details"
|
||||
/>
|
||||
|
||||
{/* Right pane — detail */}
|
||||
<div className={styles.detailPane}>
|
||||
{!selectedId ? (
|
||||
<div className={styles.emptyDetail}>Select a role to view details</div>
|
||||
) : detailLoading || !detail ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<RoleDetailPanel
|
||||
role={detail}
|
||||
onDeleteRequest={() => setConfirmDelete(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detail && (
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
onClose={() => setConfirmDelete(false)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete role"
|
||||
message={`Delete role "${detail.name}"? This cannot be undone.`}
|
||||
confirmText={detail.name}
|
||||
confirmLabel="Delete"
|
||||
variant="danger"
|
||||
loading={deleteRole.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={deleteTarget !== null}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDelete}
|
||||
message={`Delete role "${deleteTarget?.name}"? This cannot be undone.`}
|
||||
confirmText={deleteTarget?.name ?? ''}
|
||||
loading={deleteRole.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -213,93 +229,93 @@ interface RoleDetailPanelProps {
|
||||
}
|
||||
|
||||
function RoleDetailPanel({ role, onDeleteRequest }: RoleDetailPanelProps) {
|
||||
// Build a set of directly-assigned user IDs for distinguishing inherited principals
|
||||
const directUserIds = new Set((role.directUsers ?? []).map((u) => u.userId));
|
||||
const directUserIds = new Set(
|
||||
(role.directUsers ?? []).map((u) => u.userId),
|
||||
);
|
||||
|
||||
const assignedGroups = role.assignedGroups ?? [];
|
||||
const directUsers = role.directUsers ?? [];
|
||||
const effectivePrincipals = role.effectivePrincipals ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<>
|
||||
<div className={styles.detailHeader}>
|
||||
<Avatar name={role.name} size="md" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 16 }}>{role.name}</div>
|
||||
<Avatar name={role.name} size="lg" />
|
||||
<div className={styles.detailHeaderInfo}>
|
||||
<div className={styles.detailName}>{role.name}</div>
|
||||
{role.description && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
||||
{role.description}
|
||||
</div>
|
||||
<div className={styles.detailEmail}>{role.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={role.system}
|
||||
onClick={onDeleteRequest}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
{!role.system && (
|
||||
<Button size="sm" variant="danger" onClick={onDeleteRequest}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<div className={styles.metaGrid}>
|
||||
<span className={styles.metaLabel}>ID</span>
|
||||
<MonoText size="xs">{role.id}</MonoText>
|
||||
|
||||
<span className={styles.metaLabel}>Scope</span>
|
||||
<span>{role.scope || '—'}</span>
|
||||
|
||||
<span className={styles.metaLabel}>Type</span>
|
||||
<span>{role.system ? 'System role (read-only)' : 'Custom role'}</span>
|
||||
</div>
|
||||
|
||||
{/* Assigned to groups */}
|
||||
<div className={styles.sectionTitle}>Assigned to groups</div>
|
||||
<div className={styles.sectionTags}>
|
||||
{(role.assignedGroups ?? []).length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
||||
) : (
|
||||
(role.assignedGroups ?? []).map((g) => (
|
||||
<Tag key={g.id} label={g.name} color="success" />
|
||||
))
|
||||
<span className={styles.metaValue}>{role.scope || '\u2014'}</span>
|
||||
{role.system && (
|
||||
<>
|
||||
<span className={styles.metaLabel}>Type</span>
|
||||
<span className={styles.metaValue}>System role (read-only)</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assigned to users (direct) */}
|
||||
<div className={styles.sectionTitle}>Assigned to users (direct)</div>
|
||||
<SectionHeader>Assigned to groups</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
{(role.directUsers ?? []).length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
||||
) : (
|
||||
(role.directUsers ?? []).map((u) => (
|
||||
<Tag key={u.userId} label={u.displayName} />
|
||||
))
|
||||
{assignedGroups.map((g) => (
|
||||
<Tag key={g.id} label={g.name} color="success" />
|
||||
))}
|
||||
{assignedGroups.length === 0 && (
|
||||
<span className={styles.inheritedNote}>(none)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Effective principals */}
|
||||
<div className={styles.sectionTitle}>Effective principals</div>
|
||||
<SectionHeader>Assigned to users (direct)</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
{(role.effectivePrincipals ?? []).length === 0 ? (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
||||
) : (
|
||||
(role.effectivePrincipals ?? []).map((u) => {
|
||||
const isDirect = directUserIds.has(u.userId);
|
||||
return isDirect ? (
|
||||
<Badge key={u.userId} label={u.displayName} variant="filled" />
|
||||
) : (
|
||||
<Badge
|
||||
key={u.userId}
|
||||
label={`↑ ${u.displayName}`}
|
||||
variant="dashed"
|
||||
/>
|
||||
);
|
||||
})
|
||||
{directUsers.map((u) => (
|
||||
<Tag key={u.userId} label={u.displayName} color="auto" />
|
||||
))}
|
||||
{directUsers.length === 0 && (
|
||||
<span className={styles.inheritedNote}>(none)</span>
|
||||
)}
|
||||
</div>
|
||||
{(role.effectivePrincipals ?? []).some((u) => !directUserIds.has(u.userId)) && (
|
||||
<div className={styles.inheritedNote}>
|
||||
|
||||
<SectionHeader>Effective principals</SectionHeader>
|
||||
<div className={styles.sectionTags}>
|
||||
{effectivePrincipals.map((u) => {
|
||||
const isDirect = directUserIds.has(u.userId);
|
||||
return isDirect ? (
|
||||
<Badge
|
||||
key={u.userId}
|
||||
label={u.displayName}
|
||||
color="auto"
|
||||
variant="filled"
|
||||
/>
|
||||
) : (
|
||||
<Badge
|
||||
key={u.userId}
|
||||
label={u.displayName}
|
||||
color="auto"
|
||||
variant="dashed"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{effectivePrincipals.length === 0 && (
|
||||
<span className={styles.inheritedNote}>(none)</span>
|
||||
)}
|
||||
</div>
|
||||
{effectivePrincipals.some((u) => !directUserIds.has(u.userId)) && (
|
||||
<span className={styles.inheritedNote}>
|
||||
Dashed entries inherit this role through group membership
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,187 +5,149 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.splitPane {
|
||||
display: grid;
|
||||
grid-template-columns: 52fr 48fr;
|
||||
gap: 1px;
|
||||
background: var(--border-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
min-height: 500px;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.listPane {
|
||||
background: var(--bg-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
||||
}
|
||||
|
||||
.detailPane {
|
||||
background: var(--bg-surface);
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.listHeader input { flex: 1; }
|
||||
|
||||
.entityList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.entityItem {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.entityItem:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.entityItem:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.entityItemSelected {
|
||||
background: var(--bg-raised);
|
||||
.tabContent {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.entityInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entityName {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.entityMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.entityTags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.createForm {
|
||||
background: var(--bg-raised);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.createFormActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.detailHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.detailHeaderInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detailName {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.detailEmail {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.metaGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: 6px 12px;
|
||||
font-size: 13px;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 16px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.metaLabel {
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
.metaValue {
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.sectionTags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.createForm {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-raised);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.createFormRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.createFormActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.inheritedNote {
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-family: var(--font-body);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.providerBadge {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.inherited {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.securitySection {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 16px;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.securityRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.passwordDots {
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.resetForm {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.emptyDetail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.emptySearch {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.providerBadge {
|
||||
font-size: 9px;
|
||||
.resetInput {
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,13 @@
|
||||
/* Scrollable content area */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 40px;
|
||||
min-width: 0;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
/* Stat strip */
|
||||
.statStrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
@@ -5,13 +15,66 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Stat breakdown with colored dots */
|
||||
.breakdown {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.bpLive { color: var(--success); display: inline-flex; align-items: center; gap: 3px; }
|
||||
.bpStale { color: var(--warning); display: inline-flex; align-items: center; gap: 3px; }
|
||||
.bpDead { color: var(--error); display: inline-flex; align-items: center; gap: 3px; }
|
||||
|
||||
.routesSuccess { color: var(--success); }
|
||||
.routesWarning { color: var(--warning); }
|
||||
.routesError { color: var(--error); }
|
||||
|
||||
/* Scope breadcrumb trail */
|
||||
.scopeTrail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scopeLink {
|
||||
color: var(--amber);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scopeLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.scopeSep {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.scopeCurrent {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Section header */
|
||||
.sectionTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sectionMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Group cards grid */
|
||||
.groupGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -19,115 +82,131 @@
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* GroupCard meta strip */
|
||||
.groupGridSingle {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Group meta row */
|
||||
.groupMeta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
gap: 16px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.groupMeta strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Instance table */
|
||||
.instanceTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.instanceTable thead tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.instanceTable thead th {
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thStatus {
|
||||
width: 24px;
|
||||
/* Alert banner in group footer */
|
||||
.alertBanner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--error-bg);
|
||||
font-size: 11px;
|
||||
color: var(--error);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tdStatus {
|
||||
width: 24px;
|
||||
padding: 0 4px 0 8px;
|
||||
}
|
||||
|
||||
.instanceRow {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.instanceRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.instanceRow:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.instanceRow td {
|
||||
padding: 7px 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.instanceRowActive {
|
||||
background: var(--bg-selected, var(--bg-hover));
|
||||
.alertIcon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Instance fields */
|
||||
.instanceName {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.instanceMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.instanceError {
|
||||
font-size: 11px;
|
||||
color: var(--error);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.instanceHeartbeatDead {
|
||||
font-size: 11px;
|
||||
color: var(--error);
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.instanceHeartbeatStale {
|
||||
font-size: 11px;
|
||||
color: var(--warning);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.instanceLink {
|
||||
.instanceHeartbeatDead {
|
||||
color: var(--error);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Detail panel content */
|
||||
.detailContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detailRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.detailLabel {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
padding: 4px;
|
||||
margin-left: auto;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.instanceLink:hover {
|
||||
color: var(--text-primary);
|
||||
.detailProgress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.chartPanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.emptyChart {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80px;
|
||||
background: var(--bg-surface-raised);
|
||||
border: 1px dashed var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Event card (timeline panel) */
|
||||
.eventCard {
|
||||
margin-top: 20px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -144,136 +223,4 @@
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* DetailPanel: Overview tab */
|
||||
|
||||
.overviewContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.overviewRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detailList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.detailRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detailRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.detailRow dt {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detailRow dd {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.metricsSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.metricLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* DetailPanel: Performance tab */
|
||||
|
||||
.performanceContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.chartSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chartLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.emptyChart {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 80px;
|
||||
background: var(--bg-surface-raised);
|
||||
border: 1px dashed var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Status breakdown in stat card */
|
||||
.statusBreakdown {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.statusLive { color: var(--success); }
|
||||
.statusStale { color: var(--warning); }
|
||||
.statusDead { color: var(--error); }
|
||||
|
||||
/* Scope trail */
|
||||
.scopeLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* DetailPanel override */
|
||||
.detailPanelOverride {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.panelDivider {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import {
|
||||
StatCard, StatusDot, Badge, MonoText,
|
||||
GroupCard, EventFeed, Alert,
|
||||
DetailPanel, ProgressBar, LineChart,
|
||||
StatCard, StatusDot, Badge, MonoText, ProgressBar,
|
||||
GroupCard, DataTable, LineChart, EventFeed, DetailPanel,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column, FeedEvent } from '@cameleer/design-system';
|
||||
import styles from './AgentHealth.module.css';
|
||||
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||
import { useAgentMetrics } from '../../api/queries/agent-metrics';
|
||||
import type { AgentInstance } from '../../api/types';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function timeAgo(iso?: string): string {
|
||||
if (!iso) return '\u2014';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const secs = Math.floor(diff / 1000);
|
||||
if (secs < 60) return `${secs}s ago`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds?: number): string {
|
||||
if (!seconds) return '—';
|
||||
if (!seconds) return '\u2014';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
@@ -20,18 +34,65 @@ function formatUptime(seconds?: number): string {
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso?: string): string {
|
||||
if (!iso) return '—';
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
return `${Math.floor(hours / 24)}d ago`;
|
||||
function formatErrorRate(rate?: number): string {
|
||||
if (rate == null) return '\u2014';
|
||||
return `${(rate * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function AgentOverviewContent({ agent }: { agent: any }) {
|
||||
type NormStatus = 'live' | 'stale' | 'dead';
|
||||
|
||||
function normalizeStatus(status: string): NormStatus {
|
||||
return status.toLowerCase() as NormStatus;
|
||||
}
|
||||
|
||||
function statusColor(s: NormStatus): 'success' | 'warning' | 'error' {
|
||||
if (s === 'live') return 'success';
|
||||
if (s === 'stale') return 'warning';
|
||||
return 'error';
|
||||
}
|
||||
|
||||
// ── Data grouping ────────────────────────────────────────────────────────────
|
||||
|
||||
interface AppGroup {
|
||||
appId: string;
|
||||
instances: AgentInstance[];
|
||||
liveCount: number;
|
||||
staleCount: number;
|
||||
deadCount: number;
|
||||
totalTps: number;
|
||||
totalActiveRoutes: number;
|
||||
totalRoutes: number;
|
||||
}
|
||||
|
||||
function groupByApp(agentList: AgentInstance[]): AppGroup[] {
|
||||
const map = new Map<string, AgentInstance[]>();
|
||||
for (const a of agentList) {
|
||||
const app = a.application;
|
||||
const list = map.get(app) ?? [];
|
||||
list.push(a);
|
||||
map.set(app, list);
|
||||
}
|
||||
return Array.from(map.entries()).map(([appId, instances]) => ({
|
||||
appId,
|
||||
instances,
|
||||
liveCount: instances.filter((i) => normalizeStatus(i.status) === 'live').length,
|
||||
staleCount: instances.filter((i) => normalizeStatus(i.status) === 'stale').length,
|
||||
deadCount: instances.filter((i) => normalizeStatus(i.status) === 'dead').length,
|
||||
totalTps: instances.reduce((s, i) => s + (i.tps ?? 0), 0),
|
||||
totalActiveRoutes: instances.reduce((s, i) => s + (i.activeRoutes ?? 0), 0),
|
||||
totalRoutes: instances.reduce((s, i) => s + (i.totalRoutes ?? 0), 0),
|
||||
}));
|
||||
}
|
||||
|
||||
function appHealth(group: AppGroup): 'success' | 'warning' | 'error' {
|
||||
if (group.deadCount > 0) return 'error';
|
||||
if (group.staleCount > 0) return 'warning';
|
||||
return 'success';
|
||||
}
|
||||
|
||||
// ── Detail sub-components ────────────────────────────────────────────────────
|
||||
|
||||
function AgentOverviewContent({ agent }: { agent: AgentInstance }) {
|
||||
const { data: memMetrics } = useAgentMetrics(
|
||||
agent.id,
|
||||
['jvm.memory.heap.used', 'jvm.memory.heap.max'],
|
||||
@@ -43,93 +104,81 @@ function AgentOverviewContent({ agent }: { agent: any }) {
|
||||
const heapUsed = memMetrics?.metrics?.['jvm.memory.heap.used']?.[0]?.value;
|
||||
const heapMax = memMetrics?.metrics?.['jvm.memory.heap.max']?.[0]?.value;
|
||||
|
||||
const heapPercent = heapUsed != null && heapMax != null && heapMax > 0
|
||||
? Math.round((heapUsed / heapMax) * 100)
|
||||
: undefined;
|
||||
const heapPercent =
|
||||
heapUsed != null && heapMax != null && heapMax > 0
|
||||
? Math.round((heapUsed / heapMax) * 100)
|
||||
: undefined;
|
||||
const cpuPercent = cpuValue != null ? Math.round(cpuValue * 100) : undefined;
|
||||
|
||||
const statusVariant: 'live' | 'stale' | 'dead' =
|
||||
agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead';
|
||||
const statusColor: 'success' | 'warning' | 'error' =
|
||||
agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error';
|
||||
const ns = normalizeStatus(agent.status);
|
||||
|
||||
return (
|
||||
<div className={styles.overviewContent}>
|
||||
<div className={styles.overviewRow}>
|
||||
<StatusDot variant={statusVariant} />
|
||||
<Badge label={agent.status} color={statusColor} />
|
||||
<div className={styles.detailContent}>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Status</span>
|
||||
<Badge label={agent.status} color={statusColor(ns)} variant="filled" />
|
||||
</div>
|
||||
|
||||
<dl className={styles.detailList}>
|
||||
<div className={styles.detailRow}>
|
||||
<dt>Application</dt>
|
||||
<dd><MonoText>{agent.application ?? '—'}</MonoText></dd>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<dt>Version</dt>
|
||||
<dd><MonoText>{agent.version ?? '—'}</MonoText></dd>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<dt>Uptime</dt>
|
||||
<dd>{formatUptime(agent.uptimeSeconds)}</dd>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<dt>Last Heartbeat</dt>
|
||||
<dd>{formatRelativeTime(agent.lastHeartbeat)}</dd>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<dt>TPS</dt>
|
||||
<dd>{agent.tps != null ? (agent.tps as number).toFixed(2) : '—'}</dd>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<dt>Error Rate</dt>
|
||||
<dd>{agent.errorRate != null ? `${((agent.errorRate as number) * 100).toFixed(1)}%` : '—'}</dd>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<dt>Routes</dt>
|
||||
<dd>{agent.activeRoutes ?? '—'} active / {agent.totalRoutes ?? '—'} total</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className={styles.metricsSection}>
|
||||
<div className={styles.metricLabel}>
|
||||
Heap Memory{heapUsed != null && heapMax != null
|
||||
? ` — ${Math.round(heapUsed / 1024 / 1024)}MB / ${Math.round(heapMax / 1024 / 1024)}MB`
|
||||
: ''}
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={heapPercent}
|
||||
variant={heapPercent == null ? 'primary' : heapPercent > 85 ? 'error' : heapPercent > 70 ? 'warning' : 'success'}
|
||||
indeterminate={heapPercent == null}
|
||||
size="sm"
|
||||
/>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Application</span>
|
||||
<MonoText size="xs">{agent.application}</MonoText>
|
||||
</div>
|
||||
|
||||
<div className={styles.metricsSection}>
|
||||
<div className={styles.metricLabel}>
|
||||
CPU Usage{cpuPercent != null ? ` — ${cpuPercent}%` : ''}
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Uptime</span>
|
||||
<MonoText size="xs">{formatUptime(agent.uptimeSeconds)}</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Last Seen</span>
|
||||
<MonoText size="xs">{timeAgo(agent.lastHeartbeat)}</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Throughput</span>
|
||||
<MonoText size="xs">{agent.tps != null ? `${agent.tps.toFixed(1)}/s` : '\u2014'}</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Errors</span>
|
||||
<MonoText size="xs" className={agent.errorRate ? styles.instanceError : undefined}>
|
||||
{formatErrorRate(agent.errorRate)}
|
||||
</MonoText>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Routes</span>
|
||||
<span>{agent.activeRoutes ?? 0}/{agent.totalRoutes ?? 0} active</span>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>Heap Memory</span>
|
||||
<div className={styles.detailProgress}>
|
||||
<ProgressBar
|
||||
value={heapPercent}
|
||||
variant={heapPercent == null ? 'primary' : heapPercent > 85 ? 'error' : heapPercent > 70 ? 'warning' : 'success'}
|
||||
indeterminate={heapPercent == null}
|
||||
size="sm"
|
||||
/>
|
||||
<MonoText size="xs">{heapPercent != null ? `${heapPercent}%` : '\u2014'}</MonoText>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<span className={styles.detailLabel}>CPU</span>
|
||||
<div className={styles.detailProgress}>
|
||||
<ProgressBar
|
||||
value={cpuPercent}
|
||||
variant={cpuPercent == null ? 'primary' : cpuPercent > 80 ? 'error' : cpuPercent > 60 ? 'warning' : 'success'}
|
||||
indeterminate={cpuPercent == null}
|
||||
size="sm"
|
||||
/>
|
||||
<MonoText size="xs">{cpuPercent != null ? `${cpuPercent}%` : '\u2014'}</MonoText>
|
||||
</div>
|
||||
<ProgressBar
|
||||
value={cpuPercent}
|
||||
variant={cpuPercent == null ? 'primary' : cpuPercent > 80 ? 'error' : cpuPercent > 60 ? 'warning' : 'success'}
|
||||
indeterminate={cpuPercent == null}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentPerformanceContent({ agent }: { agent: any }) {
|
||||
function AgentPerformanceContent({ agent }: { agent: AgentInstance }) {
|
||||
const { data: tpsMetrics } = useAgentMetrics(agent.id, ['cameleer.tps'], 60);
|
||||
const { data: errMetrics } = useAgentMetrics(agent.id, ['cameleer.error.rate'], 60);
|
||||
|
||||
const tpsSeries = useMemo(() => {
|
||||
const raw = tpsMetrics?.metrics?.['cameleer.tps'] ?? [];
|
||||
return [{
|
||||
label: 'TPS',
|
||||
data: raw.map((p) => ({ x: new Date(p.time), y: p.value })),
|
||||
}];
|
||||
return [{ label: 'TPS', data: raw.map((p) => ({ x: new Date(p.time), y: p.value })) }];
|
||||
}, [tpsMetrics]);
|
||||
|
||||
const errSeries = useMemo(() => {
|
||||
@@ -137,24 +186,24 @@ function AgentPerformanceContent({ agent }: { agent: any }) {
|
||||
return [{
|
||||
label: 'Error Rate',
|
||||
data: raw.map((p) => ({ x: new Date(p.time), y: p.value * 100 })),
|
||||
color: 'var(--error)',
|
||||
}];
|
||||
}, [errMetrics]);
|
||||
|
||||
return (
|
||||
<div className={styles.performanceContent}>
|
||||
<div className={styles.chartSection}>
|
||||
<div className={styles.chartLabel}>Throughput (TPS)</div>
|
||||
<div className={styles.detailContent}>
|
||||
<div className={styles.chartPanel}>
|
||||
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
||||
{tpsSeries[0].data.length > 0 ? (
|
||||
<LineChart series={tpsSeries} yLabel="req/s" height={160} />
|
||||
<LineChart series={tpsSeries} height={160} yLabel="msg/s" />
|
||||
) : (
|
||||
<div className={styles.emptyChart}>No data available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.chartSection}>
|
||||
<div className={styles.chartLabel}>Error Rate (%)</div>
|
||||
<div className={styles.chartPanel}>
|
||||
<div className={styles.chartTitle}>Error Rate (%)</div>
|
||||
{errSeries[0].data.length > 0 ? (
|
||||
<LineChart series={errSeries} yLabel="%" height={160} />
|
||||
<LineChart series={errSeries} height={160} yLabel="%" />
|
||||
) : (
|
||||
<div className={styles.emptyChart}>No data available</div>
|
||||
)}
|
||||
@@ -163,197 +212,308 @@ function AgentPerformanceContent({ agent }: { agent: any }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── AgentHealth page ─────────────────────────────────────────────────────────
|
||||
|
||||
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 [selectedAgent, setSelectedAgent] = useState<any>(null);
|
||||
const [selectedInstance, setSelectedInstance] = useState<AgentInstance | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
|
||||
const agentsByApp = useMemo(() => {
|
||||
const map: Record<string, any[]> = {};
|
||||
(agents || []).forEach((a: any) => {
|
||||
const g = a.application;
|
||||
if (!map[g]) map[g] = [];
|
||||
map[g].push(a);
|
||||
});
|
||||
return map;
|
||||
}, [agents]);
|
||||
const agentList = agents ?? [];
|
||||
|
||||
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 uniqueApps = new Set((agents || []).map((a: any) => a.application)).size;
|
||||
const activeRoutes = (agents || []).filter((a: any) => a.status === 'LIVE').reduce((sum: number, a: any) => sum + (a.activeRoutes || 0), 0);
|
||||
const totalTps = (agents || []).filter((a: any) => a.status === 'LIVE').reduce((sum: number, a: any) => sum + (a.tps || 0), 0);
|
||||
const groups = useMemo(() => groupByApp(agentList), [agentList]);
|
||||
|
||||
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),
|
||||
})),
|
||||
// Aggregate stats
|
||||
const totalInstances = agentList.length;
|
||||
const liveCount = agentList.filter((a) => normalizeStatus(a.status) === 'live').length;
|
||||
const staleCount = agentList.filter((a) => normalizeStatus(a.status) === 'stale').length;
|
||||
const deadCount = agentList.filter((a) => normalizeStatus(a.status) === 'dead').length;
|
||||
const totalTps = agentList.reduce((s, a) => s + (a.tps ?? 0), 0);
|
||||
const totalActiveRoutes = agentList.reduce((s, a) => s + (a.activeRoutes ?? 0), 0);
|
||||
const totalRoutes = agentList.reduce((s, a) => s + (a.totalRoutes ?? 0), 0);
|
||||
|
||||
// Map events to FeedEvent
|
||||
const feedEvents: FeedEvent[] = useMemo(
|
||||
() =>
|
||||
(events ?? []).map((e: { id: number; agentId: string; eventType: string; detail: string; timestamp: string }) => ({
|
||||
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 ? ' \u2014 ' + e.detail : ''}`,
|
||||
timestamp: new Date(e.timestamp),
|
||||
})),
|
||||
[events],
|
||||
);
|
||||
|
||||
const apps = appId ? { [appId]: agentsByApp[appId] || [] } : agentsByApp;
|
||||
// Column definitions for the instance DataTable
|
||||
const instanceColumns: Column<AgentInstance>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'status',
|
||||
header: '',
|
||||
width: '12px',
|
||||
render: (_val, row) => <StatusDot variant={normalizeStatus(row.status)} />,
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Instance',
|
||||
render: (_val, row) => (
|
||||
<MonoText size="sm" className={styles.instanceName}>{row.name ?? row.id}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'state',
|
||||
header: 'State',
|
||||
render: (_val, row) => {
|
||||
const ns = normalizeStatus(row.status);
|
||||
return <Badge label={row.status} color={statusColor(ns)} variant="filled" />;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'uptime',
|
||||
header: 'Uptime',
|
||||
render: (_val, row) => (
|
||||
<MonoText size="xs" className={styles.instanceMeta}>{formatUptime(row.uptimeSeconds)}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tps',
|
||||
header: 'TPS',
|
||||
render: (_val, row) => (
|
||||
<MonoText size="xs" className={styles.instanceMeta}>
|
||||
{row.tps != null ? `${row.tps.toFixed(1)}/s` : '\u2014'}
|
||||
</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'errorRate',
|
||||
header: 'Errors',
|
||||
render: (_val, row) => (
|
||||
<MonoText size="xs" className={row.errorRate ? styles.instanceError : styles.instanceMeta}>
|
||||
{formatErrorRate(row.errorRate)}
|
||||
</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastHeartbeat',
|
||||
header: 'Heartbeat',
|
||||
render: (_val, row) => {
|
||||
const ns = normalizeStatus(row.status);
|
||||
return (
|
||||
<MonoText
|
||||
size="xs"
|
||||
className={
|
||||
ns === 'dead'
|
||||
? styles.instanceHeartbeatDead
|
||||
: ns === 'stale'
|
||||
? styles.instanceHeartbeatStale
|
||||
: styles.instanceMeta
|
||||
}
|
||||
>
|
||||
{timeAgo(row.lastHeartbeat)}
|
||||
</MonoText>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
function handleInstanceClick(inst: AgentInstance) {
|
||||
setSelectedInstance(inst);
|
||||
setPanelOpen(true);
|
||||
}
|
||||
|
||||
// Detail panel tabs
|
||||
const detailTabs = selectedInstance
|
||||
? [
|
||||
{
|
||||
label: 'Overview',
|
||||
value: 'overview',
|
||||
content: <AgentOverviewContent agent={selectedInstance} />,
|
||||
},
|
||||
{
|
||||
label: 'Performance',
|
||||
value: 'performance',
|
||||
content: <AgentPerformanceContent agent={selectedInstance} />,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const isFullWidth = !!appId;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.content}>
|
||||
{/* Stat strip */}
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard
|
||||
label="Total Agents"
|
||||
value={(agents || []).length}
|
||||
value={String(totalInstances)}
|
||||
accent={deadCount > 0 ? 'warning' : 'amber'}
|
||||
detail={
|
||||
<span className={styles.statusBreakdown}>
|
||||
<span className={styles.statusLive}>{liveCount} live</span>
|
||||
<span className={styles.statusStale}>{staleCount} stale</span>
|
||||
<span className={styles.statusDead}>{deadCount} dead</span>
|
||||
<span className={styles.breakdown}>
|
||||
<span className={styles.bpLive}><StatusDot variant="live" /> {liveCount} live</span>
|
||||
<span className={styles.bpStale}><StatusDot variant="stale" /> {staleCount} stale</span>
|
||||
<span className={styles.bpDead}><StatusDot variant="dead" /> {deadCount} dead</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<StatCard label="Applications" value={uniqueApps} />
|
||||
<StatCard label="Active Routes" value={activeRoutes} />
|
||||
<StatCard label="Total TPS" value={totalTps.toFixed(1)} detail="msg/s" />
|
||||
<StatCard label="Dead" value={deadCount} accent={deadCount > 0 ? 'error' : undefined} detail={deadCount > 0 ? 'requires attention' : undefined} />
|
||||
</div>
|
||||
|
||||
<div className={styles.scopeTrail}>
|
||||
<span className={styles.scopeLabel}>{liveCount}/{(agents || []).length} live</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.groupGrid}>
|
||||
{Object.entries(apps).map(([group, groupAgents]) => {
|
||||
const deadInGroup = (groupAgents || []).filter((a: any) => a.status === 'DEAD');
|
||||
const groupTps = (groupAgents || []).reduce((s: number, a: any) => s + (a.tps || 0), 0);
|
||||
const groupActiveRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.activeRoutes || 0), 0);
|
||||
const groupTotalRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.totalRoutes || 0), 0);
|
||||
const liveInGroup = (groupAgents || []).filter((a: any) => a.status === 'LIVE').length;
|
||||
return (
|
||||
<GroupCard
|
||||
key={group}
|
||||
title={group}
|
||||
headerRight={
|
||||
<Badge
|
||||
label={`${liveInGroup}/${groupAgents?.length ?? 0} LIVE`}
|
||||
color={
|
||||
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
|
||||
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
|
||||
: 'success'
|
||||
}
|
||||
variant="filled"
|
||||
/>
|
||||
}
|
||||
meta={
|
||||
<div className={styles.groupMeta}>
|
||||
<span><strong>{groupTps.toFixed(1)}</strong> msg/s</span>
|
||||
<span><strong>{groupActiveRoutes}</strong>/{groupTotalRoutes} routes</span>
|
||||
</div>
|
||||
}
|
||||
accent={
|
||||
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
|
||||
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
|
||||
: 'success'
|
||||
<StatCard
|
||||
label="Applications"
|
||||
value={String(groups.length)}
|
||||
accent="running"
|
||||
detail={
|
||||
<span className={styles.breakdown}>
|
||||
<span className={styles.bpLive}>
|
||||
<StatusDot variant="live" /> {groups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy
|
||||
</span>
|
||||
<span className={styles.bpStale}>
|
||||
<StatusDot variant="stale" /> {groups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded
|
||||
</span>
|
||||
<span className={styles.bpDead}>
|
||||
<StatusDot variant="dead" /> {groups.filter((g) => g.deadCount > 0).length} critical
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Active Routes"
|
||||
value={
|
||||
<span
|
||||
className={
|
||||
styles[
|
||||
totalActiveRoutes === 0
|
||||
? 'routesError'
|
||||
: totalActiveRoutes < totalRoutes
|
||||
? 'routesWarning'
|
||||
: 'routesSuccess'
|
||||
]
|
||||
}
|
||||
>
|
||||
{deadInGroup.length > 0 && (
|
||||
<Alert variant="error">{deadInGroup.length} instance(s) unreachable</Alert>
|
||||
)}
|
||||
<table className={styles.instanceTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.thStatus} />
|
||||
<th>Instance</th>
|
||||
<th>State</th>
|
||||
<th>Uptime</th>
|
||||
<th>TPS</th>
|
||||
<th>Errors</th>
|
||||
<th>Heartbeat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(groupAgents || []).map((agent: any) => (
|
||||
<tr
|
||||
key={agent.id}
|
||||
className={[
|
||||
styles.instanceRow,
|
||||
selectedAgent?.id === agent.id ? styles.instanceRowActive : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
setSelectedAgent(agent);
|
||||
navigate(`/agents/${group}/${agent.id}`);
|
||||
}}
|
||||
>
|
||||
<td className={styles.tdStatus}>
|
||||
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
|
||||
</td>
|
||||
<td>
|
||||
<MonoText size="sm" className={styles.instanceName}>{agent.name ?? agent.id}</MonoText>
|
||||
</td>
|
||||
<td>
|
||||
<Badge
|
||||
label={agent.status}
|
||||
color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'}
|
||||
variant="filled"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.instanceMeta}>{formatUptime(agent.uptimeSeconds)}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.instanceMeta}>{agent.tps != null ? `${(agent.tps as number).toFixed(1)}/s` : '—'}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={agent.errorRate != null ? styles.instanceError : styles.instanceMeta}>
|
||||
{agent.errorRate != null ? `${((agent.errorRate as number) * 100).toFixed(1)}%` : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={
|
||||
agent.status === 'DEAD' ? styles.instanceHeartbeatDead
|
||||
: agent.status === 'STALE' ? styles.instanceHeartbeatStale
|
||||
: styles.instanceMeta
|
||||
}>
|
||||
{formatRelativeTime(agent.lastHeartbeat)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</GroupCard>
|
||||
);
|
||||
})}
|
||||
{totalActiveRoutes}/{totalRoutes}
|
||||
</span>
|
||||
}
|
||||
accent={totalActiveRoutes === 0 ? 'error' : totalActiveRoutes < totalRoutes ? 'warning' : 'success'}
|
||||
detail={totalActiveRoutes < totalRoutes ? `${totalRoutes - totalActiveRoutes} suspended` : 'all routes active'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total TPS"
|
||||
value={totalTps.toFixed(1)}
|
||||
accent="amber"
|
||||
detail="msg/s"
|
||||
/>
|
||||
<StatCard
|
||||
label="Dead"
|
||||
value={String(deadCount)}
|
||||
accent={deadCount > 0 ? 'error' : 'success'}
|
||||
detail={deadCount > 0 ? 'requires attention' : 'all healthy'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scope trail + badges */}
|
||||
<div className={styles.scopeTrail}>
|
||||
{appId && (
|
||||
<>
|
||||
<Link to="/agents" className={styles.scopeLink}>All Agents</Link>
|
||||
<span className={styles.scopeSep}>▸</span>
|
||||
<span className={styles.scopeCurrent}>{appId}</span>
|
||||
</>
|
||||
)}
|
||||
<Badge
|
||||
label={`${liveCount}/${totalInstances} live`}
|
||||
color={deadCount > 0 ? 'error' : staleCount > 0 ? 'warning' : 'success'}
|
||||
variant="filled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group cards grid */}
|
||||
<div className={isFullWidth ? styles.groupGridSingle : styles.groupGrid}>
|
||||
{groups.map((group) => (
|
||||
<GroupCard
|
||||
key={group.appId}
|
||||
title={group.appId}
|
||||
accent={appHealth(group)}
|
||||
headerRight={
|
||||
<Badge
|
||||
label={`${group.liveCount}/${group.instances.length} LIVE`}
|
||||
color={appHealth(group)}
|
||||
variant="filled"
|
||||
/>
|
||||
}
|
||||
meta={
|
||||
<div className={styles.groupMeta}>
|
||||
<span><strong>{group.totalTps.toFixed(1)}</strong> msg/s</span>
|
||||
<span><strong>{group.totalActiveRoutes}</strong>/{group.totalRoutes} routes</span>
|
||||
<span>
|
||||
<StatusDot
|
||||
variant={
|
||||
appHealth(group) === 'success'
|
||||
? 'live'
|
||||
: appHealth(group) === 'warning'
|
||||
? 'stale'
|
||||
: 'dead'
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
group.deadCount > 0 ? (
|
||||
<div className={styles.alertBanner}>
|
||||
<span className={styles.alertIcon}>⚠</span>
|
||||
<span>
|
||||
Single point of failure —{' '}
|
||||
{group.deadCount === group.instances.length
|
||||
? 'no redundancy'
|
||||
: `${group.deadCount} dead instance${group.deadCount > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<DataTable<AgentInstance>
|
||||
columns={instanceColumns}
|
||||
data={group.instances}
|
||||
onRowClick={handleInstanceClick}
|
||||
selectedId={panelOpen ? selectedInstance?.id : undefined}
|
||||
pageSize={50}
|
||||
flush
|
||||
/>
|
||||
</GroupCard>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* EventFeed */}
|
||||
{feedEvents.length > 0 && (
|
||||
<div className={styles.eventCard}>
|
||||
<div className={styles.eventCardHeader}>
|
||||
<span>Timeline</span>
|
||||
<Badge label={`${feedEvents.length} events`} variant="outlined" />
|
||||
<span className={styles.sectionTitle}>Timeline</span>
|
||||
<span className={styles.sectionMeta}>{feedEvents.length} events</span>
|
||||
</div>
|
||||
<EventFeed events={feedEvents} maxItems={100} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedAgent && (
|
||||
{/* Detail panel */}
|
||||
{selectedInstance && (
|
||||
<DetailPanel
|
||||
key={selectedAgent.id}
|
||||
open={true}
|
||||
title={selectedAgent.name ?? selectedAgent.id}
|
||||
onClose={() => setSelectedAgent(null)}
|
||||
className={styles.detailPanelOverride}
|
||||
>
|
||||
<AgentOverviewContent agent={selectedAgent} />
|
||||
<div className={styles.panelDivider} />
|
||||
<AgentPerformanceContent agent={selectedAgent} />
|
||||
</DetailPanel>
|
||||
open={panelOpen}
|
||||
onClose={() => {
|
||||
setPanelOpen(false);
|
||||
setSelectedInstance(null);
|
||||
}}
|
||||
title={selectedInstance.name ?? selectedInstance.id}
|
||||
tabs={detailTabs}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 40px;
|
||||
min-width: 0;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
/* Stat strip — 5 columns matching /agents */
|
||||
.statStrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
@@ -5,18 +14,67 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.agentHeader {
|
||||
/* Scope trail — matches /agents */
|
||||
.scopeTrail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.agentHeader h2 {
|
||||
font-size: 18px;
|
||||
.scopeLink {
|
||||
color: var(--amber);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scopeLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.scopeSep {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.scopeCurrent {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Process info card */
|
||||
.processCard {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.processGrid {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto 1fr;
|
||||
gap: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-family: var(--font-body);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.processLabel {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.capTags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Route badges */
|
||||
.routeBadges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -24,9 +82,10 @@
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Charts 3x2 grid */
|
||||
.chartsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@@ -53,14 +112,46 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
.chartMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.eventCard {
|
||||
/* Log + Timeline side by side */
|
||||
.bottomRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Log viewer */
|
||||
.logCard {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* Empty state (shared) */
|
||||
.logEmpty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: var(--text-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Timeline card */
|
||||
.timelineCard {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -69,107 +160,12 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 420px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.eventCardHeader {
|
||||
.timelineHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 16px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.infoCard {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.infoGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.capTags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scopeTrail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scopeLink {
|
||||
color: var(--text-accent, var(--text-primary));
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scopeLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.scopeSep {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.scopeCurrent {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.paneTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chartMeta {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.bottomSection {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.eventCount {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.emptyEvents {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams, Link } from 'react-router';
|
||||
import {
|
||||
StatCard, StatusDot, Badge, Card,
|
||||
LineChart, AreaChart, BarChart, EventFeed, Breadcrumb, Spinner, EmptyState,
|
||||
StatCard, StatusDot, Badge, LineChart, AreaChart, BarChart,
|
||||
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
|
||||
LogViewer, Tabs, useGlobalFilters,
|
||||
} from '@cameleer/design-system';
|
||||
import type { FeedEvent, LogEntry } from '@cameleer/design-system';
|
||||
import styles from './AgentInstance.module.css';
|
||||
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
||||
import { useStatsTimeseries } from '../../api/queries/executions';
|
||||
import { useAgentMetrics } from '../../api/queries/agent-metrics';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
|
||||
const LOG_TABS = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Warnings', value: 'warn' },
|
||||
{ label: 'Errors', value: 'error' },
|
||||
];
|
||||
|
||||
export default function AgentInstance() {
|
||||
const { appId, instanceId } = useParams();
|
||||
const { timeRange } = useGlobalFilters();
|
||||
const [logFilter, setLogFilter] = useState('all');
|
||||
const timeFrom = timeRange.start.toISOString();
|
||||
const timeTo = timeRange.end.toISOString();
|
||||
|
||||
@@ -20,8 +28,8 @@ export default function AgentInstance() {
|
||||
const { data: events } = useAgentEvents(appId, instanceId);
|
||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
|
||||
|
||||
const agent = useMemo(() =>
|
||||
(agents || []).find((a: any) => a.id === instanceId) as any,
|
||||
const agent = useMemo(
|
||||
() => (agents || []).find((a: any) => a.id === instanceId) as any,
|
||||
[agents, instanceId],
|
||||
);
|
||||
|
||||
@@ -43,26 +51,34 @@ export default function AgentInstance() {
|
||||
60,
|
||||
);
|
||||
|
||||
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,
|
||||
})),
|
||||
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),
|
||||
})),
|
||||
const feedEvents = useMemo<FeedEvent[]>(
|
||||
() =>
|
||||
(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 ? ' \u2014 ' + e.detail : ''}`,
|
||||
timestamp: new Date(e.timestamp),
|
||||
})),
|
||||
[events, instanceId],
|
||||
);
|
||||
|
||||
@@ -88,194 +104,305 @@ export default function AgentInstance() {
|
||||
const gcSeries = useMemo(() => {
|
||||
const pts = jvmMetrics?.metrics?.['jvm.gc.time'];
|
||||
if (!pts?.length) return null;
|
||||
return [{ label: 'GC ms', data: pts.map((p: any, i: number) => ({ x: String(i), y: p.value })) }];
|
||||
return [{ label: 'GC ms', data: pts.map((p: any) => ({ x: String(p.time ?? ''), y: p.value })) }];
|
||||
}, [jvmMetrics]);
|
||||
|
||||
const throughputSeries = useMemo(() =>
|
||||
chartData.length ? [{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }] : null,
|
||||
const throughputSeries = useMemo(
|
||||
() =>
|
||||
chartData.length
|
||||
? [{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]
|
||||
: null,
|
||||
[chartData],
|
||||
);
|
||||
|
||||
const errorSeries = useMemo(() =>
|
||||
chartData.length ? [{ label: 'Errors', data: chartData.map((d: any, i: number) => ({ x: i, y: d.errors })) }] : null,
|
||||
const errorSeries = useMemo(
|
||||
() =>
|
||||
chartData.length
|
||||
? [{ label: 'Errors', data: chartData.map((d: any, i: number) => ({ x: i, y: d.errors })) }]
|
||||
: null,
|
||||
[chartData],
|
||||
);
|
||||
|
||||
// Placeholder log entries (backend does not stream logs yet)
|
||||
const logEntries = useMemo<LogEntry[]>(() => [], []);
|
||||
const filteredLogs =
|
||||
logFilter === 'all' ? logEntries : logEntries.filter((l) => l.level === logFilter);
|
||||
|
||||
if (isLoading) return <Spinner size="lg" />;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb items={[
|
||||
{ label: 'Agents', href: '/agents' },
|
||||
{ label: appId || '', href: `/agents/${appId}` },
|
||||
{ label: agent?.name || instanceId || '' },
|
||||
]} />
|
||||
const statusVariant =
|
||||
agent?.status === 'LIVE' ? 'live' : agent?.status === 'STALE' ? 'stale' : 'dead';
|
||||
const statusColor: 'success' | 'warning' | 'error' =
|
||||
agent?.status === 'LIVE' ? 'success' : agent?.status === 'STALE' ? 'warning' : 'error';
|
||||
const cpuDisplay = cpuPct != null ? (cpuPct * 100).toFixed(0) : null;
|
||||
const heapUsedMB = heapUsed != null ? (heapUsed / (1024 * 1024)).toFixed(0) : null;
|
||||
const heapMaxMB = heapMax != null ? (heapMax / (1024 * 1024)).toFixed(0) : null;
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
{/* Stat strip — 5 columns */}
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard
|
||||
label="CPU"
|
||||
value={cpuDisplay != null ? `${cpuDisplay}%` : '\u2014'}
|
||||
accent={
|
||||
cpuDisplay != null
|
||||
? Number(cpuDisplay) > 85
|
||||
? 'error'
|
||||
: Number(cpuDisplay) > 70
|
||||
? 'warning'
|
||||
: 'success'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Memory"
|
||||
value={memPct != null ? `${memPct.toFixed(0)}%` : '\u2014'}
|
||||
accent={
|
||||
memPct != null
|
||||
? memPct > 85
|
||||
? 'error'
|
||||
: memPct > 70
|
||||
? 'warning'
|
||||
: 'success'
|
||||
: undefined
|
||||
}
|
||||
detail={
|
||||
heapUsedMB != null && heapMaxMB != null
|
||||
? `${heapUsedMB} MB / ${heapMaxMB} MB`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Throughput"
|
||||
value={agent?.tps != null ? `${agent.tps.toFixed(1)}/s` : '\u2014'}
|
||||
accent="amber"
|
||||
detail="msg/s"
|
||||
/>
|
||||
<StatCard
|
||||
label="Errors"
|
||||
value={agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : '\u2014'}
|
||||
accent={agent?.errorRate > 0 ? 'error' : 'success'}
|
||||
/>
|
||||
<StatCard
|
||||
label="Uptime"
|
||||
value={formatUptime(agent?.uptimeSeconds)}
|
||||
accent="running"
|
||||
detail={
|
||||
agent?.registeredAt
|
||||
? `since ${new Date(agent.registeredAt).toLocaleDateString()}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scope trail + badges */}
|
||||
{agent && (
|
||||
<>
|
||||
<div className={styles.agentHeader}>
|
||||
<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'} />
|
||||
{agent.version && <Badge label={agent.version} variant="outlined" />}
|
||||
</div>
|
||||
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard label="CPU" value={cpuPct != null ? `${(cpuPct * 100).toFixed(0)}%` : '—'} />
|
||||
<StatCard
|
||||
label="Memory"
|
||||
value={memPct != null ? `${memPct.toFixed(0)}%` : '—'}
|
||||
detail={heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : undefined}
|
||||
/>
|
||||
<StatCard label="Throughput" value={agent?.tps != null ? `${agent.tps.toFixed(1)}/s` : '—'} />
|
||||
<StatCard label="Errors" value={agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : '—'} accent={agent?.errorRate > 0 ? 'error' : undefined} />
|
||||
<StatCard
|
||||
label="Uptime"
|
||||
value={formatUptime(agent?.uptimeSeconds)}
|
||||
detail={agent?.registeredAt ? `since ${new Date(agent.registeredAt).toLocaleDateString()}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.scopeTrail}>
|
||||
<a href="/agents" className={styles.scopeLink}>All Agents</a>
|
||||
<Link to="/agents" className={styles.scopeLink}>
|
||||
All Agents
|
||||
</Link>
|
||||
<span className={styles.scopeSep}>▸</span>
|
||||
<a href={`/agents/${appId}`} className={styles.scopeLink}>{appId}</a>
|
||||
<Link to={`/agents/${appId}`} className={styles.scopeLink}>
|
||||
{appId}
|
||||
</Link>
|
||||
<span className={styles.scopeSep}>▸</span>
|
||||
<span className={styles.scopeCurrent}>{agent.name}</span>
|
||||
<Badge
|
||||
label={agent.status.toUpperCase()}
|
||||
color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'}
|
||||
/>
|
||||
{agent.version && <Badge label={agent.version} variant="outlined" />}
|
||||
<StatusDot variant={statusVariant} />
|
||||
<Badge label={agent.status} color={statusColor} />
|
||||
{agent.version && <Badge label={agent.version} variant="outlined" color="auto" />}
|
||||
<Badge
|
||||
label={`${agent.activeRoutes ?? (agent.routeIds?.length ?? 0)}/${agent.totalRoutes ?? (agent.routeIds?.length ?? 0)} routes`}
|
||||
color={(agent.activeRoutes ?? 0) < (agent.totalRoutes ?? 0) ? 'warning' : 'success'}
|
||||
color={
|
||||
(agent.activeRoutes ?? 0) < (agent.totalRoutes ?? 0) ? 'warning' : 'success'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className={styles.infoCard}>
|
||||
<div className={styles.paneTitle}>Process Information</div>
|
||||
<div className={styles.infoGrid}>
|
||||
{agent?.capabilities?.jvmVersion && (
|
||||
<div>
|
||||
<span className={styles.infoLabel}>JVM</span>
|
||||
<span>{agent.capabilities.jvmVersion}</span>
|
||||
</div>
|
||||
{/* Process info card */}
|
||||
<div className={styles.processCard}>
|
||||
<SectionHeader>Process Information</SectionHeader>
|
||||
<div className={styles.processGrid}>
|
||||
{agent.capabilities?.jvmVersion && (
|
||||
<>
|
||||
<span className={styles.processLabel}>JVM</span>
|
||||
<MonoText size="xs">{agent.capabilities.jvmVersion}</MonoText>
|
||||
</>
|
||||
)}
|
||||
{agent?.capabilities?.camelVersion && (
|
||||
<div>
|
||||
<span className={styles.infoLabel}>Camel</span>
|
||||
<span>{agent.capabilities.camelVersion}</span>
|
||||
</div>
|
||||
{agent.capabilities?.camelVersion && (
|
||||
<>
|
||||
<span className={styles.processLabel}>Camel</span>
|
||||
<MonoText size="xs">{agent.capabilities.camelVersion}</MonoText>
|
||||
</>
|
||||
)}
|
||||
{agent?.capabilities?.springBootVersion && (
|
||||
<div>
|
||||
<span className={styles.infoLabel}>Spring Boot</span>
|
||||
<span>{agent.capabilities.springBootVersion}</span>
|
||||
</div>
|
||||
{agent.capabilities?.springBootVersion && (
|
||||
<>
|
||||
<span className={styles.processLabel}>Spring Boot</span>
|
||||
<MonoText size="xs">{agent.capabilities.springBootVersion}</MonoText>
|
||||
</>
|
||||
)}
|
||||
<span className={styles.processLabel}>Started</span>
|
||||
<MonoText size="xs">
|
||||
{agent.registeredAt ? new Date(agent.registeredAt).toLocaleString() : '\u2014'}
|
||||
</MonoText>
|
||||
{agent.capabilities && (
|
||||
<>
|
||||
<span className={styles.processLabel}>Capabilities</span>
|
||||
<span className={styles.capTags}>
|
||||
{Object.entries(agent.capabilities)
|
||||
.filter(([, v]) => typeof v === 'boolean' && v)
|
||||
.map(([k]) => (
|
||||
<Badge key={k} label={k} variant="outlined" />
|
||||
))}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<span className={styles.infoLabel}>Started</span>
|
||||
<span>{agent?.registeredAt ? new Date(agent.registeredAt).toLocaleString() : '—'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.infoLabel}>Capabilities</span>
|
||||
<span className={styles.capTags}>
|
||||
{Object.entries(agent?.capabilities || {})
|
||||
.filter(([, v]) => typeof v === 'boolean' && v)
|
||||
.map(([k]) => (
|
||||
<Badge key={k} label={k} variant="outlined" />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className={styles.sectionTitle}>Routes</div>
|
||||
<div className={styles.routeBadges}>
|
||||
{(agent.routeIds || []).map((r: string) => (
|
||||
<Badge key={r} label={r} color="auto" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Routes */}
|
||||
{(agent.routeIds?.length ?? 0) > 0 && (
|
||||
<>
|
||||
<SectionHeader>Routes</SectionHeader>
|
||||
<div className={styles.routeBadges}>
|
||||
{(agent.routeIds || []).map((r: string) => (
|
||||
<Badge key={r} label={r} color="auto" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Charts grid — 3x2 */}
|
||||
<div className={styles.chartsGrid}>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div className={styles.chartTitle}>CPU Usage</div>
|
||||
<div className={styles.chartMeta}>{cpuPct != null ? `${(cpuPct * 100).toFixed(0)}% current` : ''}</div>
|
||||
<span className={styles.chartTitle}>CPU Usage</span>
|
||||
<span className={styles.chartMeta}>
|
||||
{cpuDisplay != null ? `${cpuDisplay}% current` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{cpuSeries
|
||||
? <AreaChart series={cpuSeries} yLabel="%" height={200} />
|
||||
: <EmptyState title="No data" description="No CPU metrics available" />}
|
||||
{cpuSeries ? (
|
||||
<AreaChart
|
||||
series={cpuSeries}
|
||||
height={160}
|
||||
yLabel="%"
|
||||
threshold={{ value: 85, label: 'Alert' }}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState title="No data" description="No CPU metrics available" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div className={styles.chartTitle}>Memory (Heap)</div>
|
||||
<div className={styles.chartMeta}>{heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : ''}</div>
|
||||
<span className={styles.chartTitle}>Memory (Heap)</span>
|
||||
<span className={styles.chartMeta}>
|
||||
{heapUsedMB != null && heapMaxMB != null
|
||||
? `${heapUsedMB} MB / ${heapMaxMB} MB`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
{heapSeries
|
||||
? <AreaChart series={heapSeries} yLabel="MB" height={200} />
|
||||
: <EmptyState title="No data" description="No heap metrics available" />}
|
||||
{heapSeries ? (
|
||||
<AreaChart series={heapSeries} height={160} yLabel="MB" />
|
||||
) : (
|
||||
<EmptyState title="No data" description="No heap metrics available" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div className={styles.chartTitle}>Throughput</div>
|
||||
<div className={styles.chartMeta}>{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}</div>
|
||||
<span className={styles.chartTitle}>Throughput</span>
|
||||
<span className={styles.chartMeta}>
|
||||
{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{throughputSeries
|
||||
? <AreaChart series={throughputSeries} yLabel="msg/s" height={200} />
|
||||
: <EmptyState title="No data" description="No throughput data in range" />}
|
||||
{throughputSeries ? (
|
||||
<LineChart series={throughputSeries} height={160} yLabel="msg/s" />
|
||||
) : (
|
||||
<EmptyState title="No data" description="No throughput data in range" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div className={styles.chartTitle}>Error Rate</div>
|
||||
<div className={styles.chartMeta}>{agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : ''}</div>
|
||||
<span className={styles.chartTitle}>Error Rate</span>
|
||||
<span className={styles.chartMeta}>
|
||||
{agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : ''}
|
||||
</span>
|
||||
</div>
|
||||
{errorSeries
|
||||
? <LineChart series={errorSeries} yLabel="%" height={200} />
|
||||
: <EmptyState title="No data" description="No error data in range" />}
|
||||
{errorSeries ? (
|
||||
<LineChart series={errorSeries} height={160} yLabel="err/h" />
|
||||
) : (
|
||||
<EmptyState title="No data" description="No error data in range" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div className={styles.chartTitle}>Thread Count</div>
|
||||
{threadSeries && <div className={styles.chartMeta}>{threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active</div>}
|
||||
<span className={styles.chartTitle}>Thread Count</span>
|
||||
<span className={styles.chartMeta}>
|
||||
{threadSeries
|
||||
? `${threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active`
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
{threadSeries
|
||||
? <LineChart series={threadSeries} yLabel="threads" height={200} />
|
||||
: <EmptyState title="No data" description="No thread metrics available" />}
|
||||
{threadSeries ? (
|
||||
<LineChart series={threadSeries} height={160} yLabel="threads" />
|
||||
) : (
|
||||
<EmptyState title="No data" description="No thread metrics available" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}>
|
||||
<div className={styles.chartTitle}>GC Pauses</div>
|
||||
<span className={styles.chartTitle}>GC Pauses</span>
|
||||
<span className={styles.chartMeta} />
|
||||
</div>
|
||||
{gcSeries
|
||||
? <BarChart series={gcSeries} yLabel="ms" height={200} />
|
||||
: <EmptyState title="No data" description="No GC metrics available" />}
|
||||
{gcSeries ? (
|
||||
<BarChart series={gcSeries} height={160} yLabel="ms" />
|
||||
) : (
|
||||
<EmptyState title="No data" description="No GC metrics available" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.bottomSection}>
|
||||
<EmptyState title="Application Log" description="Application log streaming is not yet available" />
|
||||
|
||||
<div className={styles.eventCard}>
|
||||
<div className={styles.eventCardHeader}>
|
||||
<span>Timeline</span>
|
||||
<span className={styles.eventCount}>{feedEvents.length} events</span>
|
||||
{/* Log + Timeline side by side */}
|
||||
<div className={styles.bottomRow}>
|
||||
<div className={styles.logCard}>
|
||||
<div className={styles.logHeader}>
|
||||
<SectionHeader>Application Log</SectionHeader>
|
||||
<Tabs tabs={LOG_TABS} active={logFilter} onChange={setLogFilter} />
|
||||
</div>
|
||||
{feedEvents.length > 0
|
||||
? <EventFeed events={feedEvents} maxItems={50} />
|
||||
: <div className={styles.emptyEvents}>No events in the selected time range.</div>}
|
||||
{filteredLogs.length > 0 ? (
|
||||
<LogViewer entries={filteredLogs} maxHeight={360} />
|
||||
) : (
|
||||
<div className={styles.logEmpty}>
|
||||
Application log streaming is not yet available.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.timelineCard}>
|
||||
<div className={styles.timelineHeader}>
|
||||
<span className={styles.chartTitle}>Timeline</span>
|
||||
<span className={styles.chartMeta}>{feedEvents.length} events</span>
|
||||
</div>
|
||||
{feedEvents.length > 0 ? (
|
||||
<EventFeed events={feedEvents} maxItems={50} />
|
||||
) : (
|
||||
<div className={styles.logEmpty}>No events in the selected time range.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatUptime(seconds?: number): string {
|
||||
if (!seconds) return '—';
|
||||
if (!seconds) return '\u2014';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
.healthStrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
/* Scrollable content area */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 40px;
|
||||
min-width: 0;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
/* Filter bar spacing */
|
||||
.filterBar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Table section */
|
||||
.tableSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
@@ -39,6 +47,93 @@
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Status cell */
|
||||
.statusCell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* Route cells */
|
||||
.routeName {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Application column */
|
||||
.appName {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Duration color classes */
|
||||
.durFast {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.durNormal {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.durSlow {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.durBreach {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* Agent badge in table */
|
||||
.agentBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.agentDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #5db866;
|
||||
box-shadow: 0 0 4px rgba(93, 184, 102, 0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Inline error preview below row */
|
||||
.inlineError {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
background: var(--error-bg);
|
||||
border-left: 3px solid var(--error-border);
|
||||
}
|
||||
|
||||
.inlineErrorIcon {
|
||||
color: var(--error);
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.inlineErrorText {
|
||||
font-size: 11px;
|
||||
color: var(--error);
|
||||
font-family: var(--font-mono);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.inlineErrorHint {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
/* Detail panel sections */
|
||||
.panelSection {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
@@ -59,19 +154,21 @@
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.panelSectionMeta {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
margin-left: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
/* Overview grid */
|
||||
.overviewGrid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -95,45 +192,67 @@
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
/* Error block */
|
||||
.errorBlock {
|
||||
background: var(--error-bg);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.errorClass {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--error);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
font-family: var(--font-mono);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Inspect exchange icon in table */
|
||||
.inspectLink {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-faint);
|
||||
opacity: 0.75;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.15s, opacity 0.15s;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.inspectLink:hover {
|
||||
color: var(--accent, #c6820e);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.detailPanelOverride {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
|
||||
color: var(--text-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Open full details link in panel */
|
||||
.openDetailLink {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent, #c6820e);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--amber);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
font-family: var(--font-body);
|
||||
transition: color 0.1s;
|
||||
}
|
||||
|
||||
.openDetailLink:hover {
|
||||
color: var(--amber-deep);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -1,186 +1,417 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router'
|
||||
import {
|
||||
StatCard, StatusDot, Badge, MonoText,
|
||||
DataTable, DetailPanel, ProcessorTimeline, RouteFlow,
|
||||
Alert, Collapsible, CodeBlock, ShortcutsBar,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail } from '../../api/queries/executions';
|
||||
import { useDiagramLayout } from '../../api/queries/diagrams';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
import type { ExecutionSummary } from '../../api/types';
|
||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
||||
import styles from './Dashboard.module.css';
|
||||
DataTable,
|
||||
DetailPanel,
|
||||
ShortcutsBar,
|
||||
ProcessorTimeline,
|
||||
RouteFlow,
|
||||
KpiStrip,
|
||||
StatusDot,
|
||||
MonoText,
|
||||
Badge,
|
||||
useGlobalFilters,
|
||||
} from '@cameleer/design-system'
|
||||
import type { Column, KpiItem, RouteNode } from '@cameleer/design-system'
|
||||
import {
|
||||
useSearchExecutions,
|
||||
useExecutionStats,
|
||||
useStatsTimeseries,
|
||||
useExecutionDetail,
|
||||
} from '../../api/queries/executions'
|
||||
import { useDiagramLayout } from '../../api/queries/diagrams'
|
||||
import type { ExecutionSummary } from '../../api/types'
|
||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'
|
||||
import styles from './Dashboard.module.css'
|
||||
|
||||
interface Row extends ExecutionSummary { id: string }
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
// Row type extends ExecutionSummary with an `id` field for DataTable
|
||||
interface Row extends ExecutionSummary {
|
||||
id: string
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { appId, routeId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { timeRange } = useGlobalFilters();
|
||||
const timeFrom = timeRange.start.toISOString();
|
||||
const timeTo = timeRange.end.toISOString();
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
|
||||
return `${ms}ms`
|
||||
}
|
||||
|
||||
const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
|
||||
function formatTimestamp(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
const y = date.getFullYear()
|
||||
const mo = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const mi = String(date.getMinutes()).padStart(2, '0')
|
||||
const s = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${y}-${mo}-${d} ${h}:${mi}:${s}`
|
||||
}
|
||||
|
||||
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,
|
||||
application: appId || undefined,
|
||||
offset: 0, limit: 50,
|
||||
}, true);
|
||||
const { data: detail } = useExecutionDetail(selectedId);
|
||||
function statusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
|
||||
switch (status) {
|
||||
case 'COMPLETED': return 'success'
|
||||
case 'FAILED': return 'error'
|
||||
case 'RUNNING': return 'running'
|
||||
default: return 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
const rows: Row[] = useMemo(() =>
|
||||
(searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
||||
[searchResult],
|
||||
);
|
||||
function statusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'COMPLETED': return 'OK'
|
||||
case 'FAILED': return 'ERR'
|
||||
case 'RUNNING': return 'RUN'
|
||||
default: return 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null);
|
||||
function durationClass(ms: number, status: string): string {
|
||||
if (status === 'FAILED') return styles.durBreach
|
||||
if (ms < 100) return styles.durFast
|
||||
if (ms < 200) return styles.durNormal
|
||||
if (ms < 300) return styles.durSlow
|
||||
return styles.durBreach
|
||||
}
|
||||
|
||||
const totalCount = stats?.totalCount ?? 0;
|
||||
const failedCount = stats?.failedCount ?? 0;
|
||||
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount * 100) : 100;
|
||||
const throughput = timeWindowSeconds > 0 ? totalCount / timeWindowSeconds : 0;
|
||||
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
|
||||
}
|
||||
|
||||
const sparkExchanges = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => b.totalCount as number), [timeseries]);
|
||||
const sparkErrors = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => b.failedCount as number), [timeseries]);
|
||||
const sparkLatency = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number), [timeseries]);
|
||||
const sparkThroughput = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => {
|
||||
const bucketSeconds = timeWindowSeconds / Math.max((timeseries?.buckets || []).length, 1);
|
||||
return bucketSeconds > 0 ? (b.totalCount as number) / bucketSeconds : 0;
|
||||
}), [timeseries, timeWindowSeconds]);
|
||||
// ─── Table columns (base, without inspect action) ────────────────────────────
|
||||
|
||||
const prevTotal = stats?.prevTotalCount ?? 0;
|
||||
const prevFailed = stats?.prevFailedCount ?? 0;
|
||||
const exchangeTrend = prevTotal > 0 ? ((totalCount - prevTotal) / prevTotal * 100) : 0;
|
||||
const prevSuccessRate = prevTotal > 0 ? ((prevTotal - prevFailed) / prevTotal * 100) : 100;
|
||||
const successRateDelta = successRate - prevSuccessRate;
|
||||
const errorDelta = failedCount - prevFailed;
|
||||
|
||||
const columns: Column<Row>[] = [
|
||||
function buildBaseColumns(): Column<Row>[] {
|
||||
return [
|
||||
{
|
||||
key: 'status', header: 'Status', width: '80px',
|
||||
render: (v, row) => (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />
|
||||
<MonoText size="xs">{v === 'COMPLETED' ? 'OK' : v === 'FAILED' ? 'ERR' : 'RUN'}</MonoText>
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: '80px',
|
||||
render: (_: unknown, row: Row) => (
|
||||
<span className={styles.statusCell}>
|
||||
<StatusDot variant={statusToVariant(row.status)} />
|
||||
<MonoText size="xs">{statusLabel(row.status)}</MonoText>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: '_inspect' as any, header: '', width: '36px',
|
||||
render: (_v, row) => (
|
||||
<a
|
||||
href={`/exchanges/${row.executionId}`}
|
||||
onClick={(e) => { e.stopPropagation(); e.preventDefault(); navigate(`/exchanges/${row.executionId}`); }}
|
||||
className={styles.inspectLink}
|
||||
title="Open full details"
|
||||
>↗</a>
|
||||
key: 'routeId',
|
||||
header: 'Route',
|
||||
sortable: true,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<span className={styles.routeName}>{row.routeId}</span>
|
||||
),
|
||||
},
|
||||
{ key: 'routeId', header: 'Route', sortable: true, render: (v) => <span>{String(v)}</span> },
|
||||
{ key: 'applicationName', header: 'Application', sortable: true, render: (v) => <span>{String(v ?? '')}</span> },
|
||||
{ key: 'executionId', header: 'Exchange ID', sortable: true, render: (v) => <MonoText size="xs">{String(v)}</MonoText> },
|
||||
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => <MonoText size="xs">{new Date(v as string).toLocaleString()}</MonoText> },
|
||||
{
|
||||
key: 'durationMs', header: 'Duration', sortable: true,
|
||||
render: (v) => <MonoText size="sm">{formatDuration(v as number)}</MonoText>,
|
||||
key: 'applicationName',
|
||||
header: 'Application',
|
||||
sortable: true,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<span className={styles.appName}>{row.applicationName ?? ''}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'agentId', header: 'Agent',
|
||||
render: (v) => v ? <Badge label={String(v)} color="auto" /> : null,
|
||||
key: 'executionId',
|
||||
header: 'Exchange ID',
|
||||
sortable: true,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<MonoText size="xs">{row.executionId}</MonoText>
|
||||
),
|
||||
},
|
||||
];
|
||||
{
|
||||
key: 'startTime',
|
||||
header: 'Started',
|
||||
sortable: true,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<MonoText size="xs">{formatTimestamp(row.startTime)}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'durationMs',
|
||||
header: 'Duration',
|
||||
sortable: true,
|
||||
render: (_: unknown, row: Row) => (
|
||||
<MonoText size="sm" className={durationClass(row.durationMs, row.status)}>
|
||||
{formatDuration(row.durationMs)}
|
||||
</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'agentId',
|
||||
header: 'Agent',
|
||||
render: (_: unknown, row: Row) => (
|
||||
<span className={styles.agentBadge}>
|
||||
<span className={styles.agentDot} />
|
||||
{row.agentId}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const procList = detail ? (detail.processors?.length ? detail.processors : (detail.children ?? [])) : [];
|
||||
const SHORTCUTS = [
|
||||
{ keys: 'Ctrl+K', label: 'Search' },
|
||||
{ keys: '\u2191\u2193', label: 'Navigate rows' },
|
||||
{ keys: 'Enter', label: 'Open detail' },
|
||||
{ keys: 'Esc', label: 'Close panel' },
|
||||
]
|
||||
|
||||
// ─── Dashboard component ─────────────────────────────────────────────────────
|
||||
|
||||
export default function Dashboard() {
|
||||
const { appId, routeId } = useParams<{ appId: string; routeId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [selectedId, setSelectedId] = useState<string | undefined>()
|
||||
const [panelOpen, setPanelOpen] = useState(false)
|
||||
|
||||
const { timeRange, statusFilters } = useGlobalFilters()
|
||||
const timeFrom = timeRange.start.toISOString()
|
||||
const timeTo = timeRange.end.toISOString()
|
||||
const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000
|
||||
|
||||
// ─── API hooks ───────────────────────────────────────────────────────────
|
||||
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,
|
||||
application: appId || undefined,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
},
|
||||
true,
|
||||
)
|
||||
const { data: detail } = useExecutionDetail(selectedId ?? null)
|
||||
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null)
|
||||
|
||||
// ─── Rows ────────────────────────────────────────────────────────────────
|
||||
const allRows: Row[] = useMemo(
|
||||
() => (searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
||||
[searchResult],
|
||||
)
|
||||
|
||||
// Apply global status filters (time filtering is done server-side via timeFrom/timeTo)
|
||||
const rows: Row[] = useMemo(() => {
|
||||
if (statusFilters.size === 0) return allRows
|
||||
return allRows.filter((r) => statusFilters.has(r.status.toLowerCase() as any))
|
||||
}, [allRows, statusFilters])
|
||||
|
||||
// ─── KPI items ───────────────────────────────────────────────────────────
|
||||
const totalCount = stats?.totalCount ?? 0
|
||||
const failedCount = stats?.failedCount ?? 0
|
||||
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100
|
||||
const throughput = timeWindowSeconds > 0 ? totalCount / timeWindowSeconds : 0
|
||||
|
||||
const prevTotal = stats?.prevTotalCount ?? 0
|
||||
const prevFailed = stats?.prevFailedCount ?? 0
|
||||
const exchangeTrend = prevTotal > 0 ? ((totalCount - prevTotal) / prevTotal) * 100 : 0
|
||||
const prevSuccessRate = prevTotal > 0 ? ((prevTotal - prevFailed) / prevTotal) * 100 : 100
|
||||
const successRateDelta = successRate - prevSuccessRate
|
||||
const errorDelta = failedCount - prevFailed
|
||||
|
||||
const sparkExchanges = useMemo(
|
||||
() => (timeseries?.buckets || []).map((b: any) => b.totalCount as number),
|
||||
[timeseries],
|
||||
)
|
||||
const sparkErrors = useMemo(
|
||||
() => (timeseries?.buckets || []).map((b: any) => b.failedCount as number),
|
||||
[timeseries],
|
||||
)
|
||||
const sparkLatency = useMemo(
|
||||
() => (timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number),
|
||||
[timeseries],
|
||||
)
|
||||
const sparkThroughput = useMemo(
|
||||
() =>
|
||||
(timeseries?.buckets || []).map((b: any) => {
|
||||
const bucketSeconds = timeWindowSeconds / Math.max((timeseries?.buckets || []).length, 1)
|
||||
return bucketSeconds > 0 ? (b.totalCount as number) / bucketSeconds : 0
|
||||
}),
|
||||
[timeseries, timeWindowSeconds],
|
||||
)
|
||||
|
||||
const kpiItems: KpiItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: 'Exchanges',
|
||||
value: totalCount.toLocaleString(),
|
||||
trend: {
|
||||
label: `${exchangeTrend > 0 ? '\u2191' : exchangeTrend < 0 ? '\u2193' : '\u2192'} ${exchangeTrend > 0 ? '+' : ''}${exchangeTrend.toFixed(0)}%`,
|
||||
variant: (exchangeTrend > 0 ? 'success' : exchangeTrend < 0 ? 'error' : 'muted') as 'success' | 'error' | 'muted',
|
||||
},
|
||||
subtitle: `${successRate.toFixed(1)}% success rate`,
|
||||
sparkline: sparkExchanges,
|
||||
borderColor: 'var(--amber)',
|
||||
},
|
||||
{
|
||||
label: 'Success Rate',
|
||||
value: `${successRate.toFixed(1)}%`,
|
||||
trend: {
|
||||
label: `${successRateDelta >= 0 ? '\u2191' : '\u2193'} ${successRateDelta >= 0 ? '+' : ''}${successRateDelta.toFixed(1)}%`,
|
||||
variant: (successRateDelta >= 0 ? 'success' : 'error') as 'success' | 'error',
|
||||
},
|
||||
subtitle: `${(totalCount - failedCount).toLocaleString()} ok / ${failedCount} error`,
|
||||
borderColor: 'var(--success)',
|
||||
},
|
||||
{
|
||||
label: 'Errors',
|
||||
value: failedCount,
|
||||
trend: {
|
||||
label: `${errorDelta > 0 ? '\u2191' : errorDelta < 0 ? '\u2193' : '\u2192'} ${errorDelta > 0 ? '+' : ''}${errorDelta}`,
|
||||
variant: (errorDelta > 0 ? 'error' : errorDelta < 0 ? 'success' : 'muted') as 'success' | 'error' | 'muted',
|
||||
},
|
||||
subtitle: `${failedCount} errors in selected period`,
|
||||
sparkline: sparkErrors,
|
||||
borderColor: 'var(--error)',
|
||||
},
|
||||
{
|
||||
label: 'Throughput',
|
||||
value: `${throughput.toFixed(1)} msg/s`,
|
||||
trend: { label: '\u2192', variant: 'muted' as const },
|
||||
subtitle: `${throughput.toFixed(1)} msg/s`,
|
||||
sparkline: sparkThroughput,
|
||||
borderColor: 'var(--running)',
|
||||
},
|
||||
{
|
||||
label: 'Latency p99',
|
||||
value: `${(stats?.p99LatencyMs ?? 0).toLocaleString()} ms`,
|
||||
trend: { label: '', variant: 'muted' as const },
|
||||
subtitle: `${(stats?.p99LatencyMs ?? 0).toLocaleString()}ms`,
|
||||
sparkline: sparkLatency,
|
||||
borderColor: 'var(--warning)',
|
||||
},
|
||||
],
|
||||
[totalCount, failedCount, successRate, throughput, exchangeTrend, successRateDelta, errorDelta, sparkExchanges, sparkErrors, sparkLatency, sparkThroughput, stats?.p99LatencyMs],
|
||||
)
|
||||
|
||||
// ─── Table columns with inspect action ───────────────────────────────────
|
||||
const columns: Column<Row>[] = useMemo(() => {
|
||||
const inspectCol: Column<Row> = {
|
||||
key: 'correlationId',
|
||||
header: '',
|
||||
width: '36px',
|
||||
render: (_: unknown, row: Row) => (
|
||||
<button
|
||||
className={styles.inspectLink}
|
||||
title="Inspect exchange"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
navigate(`/exchanges/${row.executionId}`)
|
||||
}}
|
||||
>
|
||||
↗
|
||||
</button>
|
||||
),
|
||||
}
|
||||
const base = buildBaseColumns()
|
||||
const [statusCol, ...rest] = base
|
||||
return [statusCol, inspectCol, ...rest]
|
||||
}, [navigate])
|
||||
|
||||
// ─── Row click / detail panel ────────────────────────────────────────────
|
||||
const selectedRow = useMemo(
|
||||
() => rows.find((r) => r.id === selectedId),
|
||||
[rows, selectedId],
|
||||
)
|
||||
|
||||
function handleRowClick(row: Row) {
|
||||
setSelectedId(row.id)
|
||||
setPanelOpen(true)
|
||||
}
|
||||
|
||||
function handleRowAccent(row: Row): 'error' | 'warning' | undefined {
|
||||
if (row.status === 'FAILED') return 'error'
|
||||
return undefined
|
||||
}
|
||||
|
||||
// ─── Detail panel data ───────────────────────────────────────────────────
|
||||
const procList = detail
|
||||
? detail.processors?.length
|
||||
? detail.processors
|
||||
: (detail.children ?? [])
|
||||
: []
|
||||
|
||||
const routeNodes: RouteNode[] = useMemo(() => {
|
||||
if (diagram?.nodes) {
|
||||
return mapDiagramToRouteNodes(diagram.nodes || [], procList)
|
||||
}
|
||||
return []
|
||||
}, [diagram, procList])
|
||||
|
||||
const flatProcs = useMemo(() => flattenProcessors(procList), [procList])
|
||||
|
||||
// Error info from detail
|
||||
const errorClass = detail?.errorMessage?.split(':')[0] ?? ''
|
||||
const errorMsg = detail?.errorMessage ?? ''
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.healthStrip}>
|
||||
<StatCard
|
||||
label="Exchanges"
|
||||
value={totalCount.toLocaleString()}
|
||||
detail={`${successRate.toFixed(1)}% success rate`}
|
||||
trend={exchangeTrend > 0 ? 'up' : exchangeTrend < 0 ? 'down' : 'neutral'}
|
||||
trendValue={exchangeTrend > 0 ? `+${exchangeTrend.toFixed(0)}%` : `${exchangeTrend.toFixed(0)}%`}
|
||||
sparkline={sparkExchanges}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label="Success Rate"
|
||||
value={`${successRate.toFixed(1)}%`}
|
||||
detail={`${(totalCount - failedCount).toLocaleString()} ok / ${failedCount} error`}
|
||||
trend={successRateDelta >= 0 ? 'up' : 'down'}
|
||||
trendValue={`${successRateDelta >= 0 ? '+' : ''}${successRateDelta.toFixed(1)}%`}
|
||||
accent="success"
|
||||
/>
|
||||
<StatCard
|
||||
label="Errors"
|
||||
value={failedCount}
|
||||
detail={`${failedCount} errors in selected period`}
|
||||
trend={errorDelta > 0 ? 'up' : errorDelta < 0 ? 'down' : 'neutral'}
|
||||
trendValue={errorDelta > 0 ? `+${errorDelta}` : `${errorDelta}`}
|
||||
sparkline={sparkErrors}
|
||||
accent="error"
|
||||
/>
|
||||
<StatCard
|
||||
label="Throughput"
|
||||
value={throughput.toFixed(1)}
|
||||
detail={`${throughput.toFixed(1)} msg/s`}
|
||||
sparkline={sparkThroughput}
|
||||
accent="running"
|
||||
/>
|
||||
<StatCard
|
||||
label="Latency p99"
|
||||
value={(stats?.p99LatencyMs ?? 0).toLocaleString()}
|
||||
detail={`${(stats?.p99LatencyMs ?? 0).toLocaleString()}ms`}
|
||||
sparkline={sparkLatency}
|
||||
accent="warning"
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{/* Scrollable content */}
|
||||
<div className={styles.content}>
|
||||
{/* KPI strip */}
|
||||
<KpiStrip items={kpiItems} />
|
||||
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Recent Exchanges</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>{rows.length} of {searchResult?.total ?? 0} exchanges</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
{/* Exchanges table */}
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Recent Exchanges</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>
|
||||
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
|
||||
</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
onRowClick={handleRowClick}
|
||||
selectedId={selectedId}
|
||||
sortable
|
||||
flush
|
||||
rowAccent={handleRowAccent}
|
||||
expandedContent={(row: Row) =>
|
||||
row.errorMessage ? (
|
||||
<div className={styles.inlineError}>
|
||||
<span className={styles.inlineErrorIcon}>{'\u26A0'}</span>
|
||||
<div>
|
||||
<div className={styles.inlineErrorText}>{row.errorMessage}</div>
|
||||
<div className={styles.inlineErrorHint}>Click to view full stack trace</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
onRowClick={(row) => { setSelectedId(row.id); }}
|
||||
selectedId={selectedId ?? undefined}
|
||||
sortable
|
||||
pageSize={25}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedId && detail && (
|
||||
{/* Shortcuts bar */}
|
||||
<ShortcutsBar shortcuts={SHORTCUTS} />
|
||||
|
||||
{/* Detail panel */}
|
||||
{selectedRow && detail && (
|
||||
<DetailPanel
|
||||
key={selectedId}
|
||||
open={true}
|
||||
onClose={() => setSelectedId(null)}
|
||||
title={`${detail.routeId} — ${selectedId.slice(0, 12)}`}
|
||||
className={styles.detailPanelOverride}
|
||||
open={panelOpen}
|
||||
onClose={() => setPanelOpen(false)}
|
||||
title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`}
|
||||
>
|
||||
{/* Open full details link */}
|
||||
{/* Link to full detail page */}
|
||||
<div className={styles.panelSection}>
|
||||
<button
|
||||
className={styles.openDetailLink}
|
||||
@@ -196,9 +427,9 @@ export default function Dashboard() {
|
||||
<div className={styles.overviewGrid}>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Status</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : 'error'} />
|
||||
<span>{detail.status}</span>
|
||||
<span className={styles.statusCell}>
|
||||
<StatusDot variant={statusToVariant(detail.status)} />
|
||||
<span>{statusLabel(detail.status)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
@@ -211,44 +442,38 @@ export default function Dashboard() {
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Agent</span>
|
||||
<MonoText size="sm">{detail.agentId ?? '—'}</MonoText>
|
||||
<MonoText size="sm">{detail.agentId ?? '\u2014'}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Correlation</span>
|
||||
<MonoText size="xs">{detail.correlationId ?? '—'}</MonoText>
|
||||
<MonoText size="xs">{detail.correlationId ?? '\u2014'}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Timestamp</span>
|
||||
<MonoText size="xs">{detail.startTime ? new Date(detail.startTime).toLocaleString() : '—'}</MonoText>
|
||||
<MonoText size="xs">{detail.startTime ? new Date(detail.startTime).toISOString() : '\u2014'}</MonoText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{detail.errorMessage && (
|
||||
{errorMsg && (
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Errors</div>
|
||||
<Alert variant="error">
|
||||
<strong>{detail.errorMessage.split(':')[0]}</strong>
|
||||
<div>{detail.errorMessage.includes(':') ? detail.errorMessage.substring(detail.errorMessage.indexOf(':') + 1).trim() : ''}</div>
|
||||
</Alert>
|
||||
{detail.errorStackTrace && (
|
||||
<Collapsible title="Stack Trace">
|
||||
<CodeBlock content={detail.errorStackTrace} />
|
||||
</Collapsible>
|
||||
)}
|
||||
<div className={styles.errorBlock}>
|
||||
<div className={styles.errorClass}>{errorClass}</div>
|
||||
<div className={styles.errorMessage}>{errorMsg}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route Flow */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Route Flow</div>
|
||||
{diagram ? (
|
||||
<RouteFlow
|
||||
nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
|
||||
onNodeClick={(_node, _i) => {}}
|
||||
/>
|
||||
) : <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>}
|
||||
{routeNodes.length > 0 ? (
|
||||
<RouteFlow nodes={routeNodes} />
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Processor Timeline */}
|
||||
@@ -257,33 +482,17 @@ export default function Dashboard() {
|
||||
Processor Timeline
|
||||
<span className={styles.panelSectionMeta}>{formatDuration(detail.durationMs)}</span>
|
||||
</div>
|
||||
{procList.length ? (
|
||||
{flatProcs.length > 0 ? (
|
||||
<ProcessorTimeline
|
||||
processors={flattenProcessors(procList)}
|
||||
processors={flatProcs}
|
||||
totalMs={detail.durationMs}
|
||||
/>
|
||||
) : <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>}
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>
|
||||
)}
|
||||
</div>
|
||||
</DetailPanel>
|
||||
)}
|
||||
</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;
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/* Scrollable content area */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 40px;
|
||||
min-width: 0;
|
||||
background: var(--bg-body);
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 4rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
EXCHANGE HEADER CARD
|
||||
========================================================================== */
|
||||
.exchangeHeader {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
@@ -38,14 +56,14 @@
|
||||
}
|
||||
|
||||
.routeLink {
|
||||
color: var(--accent, #c6820e);
|
||||
color: var(--amber);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.routeLink:hover {
|
||||
color: var(--amber-deep, #a36b0b);
|
||||
color: var(--amber-deep);
|
||||
}
|
||||
|
||||
.headerDivider {
|
||||
@@ -78,7 +96,9 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Correlation Chain */
|
||||
/* ==========================================================================
|
||||
CORRELATION CHAIN
|
||||
========================================================================== */
|
||||
.correlationChain {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -104,7 +124,7 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-subtle);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
@@ -120,20 +140,37 @@
|
||||
}
|
||||
|
||||
.chainNodeCurrent {
|
||||
background: var(--amber-bg, rgba(198, 130, 14, 0.08));
|
||||
border-color: var(--accent, #c6820e);
|
||||
color: var(--accent, #c6820e);
|
||||
background: var(--amber-bg);
|
||||
border-color: var(--amber-light);
|
||||
color: var(--amber-deep);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chainNodeSuccess { border-left: 3px solid var(--success); }
|
||||
.chainNodeError { border-left: 3px solid var(--error); }
|
||||
.chainNodeRunning { border-left: 3px solid var(--running); }
|
||||
.chainNodeWarning { border-left: 3px solid var(--warning); }
|
||||
.chainNodeSuccess {
|
||||
border-left: 3px solid var(--success);
|
||||
}
|
||||
|
||||
.chainMore { color: var(--text-muted); font-size: 11px; font-style: italic; }
|
||||
.chainNodeError {
|
||||
border-left: 3px solid var(--error);
|
||||
}
|
||||
|
||||
/* Timeline Section */
|
||||
.chainNodeRunning {
|
||||
border-left: 3px solid var(--running);
|
||||
}
|
||||
|
||||
.chainNodeWarning {
|
||||
border-left: 3px solid var(--warning);
|
||||
}
|
||||
|
||||
.chainMore {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
TIMELINE SECTION
|
||||
========================================================================== */
|
||||
.timelineSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
@@ -174,7 +211,7 @@
|
||||
display: inline-flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -194,20 +231,22 @@
|
||||
}
|
||||
|
||||
.toggleBtnActive {
|
||||
background: var(--accent, #c6820e);
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toggleBtnActive:hover {
|
||||
background: var(--amber-deep, #a36b0b);
|
||||
background: var(--amber-deep);
|
||||
}
|
||||
|
||||
.timelineBody {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Detail Split (IN / OUT panels) */
|
||||
/* ==========================================================================
|
||||
DETAIL SPLIT (IN / OUT panels)
|
||||
========================================================================== */
|
||||
.detailSplit {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -224,7 +263,7 @@
|
||||
}
|
||||
|
||||
.detailPanelError {
|
||||
border-color: var(--error-border, rgba(220, 38, 38, 0.3));
|
||||
border-color: var(--error-border);
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
@@ -238,8 +277,8 @@
|
||||
}
|
||||
|
||||
.detailPanelError .panelHeader {
|
||||
background: var(--error-bg, rgba(220, 38, 38, 0.06));
|
||||
border-bottom-color: var(--error-border, rgba(220, 38, 38, 0.3));
|
||||
background: var(--error-bg);
|
||||
border-bottom-color: var(--error-border);
|
||||
}
|
||||
|
||||
.panelTitle {
|
||||
@@ -350,14 +389,33 @@
|
||||
}
|
||||
|
||||
/* Error panel styles */
|
||||
.errorBadgeRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.errorHttpBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: var(--error-bg);
|
||||
color: var(--error);
|
||||
border: 1px solid var(--error-border);
|
||||
}
|
||||
|
||||
.errorMessageBox {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--error-bg, rgba(220, 38, 38, 0.06));
|
||||
background: var(--error-bg);
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border: 1px solid var(--error-border, rgba(220, 38, 38, 0.3));
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--error-border);
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
@@ -382,3 +440,11 @@
|
||||
font-family: var(--font-mono);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Snapshot loading */
|
||||
.snapshotLoading {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@@ -1,112 +1,187 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router'
|
||||
import {
|
||||
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
|
||||
ProcessorTimeline, Breadcrumb, Spinner, RouteFlow,
|
||||
} from '@cameleer/design-system';
|
||||
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
|
||||
import { useCorrelationChain } from '../../api/queries/correlation';
|
||||
import { useDiagramLayout } from '../../api/queries/diagrams';
|
||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
||||
import styles from './ExchangeDetail.module.css';
|
||||
} from '@cameleer/design-system'
|
||||
import type { ProcessorStep, RouteNode } from '@cameleer/design-system'
|
||||
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'
|
||||
import { useCorrelationChain } from '../../api/queries/correlation'
|
||||
import { useDiagramLayout } from '../../api/queries/diagrams'
|
||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'
|
||||
import styles from './ExchangeDetail.module.css'
|
||||
|
||||
function countProcessors(nodes: any[]): number {
|
||||
return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0);
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`
|
||||
return `${ms}ms`
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
|
||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
|
||||
return `${ms}ms`;
|
||||
function backendStatusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
|
||||
switch (status.toUpperCase()) {
|
||||
case 'COMPLETED': return 'success'
|
||||
case 'FAILED': return 'error'
|
||||
case 'RUNNING': return 'running'
|
||||
default: return 'warning'
|
||||
}
|
||||
}
|
||||
|
||||
function backendStatusToLabel(status: string): string {
|
||||
return status.toUpperCase()
|
||||
}
|
||||
|
||||
function procStatusToStep(status: string): 'ok' | 'slow' | 'fail' {
|
||||
const s = status.toUpperCase()
|
||||
if (s === 'FAILED') return 'fail'
|
||||
if (s === 'RUNNING') return 'slow'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
function parseHeaders(raw: string | undefined | null): Record<string, string> {
|
||||
if (!raw) return {};
|
||||
if (!raw) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const parsed = JSON.parse(raw)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
const result: Record<string, string> = {};
|
||||
const result: Record<string, string> = {}
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
result[k] = typeof v === 'string' ? v : JSON.stringify(v);
|
||||
result[k] = typeof v === 'string' ? v : JSON.stringify(v)
|
||||
}
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return {};
|
||||
return {}
|
||||
}
|
||||
|
||||
function countProcessors(nodes: Array<{ children?: any[] }>): number {
|
||||
return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0)
|
||||
}
|
||||
|
||||
// ── ExchangeDetail ───────────────────────────────────────────────────────────
|
||||
export default function ExchangeDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { data: detail, isLoading } = useExecutionDetail(id ?? null);
|
||||
const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt');
|
||||
const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null);
|
||||
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null);
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const procList = detail ? (detail.processors?.length ? detail.processors : (detail.children ?? [])) : [];
|
||||
const { data: detail, isLoading } = useExecutionDetail(id ?? null)
|
||||
const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null)
|
||||
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null)
|
||||
|
||||
// Auto-select first failed processor, or 0
|
||||
const defaultIndex = useMemo(() => {
|
||||
if (!procList.length) return 0;
|
||||
const failIdx = procList.findIndex((p: any) =>
|
||||
(p.status || '').toUpperCase() === 'FAILED' || p.status === 'fail'
|
||||
);
|
||||
return failIdx >= 0 ? failIdx : 0;
|
||||
}, [procList]);
|
||||
const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt')
|
||||
|
||||
const [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number | null>(null);
|
||||
const activeIndex = selectedProcessorIndex ?? defaultIndex;
|
||||
const procList = detail
|
||||
? (detail.processors?.length ? detail.processors : (detail.children ?? []))
|
||||
: []
|
||||
|
||||
const { data: snapshot } = useProcessorSnapshot(id ?? null, procList.length > 0 ? activeIndex : null);
|
||||
|
||||
const processors = useMemo(() => {
|
||||
if (!procList.length) return [];
|
||||
const result: any[] = [];
|
||||
let offset = 0;
|
||||
// Flatten processor tree into ProcessorStep[]
|
||||
const processors: ProcessorStep[] = useMemo(() => {
|
||||
if (!procList.length) return []
|
||||
const result: ProcessorStep[] = []
|
||||
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',
|
||||
status: procStatusToStep(node.status ?? ''),
|
||||
startMs: offset,
|
||||
});
|
||||
offset += node.durationMs ?? 0;
|
||||
if (node.children) node.children.forEach(walk);
|
||||
})
|
||||
offset += node.durationMs ?? 0
|
||||
if (node.children) node.children.forEach(walk)
|
||||
}
|
||||
procList.forEach(walk);
|
||||
return result;
|
||||
}, [procList]);
|
||||
procList.forEach(walk)
|
||||
return result
|
||||
}, [procList])
|
||||
|
||||
const selectedProc = processors[activeIndex];
|
||||
const isSelectedFailed = selectedProc?.status === 'fail';
|
||||
// Default selected processor: first failed, or 0
|
||||
const defaultIndex = useMemo(() => {
|
||||
if (!processors.length) return 0
|
||||
const failIdx = processors.findIndex((p) => p.status === 'fail')
|
||||
return failIdx >= 0 ? failIdx : 0
|
||||
}, [processors])
|
||||
|
||||
// Parse snapshot headers
|
||||
const inputHeaders = parseHeaders(snapshot?.inputHeaders);
|
||||
const outputHeaders = parseHeaders(snapshot?.outputHeaders);
|
||||
const inputBody = snapshot?.inputBody ?? null;
|
||||
const outputBody = snapshot?.outputBody ?? null;
|
||||
const [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number | null>(null)
|
||||
const activeIndex = selectedProcessorIndex ?? defaultIndex
|
||||
|
||||
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>;
|
||||
const { data: snapshot } = useProcessorSnapshot(
|
||||
id ?? null,
|
||||
procList.length > 0 ? activeIndex : null,
|
||||
)
|
||||
|
||||
const selectedProc = processors[activeIndex]
|
||||
const isSelectedFailed = selectedProc?.status === 'fail'
|
||||
|
||||
// Parse snapshot data
|
||||
const inputHeaders = parseHeaders(snapshot?.inputHeaders)
|
||||
const outputHeaders = parseHeaders(snapshot?.outputHeaders)
|
||||
const inputBody = snapshot?.inputBody ?? null
|
||||
const outputBody = snapshot?.outputBody ?? null
|
||||
|
||||
// Build RouteFlow nodes from diagram + execution data
|
||||
const routeNodes: RouteNode[] = useMemo(() => {
|
||||
if (diagram?.nodes) {
|
||||
return mapDiagramToRouteNodes(diagram.nodes, procList)
|
||||
}
|
||||
// Fallback: build from processor list
|
||||
return processors.map((p) => ({
|
||||
name: p.name,
|
||||
type: 'process' as RouteNode['type'],
|
||||
durationMs: p.durationMs,
|
||||
status: p.status,
|
||||
}))
|
||||
}, [diagram, processors, procList])
|
||||
|
||||
// Correlation chain
|
||||
const correlatedExchanges = useMemo(() => {
|
||||
if (!correlationData?.data || correlationData.data.length <= 1) return []
|
||||
return correlationData.data
|
||||
}, [correlationData])
|
||||
|
||||
// ── Loading state ────────────────────────────────────────────────────────
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={styles.loadingContainer}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Not found state ──────────────────────────────────────────────────────
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<Breadcrumb items={[
|
||||
{ label: 'Applications', href: '/apps' },
|
||||
{ label: 'Exchanges' },
|
||||
{ label: id ?? 'Unknown' },
|
||||
]} />
|
||||
<InfoCallout variant="warning">Exchange "{id}" not found.</InfoCallout>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statusVariant = backendStatusToVariant(detail.status)
|
||||
const statusLabel = backendStatusToLabel(detail.status)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.content}>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumb items={[
|
||||
{ label: 'Dashboard', href: '/apps' },
|
||||
{ label: 'Applications', href: '/apps' },
|
||||
{ label: detail.applicationName || 'App', href: `/apps/${detail.applicationName}` },
|
||||
{ label: id?.slice(0, 12) || '' },
|
||||
{ label: detail.routeId, href: `/apps/${detail.applicationName}/${detail.routeId}` },
|
||||
{ label: detail.executionId?.slice(0, 12) || '' },
|
||||
]} />
|
||||
|
||||
{/* Exchange header card */}
|
||||
<div className={styles.exchangeHeader}>
|
||||
<div className={styles.headerRow}>
|
||||
<div className={styles.headerLeft}>
|
||||
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : detail.status === 'FAILED' ? 'error' : 'running'} />
|
||||
<StatusDot variant={statusVariant} />
|
||||
<div>
|
||||
<div className={styles.exchangeId}>
|
||||
<MonoText size="md">{id}</MonoText>
|
||||
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} variant="filled" />
|
||||
<MonoText size="md">{detail.executionId}</MonoText>
|
||||
<Badge label={statusLabel} color={statusVariant} variant="filled" />
|
||||
</div>
|
||||
<div className={styles.exchangeRoute}>
|
||||
Route: <span className={styles.routeLink} onClick={() => navigate(`/apps/${detail.applicationName}/${detail.routeId}`)}>{detail.routeId}</span>
|
||||
@@ -116,6 +191,12 @@ export default function ExchangeDetail() {
|
||||
App: <MonoText size="xs">{detail.applicationName}</MonoText>
|
||||
</>
|
||||
)}
|
||||
{detail.correlationId && (
|
||||
<>
|
||||
<span className={styles.headerDivider}>·</span>
|
||||
Correlation: <MonoText size="xs">{detail.correlationId}</MonoText>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,7 +212,9 @@ export default function ExchangeDetail() {
|
||||
<div className={styles.headerStat}>
|
||||
<div className={styles.headerStatLabel}>Started</div>
|
||||
<div className={styles.headerStatValue}>
|
||||
{detail.startTime ? new Date(detail.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '—'}
|
||||
{detail.startTime
|
||||
? new Date(detail.startTime).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
: '\u2014'}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerStat}>
|
||||
@@ -142,43 +225,39 @@ export default function ExchangeDetail() {
|
||||
</div>
|
||||
|
||||
{/* Correlation Chain */}
|
||||
{correlationData?.data && correlationData.data.length > 1 && (
|
||||
{correlatedExchanges.length > 1 && (
|
||||
<div className={styles.correlationChain}>
|
||||
<span className={styles.chainLabel}>Correlated Exchanges</span>
|
||||
{correlationData.data.map((exec: any) => {
|
||||
const isCurrent = exec.executionId === id;
|
||||
const variant = exec.status === 'COMPLETED' ? 'success' : exec.status === 'FAILED' ? 'error' : 'running';
|
||||
{correlatedExchanges.map((ce) => {
|
||||
const isCurrent = ce.executionId === id
|
||||
const variant = backendStatusToVariant(ce.status)
|
||||
const statusCls =
|
||||
variant === 'success' ? styles.chainNodeSuccess
|
||||
: variant === 'error' ? styles.chainNodeError
|
||||
: styles.chainNodeRunning;
|
||||
: variant === 'running' ? styles.chainNodeRunning
|
||||
: styles.chainNodeWarning
|
||||
return (
|
||||
<button
|
||||
key={exec.executionId}
|
||||
key={ce.executionId}
|
||||
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
|
||||
onClick={() => { if (!isCurrent) navigate(`/exchanges/${exec.executionId}`); }}
|
||||
title={`${exec.executionId} — ${exec.routeId}`}
|
||||
onClick={() => {
|
||||
if (!isCurrent) navigate(`/exchanges/${ce.executionId}`)
|
||||
}}
|
||||
title={`${ce.executionId} \u2014 ${ce.routeId}`}
|
||||
>
|
||||
<StatusDot variant={variant as any} />
|
||||
<span>{exec.routeId}</span>
|
||||
<StatusDot variant={variant} />
|
||||
<span>{ce.routeId}</span>
|
||||
</button>
|
||||
);
|
||||
)
|
||||
})}
|
||||
{correlationData.total > 20 && (
|
||||
{correlationData && correlationData.total > 20 && (
|
||||
<span className={styles.chainMore}>+{correlationData.total - 20} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error callout */}
|
||||
{detail.errorMessage && (
|
||||
<InfoCallout variant="error">
|
||||
{detail.errorMessage}
|
||||
</InfoCallout>
|
||||
)}
|
||||
|
||||
{/* Processor Timeline / Flow Section */}
|
||||
{/* Processor Timeline Section */}
|
||||
<div className={styles.timelineSection}>
|
||||
<div className={styles.timelineHeader}>
|
||||
<span className={styles.timelineTitle}>
|
||||
@@ -206,17 +285,17 @@ export default function ExchangeDetail() {
|
||||
<ProcessorTimeline
|
||||
processors={processors}
|
||||
totalMs={detail.durationMs}
|
||||
onProcessorClick={(_p, i) => setSelectedProcessorIndex(i)}
|
||||
onProcessorClick={(_proc, index) => setSelectedProcessorIndex(index)}
|
||||
selectedIndex={activeIndex}
|
||||
/>
|
||||
) : (
|
||||
<InfoCallout>No processor data available</InfoCallout>
|
||||
)
|
||||
) : (
|
||||
diagram ? (
|
||||
routeNodes.length > 0 ? (
|
||||
<RouteFlow
|
||||
nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
|
||||
onNodeClick={(_node, i) => setSelectedProcessorIndex(i)}
|
||||
nodes={routeNodes}
|
||||
onNodeClick={(_node, index) => setSelectedProcessorIndex(index)}
|
||||
selectedIndex={activeIndex}
|
||||
/>
|
||||
) : (
|
||||
@@ -226,7 +305,7 @@ export default function ExchangeDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processor Detail: Message IN / Message OUT or Error */}
|
||||
{/* Processor Detail Panel (split IN / OUT) */}
|
||||
{selectedProc && snapshot && (
|
||||
<div className={styles.detailSplit}>
|
||||
{/* Message IN */}
|
||||
@@ -255,7 +334,7 @@ export default function ExchangeDetail() {
|
||||
)}
|
||||
<div className={styles.bodySection}>
|
||||
<div className={styles.sectionLabel}>Body</div>
|
||||
<CodeBlock content={inputBody ?? 'null'} />
|
||||
<CodeBlock content={inputBody ?? 'null'} language="json" copyable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,7 +388,7 @@ export default function ExchangeDetail() {
|
||||
)}
|
||||
<div className={styles.bodySection}>
|
||||
<div className={styles.sectionLabel}>Body</div>
|
||||
<CodeBlock content={outputBody ?? 'null'} />
|
||||
<CodeBlock content={outputBody ?? 'null'} language="json" copyable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -317,12 +396,13 @@ export default function ExchangeDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No snapshot loaded yet - show prompt */}
|
||||
{/* Snapshot loading indicator */}
|
||||
{selectedProc && !snapshot && procList.length > 0 && (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', padding: 20 }}>
|
||||
<div className={styles.snapshotLoading}>
|
||||
Loading exchange snapshot...
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,39 +1,288 @@
|
||||
/* Back link */
|
||||
.backLink {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
margin-bottom: 12px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.backLink:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Route header card */
|
||||
.headerCard {
|
||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-card);
|
||||
padding: 16px; margin-bottom: 16px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.headerRow { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
|
||||
.headerLeft { display: flex; align-items: center; gap: 12px; }
|
||||
.headerRight { display: flex; gap: 20px; }
|
||||
.headerStat { text-align: center; }
|
||||
.headerStatLabel { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 2px; }
|
||||
.headerStatValue { font-size: 14px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); }
|
||||
.diagramStatsGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
|
||||
.diagramPane, .statsPane {
|
||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); padding: 16px; overflow: hidden;
|
||||
|
||||
.headerRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.paneTitle { font-size: 13px; font-weight: 700; color: var(--text-primary); margin-bottom: 12px; }
|
||||
.tabSection { margin-top: 20px; }
|
||||
.chartGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.headerStat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.headerStatLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.headerStatValue {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Diagram + Stats side-by-side */
|
||||
.diagramStatsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.diagramPane,
|
||||
.statsPane {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.paneTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Processor type badges */
|
||||
.processorType {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.typeConsumer {
|
||||
background: var(--running-bg);
|
||||
color: var(--running);
|
||||
}
|
||||
|
||||
.typeProducer {
|
||||
background: var(--success-bg);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.typeEnricher {
|
||||
background: var(--amber-bg);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.typeValidator {
|
||||
background: var(--running-bg);
|
||||
color: var(--running);
|
||||
}
|
||||
|
||||
.typeTransformer {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.typeRouter {
|
||||
background: var(--purple-bg);
|
||||
color: var(--purple);
|
||||
}
|
||||
|
||||
.typeProcessor {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Tabs section */
|
||||
.tabSection {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
/* Rate color classes */
|
||||
.rateGood {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.rateWarn {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.rateBad {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.rateNeutral {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Route name in table */
|
||||
.routeNameCell {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Table section (reused for processor table) */
|
||||
.tableSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.tableTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tableRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tableMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Chart grid */
|
||||
.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-lg); box-shadow: var(--shadow-card); padding: 16px; overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chartTitle { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); margin-bottom: 12px; }
|
||||
|
||||
.chartTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Executions table */
|
||||
.executionsTable {
|
||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
.errorPatterns { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
/* Error patterns */
|
||||
.errorPatterns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.errorRow {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 10px 12px; background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg); font-size: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
flex: 1;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.errorCount {
|
||||
font-weight: 700;
|
||||
color: var(--error);
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.errorTime {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Route flow section */
|
||||
.routeFlowSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Empty / muted text */
|
||||
.emptyText {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.errorMessage { flex: 1; font-family: var(--font-mono); color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 400px; }
|
||||
.errorCount { font-weight: 700; color: var(--error); margin: 0 12px; }
|
||||
.errorTime { color: var(--text-muted); font-size: 11px; }
|
||||
.backLink { font-size: 13px; color: var(--text-muted); text-decoration: none; margin-bottom: 12px; display: inline-block; }
|
||||
.backLink:hover { color: var(--text-primary); }
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router';
|
||||
import {
|
||||
Badge, StatusDot, DataTable, Tabs,
|
||||
AreaChart, LineChart, BarChart, RouteFlow, Spinner,
|
||||
KpiStrip,
|
||||
Badge,
|
||||
StatusDot,
|
||||
DataTable,
|
||||
Tabs,
|
||||
AreaChart,
|
||||
LineChart,
|
||||
BarChart,
|
||||
RouteFlow,
|
||||
Spinner,
|
||||
MonoText,
|
||||
Sparkline,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import type { KpiItem, Column } from '@cameleer/design-system';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
||||
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
|
||||
import { useStatsTimeseries, useSearchExecutions } from '../../api/queries/executions';
|
||||
import { useStatsTimeseries, useSearchExecutions, useExecutionStats } from '../../api/queries/executions';
|
||||
import type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types';
|
||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
||||
import styles from './RouteDetail.module.css';
|
||||
|
||||
// ── Row types ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ExchangeRow extends ExecutionSummary {
|
||||
id: string;
|
||||
}
|
||||
@@ -26,6 +37,8 @@ interface ProcessorRow {
|
||||
avgDurationMs: number;
|
||||
p99DurationMs: number;
|
||||
errorCount: number;
|
||||
errorRate: number;
|
||||
sparkline: number[];
|
||||
}
|
||||
|
||||
interface ErrorPattern {
|
||||
@@ -34,6 +47,211 @@ interface ErrorPattern {
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
// ── Processor type badge classes ─────────────────────────────────────────────
|
||||
|
||||
const TYPE_STYLE_MAP: Record<string, string> = {
|
||||
consumer: styles.typeConsumer,
|
||||
producer: styles.typeProducer,
|
||||
enricher: styles.typeEnricher,
|
||||
validator: styles.typeValidator,
|
||||
transformer: styles.typeTransformer,
|
||||
router: styles.typeRouter,
|
||||
processor: styles.typeProcessor,
|
||||
};
|
||||
|
||||
function classifyProcessorType(processorId: string): string {
|
||||
const lower = processorId.toLowerCase();
|
||||
if (lower.startsWith('from(') || lower.includes('consumer')) return 'consumer';
|
||||
if (lower.startsWith('to(')) return 'producer';
|
||||
if (lower.includes('enrich')) return 'enricher';
|
||||
if (lower.includes('validate') || lower.includes('check')) return 'validator';
|
||||
if (lower.includes('unmarshal') || lower.includes('marshal')) return 'transformer';
|
||||
if (lower.includes('route') || lower.includes('choice')) return 'router';
|
||||
return 'processor';
|
||||
}
|
||||
|
||||
// ── Processor table columns ──────────────────────────────────────────────────
|
||||
|
||||
function makeProcessorColumns(css: typeof styles): Column<ProcessorRow>[] {
|
||||
return [
|
||||
{
|
||||
key: 'processorId',
|
||||
header: 'Processor',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<span className={css.routeNameCell}>{row.processorId}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'callCount',
|
||||
header: 'Invocations',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{row.callCount.toLocaleString()}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'avgDurationMs',
|
||||
header: 'Avg Duration',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const cls = row.avgDurationMs > 200 ? css.rateBad : row.avgDurationMs > 100 ? css.rateWarn : css.rateGood;
|
||||
return <MonoText size="sm" className={cls}>{Math.round(row.avgDurationMs)}ms</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'p99DurationMs',
|
||||
header: 'p99 Duration',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const cls = row.p99DurationMs > 300 ? css.rateBad : row.p99DurationMs > 200 ? css.rateWarn : css.rateGood;
|
||||
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'errorCount',
|
||||
header: 'Errors',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm" className={row.errorCount > 10 ? css.rateBad : css.rateNeutral}>
|
||||
{row.errorCount}
|
||||
</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'errorRate',
|
||||
header: 'Error Rate',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const cls = row.errorRate > 1 ? css.rateBad : row.errorRate > 0.5 ? css.rateWarn : css.rateGood;
|
||||
return <MonoText size="sm" className={cls}>{row.errorRate.toFixed(2)}%</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sparkline',
|
||||
header: 'Trend',
|
||||
render: (_, row) => (
|
||||
<Sparkline data={row.sparkline} width={80} height={24} />
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Exchange table columns ───────────────────────────────────────────────────
|
||||
|
||||
const EXCHANGE_COLUMNS: Column<ExchangeRow>[] = [
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: '80px',
|
||||
render: (_, row) => (
|
||||
<StatusDot variant={row.status === 'COMPLETED' ? 'success' : row.status === 'FAILED' ? 'error' : 'running'} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'executionId',
|
||||
header: 'Exchange ID',
|
||||
render: (_, row) => <MonoText size="xs">{row.executionId.slice(0, 12)}</MonoText>,
|
||||
},
|
||||
{
|
||||
key: 'startTime',
|
||||
header: 'Started',
|
||||
sortable: true,
|
||||
render: (_, row) => new Date(row.startTime).toLocaleTimeString(),
|
||||
},
|
||||
{
|
||||
key: 'durationMs',
|
||||
header: 'Duration',
|
||||
sortable: true,
|
||||
render: (_, row) => `${row.durationMs}ms`,
|
||||
},
|
||||
];
|
||||
|
||||
// ── Build KPI items ──────────────────────────────────────────────────────────
|
||||
|
||||
function buildDetailKpiItems(
|
||||
stats: {
|
||||
totalCount: number;
|
||||
failedCount: number;
|
||||
avgDurationMs: number;
|
||||
p99LatencyMs: number;
|
||||
activeCount: number;
|
||||
prevTotalCount: number;
|
||||
prevFailedCount: number;
|
||||
prevP99LatencyMs: number;
|
||||
} | undefined,
|
||||
throughputSparkline: number[],
|
||||
errorSparkline: number[],
|
||||
latencySparkline: number[],
|
||||
): KpiItem[] {
|
||||
const totalCount = stats?.totalCount ?? 0;
|
||||
const failedCount = stats?.failedCount ?? 0;
|
||||
const prevTotalCount = stats?.prevTotalCount ?? 0;
|
||||
const p99Ms = stats?.p99LatencyMs ?? 0;
|
||||
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
|
||||
const avgMs = stats?.avgDurationMs ?? 0;
|
||||
const activeCount = stats?.activeCount ?? 0;
|
||||
|
||||
const errorRate = totalCount > 0 ? (failedCount / totalCount) * 100 : 0;
|
||||
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount) * 100 : 100;
|
||||
|
||||
const throughputPctChange = prevTotalCount > 0
|
||||
? Math.round(((totalCount - prevTotalCount) / prevTotalCount) * 100)
|
||||
: 0;
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Total Throughput',
|
||||
value: totalCount.toLocaleString(),
|
||||
trend: {
|
||||
label: throughputPctChange >= 0 ? `\u25B2 +${throughputPctChange}%` : `\u25BC ${throughputPctChange}%`,
|
||||
variant: throughputPctChange >= 0 ? 'success' as const : 'error' as const,
|
||||
},
|
||||
subtitle: `${activeCount} in-flight`,
|
||||
sparkline: throughputSparkline,
|
||||
borderColor: 'var(--amber)',
|
||||
},
|
||||
{
|
||||
label: 'System Error Rate',
|
||||
value: `${errorRate.toFixed(2)}%`,
|
||||
trend: {
|
||||
label: errorRate < 1 ? '\u25BC low' : `\u25B2 ${errorRate.toFixed(1)}%`,
|
||||
variant: errorRate < 1 ? 'success' as const : 'error' as const,
|
||||
},
|
||||
subtitle: `${failedCount} errors / ${totalCount.toLocaleString()} total`,
|
||||
sparkline: errorSparkline,
|
||||
borderColor: errorRate < 1 ? 'var(--success)' : 'var(--error)',
|
||||
},
|
||||
{
|
||||
label: 'Latency P99',
|
||||
value: `${p99Ms}ms`,
|
||||
trend: {
|
||||
label: p99Ms > prevP99Ms ? `\u25B2 +${p99Ms - prevP99Ms}ms` : `\u25BC ${prevP99Ms - p99Ms}ms`,
|
||||
variant: p99Ms > 300 ? 'error' as const : 'warning' as const,
|
||||
},
|
||||
subtitle: `Avg ${avgMs}ms \u00B7 SLA <300ms`,
|
||||
sparkline: latencySparkline,
|
||||
borderColor: p99Ms > 300 ? 'var(--warning)' : 'var(--success)',
|
||||
},
|
||||
{
|
||||
label: 'Success Rate',
|
||||
value: `${successRate.toFixed(1)}%`,
|
||||
trend: { label: '\u2194', variant: 'muted' as const },
|
||||
subtitle: `${totalCount - failedCount} ok / ${failedCount} failed`,
|
||||
borderColor: 'var(--success)',
|
||||
},
|
||||
{
|
||||
label: 'In-Flight',
|
||||
value: String(activeCount),
|
||||
trend: { label: '\u2194', variant: 'muted' as const },
|
||||
subtitle: `${activeCount} active exchanges`,
|
||||
borderColor: 'var(--amber)',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function RouteDetail() {
|
||||
const { appId, routeId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -43,9 +261,11 @@ export default function RouteDetail() {
|
||||
|
||||
const [activeTab, setActiveTab] = useState('performance');
|
||||
|
||||
// ── API queries ────────────────────────────────────────────────────────────
|
||||
const { data: catalog } = useRouteCatalog();
|
||||
const { data: diagram } = useDiagramByRoute(appId, routeId);
|
||||
const { data: processorMetrics, isLoading: processorLoading } = useProcessorMetrics(routeId ?? null, appId);
|
||||
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
|
||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
|
||||
const { data: recentResult, isLoading: recentLoading } = useSearchExecutions({
|
||||
timeFrom,
|
||||
@@ -65,6 +285,8 @@ export default function RouteDetail() {
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
// ── Derived data ───────────────────────────────────────────────────────────
|
||||
|
||||
const appEntry: AppCatalogEntry | undefined = useMemo(() =>
|
||||
(catalog || []).find((e: AppCatalogEntry) => e.appId === appId),
|
||||
[catalog, appId],
|
||||
@@ -79,7 +301,7 @@ export default function RouteDetail() {
|
||||
const exchangeCount = routeSummary?.exchangeCount ?? 0;
|
||||
const lastSeen = routeSummary?.lastSeen
|
||||
? new Date(routeSummary.lastSeen).toLocaleString()
|
||||
: '—';
|
||||
: '\u2014';
|
||||
|
||||
const healthVariant = useMemo((): 'success' | 'warning' | 'error' | 'dead' => {
|
||||
const h = health.toLowerCase();
|
||||
@@ -89,39 +311,70 @@ export default function RouteDetail() {
|
||||
return 'dead';
|
||||
}, [health]);
|
||||
|
||||
// Route flow from diagram
|
||||
const diagramNodes = useMemo(() => {
|
||||
if (!diagram?.nodes) return [];
|
||||
return mapDiagramToRouteNodes(diagram.nodes, []);
|
||||
}, [diagram]);
|
||||
|
||||
// Processor table rows
|
||||
const processorRows: ProcessorRow[] = useMemo(() =>
|
||||
(processorMetrics || []).map((p: any) => ({
|
||||
id: p.processorId,
|
||||
processorId: p.processorId,
|
||||
callCount: p.callCount ?? 0,
|
||||
avgDurationMs: p.avgDurationMs ?? 0,
|
||||
p99DurationMs: p.p99DurationMs ?? 0,
|
||||
errorCount: p.errorCount ?? 0,
|
||||
})),
|
||||
(processorMetrics || []).map((p: any) => {
|
||||
const callCount = p.callCount ?? 0;
|
||||
const errorCount = p.errorCount ?? 0;
|
||||
const errRate = callCount > 0 ? (errorCount / callCount) * 100 : 0;
|
||||
return {
|
||||
id: p.processorId,
|
||||
processorId: p.processorId,
|
||||
type: classifyProcessorType(p.processorId ?? ''),
|
||||
callCount,
|
||||
avgDurationMs: p.avgDurationMs ?? 0,
|
||||
p99DurationMs: p.p99DurationMs ?? 0,
|
||||
errorCount,
|
||||
errorRate: Number(errRate.toFixed(2)),
|
||||
sparkline: p.sparkline ?? [],
|
||||
};
|
||||
}),
|
||||
[processorMetrics],
|
||||
);
|
||||
|
||||
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-derived data
|
||||
const throughputSparkline = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b) => b.totalCount),
|
||||
[timeseries],
|
||||
);
|
||||
const errorSparkline = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b) => b.failedCount),
|
||||
[timeseries],
|
||||
);
|
||||
const latencySparkline = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b) => b.p99DurationMs),
|
||||
[timeseries],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b) => {
|
||||
const ts = new Date(b.time);
|
||||
return {
|
||||
time: !isNaN(ts.getTime())
|
||||
? ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: '\u2014',
|
||||
throughput: b.totalCount,
|
||||
latency: b.avgDurationMs,
|
||||
errors: b.failedCount,
|
||||
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
|
||||
};
|
||||
}),
|
||||
[timeseries],
|
||||
);
|
||||
|
||||
// Exchange rows
|
||||
const exchangeRows: ExchangeRow[] = useMemo(() =>
|
||||
(recentResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
||||
[recentResult],
|
||||
);
|
||||
|
||||
// Error patterns
|
||||
const errorPatterns: ErrorPattern[] = useMemo(() => {
|
||||
const failed = (errorResult?.data || []) as ExecutionSummary[];
|
||||
const grouped = new Map<string, { count: number; lastSeen: string }>();
|
||||
@@ -141,31 +394,18 @@ export default function RouteDetail() {
|
||||
.map(([message, { count, lastSeen: ls }]) => ({
|
||||
message,
|
||||
count,
|
||||
lastSeen: ls ? new Date(ls).toLocaleString() : '—',
|
||||
lastSeen: ls ? new Date(ls).toLocaleString() : '\u2014',
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}, [errorResult]);
|
||||
|
||||
const processorColumns: Column<ProcessorRow>[] = [
|
||||
{ key: 'processorId', header: 'Processor', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
|
||||
{ key: 'callCount', header: 'Calls', sortable: true },
|
||||
{ key: 'avgDurationMs', header: 'Avg', sortable: true, render: (v) => `${(v as number).toFixed(1)}ms` },
|
||||
{ key: 'p99DurationMs', header: 'P99', sortable: true, render: (v) => `${(v as number).toFixed(1)}ms` },
|
||||
{ key: 'errorCount', header: 'Errors', sortable: true, render: (v) => {
|
||||
const n = v as number;
|
||||
return n > 0 ? <span style={{ color: 'var(--error)', fontWeight: 700 }}>{n}</span> : <span>0</span>;
|
||||
}},
|
||||
];
|
||||
// KPI items
|
||||
const kpiItems = useMemo(() =>
|
||||
buildDetailKpiItems(stats, throughputSparkline, errorSparkline, latencySparkline),
|
||||
[stats, throughputSparkline, errorSparkline, latencySparkline],
|
||||
);
|
||||
|
||||
const exchangeColumns: Column<ExchangeRow>[] = [
|
||||
{
|
||||
key: 'status', header: 'Status', width: '80px',
|
||||
render: (v) => <StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />,
|
||||
},
|
||||
{ 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 processorColumns = useMemo(() => makeProcessorColumns(styles), []);
|
||||
|
||||
const tabs = [
|
||||
{ label: 'Performance', value: 'performance' },
|
||||
@@ -173,12 +413,15 @@ export default function RouteDetail() {
|
||||
{ label: 'Error Patterns', value: 'errors', count: errorPatterns.length },
|
||||
];
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link to={`/routes/${appId}`} className={styles.backLink}>
|
||||
← {appId} routes
|
||||
← {appId} routes
|
||||
</Link>
|
||||
|
||||
{/* Route header card */}
|
||||
<div className={styles.headerCard}>
|
||||
<div className={styles.headerRow}>
|
||||
<div className={styles.headerLeft}>
|
||||
@@ -199,13 +442,17 @@ export default function RouteDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI strip */}
|
||||
<KpiStrip items={kpiItems} />
|
||||
|
||||
{/* Diagram + Processor Stats grid */}
|
||||
<div className={styles.diagramStatsGrid}>
|
||||
<div className={styles.diagramPane}>
|
||||
<div className={styles.paneTitle}>Route Diagram</div>
|
||||
{diagramNodes.length > 0 ? (
|
||||
<RouteFlow nodes={diagramNodes} />
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
|
||||
<div className={styles.emptyText}>
|
||||
No diagram available for this route.
|
||||
</div>
|
||||
)}
|
||||
@@ -217,13 +464,40 @@ export default function RouteDetail() {
|
||||
) : processorRows.length > 0 ? (
|
||||
<DataTable columns={processorColumns} data={processorRows} sortable pageSize={10} />
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
|
||||
<div className={styles.emptyText}>
|
||||
No processor data available.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processor Performance table (full width) */}
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Processor Performance</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>{processorRows.length} processors</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={processorColumns}
|
||||
data={processorRows}
|
||||
sortable
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Route Flow section */}
|
||||
{diagramNodes.length > 0 && (
|
||||
<div className={styles.routeFlowSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Route Flow</span>
|
||||
</div>
|
||||
<RouteFlow nodes={diagramNodes} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabbed section: Performance charts, Recent Executions, Error Patterns */}
|
||||
<div className={styles.tabSection}>
|
||||
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
||||
|
||||
@@ -232,28 +506,41 @@ export default function RouteDetail() {
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Throughput</div>
|
||||
<AreaChart
|
||||
series={[{ label: 'Throughput', data: chartData.map((d, i) => ({ x: i, y: d.throughput })) }]}
|
||||
series={[{
|
||||
label: 'Throughput',
|
||||
data: chartData.map((d, i) => ({ x: i, y: d.throughput })),
|
||||
}]}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Latency</div>
|
||||
<LineChart
|
||||
series={[{ label: 'Latency', data: chartData.map((d, i) => ({ x: i, y: d.latency })) }]}
|
||||
series={[{
|
||||
label: 'Latency',
|
||||
data: chartData.map((d, i) => ({ x: i, y: d.latency })),
|
||||
}]}
|
||||
height={200}
|
||||
threshold={{ value: 300, label: 'SLA 300ms' }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Errors</div>
|
||||
<BarChart
|
||||
series={[{ label: 'Errors', data: chartData.map((d) => ({ x: d.time, y: d.errors })) }]}
|
||||
series={[{
|
||||
label: 'Errors',
|
||||
data: chartData.map((d) => ({ x: d.time, y: d.errors })),
|
||||
}]}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Success Rate</div>
|
||||
<AreaChart
|
||||
series={[{ label: 'Success Rate', data: chartData.map((d, i) => ({ x: i, y: d.successRate })) }]}
|
||||
series={[{
|
||||
label: 'Success Rate',
|
||||
data: chartData.map((d, i) => ({ x: i, y: d.successRate })),
|
||||
}]}
|
||||
height={200}
|
||||
/>
|
||||
</div>
|
||||
@@ -268,7 +555,7 @@ export default function RouteDetail() {
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={exchangeColumns}
|
||||
columns={EXCHANGE_COLUMNS}
|
||||
data={exchangeRows}
|
||||
onRowClick={(row) => navigate(`/exchanges/${row.executionId}`)}
|
||||
sortable
|
||||
@@ -281,7 +568,7 @@ export default function RouteDetail() {
|
||||
{activeTab === 'errors' && (
|
||||
<div className={styles.errorPatterns} style={{ marginTop: 16 }}>
|
||||
{errorPatterns.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
|
||||
<div className={styles.emptyText}>
|
||||
No error patterns found in the selected time range.
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
.statStrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
/* Scrollable content area */
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.refreshIndicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.refreshDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 4px rgba(61, 124, 71, 0.5);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.refreshText {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Route performance table */
|
||||
.tableSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tableHeader {
|
||||
@@ -28,36 +55,56 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tableRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tableMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Route name in table */
|
||||
.routeNameCell {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Application column */
|
||||
.appCell {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Rate color classes */
|
||||
.rateGood {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.rateWarn {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.rateBad {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.rateNeutral {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* 2x2 chart grid */
|
||||
.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-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 16px;
|
||||
overflow: hidden;
|
||||
.chart {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rateGood { color: var(--success); }
|
||||
.rateWarn { color: var(--warning); }
|
||||
.rateBad { color: var(--error); }
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import {
|
||||
StatCard, Sparkline, MonoText, Badge,
|
||||
DataTable, AreaChart, LineChart, BarChart,
|
||||
KpiStrip,
|
||||
DataTable,
|
||||
AreaChart,
|
||||
LineChart,
|
||||
BarChart,
|
||||
Card,
|
||||
Sparkline,
|
||||
MonoText,
|
||||
Badge,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import type { KpiItem, Column } from '@cameleer/design-system';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
import { useRouteMetrics } from '../../api/queries/catalog';
|
||||
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
import type { RouteMetrics } from '../../api/types';
|
||||
import styles from './RoutesMetrics.module.css';
|
||||
|
||||
interface RouteRow {
|
||||
@@ -23,186 +31,322 @@ interface RouteRow {
|
||||
sparkline: number[];
|
||||
}
|
||||
|
||||
// ── Route table columns ──────────────────────────────────────────────────────
|
||||
|
||||
const ROUTE_COLUMNS: Column<RouteRow>[] = [
|
||||
{
|
||||
key: 'routeId',
|
||||
header: 'Route',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<span className={styles.routeNameCell}>{row.routeId}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'appId',
|
||||
header: 'Application',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<span className={styles.appCell}>{row.appId}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'exchangeCount',
|
||||
header: 'Exchanges',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{row.exchangeCount.toLocaleString()}</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'successRate',
|
||||
header: 'Success %',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const pct = row.successRate * 100;
|
||||
const cls = pct >= 99 ? styles.rateGood : pct >= 97 ? styles.rateWarn : styles.rateBad;
|
||||
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'avgDurationMs',
|
||||
header: 'Avg Duration',
|
||||
sortable: true,
|
||||
render: (_, row) => (
|
||||
<MonoText size="sm">{Math.round(row.avgDurationMs)}ms</MonoText>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'p99DurationMs',
|
||||
header: 'p99 Duration',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const cls = row.p99DurationMs > 300 ? styles.rateBad : row.p99DurationMs > 200 ? styles.rateWarn : styles.rateGood;
|
||||
return <MonoText size="sm" className={cls}>{Math.round(row.p99DurationMs)}ms</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'errorRate',
|
||||
header: 'Error Rate',
|
||||
sortable: true,
|
||||
render: (_, row) => {
|
||||
const pct = row.errorRate * 100;
|
||||
const cls = pct > 5 ? styles.rateBad : pct > 1 ? styles.rateWarn : styles.rateGood;
|
||||
return <MonoText size="sm" className={cls}>{pct.toFixed(1)}%</MonoText>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sparkline',
|
||||
header: 'Trend',
|
||||
render: (_, row) => (
|
||||
<Sparkline data={row.sparkline} width={80} height={24} />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// ── Build KPI items from backend stats ───────────────────────────────────────
|
||||
|
||||
function buildKpiItems(
|
||||
stats: {
|
||||
totalCount: number;
|
||||
failedCount: number;
|
||||
avgDurationMs: number;
|
||||
p99LatencyMs: number;
|
||||
activeCount: number;
|
||||
prevTotalCount: number;
|
||||
prevFailedCount: number;
|
||||
prevP99LatencyMs: number;
|
||||
} | undefined,
|
||||
routeCount: number,
|
||||
throughputSparkline: number[],
|
||||
errorSparkline: number[],
|
||||
): KpiItem[] {
|
||||
const totalCount = stats?.totalCount ?? 0;
|
||||
const failedCount = stats?.failedCount ?? 0;
|
||||
const prevTotalCount = stats?.prevTotalCount ?? 0;
|
||||
const p99Ms = stats?.p99LatencyMs ?? 0;
|
||||
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
|
||||
const avgMs = stats?.avgDurationMs ?? 0;
|
||||
const activeCount = stats?.activeCount ?? 0;
|
||||
|
||||
const errorRate = totalCount > 0 ? (failedCount / totalCount) * 100 : 0;
|
||||
|
||||
const throughputPctChange = prevTotalCount > 0
|
||||
? Math.round(((totalCount - prevTotalCount) / prevTotalCount) * 100)
|
||||
: 0;
|
||||
const throughputTrendLabel = throughputPctChange >= 0
|
||||
? `\u25B2 +${throughputPctChange}%`
|
||||
: `\u25BC ${throughputPctChange}%`;
|
||||
|
||||
const p50 = Math.round(avgMs * 0.5);
|
||||
const p95 = Math.round(avgMs * 1.4);
|
||||
const slaStatus = p99Ms > 300 ? 'BREACH' : 'OK';
|
||||
|
||||
const prevErrorRate = prevTotalCount > 0
|
||||
? ((stats?.prevFailedCount ?? 0) / prevTotalCount) * 100
|
||||
: 0;
|
||||
const errorDelta = (errorRate - prevErrorRate).toFixed(1);
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Total Throughput',
|
||||
value: totalCount.toLocaleString(),
|
||||
trend: {
|
||||
label: throughputTrendLabel,
|
||||
variant: throughputPctChange >= 0 ? 'success' as const : 'error' as const,
|
||||
},
|
||||
subtitle: `${activeCount} active exchanges`,
|
||||
sparkline: throughputSparkline,
|
||||
borderColor: 'var(--amber)',
|
||||
},
|
||||
{
|
||||
label: 'System Error Rate',
|
||||
value: `${errorRate.toFixed(2)}%`,
|
||||
trend: {
|
||||
label: errorRate <= prevErrorRate ? `\u25BC ${errorDelta}%` : `\u25B2 +${errorDelta}%`,
|
||||
variant: errorRate < 1 ? 'success' as const : 'error' as const,
|
||||
},
|
||||
subtitle: `${failedCount} errors / ${totalCount.toLocaleString()} total`,
|
||||
sparkline: errorSparkline,
|
||||
borderColor: errorRate < 1 ? 'var(--success)' : 'var(--error)',
|
||||
},
|
||||
{
|
||||
label: 'Latency Percentiles',
|
||||
value: `${p99Ms}ms`,
|
||||
trend: {
|
||||
label: p99Ms > prevP99Ms ? `\u25B2 +${p99Ms - prevP99Ms}ms` : `\u25BC ${prevP99Ms - p99Ms}ms`,
|
||||
variant: p99Ms > 300 ? 'error' as const : 'warning' as const,
|
||||
},
|
||||
subtitle: `P50 ${p50}ms \u00B7 P95 ${p95}ms \u00B7 SLA <300ms P99: ${slaStatus}`,
|
||||
borderColor: p99Ms > 300 ? 'var(--warning)' : 'var(--success)',
|
||||
},
|
||||
{
|
||||
label: 'Active Routes',
|
||||
value: `${routeCount}`,
|
||||
trend: { label: '\u2194 stable', variant: 'muted' as const },
|
||||
subtitle: `${routeCount} routes reporting`,
|
||||
borderColor: 'var(--running)',
|
||||
},
|
||||
{
|
||||
label: 'In-Flight Exchanges',
|
||||
value: String(activeCount),
|
||||
trend: { label: '\u2194', variant: 'muted' as const },
|
||||
subtitle: `${activeCount} active`,
|
||||
sparkline: throughputSparkline,
|
||||
borderColor: 'var(--amber)',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function RoutesMetrics() {
|
||||
const { appId, routeId } = useParams();
|
||||
const { appId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
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 { data: stats } = useExecutionStats(timeFrom, timeTo, undefined, appId);
|
||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
|
||||
|
||||
// Map backend RouteMetrics[] to table rows
|
||||
const rows: RouteRow[] = useMemo(() =>
|
||||
(metrics || []).map((m: any) => ({
|
||||
(metrics || []).map((m: RouteMetrics) => ({
|
||||
id: `${m.appId}/${m.routeId}`,
|
||||
...m,
|
||||
routeId: m.routeId,
|
||||
appId: m.appId,
|
||||
exchangeCount: m.exchangeCount,
|
||||
successRate: m.successRate,
|
||||
avgDurationMs: m.avgDurationMs,
|
||||
p99DurationMs: m.p99DurationMs,
|
||||
errorRate: m.errorRate,
|
||||
throughputPerSec: m.throughputPerSec,
|
||||
sparkline: m.sparkline ?? [],
|
||||
})),
|
||||
[metrics],
|
||||
);
|
||||
|
||||
const sparklineData = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => b.totalCount as number),
|
||||
// Sparkline data from timeseries buckets
|
||||
const throughputSparkline = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b) => b.totalCount),
|
||||
[timeseries],
|
||||
);
|
||||
const errorSparkline = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b) => b.failedCount),
|
||||
[timeseries],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any, i: number) => {
|
||||
const ts = b.timestamp ? new Date(b.timestamp) : null;
|
||||
const time = ts && !isNaN(ts.getTime())
|
||||
// Chart series from timeseries buckets
|
||||
const throughputChartSeries = useMemo(() => [{
|
||||
label: 'Throughput',
|
||||
data: (timeseries?.buckets || []).map((b, i) => ({
|
||||
x: i as number,
|
||||
y: b.totalCount,
|
||||
})),
|
||||
}], [timeseries]);
|
||||
|
||||
const latencyChartSeries = useMemo(() => [{
|
||||
label: 'Latency',
|
||||
data: (timeseries?.buckets || []).map((b, i) => ({
|
||||
x: i as number,
|
||||
y: b.avgDurationMs,
|
||||
})),
|
||||
}], [timeseries]);
|
||||
|
||||
const errorBarSeries = useMemo(() => [{
|
||||
label: 'Errors',
|
||||
data: (timeseries?.buckets || []).map((b) => {
|
||||
const ts = new Date(b.time);
|
||||
const label = !isNaN(ts.getTime())
|
||||
? ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: String(i);
|
||||
return {
|
||||
time,
|
||||
throughput: b.totalCount ?? 0,
|
||||
latency: b.avgDurationMs ?? 0,
|
||||
errors: b.failedCount ?? 0,
|
||||
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
|
||||
};
|
||||
: '—';
|
||||
return { x: label, y: b.failedCount };
|
||||
}),
|
||||
[timeseries],
|
||||
}], [timeseries]);
|
||||
|
||||
const volumeChartSeries = useMemo(() => [{
|
||||
label: 'Volume',
|
||||
data: (timeseries?.buckets || []).map((b, i) => ({
|
||||
x: i as number,
|
||||
y: b.totalCount,
|
||||
})),
|
||||
}], [timeseries]);
|
||||
|
||||
const kpiItems = useMemo(() =>
|
||||
buildKpiItems(stats, rows.length, throughputSparkline, errorSparkline),
|
||||
[stats, rows.length, throughputSparkline, errorSparkline],
|
||||
);
|
||||
|
||||
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) => {
|
||||
const rate = v as number;
|
||||
const cls = rate > 0.05 ? styles.rateBad : rate > 0.01 ? styles.rateWarn : styles.rateGood;
|
||||
return <span className={cls}>{(rate * 100).toFixed(1)}%</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'sparkline', header: 'Trend', width: '80px',
|
||||
render: (v) => <Sparkline data={v as number[]} />,
|
||||
},
|
||||
];
|
||||
|
||||
const errorRate = stats?.totalCount
|
||||
? (((stats.failedCount ?? 0) / stats.totalCount) * 100)
|
||||
: 0;
|
||||
const prevErrorRate = stats?.prevTotalCount
|
||||
? (((stats.prevFailedCount ?? 0) / stats.prevTotalCount) * 100)
|
||||
: 0;
|
||||
const errorTrend: 'up' | 'down' | 'neutral' = errorRate > prevErrorRate ? 'up' : errorRate < prevErrorRate ? 'down' : 'neutral';
|
||||
const errorTrendValue = stats?.prevTotalCount
|
||||
? `${Math.abs(errorRate - prevErrorRate).toFixed(2)}%`
|
||||
: undefined;
|
||||
|
||||
const p99Ms = stats?.p99LatencyMs ?? 0;
|
||||
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
|
||||
const latencyTrend: 'up' | 'down' | 'neutral' = p99Ms > prevP99Ms ? 'up' : p99Ms < prevP99Ms ? 'down' : 'neutral';
|
||||
const latencyTrendValue = prevP99Ms ? `${Math.abs(p99Ms - prevP99Ms)}ms` : undefined;
|
||||
|
||||
const totalCount = stats?.totalCount ?? 0;
|
||||
const prevTotalCount = stats?.prevTotalCount ?? 0;
|
||||
const throughputTrend: 'up' | 'down' | 'neutral' = totalCount > prevTotalCount ? 'up' : totalCount < prevTotalCount ? 'down' : 'neutral';
|
||||
const throughputTrendValue = prevTotalCount
|
||||
? `${Math.abs(((totalCount - prevTotalCount) / prevTotalCount) * 100).toFixed(0)}%`
|
||||
: undefined;
|
||||
|
||||
const successRate = stats?.totalCount
|
||||
? (((stats.totalCount - (stats.failedCount ?? 0)) / stats.totalCount) * 100)
|
||||
: 100;
|
||||
|
||||
const activeCount = stats?.activeCount ?? 0;
|
||||
|
||||
const errorSparkline = (timeseries?.buckets || []).map((b: any) => b.failedCount as number);
|
||||
const latencySparkline = (timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard
|
||||
label="Total Throughput"
|
||||
value={totalCount.toLocaleString()}
|
||||
detail="exchanges"
|
||||
trend={throughputTrend}
|
||||
trendValue={throughputTrendValue}
|
||||
accent="amber"
|
||||
sparkline={sparklineData}
|
||||
/>
|
||||
<StatCard
|
||||
label="System Error Rate"
|
||||
value={`${errorRate.toFixed(2)}%`}
|
||||
detail={`${stats?.failedCount ?? 0} errors / ${totalCount.toLocaleString()} total`}
|
||||
trend={errorTrend}
|
||||
trendValue={errorTrendValue}
|
||||
accent={errorRate < 1 ? 'success' : 'error'}
|
||||
sparkline={errorSparkline}
|
||||
/>
|
||||
<StatCard
|
||||
label="P99 Latency"
|
||||
value={`${p99Ms}ms`}
|
||||
detail={`Avg: ${stats?.avgDurationMs ?? 0}ms`}
|
||||
trend={latencyTrend}
|
||||
trendValue={latencyTrendValue}
|
||||
accent={p99Ms > 300 ? 'error' : p99Ms > 200 ? 'warning' : 'success'}
|
||||
sparkline={latencySparkline}
|
||||
/>
|
||||
<StatCard
|
||||
label="Success Rate"
|
||||
value={`${successRate.toFixed(1)}%`}
|
||||
detail={`${activeCount} active routes`}
|
||||
accent="success"
|
||||
sparkline={sparklineData.map((v, i) => {
|
||||
const failed = errorSparkline[i] ?? 0;
|
||||
return v > 0 ? ((v - failed) / v) * 100 : 100;
|
||||
})}
|
||||
/>
|
||||
<StatCard
|
||||
label="In-Flight"
|
||||
value={activeCount}
|
||||
detail="active exchanges"
|
||||
accent="amber"
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.refreshIndicator}>
|
||||
<span className={styles.refreshDot} />
|
||||
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
||||
</div>
|
||||
|
||||
{/* KPI header cards */}
|
||||
<KpiStrip items={kpiItems} />
|
||||
|
||||
{/* Per-route performance table */}
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Per-Route Performance</span>
|
||||
<span className={styles.tableMeta}>{rows.length} routes</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>{rows.length} routes</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
columns={ROUTE_COLUMNS}
|
||||
data={rows}
|
||||
sortable
|
||||
pageSize={20}
|
||||
onRowClick={(row) => {
|
||||
const targetAppId = appId ?? row.appId;
|
||||
navigate(`/routes/${targetAppId}/${row.routeId}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{chartData.length > 0 && (
|
||||
{/* 2x2 chart grid */}
|
||||
{(timeseries?.buckets?.length ?? 0) > 0 && (
|
||||
<div className={styles.chartGrid}>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
||||
<AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/s" height={200} />
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Latency (ms)</div>
|
||||
<LineChart
|
||||
series={[{ label: 'Latency', data: chartData.map((d: any, i: number) => ({ x: i, y: d.latency })) }]}
|
||||
yLabel="ms"
|
||||
<Card title="Throughput (msg/s)">
|
||||
<AreaChart
|
||||
series={throughputChartSeries}
|
||||
yLabel="msg/s"
|
||||
height={200}
|
||||
threshold={{ value: 300, label: 'SLA 300ms' }}
|
||||
className={styles.chart}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Errors by Route</div>
|
||||
<BarChart series={[{ label: 'Errors', data: chartData.map((d: any) => ({ x: d.time as string, y: d.errors })) }]} height={200} />
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Message Volume (msg/min)</div>
|
||||
<AreaChart series={[{ label: 'Volume', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/min" height={200} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Latency (ms)">
|
||||
<LineChart
|
||||
series={latencyChartSeries}
|
||||
yLabel="ms"
|
||||
threshold={{ value: 300, label: 'SLA 300ms' }}
|
||||
height={200}
|
||||
className={styles.chart}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Errors by Route">
|
||||
<BarChart
|
||||
series={errorBarSeries}
|
||||
height={200}
|
||||
className={styles.chart}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title="Message Volume (msg/min)">
|
||||
<AreaChart
|
||||
series={volumeChartSeries}
|
||||
yLabel="msg/min"
|
||||
height={200}
|
||||
className={styles.chart}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user