feat: add default roles and ConfirmDialog to OIDC config
Adds a Default Roles section with Tag components for viewing/removing roles and an Input+Button for adding new ones. Replaces the plain delete button with a ConfirmDialog requiring typed confirmation. Introduces OidcConfigPage.module.css for CSS module layout classes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
28
ui/src/pages/Admin/OidcConfigPage.module.css
Normal file
28
ui/src/pages/Admin/OidcConfigPage.module.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 { adminFetch } from '../../api/queries/admin/admin-api';
|
||||||
|
import styles from './OidcConfigPage.module.css';
|
||||||
|
|
||||||
interface OidcConfig {
|
interface OidcConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -18,6 +19,8 @@ export default function OidcConfigPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [newRole, setNewRole] = useState('');
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adminFetch<OidcConfig>('/oidc')
|
adminFetch<OidcConfig>('/oidc')
|
||||||
@@ -64,15 +67,44 @@ export default function OidcConfigPage() {
|
|||||||
<FormField label="Display Name Claim"><Input value={config.displayNameClaim} onChange={(e) => setConfig({ ...config, displayNameClaim: e.target.value })} /></FormField>
|
<FormField label="Display Name Claim"><Input value={config.displayNameClaim} onChange={(e) => setConfig({ ...config, displayNameClaim: e.target.value })} /></FormField>
|
||||||
<Toggle checked={config.autoSignup} onChange={(e) => setConfig({ ...config, autoSignup: e.target.checked })} label="Auto Signup" />
|
<Toggle checked={config.autoSignup} onChange={(e) => setConfig({ ...config, autoSignup: e.target.checked })} label="Auto Signup" />
|
||||||
|
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3>Default Roles</h3>
|
||||||
|
<div className={styles.tagRow}>
|
||||||
|
{(config.defaultRoles || []).map(role => (
|
||||||
|
<Tag key={role} label={role} onRemove={() => {
|
||||||
|
setConfig(prev => ({ ...prev!, defaultRoles: prev!.defaultRoles.filter(r => r !== role) }));
|
||||||
|
}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className={styles.addRow}>
|
||||||
|
<Input placeholder="Add role..." value={newRole} onChange={e => setNewRole(e.target.value)} />
|
||||||
|
<Button onClick={() => {
|
||||||
|
if (newRole.trim() && !config.defaultRoles?.includes(newRole.trim())) {
|
||||||
|
setConfig(prev => ({ ...prev!, defaultRoles: [...(prev!.defaultRoles || []), newRole.trim()] }));
|
||||||
|
setNewRole('');
|
||||||
|
}
|
||||||
|
}}>Add</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||||
<Button variant="primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
<Button variant="primary" onClick={handleSave} disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||||
<Button variant="danger" onClick={handleDelete}>Remove Config</Button>
|
<Button variant="danger" onClick={() => setDeleteOpen(true)}>Delete Configuration</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <Alert variant="error">{error}</Alert>}
|
{error && <Alert variant="error">{error}</Alert>}
|
||||||
{success && <Alert variant="success">Configuration saved</Alert>}
|
{success && <Alert variant="success">Configuration saved</Alert>}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
onClose={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="Delete OIDC Configuration"
|
||||||
|
message="Delete OIDC configuration? All OIDC users will lose access."
|
||||||
|
confirmText="DELETE"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
.statStrip {
|
.statStrip {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scopeTrail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.groupGrid {
|
.groupGrid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
@@ -36,11 +43,22 @@
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.instanceTps {
|
.instanceMeta {
|
||||||
margin-left: auto;
|
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: var(--font-mono);
|
|
||||||
color: var(--text-muted);
|
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 {
|
.eventCard {
|
||||||
|
|||||||
@@ -1,13 +1,34 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import {
|
import {
|
||||||
StatCard, StatusDot, Badge, MonoText,
|
StatCard, StatusDot, Badge, MonoText,
|
||||||
GroupCard, EventFeed,
|
GroupCard, EventFeed, Breadcrumb, Alert,
|
||||||
} from '@cameleer/design-system';
|
} from '@cameleer/design-system';
|
||||||
import styles from './AgentHealth.module.css';
|
import styles from './AgentHealth.module.css';
|
||||||
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
import { useAgents, useAgentEvents } from '../../api/queries/agents';
|
||||||
import { useRouteCatalog } from '../../api/queries/catalog';
|
import { 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() {
|
export default function AgentHealth() {
|
||||||
const { appId } = useParams();
|
const { appId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -15,6 +36,8 @@ export default function AgentHealth() {
|
|||||||
const { data: catalog } = useRouteCatalog();
|
const { data: catalog } = useRouteCatalog();
|
||||||
const { data: events } = useAgentEvents(appId);
|
const { data: events } = useAgentEvents(appId);
|
||||||
|
|
||||||
|
const [selectedAgent, setSelectedAgent] = useState<any>(null);
|
||||||
|
|
||||||
const agentsByApp = useMemo(() => {
|
const agentsByApp = useMemo(() => {
|
||||||
const map: Record<string, any[]> = {};
|
const map: Record<string, any[]> = {};
|
||||||
(agents || []).forEach((a: any) => {
|
(agents || []).forEach((a: any) => {
|
||||||
@@ -25,10 +48,30 @@ export default function AgentHealth() {
|
|||||||
return map;
|
return map;
|
||||||
}, [agents]);
|
}, [agents]);
|
||||||
|
|
||||||
const totalAgents = agents?.length ?? 0;
|
|
||||||
const liveCount = (agents || []).filter((a: any) => a.status === 'LIVE').length;
|
const liveCount = (agents || []).filter((a: any) => a.status === 'LIVE').length;
|
||||||
const staleCount = (agents || []).filter((a: any) => a.status === 'STALE').length;
|
const staleCount = (agents || []).filter((a: any) => a.status === 'STALE').length;
|
||||||
const deadCount = (agents || []).filter((a: any) => a.status === 'DEAD').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(() =>
|
const feedEvents = useMemo(() =>
|
||||||
(events || []).map((e: any) => ({
|
(events || []).map((e: any) => ({
|
||||||
@@ -48,14 +91,28 @@ export default function AgentHealth() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.statStrip}>
|
<div className={styles.statStrip}>
|
||||||
<StatCard label="Total Agents" value={totalAgents} />
|
<StatCard label="Total Agents" value={(agents || []).length} detail={`${liveCount} live / ${staleCount} stale / ${deadCount} dead`} />
|
||||||
<StatCard label="Live" value={liveCount} accent="success" />
|
<StatCard label="Applications" value={uniqueApps} />
|
||||||
<StatCard label="Stale" value={staleCount} accent="warning" />
|
<StatCard label="Active Routes" value={activeRoutes} />
|
||||||
<StatCard label="Dead" value={deadCount} accent="error" />
|
<StatCard label="Total TPS" value={totalTps.toFixed(1)} />
|
||||||
|
<StatCard label="Dead" value={deadCount} accent={deadCount > 0 ? 'error' : undefined} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.scopeTrail}>
|
||||||
|
<Breadcrumb items={scopeItems} />
|
||||||
|
{!appId && <Badge label={`${liveCount} live`} variant="outlined" />}
|
||||||
|
{appId && (
|
||||||
|
<Badge
|
||||||
|
label={groupHealth}
|
||||||
|
color={groupHealth === 'live' ? 'success' : groupHealth === 'stale' ? 'warning' : 'error'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.groupGrid}>
|
<div className={styles.groupGrid}>
|
||||||
{Object.entries(apps).map(([group, groupAgents]) => (
|
{Object.entries(apps).map(([group, groupAgents]) => {
|
||||||
|
const deadInGroup = (groupAgents || []).filter((a: any) => a.status === 'DEAD');
|
||||||
|
return (
|
||||||
<GroupCard
|
<GroupCard
|
||||||
key={group}
|
key={group}
|
||||||
title={group}
|
title={group}
|
||||||
@@ -67,20 +124,34 @@ export default function AgentHealth() {
|
|||||||
}
|
}
|
||||||
onClick={() => navigate(`/agents/${group}`)}
|
onClick={() => navigate(`/agents/${group}`)}
|
||||||
>
|
>
|
||||||
|
{deadInGroup.length > 0 && (
|
||||||
|
<Alert variant="error">{deadInGroup.length} instance(s) unreachable</Alert>
|
||||||
|
)}
|
||||||
{(groupAgents || []).map((agent: any) => (
|
{(groupAgents || []).map((agent: any) => (
|
||||||
<div
|
<div
|
||||||
key={agent.id}
|
key={agent.id}
|
||||||
className={styles.instanceRow}
|
className={styles.instanceRow}
|
||||||
onClick={(e) => { e.stopPropagation(); navigate(`/agents/${group}/${agent.id}`); }}
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedAgent(agent);
|
||||||
|
navigate(`/agents/${group}/${agent.id}`);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
|
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
|
||||||
<span className={styles.instanceName}>{agent.name}</span>
|
<span className={styles.instanceName}>{agent.name}</span>
|
||||||
<Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} />
|
<Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} />
|
||||||
{agent.tps > 0 && <span className={styles.instanceTps}>{agent.tps.toFixed(1)} tps</span>}
|
<span className={styles.instanceMeta}>{formatUptime(agent.uptimeSeconds)}</span>
|
||||||
|
{agent.tps != null && <span className={styles.instanceMeta}>{(agent.tps || 0).toFixed(1)} tps</span>}
|
||||||
|
{agent.errorRate != null && (
|
||||||
|
<span className={styles.instanceMeta}>{(agent.errorRate * 100).toFixed(1)}% err</span>
|
||||||
|
)}
|
||||||
|
<span className={styles.instanceMeta}>{formatRelativeTime(agent.lastHeartbeat)}</span>
|
||||||
|
<span className={styles.instanceLink} aria-label="View instance">›</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</GroupCard>
|
</GroupCard>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{feedEvents.length > 0 && (
|
{feedEvents.length > 0 && (
|
||||||
|
|||||||
Reference in New Issue
Block a user