Implement M5: hardening, input validation, and Vite+React+TS dashboard

Backend hardening:
- Fix 6 nginx.go non-constant format string build errors
- Add validation.go with hostname, PEM, and enum validators
- Apply input validation to all POST/PUT handlers (certificates,
  agents, CSR, policies, teams, owners, targets, issuers)
- Fix unchecked JSON decode in TriggerDeployment handler

Frontend (Vite + React + TypeScript):
- Migrate from single-file SPA to proper build pipeline
- 7 pages: Dashboard, Certificates (list+detail), Agents, Jobs,
  Notifications, Policies, Audit Trail
- TanStack Query for server state with auto-refetch intervals
- Certificate detail with version history and renewal trigger
- Job cancellation, status/type filtering, expiry countdowns
- Reusable components: DataTable, StatusBadge, ErrorState, PageHeader
- Dark theme with Tailwind CSS, sidebar nav via React Router

Server integration:
- Go server serves web/dist/ (Vite output) with SPA fallback
- Falls back to web/index.html for legacy mode
- .gitignore updated for web/node_modules/ and web/dist/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-15 01:19:19 -04:00
parent 7845d282e9
commit 9e6756d02f
39 changed files with 5725 additions and 1878 deletions
+69
View File
@@ -0,0 +1,69 @@
interface Column<T> {
key: string;
label: string;
render: (item: T) => React.ReactNode;
className?: string;
}
interface DataTableProps<T> {
columns: Column<T>[];
data: T[];
onRowClick?: (item: T) => void;
emptyMessage?: string;
isLoading?: boolean;
}
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading }: DataTableProps<T>) {
if (isLoading) {
return (
<div className="flex items-center justify-center py-16 text-slate-400">
<svg className="animate-spin h-5 w-5 mr-3" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Loading...
</div>
);
}
if (!data.length) {
return (
<div className="flex items-center justify-center py-16 text-slate-500">
{emptyMessage || 'No data found'}
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b-2 border-slate-700">
{columns.map(col => (
<th key={col.key} className={`px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider ${col.className || ''}`}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.map((item, i) => (
<tr
key={i}
onClick={() => onRowClick?.(item)}
className={`border-b border-slate-700/50 transition-colors hover:bg-blue-500/5 ${onRowClick ? 'cursor-pointer' : ''}`}
>
{columns.map(col => (
<td key={col.key} className={`px-4 py-3 ${col.className || ''}`}>
{col.render(item)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export type { Column };
+21
View File
@@ -0,0 +1,21 @@
interface ErrorStateProps {
error: Error;
onRetry?: () => void;
}
export default function ErrorState({ error, onRetry }: ErrorStateProps) {
return (
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
<svg className="w-12 h-12 text-red-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<p className="text-sm mb-2">Failed to load data</p>
<p className="text-xs text-slate-500 mb-4">{error.message}</p>
{onRetry && (
<button onClick={onRetry} className="btn btn-primary text-xs">
Retry
</button>
)}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { NavLink, Outlet } from 'react-router-dom';
const nav = [
{ to: '/', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4' },
{ to: '/certificates', label: 'Certificates', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' },
{ to: '/agents', label: 'Agents', icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2' },
{ to: '/jobs', label: 'Jobs', icon: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' },
{ to: '/notifications', label: 'Notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
{ to: '/policies', label: 'Policies', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' },
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
];
function Icon({ d }: { d: string }) {
return (
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d={d} />
</svg>
);
}
export default function Layout() {
return (
<div className="flex h-screen overflow-hidden">
{/* Sidebar */}
<aside className="w-64 bg-slate-800 border-r border-slate-700 flex flex-col">
<div className="p-6 border-b border-slate-700">
<h1 className="text-xl font-bold text-blue-400">certctl</h1>
<p className="text-xs text-slate-400 uppercase tracking-wider mt-1">Certificate Control Plane</p>
</div>
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
{nav.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors ${
isActive
? 'bg-blue-600 text-white'
: 'text-slate-400 hover:bg-slate-700 hover:text-slate-200'
}`
}
>
<Icon d={item.icon} />
{item.label}
</NavLink>
))}
</nav>
<div className="p-4 border-t border-slate-700 text-xs text-slate-500">
certctl v1.0-dev
</div>
</aside>
{/* Main content */}
<main className="flex-1 flex flex-col overflow-hidden">
<Outlet />
</main>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
interface PageHeaderProps {
title: string;
subtitle?: string;
action?: React.ReactNode;
}
export default function PageHeader({ title, subtitle, action }: PageHeaderProps) {
return (
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-700 bg-slate-800">
<div>
<h2 className="text-lg font-semibold">{title}</h2>
{subtitle && <p className="text-sm text-slate-400 mt-0.5">{subtitle}</p>}
</div>
{action}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
const statusStyles: Record<string, string> = {
Active: 'badge-success',
Expiring: 'badge-warning',
Expired: 'badge-danger',
RenewalInProgress: 'badge-info',
PendingIssuance: 'badge-info',
Archived: 'badge-neutral',
Revoked: 'badge-danger',
// Job statuses
Pending: 'badge-info',
Running: 'badge-warning',
Completed: 'badge-success',
Failed: 'badge-danger',
Cancelled: 'badge-neutral',
// Agent statuses
Online: 'badge-success',
Offline: 'badge-danger',
Stale: 'badge-warning',
// Notification statuses
sent: 'badge-success',
pending: 'badge-warning',
failed: 'badge-danger',
read: 'badge-neutral',
};
export default function StatusBadge({ status }: { status: string }) {
const cls = statusStyles[status] || 'badge-neutral';
return <span className={`badge ${cls}`}>{status}</span>;
}