refactor: migrate pages to use new design system components
All checks were successful
Build & Publish / publish (push) Successful in 54s
All checks were successful
Build & Publish / publish (push) Successful in 54s
Dashboard: KpiStrip replaces StatCard strip Routes: KpiStrip + Card title + removed ~250 lines of KPI/chart CSS AgentInstance: LogViewer replaces custom log rendering Admin RBAC: SplitPane + EntityList replaces custom split-pane layout CLAUDE.md: updated import examples Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,8 +37,8 @@ Always read `COMPONENT_GUIDE.md` before building any UI feature. It contains dec
|
|||||||
### Import Paths
|
### Import Paths
|
||||||
```tsx
|
```tsx
|
||||||
import { Button, Input } from '../design-system/primitives'
|
import { Button, Input } from '../design-system/primitives'
|
||||||
import { Modal, DataTable } from '../design-system/composites'
|
import { Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer } from '../design-system/composites'
|
||||||
import type { Column } from '../design-system/composites'
|
import type { Column, KpiItem, LogEntry } from '../design-system/composites'
|
||||||
import { AppShell } from '../design-system/layout/AppShell'
|
import { AppShell } from '../design-system/layout/AppShell'
|
||||||
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
|
import { ThemeProvider } from '../design-system/providers/ThemeProvider'
|
||||||
```
|
```
|
||||||
@@ -91,10 +91,10 @@ import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// All components from single entry
|
// All components from single entry
|
||||||
import { Button, Input, Modal, DataTable, AppShell } from '@cameleer/design-system'
|
import { Button, Input, Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer, StatusText, AppShell } from '@cameleer/design-system'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import type { Column, DataTableProps, SearchResult } from '@cameleer/design-system'
|
import type { Column, DataTableProps, SearchResult, KpiItem, LogEntry } from '@cameleer/design-system'
|
||||||
|
|
||||||
// Providers
|
// Providers
|
||||||
import { ThemeProvider, useTheme } from '@cameleer/design-system'
|
import { ThemeProvider, useTheme } from '@cameleer/design-system'
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import { InlineEdit } from '../../../design-system/primitives/InlineEdit/InlineE
|
|||||||
import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect'
|
import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect'
|
||||||
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
||||||
import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'
|
import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'
|
||||||
|
import { SplitPane } from '../../../design-system/composites/SplitPane/SplitPane'
|
||||||
|
import { EntityList } from '../../../design-system/composites/EntityList/EntityList'
|
||||||
import { useToast } from '../../../design-system/composites/Toast/Toast'
|
import { useToast } from '../../../design-system/composites/Toast/Toast'
|
||||||
import { MOCK_GROUPS, MOCK_USERS, MOCK_ROLES, getChildGroups, type MockGroup } from './rbacMocks'
|
import { MOCK_GROUPS, MOCK_USERS, MOCK_ROLES, type MockGroup } from './rbacMocks'
|
||||||
import styles from './UserManagement.module.css'
|
import styles from './UserManagement.module.css'
|
||||||
|
|
||||||
export function GroupsTab() {
|
export function GroupsTab() {
|
||||||
@@ -83,207 +85,190 @@ export function GroupsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.splitPane}>
|
<SplitPane
|
||||||
<div className={styles.listPane}>
|
list={
|
||||||
<div className={styles.listHeader}>
|
<>
|
||||||
<Input
|
{creating && (
|
||||||
placeholder="Search groups..."
|
<div className={styles.createForm}>
|
||||||
value={search}
|
<Input placeholder="Group name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
{duplicateGroupName && <span style={{ color: 'var(--error)', fontSize: 11 }}>Group name already exists</span>}
|
||||||
onClear={() => setSearch('')}
|
<Select
|
||||||
className={styles.listHeaderSearch}
|
options={parentOptions}
|
||||||
/>
|
value={newParent}
|
||||||
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
|
onChange={(e) => setNewParent(e.target.value)}
|
||||||
+ Add group
|
/>
|
||||||
</Button>
|
<div className={styles.createFormActions}>
|
||||||
</div>
|
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||||||
|
<Button size="sm" variant="primary" onClick={handleCreate} disabled={!newName.trim() || duplicateGroupName}>Create</Button>
|
||||||
{creating && (
|
|
||||||
<div className={styles.createForm}>
|
|
||||||
<Input placeholder="Group name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
|
||||||
{duplicateGroupName && <span style={{ color: 'var(--error)', fontSize: 11 }}>Group name already exists</span>}
|
|
||||||
<Select
|
|
||||||
options={parentOptions}
|
|
||||||
value={newParent}
|
|
||||||
onChange={(e) => setNewParent(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className={styles.createFormActions}>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
|
||||||
<Button size="sm" variant="primary" onClick={handleCreate} disabled={!newName.trim() || duplicateGroupName}>Create</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.entityList} role="listbox" aria-label="Groups">
|
|
||||||
{filtered.map((group) => {
|
|
||||||
const groupChildren = groups.filter((g) => g.parentId === group.id)
|
|
||||||
const groupMembers = MOCK_USERS.filter((u) => u.directGroups.includes(group.id))
|
|
||||||
const groupParent = group.parentId ? groups.find((g) => g.id === group.parentId) : null
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={group.id}
|
|
||||||
className={`${styles.entityItem} ${selectedId === group.id ? styles.entityItemSelected : ''}`}
|
|
||||||
onClick={() => setSelectedId(group.id)}
|
|
||||||
role="option"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-selected={selectedId === group.id}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(group.id) } }}
|
|
||||||
>
|
|
||||||
<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'}
|
|
||||||
{' · '}{groupChildren.length} children · {groupMembers.length} members
|
|
||||||
</div>
|
|
||||||
<div className={styles.entityTags}>
|
|
||||||
{group.directRoles.map((r) => <Badge key={r} label={r} color="warning" />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
</div>
|
||||||
})}
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<div className={styles.emptySearch}>No groups match your search</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.detailPane}>
|
<EntityList
|
||||||
{selected ? (
|
items={filtered}
|
||||||
<>
|
renderItem={(group) => {
|
||||||
<div className={styles.detailHeader}>
|
const groupChildren = groups.filter((g) => g.parentId === group.id)
|
||||||
<Avatar name={selected.name} size="lg" />
|
const groupMembers = MOCK_USERS.filter((u) => u.directGroups.includes(group.id))
|
||||||
<div className={styles.detailHeaderInfo}>
|
const groupParent = group.parentId ? groups.find((g) => g.id === group.parentId) : null
|
||||||
<div className={styles.detailName}>
|
return (
|
||||||
{selected.builtIn ? selected.name : (
|
<>
|
||||||
<InlineEdit
|
<Avatar name={group.name} size="sm" />
|
||||||
value={selected.name}
|
<div className={styles.entityInfo}>
|
||||||
onSave={(v) => updateGroup(selected.id, { name: v })}
|
<div className={styles.entityName}>{group.name}</div>
|
||||||
/>
|
<div className={styles.entityMeta}>
|
||||||
)}
|
{groupParent ? `Child of ${groupParent.name}` : 'Top-level'}
|
||||||
</div>
|
{' · '}{groupChildren.length} children · {groupMembers.length} members
|
||||||
<div className={styles.detailEmail}>
|
</div>
|
||||||
{parent ? `${parent.name} > ${selected.name}` : 'Top-level group'}
|
<div className={styles.entityTags}>
|
||||||
{selected.builtIn && ' (built-in)'}
|
{group.directRoles.map((r) => <Badge key={r} label={r} color="warning" />)}
|
||||||
</div>
|
</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={selected ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.detailHeader}>
|
||||||
|
<Avatar name={selected.name} size="lg" />
|
||||||
|
<div className={styles.detailHeaderInfo}>
|
||||||
|
<div className={styles.detailName}>
|
||||||
|
{selected.builtIn ? selected.name : (
|
||||||
|
<InlineEdit
|
||||||
|
value={selected.name}
|
||||||
|
onSave={(v) => updateGroup(selected.id, { name: v })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailEmail}>
|
||||||
|
{parent ? `${parent.name} > ${selected.name}` : 'Top-level group'}
|
||||||
|
{selected.builtIn && ' (built-in)'}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="danger"
|
|
||||||
onClick={() => setDeleteTarget(selected)}
|
|
||||||
disabled={selected.builtIn}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => setDeleteTarget(selected)}
|
||||||
|
disabled={selected.builtIn}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.metaGrid}>
|
<div className={styles.metaGrid}>
|
||||||
<span className={styles.metaLabel}>ID</span>
|
<span className={styles.metaLabel}>ID</span>
|
||||||
<MonoText size="xs">{selected.id}</MonoText>
|
<MonoText size="xs">{selected.id}</MonoText>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{parent && (
|
{parent && (
|
||||||
<>
|
<>
|
||||||
<SectionHeader>Member of</SectionHeader>
|
<SectionHeader>Member of</SectionHeader>
|
||||||
<div className={styles.sectionTags}>
|
<div className={styles.sectionTags}>
|
||||||
<Tag label={parent.name} color="auto" />
|
<Tag label={parent.name} color="auto" />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SectionHeader>Members (direct)</SectionHeader>
|
<SectionHeader>Members (direct)</SectionHeader>
|
||||||
<div className={styles.sectionTags}>
|
<div className={styles.sectionTags}>
|
||||||
{members.map((u) => (
|
{members.map((u) => (
|
||||||
<Tag
|
<Tag
|
||||||
key={u.id}
|
key={u.id}
|
||||||
label={u.displayName}
|
label={u.displayName}
|
||||||
color="auto"
|
color="auto"
|
||||||
onRemove={() => {
|
onRemove={() => {
|
||||||
// Remove this group from the user's directGroups
|
// Remove this group from the user's directGroups
|
||||||
// Note: in mock data we can't easily update MOCK_USERS, so this is visual only
|
// Note: in mock data we can't easily update MOCK_USERS, so this is visual only
|
||||||
toast({ title: 'Member removed', description: u.displayName, variant: 'success' })
|
toast({ title: 'Member removed', description: u.displayName, variant: 'success' })
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{members.length === 0 && <span className={styles.inheritedNote}>(no members)</span>}
|
|
||||||
<MultiSelect
|
|
||||||
options={availableMembers}
|
|
||||||
value={[]}
|
|
||||||
onChange={(ids) => {
|
|
||||||
toast({ title: `${ids.length} member(s) added`, variant: 'success' })
|
|
||||||
}}
|
}}
|
||||||
placeholder="+ Add"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
))}
|
||||||
{children.length > 0 && (
|
{members.length === 0 && <span className={styles.inheritedNote}>(no members)</span>}
|
||||||
<span className={styles.inheritedNote}>
|
<MultiSelect
|
||||||
+ all members of {children.map((c) => c.name).join(', ')}
|
options={availableMembers}
|
||||||
</span>
|
value={[]}
|
||||||
)}
|
onChange={(ids) => {
|
||||||
|
toast({ title: `${ids.length} member(s) added`, variant: 'success' })
|
||||||
|
}}
|
||||||
|
placeholder="+ Add"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{children.length > 0 && (
|
||||||
|
<span className={styles.inheritedNote}>
|
||||||
|
+ all members of {children.map((c) => c.name).join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<SectionHeader>Child groups</SectionHeader>
|
<SectionHeader>Child groups</SectionHeader>
|
||||||
<div className={styles.sectionTags}>
|
<div className={styles.sectionTags}>
|
||||||
{children.map((c) => (
|
{children.map((c) => (
|
||||||
<Tag
|
<Tag
|
||||||
key={c.id}
|
key={c.id}
|
||||||
label={c.name}
|
label={c.name}
|
||||||
color="success"
|
color="success"
|
||||||
onRemove={() => {
|
onRemove={() => {
|
||||||
updateGroup(c.id, { parentId: null })
|
updateGroup(c.id, { parentId: null })
|
||||||
toast({ title: 'Child group removed', description: c.name, variant: 'success' })
|
toast({ title: 'Child group removed', description: c.name, variant: 'success' })
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{children.length === 0 && <span className={styles.inheritedNote}>(no child groups)</span>}
|
{children.length === 0 && <span className={styles.inheritedNote}>(no child groups)</span>}
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
options={availableChildGroups}
|
options={availableChildGroups}
|
||||||
value={[]}
|
value={[]}
|
||||||
onChange={(ids) => {
|
onChange={(ids) => {
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
updateGroup(id, { parentId: selected!.id })
|
updateGroup(id, { parentId: selected!.id })
|
||||||
|
}
|
||||||
|
toast({ title: `${ids.length} child group(s) added`, variant: 'success' })
|
||||||
|
}}
|
||||||
|
placeholder="+ Add"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Assigned roles</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{selected.directRoles.map((r) => (
|
||||||
|
<Tag
|
||||||
|
key={r}
|
||||||
|
label={r}
|
||||||
|
color="warning"
|
||||||
|
onRemove={() => {
|
||||||
|
const memberCount = MOCK_USERS.filter((u) => u.directGroups.includes(selected.id)).length
|
||||||
|
if (memberCount > 0) {
|
||||||
|
setRemoveRoleTarget(r)
|
||||||
|
} else {
|
||||||
|
updateGroup(selected.id, { directRoles: selected.directRoles.filter((role) => role !== r) })
|
||||||
|
toast({ title: 'Role removed', variant: 'success' })
|
||||||
}
|
}
|
||||||
toast({ title: `${ids.length} child group(s) added`, variant: 'success' })
|
|
||||||
}}
|
}}
|
||||||
placeholder="+ Add"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
))}
|
||||||
|
{selected.directRoles.length === 0 && <span className={styles.inheritedNote}>(no roles)</span>}
|
||||||
<SectionHeader>Assigned roles</SectionHeader>
|
<MultiSelect
|
||||||
<div className={styles.sectionTags}>
|
options={availableRoles}
|
||||||
{selected.directRoles.map((r) => (
|
value={[]}
|
||||||
<Tag
|
onChange={(roles) => {
|
||||||
key={r}
|
updateGroup(selected.id, { directRoles: [...selected.directRoles, ...roles] })
|
||||||
label={r}
|
toast({ title: `${roles.length} role(s) added`, variant: 'success' })
|
||||||
color="warning"
|
}}
|
||||||
onRemove={() => {
|
placeholder="+ Add"
|
||||||
const memberCount = MOCK_USERS.filter((u) => u.directGroups.includes(selected.id)).length
|
/>
|
||||||
if (memberCount > 0) {
|
</div>
|
||||||
setRemoveRoleTarget(r)
|
</>
|
||||||
} else {
|
) : null}
|
||||||
updateGroup(selected.id, { directRoles: selected.directRoles.filter((role) => role !== r) })
|
emptyMessage="Select a group to view details"
|
||||||
toast({ title: 'Role removed', variant: 'success' })
|
/>
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{selected.directRoles.length === 0 && <span className={styles.inheritedNote}>(no roles)</span>}
|
|
||||||
<MultiSelect
|
|
||||||
options={availableRoles}
|
|
||||||
value={[]}
|
|
||||||
onChange={(roles) => {
|
|
||||||
updateGroup(selected.id, { directRoles: [...selected.directRoles, ...roles] })
|
|
||||||
toast({ title: `${roles.length} role(s) added`, variant: 'success' })
|
|
||||||
}}
|
|
||||||
placeholder="+ Add"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className={styles.emptyDetail}>Select a group to view details</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { MonoText } from '../../../design-system/primitives/MonoText/MonoText'
|
|||||||
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
|
import { SectionHeader } from '../../../design-system/primitives/SectionHeader/SectionHeader'
|
||||||
import { Tag } from '../../../design-system/primitives/Tag/Tag'
|
import { Tag } from '../../../design-system/primitives/Tag/Tag'
|
||||||
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
||||||
|
import { SplitPane } from '../../../design-system/composites/SplitPane/SplitPane'
|
||||||
|
import { EntityList } from '../../../design-system/composites/EntityList/EntityList'
|
||||||
import { useToast } from '../../../design-system/composites/Toast/Toast'
|
import { useToast } from '../../../design-system/composites/Toast/Toast'
|
||||||
import { MOCK_ROLES, MOCK_GROUPS, MOCK_USERS, getEffectiveRoles, type MockRole } from './rbacMocks'
|
import { MOCK_ROLES, MOCK_GROUPS, MOCK_USERS, getEffectiveRoles, type MockRole } from './rbacMocks'
|
||||||
import styles from './UserManagement.module.css'
|
import styles from './UserManagement.module.css'
|
||||||
@@ -79,141 +81,124 @@ export function RolesTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.splitPane}>
|
<SplitPane
|
||||||
<div className={styles.listPane}>
|
list={
|
||||||
<div className={styles.listHeader}>
|
<>
|
||||||
<Input
|
{creating && (
|
||||||
placeholder="Search roles..."
|
<div className={styles.createForm}>
|
||||||
value={search}
|
<Input placeholder="Role name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
{duplicateRoleName && <span style={{ color: 'var(--error)', fontSize: 11 }}>Role name already exists</span>}
|
||||||
onClear={() => setSearch('')}
|
<Input placeholder="Description" value={newDesc} onChange={(e) => setNewDesc(e.target.value)} />
|
||||||
className={styles.listHeaderSearch}
|
<div className={styles.createFormActions}>
|
||||||
/>
|
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||||||
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
|
<Button size="sm" variant="primary" onClick={handleCreate} disabled={!newName.trim() || duplicateRoleName}>Create</Button>
|
||||||
+ Add role
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{creating && (
|
|
||||||
<div className={styles.createForm}>
|
|
||||||
<Input placeholder="Role name *" value={newName} onChange={(e) => setNewName(e.target.value)} />
|
|
||||||
{duplicateRoleName && <span style={{ color: 'var(--error)', fontSize: 11 }}>Role name already exists</span>}
|
|
||||||
<Input placeholder="Description" value={newDesc} onChange={(e) => setNewDesc(e.target.value)} />
|
|
||||||
<div className={styles.createFormActions}>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
|
||||||
<Button size="sm" variant="primary" onClick={handleCreate} disabled={!newName.trim() || duplicateRoleName}>Create</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.entityList} role="listbox" aria-label="Roles">
|
|
||||||
{filtered.map((role) => (
|
|
||||||
<div
|
|
||||||
key={role.id}
|
|
||||||
className={`${styles.entityItem} ${selectedId === role.id ? styles.entityItemSelected : ''}`}
|
|
||||||
onClick={() => setSelectedId(role.id)}
|
|
||||||
role="option"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-selected={selectedId === role.id}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(role.id) } }}
|
|
||||||
>
|
|
||||||
<Avatar name={role.name} size="sm" />
|
|
||||||
<div className={styles.entityInfo}>
|
|
||||||
<div className={styles.entityName}>
|
|
||||||
{role.name}
|
|
||||||
{role.system && <Badge label="system" color="auto" variant="outlined" className={styles.providerBadge} />}
|
|
||||||
</div>
|
|
||||||
<div className={styles.entityMeta}>
|
|
||||||
{role.description} · {getAssignmentCount(role)} assignments
|
|
||||||
</div>
|
|
||||||
<div className={styles.entityTags}>
|
|
||||||
{MOCK_GROUPS.filter((g) => g.directRoles.includes(role.name))
|
|
||||||
.map((g) => <Badge key={g.id} label={g.name} color="success" />)}
|
|
||||||
{MOCK_USERS.filter((u) => u.directRoles.includes(role.name))
|
|
||||||
.map((u) => <Badge key={u.id} label={u.username} color="auto" />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<div className={styles.emptySearch}>No roles match your search</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.detailPane}>
|
<EntityList
|
||||||
{selected ? (
|
items={filtered}
|
||||||
<>
|
renderItem={(role) => (
|
||||||
<div className={styles.detailHeader}>
|
<>
|
||||||
<Avatar name={selected.name} size="lg" />
|
<Avatar name={role.name} size="sm" />
|
||||||
<div className={styles.detailHeaderInfo}>
|
<div className={styles.entityInfo}>
|
||||||
<div className={styles.detailName}>{selected.name}</div>
|
<div className={styles.entityName}>
|
||||||
{selected.description && (
|
{role.name}
|
||||||
<div className={styles.detailEmail}>{selected.description}</div>
|
{role.system && <Badge label="system" color="auto" variant="outlined" className={styles.providerBadge} />}
|
||||||
)}
|
</div>
|
||||||
</div>
|
<div className={styles.entityMeta}>
|
||||||
{!selected.system && (
|
{role.description} · {getAssignmentCount(role)} assignments
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
<div className={styles.entityTags}>
|
||||||
variant="danger"
|
{MOCK_GROUPS.filter((g) => g.directRoles.includes(role.name))
|
||||||
onClick={() => setDeleteTarget(selected)}
|
.map((g) => <Badge key={g.id} label={g.name} color="success" />)}
|
||||||
>
|
{MOCK_USERS.filter((u) => u.directRoles.includes(role.name))
|
||||||
Delete
|
.map((u) => <Badge key={u.id} label={u.username} color="auto" />)}
|
||||||
</Button>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
|
||||||
<div className={styles.metaGrid}>
|
|
||||||
<span className={styles.metaLabel}>ID</span>
|
|
||||||
<MonoText size="xs">{selected.id}</MonoText>
|
|
||||||
<span className={styles.metaLabel}>Scope</span>
|
|
||||||
<span className={styles.metaValue}>{selected.scope}</span>
|
|
||||||
{selected.system && (
|
|
||||||
<>
|
|
||||||
<span className={styles.metaLabel}>Type</span>
|
|
||||||
<span className={styles.metaValue}>System role (read-only)</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SectionHeader>Assigned to groups</SectionHeader>
|
|
||||||
<div className={styles.sectionTags}>
|
|
||||||
{assignedGroups.map((g) => <Tag key={g.id} label={g.name} color="success" />)}
|
|
||||||
{assignedGroups.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SectionHeader>Assigned to users (direct)</SectionHeader>
|
|
||||||
<div className={styles.sectionTags}>
|
|
||||||
{directUsers.map((u) => <Tag key={u.id} label={u.displayName} color="auto" />)}
|
|
||||||
{directUsers.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SectionHeader>Effective principals</SectionHeader>
|
|
||||||
<div className={styles.sectionTags}>
|
|
||||||
{effectivePrincipals.map((u) => {
|
|
||||||
const isDirect = u.directRoles.includes(selected.name)
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
key={u.id}
|
|
||||||
label={u.displayName}
|
|
||||||
color="auto"
|
|
||||||
variant={isDirect ? 'filled' : 'dashed'}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{effectivePrincipals.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
|
|
||||||
</div>
|
|
||||||
{effectivePrincipals.some((u) => !u.directRoles.includes(selected.name)) && (
|
|
||||||
<span className={styles.inheritedNote}>
|
|
||||||
Dashed entries inherit this role through group membership
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</>
|
getItemId={(role) => role.id}
|
||||||
) : (
|
selectedId={selectedId ?? undefined}
|
||||||
<div className={styles.emptyDetail}>Select a role to view details</div>
|
onSelect={setSelectedId}
|
||||||
)}
|
searchPlaceholder="Search roles..."
|
||||||
</div>
|
onSearch={setSearch}
|
||||||
</div>
|
addLabel="+ Add role"
|
||||||
|
onAdd={() => setCreating(true)}
|
||||||
|
emptyMessage="No roles match your search"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
detail={selected ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.detailHeader}>
|
||||||
|
<Avatar name={selected.name} size="lg" />
|
||||||
|
<div className={styles.detailHeaderInfo}>
|
||||||
|
<div className={styles.detailName}>{selected.name}</div>
|
||||||
|
{selected.description && (
|
||||||
|
<div className={styles.detailEmail}>{selected.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!selected.system && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => setDeleteTarget(selected)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.metaGrid}>
|
||||||
|
<span className={styles.metaLabel}>ID</span>
|
||||||
|
<MonoText size="xs">{selected.id}</MonoText>
|
||||||
|
<span className={styles.metaLabel}>Scope</span>
|
||||||
|
<span className={styles.metaValue}>{selected.scope}</span>
|
||||||
|
{selected.system && (
|
||||||
|
<>
|
||||||
|
<span className={styles.metaLabel}>Type</span>
|
||||||
|
<span className={styles.metaValue}>System role (read-only)</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Assigned to groups</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{assignedGroups.map((g) => <Tag key={g.id} label={g.name} color="success" />)}
|
||||||
|
{assignedGroups.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Assigned to users (direct)</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{directUsers.map((u) => <Tag key={u.id} label={u.displayName} color="auto" />)}
|
||||||
|
{directUsers.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Effective principals</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{effectivePrincipals.map((u) => {
|
||||||
|
const isDirect = u.directRoles.includes(selected.name)
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
key={u.id}
|
||||||
|
label={u.displayName}
|
||||||
|
color="auto"
|
||||||
|
variant={isDirect ? 'filled' : 'dashed'}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{effectivePrincipals.length === 0 && <span className={styles.inheritedNote}>(none)</span>}
|
||||||
|
</div>
|
||||||
|
{effectivePrincipals.some((u) => !u.directRoles.includes(selected.name)) && (
|
||||||
|
<span className={styles.inheritedNote}>
|
||||||
|
Dashed entries inherit this role through group membership
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
emptyMessage="Select a role to view details"
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
|
|||||||
@@ -1,63 +1,3 @@
|
|||||||
.splitPane {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 52fr 48fr;
|
|
||||||
gap: 1px;
|
|
||||||
background: var(--border-subtle);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
min-height: 500px;
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.listPane {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detailPane {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.listHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
}
|
|
||||||
|
|
||||||
.listHeaderSearch {
|
|
||||||
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:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.entityItemSelected {
|
|
||||||
background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
|
|
||||||
.entityInfo {
|
.entityInfo {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -140,16 +80,6 @@
|
|||||||
max-width: 240px;
|
max-width: 240px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptyDetail {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 13px;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
.createForm {
|
.createForm {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
@@ -190,14 +120,6 @@
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.emptySearch {
|
|
||||||
padding: 32px;
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
}
|
|
||||||
|
|
||||||
.securitySection {
|
.securitySection {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { InfoCallout } from '../../../design-system/primitives/InfoCallout/InfoC
|
|||||||
import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect'
|
import { MultiSelect } from '../../../design-system/composites/MultiSelect/MultiSelect'
|
||||||
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
import { ConfirmDialog } from '../../../design-system/composites/ConfirmDialog/ConfirmDialog'
|
||||||
import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'
|
import { AlertDialog } from '../../../design-system/composites/AlertDialog/AlertDialog'
|
||||||
|
import { SplitPane } from '../../../design-system/composites/SplitPane/SplitPane'
|
||||||
|
import { EntityList } from '../../../design-system/composites/EntityList/EntityList'
|
||||||
import { useToast } from '../../../design-system/composites/Toast/Toast'
|
import { useToast } from '../../../design-system/composites/Toast/Toast'
|
||||||
import { MOCK_USERS, MOCK_GROUPS, MOCK_ROLES, getEffectiveRoles, type MockUser } from './rbacMocks'
|
import { MOCK_USERS, MOCK_GROUPS, MOCK_ROLES, getEffectiveRoles, type MockUser } from './rbacMocks'
|
||||||
import styles from './UserManagement.module.css'
|
import styles from './UserManagement.module.css'
|
||||||
@@ -97,260 +99,243 @@ export function UsersTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.splitPane}>
|
<SplitPane
|
||||||
<div className={styles.listPane}>
|
list={
|
||||||
<div className={styles.listHeader}>
|
<>
|
||||||
<Input
|
{creating && (
|
||||||
placeholder="Search users..."
|
<div className={styles.createForm}>
|
||||||
value={search}
|
<RadioGroup name="provider" value={newProvider} onChange={(v) => setNewProvider(v as 'local' | 'oidc')} orientation="horizontal">
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
<RadioItem value="local" label="Local" />
|
||||||
onClear={() => setSearch('')}
|
<RadioItem value="oidc" label="OIDC" />
|
||||||
className={styles.listHeaderSearch}
|
</RadioGroup>
|
||||||
/>
|
<div className={styles.createFormRow}>
|
||||||
<Button size="sm" variant="secondary" onClick={() => setCreating(true)}>
|
<Input placeholder="Username *" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} />
|
||||||
+ Add user
|
<Input placeholder="Display name" value={newDisplay} onChange={(e) => setNewDisplay(e.target.value)} />
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
{duplicateUsername && <span style={{ color: 'var(--error)', fontSize: 11 }}>Username already exists</span>}
|
||||||
|
<Input placeholder="Email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
|
||||||
{creating && (
|
{newProvider === 'local' && (
|
||||||
<div className={styles.createForm}>
|
<Input placeholder="Password *" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
||||||
<RadioGroup name="provider" value={newProvider} onChange={(v) => setNewProvider(v as 'local' | 'oidc')} orientation="horizontal">
|
)}
|
||||||
<RadioItem value="local" label="Local" />
|
{newProvider === 'oidc' && (
|
||||||
<RadioItem value="oidc" label="OIDC" />
|
<InfoCallout variant="amber">
|
||||||
</RadioGroup>
|
OIDC users authenticate via the configured identity provider. Pre-register to assign roles/groups before their first login.
|
||||||
<div className={styles.createFormRow}>
|
</InfoCallout>
|
||||||
<Input placeholder="Username *" value={newUsername} onChange={(e) => setNewUsername(e.target.value)} />
|
)}
|
||||||
<Input placeholder="Display name" value={newDisplay} onChange={(e) => setNewDisplay(e.target.value)} />
|
<div className={styles.createFormActions}>
|
||||||
</div>
|
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
||||||
{duplicateUsername && <span style={{ color: 'var(--error)', fontSize: 11 }}>Username already exists</span>}
|
<Button
|
||||||
<Input placeholder="Email" value={newEmail} onChange={(e) => setNewEmail(e.target.value)} />
|
size="sm"
|
||||||
{newProvider === 'local' && (
|
variant="primary"
|
||||||
<Input placeholder="Password *" type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} />
|
onClick={handleCreate}
|
||||||
)}
|
disabled={!newUsername.trim() || (newProvider === 'local' && !newPassword.trim()) || duplicateUsername}
|
||||||
{newProvider === 'oidc' && (
|
>
|
||||||
<InfoCallout variant="amber">
|
Create
|
||||||
OIDC users authenticate via the configured identity provider. Pre-register to assign roles/groups before their first login.
|
</Button>
|
||||||
</InfoCallout>
|
|
||||||
)}
|
|
||||||
<div className={styles.createFormActions}>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => setCreating(false)}>Cancel</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primary"
|
|
||||||
onClick={handleCreate}
|
|
||||||
disabled={!newUsername.trim() || (newProvider === 'local' && !newPassword.trim()) || duplicateUsername}
|
|
||||||
>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.entityList} role="listbox" aria-label="Users">
|
|
||||||
{filtered.map((user) => (
|
|
||||||
<div
|
|
||||||
key={user.id}
|
|
||||||
className={`${styles.entityItem} ${selectedId === user.id ? styles.entityItemSelected : ''}`}
|
|
||||||
onClick={() => { setSelectedId(user.id); setResettingPassword(false) }}
|
|
||||||
role="option"
|
|
||||||
tabIndex={0}
|
|
||||||
aria-selected={selectedId === user.id}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedId(user.id); setResettingPassword(false) } }}
|
|
||||||
>
|
|
||||||
<Avatar name={user.displayName} size="sm" />
|
|
||||||
<div className={styles.entityInfo}>
|
|
||||||
<div className={styles.entityName}>
|
|
||||||
{user.displayName}
|
|
||||||
{user.provider !== 'local' && (
|
|
||||||
<Badge label={user.provider} color="running" variant="outlined" className={styles.providerBadge} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.entityMeta}>
|
|
||||||
{user.email} · {getUserGroupPath(user)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.entityTags}>
|
|
||||||
{user.directRoles.map((r) => <Badge key={r} label={r} color="warning" />)}
|
|
||||||
{user.directGroups.map((gId) => {
|
|
||||||
const g = MOCK_GROUPS.find((gr) => gr.id === gId)
|
|
||||||
return g ? <Badge key={gId} label={g.name} color="success" /> : null
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
{filtered.length === 0 && (
|
|
||||||
<div className={styles.emptySearch}>No users match your search</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.detailPane}>
|
<EntityList
|
||||||
{selected ? (
|
items={filtered}
|
||||||
<>
|
renderItem={(user) => (
|
||||||
<div className={styles.detailHeader}>
|
<>
|
||||||
<Avatar name={selected.displayName} size="lg" />
|
<Avatar name={user.displayName} size="sm" />
|
||||||
<div className={styles.detailHeaderInfo}>
|
<div className={styles.entityInfo}>
|
||||||
<div className={styles.detailName}>
|
<div className={styles.entityName}>
|
||||||
<InlineEdit
|
{user.displayName}
|
||||||
value={selected.displayName}
|
{user.provider !== 'local' && (
|
||||||
onSave={(v) => updateUser(selected.id, { displayName: v })}
|
<Badge label={user.provider} color="running" variant="outlined" className={styles.providerBadge} />
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.detailEmail}>{selected.email}</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="danger"
|
|
||||||
onClick={() => setDeleteTarget(selected)}
|
|
||||||
disabled={selected.username === 'hendrik'}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SectionHeader>Status</SectionHeader>
|
|
||||||
<div className={styles.sectionTags}>
|
|
||||||
<Tag label="Active" color="success" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.metaGrid}>
|
|
||||||
<span className={styles.metaLabel}>ID</span>
|
|
||||||
<MonoText size="xs">{selected.id}</MonoText>
|
|
||||||
<span className={styles.metaLabel}>Created</span>
|
|
||||||
<span className={styles.metaValue}>{new Date(selected.createdAt).toLocaleDateString()}</span>
|
|
||||||
<span className={styles.metaLabel}>Provider</span>
|
|
||||||
<span className={styles.metaValue}>{selected.provider}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SectionHeader>Security</SectionHeader>
|
|
||||||
<div className={styles.securitySection}>
|
|
||||||
{selected.provider === 'local' ? (
|
|
||||||
<>
|
|
||||||
<div className={styles.securityRow}>
|
|
||||||
<span className={styles.metaLabel}>Password</span>
|
|
||||||
<span className={styles.passwordDots}>••••••••</span>
|
|
||||||
{!resettingPassword && (
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => { setResettingPassword(true); setNewPw('') }}>
|
|
||||||
Reset password
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{resettingPassword && (
|
<div className={styles.entityMeta}>
|
||||||
<div className={styles.resetForm}>
|
{user.email} · {getUserGroupPath(user)}
|
||||||
<Input
|
|
||||||
placeholder="New password"
|
|
||||||
type="password"
|
|
||||||
value={newPw}
|
|
||||||
onChange={(e) => setNewPw(e.target.value)}
|
|
||||||
className={styles.resetInput}
|
|
||||||
/>
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => setResettingPassword(false)}>Cancel</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="primary"
|
|
||||||
onClick={() => { setResettingPassword(false); toast({ title: 'Password updated', description: selected.username, variant: 'success' }) }}
|
|
||||||
disabled={!newPw.trim()}
|
|
||||||
>
|
|
||||||
Set
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className={styles.securityRow}>
|
|
||||||
<span className={styles.metaLabel}>Authentication</span>
|
|
||||||
<span className={styles.metaValue}>OIDC ({selected.provider})</span>
|
|
||||||
</div>
|
</div>
|
||||||
<InfoCallout variant="amber">
|
<div className={styles.entityTags}>
|
||||||
Password managed by the identity provider.
|
{user.directRoles.map((r) => <Badge key={r} label={r} color="warning" />)}
|
||||||
</InfoCallout>
|
{user.directGroups.map((gId) => {
|
||||||
</>
|
const g = MOCK_GROUPS.find((gr) => gr.id === gId)
|
||||||
)}
|
return g ? <Badge key={gId} label={g.name} color="success" /> : null
|
||||||
</div>
|
})}
|
||||||
|
</div>
|
||||||
<SectionHeader>Group membership (direct only)</SectionHeader>
|
</div>
|
||||||
<div className={styles.sectionTags}>
|
</>
|
||||||
{selected.directGroups.map((gId) => {
|
|
||||||
const g = MOCK_GROUPS.find((gr) => gr.id === gId)
|
|
||||||
return g ? (
|
|
||||||
<Tag
|
|
||||||
key={gId}
|
|
||||||
label={g.name}
|
|
||||||
color="success"
|
|
||||||
onRemove={() => {
|
|
||||||
const group = MOCK_GROUPS.find((gr) => gr.id === gId)
|
|
||||||
if (group && group.directRoles.length > 0) {
|
|
||||||
setRemoveGroupTarget(gId)
|
|
||||||
} else {
|
|
||||||
updateUser(selected.id, { directGroups: selected.directGroups.filter((id) => id !== gId) })
|
|
||||||
toast({ title: 'Group removed', variant: 'success' })
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null
|
|
||||||
})}
|
|
||||||
{selected.directGroups.length === 0 && (
|
|
||||||
<span className={styles.inheritedNote}>(no groups)</span>
|
|
||||||
)}
|
|
||||||
<MultiSelect
|
|
||||||
options={availableGroups}
|
|
||||||
value={[]}
|
|
||||||
onChange={(ids) => {
|
|
||||||
updateUser(selected.id, { directGroups: [...selected.directGroups, ...ids] })
|
|
||||||
toast({ title: `${ids.length} group(s) added`, variant: 'success' })
|
|
||||||
}}
|
|
||||||
placeholder="+ Add"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SectionHeader>Effective roles (direct + inherited)</SectionHeader>
|
|
||||||
<div className={styles.sectionTags}>
|
|
||||||
{effectiveRoles.map(({ role, source }) =>
|
|
||||||
source === 'direct' ? (
|
|
||||||
<Tag
|
|
||||||
key={role}
|
|
||||||
label={role}
|
|
||||||
color="warning"
|
|
||||||
onRemove={() => {
|
|
||||||
updateUser(selected.id, { directRoles: selected.directRoles.filter((r) => r !== role) })
|
|
||||||
toast({ title: 'Role removed', description: role, variant: 'success' })
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Badge
|
|
||||||
key={role}
|
|
||||||
label={`${role} ↑ ${source}`}
|
|
||||||
color="warning"
|
|
||||||
variant="dashed"
|
|
||||||
className={styles.inherited}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{effectiveRoles.length === 0 && (
|
|
||||||
<span className={styles.inheritedNote}>(no roles)</span>
|
|
||||||
)}
|
|
||||||
<MultiSelect
|
|
||||||
options={availableRoles}
|
|
||||||
value={[]}
|
|
||||||
onChange={(roles) => {
|
|
||||||
updateUser(selected.id, { directRoles: [...selected.directRoles, ...roles] })
|
|
||||||
toast({ title: `${roles.length} role(s) added`, variant: 'success' })
|
|
||||||
}}
|
|
||||||
placeholder="+ Add"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{effectiveRoles.some((r) => r.source !== 'direct') && (
|
|
||||||
<span className={styles.inheritedNote}>
|
|
||||||
Roles with ↑ are inherited through group membership
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</>
|
getItemId={(user) => user.id}
|
||||||
) : (
|
selectedId={selectedId ?? undefined}
|
||||||
<div className={styles.emptyDetail}>Select a user to view details</div>
|
onSelect={(id) => { setSelectedId(id); setResettingPassword(false) }}
|
||||||
)}
|
searchPlaceholder="Search users..."
|
||||||
</div>
|
onSearch={setSearch}
|
||||||
</div>
|
addLabel="+ Add user"
|
||||||
|
onAdd={() => setCreating(true)}
|
||||||
|
emptyMessage="No users match your search"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
detail={selected ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.detailHeader}>
|
||||||
|
<Avatar name={selected.displayName} size="lg" />
|
||||||
|
<div className={styles.detailHeaderInfo}>
|
||||||
|
<div className={styles.detailName}>
|
||||||
|
<InlineEdit
|
||||||
|
value={selected.displayName}
|
||||||
|
onSave={(v) => updateUser(selected.id, { displayName: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.detailEmail}>{selected.email}</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
onClick={() => setDeleteTarget(selected)}
|
||||||
|
disabled={selected.username === 'hendrik'}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Status</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
<Tag label="Active" color="success" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.metaGrid}>
|
||||||
|
<span className={styles.metaLabel}>ID</span>
|
||||||
|
<MonoText size="xs">{selected.id}</MonoText>
|
||||||
|
<span className={styles.metaLabel}>Created</span>
|
||||||
|
<span className={styles.metaValue}>{new Date(selected.createdAt).toLocaleDateString()}</span>
|
||||||
|
<span className={styles.metaLabel}>Provider</span>
|
||||||
|
<span className={styles.metaValue}>{selected.provider}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Security</SectionHeader>
|
||||||
|
<div className={styles.securitySection}>
|
||||||
|
{selected.provider === 'local' ? (
|
||||||
|
<>
|
||||||
|
<div className={styles.securityRow}>
|
||||||
|
<span className={styles.metaLabel}>Password</span>
|
||||||
|
<span className={styles.passwordDots}>••••••••</span>
|
||||||
|
{!resettingPassword && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => { setResettingPassword(true); setNewPw('') }}>
|
||||||
|
Reset password
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{resettingPassword && (
|
||||||
|
<div className={styles.resetForm}>
|
||||||
|
<Input
|
||||||
|
placeholder="New password"
|
||||||
|
type="password"
|
||||||
|
value={newPw}
|
||||||
|
onChange={(e) => setNewPw(e.target.value)}
|
||||||
|
className={styles.resetInput}
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setResettingPassword(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => { setResettingPassword(false); toast({ title: 'Password updated', description: selected.username, variant: 'success' }) }}
|
||||||
|
disabled={!newPw.trim()}
|
||||||
|
>
|
||||||
|
Set
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.securityRow}>
|
||||||
|
<span className={styles.metaLabel}>Authentication</span>
|
||||||
|
<span className={styles.metaValue}>OIDC ({selected.provider})</span>
|
||||||
|
</div>
|
||||||
|
<InfoCallout variant="amber">
|
||||||
|
Password managed by the identity provider.
|
||||||
|
</InfoCallout>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Group membership (direct only)</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{selected.directGroups.map((gId) => {
|
||||||
|
const g = MOCK_GROUPS.find((gr) => gr.id === gId)
|
||||||
|
return g ? (
|
||||||
|
<Tag
|
||||||
|
key={gId}
|
||||||
|
label={g.name}
|
||||||
|
color="success"
|
||||||
|
onRemove={() => {
|
||||||
|
const group = MOCK_GROUPS.find((gr) => gr.id === gId)
|
||||||
|
if (group && group.directRoles.length > 0) {
|
||||||
|
setRemoveGroupTarget(gId)
|
||||||
|
} else {
|
||||||
|
updateUser(selected.id, { directGroups: selected.directGroups.filter((id) => id !== gId) })
|
||||||
|
toast({ title: 'Group removed', variant: 'success' })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
|
{selected.directGroups.length === 0 && (
|
||||||
|
<span className={styles.inheritedNote}>(no groups)</span>
|
||||||
|
)}
|
||||||
|
<MultiSelect
|
||||||
|
options={availableGroups}
|
||||||
|
value={[]}
|
||||||
|
onChange={(ids) => {
|
||||||
|
updateUser(selected.id, { directGroups: [...selected.directGroups, ...ids] })
|
||||||
|
toast({ title: `${ids.length} group(s) added`, variant: 'success' })
|
||||||
|
}}
|
||||||
|
placeholder="+ Add"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionHeader>Effective roles (direct + inherited)</SectionHeader>
|
||||||
|
<div className={styles.sectionTags}>
|
||||||
|
{effectiveRoles.map(({ role, source }) =>
|
||||||
|
source === 'direct' ? (
|
||||||
|
<Tag
|
||||||
|
key={role}
|
||||||
|
label={role}
|
||||||
|
color="warning"
|
||||||
|
onRemove={() => {
|
||||||
|
updateUser(selected.id, { directRoles: selected.directRoles.filter((r) => r !== role) })
|
||||||
|
toast({ title: 'Role removed', description: role, variant: 'success' })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
key={role}
|
||||||
|
label={`${role} ↑ ${source}`}
|
||||||
|
color="warning"
|
||||||
|
variant="dashed"
|
||||||
|
className={styles.inherited}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{effectiveRoles.length === 0 && (
|
||||||
|
<span className={styles.inheritedNote}>(no roles)</span>
|
||||||
|
)}
|
||||||
|
<MultiSelect
|
||||||
|
options={availableRoles}
|
||||||
|
value={[]}
|
||||||
|
onChange={(roles) => {
|
||||||
|
updateUser(selected.id, { directRoles: [...selected.directRoles, ...roles] })
|
||||||
|
toast({ title: `${roles.length} role(s) added`, variant: 'success' })
|
||||||
|
}}
|
||||||
|
placeholder="+ Add"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{effectiveRoles.some((r) => r.source !== 'direct') && (
|
||||||
|
<span className={styles.inheritedNote}>
|
||||||
|
Roles with ↑ are inherited through group membership
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
emptyMessage="Select a user to view details"
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteTarget !== null}
|
open={deleteTarget !== null}
|
||||||
|
|||||||
@@ -51,20 +51,6 @@
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Section header — matches /agents */
|
|
||||||
.sectionHeaderRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sectionTitle {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Charts 3x2 grid */
|
/* Charts 3x2 grid */
|
||||||
.chartsGrid {
|
.chartsGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -125,12 +111,6 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fdRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Log + Timeline side by side */
|
/* Log + Timeline side by side */
|
||||||
.bottomRow {
|
.bottomRow {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -155,52 +135,7 @@
|
|||||||
border-bottom: 1px solid var(--border-subtle);
|
border-bottom: 1px solid var(--border-subtle);
|
||||||
}
|
}
|
||||||
|
|
||||||
.logEntries {
|
/* Empty state (shared) */
|
||||||
max-height: 360px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logEntry {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 5px 16px;
|
|
||||||
border-bottom: 1px solid var(--border-subtle);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
transition: background 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logEntry:hover {
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.logEntry:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logTime {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--text-muted);
|
|
||||||
min-width: 60px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logLogger {
|
|
||||||
flex-shrink: 0;
|
|
||||||
color: var(--text-faint);
|
|
||||||
max-width: 220px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logMsg {
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logEmpty {
|
.logEmpty {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -12,16 +12,15 @@ import { LineChart } from '../../design-system/composites/LineChart/LineChart'
|
|||||||
import { AreaChart } from '../../design-system/composites/AreaChart/AreaChart'
|
import { AreaChart } from '../../design-system/composites/AreaChart/AreaChart'
|
||||||
import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed'
|
import { EventFeed } from '../../design-system/composites/EventFeed/EventFeed'
|
||||||
import { Tabs } from '../../design-system/composites/Tabs/Tabs'
|
import { Tabs } from '../../design-system/composites/Tabs/Tabs'
|
||||||
|
import { LogViewer } from '../../design-system/composites/LogViewer/LogViewer'
|
||||||
|
import type { LogEntry } from '../../design-system/composites/LogViewer/LogViewer'
|
||||||
|
|
||||||
// Primitives
|
// Primitives
|
||||||
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
||||||
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
||||||
import { ProgressBar } from '../../design-system/primitives/ProgressBar/ProgressBar'
|
|
||||||
import { SectionHeader } from '../../design-system/primitives/SectionHeader/SectionHeader'
|
import { SectionHeader } from '../../design-system/primitives/SectionHeader/SectionHeader'
|
||||||
import { Card } from '../../design-system/primitives/Card/Card'
|
|
||||||
import { CodeBlock } from '../../design-system/primitives/CodeBlock/CodeBlock'
|
|
||||||
|
|
||||||
// Global filters
|
// Global filters
|
||||||
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
|
import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProvider'
|
||||||
@@ -53,27 +52,23 @@ function buildMemoryHistory(currentPct: number) {
|
|||||||
|
|
||||||
// ── Mock log entries ─────────────────────────────────────────────────────────
|
// ── Mock log entries ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildLogEntries(agentName: string) {
|
function buildLogEntries(agentName: string): LogEntry[] {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const MIN = 60_000
|
const MIN = 60_000
|
||||||
return [
|
return [
|
||||||
{ ts: new Date(now - 1 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.impl.DefaultCamelContext', msg: `Route order-validation started and consuming from: direct:validate` },
|
{ timestamp: new Date(now - 1 * MIN).toISOString(), level: 'info', message: `[o.a.c.impl.DefaultCamelContext] Route order-validation started and consuming from: direct:validate` },
|
||||||
{ ts: new Date(now - 2 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.impl.DefaultCamelContext', msg: `Total 3 routes, of which 3 are started` },
|
{ timestamp: new Date(now - 2 * MIN).toISOString(), level: 'info', message: `[o.a.c.impl.DefaultCamelContext] Total 3 routes, of which 3 are started` },
|
||||||
{ ts: new Date(now - 5 * MIN).toISOString(), level: 'WARN', logger: 'o.a.c.processor.errorhandler', msg: `Failed delivery for exchangeId: ID-${agentName}-1710847200000-0-1. Exhausted after 3 attempts.` },
|
{ timestamp: new Date(now - 5 * MIN).toISOString(), level: 'warn', message: `[o.a.c.processor.errorhandler] Failed delivery for exchangeId: ID-${agentName}-1710847200000-0-1. Exhausted after 3 attempts.` },
|
||||||
{ ts: new Date(now - 8 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.health.HealthCheckHelper', msg: `Health check [routes] is UP` },
|
{ timestamp: new Date(now - 8 * MIN).toISOString(), level: 'info', message: `[o.a.c.health.HealthCheckHelper] Health check [routes] is UP` },
|
||||||
{ ts: new Date(now - 12 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.health.HealthCheckHelper', msg: `Health check [consumers] is UP` },
|
{ timestamp: new Date(now - 12 * MIN).toISOString(), level: 'info', message: `[o.a.c.health.HealthCheckHelper] Health check [consumers] is UP` },
|
||||||
{ ts: new Date(now - 15 * MIN).toISOString(), level: 'DEBUG', logger: 'o.a.c.component.kafka', msg: `KafkaConsumer[order-events] poll returned 42 records in 18ms` },
|
{ timestamp: new Date(now - 15 * MIN).toISOString(), level: 'debug', message: `[o.a.c.component.kafka] KafkaConsumer[order-events] poll returned 42 records in 18ms` },
|
||||||
{ ts: new Date(now - 18 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.impl.engine.InternalRouteStartup', msg: `Route order-enrichment started and consuming from: kafka:order-events` },
|
{ timestamp: new Date(now - 18 * MIN).toISOString(), level: 'info', message: `[o.a.c.impl.engine.InternalRouteStartup] Route order-enrichment started and consuming from: kafka:order-events` },
|
||||||
{ ts: new Date(now - 25 * MIN).toISOString(), level: 'WARN', logger: 'o.a.c.component.http', msg: `HTTP endpoint https://payment-api.internal/verify returned 503 — will retry` },
|
{ timestamp: new Date(now - 25 * MIN).toISOString(), level: 'warn', message: `[o.a.c.component.http] HTTP endpoint https://payment-api.internal/verify returned 503 — will retry` },
|
||||||
{ ts: new Date(now - 30 * MIN).toISOString(), level: 'INFO', logger: 'o.a.c.impl.DefaultCamelContext', msg: `Apache Camel ${agentName} (CamelContext) is starting` },
|
{ timestamp: new Date(now - 30 * MIN).toISOString(), level: 'info', message: `[o.a.c.impl.DefaultCamelContext] Apache Camel ${agentName} (CamelContext) is starting` },
|
||||||
{ ts: new Date(now - 32 * MIN).toISOString(), level: 'INFO', logger: 'org.springframework.boot', msg: `Started ${agentName} in 4.231 seconds (process running for 4.892)` },
|
{ timestamp: new Date(now - 32 * MIN).toISOString(), level: 'info', message: `[org.springframework.boot] Started ${agentName} in 4.231 seconds (process running for 4.892)` },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLogTime(iso: string): string {
|
|
||||||
return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mock JVM / process info ──────────────────────────────────────────────────
|
// ── Mock JVM / process info ──────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildProcessInfo(agent: typeof agents[0]) {
|
function buildProcessInfo(agent: typeof agents[0]) {
|
||||||
@@ -144,7 +139,7 @@ export function AgentInstance() {
|
|||||||
const logEntries = buildLogEntries(agent.name)
|
const logEntries = buildLogEntries(agent.name)
|
||||||
const filteredLogs = logFilter === 'all'
|
const filteredLogs = logFilter === 'all'
|
||||||
? logEntries
|
? logEntries
|
||||||
: logEntries.filter((l) => l.level === logFilter.toUpperCase())
|
: logEntries.filter((l) => l.level === logFilter)
|
||||||
|
|
||||||
const cpuData = buildTimeSeries(agent.cpuUsagePct, 15)
|
const cpuData = buildTimeSeries(agent.cpuUsagePct, 15)
|
||||||
const memSeries = buildMemoryHistory(agent.memoryUsagePct)
|
const memSeries = buildMemoryHistory(agent.memoryUsagePct)
|
||||||
@@ -289,22 +284,7 @@ export function AgentInstance() {
|
|||||||
<SectionHeader>Application Log</SectionHeader>
|
<SectionHeader>Application Log</SectionHeader>
|
||||||
<Tabs tabs={LOG_TABS} active={logFilter} onChange={setLogFilter} />
|
<Tabs tabs={LOG_TABS} active={logFilter} onChange={setLogFilter} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.logEntries}>
|
<LogViewer entries={filteredLogs} maxHeight={360} />
|
||||||
{filteredLogs.map((entry, i) => (
|
|
||||||
<div key={i} className={styles.logEntry}>
|
|
||||||
<MonoText size="xs" className={styles.logTime}>{formatLogTime(entry.ts)}</MonoText>
|
|
||||||
<Badge
|
|
||||||
label={entry.level}
|
|
||||||
color={entry.level === 'WARN' ? 'warning' : entry.level === 'ERROR' ? 'error' : entry.level === 'DEBUG' ? 'auto' : 'success'}
|
|
||||||
/>
|
|
||||||
<MonoText size="xs" className={styles.logLogger}>{entry.logger}</MonoText>
|
|
||||||
<span className={styles.logMsg}>{entry.msg}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{filteredLogs.length === 0 && (
|
|
||||||
<div className={styles.logEmpty}>No log entries match the selected filter.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
|
|||||||
@@ -7,14 +7,6 @@
|
|||||||
background: var(--bg-body);
|
background: var(--bg-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Health strip */
|
|
||||||
.healthStrip {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, 1fr);
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Filter bar spacing */
|
/* Filter bar spacing */
|
||||||
.filterBar {
|
.filterBar {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ import { ShortcutsBar } from '../../design-system/composites/ShortcutsBar/Shortc
|
|||||||
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
|
import { ProcessorTimeline } from '../../design-system/composites/ProcessorTimeline/ProcessorTimeline'
|
||||||
import { RouteFlow } from '../../design-system/composites/RouteFlow/RouteFlow'
|
import { RouteFlow } from '../../design-system/composites/RouteFlow/RouteFlow'
|
||||||
import type { RouteNode } from '../../design-system/composites/RouteFlow/RouteFlow'
|
import type { RouteNode } from '../../design-system/composites/RouteFlow/RouteFlow'
|
||||||
|
import { KpiStrip } from '../../design-system/composites/KpiStrip/KpiStrip'
|
||||||
|
import type { KpiItem } from '../../design-system/composites/KpiStrip/KpiStrip'
|
||||||
|
|
||||||
// Primitives
|
// Primitives
|
||||||
import { StatCard } from '../../design-system/primitives/StatCard/StatCard'
|
|
||||||
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
import { StatusDot } from '../../design-system/primitives/StatusDot/StatusDot'
|
||||||
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
@@ -27,12 +28,44 @@ import { useGlobalFilters } from '../../design-system/providers/GlobalFilterProv
|
|||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
import { exchanges, type Exchange } from '../../mocks/exchanges'
|
||||||
import { kpiMetrics } from '../../mocks/metrics'
|
import { kpiMetrics, type KpiMetric } from '../../mocks/metrics'
|
||||||
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
|
import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
|
||||||
|
|
||||||
// Route → Application lookup
|
// Route → Application lookup
|
||||||
const ROUTE_TO_APP = buildRouteToAppMap()
|
const ROUTE_TO_APP = buildRouteToAppMap()
|
||||||
|
|
||||||
|
// ─── KPI mapping ─────────────────────────────────────────────────────────────
|
||||||
|
const ACCENT_TO_COLOR: Record<KpiMetric['accent'], string> = {
|
||||||
|
amber: 'var(--amber)',
|
||||||
|
success: 'var(--success)',
|
||||||
|
error: 'var(--error)',
|
||||||
|
running: 'var(--running)',
|
||||||
|
warning: 'var(--warning)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TREND_ICONS: Record<KpiMetric['trend'], string> = {
|
||||||
|
up: '\u2191',
|
||||||
|
down: '\u2193',
|
||||||
|
neutral: '\u2192',
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentimentToVariant(sentiment: KpiMetric['trendSentiment']): 'success' | 'error' | 'muted' {
|
||||||
|
switch (sentiment) {
|
||||||
|
case 'good': return 'success'
|
||||||
|
case 'bad': return 'error'
|
||||||
|
case 'neutral': return 'muted'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpiItems: KpiItem[] = kpiMetrics.map((m) => ({
|
||||||
|
label: m.label,
|
||||||
|
value: m.unit ? `${m.value} ${m.unit}` : m.value,
|
||||||
|
trend: { label: `${TREND_ICONS[m.trend]} ${m.trendValue}`, variant: sentimentToVariant(m.trendSentiment) },
|
||||||
|
subtitle: m.detail,
|
||||||
|
sparkline: m.sparkline,
|
||||||
|
borderColor: ACCENT_TO_COLOR[m.accent],
|
||||||
|
}))
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
function formatDuration(ms: number): string {
|
function formatDuration(ms: number): string {
|
||||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`
|
||||||
@@ -370,20 +403,7 @@ export function Dashboard() {
|
|||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
|
|
||||||
{/* Health strip */}
|
{/* Health strip */}
|
||||||
<div className={styles.healthStrip}>
|
<KpiStrip items={kpiItems} />
|
||||||
{kpiMetrics.map((kpi, i) => (
|
|
||||||
<StatCard
|
|
||||||
key={i}
|
|
||||||
label={kpi.label}
|
|
||||||
value={kpi.value}
|
|
||||||
detail={kpi.detail}
|
|
||||||
trend={kpi.trend}
|
|
||||||
trendValue={kpi.trendValue}
|
|
||||||
accent={kpi.accent}
|
|
||||||
sparkline={kpi.sparkline}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Exchanges table */}
|
{/* Exchanges table */}
|
||||||
<div className={styles.tableSection}>
|
<div className={styles.tableSection}>
|
||||||
|
|||||||
@@ -35,176 +35,6 @@
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* KPI strip */
|
|
||||||
.kpiStrip {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(5, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* KPI card */
|
|
||||||
.kpiCard {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
padding: 16px 18px 12px;
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: box-shadow 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiCard:hover {
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiCard::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiCardAmber::before { background: linear-gradient(90deg, var(--amber), transparent); }
|
|
||||||
.kpiCardGreen::before { background: linear-gradient(90deg, var(--success), transparent); }
|
|
||||||
.kpiCardError::before { background: linear-gradient(90deg, var(--error), transparent); }
|
|
||||||
.kpiCardTeal::before { background: linear-gradient(90deg, var(--running), transparent); }
|
|
||||||
.kpiCardWarn::before { background: linear-gradient(90deg, var(--warning), transparent); }
|
|
||||||
|
|
||||||
.kpiLabel {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.6px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiValueRow {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 6px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiValue {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 26px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiValueAmber { color: var(--amber); }
|
|
||||||
.kpiValueGreen { color: var(--success); }
|
|
||||||
.kpiValueError { color: var(--error); }
|
|
||||||
.kpiValueTeal { color: var(--running); }
|
|
||||||
.kpiValueWarn { color: var(--warning); }
|
|
||||||
|
|
||||||
.kpiUnit {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiTrend {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 11px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trendUpGood { color: var(--success); }
|
|
||||||
.trendUpBad { color: var(--error); }
|
|
||||||
.trendDownGood { color: var(--success); }
|
|
||||||
.trendDownBad { color: var(--error); }
|
|
||||||
.trendFlat { color: var(--text-muted); }
|
|
||||||
|
|
||||||
.kpiDetail {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiDetailStrong {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.kpiSparkline {
|
|
||||||
margin-top: 8px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Latency percentiles card */
|
|
||||||
.latencyValues {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latencyItem {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latencyLabel {
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.latencyVal {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.latValGreen { color: var(--success); }
|
|
||||||
.latValAmber { color: var(--amber); }
|
|
||||||
.latValRed { color: var(--error); }
|
|
||||||
|
|
||||||
.latencyTrend {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active routes donut */
|
|
||||||
.donutWrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.donutLabel {
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.donutLegend {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.donutLegendActive {
|
|
||||||
color: var(--running);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Route performance table */
|
/* Route performance table */
|
||||||
.tableSection {
|
.tableSection {
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
@@ -273,24 +103,6 @@
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartCard {
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
padding: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chartTitle {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart {
|
.chart {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,14 @@ import { DataTable } from '../../design-system/composites/DataTable/DataTable'
|
|||||||
import type { Column } from '../../design-system/composites/DataTable/types'
|
import type { Column } from '../../design-system/composites/DataTable/types'
|
||||||
import { RouteFlow } from '../../design-system/composites/RouteFlow/RouteFlow'
|
import { RouteFlow } from '../../design-system/composites/RouteFlow/RouteFlow'
|
||||||
import type { RouteNode } from '../../design-system/composites/RouteFlow/RouteFlow'
|
import type { RouteNode } from '../../design-system/composites/RouteFlow/RouteFlow'
|
||||||
|
import { KpiStrip } from '../../design-system/composites'
|
||||||
|
import type { KpiItem } from '../../design-system/composites'
|
||||||
|
|
||||||
// Primitives
|
// Primitives
|
||||||
import { Sparkline } from '../../design-system/primitives/Sparkline/Sparkline'
|
import { Sparkline } from '../../design-system/primitives/Sparkline/Sparkline'
|
||||||
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
import { MonoText } from '../../design-system/primitives/MonoText/MonoText'
|
||||||
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
import { Badge } from '../../design-system/primitives/Badge/Badge'
|
||||||
|
import { Card } from '../../design-system/primitives'
|
||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
import {
|
import {
|
||||||
@@ -34,8 +37,8 @@ import { SIDEBAR_APPS, buildRouteToAppMap } from '../../mocks/sidebar'
|
|||||||
|
|
||||||
const ROUTE_TO_APP = buildRouteToAppMap()
|
const ROUTE_TO_APP = buildRouteToAppMap()
|
||||||
|
|
||||||
// ─── KPI Header Strip (matches mock-v3-metrics-dashboard) ────────────────────
|
// ─── Build KPI items from scoped route metrics ──────────────────────────────
|
||||||
function KpiHeader({ scopedMetrics }: { scopedMetrics: RouteMetricRow[] }) {
|
function buildKpiItems(scopedMetrics: RouteMetricRow[]): KpiItem[] {
|
||||||
const totalExchanges = scopedMetrics.reduce((sum, r) => sum + r.exchangeCount, 0)
|
const totalExchanges = scopedMetrics.reduce((sum, r) => sum + r.exchangeCount, 0)
|
||||||
const totalErrors = scopedMetrics.reduce((sum, r) => sum + r.errorCount, 0)
|
const totalErrors = scopedMetrics.reduce((sum, r) => sum + r.errorCount, 0)
|
||||||
const errorRate = totalExchanges > 0 ? ((totalErrors / totalExchanges) * 100) : 0
|
const errorRate = totalExchanges > 0 ? ((totalErrors / totalExchanges) * 100) : 0
|
||||||
@@ -45,113 +48,57 @@ function KpiHeader({ scopedMetrics }: { scopedMetrics: RouteMetricRow[] }) {
|
|||||||
const p99Latency = scopedMetrics.length > 0
|
const p99Latency = scopedMetrics.length > 0
|
||||||
? Math.max(...scopedMetrics.map((r) => r.p99DurationMs))
|
? Math.max(...scopedMetrics.map((r) => r.p99DurationMs))
|
||||||
: 0
|
: 0
|
||||||
const avgSuccessRate = scopedMetrics.length > 0
|
|
||||||
? Number((scopedMetrics.reduce((sum, r) => sum + r.successRate, 0) / scopedMetrics.length).toFixed(1))
|
|
||||||
: 0
|
|
||||||
const throughputPerSec = totalExchanges > 0 ? (totalExchanges / 360).toFixed(1) : '0'
|
const throughputPerSec = totalExchanges > 0 ? (totalExchanges / 360).toFixed(1) : '0'
|
||||||
const activeRoutes = scopedMetrics.length
|
const activeRoutes = scopedMetrics.length
|
||||||
const totalRoutes = routeMetrics.length
|
const totalRoutes = routeMetrics.length
|
||||||
|
|
||||||
return (
|
const p50 = Math.round(avgLatency * 0.5)
|
||||||
<div className={styles.kpiStrip}>
|
const p95 = Math.round(avgLatency * 1.4)
|
||||||
{/* Card 1: Total Throughput */}
|
const slaStatus = p99Latency > 300 ? 'BREACH' : 'OK'
|
||||||
<div className={`${styles.kpiCard} ${styles.kpiCardAmber}`}>
|
|
||||||
<div className={styles.kpiLabel}>Total Throughput</div>
|
|
||||||
<div className={styles.kpiValueRow}>
|
|
||||||
<span className={`${styles.kpiValue} ${styles.kpiValueAmber}`}>{totalExchanges.toLocaleString()}</span>
|
|
||||||
<span className={styles.kpiUnit}>exchanges</span>
|
|
||||||
<span className={`${styles.kpiTrend} ${styles.trendUpGood}`}>▲ +8%</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.kpiDetail}>
|
|
||||||
<span className={styles.kpiDetailStrong}>{throughputPerSec}</span> msg/s · Capacity 39%
|
|
||||||
</div>
|
|
||||||
<div className={styles.kpiSparkline}>
|
|
||||||
<Sparkline data={[44, 46, 45, 47, 48, 46, 47, 48, 46, 47, 48, 47, 46, 47]} color="var(--amber)" width={200} height={32} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card 2: System Error Rate */}
|
return [
|
||||||
<div className={`${styles.kpiCard} ${errorRate < 1 ? styles.kpiCardGreen : styles.kpiCardError}`}>
|
{
|
||||||
<div className={styles.kpiLabel}>System Error Rate</div>
|
label: 'Total Throughput',
|
||||||
<div className={styles.kpiValueRow}>
|
value: totalExchanges.toLocaleString(),
|
||||||
<span className={`${styles.kpiValue} ${errorRate < 1 ? styles.kpiValueGreen : styles.kpiValueError}`}>{errorRate.toFixed(2)}%</span>
|
trend: { label: '\u25B2 +8%', variant: 'success' as const },
|
||||||
<span className={`${styles.kpiTrend} ${errorRate < 1 ? styles.trendDownGood : styles.trendUpBad}`}>
|
subtitle: `${throughputPerSec} msg/s \u00B7 Capacity 39%`,
|
||||||
{errorRate < 1 ? '\u25BC -0.1%' : '\u25B2 +0.4%'}
|
sparkline: [44, 46, 45, 47, 48, 46, 47, 48, 46, 47, 48, 47, 46, 47],
|
||||||
</span>
|
borderColor: 'var(--amber)',
|
||||||
</div>
|
},
|
||||||
<div className={styles.kpiDetail}>
|
{
|
||||||
<span className={styles.kpiDetailStrong}>{totalErrors}</span> errors / <span className={styles.kpiDetailStrong}>{totalExchanges.toLocaleString()}</span> total (6h)
|
label: 'System Error Rate',
|
||||||
</div>
|
value: `${errorRate.toFixed(2)}%`,
|
||||||
<div className={styles.kpiSparkline}>
|
trend: {
|
||||||
<Sparkline data={[1.2, 1.8, 1.5, 2.1, 2.4, 2.2, 2.5, 2.6, 2.7, 2.8, 2.7, 2.9, 2.8, errorRate]} color={errorRate < 1 ? 'var(--success)' : 'var(--error)'} width={200} height={32} />
|
label: errorRate < 1 ? '\u25BC -0.1%' : '\u25B2 +0.4%',
|
||||||
</div>
|
variant: errorRate < 1 ? 'success' as const : 'error' as const,
|
||||||
</div>
|
},
|
||||||
|
subtitle: `${totalErrors} errors / ${totalExchanges.toLocaleString()} total (6h)`,
|
||||||
{/* Card 3: Latency Percentiles */}
|
sparkline: [1.2, 1.8, 1.5, 2.1, 2.4, 2.2, 2.5, 2.6, 2.7, 2.8, 2.7, 2.9, 2.8, errorRate],
|
||||||
<div className={`${styles.kpiCard} ${p99Latency > 300 ? styles.kpiCardWarn : styles.kpiCardGreen}`}>
|
borderColor: errorRate < 1 ? 'var(--success)' : 'var(--error)',
|
||||||
<div className={styles.kpiLabel}>Latency Percentiles</div>
|
},
|
||||||
<div className={styles.latencyValues}>
|
{
|
||||||
<div className={styles.latencyItem}>
|
label: 'Latency Percentiles',
|
||||||
<span className={styles.latencyLabel}>P50</span>
|
value: `${p99Latency}ms`,
|
||||||
<span className={`${styles.latencyVal} ${styles.latValGreen}`}>{Math.round(avgLatency * 0.5)}ms</span>
|
trend: { label: '\u25B2 +28', variant: p99Latency > 300 ? 'error' as const : 'warning' as const },
|
||||||
<span className={`${styles.latencyTrend} ${styles.trendDownGood}`}>▼3</span>
|
subtitle: `P50 ${p50}ms \u00B7 P95 ${p95}ms \u00B7 SLA <300ms P99: ${slaStatus}`,
|
||||||
</div>
|
borderColor: p99Latency > 300 ? 'var(--warning)' : 'var(--success)',
|
||||||
<div className={styles.latencyItem}>
|
},
|
||||||
<span className={styles.latencyLabel}>P95</span>
|
{
|
||||||
<span className={`${styles.latencyVal} ${avgLatency > 150 ? styles.latValAmber : styles.latValGreen}`}>{Math.round(avgLatency * 1.4)}ms</span>
|
label: 'Active Routes',
|
||||||
<span className={`${styles.latencyTrend} ${styles.trendUpBad}`}>▲12</span>
|
value: `${activeRoutes} / ${totalRoutes}`,
|
||||||
</div>
|
trend: { label: '\u2194 stable', variant: 'muted' as const },
|
||||||
<div className={styles.latencyItem}>
|
subtitle: `${activeRoutes} active \u00B7 ${totalRoutes - activeRoutes} stopped`,
|
||||||
<span className={styles.latencyLabel}>P99</span>
|
borderColor: 'var(--running)',
|
||||||
<span className={`${styles.latencyVal} ${p99Latency > 300 ? styles.latValRed : styles.latValAmber}`}>{p99Latency}ms</span>
|
},
|
||||||
<span className={`${styles.latencyTrend} ${styles.trendUpBad}`}>▲28</span>
|
{
|
||||||
</div>
|
label: 'In-Flight Exchanges',
|
||||||
</div>
|
value: '23',
|
||||||
<div className={styles.kpiDetail}>
|
trend: { label: '\u2194', variant: 'muted' as const },
|
||||||
SLA: <300ms P99 · {p99Latency > 300
|
subtitle: 'High-water: 67 (2h ago)',
|
||||||
? <span style={{ color: 'var(--error)', fontWeight: 600 }}>BREACH</span>
|
sparkline: [16, 14, 18, 12, 10, 15, 8, 6, 4, 3, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 18, 16, 18, 20, 18, 23],
|
||||||
: <span style={{ color: 'var(--success)', fontWeight: 600 }}>OK</span>}
|
borderColor: 'var(--amber)',
|
||||||
</div>
|
},
|
||||||
</div>
|
]
|
||||||
|
|
||||||
{/* Card 4: Active Routes */}
|
|
||||||
<div className={`${styles.kpiCard} ${styles.kpiCardTeal}`}>
|
|
||||||
<div className={styles.kpiLabel}>Active Routes</div>
|
|
||||||
<div className={styles.kpiValueRow}>
|
|
||||||
<span className={`${styles.kpiValue} ${styles.kpiValueTeal}`}>{activeRoutes}</span>
|
|
||||||
<span className={styles.kpiUnit}>of {totalRoutes}</span>
|
|
||||||
<span className={`${styles.kpiTrend} ${styles.trendFlat}`}>↔ stable</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.donutWrap}>
|
|
||||||
<svg viewBox="0 0 36 36" width="40" height="40">
|
|
||||||
<circle cx="18" cy="18" r="15.9" fill="none" stroke="var(--bg-inset)" strokeWidth="3" />
|
|
||||||
<circle cx="18" cy="18" r="15.9" fill="none" stroke="var(--running)" strokeWidth="3"
|
|
||||||
strokeDasharray={`${(activeRoutes / totalRoutes) * 100} ${100 - (activeRoutes / totalRoutes) * 100}`}
|
|
||||||
strokeDashoffset="25" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
<div className={styles.donutLegend}>
|
|
||||||
<span className={styles.donutLegendActive}>{activeRoutes} active</span>
|
|
||||||
<span>{totalRoutes - activeRoutes} stopped</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Card 5: In-Flight Exchanges */}
|
|
||||||
<div className={`${styles.kpiCard} ${styles.kpiCardAmber}`}>
|
|
||||||
<div className={styles.kpiLabel}>In-Flight Exchanges</div>
|
|
||||||
<div className={styles.kpiValueRow}>
|
|
||||||
<span className={styles.kpiValue}>23</span>
|
|
||||||
<span className={`${styles.kpiTrend} ${styles.trendFlat}`}>↔</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.kpiDetail}>
|
|
||||||
High-water: <span className={styles.kpiDetailStrong}>67</span> (2h ago)
|
|
||||||
</div>
|
|
||||||
<div className={styles.kpiSparkline}>
|
|
||||||
<Sparkline data={[16, 14, 18, 12, 10, 15, 8, 6, 4, 3, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 18, 16, 18, 20, 18, 23]} color="var(--amber)" width={200} height={32} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Route metric row with id field (required by DataTable) ──────────────────
|
// ─── Route metric row with id field (required by DataTable) ──────────────────
|
||||||
@@ -475,7 +422,7 @@ export function Routes() {
|
|||||||
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
<span className={styles.refreshText}>Auto-refresh: 30s</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<KpiHeader scopedMetrics={scopedMetricsForKpi} />
|
<KpiStrip items={buildKpiItems(scopedMetricsForKpi)} />
|
||||||
|
|
||||||
{/* Processor Performance table */}
|
{/* Processor Performance table */}
|
||||||
<div className={styles.tableSection}>
|
<div className={styles.tableSection}>
|
||||||
@@ -520,7 +467,7 @@ export function Routes() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI header cards */}
|
{/* KPI header cards */}
|
||||||
<KpiHeader scopedMetrics={scopedMetricsForKpi} />
|
<KpiStrip items={buildKpiItems(scopedMetricsForKpi)} />
|
||||||
|
|
||||||
{/* Per-route performance table */}
|
{/* Per-route performance table */}
|
||||||
<div className={styles.tableSection}>
|
<div className={styles.tableSection}>
|
||||||
@@ -544,8 +491,7 @@ export function Routes() {
|
|||||||
|
|
||||||
{/* 2x2 chart grid */}
|
{/* 2x2 chart grid */}
|
||||||
<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={convertSeries(throughputSeries)}
|
series={convertSeries(throughputSeries)}
|
||||||
yLabel="msg/s"
|
yLabel="msg/s"
|
||||||
@@ -553,10 +499,9 @@ export function Routes() {
|
|||||||
width={500}
|
width={500}
|
||||||
className={styles.chart}
|
className={styles.chart}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div className={styles.chartCard}>
|
<Card title="Latency (ms)">
|
||||||
<div className={styles.chartTitle}>Latency (ms)</div>
|
|
||||||
<LineChart
|
<LineChart
|
||||||
series={convertSeries(latencySeries)}
|
series={convertSeries(latencySeries)}
|
||||||
yLabel="ms"
|
yLabel="ms"
|
||||||
@@ -565,10 +510,9 @@ export function Routes() {
|
|||||||
width={500}
|
width={500}
|
||||||
className={styles.chart}
|
className={styles.chart}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div className={styles.chartCard}>
|
<Card title="Errors by Route">
|
||||||
<div className={styles.chartTitle}>Errors by Route</div>
|
|
||||||
<BarChart
|
<BarChart
|
||||||
series={ERROR_BAR_SERIES}
|
series={ERROR_BAR_SERIES}
|
||||||
stacked
|
stacked
|
||||||
@@ -576,10 +520,9 @@ export function Routes() {
|
|||||||
width={500}
|
width={500}
|
||||||
className={styles.chart}
|
className={styles.chart}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
<div className={styles.chartCard}>
|
<Card title="Message Volume (msg/min)">
|
||||||
<div className={styles.chartTitle}>Message Volume (msg/min)</div>
|
|
||||||
<AreaChart
|
<AreaChart
|
||||||
series={VOLUME_SERIES}
|
series={VOLUME_SERIES}
|
||||||
yLabel="msg/min"
|
yLabel="msg/min"
|
||||||
@@ -587,7 +530,7 @@ export function Routes() {
|
|||||||
width={500}
|
width={500}
|
||||||
className={styles.chart}
|
className={styles.chart}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
Reference in New Issue
Block a user