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:
SentryAgent.ai Developer
2026-03-28 23:19:18 +00:00
parent 7328a61c44
commit 7d6e248a14
32 changed files with 4858 additions and 13 deletions

View File

@@ -0,0 +1,204 @@
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>
);
}