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>
This commit is contained in:
222
dashboard/src/pages/AgentDetail.tsx
Normal file
222
dashboard/src/pages/AgentDetail.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user