diff --git a/ui/src/pages/Admin/OidcConfigPage.module.css b/ui/src/pages/Admin/OidcConfigPage.module.css new file mode 100644 index 00000000..8fe86e98 --- /dev/null +++ b/ui/src/pages/Admin/OidcConfigPage.module.css @@ -0,0 +1,28 @@ +.section { + display: grid; + gap: 0.5rem; +} + +.section h3 { + font-size: 0.875rem; + font-weight: 600; + margin: 0; +} + +.tagRow { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + min-height: 2rem; + align-items: center; +} + +.addRow { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.addRow input { + flex: 1; +} diff --git a/ui/src/pages/Admin/OidcConfigPage.tsx b/ui/src/pages/Admin/OidcConfigPage.tsx index 70b57d67..7cc8cfb5 100644 --- a/ui/src/pages/Admin/OidcConfigPage.tsx +++ b/ui/src/pages/Admin/OidcConfigPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; -import { Button, Input, Toggle, FormField, Card, Alert, SectionHeader } from '@cameleer/design-system'; +import { Button, Input, Toggle, FormField, Card, Alert, SectionHeader, Tag, ConfirmDialog } from '@cameleer/design-system'; import { adminFetch } from '../../api/queries/admin/admin-api'; +import styles from './OidcConfigPage.module.css'; interface OidcConfig { enabled: boolean; @@ -18,6 +19,8 @@ export default function OidcConfigPage() { const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); + const [newRole, setNewRole] = useState(''); + const [deleteOpen, setDeleteOpen] = useState(false); useEffect(() => { adminFetch('/oidc') @@ -64,15 +67,44 @@ export default function OidcConfigPage() { setConfig({ ...config, displayNameClaim: e.target.value })} /> setConfig({ ...config, autoSignup: e.target.checked })} label="Auto Signup" /> +
+

Default Roles

+
+ {(config.defaultRoles || []).map(role => ( + { + setConfig(prev => ({ ...prev!, defaultRoles: prev!.defaultRoles.filter(r => r !== role) })); + }} /> + ))} +
+
+ setNewRole(e.target.value)} /> + +
+
+
- +
{error && {error}} {success && Configuration saved} + + setDeleteOpen(false)} + onConfirm={handleDelete} + title="Delete OIDC Configuration" + message="Delete OIDC configuration? All OIDC users will lose access." + confirmText="DELETE" + /> ); } diff --git a/ui/src/pages/AgentHealth/AgentHealth.module.css b/ui/src/pages/AgentHealth/AgentHealth.module.css index 814e1c2c..4c6cc04e 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.module.css +++ b/ui/src/pages/AgentHealth/AgentHealth.module.css @@ -1,10 +1,17 @@ .statStrip { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(5, 1fr); gap: 10px; margin-bottom: 16px; } +.scopeTrail { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 16px; +} + .groupGrid { display: grid; grid-template-columns: 1fr 1fr; @@ -36,11 +43,22 @@ color: var(--text-primary); } -.instanceTps { - margin-left: auto; +.instanceMeta { font-size: 11px; - font-family: var(--font-mono); color: var(--text-muted); + font-family: var(--font-mono); +} + +.instanceLink { + color: var(--text-muted); + text-decoration: none; + font-size: 14px; + padding: 4px; + margin-left: auto; +} + +.instanceLink:hover { + color: var(--text-primary); } .eventCard { diff --git a/ui/src/pages/AgentHealth/AgentHealth.tsx b/ui/src/pages/AgentHealth/AgentHealth.tsx index f3a30f66..341170c6 100644 --- a/ui/src/pages/AgentHealth/AgentHealth.tsx +++ b/ui/src/pages/AgentHealth/AgentHealth.tsx @@ -1,13 +1,34 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useParams, useNavigate } from 'react-router'; import { StatCard, StatusDot, Badge, MonoText, - GroupCard, EventFeed, + GroupCard, EventFeed, Breadcrumb, Alert, } from '@cameleer/design-system'; import styles from './AgentHealth.module.css'; import { useAgents, useAgentEvents } from '../../api/queries/agents'; import { useRouteCatalog } from '../../api/queries/catalog'; +function formatUptime(seconds?: number): string { + if (!seconds) return '—'; + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const mins = Math.floor((seconds % 3600) / 60); + if (days > 0) return `${days}d ${hours}h`; + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; +} + +function formatRelativeTime(iso?: string): string { + if (!iso) return '—'; + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + export default function AgentHealth() { const { appId } = useParams(); const navigate = useNavigate(); @@ -15,6 +36,8 @@ export default function AgentHealth() { const { data: catalog } = useRouteCatalog(); const { data: events } = useAgentEvents(appId); + const [selectedAgent, setSelectedAgent] = useState(null); + const agentsByApp = useMemo(() => { const map: Record = {}; (agents || []).forEach((a: any) => { @@ -25,10 +48,30 @@ export default function AgentHealth() { return map; }, [agents]); - const totalAgents = agents?.length ?? 0; const liveCount = (agents || []).filter((a: any) => a.status === 'LIVE').length; const staleCount = (agents || []).filter((a: any) => a.status === 'STALE').length; const deadCount = (agents || []).filter((a: any) => a.status === 'DEAD').length; + const uniqueApps = new Set((agents || []).map((a: any) => a.group)).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 groupHealth: 'live' | 'stale' | 'dead' = useMemo(() => { + if (!appId) return 'live'; + const groupAgents = agentsByApp[appId] || []; + if (groupAgents.some((a: any) => a.status === 'DEAD')) return 'dead'; + if (groupAgents.some((a: any) => a.status === 'STALE')) return 'stale'; + return 'live'; + }, [appId, agentsByApp]); + + const scopeItems = useMemo(() => { + const items: { label: string; href?: string }[] = [ + { label: 'Agent Health', href: '/agents' }, + ]; + if (appId) { + items.push({ label: appId }); + } + return items; + }, [appId]); const feedEvents = useMemo(() => (events || []).map((e: any) => ({ @@ -48,39 +91,67 @@ export default function AgentHealth() { return (
- - - - + + + + + 0 ? 'error' : undefined} /> +
+ +
+ + {!appId && } + {appId && ( + + )}
- {Object.entries(apps).map(([group, groupAgents]) => ( - } - accent={ - groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error' - : groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning' - : 'success' - } - onClick={() => navigate(`/agents/${group}`)} - > - {(groupAgents || []).map((agent: any) => ( -
{ e.stopPropagation(); navigate(`/agents/${group}/${agent.id}`); }} - > - - {agent.name} - - {agent.tps > 0 && {agent.tps.toFixed(1)} tps} -
- ))} -
- ))} + {Object.entries(apps).map(([group, groupAgents]) => { + const deadInGroup = (groupAgents || []).filter((a: any) => a.status === 'DEAD'); + return ( + } + accent={ + groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error' + : groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning' + : 'success' + } + onClick={() => navigate(`/agents/${group}`)} + > + {deadInGroup.length > 0 && ( + {deadInGroup.length} instance(s) unreachable + )} + {(groupAgents || []).map((agent: any) => ( +
{ + e.stopPropagation(); + setSelectedAgent(agent); + navigate(`/agents/${group}/${agent.id}`); + }} + > + + {agent.name} + + {formatUptime(agent.uptimeSeconds)} + {agent.tps != null && {(agent.tps || 0).toFixed(1)} tps} + {agent.errorRate != null && ( + {(agent.errorRate * 100).toFixed(1)}% err + )} + {formatRelativeTime(agent.lastHeartbeat)} + +
+ ))} +
+ ); + })}
{feedEvents.length > 0 && (