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 { Outlet, useNavigate, useLocation } from 'react-router';
|
||||||
import { AppShell, Sidebar, TopBar, CommandPalette, CommandPaletteProvider, GlobalFilterProvider, ToastProvider, useCommandPalette } from '@cameleer/design-system';
|
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 { useRouteCatalog } from '../api/queries/catalog';
|
||||||
|
import { useAgents } from '../api/queries/agents';
|
||||||
import { useAuthStore } from '../auth/auth-store';
|
import { useAuthStore } from '../auth/auth-store';
|
||||||
import { useMemo, useCallback } from 'react';
|
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() {
|
function LayoutContent() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { data: catalog } = useRouteCatalog();
|
const { data: catalog } = useRouteCatalog();
|
||||||
const { username, roles, logout } = useAuthStore();
|
const { data: agents } = useAgents();
|
||||||
|
const { username, logout } = useAuthStore();
|
||||||
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
const { open: paletteOpen, setOpen: setPaletteOpen } = useCommandPalette();
|
||||||
|
|
||||||
const sidebarApps: SidebarApp[] = useMemo(() => {
|
const sidebarApps: SidebarApp[] = useMemo(() => {
|
||||||
@@ -33,6 +90,11 @@ function LayoutContent() {
|
|||||||
}));
|
}));
|
||||||
}, [catalog]);
|
}, [catalog]);
|
||||||
|
|
||||||
|
const searchData = useMemo(
|
||||||
|
() => buildSearchData(catalog, agents as any[]),
|
||||||
|
[catalog, agents],
|
||||||
|
);
|
||||||
|
|
||||||
const breadcrumb = useMemo(() => {
|
const breadcrumb = useMemo(() => {
|
||||||
const parts = location.pathname.split('/').filter(Boolean);
|
const parts = location.pathname.split('/').filter(Boolean);
|
||||||
return parts.map((part, i) => ({
|
return parts.map((part, i) => ({
|
||||||
@@ -47,12 +109,12 @@ function LayoutContent() {
|
|||||||
}, [logout, navigate]);
|
}, [logout, navigate]);
|
||||||
|
|
||||||
const handlePaletteSelect = useCallback((result: any) => {
|
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);
|
setPaletteOpen(false);
|
||||||
}, [navigate, setPaletteOpen]);
|
}, [navigate, setPaletteOpen]);
|
||||||
|
|
||||||
const isAdmin = roles.includes('ADMIN');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
sidebar={
|
sidebar={
|
||||||
@@ -70,7 +132,7 @@ function LayoutContent() {
|
|||||||
open={paletteOpen}
|
open={paletteOpen}
|
||||||
onClose={() => setPaletteOpen(false)}
|
onClose={() => setPaletteOpen(false)}
|
||||||
onSelect={handlePaletteSelect}
|
onSelect={handlePaletteSelect}
|
||||||
data={[]}
|
data={searchData}
|
||||||
/>
|
/>
|
||||||
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
|
<main style={{ flex: 1, overflow: 'auto', padding: '1.5rem' }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ export default function AdminLayout() {
|
|||||||
active={location.pathname}
|
active={location.pathname}
|
||||||
onChange={(path) => navigate(path)}
|
onChange={(path) => navigate(path)}
|
||||||
/>
|
/>
|
||||||
<Outlet />
|
<div style={{ padding: '20px 24px 40px' }}>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
</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 { 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 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() {
|
export default function AuditLogPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [dateRange, setDateRange] = useState({
|
||||||
const [category, setCategory] = 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 [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>[] = [
|
const rows: AuditRow[] = useMemo(
|
||||||
{ key: 'timestamp', header: 'Time', sortable: true, render: (v) => new Date(v as string).toLocaleString() },
|
() => (data?.items || []).map((item) => ({ ...item, id: String(item.id) })),
|
||||||
{ key: 'username', header: 'User', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
|
|
||||||
{ key: 'action', header: 'Action' },
|
|
||||||
{ key: 'category', header: 'Category', render: (v) => <Badge label={String(v)} color="auto" /> },
|
|
||||||
{ key: 'target', header: 'Target', render: (v) => v ? <MonoText size="sm">{String(v)}</MonoText> : null },
|
|
||||||
{ key: 'result', header: 'Result', render: (v) => <Badge label={String(v)} color={v === 'SUCCESS' ? 'success' : 'error'} /> },
|
|
||||||
];
|
|
||||||
|
|
||||||
const rows = useMemo(() =>
|
|
||||||
(data?.items || []).map((item: any) => ({ ...item, id: String(item.id) })),
|
|
||||||
[data],
|
[data],
|
||||||
);
|
);
|
||||||
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ marginBottom: '1rem' }}>Audit Log</h2>
|
<div className={styles.filters}>
|
||||||
|
<DateRangePicker
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '1rem' }}>
|
value={dateRange}
|
||||||
<Input placeholder="Search..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
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
|
<Select
|
||||||
options={[
|
options={CATEGORIES}
|
||||||
{ value: '', label: 'All Categories' },
|
value={categoryFilter}
|
||||||
{ value: 'AUTH', label: 'Auth' },
|
onChange={(e) => { setCategoryFilter(e.target.value); setPage(0); }}
|
||||||
{ value: 'CONFIG', label: 'Config' },
|
className={styles.filterSelect}
|
||||||
{ value: 'RBAC', label: 'RBAC' },
|
/>
|
||||||
{ value: 'INFRA', label: 'Infra' },
|
<Input
|
||||||
]}
|
placeholder="Search action or target..."
|
||||||
value={category}
|
value={searchFilter}
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
onChange={(e) => { setSearchFilter(e.target.value); setPage(0); }}
|
||||||
|
onClear={() => { setSearchFilter(''); setPage(0); }}
|
||||||
|
className={styles.filterInput}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<div className={styles.tableSection}>
|
||||||
columns={columns}
|
<div className={styles.tableHeader}>
|
||||||
data={rows}
|
<span className={styles.tableTitle}>Audit Log</span>
|
||||||
sortable
|
<div className={styles.tableRight}>
|
||||||
pageSize={25}
|
<span className={styles.tableMeta}>
|
||||||
expandedContent={(row) => (
|
{totalCount} events
|
||||||
<div style={{ padding: '0.75rem' }}>
|
</span>
|
||||||
<CodeBlock content={JSON.stringify(row.detail, null, 2)} />
|
<Badge label="LIVE" color="success" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Input,
|
Input,
|
||||||
MonoText,
|
|
||||||
Tag,
|
|
||||||
Select,
|
Select,
|
||||||
ConfirmDialog,
|
MonoText,
|
||||||
Spinner,
|
SectionHeader,
|
||||||
|
Tag,
|
||||||
InlineEdit,
|
InlineEdit,
|
||||||
|
MultiSelect,
|
||||||
|
ConfirmDialog,
|
||||||
|
AlertDialog,
|
||||||
|
SplitPane,
|
||||||
|
EntityList,
|
||||||
|
Spinner,
|
||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import {
|
import {
|
||||||
@@ -25,26 +30,31 @@ import {
|
|||||||
useUsers,
|
useUsers,
|
||||||
useRoles,
|
useRoles,
|
||||||
} from '../../api/queries/admin/rbac';
|
} from '../../api/queries/admin/rbac';
|
||||||
|
import type { GroupDetail } from '../../api/queries/admin/rbac';
|
||||||
import styles from './UserManagement.module.css';
|
import styles from './UserManagement.module.css';
|
||||||
|
|
||||||
const BUILTIN_ADMINS_ID = '00000000-0000-0000-0000-000000000010';
|
const BUILTIN_ADMINS_ID = '00000000-0000-0000-0000-000000000010';
|
||||||
|
|
||||||
export default function GroupsTab() {
|
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 { toast } = useToast();
|
||||||
const { data: groups = [], isLoading: groupsLoading } = useGroups();
|
const { data: groups = [], isLoading: groupsLoading } = useGroups();
|
||||||
const { data: selectedGroup, isLoading: detailLoading } = useGroup(selectedGroupId);
|
|
||||||
const { data: users = [] } = useUsers();
|
const { data: users = [] } = useUsers();
|
||||||
const { data: roles = [] } = useRoles();
|
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 createGroup = useCreateGroup();
|
||||||
const updateGroup = useUpdateGroup();
|
const updateGroup = useUpdateGroup();
|
||||||
const deleteGroup = useDeleteGroup();
|
const deleteGroup = useDeleteGroup();
|
||||||
@@ -53,350 +63,385 @@ export default function GroupsTab() {
|
|||||||
const addUserToGroup = useAddUserToGroup();
|
const addUserToGroup = useAddUserToGroup();
|
||||||
const removeUserFromGroup = useRemoveUserFromGroup();
|
const removeUserFromGroup = useRemoveUserFromGroup();
|
||||||
|
|
||||||
const filteredGroups = groups.filter((g) =>
|
const filtered = useMemo(() => {
|
||||||
g.name.toLowerCase().includes(search.toLowerCase())
|
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 = [
|
const parentOptions = [
|
||||||
{ value: '', label: 'Top-level' },
|
{ 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';
|
if (!parentGroupId) return 'Top-level';
|
||||||
const parent = groups.find((g) => g.id === parentGroupId);
|
const parent = groups.find((g) => g.id === parentGroupId);
|
||||||
return parent ? parent.name : parentGroupId;
|
return parent ? parent.name : parentGroupId;
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleCreate = async () => {
|
async function handleCreate() {
|
||||||
const name = newGroupName.trim();
|
if (!newName.trim()) return;
|
||||||
if (!name) return;
|
|
||||||
try {
|
try {
|
||||||
await createGroup.mutateAsync({
|
await createGroup.mutateAsync({
|
||||||
name,
|
name: newName.trim(),
|
||||||
parentGroupId: newGroupParentId || null,
|
parentGroupId: newParent || null,
|
||||||
});
|
});
|
||||||
toast({ title: 'Group created', variant: 'success' });
|
toast({ title: 'Group created', description: newName.trim(), variant: 'success' });
|
||||||
setNewGroupName('');
|
setCreating(false);
|
||||||
setNewGroupParentId('');
|
setNewName('');
|
||||||
setShowCreate(false);
|
setNewParent('');
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: 'Failed to create group', variant: 'error' });
|
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;
|
if (!selectedGroup) return;
|
||||||
try {
|
try {
|
||||||
await updateGroup.mutateAsync({
|
await updateGroup.mutateAsync({
|
||||||
id: selectedGroup.id,
|
id: selectedGroup.id,
|
||||||
name: newName,
|
name: newNameVal,
|
||||||
parentGroupId: selectedGroup.parentGroupId,
|
parentGroupId: selectedGroup.parentGroupId,
|
||||||
});
|
});
|
||||||
toast({ title: 'Group renamed', variant: 'success' });
|
toast({ title: 'Group renamed', variant: 'success' });
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: 'Failed to rename group', variant: 'error' });
|
toast({ title: 'Failed to rename group', variant: 'error' });
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
async function handleRemoveMember(userId: string) {
|
||||||
if (!selectedGroup) return;
|
if (!selectedGroup) return;
|
||||||
try {
|
try {
|
||||||
await deleteGroup.mutateAsync(selectedGroup.id);
|
await removeUserFromGroup.mutateAsync({
|
||||||
toast({ title: 'Group deleted', variant: 'success' });
|
userId,
|
||||||
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,
|
|
||||||
groupId: selectedGroup.id,
|
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' });
|
toast({ title: 'Member removed', variant: 'success' });
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: 'Failed to remove member', variant: 'error' });
|
toast({ title: 'Failed to remove member', variant: 'error' });
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleAddRole = async () => {
|
async function handleAddMembers(userIds: string[]) {
|
||||||
if (!selectedGroup || !addRoleId) return;
|
if (!selectedGroup) return;
|
||||||
try {
|
for (const userId of userIds) {
|
||||||
await assignRoleToGroup.mutateAsync({
|
try {
|
||||||
groupId: selectedGroup.id,
|
await addUserToGroup.mutateAsync({
|
||||||
roleId: addRoleId,
|
userId,
|
||||||
});
|
groupId: selectedGroup.id,
|
||||||
toast({ title: 'Role assigned', variant: 'success' });
|
});
|
||||||
setAddRoleId('');
|
toast({ title: 'Member added', variant: 'success' });
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: 'Failed to assign role', variant: 'error' });
|
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;
|
if (!selectedGroup) return;
|
||||||
try {
|
try {
|
||||||
await removeRoleFromGroup.mutateAsync({ groupId: selectedGroup.id, roleId });
|
await removeRoleFromGroup.mutateAsync({
|
||||||
|
groupId: selectedGroup.id,
|
||||||
|
roleId,
|
||||||
|
});
|
||||||
toast({ title: 'Role removed', variant: 'success' });
|
toast({ title: 'Role removed', variant: 'success' });
|
||||||
} catch {
|
} catch {
|
||||||
toast({ title: 'Failed to remove role', variant: 'error' });
|
toast({ title: 'Failed to remove role', variant: 'error' });
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const isBuiltinAdmins = selectedGroup?.id === BUILTIN_ADMINS_ID;
|
if (groupsLoading) return <Spinner size="md" />;
|
||||||
|
|
||||||
// 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));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.splitPane}>
|
<>
|
||||||
{/* Left pane */}
|
<SplitPane
|
||||||
<div className={styles.listPane}>
|
list={
|
||||||
<div className={styles.listHeader}>
|
<>
|
||||||
<Input
|
{creating && (
|
||||||
placeholder="Search groups..."
|
<div className={styles.createForm}>
|
||||||
value={search}
|
<Input
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
placeholder="Group name *"
|
||||||
onClear={() => setSearch('')}
|
value={newName}
|
||||||
/>
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
<Button
|
/>
|
||||||
size="sm"
|
{duplicateGroupName && (
|
||||||
variant="secondary"
|
<span style={{ color: 'var(--error)', fontSize: 11 }}>
|
||||||
onClick={() => setShowCreate((v) => !v)}
|
Group name already exists
|
||||||
>
|
</span>
|
||||||
+ Add Group
|
)}
|
||||||
</Button>
|
<Select
|
||||||
</div>
|
options={parentOptions}
|
||||||
|
value={newParent}
|
||||||
{showCreate && (
|
onChange={(e) => setNewParent(e.target.value)}
|
||||||
<div className={styles.createForm}>
|
/>
|
||||||
<Input
|
<div className={styles.createFormActions}>
|
||||||
placeholder="Group name"
|
<Button
|
||||||
value={newGroupName}
|
size="sm"
|
||||||
onChange={(e) => setNewGroupName(e.target.value)}
|
variant="ghost"
|
||||||
/>
|
onClick={() => setCreating(false)}
|
||||||
<div style={{ marginTop: 8 }}>
|
>
|
||||||
<Select
|
Cancel
|
||||||
options={parentOptions}
|
</Button>
|
||||||
value={newGroupParentId}
|
<Button
|
||||||
onChange={(e) => setNewGroupParentId(e.target.value)}
|
size="sm"
|
||||||
/>
|
variant="primary"
|
||||||
</div>
|
onClick={handleCreate}
|
||||||
<div className={styles.createFormActions}>
|
loading={createGroup.isPending}
|
||||||
<Button
|
disabled={!newName.trim() || duplicateGroupName}
|
||||||
size="sm"
|
>
|
||||||
variant="ghost"
|
Create
|
||||||
onClick={() => {
|
</Button>
|
||||||
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>
|
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
<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
|
<ConfirmDialog
|
||||||
open={deleteOpen}
|
open={deleteTarget !== null}
|
||||||
onClose={() => setDeleteOpen(false)}
|
onClose={() => setDeleteTarget(null)}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
title="Delete Group"
|
message={`Delete group "${deleteTarget?.name}"? This cannot be undone.`}
|
||||||
message={`Delete group "${selectedGroup?.name}"? This action cannot be undone.`}
|
confirmText={deleteTarget?.name ?? ''}
|
||||||
confirmText="DELETE"
|
|
||||||
variant="danger"
|
|
||||||
loading={deleteGroup.isPending}
|
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 {
|
.section {
|
||||||
display: grid;
|
margin-bottom: 24px;
|
||||||
gap: 0.5rem;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section h3 {
|
.toggleRow {
|
||||||
font-size: 0.875rem;
|
display: flex;
|
||||||
font-weight: 600;
|
align-items: center;
|
||||||
margin: 0;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tagRow {
|
.hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tagList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
gap: 6px;
|
||||||
min-height: 2rem;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.addRow {
|
.noRoles {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.addRoleRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.addRow input {
|
.roleInput {
|
||||||
flex: 1;
|
width: 200px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,110 +1,226 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 { adminFetch } from '../../api/queries/admin/admin-api';
|
||||||
import styles from './OidcConfigPage.module.css';
|
import styles from './OidcConfigPage.module.css';
|
||||||
|
|
||||||
interface OidcConfig {
|
interface OidcFormData {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
autoSignup: boolean;
|
||||||
issuerUri: string;
|
issuerUri: string;
|
||||||
clientId: string;
|
clientId: string;
|
||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
rolesClaim: string;
|
rolesClaim: string;
|
||||||
defaultRoles: string[];
|
|
||||||
autoSignup: boolean;
|
|
||||||
displayNameClaim: string;
|
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() {
|
export default function OidcConfigPage() {
|
||||||
const [config, setConfig] = useState<OidcConfig | null>(null);
|
const [form, setForm] = useState<OidcFormData | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
const [newRole, setNewRole] = useState('');
|
const [newRole, setNewRole] = useState('');
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
adminFetch<OidcConfig>('/oidc')
|
adminFetch<OidcFormData>('/oidc')
|
||||||
.then(setConfig)
|
.then(setForm)
|
||||||
.catch(() => setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' }));
|
.catch(() => setForm(EMPTY_CONFIG));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = async () => {
|
function update<K extends keyof OidcFormData>(key: K, value: OidcFormData[K]) {
|
||||||
if (!config) return;
|
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);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(config) });
|
await adminFetch('/oidc', { method: 'PUT', body: JSON.stringify(form) });
|
||||||
setSuccess(true);
|
toast({ title: 'Settings saved', description: 'OIDC configuration updated successfully.', variant: 'success' });
|
||||||
setTimeout(() => setSuccess(false), 3000);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
|
toast({ title: 'Save failed', description: e.message, variant: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const handleDelete = async () => {
|
async function handleTest() {
|
||||||
|
if (!form) return;
|
||||||
|
setTesting(true);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await adminFetch('/oidc', { method: 'DELETE' });
|
const result = await adminFetch<{ status: string; authorizationEndpoint?: string }>('/oidc/test', { method: 'POST' });
|
||||||
setConfig({ enabled: false, issuerUri: '', clientId: '', clientSecret: '', rolesClaim: 'roles', defaultRoles: ['VIEWER'], autoSignup: true, displayNameClaim: 'name' });
|
toast({ title: 'Connection test', description: `OIDC provider responded: ${result.status}`, variant: 'success' });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
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 (
|
return (
|
||||||
<div>
|
<div className={styles.page}>
|
||||||
<h2 style={{ marginBottom: '1rem' }}>OIDC Configuration</h2>
|
<div className={styles.toolbar}>
|
||||||
<Card>
|
<Button size="sm" variant="secondary" onClick={handleTest} disabled={!form.issuerUri || testing}>
|
||||||
<div style={{ padding: '1.5rem', display: 'grid', gap: '1rem' }}>
|
{testing ? 'Testing...' : 'Test Connection'}
|
||||||
<Toggle checked={config.enabled} onChange={(e) => setConfig({ ...config, enabled: e.target.checked })} label="Enable OIDC" />
|
</Button>
|
||||||
<FormField label="Issuer URI"><Input value={config.issuerUri} onChange={(e) => setConfig({ ...config, issuerUri: e.target.value })} /></FormField>
|
<Button size="sm" variant="primary" onClick={handleSave} disabled={saving}>
|
||||||
<FormField label="Client ID"><Input value={config.clientId} onChange={(e) => setConfig({ ...config, clientId: e.target.value })} /></FormField>
|
{saving ? 'Saving...' : 'Save'}
|
||||||
<FormField label="Client Secret"><Input type="password" value={config.clientSecret} onChange={(e) => setConfig({ ...config, clientSecret: e.target.value })} /></FormField>
|
</Button>
|
||||||
<FormField label="Roles Claim"><Input value={config.rolesClaim} onChange={(e) => setConfig({ ...config, rolesClaim: e.target.value })} /></FormField>
|
</div>
|
||||||
<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.section}>
|
{error && <div style={{ marginBottom: 16 }}><Alert variant="error">{error}</Alert></div>}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
<section className={styles.section}>
|
||||||
<Button variant="primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
<SectionHeader>Behavior</SectionHeader>
|
||||||
<Button variant="danger" onClick={() => setDeleteOpen(true)}>Delete Configuration</Button>
|
<div className={styles.toggleRow}>
|
||||||
</div>
|
<Toggle
|
||||||
|
label="Enabled"
|
||||||
{error && <Alert variant="error">{error}</Alert>}
|
checked={form.enabled}
|
||||||
{success && <Alert variant="success">Configuration saved</Alert>}
|
onChange={(e) => update('enabled', e.target.checked)}
|
||||||
|
/>
|
||||||
</div>
|
</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
|
<section className={styles.section}>
|
||||||
open={deleteOpen}
|
<SectionHeader>Provider Settings</SectionHeader>
|
||||||
onClose={() => setDeleteOpen(false)}
|
<FormField label="Issuer URI" htmlFor="issuer">
|
||||||
onConfirm={handleDelete}
|
<Input
|
||||||
title="Delete OIDC Configuration"
|
id="issuer"
|
||||||
message="Delete OIDC configuration? All OIDC users will lose access."
|
type="url"
|
||||||
confirmText="DELETE"
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,30 +6,29 @@ import UsersTab from './UsersTab';
|
|||||||
import GroupsTab from './GroupsTab';
|
import GroupsTab from './GroupsTab';
|
||||||
import RolesTab from './RolesTab';
|
import RolesTab from './RolesTab';
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
{ label: 'Users', value: 'users' },
|
||||||
|
{ label: 'Groups', value: 'groups' },
|
||||||
|
{ label: 'Roles', value: 'roles' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function RbacPage() {
|
export default function RbacPage() {
|
||||||
const { data: stats } = useRbacStats();
|
const { data: stats } = useRbacStats();
|
||||||
const [tab, setTab] = useState('users');
|
const [tab, setTab] = useState('users');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ margin: '0 0 16px' }}>User Management</h2>
|
|
||||||
<div className={styles.statStrip}>
|
<div className={styles.statStrip}>
|
||||||
<StatCard label="Users" value={stats?.userCount ?? 0} />
|
<StatCard label="Users" value={stats?.userCount ?? 0} />
|
||||||
<StatCard label="Groups" value={stats?.groupCount ?? 0} />
|
<StatCard label="Groups" value={stats?.groupCount ?? 0} />
|
||||||
<StatCard label="Roles" value={stats?.roleCount ?? 0} />
|
<StatCard label="Roles" value={stats?.roleCount ?? 0} />
|
||||||
</div>
|
</div>
|
||||||
<Tabs
|
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||||||
tabs={[
|
<div className={styles.tabContent}>
|
||||||
{ label: 'Users', value: 'users' },
|
{tab === 'users' && <UsersTab />}
|
||||||
{ label: 'Groups', value: 'groups' },
|
{tab === 'groups' && <GroupsTab />}
|
||||||
{ label: 'Roles', value: 'roles' },
|
{tab === 'roles' && <RolesTab />}
|
||||||
]}
|
</div>
|
||||||
active={tab}
|
|
||||||
onChange={setTab}
|
|
||||||
/>
|
|
||||||
{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 {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
ConfirmDialog,
|
|
||||||
Input,
|
Input,
|
||||||
MonoText,
|
MonoText,
|
||||||
Spinner,
|
SectionHeader,
|
||||||
Tag,
|
Tag,
|
||||||
|
ConfirmDialog,
|
||||||
|
SplitPane,
|
||||||
|
EntityList,
|
||||||
|
Spinner,
|
||||||
useToast,
|
useToast,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import {
|
import {
|
||||||
@@ -20,33 +23,54 @@ import type { RoleDetail } from '../../api/queries/admin/rbac';
|
|||||||
import styles from './UserManagement.module.css';
|
import styles from './UserManagement.module.css';
|
||||||
|
|
||||||
export default function RolesTab() {
|
export default function RolesTab() {
|
||||||
|
const { toast } = useToast();
|
||||||
const { data: roles, isLoading } = useRoles();
|
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);
|
const { data: detail, isLoading: detailLoading } = useRole(selectedId);
|
||||||
|
|
||||||
|
// Mutations
|
||||||
const createRole = useCreateRole();
|
const createRole = useCreateRole();
|
||||||
const deleteRole = useDeleteRole();
|
const deleteRole = useDeleteRole();
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const filtered = (roles ?? []).filter((r) =>
|
const filtered = useMemo(() => {
|
||||||
r.name.toLowerCase().includes(search.toLowerCase()),
|
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() {
|
function handleCreate() {
|
||||||
if (!newName.trim()) return;
|
if (!newName.trim()) return;
|
||||||
createRole.mutate(
|
createRole.mutate(
|
||||||
{ name: newName.trim(), description: newDescription.trim() || undefined },
|
{ name: newName.trim().toUpperCase(), description: newDesc.trim() || undefined },
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: 'Role created', variant: 'success' });
|
toast({
|
||||||
setShowCreate(false);
|
title: 'Role created',
|
||||||
|
description: newName.trim().toUpperCase(),
|
||||||
|
variant: 'success',
|
||||||
|
});
|
||||||
|
setCreating(false);
|
||||||
setNewName('');
|
setNewName('');
|
||||||
setNewDescription('');
|
setNewDesc('');
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({ title: 'Failed to create role', variant: 'error' });
|
toast({ title: 'Failed to create role', variant: 'error' });
|
||||||
@@ -56,152 +80,144 @@ export default function RolesTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDelete() {
|
function handleDelete() {
|
||||||
if (!selectedId) return;
|
if (!deleteTarget) return;
|
||||||
deleteRole.mutate(selectedId, {
|
deleteRole.mutate(deleteTarget.id, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast({ title: 'Role deleted', variant: 'success' });
|
toast({
|
||||||
setSelectedId(null);
|
title: 'Role deleted',
|
||||||
setConfirmDelete(false);
|
description: deleteTarget.name,
|
||||||
|
variant: 'warning',
|
||||||
|
});
|
||||||
|
if (selectedId === deleteTarget.id) setSelectedId(null);
|
||||||
|
setDeleteTarget(null);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast({ title: 'Failed to delete role', variant: 'error' });
|
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 (
|
return (
|
||||||
<div className={styles.splitPane}>
|
<>
|
||||||
{/* Left pane — list */}
|
<SplitPane
|
||||||
<div className={styles.listPane}>
|
list={
|
||||||
<div className={styles.listHeader}>
|
<>
|
||||||
<Input
|
{creating && (
|
||||||
placeholder="Search roles…"
|
<div className={styles.createForm}>
|
||||||
value={search}
|
<Input
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
placeholder="Role name *"
|
||||||
/>
|
value={newName}
|
||||||
<Button
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
variant="secondary"
|
/>
|
||||||
size="sm"
|
{duplicateRoleName && (
|
||||||
onClick={() => setShowCreate((v) => !v)}
|
<span style={{ color: 'var(--error)', fontSize: 11 }}>
|
||||||
>
|
Role name already exists
|
||||||
+ Add Role
|
</span>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
<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 && (
|
<EntityList
|
||||||
<div className={styles.createForm}>
|
items={filtered}
|
||||||
<Input
|
renderItem={(role) => (
|
||||||
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)}
|
|
||||||
>
|
|
||||||
<Avatar name={role.name} size="sm" />
|
<Avatar name={role.name} size="sm" />
|
||||||
<div className={styles.entityInfo}>
|
<div className={styles.entityInfo}>
|
||||||
<div className={styles.entityName}>
|
<div className={styles.entityName}>
|
||||||
{role.name}
|
{role.name}
|
||||||
{role.system && <Badge label="system" variant="outlined" />}
|
{role.system && (
|
||||||
|
<Badge
|
||||||
|
label="system"
|
||||||
|
color="auto"
|
||||||
|
variant="outlined"
|
||||||
|
className={styles.providerBadge}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.entityMeta}>
|
<div className={styles.entityMeta}>
|
||||||
{role.description || '—'} · {assignmentCount} assignment
|
{role.description || '\u2014'} \u00b7{' '}
|
||||||
{assignmentCount !== 1 ? 's' : ''}
|
{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>
|
</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>
|
</>
|
||||||
);
|
)}
|
||||||
})}
|
getItemId={(role) => role.id}
|
||||||
</div>
|
selectedId={selectedId ?? undefined}
|
||||||
)}
|
onSelect={setSelectedId}
|
||||||
</div>
|
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 */}
|
<ConfirmDialog
|
||||||
<div className={styles.detailPane}>
|
open={deleteTarget !== null}
|
||||||
{!selectedId ? (
|
onClose={() => setDeleteTarget(null)}
|
||||||
<div className={styles.emptyDetail}>Select a role to view details</div>
|
onConfirm={handleDelete}
|
||||||
) : detailLoading || !detail ? (
|
message={`Delete role "${deleteTarget?.name}"? This cannot be undone.`}
|
||||||
<Spinner />
|
confirmText={deleteTarget?.name ?? ''}
|
||||||
) : (
|
loading={deleteRole.isPending}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,93 +229,93 @@ interface RoleDetailPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RoleDetailPanel({ role, onDeleteRequest }: RoleDetailPanelProps) {
|
function RoleDetailPanel({ role, onDeleteRequest }: RoleDetailPanelProps) {
|
||||||
// Build a set of directly-assigned user IDs for distinguishing inherited principals
|
const directUserIds = new Set(
|
||||||
const directUserIds = new Set((role.directUsers ?? []).map((u) => u.userId));
|
(role.directUsers ?? []).map((u) => u.userId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignedGroups = role.assignedGroups ?? [];
|
||||||
|
const directUsers = role.directUsers ?? [];
|
||||||
|
const effectivePrincipals = role.effectivePrincipals ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
{/* Header */}
|
|
||||||
<div className={styles.detailHeader}>
|
<div className={styles.detailHeader}>
|
||||||
<Avatar name={role.name} size="md" />
|
<Avatar name={role.name} size="lg" />
|
||||||
<div style={{ flex: 1 }}>
|
<div className={styles.detailHeaderInfo}>
|
||||||
<div style={{ fontWeight: 700, fontSize: 16 }}>{role.name}</div>
|
<div className={styles.detailName}>{role.name}</div>
|
||||||
{role.description && (
|
{role.description && (
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>
|
<div className={styles.detailEmail}>{role.description}</div>
|
||||||
{role.description}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{!role.system && (
|
||||||
variant="danger"
|
<Button size="sm" variant="danger" onClick={onDeleteRequest}>
|
||||||
size="sm"
|
Delete
|
||||||
disabled={role.system}
|
</Button>
|
||||||
onClick={onDeleteRequest}
|
)}
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metadata */}
|
|
||||||
<div className={styles.metaGrid}>
|
<div className={styles.metaGrid}>
|
||||||
<span className={styles.metaLabel}>ID</span>
|
<span className={styles.metaLabel}>ID</span>
|
||||||
<MonoText size="xs">{role.id}</MonoText>
|
<MonoText size="xs">{role.id}</MonoText>
|
||||||
|
|
||||||
<span className={styles.metaLabel}>Scope</span>
|
<span className={styles.metaLabel}>Scope</span>
|
||||||
<span>{role.scope || '—'}</span>
|
<span className={styles.metaValue}>{role.scope || '\u2014'}</span>
|
||||||
|
{role.system && (
|
||||||
<span className={styles.metaLabel}>Type</span>
|
<>
|
||||||
<span>{role.system ? 'System role (read-only)' : 'Custom role'}</span>
|
<span className={styles.metaLabel}>Type</span>
|
||||||
</div>
|
<span className={styles.metaValue}>System role (read-only)</span>
|
||||||
|
</>
|
||||||
{/* 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" />
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Assigned to users (direct) */}
|
<SectionHeader>Assigned to groups</SectionHeader>
|
||||||
<div className={styles.sectionTitle}>Assigned to users (direct)</div>
|
|
||||||
<div className={styles.sectionTags}>
|
<div className={styles.sectionTags}>
|
||||||
{(role.directUsers ?? []).length === 0 ? (
|
{assignedGroups.map((g) => (
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
<Tag key={g.id} label={g.name} color="success" />
|
||||||
) : (
|
))}
|
||||||
(role.directUsers ?? []).map((u) => (
|
{assignedGroups.length === 0 && (
|
||||||
<Tag key={u.userId} label={u.displayName} />
|
<span className={styles.inheritedNote}>(none)</span>
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Effective principals */}
|
<SectionHeader>Assigned to users (direct)</SectionHeader>
|
||||||
<div className={styles.sectionTitle}>Effective principals</div>
|
|
||||||
<div className={styles.sectionTags}>
|
<div className={styles.sectionTags}>
|
||||||
{(role.effectivePrincipals ?? []).length === 0 ? (
|
{directUsers.map((u) => (
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>None</span>
|
<Tag key={u.userId} label={u.displayName} color="auto" />
|
||||||
) : (
|
))}
|
||||||
(role.effectivePrincipals ?? []).map((u) => {
|
{directUsers.length === 0 && (
|
||||||
const isDirect = directUserIds.has(u.userId);
|
<span className={styles.inheritedNote}>(none)</span>
|
||||||
return isDirect ? (
|
|
||||||
<Badge key={u.userId} label={u.displayName} variant="filled" />
|
|
||||||
) : (
|
|
||||||
<Badge
|
|
||||||
key={u.userId}
|
|
||||||
label={`↑ ${u.displayName}`}
|
|
||||||
variant="dashed"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
Dashed entries inherit this role through group membership
|
||||||
</div>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,187 +5,149 @@
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.splitPane {
|
.tabContent {
|
||||||
display: grid;
|
margin-top: 16px;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.entityInfo {
|
.entityInfo {
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entityName {
|
.entityName {
|
||||||
font-weight: 600;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
display: flex;
|
font-weight: 500;
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
.entityMeta {
|
.entityMeta {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
font-family: var(--font-body);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entityTags {
|
.entityTags {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
margin-top: 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 {
|
.detailHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 16px;
|
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 {
|
.metaGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 100px 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
gap: 6px 12px;
|
gap: 6px 16px;
|
||||||
font-size: 13px;
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
.metaLabel {
|
.metaLabel {
|
||||||
font-weight: 700;
|
|
||||||
font-size: 10px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.6px;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.metaValue {
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
margin-bottom: 8px;
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTags {
|
.sectionTags {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
margin-bottom: 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 {
|
.inheritedNote {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-style: italic;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
font-family: var(--font-body);
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.providerBadge {
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inherited {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
.securitySection {
|
.securitySection {
|
||||||
padding: 12px;
|
margin-top: 8px;
|
||||||
border: 1px solid var(--border-subtle);
|
margin-bottom: 8px;
|
||||||
border-radius: var(--radius-lg);
|
}
|
||||||
margin-bottom: 16px;
|
|
||||||
|
.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 {
|
.resetForm {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyDetail {
|
.resetInput {
|
||||||
display: flex;
|
width: 200px;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
.statStrip {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
@@ -5,13 +15,66 @@
|
|||||||
margin-bottom: 16px;
|
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 {
|
.scopeTrail {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
margin-bottom: 16px;
|
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 {
|
.groupGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -19,115 +82,131 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* GroupCard meta strip */
|
.groupGridSingle {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group meta row */
|
||||||
.groupMeta {
|
.groupMeta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 12px;
|
gap: 16px;
|
||||||
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.groupMeta strong {
|
.groupMeta strong {
|
||||||
color: var(--text-primary);
|
font-family: var(--font-mono);
|
||||||
}
|
color: var(--text-secondary);
|
||||||
|
|
||||||
/* 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-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.thStatus {
|
/* Alert banner in group footer */
|
||||||
width: 24px;
|
.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 {
|
.alertIcon {
|
||||||
width: 24px;
|
font-size: 14px;
|
||||||
padding: 0 4px 0 8px;
|
flex-shrink: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Instance fields */
|
||||||
.instanceName {
|
.instanceName {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceMeta {
|
.instanceMeta {
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceError {
|
.instanceError {
|
||||||
font-size: 11px;
|
|
||||||
color: var(--error);
|
color: var(--error);
|
||||||
font-family: var(--font-mono);
|
white-space: nowrap;
|
||||||
}
|
|
||||||
|
|
||||||
.instanceHeartbeatDead {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--error);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceHeartbeatStale {
|
.instanceHeartbeatStale {
|
||||||
font-size: 11px;
|
|
||||||
color: var(--warning);
|
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);
|
color: var(--text-muted);
|
||||||
text-decoration: none;
|
font-weight: 500;
|
||||||
font-size: 14px;
|
|
||||||
padding: 4px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceLink:hover {
|
.detailProgress {
|
||||||
color: var(--text-primary);
|
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 {
|
.eventCard {
|
||||||
|
margin-top: 20px;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -144,136 +223,4 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
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 { useState, useMemo } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, Link } from 'react-router';
|
||||||
import {
|
import {
|
||||||
StatCard, StatusDot, Badge, MonoText,
|
StatCard, StatusDot, Badge, MonoText, ProgressBar,
|
||||||
GroupCard, EventFeed, Alert,
|
GroupCard, DataTable, LineChart, EventFeed, DetailPanel,
|
||||||
DetailPanel, ProgressBar, LineChart,
|
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
|
import type { Column, FeedEvent } from '@cameleer/design-system';
|
||||||
import styles from './AgentHealth.module.css';
|
import styles from './AgentHealth.module.css';
|
||||||
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
||||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
|
||||||
import { useAgentMetrics } from '../../api/queries/agent-metrics';
|
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 {
|
function formatUptime(seconds?: number): string {
|
||||||
if (!seconds) return '—';
|
if (!seconds) return '\u2014';
|
||||||
const days = Math.floor(seconds / 86400);
|
const days = Math.floor(seconds / 86400);
|
||||||
const hours = Math.floor((seconds % 86400) / 3600);
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
const mins = Math.floor((seconds % 3600) / 60);
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
@@ -20,18 +34,65 @@ function formatUptime(seconds?: number): string {
|
|||||||
return `${mins}m`;
|
return `${mins}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRelativeTime(iso?: string): string {
|
function formatErrorRate(rate?: number): string {
|
||||||
if (!iso) return '—';
|
if (rate == null) return '\u2014';
|
||||||
const diff = Date.now() - new Date(iso).getTime();
|
return `${(rate * 100).toFixed(1)}%`;
|
||||||
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 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(
|
const { data: memMetrics } = useAgentMetrics(
|
||||||
agent.id,
|
agent.id,
|
||||||
['jvm.memory.heap.used', 'jvm.memory.heap.max'],
|
['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 heapUsed = memMetrics?.metrics?.['jvm.memory.heap.used']?.[0]?.value;
|
||||||
const heapMax = memMetrics?.metrics?.['jvm.memory.heap.max']?.[0]?.value;
|
const heapMax = memMetrics?.metrics?.['jvm.memory.heap.max']?.[0]?.value;
|
||||||
|
|
||||||
const heapPercent = heapUsed != null && heapMax != null && heapMax > 0
|
const heapPercent =
|
||||||
? Math.round((heapUsed / heapMax) * 100)
|
heapUsed != null && heapMax != null && heapMax > 0
|
||||||
: undefined;
|
? Math.round((heapUsed / heapMax) * 100)
|
||||||
|
: undefined;
|
||||||
const cpuPercent = cpuValue != null ? Math.round(cpuValue * 100) : undefined;
|
const cpuPercent = cpuValue != null ? Math.round(cpuValue * 100) : undefined;
|
||||||
|
|
||||||
const statusVariant: 'live' | 'stale' | 'dead' =
|
const ns = normalizeStatus(agent.status);
|
||||||
agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead';
|
|
||||||
const statusColor: 'success' | 'warning' | 'error' =
|
|
||||||
agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.overviewContent}>
|
<div className={styles.detailContent}>
|
||||||
<div className={styles.overviewRow}>
|
<div className={styles.detailRow}>
|
||||||
<StatusDot variant={statusVariant} />
|
<span className={styles.detailLabel}>Status</span>
|
||||||
<Badge label={agent.status} color={statusColor} />
|
<Badge label={agent.status} color={statusColor(ns)} variant="filled" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
<dl className={styles.detailList}>
|
<span className={styles.detailLabel}>Application</span>
|
||||||
<div className={styles.detailRow}>
|
<MonoText size="xs">{agent.application}</MonoText>
|
||||||
<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>
|
</div>
|
||||||
|
<div className={styles.detailRow}>
|
||||||
<div className={styles.metricsSection}>
|
<span className={styles.detailLabel}>Uptime</span>
|
||||||
<div className={styles.metricLabel}>
|
<MonoText size="xs">{formatUptime(agent.uptimeSeconds)}</MonoText>
|
||||||
CPU Usage{cpuPercent != null ? ` — ${cpuPercent}%` : ''}
|
</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>
|
</div>
|
||||||
<ProgressBar
|
|
||||||
value={cpuPercent}
|
|
||||||
variant={cpuPercent == null ? 'primary' : cpuPercent > 80 ? 'error' : cpuPercent > 60 ? 'warning' : 'success'}
|
|
||||||
indeterminate={cpuPercent == null}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentPerformanceContent({ agent }: { agent: any }) {
|
function AgentPerformanceContent({ agent }: { agent: AgentInstance }) {
|
||||||
const { data: tpsMetrics } = useAgentMetrics(agent.id, ['cameleer.tps'], 60);
|
const { data: tpsMetrics } = useAgentMetrics(agent.id, ['cameleer.tps'], 60);
|
||||||
const { data: errMetrics } = useAgentMetrics(agent.id, ['cameleer.error.rate'], 60);
|
const { data: errMetrics } = useAgentMetrics(agent.id, ['cameleer.error.rate'], 60);
|
||||||
|
|
||||||
const tpsSeries = useMemo(() => {
|
const tpsSeries = useMemo(() => {
|
||||||
const raw = tpsMetrics?.metrics?.['cameleer.tps'] ?? [];
|
const raw = tpsMetrics?.metrics?.['cameleer.tps'] ?? [];
|
||||||
return [{
|
return [{ label: 'TPS', data: raw.map((p) => ({ x: new Date(p.time), y: p.value })) }];
|
||||||
label: 'TPS',
|
|
||||||
data: raw.map((p) => ({ x: new Date(p.time), y: p.value })),
|
|
||||||
}];
|
|
||||||
}, [tpsMetrics]);
|
}, [tpsMetrics]);
|
||||||
|
|
||||||
const errSeries = useMemo(() => {
|
const errSeries = useMemo(() => {
|
||||||
@@ -137,24 +186,24 @@ function AgentPerformanceContent({ agent }: { agent: any }) {
|
|||||||
return [{
|
return [{
|
||||||
label: 'Error Rate',
|
label: 'Error Rate',
|
||||||
data: raw.map((p) => ({ x: new Date(p.time), y: p.value * 100 })),
|
data: raw.map((p) => ({ x: new Date(p.time), y: p.value * 100 })),
|
||||||
|
color: 'var(--error)',
|
||||||
}];
|
}];
|
||||||
}, [errMetrics]);
|
}, [errMetrics]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.performanceContent}>
|
<div className={styles.detailContent}>
|
||||||
<div className={styles.chartSection}>
|
<div className={styles.chartPanel}>
|
||||||
<div className={styles.chartLabel}>Throughput (TPS)</div>
|
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
||||||
{tpsSeries[0].data.length > 0 ? (
|
{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 className={styles.emptyChart}>No data available</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className={styles.chartPanel}>
|
||||||
<div className={styles.chartSection}>
|
<div className={styles.chartTitle}>Error Rate (%)</div>
|
||||||
<div className={styles.chartLabel}>Error Rate (%)</div>
|
|
||||||
{errSeries[0].data.length > 0 ? (
|
{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>
|
<div className={styles.emptyChart}>No data available</div>
|
||||||
)}
|
)}
|
||||||
@@ -163,197 +212,308 @@ function AgentPerformanceContent({ agent }: { agent: any }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── AgentHealth page ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function AgentHealth() {
|
export default function AgentHealth() {
|
||||||
const { appId } = useParams();
|
const { appId } = useParams();
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data: agents } = useAgents(undefined, appId);
|
const { data: agents } = useAgents(undefined, appId);
|
||||||
const { data: catalog } = useRouteCatalog();
|
|
||||||
const { data: events } = useAgentEvents(appId);
|
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 agentList = agents ?? [];
|
||||||
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 liveCount = (agents || []).filter((a: any) => a.status === 'LIVE').length;
|
const groups = useMemo(() => groupByApp(agentList), [agentList]);
|
||||||
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 feedEvents = useMemo(() =>
|
// Aggregate stats
|
||||||
(events || []).map((e: any) => ({
|
const totalInstances = agentList.length;
|
||||||
id: String(e.id),
|
const liveCount = agentList.filter((a) => normalizeStatus(a.status) === 'live').length;
|
||||||
severity: e.eventType === 'WENT_DEAD' ? 'error' as const
|
const staleCount = agentList.filter((a) => normalizeStatus(a.status) === 'stale').length;
|
||||||
: e.eventType === 'WENT_STALE' ? 'warning' as const
|
const deadCount = agentList.filter((a) => normalizeStatus(a.status) === 'dead').length;
|
||||||
: e.eventType === 'RECOVERED' ? 'success' as const
|
const totalTps = agentList.reduce((s, a) => s + (a.tps ?? 0), 0);
|
||||||
: 'running' as const,
|
const totalActiveRoutes = agentList.reduce((s, a) => s + (a.activeRoutes ?? 0), 0);
|
||||||
message: `${e.agentId}: ${e.eventType}${e.detail ? ' — ' + e.detail : ''}`,
|
const totalRoutes = agentList.reduce((s, a) => s + (a.totalRoutes ?? 0), 0);
|
||||||
timestamp: new Date(e.timestamp),
|
|
||||||
})),
|
// 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],
|
[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 (
|
return (
|
||||||
<div>
|
<div className={styles.content}>
|
||||||
|
{/* Stat strip */}
|
||||||
<div className={styles.statStrip}>
|
<div className={styles.statStrip}>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Total Agents"
|
label="Total Agents"
|
||||||
value={(agents || []).length}
|
value={String(totalInstances)}
|
||||||
|
accent={deadCount > 0 ? 'warning' : 'amber'}
|
||||||
detail={
|
detail={
|
||||||
<span className={styles.statusBreakdown}>
|
<span className={styles.breakdown}>
|
||||||
<span className={styles.statusLive}>{liveCount} live</span>
|
<span className={styles.bpLive}><StatusDot variant="live" /> {liveCount} live</span>
|
||||||
<span className={styles.statusStale}>{staleCount} stale</span>
|
<span className={styles.bpStale}><StatusDot variant="stale" /> {staleCount} stale</span>
|
||||||
<span className={styles.statusDead}>{deadCount} dead</span>
|
<span className={styles.bpDead}><StatusDot variant="dead" /> {deadCount} dead</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<StatCard label="Applications" value={uniqueApps} />
|
<StatCard
|
||||||
<StatCard label="Active Routes" value={activeRoutes} />
|
label="Applications"
|
||||||
<StatCard label="Total TPS" value={totalTps.toFixed(1)} detail="msg/s" />
|
value={String(groups.length)}
|
||||||
<StatCard label="Dead" value={deadCount} accent={deadCount > 0 ? 'error' : undefined} detail={deadCount > 0 ? 'requires attention' : undefined} />
|
accent="running"
|
||||||
</div>
|
detail={
|
||||||
|
<span className={styles.breakdown}>
|
||||||
<div className={styles.scopeTrail}>
|
<span className={styles.bpLive}>
|
||||||
<span className={styles.scopeLabel}>{liveCount}/{(agents || []).length} live</span>
|
<StatusDot variant="live" /> {groups.filter((g) => g.deadCount === 0 && g.staleCount === 0).length} healthy
|
||||||
</div>
|
</span>
|
||||||
|
<span className={styles.bpStale}>
|
||||||
<div className={styles.groupGrid}>
|
<StatusDot variant="stale" /> {groups.filter((g) => g.staleCount > 0 && g.deadCount === 0).length} degraded
|
||||||
{Object.entries(apps).map(([group, groupAgents]) => {
|
</span>
|
||||||
const deadInGroup = (groupAgents || []).filter((a: any) => a.status === 'DEAD');
|
<span className={styles.bpDead}>
|
||||||
const groupTps = (groupAgents || []).reduce((s: number, a: any) => s + (a.tps || 0), 0);
|
<StatusDot variant="dead" /> {groups.filter((g) => g.deadCount > 0).length} critical
|
||||||
const groupActiveRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.activeRoutes || 0), 0);
|
</span>
|
||||||
const groupTotalRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.totalRoutes || 0), 0);
|
</span>
|
||||||
const liveInGroup = (groupAgents || []).filter((a: any) => a.status === 'LIVE').length;
|
}
|
||||||
return (
|
/>
|
||||||
<GroupCard
|
<StatCard
|
||||||
key={group}
|
label="Active Routes"
|
||||||
title={group}
|
value={
|
||||||
headerRight={
|
<span
|
||||||
<Badge
|
className={
|
||||||
label={`${liveInGroup}/${groupAgents?.length ?? 0} LIVE`}
|
styles[
|
||||||
color={
|
totalActiveRoutes === 0
|
||||||
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
|
? 'routesError'
|
||||||
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
|
: totalActiveRoutes < totalRoutes
|
||||||
: 'success'
|
? 'routesWarning'
|
||||||
}
|
: 'routesSuccess'
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{deadInGroup.length > 0 && (
|
{totalActiveRoutes}/{totalRoutes}
|
||||||
<Alert variant="error">{deadInGroup.length} instance(s) unreachable</Alert>
|
</span>
|
||||||
)}
|
}
|
||||||
<table className={styles.instanceTable}>
|
accent={totalActiveRoutes === 0 ? 'error' : totalActiveRoutes < totalRoutes ? 'warning' : 'success'}
|
||||||
<thead>
|
detail={totalActiveRoutes < totalRoutes ? `${totalRoutes - totalActiveRoutes} suspended` : 'all routes active'}
|
||||||
<tr>
|
/>
|
||||||
<th className={styles.thStatus} />
|
<StatCard
|
||||||
<th>Instance</th>
|
label="Total TPS"
|
||||||
<th>State</th>
|
value={totalTps.toFixed(1)}
|
||||||
<th>Uptime</th>
|
accent="amber"
|
||||||
<th>TPS</th>
|
detail="msg/s"
|
||||||
<th>Errors</th>
|
/>
|
||||||
<th>Heartbeat</th>
|
<StatCard
|
||||||
</tr>
|
label="Dead"
|
||||||
</thead>
|
value={String(deadCount)}
|
||||||
<tbody>
|
accent={deadCount > 0 ? 'error' : 'success'}
|
||||||
{(groupAgents || []).map((agent: any) => (
|
detail={deadCount > 0 ? 'requires attention' : 'all healthy'}
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</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 && (
|
{feedEvents.length > 0 && (
|
||||||
<div className={styles.eventCard}>
|
<div className={styles.eventCard}>
|
||||||
<div className={styles.eventCardHeader}>
|
<div className={styles.eventCardHeader}>
|
||||||
<span>Timeline</span>
|
<span className={styles.sectionTitle}>Timeline</span>
|
||||||
<Badge label={`${feedEvents.length} events`} variant="outlined" />
|
<span className={styles.sectionMeta}>{feedEvents.length} events</span>
|
||||||
</div>
|
</div>
|
||||||
<EventFeed events={feedEvents} maxItems={100} />
|
<EventFeed events={feedEvents} maxItems={100} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedAgent && (
|
{/* Detail panel */}
|
||||||
|
{selectedInstance && (
|
||||||
<DetailPanel
|
<DetailPanel
|
||||||
key={selectedAgent.id}
|
open={panelOpen}
|
||||||
open={true}
|
onClose={() => {
|
||||||
title={selectedAgent.name ?? selectedAgent.id}
|
setPanelOpen(false);
|
||||||
onClose={() => setSelectedAgent(null)}
|
setSelectedInstance(null);
|
||||||
className={styles.detailPanelOverride}
|
}}
|
||||||
>
|
title={selectedInstance.name ?? selectedInstance.id}
|
||||||
<AgentOverviewContent agent={selectedAgent} />
|
tabs={detailTabs}
|
||||||
<div className={styles.panelDivider} />
|
/>
|
||||||
<AgentPerformanceContent agent={selectedAgent} />
|
|
||||||
</DetailPanel>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 {
|
.statStrip {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
@@ -5,18 +14,67 @@
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agentHeader {
|
/* Scope trail — matches /agents */
|
||||||
|
.scopeTrail {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 6px;
|
||||||
margin: 16px 0;
|
margin-bottom: 12px;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agentHeader h2 {
|
.scopeLink {
|
||||||
font-size: 18px;
|
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-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 {
|
.routeBadges {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -24,9 +82,10 @@
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Charts 3x2 grid */
|
||||||
.chartsGrid {
|
.chartsGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
@@ -53,14 +112,46 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sectionTitle {
|
.chartMeta {
|
||||||
font-size: 13px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
color: var(--text-muted);
|
||||||
color: var(--text-primary);
|
font-family: var(--font-mono);
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
@@ -69,107 +160,12 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.eventCardHeader {
|
.timelineHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 16px;
|
padding: 12px 16px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
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 { useMemo, useState } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams, Link } from 'react-router';
|
||||||
import {
|
import {
|
||||||
StatCard, StatusDot, Badge, Card,
|
StatCard, StatusDot, Badge, LineChart, AreaChart, BarChart,
|
||||||
LineChart, AreaChart, BarChart, EventFeed, Breadcrumb, Spinner, EmptyState,
|
EventFeed, Spinner, EmptyState, SectionHeader, MonoText,
|
||||||
|
LogViewer, Tabs, useGlobalFilters,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
|
import type { FeedEvent, LogEntry } from '@cameleer/design-system';
|
||||||
import styles from './AgentInstance.module.css';
|
import styles from './AgentInstance.module.css';
|
||||||
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
||||||
import { useStatsTimeseries } from '../../api/queries/executions';
|
import { useStatsTimeseries } from '../../api/queries/executions';
|
||||||
import { useAgentMetrics } from '../../api/queries/agent-metrics';
|
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() {
|
export default function AgentInstance() {
|
||||||
const { appId, instanceId } = useParams();
|
const { appId, instanceId } = useParams();
|
||||||
const { timeRange } = useGlobalFilters();
|
const { timeRange } = useGlobalFilters();
|
||||||
|
const [logFilter, setLogFilter] = useState('all');
|
||||||
const timeFrom = timeRange.start.toISOString();
|
const timeFrom = timeRange.start.toISOString();
|
||||||
const timeTo = timeRange.end.toISOString();
|
const timeTo = timeRange.end.toISOString();
|
||||||
|
|
||||||
@@ -20,8 +28,8 @@ export default function AgentInstance() {
|
|||||||
const { data: events } = useAgentEvents(appId, instanceId);
|
const { data: events } = useAgentEvents(appId, instanceId);
|
||||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
|
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
|
||||||
|
|
||||||
const agent = useMemo(() =>
|
const agent = useMemo(
|
||||||
(agents || []).find((a: any) => a.id === instanceId) as any,
|
() => (agents || []).find((a: any) => a.id === instanceId) as any,
|
||||||
[agents, instanceId],
|
[agents, instanceId],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -43,26 +51,34 @@ export default function AgentInstance() {
|
|||||||
60,
|
60,
|
||||||
);
|
);
|
||||||
|
|
||||||
const chartData = useMemo(() =>
|
const chartData = useMemo(
|
||||||
(timeseries?.buckets || []).map((b: any) => ({
|
() =>
|
||||||
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
(timeseries?.buckets || []).map((b: any) => ({
|
||||||
throughput: b.totalCount,
|
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||||
latency: b.avgDurationMs,
|
throughput: b.totalCount,
|
||||||
errors: b.failedCount,
|
latency: b.avgDurationMs,
|
||||||
})),
|
errors: b.failedCount,
|
||||||
|
})),
|
||||||
[timeseries],
|
[timeseries],
|
||||||
);
|
);
|
||||||
|
|
||||||
const feedEvents = useMemo(() =>
|
const feedEvents = useMemo<FeedEvent[]>(
|
||||||
(events || []).filter((e: any) => !instanceId || e.agentId === instanceId).map((e: any) => ({
|
() =>
|
||||||
id: String(e.id),
|
(events || [])
|
||||||
severity: e.eventType === 'WENT_DEAD' ? 'error' as const
|
.filter((e: any) => !instanceId || e.agentId === instanceId)
|
||||||
: e.eventType === 'WENT_STALE' ? 'warning' as const
|
.map((e: any) => ({
|
||||||
: e.eventType === 'RECOVERED' ? 'success' as const
|
id: String(e.id),
|
||||||
: 'running' as const,
|
severity:
|
||||||
message: `${e.eventType}${e.detail ? ' — ' + e.detail : ''}`,
|
e.eventType === 'WENT_DEAD'
|
||||||
timestamp: new Date(e.timestamp),
|
? ('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],
|
[events, instanceId],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -88,194 +104,305 @@ export default function AgentInstance() {
|
|||||||
const gcSeries = useMemo(() => {
|
const gcSeries = useMemo(() => {
|
||||||
const pts = jvmMetrics?.metrics?.['jvm.gc.time'];
|
const pts = jvmMetrics?.metrics?.['jvm.gc.time'];
|
||||||
if (!pts?.length) return null;
|
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]);
|
}, [jvmMetrics]);
|
||||||
|
|
||||||
const throughputSeries = useMemo(() =>
|
const throughputSeries = useMemo(
|
||||||
chartData.length ? [{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }] : null,
|
() =>
|
||||||
|
chartData.length
|
||||||
|
? [{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]
|
||||||
|
: null,
|
||||||
[chartData],
|
[chartData],
|
||||||
);
|
);
|
||||||
|
|
||||||
const errorSeries = useMemo(() =>
|
const errorSeries = useMemo(
|
||||||
chartData.length ? [{ label: 'Errors', data: chartData.map((d: any, i: number) => ({ x: i, y: d.errors })) }] : null,
|
() =>
|
||||||
|
chartData.length
|
||||||
|
? [{ label: 'Errors', data: chartData.map((d: any, i: number) => ({ x: i, y: d.errors })) }]
|
||||||
|
: null,
|
||||||
[chartData],
|
[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" />;
|
if (isLoading) return <Spinner size="lg" />;
|
||||||
|
|
||||||
return (
|
const statusVariant =
|
||||||
<div>
|
agent?.status === 'LIVE' ? 'live' : agent?.status === 'STALE' ? 'stale' : 'dead';
|
||||||
<Breadcrumb items={[
|
const statusColor: 'success' | 'warning' | 'error' =
|
||||||
{ label: 'Agents', href: '/agents' },
|
agent?.status === 'LIVE' ? 'success' : agent?.status === 'STALE' ? 'warning' : 'error';
|
||||||
{ label: appId || '', href: `/agents/${appId}` },
|
const cpuDisplay = cpuPct != null ? (cpuPct * 100).toFixed(0) : null;
|
||||||
{ label: agent?.name || instanceId || '' },
|
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 && (
|
{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}>
|
<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>
|
<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.scopeSep}>▸</span>
|
||||||
<span className={styles.scopeCurrent}>{agent.name}</span>
|
<span className={styles.scopeCurrent}>{agent.name}</span>
|
||||||
<Badge
|
<StatusDot variant={statusVariant} />
|
||||||
label={agent.status.toUpperCase()}
|
<Badge label={agent.status} color={statusColor} />
|
||||||
color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'}
|
{agent.version && <Badge label={agent.version} variant="outlined" color="auto" />}
|
||||||
/>
|
|
||||||
{agent.version && <Badge label={agent.version} variant="outlined" />}
|
|
||||||
<Badge
|
<Badge
|
||||||
label={`${agent.activeRoutes ?? (agent.routeIds?.length ?? 0)}/${agent.totalRoutes ?? (agent.routeIds?.length ?? 0)} routes`}
|
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>
|
</div>
|
||||||
|
|
||||||
<Card className={styles.infoCard}>
|
{/* Process info card */}
|
||||||
<div className={styles.paneTitle}>Process Information</div>
|
<div className={styles.processCard}>
|
||||||
<div className={styles.infoGrid}>
|
<SectionHeader>Process Information</SectionHeader>
|
||||||
{agent?.capabilities?.jvmVersion && (
|
<div className={styles.processGrid}>
|
||||||
<div>
|
{agent.capabilities?.jvmVersion && (
|
||||||
<span className={styles.infoLabel}>JVM</span>
|
<>
|
||||||
<span>{agent.capabilities.jvmVersion}</span>
|
<span className={styles.processLabel}>JVM</span>
|
||||||
</div>
|
<MonoText size="xs">{agent.capabilities.jvmVersion}</MonoText>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{agent?.capabilities?.camelVersion && (
|
{agent.capabilities?.camelVersion && (
|
||||||
<div>
|
<>
|
||||||
<span className={styles.infoLabel}>Camel</span>
|
<span className={styles.processLabel}>Camel</span>
|
||||||
<span>{agent.capabilities.camelVersion}</span>
|
<MonoText size="xs">{agent.capabilities.camelVersion}</MonoText>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
{agent?.capabilities?.springBootVersion && (
|
{agent.capabilities?.springBootVersion && (
|
||||||
<div>
|
<>
|
||||||
<span className={styles.infoLabel}>Spring Boot</span>
|
<span className={styles.processLabel}>Spring Boot</span>
|
||||||
<span>{agent.capabilities.springBootVersion}</span>
|
<MonoText size="xs">{agent.capabilities.springBootVersion}</MonoText>
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
|
<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>
|
</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>
|
</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.chartsGrid}>
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartHeader}>
|
<div className={styles.chartHeader}>
|
||||||
<div className={styles.chartTitle}>CPU Usage</div>
|
<span className={styles.chartTitle}>CPU Usage</span>
|
||||||
<div className={styles.chartMeta}>{cpuPct != null ? `${(cpuPct * 100).toFixed(0)}% current` : ''}</div>
|
<span className={styles.chartMeta}>
|
||||||
|
{cpuDisplay != null ? `${cpuDisplay}% current` : ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{cpuSeries
|
{cpuSeries ? (
|
||||||
? <AreaChart series={cpuSeries} yLabel="%" height={200} />
|
<AreaChart
|
||||||
: <EmptyState title="No data" description="No CPU metrics available" />}
|
series={cpuSeries}
|
||||||
|
height={160}
|
||||||
|
yLabel="%"
|
||||||
|
threshold={{ value: 85, label: 'Alert' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState title="No data" description="No CPU metrics available" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartHeader}>
|
<div className={styles.chartHeader}>
|
||||||
<div className={styles.chartTitle}>Memory (Heap)</div>
|
<span className={styles.chartTitle}>Memory (Heap)</span>
|
||||||
<div className={styles.chartMeta}>{heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : ''}</div>
|
<span className={styles.chartMeta}>
|
||||||
|
{heapUsedMB != null && heapMaxMB != null
|
||||||
|
? `${heapUsedMB} MB / ${heapMaxMB} MB`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{heapSeries
|
{heapSeries ? (
|
||||||
? <AreaChart series={heapSeries} yLabel="MB" height={200} />
|
<AreaChart series={heapSeries} height={160} yLabel="MB" />
|
||||||
: <EmptyState title="No data" description="No heap metrics available" />}
|
) : (
|
||||||
|
<EmptyState title="No data" description="No heap metrics available" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartHeader}>
|
<div className={styles.chartHeader}>
|
||||||
<div className={styles.chartTitle}>Throughput</div>
|
<span className={styles.chartTitle}>Throughput</span>
|
||||||
<div className={styles.chartMeta}>{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}</div>
|
<span className={styles.chartMeta}>
|
||||||
|
{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{throughputSeries
|
{throughputSeries ? (
|
||||||
? <AreaChart series={throughputSeries} yLabel="msg/s" height={200} />
|
<LineChart series={throughputSeries} height={160} yLabel="msg/s" />
|
||||||
: <EmptyState title="No data" description="No throughput data in range" />}
|
) : (
|
||||||
|
<EmptyState title="No data" description="No throughput data in range" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartHeader}>
|
<div className={styles.chartHeader}>
|
||||||
<div className={styles.chartTitle}>Error Rate</div>
|
<span className={styles.chartTitle}>Error Rate</span>
|
||||||
<div className={styles.chartMeta}>{agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : ''}</div>
|
<span className={styles.chartMeta}>
|
||||||
|
{agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{errorSeries
|
{errorSeries ? (
|
||||||
? <LineChart series={errorSeries} yLabel="%" height={200} />
|
<LineChart series={errorSeries} height={160} yLabel="err/h" />
|
||||||
: <EmptyState title="No data" description="No error data in range" />}
|
) : (
|
||||||
|
<EmptyState title="No data" description="No error data in range" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartHeader}>
|
<div className={styles.chartHeader}>
|
||||||
<div className={styles.chartTitle}>Thread Count</div>
|
<span className={styles.chartTitle}>Thread Count</span>
|
||||||
{threadSeries && <div className={styles.chartMeta}>{threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active</div>}
|
<span className={styles.chartMeta}>
|
||||||
|
{threadSeries
|
||||||
|
? `${threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{threadSeries
|
{threadSeries ? (
|
||||||
? <LineChart series={threadSeries} yLabel="threads" height={200} />
|
<LineChart series={threadSeries} height={160} yLabel="threads" />
|
||||||
: <EmptyState title="No data" description="No thread metrics available" />}
|
) : (
|
||||||
|
<EmptyState title="No data" description="No thread metrics available" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartHeader}>
|
<div className={styles.chartHeader}>
|
||||||
<div className={styles.chartTitle}>GC Pauses</div>
|
<span className={styles.chartTitle}>GC Pauses</span>
|
||||||
|
<span className={styles.chartMeta} />
|
||||||
</div>
|
</div>
|
||||||
{gcSeries
|
{gcSeries ? (
|
||||||
? <BarChart series={gcSeries} yLabel="ms" height={200} />
|
<BarChart series={gcSeries} height={160} yLabel="ms" />
|
||||||
: <EmptyState title="No data" description="No GC metrics available" />}
|
) : (
|
||||||
|
<EmptyState title="No data" description="No GC metrics available" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.bottomSection}>
|
{/* Log + Timeline side by side */}
|
||||||
<EmptyState title="Application Log" description="Application log streaming is not yet available" />
|
<div className={styles.bottomRow}>
|
||||||
|
<div className={styles.logCard}>
|
||||||
<div className={styles.eventCard}>
|
<div className={styles.logHeader}>
|
||||||
<div className={styles.eventCardHeader}>
|
<SectionHeader>Application Log</SectionHeader>
|
||||||
<span>Timeline</span>
|
<Tabs tabs={LOG_TABS} active={logFilter} onChange={setLogFilter} />
|
||||||
<span className={styles.eventCount}>{feedEvents.length} events</span>
|
|
||||||
</div>
|
</div>
|
||||||
{feedEvents.length > 0
|
{filteredLogs.length > 0 ? (
|
||||||
? <EventFeed events={feedEvents} maxItems={50} />
|
<LogViewer entries={filteredLogs} maxHeight={360} />
|
||||||
: <div className={styles.emptyEvents}>No events in the selected time range.</div>}
|
) : (
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUptime(seconds?: number): string {
|
function formatUptime(seconds?: number): string {
|
||||||
if (!seconds) return '—';
|
if (!seconds) return '\u2014';
|
||||||
const days = Math.floor(seconds / 86400);
|
const days = Math.floor(seconds / 86400);
|
||||||
const hours = Math.floor((seconds % 86400) / 3600);
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
const mins = Math.floor((seconds % 3600) / 60);
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
.healthStrip {
|
/* Scrollable content area */
|
||||||
display: grid;
|
.content {
|
||||||
grid-template-columns: repeat(5, 1fr);
|
flex: 1;
|
||||||
gap: 10px;
|
overflow-y: auto;
|
||||||
|
padding: 20px 24px 40px;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--bg-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter bar spacing */
|
||||||
|
.filterBar {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Table section */
|
||||||
.tableSection {
|
.tableSection {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
@@ -39,6 +47,93 @@
|
|||||||
font-family: var(--font-mono);
|
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 {
|
.panelSection {
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
@@ -59,19 +154,21 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelSectionMeta {
|
.panelSectionMeta {
|
||||||
font-size: 11px;
|
margin-left: auto;
|
||||||
font-weight: 400;
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
color: var(--text-muted);
|
color: var(--text-faint);
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Overview grid */
|
||||||
.overviewGrid {
|
.overviewGrid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -95,45 +192,67 @@
|
|||||||
padding-top: 2px;
|
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 {
|
.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;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 24px;
|
transition: color 0.15s, opacity 0.15s;
|
||||||
height: 24px;
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 4px;
|
|
||||||
transition: color 0.15s, background 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inspectLink:hover {
|
.inspectLink:hover {
|
||||||
color: var(--accent, #c6820e);
|
color: var(--text-primary);
|
||||||
background: var(--bg-hover);
|
opacity: 1;
|
||||||
}
|
|
||||||
|
|
||||||
.detailPanelOverride {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 100vh;
|
|
||||||
z-index: 100;
|
|
||||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Open full details link in panel */
|
||||||
.openDetailLink {
|
.openDetailLink {
|
||||||
display: inline-block;
|
background: transparent;
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--accent, #c6820e);
|
|
||||||
cursor: pointer;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
border: none;
|
||||||
|
color: var(--amber);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
text-decoration: none;
|
font-family: var(--font-body);
|
||||||
|
transition: color 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.openDetailLink:hover {
|
.openDetailLink:hover {
|
||||||
|
color: var(--amber-deep);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,186 +1,417 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
StatCard, StatusDot, Badge, MonoText,
|
DataTable,
|
||||||
DataTable, DetailPanel, ProcessorTimeline, RouteFlow,
|
DetailPanel,
|
||||||
Alert, Collapsible, CodeBlock, ShortcutsBar,
|
ShortcutsBar,
|
||||||
} from '@cameleer/design-system';
|
ProcessorTimeline,
|
||||||
import type { Column } from '@cameleer/design-system';
|
RouteFlow,
|
||||||
import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail } from '../../api/queries/executions';
|
KpiStrip,
|
||||||
import { useDiagramLayout } from '../../api/queries/diagrams';
|
StatusDot,
|
||||||
import { useGlobalFilters } from '@cameleer/design-system';
|
MonoText,
|
||||||
import type { ExecutionSummary } from '../../api/types';
|
Badge,
|
||||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
useGlobalFilters,
|
||||||
import styles from './Dashboard.module.css';
|
} 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 }
|
// Row type extends ExecutionSummary with an `id` field for DataTable
|
||||||
|
interface Row extends ExecutionSummary {
|
||||||
function formatDuration(ms: number): string {
|
id: string
|
||||||
if (ms < 1000) return `${ms}ms`;
|
|
||||||
return `${(ms / 1000).toFixed(1)}s`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
const { appId, routeId } = useParams();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { timeRange } = useGlobalFilters();
|
|
||||||
const timeFrom = timeRange.start.toISOString();
|
|
||||||
const timeTo = timeRange.end.toISOString();
|
|
||||||
|
|
||||||
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);
|
function statusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
|
||||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
|
switch (status) {
|
||||||
const { data: searchResult } = useSearchExecutions({
|
case 'COMPLETED': return 'success'
|
||||||
timeFrom, timeTo,
|
case 'FAILED': return 'error'
|
||||||
routeId: routeId || undefined,
|
case 'RUNNING': return 'running'
|
||||||
application: appId || undefined,
|
default: return 'warning'
|
||||||
offset: 0, limit: 50,
|
}
|
||||||
}, true);
|
}
|
||||||
const { data: detail } = useExecutionDetail(selectedId);
|
|
||||||
|
|
||||||
const rows: Row[] = useMemo(() =>
|
function statusLabel(status: string): string {
|
||||||
(searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
switch (status) {
|
||||||
[searchResult],
|
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;
|
function flattenProcessors(nodes: any[]): any[] {
|
||||||
const failedCount = stats?.failedCount ?? 0;
|
const result: any[] = []
|
||||||
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount * 100) : 100;
|
let offset = 0
|
||||||
const throughput = timeWindowSeconds > 0 ? totalCount / timeWindowSeconds : 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(() =>
|
// ─── Table columns (base, without inspect action) ────────────────────────────
|
||||||
(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 prevTotal = stats?.prevTotalCount ?? 0;
|
function buildBaseColumns(): Column<Row>[] {
|
||||||
const prevFailed = stats?.prevFailedCount ?? 0;
|
return [
|
||||||
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>[] = [
|
|
||||||
{
|
{
|
||||||
key: 'status', header: 'Status', width: '80px',
|
key: 'status',
|
||||||
render: (v, row) => (
|
header: 'Status',
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
width: '80px',
|
||||||
<StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />
|
render: (_: unknown, row: Row) => (
|
||||||
<MonoText size="xs">{v === 'COMPLETED' ? 'OK' : v === 'FAILED' ? 'ERR' : 'RUN'}</MonoText>
|
<span className={styles.statusCell}>
|
||||||
|
<StatusDot variant={statusToVariant(row.status)} />
|
||||||
|
<MonoText size="xs">{statusLabel(row.status)}</MonoText>
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '_inspect' as any, header: '', width: '36px',
|
key: 'routeId',
|
||||||
render: (_v, row) => (
|
header: 'Route',
|
||||||
<a
|
sortable: true,
|
||||||
href={`/exchanges/${row.executionId}`}
|
render: (_: unknown, row: Row) => (
|
||||||
onClick={(e) => { e.stopPropagation(); e.preventDefault(); navigate(`/exchanges/${row.executionId}`); }}
|
<span className={styles.routeName}>{row.routeId}</span>
|
||||||
className={styles.inspectLink}
|
|
||||||
title="Open full details"
|
|
||||||
>↗</a>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{ 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,
|
key: 'applicationName',
|
||||||
render: (v) => <MonoText size="sm">{formatDuration(v as number)}</MonoText>,
|
header: 'Application',
|
||||||
|
sortable: true,
|
||||||
|
render: (_: unknown, row: Row) => (
|
||||||
|
<span className={styles.appName}>{row.applicationName ?? ''}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'agentId', header: 'Agent',
|
key: 'executionId',
|
||||||
render: (v) => v ? <Badge label={String(v)} color="auto" /> : null,
|
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 (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div className={styles.healthStrip}>
|
{/* Scrollable content */}
|
||||||
<StatCard
|
<div className={styles.content}>
|
||||||
label="Exchanges"
|
{/* KPI strip */}
|
||||||
value={totalCount.toLocaleString()}
|
<KpiStrip items={kpiItems} />
|
||||||
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>
|
|
||||||
|
|
||||||
<div className={styles.tableSection}>
|
{/* Exchanges table */}
|
||||||
<div className={styles.tableHeader}>
|
<div className={styles.tableSection}>
|
||||||
<span className={styles.tableTitle}>Recent Exchanges</span>
|
<div className={styles.tableHeader}>
|
||||||
<div className={styles.tableRight}>
|
<span className={styles.tableTitle}>Recent Exchanges</span>
|
||||||
<span className={styles.tableMeta}>{rows.length} of {searchResult?.total ?? 0} exchanges</span>
|
<div className={styles.tableRight}>
|
||||||
<Badge label="LIVE" color="success" />
|
<span className={styles.tableMeta}>
|
||||||
|
{rows.length.toLocaleString()} of {(searchResult?.total ?? 0).toLocaleString()} exchanges
|
||||||
|
</span>
|
||||||
|
<Badge label="LIVE" color="success" />
|
||||||
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<DataTable
|
|
||||||
columns={columns}
|
|
||||||
data={rows}
|
|
||||||
onRowClick={(row) => { setSelectedId(row.id); }}
|
|
||||||
selectedId={selectedId ?? undefined}
|
|
||||||
sortable
|
|
||||||
pageSize={25}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedId && detail && (
|
{/* Shortcuts bar */}
|
||||||
|
<ShortcutsBar shortcuts={SHORTCUTS} />
|
||||||
|
|
||||||
|
{/* Detail panel */}
|
||||||
|
{selectedRow && detail && (
|
||||||
<DetailPanel
|
<DetailPanel
|
||||||
key={selectedId}
|
open={panelOpen}
|
||||||
open={true}
|
onClose={() => setPanelOpen(false)}
|
||||||
onClose={() => setSelectedId(null)}
|
title={`${detail.routeId} \u2014 ${selectedRow.executionId.slice(0, 12)}`}
|
||||||
title={`${detail.routeId} — ${selectedId.slice(0, 12)}`}
|
|
||||||
className={styles.detailPanelOverride}
|
|
||||||
>
|
>
|
||||||
{/* Open full details link */}
|
{/* Link to full detail page */}
|
||||||
<div className={styles.panelSection}>
|
<div className={styles.panelSection}>
|
||||||
<button
|
<button
|
||||||
className={styles.openDetailLink}
|
className={styles.openDetailLink}
|
||||||
@@ -196,9 +427,9 @@ export default function Dashboard() {
|
|||||||
<div className={styles.overviewGrid}>
|
<div className={styles.overviewGrid}>
|
||||||
<div className={styles.overviewRow}>
|
<div className={styles.overviewRow}>
|
||||||
<span className={styles.overviewLabel}>Status</span>
|
<span className={styles.overviewLabel}>Status</span>
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<span className={styles.statusCell}>
|
||||||
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : 'error'} />
|
<StatusDot variant={statusToVariant(detail.status)} />
|
||||||
<span>{detail.status}</span>
|
<span>{statusLabel(detail.status)}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.overviewRow}>
|
<div className={styles.overviewRow}>
|
||||||
@@ -211,44 +442,38 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.overviewRow}>
|
<div className={styles.overviewRow}>
|
||||||
<span className={styles.overviewLabel}>Agent</span>
|
<span className={styles.overviewLabel}>Agent</span>
|
||||||
<MonoText size="sm">{detail.agentId ?? '—'}</MonoText>
|
<MonoText size="sm">{detail.agentId ?? '\u2014'}</MonoText>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.overviewRow}>
|
<div className={styles.overviewRow}>
|
||||||
<span className={styles.overviewLabel}>Correlation</span>
|
<span className={styles.overviewLabel}>Correlation</span>
|
||||||
<MonoText size="xs">{detail.correlationId ?? '—'}</MonoText>
|
<MonoText size="xs">{detail.correlationId ?? '\u2014'}</MonoText>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.overviewRow}>
|
<div className={styles.overviewRow}>
|
||||||
<span className={styles.overviewLabel}>Timestamp</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Errors */}
|
{/* Errors */}
|
||||||
{detail.errorMessage && (
|
{errorMsg && (
|
||||||
<div className={styles.panelSection}>
|
<div className={styles.panelSection}>
|
||||||
<div className={styles.panelSectionTitle}>Errors</div>
|
<div className={styles.panelSectionTitle}>Errors</div>
|
||||||
<Alert variant="error">
|
<div className={styles.errorBlock}>
|
||||||
<strong>{detail.errorMessage.split(':')[0]}</strong>
|
<div className={styles.errorClass}>{errorClass}</div>
|
||||||
<div>{detail.errorMessage.includes(':') ? detail.errorMessage.substring(detail.errorMessage.indexOf(':') + 1).trim() : ''}</div>
|
<div className={styles.errorMessage}>{errorMsg}</div>
|
||||||
</Alert>
|
</div>
|
||||||
{detail.errorStackTrace && (
|
|
||||||
<Collapsible title="Stack Trace">
|
|
||||||
<CodeBlock content={detail.errorStackTrace} />
|
|
||||||
</Collapsible>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Route Flow */}
|
{/* Route Flow */}
|
||||||
<div className={styles.panelSection}>
|
<div className={styles.panelSection}>
|
||||||
<div className={styles.panelSectionTitle}>Route Flow</div>
|
<div className={styles.panelSectionTitle}>Route Flow</div>
|
||||||
{diagram ? (
|
{routeNodes.length > 0 ? (
|
||||||
<RouteFlow
|
<RouteFlow nodes={routeNodes} />
|
||||||
nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
|
) : (
|
||||||
onNodeClick={(_node, _i) => {}}
|
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>
|
||||||
/>
|
)}
|
||||||
) : <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Processor Timeline */}
|
{/* Processor Timeline */}
|
||||||
@@ -257,33 +482,17 @@ export default function Dashboard() {
|
|||||||
Processor Timeline
|
Processor Timeline
|
||||||
<span className={styles.panelSectionMeta}>{formatDuration(detail.durationMs)}</span>
|
<span className={styles.panelSectionMeta}>{formatDuration(detail.durationMs)}</span>
|
||||||
</div>
|
</div>
|
||||||
{procList.length ? (
|
{flatProcs.length > 0 ? (
|
||||||
<ProcessorTimeline
|
<ProcessorTimeline
|
||||||
processors={flattenProcessors(procList)}
|
processors={flatProcs}
|
||||||
totalMs={detail.durationMs}
|
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>
|
</div>
|
||||||
</DetailPanel>
|
</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 {
|
.exchangeHeader {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
@@ -38,14 +56,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.routeLink {
|
.routeLink {
|
||||||
color: var(--accent, #c6820e);
|
color: var(--amber);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.routeLink:hover {
|
.routeLink:hover {
|
||||||
color: var(--amber-deep, #a36b0b);
|
color: var(--amber-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
.headerDivider {
|
.headerDivider {
|
||||||
@@ -78,7 +96,9 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Correlation Chain */
|
/* ==========================================================================
|
||||||
|
CORRELATION CHAIN
|
||||||
|
========================================================================== */
|
||||||
.correlationChain {
|
.correlationChain {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
@@ -104,7 +124,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: var(--radius-sm, 4px);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
@@ -120,20 +140,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chainNodeCurrent {
|
.chainNodeCurrent {
|
||||||
background: var(--amber-bg, rgba(198, 130, 14, 0.08));
|
background: var(--amber-bg);
|
||||||
border-color: var(--accent, #c6820e);
|
border-color: var(--amber-light);
|
||||||
color: var(--accent, #c6820e);
|
color: var(--amber-deep);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chainNodeSuccess { border-left: 3px solid var(--success); }
|
.chainNodeSuccess {
|
||||||
.chainNodeError { border-left: 3px solid var(--error); }
|
border-left: 3px solid var(--success);
|
||||||
.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; }
|
.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 {
|
.timelineSection {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
@@ -174,7 +211,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-sm, 4px);
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,20 +231,22 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.toggleBtnActive {
|
.toggleBtnActive {
|
||||||
background: var(--accent, #c6820e);
|
background: var(--amber);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggleBtnActive:hover {
|
.toggleBtnActive:hover {
|
||||||
background: var(--amber-deep, #a36b0b);
|
background: var(--amber-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
.timelineBody {
|
.timelineBody {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Detail Split (IN / OUT panels) */
|
/* ==========================================================================
|
||||||
|
DETAIL SPLIT (IN / OUT panels)
|
||||||
|
========================================================================== */
|
||||||
.detailSplit {
|
.detailSplit {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -224,7 +263,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detailPanelError {
|
.detailPanelError {
|
||||||
border-color: var(--error-border, rgba(220, 38, 38, 0.3));
|
border-color: var(--error-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelHeader {
|
.panelHeader {
|
||||||
@@ -238,8 +277,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.detailPanelError .panelHeader {
|
.detailPanelError .panelHeader {
|
||||||
background: var(--error-bg, rgba(220, 38, 38, 0.06));
|
background: var(--error-bg);
|
||||||
border-bottom-color: var(--error-border, rgba(220, 38, 38, 0.3));
|
border-bottom-color: var(--error-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelTitle {
|
.panelTitle {
|
||||||
@@ -350,14 +389,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Error panel styles */
|
/* 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 {
|
.errorMessageBox {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: var(--error-bg, rgba(220, 38, 38, 0.06));
|
background: var(--error-bg);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: var(--radius-sm, 4px);
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--error-border, rgba(220, 38, 38, 0.3));
|
border: 1px solid var(--error-border);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
@@ -382,3 +440,11 @@
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
word-break: break-all;
|
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 { useState, useMemo } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
|
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
|
||||||
ProcessorTimeline, Breadcrumb, Spinner, RouteFlow,
|
ProcessorTimeline, Breadcrumb, Spinner, RouteFlow,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system'
|
||||||
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
|
import type { ProcessorStep, RouteNode } from '@cameleer/design-system'
|
||||||
import { useCorrelationChain } from '../../api/queries/correlation';
|
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions'
|
||||||
import { useDiagramLayout } from '../../api/queries/diagrams';
|
import { useCorrelationChain } from '../../api/queries/correlation'
|
||||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
import { useDiagramLayout } from '../../api/queries/diagrams'
|
||||||
import styles from './ExchangeDetail.module.css';
|
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping'
|
||||||
|
import styles from './ExchangeDetail.module.css'
|
||||||
|
|
||||||
function countProcessors(nodes: any[]): number {
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0);
|
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 {
|
function backendStatusToVariant(status: string): 'success' | 'error' | 'running' | 'warning' {
|
||||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
|
switch (status.toUpperCase()) {
|
||||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
|
case 'COMPLETED': return 'success'
|
||||||
return `${ms}ms`;
|
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> {
|
function parseHeaders(raw: string | undefined | null): Record<string, string> {
|
||||||
if (!raw) return {};
|
if (!raw) return {}
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw)
|
||||||
if (typeof parsed === 'object' && parsed !== null) {
|
if (typeof parsed === 'object' && parsed !== null) {
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {}
|
||||||
for (const [k, v] of Object.entries(parsed)) {
|
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 */ }
|
} 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() {
|
export default function ExchangeDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate();
|
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 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 [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt')
|
||||||
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 [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number | null>(null);
|
const procList = detail
|
||||||
const activeIndex = selectedProcessorIndex ?? defaultIndex;
|
? (detail.processors?.length ? detail.processors : (detail.children ?? []))
|
||||||
|
: []
|
||||||
|
|
||||||
const { data: snapshot } = useProcessorSnapshot(id ?? null, procList.length > 0 ? activeIndex : null);
|
// Flatten processor tree into ProcessorStep[]
|
||||||
|
const processors: ProcessorStep[] = useMemo(() => {
|
||||||
const processors = useMemo(() => {
|
if (!procList.length) return []
|
||||||
if (!procList.length) return [];
|
const result: ProcessorStep[] = []
|
||||||
const result: any[] = [];
|
let offset = 0
|
||||||
let offset = 0;
|
|
||||||
function walk(node: any) {
|
function walk(node: any) {
|
||||||
result.push({
|
result.push({
|
||||||
name: node.processorId || node.processorType,
|
name: node.processorId || node.processorType,
|
||||||
type: node.processorType,
|
type: node.processorType,
|
||||||
durationMs: node.durationMs ?? 0,
|
durationMs: node.durationMs ?? 0,
|
||||||
status: node.status === 'COMPLETED' ? 'ok' : node.status === 'FAILED' ? 'fail' : 'ok',
|
status: procStatusToStep(node.status ?? ''),
|
||||||
startMs: offset,
|
startMs: offset,
|
||||||
});
|
})
|
||||||
offset += node.durationMs ?? 0;
|
offset += node.durationMs ?? 0
|
||||||
if (node.children) node.children.forEach(walk);
|
if (node.children) node.children.forEach(walk)
|
||||||
}
|
}
|
||||||
procList.forEach(walk);
|
procList.forEach(walk)
|
||||||
return result;
|
return result
|
||||||
}, [procList]);
|
}, [procList])
|
||||||
|
|
||||||
const selectedProc = processors[activeIndex];
|
// Default selected processor: first failed, or 0
|
||||||
const isSelectedFailed = selectedProc?.status === 'fail';
|
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 [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number | null>(null)
|
||||||
const inputHeaders = parseHeaders(snapshot?.inputHeaders);
|
const activeIndex = selectedProcessorIndex ?? defaultIndex
|
||||||
const outputHeaders = parseHeaders(snapshot?.outputHeaders);
|
|
||||||
const inputBody = snapshot?.inputBody ?? null;
|
|
||||||
const outputBody = snapshot?.outputBody ?? null;
|
|
||||||
|
|
||||||
if (isLoading) return <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
|
const { data: snapshot } = useProcessorSnapshot(
|
||||||
if (!detail) return <InfoCallout variant="warning">Exchange not found</InfoCallout>;
|
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 (
|
return (
|
||||||
<div>
|
<div className={styles.content}>
|
||||||
|
|
||||||
|
{/* Breadcrumb */}
|
||||||
<Breadcrumb items={[
|
<Breadcrumb items={[
|
||||||
{ label: 'Dashboard', href: '/apps' },
|
{ label: 'Applications', href: '/apps' },
|
||||||
{ label: detail.applicationName || 'App', href: `/apps/${detail.applicationName}` },
|
{ 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 */}
|
{/* Exchange header card */}
|
||||||
<div className={styles.exchangeHeader}>
|
<div className={styles.exchangeHeader}>
|
||||||
<div className={styles.headerRow}>
|
<div className={styles.headerRow}>
|
||||||
<div className={styles.headerLeft}>
|
<div className={styles.headerLeft}>
|
||||||
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : detail.status === 'FAILED' ? 'error' : 'running'} />
|
<StatusDot variant={statusVariant} />
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.exchangeId}>
|
<div className={styles.exchangeId}>
|
||||||
<MonoText size="md">{id}</MonoText>
|
<MonoText size="md">{detail.executionId}</MonoText>
|
||||||
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} variant="filled" />
|
<Badge label={statusLabel} color={statusVariant} variant="filled" />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.exchangeRoute}>
|
<div className={styles.exchangeRoute}>
|
||||||
Route: <span className={styles.routeLink} onClick={() => navigate(`/apps/${detail.applicationName}/${detail.routeId}`)}>{detail.routeId}</span>
|
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>
|
App: <MonoText size="xs">{detail.applicationName}</MonoText>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{detail.correlationId && (
|
||||||
|
<>
|
||||||
|
<span className={styles.headerDivider}>·</span>
|
||||||
|
Correlation: <MonoText size="xs">{detail.correlationId}</MonoText>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +212,9 @@ export default function ExchangeDetail() {
|
|||||||
<div className={styles.headerStat}>
|
<div className={styles.headerStat}>
|
||||||
<div className={styles.headerStatLabel}>Started</div>
|
<div className={styles.headerStatLabel}>Started</div>
|
||||||
<div className={styles.headerStatValue}>
|
<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>
|
</div>
|
||||||
<div className={styles.headerStat}>
|
<div className={styles.headerStat}>
|
||||||
@@ -142,43 +225,39 @@ export default function ExchangeDetail() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Correlation Chain */}
|
{/* Correlation Chain */}
|
||||||
{correlationData?.data && correlationData.data.length > 1 && (
|
{correlatedExchanges.length > 1 && (
|
||||||
<div className={styles.correlationChain}>
|
<div className={styles.correlationChain}>
|
||||||
<span className={styles.chainLabel}>Correlated Exchanges</span>
|
<span className={styles.chainLabel}>Correlated Exchanges</span>
|
||||||
{correlationData.data.map((exec: any) => {
|
{correlatedExchanges.map((ce) => {
|
||||||
const isCurrent = exec.executionId === id;
|
const isCurrent = ce.executionId === id
|
||||||
const variant = exec.status === 'COMPLETED' ? 'success' : exec.status === 'FAILED' ? 'error' : 'running';
|
const variant = backendStatusToVariant(ce.status)
|
||||||
const statusCls =
|
const statusCls =
|
||||||
variant === 'success' ? styles.chainNodeSuccess
|
variant === 'success' ? styles.chainNodeSuccess
|
||||||
: variant === 'error' ? styles.chainNodeError
|
: variant === 'error' ? styles.chainNodeError
|
||||||
: styles.chainNodeRunning;
|
: variant === 'running' ? styles.chainNodeRunning
|
||||||
|
: styles.chainNodeWarning
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={exec.executionId}
|
key={ce.executionId}
|
||||||
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
|
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
|
||||||
onClick={() => { if (!isCurrent) navigate(`/exchanges/${exec.executionId}`); }}
|
onClick={() => {
|
||||||
title={`${exec.executionId} — ${exec.routeId}`}
|
if (!isCurrent) navigate(`/exchanges/${ce.executionId}`)
|
||||||
|
}}
|
||||||
|
title={`${ce.executionId} \u2014 ${ce.routeId}`}
|
||||||
>
|
>
|
||||||
<StatusDot variant={variant as any} />
|
<StatusDot variant={variant} />
|
||||||
<span>{exec.routeId}</span>
|
<span>{ce.routeId}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
)
|
||||||
})}
|
})}
|
||||||
{correlationData.total > 20 && (
|
{correlationData && correlationData.total > 20 && (
|
||||||
<span className={styles.chainMore}>+{correlationData.total - 20} more</span>
|
<span className={styles.chainMore}>+{correlationData.total - 20} more</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Error callout */}
|
{/* Processor Timeline Section */}
|
||||||
{detail.errorMessage && (
|
|
||||||
<InfoCallout variant="error">
|
|
||||||
{detail.errorMessage}
|
|
||||||
</InfoCallout>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Processor Timeline / Flow Section */}
|
|
||||||
<div className={styles.timelineSection}>
|
<div className={styles.timelineSection}>
|
||||||
<div className={styles.timelineHeader}>
|
<div className={styles.timelineHeader}>
|
||||||
<span className={styles.timelineTitle}>
|
<span className={styles.timelineTitle}>
|
||||||
@@ -206,17 +285,17 @@ export default function ExchangeDetail() {
|
|||||||
<ProcessorTimeline
|
<ProcessorTimeline
|
||||||
processors={processors}
|
processors={processors}
|
||||||
totalMs={detail.durationMs}
|
totalMs={detail.durationMs}
|
||||||
onProcessorClick={(_p, i) => setSelectedProcessorIndex(i)}
|
onProcessorClick={(_proc, index) => setSelectedProcessorIndex(index)}
|
||||||
selectedIndex={activeIndex}
|
selectedIndex={activeIndex}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<InfoCallout>No processor data available</InfoCallout>
|
<InfoCallout>No processor data available</InfoCallout>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
diagram ? (
|
routeNodes.length > 0 ? (
|
||||||
<RouteFlow
|
<RouteFlow
|
||||||
nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
|
nodes={routeNodes}
|
||||||
onNodeClick={(_node, i) => setSelectedProcessorIndex(i)}
|
onNodeClick={(_node, index) => setSelectedProcessorIndex(index)}
|
||||||
selectedIndex={activeIndex}
|
selectedIndex={activeIndex}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -226,7 +305,7 @@ export default function ExchangeDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Processor Detail: Message IN / Message OUT or Error */}
|
{/* Processor Detail Panel (split IN / OUT) */}
|
||||||
{selectedProc && snapshot && (
|
{selectedProc && snapshot && (
|
||||||
<div className={styles.detailSplit}>
|
<div className={styles.detailSplit}>
|
||||||
{/* Message IN */}
|
{/* Message IN */}
|
||||||
@@ -255,7 +334,7 @@ export default function ExchangeDetail() {
|
|||||||
)}
|
)}
|
||||||
<div className={styles.bodySection}>
|
<div className={styles.bodySection}>
|
||||||
<div className={styles.sectionLabel}>Body</div>
|
<div className={styles.sectionLabel}>Body</div>
|
||||||
<CodeBlock content={inputBody ?? 'null'} />
|
<CodeBlock content={inputBody ?? 'null'} language="json" copyable />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,7 +388,7 @@ export default function ExchangeDetail() {
|
|||||||
)}
|
)}
|
||||||
<div className={styles.bodySection}>
|
<div className={styles.bodySection}>
|
||||||
<div className={styles.sectionLabel}>Body</div>
|
<div className={styles.sectionLabel}>Body</div>
|
||||||
<CodeBlock content={outputBody ?? 'null'} />
|
<CodeBlock content={outputBody ?? 'null'} language="json" copyable />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,12 +396,13 @@ export default function ExchangeDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* No snapshot loaded yet - show prompt */}
|
{/* Snapshot loading indicator */}
|
||||||
{selectedProc && !snapshot && procList.length > 0 && (
|
{selectedProc && !snapshot && procList.length > 0 && (
|
||||||
<div style={{ color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', padding: 20 }}>
|
<div className={styles.snapshotLoading}>
|
||||||
Loading exchange snapshot...
|
Loading exchange snapshot...
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</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 {
|
.headerCard {
|
||||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
background: var(--bg-surface);
|
||||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-card);
|
border: 1px solid var(--border-subtle);
|
||||||
padding: 16px; margin-bottom: 16px;
|
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; }
|
.headerRow {
|
||||||
.headerRight { display: flex; gap: 20px; }
|
display: flex;
|
||||||
.headerStat { text-align: center; }
|
justify-content: space-between;
|
||||||
.headerStatLabel { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.6px; color: var(--text-muted); margin-bottom: 2px; }
|
align-items: center;
|
||||||
.headerStatValue { font-size: 14px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); }
|
gap: 16px;
|
||||||
.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; }
|
|
||||||
.tabSection { margin-top: 20px; }
|
.headerLeft {
|
||||||
.chartGrid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
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 {
|
.chartCard {
|
||||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
background: var(--bg-surface);
|
||||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); padding: 16px; overflow: hidden;
|
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 {
|
.executionsTable {
|
||||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
background: var(--bg-surface);
|
||||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-card); overflow: hidden;
|
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 {
|
.errorRow {
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
display: flex;
|
||||||
padding: 10px 12px; background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
justify-content: space-between;
|
||||||
border-radius: var(--radius-lg); font-size: 12px;
|
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 { useState, useMemo } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router';
|
import { useParams, useNavigate, Link } from 'react-router';
|
||||||
import {
|
import {
|
||||||
Badge, StatusDot, DataTable, Tabs,
|
KpiStrip,
|
||||||
AreaChart, LineChart, BarChart, RouteFlow, Spinner,
|
Badge,
|
||||||
|
StatusDot,
|
||||||
|
DataTable,
|
||||||
|
Tabs,
|
||||||
|
AreaChart,
|
||||||
|
LineChart,
|
||||||
|
BarChart,
|
||||||
|
RouteFlow,
|
||||||
|
Spinner,
|
||||||
MonoText,
|
MonoText,
|
||||||
|
Sparkline,
|
||||||
} from '@cameleer/design-system';
|
} 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 { useGlobalFilters } from '@cameleer/design-system';
|
||||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
import { useRouteCatalog } from '../../api/queries/catalog';
|
||||||
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
||||||
import { useProcessorMetrics } from '../../api/queries/processor-metrics';
|
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 type { ExecutionSummary, AppCatalogEntry, RouteSummary } from '../../api/types';
|
||||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
||||||
import styles from './RouteDetail.module.css';
|
import styles from './RouteDetail.module.css';
|
||||||
|
|
||||||
|
// ── Row types ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ExchangeRow extends ExecutionSummary {
|
interface ExchangeRow extends ExecutionSummary {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
@@ -26,6 +37,8 @@ interface ProcessorRow {
|
|||||||
avgDurationMs: number;
|
avgDurationMs: number;
|
||||||
p99DurationMs: number;
|
p99DurationMs: number;
|
||||||
errorCount: number;
|
errorCount: number;
|
||||||
|
errorRate: number;
|
||||||
|
sparkline: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorPattern {
|
interface ErrorPattern {
|
||||||
@@ -34,6 +47,211 @@ interface ErrorPattern {
|
|||||||
lastSeen: string;
|
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() {
|
export default function RouteDetail() {
|
||||||
const { appId, routeId } = useParams();
|
const { appId, routeId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -43,9 +261,11 @@ export default function RouteDetail() {
|
|||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('performance');
|
const [activeTab, setActiveTab] = useState('performance');
|
||||||
|
|
||||||
|
// ── API queries ────────────────────────────────────────────────────────────
|
||||||
const { data: catalog } = useRouteCatalog();
|
const { data: catalog } = useRouteCatalog();
|
||||||
const { data: diagram } = useDiagramByRoute(appId, routeId);
|
const { data: diagram } = useDiagramByRoute(appId, routeId);
|
||||||
const { data: processorMetrics, isLoading: processorLoading } = useProcessorMetrics(routeId ?? null, appId);
|
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: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
|
||||||
const { data: recentResult, isLoading: recentLoading } = useSearchExecutions({
|
const { data: recentResult, isLoading: recentLoading } = useSearchExecutions({
|
||||||
timeFrom,
|
timeFrom,
|
||||||
@@ -65,6 +285,8 @@ export default function RouteDetail() {
|
|||||||
limit: 200,
|
limit: 200,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Derived data ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const appEntry: AppCatalogEntry | undefined = useMemo(() =>
|
const appEntry: AppCatalogEntry | undefined = useMemo(() =>
|
||||||
(catalog || []).find((e: AppCatalogEntry) => e.appId === appId),
|
(catalog || []).find((e: AppCatalogEntry) => e.appId === appId),
|
||||||
[catalog, appId],
|
[catalog, appId],
|
||||||
@@ -79,7 +301,7 @@ export default function RouteDetail() {
|
|||||||
const exchangeCount = routeSummary?.exchangeCount ?? 0;
|
const exchangeCount = routeSummary?.exchangeCount ?? 0;
|
||||||
const lastSeen = routeSummary?.lastSeen
|
const lastSeen = routeSummary?.lastSeen
|
||||||
? new Date(routeSummary.lastSeen).toLocaleString()
|
? new Date(routeSummary.lastSeen).toLocaleString()
|
||||||
: '—';
|
: '\u2014';
|
||||||
|
|
||||||
const healthVariant = useMemo((): 'success' | 'warning' | 'error' | 'dead' => {
|
const healthVariant = useMemo((): 'success' | 'warning' | 'error' | 'dead' => {
|
||||||
const h = health.toLowerCase();
|
const h = health.toLowerCase();
|
||||||
@@ -89,39 +311,70 @@ export default function RouteDetail() {
|
|||||||
return 'dead';
|
return 'dead';
|
||||||
}, [health]);
|
}, [health]);
|
||||||
|
|
||||||
|
// Route flow from diagram
|
||||||
const diagramNodes = useMemo(() => {
|
const diagramNodes = useMemo(() => {
|
||||||
if (!diagram?.nodes) return [];
|
if (!diagram?.nodes) return [];
|
||||||
return mapDiagramToRouteNodes(diagram.nodes, []);
|
return mapDiagramToRouteNodes(diagram.nodes, []);
|
||||||
}, [diagram]);
|
}, [diagram]);
|
||||||
|
|
||||||
|
// Processor table rows
|
||||||
const processorRows: ProcessorRow[] = useMemo(() =>
|
const processorRows: ProcessorRow[] = useMemo(() =>
|
||||||
(processorMetrics || []).map((p: any) => ({
|
(processorMetrics || []).map((p: any) => {
|
||||||
id: p.processorId,
|
const callCount = p.callCount ?? 0;
|
||||||
processorId: p.processorId,
|
const errorCount = p.errorCount ?? 0;
|
||||||
callCount: p.callCount ?? 0,
|
const errRate = callCount > 0 ? (errorCount / callCount) * 100 : 0;
|
||||||
avgDurationMs: p.avgDurationMs ?? 0,
|
return {
|
||||||
p99DurationMs: p.p99DurationMs ?? 0,
|
id: p.processorId,
|
||||||
errorCount: p.errorCount ?? 0,
|
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],
|
[processorMetrics],
|
||||||
);
|
);
|
||||||
|
|
||||||
const chartData = useMemo(() =>
|
// Timeseries-derived data
|
||||||
(timeseries?.buckets || []).map((b: any) => ({
|
const throughputSparkline = useMemo(() =>
|
||||||
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
(timeseries?.buckets || []).map((b) => b.totalCount),
|
||||||
throughput: b.totalCount,
|
[timeseries],
|
||||||
latency: b.avgDurationMs,
|
);
|
||||||
errors: b.failedCount,
|
const errorSparkline = useMemo(() =>
|
||||||
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
|
(timeseries?.buckets || []).map((b) => b.failedCount),
|
||||||
})),
|
[timeseries],
|
||||||
|
);
|
||||||
|
const latencySparkline = useMemo(() =>
|
||||||
|
(timeseries?.buckets || []).map((b) => b.p99DurationMs),
|
||||||
[timeseries],
|
[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(() =>
|
const exchangeRows: ExchangeRow[] = useMemo(() =>
|
||||||
(recentResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
(recentResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
||||||
[recentResult],
|
[recentResult],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Error patterns
|
||||||
const errorPatterns: ErrorPattern[] = useMemo(() => {
|
const errorPatterns: ErrorPattern[] = useMemo(() => {
|
||||||
const failed = (errorResult?.data || []) as ExecutionSummary[];
|
const failed = (errorResult?.data || []) as ExecutionSummary[];
|
||||||
const grouped = new Map<string, { count: number; lastSeen: string }>();
|
const grouped = new Map<string, { count: number; lastSeen: string }>();
|
||||||
@@ -141,31 +394,18 @@ export default function RouteDetail() {
|
|||||||
.map(([message, { count, lastSeen: ls }]) => ({
|
.map(([message, { count, lastSeen: ls }]) => ({
|
||||||
message,
|
message,
|
||||||
count,
|
count,
|
||||||
lastSeen: ls ? new Date(ls).toLocaleString() : '—',
|
lastSeen: ls ? new Date(ls).toLocaleString() : '\u2014',
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.count - a.count);
|
.sort((a, b) => b.count - a.count);
|
||||||
}, [errorResult]);
|
}, [errorResult]);
|
||||||
|
|
||||||
const processorColumns: Column<ProcessorRow>[] = [
|
// KPI items
|
||||||
{ key: 'processorId', header: 'Processor', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
|
const kpiItems = useMemo(() =>
|
||||||
{ key: 'callCount', header: 'Calls', sortable: true },
|
buildDetailKpiItems(stats, throughputSparkline, errorSparkline, latencySparkline),
|
||||||
{ key: 'avgDurationMs', header: 'Avg', sortable: true, render: (v) => `${(v as number).toFixed(1)}ms` },
|
[stats, throughputSparkline, errorSparkline, latencySparkline],
|
||||||
{ 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>;
|
|
||||||
}},
|
|
||||||
];
|
|
||||||
|
|
||||||
const exchangeColumns: Column<ExchangeRow>[] = [
|
const processorColumns = useMemo(() => makeProcessorColumns(styles), []);
|
||||||
{
|
|
||||||
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 tabs = [
|
const tabs = [
|
||||||
{ label: 'Performance', value: 'performance' },
|
{ label: 'Performance', value: 'performance' },
|
||||||
@@ -173,12 +413,15 @@ export default function RouteDetail() {
|
|||||||
{ label: 'Error Patterns', value: 'errors', count: errorPatterns.length },
|
{ label: 'Error Patterns', value: 'errors', count: errorPatterns.length },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ── Render ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Link to={`/routes/${appId}`} className={styles.backLink}>
|
<Link to={`/routes/${appId}`} className={styles.backLink}>
|
||||||
← {appId} routes
|
← {appId} routes
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Route header card */}
|
||||||
<div className={styles.headerCard}>
|
<div className={styles.headerCard}>
|
||||||
<div className={styles.headerRow}>
|
<div className={styles.headerRow}>
|
||||||
<div className={styles.headerLeft}>
|
<div className={styles.headerLeft}>
|
||||||
@@ -199,13 +442,17 @@ export default function RouteDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* KPI strip */}
|
||||||
|
<KpiStrip items={kpiItems} />
|
||||||
|
|
||||||
|
{/* Diagram + Processor Stats grid */}
|
||||||
<div className={styles.diagramStatsGrid}>
|
<div className={styles.diagramStatsGrid}>
|
||||||
<div className={styles.diagramPane}>
|
<div className={styles.diagramPane}>
|
||||||
<div className={styles.paneTitle}>Route Diagram</div>
|
<div className={styles.paneTitle}>Route Diagram</div>
|
||||||
{diagramNodes.length > 0 ? (
|
{diagramNodes.length > 0 ? (
|
||||||
<RouteFlow nodes={diagramNodes} />
|
<RouteFlow nodes={diagramNodes} />
|
||||||
) : (
|
) : (
|
||||||
<div style={{ color: 'var(--text-muted)', fontSize: 13, padding: '8px 0' }}>
|
<div className={styles.emptyText}>
|
||||||
No diagram available for this route.
|
No diagram available for this route.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -217,13 +464,40 @@ export default function RouteDetail() {
|
|||||||
) : processorRows.length > 0 ? (
|
) : processorRows.length > 0 ? (
|
||||||
<DataTable columns={processorColumns} data={processorRows} sortable pageSize={10} />
|
<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.
|
No processor data available.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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}>
|
<div className={styles.tabSection}>
|
||||||
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
@@ -232,28 +506,41 @@ export default function RouteDetail() {
|
|||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartTitle}>Throughput</div>
|
<div className={styles.chartTitle}>Throughput</div>
|
||||||
<AreaChart
|
<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}
|
height={200}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartTitle}>Latency</div>
|
<div className={styles.chartTitle}>Latency</div>
|
||||||
<LineChart
|
<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}
|
height={200}
|
||||||
|
threshold={{ value: 300, label: 'SLA 300ms' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartTitle}>Errors</div>
|
<div className={styles.chartTitle}>Errors</div>
|
||||||
<BarChart
|
<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}
|
height={200}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.chartCard}>
|
<div className={styles.chartCard}>
|
||||||
<div className={styles.chartTitle}>Success Rate</div>
|
<div className={styles.chartTitle}>Success Rate</div>
|
||||||
<AreaChart
|
<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}
|
height={200}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,7 +555,7 @@ export default function RouteDetail() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={exchangeColumns}
|
columns={EXCHANGE_COLUMNS}
|
||||||
data={exchangeRows}
|
data={exchangeRows}
|
||||||
onRowClick={(row) => navigate(`/exchanges/${row.executionId}`)}
|
onRowClick={(row) => navigate(`/exchanges/${row.executionId}`)}
|
||||||
sortable
|
sortable
|
||||||
@@ -281,7 +568,7 @@ export default function RouteDetail() {
|
|||||||
{activeTab === 'errors' && (
|
{activeTab === 'errors' && (
|
||||||
<div className={styles.errorPatterns} style={{ marginTop: 16 }}>
|
<div className={styles.errorPatterns} style={{ marginTop: 16 }}>
|
||||||
{errorPatterns.length === 0 ? (
|
{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.
|
No error patterns found in the selected time range.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,17 +1,44 @@
|
|||||||
.statStrip {
|
/* Scrollable content area */
|
||||||
display: grid;
|
.content {
|
||||||
grid-template-columns: repeat(5, 1fr);
|
display: flex;
|
||||||
gap: 10px;
|
flex-direction: column;
|
||||||
margin-bottom: 16px;
|
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 {
|
.tableSection {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: 1px solid var(--border-subtle);
|
border: 1px solid var(--border-subtle);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: var(--shadow-card);
|
box-shadow: var(--shadow-card);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableHeader {
|
.tableHeader {
|
||||||
@@ -28,36 +55,56 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tableRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.tableMeta {
|
.tableMeta {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: var(--font-mono);
|
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 {
|
.chartGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartCard {
|
.chart {
|
||||||
background: var(--bg-surface);
|
width: 100%;
|
||||||
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: 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 { useMemo } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import {
|
import {
|
||||||
StatCard, Sparkline, MonoText, Badge,
|
KpiStrip,
|
||||||
DataTable, AreaChart, LineChart, BarChart,
|
DataTable,
|
||||||
|
AreaChart,
|
||||||
|
LineChart,
|
||||||
|
BarChart,
|
||||||
|
Card,
|
||||||
|
Sparkline,
|
||||||
|
MonoText,
|
||||||
|
Badge,
|
||||||
} from '@cameleer/design-system';
|
} 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 { useRouteMetrics } from '../../api/queries/catalog';
|
||||||
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
|
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';
|
import styles from './RoutesMetrics.module.css';
|
||||||
|
|
||||||
interface RouteRow {
|
interface RouteRow {
|
||||||
@@ -23,186 +31,322 @@ interface RouteRow {
|
|||||||
sparkline: number[];
|
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() {
|
export default function RoutesMetrics() {
|
||||||
const { appId, routeId } = useParams();
|
const { appId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
const { timeRange } = useGlobalFilters();
|
const { timeRange } = useGlobalFilters();
|
||||||
const timeFrom = timeRange.start.toISOString();
|
const timeFrom = timeRange.start.toISOString();
|
||||||
const timeTo = timeRange.end.toISOString();
|
const timeTo = timeRange.end.toISOString();
|
||||||
|
|
||||||
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId);
|
const { data: metrics } = useRouteMetrics(timeFrom, timeTo, appId);
|
||||||
const { data: stats } = useExecutionStats(timeFrom, timeTo, routeId, appId);
|
const { data: stats } = useExecutionStats(timeFrom, timeTo, undefined, appId);
|
||||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, routeId, appId);
|
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo, undefined, appId);
|
||||||
|
|
||||||
|
// Map backend RouteMetrics[] to table rows
|
||||||
const rows: RouteRow[] = useMemo(() =>
|
const rows: RouteRow[] = useMemo(() =>
|
||||||
(metrics || []).map((m: any) => ({
|
(metrics || []).map((m: RouteMetrics) => ({
|
||||||
id: `${m.appId}/${m.routeId}`,
|
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],
|
[metrics],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sparklineData = useMemo(() =>
|
// Sparkline data from timeseries buckets
|
||||||
(timeseries?.buckets || []).map((b: any) => b.totalCount as number),
|
const throughputSparkline = useMemo(() =>
|
||||||
|
(timeseries?.buckets || []).map((b) => b.totalCount),
|
||||||
|
[timeseries],
|
||||||
|
);
|
||||||
|
const errorSparkline = useMemo(() =>
|
||||||
|
(timeseries?.buckets || []).map((b) => b.failedCount),
|
||||||
[timeseries],
|
[timeseries],
|
||||||
);
|
);
|
||||||
|
|
||||||
const chartData = useMemo(() =>
|
// Chart series from timeseries buckets
|
||||||
(timeseries?.buckets || []).map((b: any, i: number) => {
|
const throughputChartSeries = useMemo(() => [{
|
||||||
const ts = b.timestamp ? new Date(b.timestamp) : null;
|
label: 'Throughput',
|
||||||
const time = ts && !isNaN(ts.getTime())
|
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' })
|
? ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
: String(i);
|
: '—';
|
||||||
return {
|
return { x: label, y: b.failedCount };
|
||||||
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,
|
|
||||||
};
|
|
||||||
}),
|
}),
|
||||||
[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 (
|
return (
|
||||||
<div>
|
<div className={styles.content}>
|
||||||
<div className={styles.statStrip}>
|
<div className={styles.refreshIndicator}>
|
||||||
<StatCard
|
<span className={styles.refreshDot} />
|
||||||
label="Total Throughput"
|
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
|
{/* KPI header cards */}
|
||||||
|
<KpiStrip items={kpiItems} />
|
||||||
|
|
||||||
|
{/* Per-route performance table */}
|
||||||
<div className={styles.tableSection}>
|
<div className={styles.tableSection}>
|
||||||
<div className={styles.tableHeader}>
|
<div className={styles.tableHeader}>
|
||||||
<span className={styles.tableTitle}>Per-Route Performance</span>
|
<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>
|
</div>
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={ROUTE_COLUMNS}
|
||||||
data={rows}
|
data={rows}
|
||||||
sortable
|
sortable
|
||||||
pageSize={20}
|
onRowClick={(row) => {
|
||||||
|
const targetAppId = appId ?? row.appId;
|
||||||
|
navigate(`/routes/${targetAppId}/${row.routeId}`);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chartData.length > 0 && (
|
{/* 2x2 chart grid */}
|
||||||
|
{(timeseries?.buckets?.length ?? 0) > 0 && (
|
||||||
<div className={styles.chartGrid}>
|
<div className={styles.chartGrid}>
|
||||||
<div className={styles.chartCard}>
|
<Card title="Throughput (msg/s)">
|
||||||
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
<AreaChart
|
||||||
<AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/s" height={200} />
|
series={throughputChartSeries}
|
||||||
</div>
|
yLabel="msg/s"
|
||||||
<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"
|
|
||||||
height={200}
|
height={200}
|
||||||
threshold={{ value: 300, label: 'SLA 300ms' }}
|
className={styles.chart}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
<div className={styles.chartCard}>
|
|
||||||
<div className={styles.chartTitle}>Errors by Route</div>
|
<Card title="Latency (ms)">
|
||||||
<BarChart series={[{ label: 'Errors', data: chartData.map((d: any) => ({ x: d.time as string, y: d.errors })) }]} height={200} />
|
<LineChart
|
||||||
</div>
|
series={latencyChartSeries}
|
||||||
<div className={styles.chartCard}>
|
yLabel="ms"
|
||||||
<div className={styles.chartTitle}>Message Volume (msg/min)</div>
|
threshold={{ value: 300, label: 'SLA 300ms' }}
|
||||||
<AreaChart series={[{ label: 'Volume', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/min" height={200} />
|
height={200}
|
||||||
</div>
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user