- 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>
205 lines
7.4 KiB
TypeScript
205 lines
7.4 KiB
TypeScript
import * as React from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import type { Agent, AgentStatus } from '@sentryagent/idp-sdk';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { getClient } from '@/lib/client';
|
|
|
|
const PAGE_LIMIT = 20;
|
|
|
|
/** Maps AgentStatus to a Badge variant. */
|
|
function statusVariant(status: AgentStatus): 'success' | 'warning' | 'danger' | 'muted' {
|
|
switch (status) {
|
|
case 'active': return 'success';
|
|
case 'suspended': return 'warning';
|
|
case 'decommissioned': return 'danger';
|
|
}
|
|
}
|
|
|
|
/** Formats an ISO timestamp to a short local date string. */
|
|
function formatDate(iso: string): string {
|
|
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
}
|
|
|
|
/** Skeleton row shown while loading. */
|
|
function SkeletonRow(): React.JSX.Element {
|
|
return (
|
|
<tr>
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<td key={i} className="px-4 py-3">
|
|
<div className="h-4 w-full animate-pulse rounded bg-slate-200" />
|
|
</td>
|
|
))}
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Agents list page — displays all registered agents with search, status filter, and pagination.
|
|
* Clicking a row navigates to the Agent Detail page.
|
|
*/
|
|
export default function Agents(): React.JSX.Element {
|
|
const navigate = useNavigate();
|
|
|
|
const [agents, setAgents] = React.useState<Agent[]>([]);
|
|
const [total, setTotal] = React.useState<number>(0);
|
|
const [page, setPage] = React.useState<number>(1);
|
|
const [loading, setLoading] = React.useState<boolean>(false);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
|
|
// Filters (client-side email search, server-side status)
|
|
const [searchInput, setSearchInput] = React.useState<string>('');
|
|
const [debouncedSearch, setDebouncedSearch] = React.useState<string>('');
|
|
const [statusFilter, setStatusFilter] = React.useState<AgentStatus | ''>('');
|
|
|
|
// Debounce search input 300ms
|
|
React.useEffect(() => {
|
|
const timer = setTimeout(() => { setDebouncedSearch(searchInput); }, 300);
|
|
return () => { clearTimeout(timer); };
|
|
}, [searchInput]);
|
|
|
|
// Reset to page 1 on filter change
|
|
React.useEffect(() => {
|
|
setPage(1);
|
|
}, [debouncedSearch, statusFilter]);
|
|
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const fetchAgents = async (): Promise<void> => {
|
|
try {
|
|
const client = getClient();
|
|
const result = await client.agents.listAgents({
|
|
page,
|
|
limit: PAGE_LIMIT,
|
|
status: statusFilter !== '' ? statusFilter : undefined,
|
|
});
|
|
if (!cancelled) {
|
|
setAgents(result.data);
|
|
setTotal(result.total);
|
|
}
|
|
} catch (err) {
|
|
if (!cancelled) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load agents.');
|
|
}
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
};
|
|
|
|
void fetchAgents();
|
|
return () => { cancelled = true; };
|
|
}, [page, statusFilter]);
|
|
|
|
// Client-side email filter applied after API results arrive
|
|
const filteredAgents = React.useMemo(() => {
|
|
if (!debouncedSearch.trim()) return agents;
|
|
const lower = debouncedSearch.toLowerCase();
|
|
return agents.filter((a) => a.email.toLowerCase().includes(lower));
|
|
}, [agents, debouncedSearch]);
|
|
|
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_LIMIT));
|
|
|
|
return (
|
|
<div>
|
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<h1 className="text-2xl font-bold text-slate-900">Agents</h1>
|
|
<div className="flex gap-3">
|
|
<input
|
|
type="search"
|
|
value={searchInput}
|
|
onChange={(e) => { setSearchInput(e.target.value); }}
|
|
placeholder="Search by email…"
|
|
className="w-60 rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
|
/>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => { setStatusFilter(e.target.value as AgentStatus | ''); }}
|
|
className="rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-brand-500 focus:outline-none focus:ring-1 focus:ring-brand-500"
|
|
>
|
|
<option value="">All Statuses</option>
|
|
<option value="active">Active</option>
|
|
<option value="suspended">Suspended</option>
|
|
<option value="decommissioned">Decommissioned</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-4 rounded-md bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
|
<table className="min-w-full divide-y divide-slate-200 text-sm">
|
|
<thead className="bg-slate-50">
|
|
<tr>
|
|
{['Name (Email)', 'Type', 'Status', 'Environment', 'Owner', 'Created'].map((col) => (
|
|
<th key={col} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500">
|
|
{col}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{loading
|
|
? Array.from({ length: 5 }).map((_, i) => <SkeletonRow key={i} />)
|
|
: filteredAgents.length === 0
|
|
? (
|
|
<tr>
|
|
<td colSpan={6} className="px-4 py-12 text-center text-slate-400">
|
|
No agents found.
|
|
</td>
|
|
</tr>
|
|
)
|
|
: filteredAgents.map((agent) => (
|
|
<tr
|
|
key={agent.agentId}
|
|
onClick={() => { navigate(`/dashboard/agents/${agent.agentId}`); }}
|
|
className="cursor-pointer hover:bg-slate-50"
|
|
>
|
|
<td className="px-4 py-3 font-medium text-brand-700">{agent.email}</td>
|
|
<td className="px-4 py-3 text-slate-600">{agent.agentType}</td>
|
|
<td className="px-4 py-3">
|
|
<Badge variant={statusVariant(agent.status)}>{agent.status}</Badge>
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-600">{agent.deploymentEnv}</td>
|
|
<td className="px-4 py-3 text-slate-600">{agent.owner}</td>
|
|
<td className="px-4 py-3 text-slate-500">{formatDate(agent.createdAt)}</td>
|
|
</tr>
|
|
))
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Pagination */}
|
|
{!loading && total > 0 && (
|
|
<div className="mt-4 flex items-center justify-between text-sm text-slate-600">
|
|
<span>
|
|
Page {page} of {totalPages} ({total} total)
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => { setPage((p) => Math.max(1, p - 1)); }}
|
|
disabled={page <= 1}
|
|
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
|
|
>
|
|
Previous
|
|
</button>
|
|
<button
|
|
onClick={() => { setPage((p) => Math.min(totalPages, p + 1)); }}
|
|
disabled={page >= totalPages}
|
|
className="rounded-md border border-slate-300 px-3 py-1.5 hover:bg-slate-50 disabled:opacity-40"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|