Files
sentryagent-idp/dashboard/src/pages/AgentDetail.tsx
SentryAgent.ai Developer 7d6e248a14 feat(phase-2): workstream 6 — Web Dashboard UI
- 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>
2026-03-28 23:19:18 +00:00

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>
);
}