- dashboard/: Vite 5 + React 18 + TypeScript strict SPA
- Auth: sessionStorage credentials, TokenManager validation, AuthProvider context
- Pages: Login, Agents (search + filter), AgentDetail (suspend/reactivate),
Credentials (generate/rotate/revoke, new secret shown once),
AuditLog (filters + pagination), Health (PG + Redis status, 30s refresh)
- Components: Button, Badge, ConfirmDialog, AppShell, RequireAuth
- All destructive actions gated by ConfirmDialog
- Zero dangerouslySetInnerHTML; sessionStorage only (OWASP compliant)
- src/routes/health.ts: unauthenticated GET /health — PG + Redis connectivity
- src/app.ts: health route + dashboard/dist/ served at /dashboard with SPA fallback
- 6 new health route tests; 308/308 unit tests passing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
223 lines
7.2 KiB
TypeScript
223 lines
7.2 KiB
TypeScript
import * as React from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import type { Agent } from '@sentryagent/idp-sdk';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ConfirmDialog } from '@/components/ui/dialog';
|
|
import { getClient } from '@/lib/client';
|
|
|
|
type BadgeVariant = 'success' | 'warning' | 'danger';
|
|
|
|
/** Maps AgentStatus to a Badge variant. */
|
|
function statusVariant(status: Agent['status']): BadgeVariant {
|
|
switch (status) {
|
|
case 'active': return 'success';
|
|
case 'suspended': return 'warning';
|
|
case 'decommissioned': return 'danger';
|
|
}
|
|
}
|
|
|
|
/** Formats an ISO timestamp to a readable local date-time string. */
|
|
function formatDateTime(iso: string): string {
|
|
return new Date(iso).toLocaleString(undefined, {
|
|
year: 'numeric', month: 'short', day: 'numeric',
|
|
hour: '2-digit', minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
interface DetailRowProps {
|
|
label: string;
|
|
value: string;
|
|
}
|
|
|
|
/** Single label/value row in the detail card. */
|
|
function DetailRow({ label, value }: DetailRowProps): React.JSX.Element {
|
|
return (
|
|
<div className="flex flex-col gap-1 sm:flex-row sm:gap-4">
|
|
<dt className="w-36 shrink-0 text-sm font-medium text-slate-500">{label}</dt>
|
|
<dd className="text-sm text-slate-900 break-all">{value}</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type DialogAction = 'suspend' | 'reactivate';
|
|
|
|
/**
|
|
* Agent Detail page — shows all agent fields and provides suspend/reactivate actions.
|
|
* Route: /dashboard/agents/:agentId
|
|
*/
|
|
export default function AgentDetail(): React.JSX.Element {
|
|
const { agentId } = useParams<{ agentId: string }>();
|
|
const navigate = useNavigate();
|
|
|
|
const [agent, setAgent] = React.useState<Agent | null>(null);
|
|
const [loading, setLoading] = React.useState<boolean>(true);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [actionLoading, setActionLoading] = React.useState<boolean>(false);
|
|
const [dialog, setDialog] = React.useState<DialogAction | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
if (!agentId) return;
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const fetchAgent = async (): Promise<void> => {
|
|
try {
|
|
const result = await getClient().agents.getAgent(agentId);
|
|
if (!cancelled) setAgent(result);
|
|
} catch (err) {
|
|
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load agent.');
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
};
|
|
|
|
void fetchAgent();
|
|
return () => { cancelled = true; };
|
|
}, [agentId]);
|
|
|
|
const handleAction = React.useCallback(
|
|
async (action: DialogAction): Promise<void> => {
|
|
if (!agentId) return;
|
|
setActionLoading(true);
|
|
setDialog(null);
|
|
try {
|
|
const newStatus = action === 'suspend' ? 'suspended' : 'active';
|
|
const updated = await getClient().agents.updateAgent(agentId, { status: newStatus });
|
|
setAgent(updated);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Action failed.');
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
},
|
|
[agentId],
|
|
);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-4">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<div key={i} className="h-5 w-full animate-pulse rounded bg-slate-200" />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !agent) {
|
|
return (
|
|
<div className="rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
|
{error ?? 'Agent not found.'}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const dialogConfig = dialog === 'suspend'
|
|
? {
|
|
title: `Suspend agent ${agent.email}?`,
|
|
description: `Suspending ${agent.email} means it will no longer be able to authenticate.`,
|
|
confirmLabel: 'Suspend',
|
|
variant: 'destructive' as const,
|
|
}
|
|
: {
|
|
title: `Reactivate agent ${agent.email}?`,
|
|
description: `Reactivating ${agent.email} will allow it to authenticate again.`,
|
|
confirmLabel: 'Reactivate',
|
|
variant: 'default' as const,
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{/* Back navigation */}
|
|
<button
|
|
onClick={() => { navigate('/dashboard/agents'); }}
|
|
className="mb-6 flex items-center gap-1 text-sm text-brand-600 hover:text-brand-800"
|
|
>
|
|
← Back to Agents
|
|
</button>
|
|
|
|
<div className="mb-6 flex items-start justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-slate-900">{agent.email}</h1>
|
|
<p className="mt-1 text-sm text-slate-500">Agent ID: {agent.agentId}</p>
|
|
</div>
|
|
<Badge variant={statusVariant(agent.status)} className="mt-1">{agent.status}</Badge>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Detail card */}
|
|
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
|
<dl className="space-y-4">
|
|
<DetailRow label="Email" value={agent.email} />
|
|
<DetailRow label="Agent ID" value={agent.agentId} />
|
|
<DetailRow label="Type" value={agent.agentType} />
|
|
<DetailRow label="Version" value={agent.version} />
|
|
<DetailRow label="Owner" value={agent.owner} />
|
|
<DetailRow label="Environment" value={agent.deploymentEnv} />
|
|
<DetailRow label="Capabilities" value={agent.capabilities.join(', ') || '—'} />
|
|
<DetailRow label="Status" value={agent.status} />
|
|
<DetailRow label="Created" value={formatDateTime(agent.createdAt)} />
|
|
<DetailRow label="Updated" value={formatDateTime(agent.updatedAt)} />
|
|
</dl>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
{agent.status !== 'decommissioned' && (
|
|
<div className="mt-6 flex gap-3">
|
|
{agent.status === 'active' && (
|
|
<Button
|
|
variant="destructive"
|
|
loading={actionLoading}
|
|
onClick={() => { setDialog('suspend'); }}
|
|
>
|
|
Suspend Agent
|
|
</Button>
|
|
)}
|
|
{agent.status === 'suspended' && (
|
|
<Button
|
|
variant="default"
|
|
loading={actionLoading}
|
|
onClick={() => { setDialog('reactivate'); }}
|
|
>
|
|
Reactivate Agent
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Credentials section */}
|
|
<div className="mt-8 rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
|
<h2 className="mb-4 text-lg font-semibold text-slate-900">Credentials</h2>
|
|
<p className="mb-4 text-sm text-slate-600">
|
|
Manage client secrets for this agent. Rotate or revoke credentials as needed.
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => { navigate(`/dashboard/agents/${agent.agentId}/credentials`); }}
|
|
>
|
|
View Credentials
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Confirm dialog */}
|
|
{dialog !== null && (
|
|
<ConfirmDialog
|
|
open
|
|
title={dialogConfig.title}
|
|
description={dialogConfig.description}
|
|
confirmLabel={dialogConfig.confirmLabel}
|
|
variant={dialogConfig.variant}
|
|
onConfirm={() => { void handleAction(dialog); }}
|
|
onCancel={() => { setDialog(null); }}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|