mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 23:18:55 +00:00
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:
@@ -0,0 +1,64 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAgents } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { timeAgo } from '../api/utils';
|
||||
import type { Agent } from '../api/types';
|
||||
|
||||
function heartbeatStatus(lastHeartbeat: string): string {
|
||||
if (!lastHeartbeat) return 'Offline';
|
||||
const ago = Date.now() - new Date(lastHeartbeat).getTime();
|
||||
if (ago < 5 * 60 * 1000) return 'Online';
|
||||
if (ago < 15 * 60 * 1000) return 'Stale';
|
||||
return 'Offline';
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['agents'],
|
||||
queryFn: () => getAgents(),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const columns: Column<Agent>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Agent',
|
||||
render: (a) => (
|
||||
<div>
|
||||
<div className="font-medium text-slate-200">{a.name}</div>
|
||||
<div className="text-xs text-slate-500">{a.id}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Health',
|
||||
render: (a) => <StatusBadge status={a.status || heartbeatStatus(a.last_heartbeat)} />,
|
||||
},
|
||||
{ key: 'hostname', label: 'Hostname', render: (a) => <span className="text-slate-300 font-mono text-xs">{a.hostname || '—'}</span> },
|
||||
{ key: 'ip', label: 'IP Address', render: (a) => <span className="text-slate-400 font-mono text-xs">{a.ip_address || '—'}</span> },
|
||||
{ key: 'version', label: 'Version', render: (a) => <span className="text-slate-400 text-xs">{a.version || '—'}</span> },
|
||||
{
|
||||
key: 'heartbeat',
|
||||
label: 'Last Heartbeat',
|
||||
render: (a) => <span className="text-slate-400 text-xs">{timeAgo(a.last_heartbeat)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Agents" subtitle={data ? `${data.total} agents` : undefined} />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
) : (
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No agents registered" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAuditEvents } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { AuditEvent } from '../api/types';
|
||||
|
||||
const actionColors: Record<string, string> = {
|
||||
certificate_created: 'text-emerald-400',
|
||||
renewal_triggered: 'text-blue-400',
|
||||
renewal_job_created: 'text-blue-400',
|
||||
renewal_completed: 'text-emerald-400',
|
||||
deployment_completed: 'text-emerald-400',
|
||||
deployment_failed: 'text-red-400',
|
||||
expiration_alert_sent: 'text-amber-400',
|
||||
agent_registered: 'text-blue-400',
|
||||
policy_violated: 'text-red-400',
|
||||
};
|
||||
|
||||
export default function AuditPage() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['audit'],
|
||||
queryFn: () => getAuditEvents(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const columns: Column<AuditEvent>[] = [
|
||||
{
|
||||
key: 'action',
|
||||
label: 'Action',
|
||||
render: (e) => (
|
||||
<span className={`text-sm font-medium ${actionColors[e.action] || 'text-slate-300'}`}>
|
||||
{e.action.replace(/_/g, ' ')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actor',
|
||||
label: 'Actor',
|
||||
render: (e) => (
|
||||
<div>
|
||||
<div className="text-sm text-slate-200">{e.actor}</div>
|
||||
<div className="text-xs text-slate-500">{e.actor_type}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'resource',
|
||||
label: 'Resource',
|
||||
render: (e) => (
|
||||
<div>
|
||||
<div className="text-sm text-slate-300">{e.resource_type}</div>
|
||||
<div className="text-xs text-slate-500 font-mono">{e.resource_id}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'details',
|
||||
label: 'Details',
|
||||
render: (e) => {
|
||||
if (!e.details || Object.keys(e.details).length === 0) return <span className="text-slate-500">—</span>;
|
||||
return (
|
||||
<span className="text-xs text-slate-400 font-mono truncate max-w-xs block">
|
||||
{JSON.stringify(e.details).slice(0, 60)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'time', label: 'Time', render: (e) => <span className="text-xs text-slate-400">{formatDateTime(e.timestamp)}</span> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Audit Trail" subtitle={data ? `${data.total} events` : undefined} />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
) : (
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No audit events" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getCertificate, getCertificateVersions, triggerRenewal } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDate, formatDateTime, daysUntil, expiryColor } from '../api/utils';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex justify-between py-2 border-b border-slate-700/50">
|
||||
<span className="text-sm text-slate-400">{label}</span>
|
||||
<span className="text-sm text-slate-200">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CertificateDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: cert, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['certificate', id],
|
||||
queryFn: () => getCertificate(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const { data: versions } = useQuery({
|
||||
queryKey: ['certificate-versions', id],
|
||||
queryFn: () => getCertificateVersions(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const renewMutation = useMutation({
|
||||
mutationFn: () => triggerRenewal(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificate', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Certificate" />
|
||||
<div className="flex items-center justify-center flex-1 text-slate-400">Loading...</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !cert) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Certificate" />
|
||||
<ErrorState error={error as Error || new Error('Not found')} onRetry={() => refetch()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const days = daysUntil(cert.expires_at);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={cert.common_name}
|
||||
subtitle={cert.id}
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => navigate('/certificates')} className="btn btn-ghost text-xs">
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => renewMutation.mutate()}
|
||||
disabled={renewMutation.isPending || cert.status === 'Archived' || cert.status === 'RenewalInProgress'}
|
||||
className="btn btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{renewMutation.isPending ? 'Renewing...' : 'Trigger Renewal'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{renewMutation.isSuccess && (
|
||||
<div className="bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-lg px-4 py-3 text-sm">
|
||||
Renewal triggered successfully. A renewal job has been created.
|
||||
</div>
|
||||
)}
|
||||
{renewMutation.isError && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-4 py-3 text-sm">
|
||||
Failed to trigger renewal: {(renewMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Certificate Info */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Certificate Details</h3>
|
||||
<InfoRow label="Status" value={<StatusBadge status={cert.status} />} />
|
||||
<InfoRow label="Common Name" value={cert.common_name} />
|
||||
<InfoRow label="SANs" value={cert.sans?.length ? cert.sans.join(', ') : '—'} />
|
||||
<InfoRow label="Serial Number" value={cert.serial_number || '—'} />
|
||||
<InfoRow label="Fingerprint" value={
|
||||
cert.fingerprint ? <span className="font-mono text-xs">{cert.fingerprint.slice(0, 24)}...</span> : '—'
|
||||
} />
|
||||
<InfoRow label="Key Algorithm" value={cert.key_algorithm || '—'} />
|
||||
<InfoRow label="Key Size" value={cert.key_size ? `${cert.key_size} bits` : '—'} />
|
||||
</div>
|
||||
|
||||
{/* Lifecycle */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Lifecycle</h3>
|
||||
<InfoRow label="Issued" value={formatDate(cert.issued_at)} />
|
||||
<InfoRow label="Expires" value={
|
||||
<span className={expiryColor(days)}>
|
||||
{formatDate(cert.expires_at)} ({days <= 0 ? 'expired' : `${days} days`})
|
||||
</span>
|
||||
} />
|
||||
<InfoRow label="Environment" value={cert.environment || '—'} />
|
||||
<InfoRow label="Issuer" value={cert.issuer_id} />
|
||||
<InfoRow label="Renewal Policy" value={cert.renewal_policy_id || '—'} />
|
||||
<InfoRow label="Owner" value={cert.owner_id} />
|
||||
<InfoRow label="Team" value={cert.team_id} />
|
||||
<InfoRow label="Created" value={formatDateTime(cert.created_at)} />
|
||||
<InfoRow label="Updated" value={formatDateTime(cert.updated_at)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{cert.tags && Object.keys(cert.tags).length > 0 && (
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(cert.tags).map(([k, v]) => (
|
||||
<span key={k} className="badge badge-neutral">{k}: {v}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version History */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">
|
||||
Version History {versions?.data?.length ? `(${versions.data.length})` : ''}
|
||||
</h3>
|
||||
{!versions?.data?.length ? (
|
||||
<p className="text-sm text-slate-500">No versions yet</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{versions.data.map((v) => (
|
||||
<div key={v.id} className="flex items-center justify-between py-2 border-b border-slate-700/50 last:border-0">
|
||||
<div>
|
||||
<div className="text-sm text-slate-200">Version {v.version}</div>
|
||||
<div className="text-xs text-slate-500 font-mono">{v.serial_number}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-slate-300">{formatDate(v.not_before)} — {formatDate(v.not_after)}</div>
|
||||
<div className="text-xs text-slate-500">{formatDateTime(v.created_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getCertificates } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDate, daysUntil, expiryColor } from '../api/utils';
|
||||
import type { Certificate } from '../api/types';
|
||||
|
||||
export default function CertificatesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [envFilter, setEnvFilter] = useState('');
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (envFilter) params.environment = envFilter;
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['certificates', params],
|
||||
queryFn: () => getCertificates(params),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const columns: Column<Certificate>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Certificate',
|
||||
render: (c) => (
|
||||
<div>
|
||||
<div className="font-medium text-slate-200">{c.common_name}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{c.id}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'status', label: 'Status', render: (c) => <StatusBadge status={c.status} /> },
|
||||
{
|
||||
key: 'expires',
|
||||
label: 'Expires',
|
||||
render: (c) => {
|
||||
const days = daysUntil(c.expires_at);
|
||||
return (
|
||||
<div>
|
||||
<div className={expiryColor(days)}>{formatDate(c.expires_at)}</div>
|
||||
<div className="text-xs text-slate-500">{days <= 0 ? 'Expired' : `${days} days`}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: 'env', label: 'Environment', render: (c) => <span className="text-slate-300">{c.environment || '—'}</span> },
|
||||
{ key: 'issuer', label: 'Issuer', render: (c) => <span className="text-slate-400 text-xs">{c.issuer_id}</span> },
|
||||
{ key: 'owner', label: 'Owner', render: (c) => <span className="text-slate-400 text-xs">{c.owner_id}</span> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Certificates"
|
||||
subtitle={data ? `${data.total} certificates` : undefined}
|
||||
/>
|
||||
<div className="px-6 py-3 flex gap-3 border-b border-slate-700/50">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-300"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="Active">Active</option>
|
||||
<option value="Expiring">Expiring</option>
|
||||
<option value="Expired">Expired</option>
|
||||
<option value="RenewalInProgress">Renewal In Progress</option>
|
||||
<option value="Archived">Archived</option>
|
||||
</select>
|
||||
<select
|
||||
value={envFilter}
|
||||
onChange={e => setEnvFilter(e.target.value)}
|
||||
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-300"
|
||||
>
|
||||
<option value="">All environments</option>
|
||||
<option value="production">Production</option>
|
||||
<option value="staging">Staging</option>
|
||||
<option value="development">Development</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data?.data || []}
|
||||
isLoading={isLoading}
|
||||
onRowClick={(c) => navigate(`/certificates/${c.id}`)}
|
||||
emptyMessage="No certificates found"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getCertificates, getAgents, getJobs, getNotifications, getHealth } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { daysUntil, expiryColor, formatDate } from '../api/utils';
|
||||
|
||||
function StatCard({ label, value, icon, color }: { label: string; value: string | number; icon: string; color: string }) {
|
||||
const colorMap: Record<string, string> = {
|
||||
success: 'bg-emerald-500/10 text-emerald-400',
|
||||
warning: 'bg-amber-500/10 text-amber-400',
|
||||
danger: 'bg-red-500/10 text-red-400',
|
||||
info: 'bg-blue-500/10 text-blue-400',
|
||||
};
|
||||
return (
|
||||
<div className="card p-5 flex items-start gap-4 hover:border-blue-500/30 transition-colors">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${colorMap[color] || colorMap.info}`}>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={icon} />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">{label}</p>
|
||||
<p className="text-2xl font-bold mt-1">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, refetchInterval: 30000 });
|
||||
const { data: certs } = useQuery({ queryKey: ['certificates', {}], queryFn: () => getCertificates(), refetchInterval: 30000 });
|
||||
const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => getAgents(), refetchInterval: 15000 });
|
||||
const { data: jobs } = useQuery({ queryKey: ['jobs', {}], queryFn: () => getJobs(), refetchInterval: 10000 });
|
||||
const { data: notifs } = useQuery({ queryKey: ['notifications'], queryFn: () => getNotifications() });
|
||||
|
||||
const totalCerts = certs?.total || 0;
|
||||
const expiringSoon = certs?.data?.filter(c => {
|
||||
const d = daysUntil(c.expires_at);
|
||||
return d > 0 && d <= 30;
|
||||
}).length || 0;
|
||||
const expired = certs?.data?.filter(c => c.status === 'Expired' || daysUntil(c.expires_at) <= 0).length || 0;
|
||||
const activeAgents = agents?.data?.filter(a => a.status === 'Online').length || agents?.total || 0;
|
||||
const pendingJobs = jobs?.data?.filter(j => j.status === 'Pending' || j.status === 'Running').length || 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle={health?.status === 'healthy' ? 'System healthy' : 'Checking system status...'}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard label="Total Certificates" value={totalCerts} color="info"
|
||||
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" />
|
||||
<StatCard label="Expiring Soon" value={expiringSoon} color={expiringSoon > 0 ? 'warning' : 'success'}
|
||||
icon="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<StatCard label="Expired" value={expired} color={expired > 0 ? 'danger' : 'success'}
|
||||
icon="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
<StatCard label="Active Agents" value={activeAgents} color="success"
|
||||
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" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Expiring Certificates */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-slate-300">Certificates Expiring Soon</h3>
|
||||
<button onClick={() => navigate('/certificates')} className="text-xs text-blue-400 hover:text-blue-300">View all</button>
|
||||
</div>
|
||||
{!certs?.data?.length ? (
|
||||
<p className="text-sm text-slate-500">No certificates</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{certs.data
|
||||
.filter(c => c.status !== 'Archived')
|
||||
.sort((a, b) => new Date(a.expires_at).getTime() - new Date(b.expires_at).getTime())
|
||||
.slice(0, 5)
|
||||
.map(c => {
|
||||
const days = daysUntil(c.expires_at);
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
onClick={() => navigate(`/certificates/${c.id}`)}
|
||||
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-700/50 cursor-pointer transition-colors"
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm text-slate-200">{c.common_name}</div>
|
||||
<div className="text-xs text-slate-500">{c.environment || 'no env'}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-sm ${expiryColor(days)}`}>
|
||||
{days <= 0 ? 'Expired' : `${days} days`}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">{formatDate(c.expires_at)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Jobs */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-slate-300">Recent Jobs</h3>
|
||||
<button onClick={() => navigate('/jobs')} className="text-xs text-blue-400 hover:text-blue-300">View all</button>
|
||||
</div>
|
||||
{!jobs?.data?.length ? (
|
||||
<p className="text-sm text-slate-500">No jobs</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{jobs.data.slice(0, 5).map(j => (
|
||||
<div key={j.id} className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-700/50 transition-colors">
|
||||
<div>
|
||||
<div className="text-sm text-slate-200">{j.type}</div>
|
||||
<div className="text-xs text-slate-500 font-mono">{j.certificate_id}</div>
|
||||
</div>
|
||||
<StatusBadge status={j.status} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending Jobs Banner */}
|
||||
{pendingJobs > 0 && (
|
||||
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg px-5 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-400">{pendingJobs} pending job{pendingJobs > 1 ? 's' : ''}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Jobs are waiting to be processed</p>
|
||||
</div>
|
||||
<button onClick={() => navigate('/jobs')} className="btn btn-primary text-xs">View Jobs</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getJobs, cancelJob } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Job } from '../api/types';
|
||||
|
||||
export default function JobsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
if (typeFilter) params.type = typeFilter;
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['jobs', params],
|
||||
queryFn: () => getJobs(params),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: cancelJob,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }),
|
||||
});
|
||||
|
||||
const columns: Column<Job>[] = [
|
||||
{
|
||||
key: 'id',
|
||||
label: 'Job',
|
||||
render: (j) => (
|
||||
<div>
|
||||
<div className="font-mono text-xs text-slate-200">{j.id}</div>
|
||||
<div className="text-xs text-slate-500">{j.type}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
|
||||
{ key: 'cert', label: 'Certificate', render: (j) => <span className="text-xs text-slate-400 font-mono">{j.certificate_id}</span> },
|
||||
{
|
||||
key: 'attempts',
|
||||
label: 'Attempts',
|
||||
render: (j) => <span className="text-slate-300">{j.attempts}/{j.max_attempts}</span>,
|
||||
},
|
||||
{ key: 'scheduled', label: 'Scheduled', render: (j) => <span className="text-xs text-slate-400">{formatDateTime(j.scheduled_at)}</span> },
|
||||
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-slate-400">{formatDateTime(j.completed_at)}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
render: (j) => (
|
||||
j.status === 'Pending' || j.status === 'Running' ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); cancelMutation.mutate(j.id); }}
|
||||
className="text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : null
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Jobs" subtitle={data ? `${data.total} jobs` : undefined} />
|
||||
<div className="px-6 py-3 flex gap-3 border-b border-slate-700/50">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-300"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="Pending">Pending</option>
|
||||
<option value="Running">Running</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="Failed">Failed</option>
|
||||
<option value="Cancelled">Cancelled</option>
|
||||
</select>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value)}
|
||||
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-300"
|
||||
>
|
||||
<option value="">All types</option>
|
||||
<option value="Renewal">Renewal</option>
|
||||
<option value="Issuance">Issuance</option>
|
||||
<option value="Deployment">Deployment</option>
|
||||
<option value="Validation">Validation</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
) : (
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No jobs found" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getNotifications } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Notification } from '../api/types';
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['notifications'],
|
||||
queryFn: () => getNotifications(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const columns: Column<Notification>[] = [
|
||||
{
|
||||
key: 'type',
|
||||
label: 'Type',
|
||||
render: (n) => <span className="text-sm text-slate-200">{n.type.replace(/([A-Z])/g, ' $1').trim()}</span>,
|
||||
},
|
||||
{ key: 'status', label: 'Status', render: (n) => <StatusBadge status={n.status} /> },
|
||||
{ key: 'channel', label: 'Channel', render: (n) => <span className="text-xs text-slate-400">{n.channel}</span> },
|
||||
{ key: 'recipient', label: 'Recipient', render: (n) => <span className="text-xs text-slate-300">{n.recipient}</span> },
|
||||
{
|
||||
key: 'message',
|
||||
label: 'Message',
|
||||
render: (n) => <span className="text-xs text-slate-400 truncate max-w-xs block">{n.message || n.subject}</span>,
|
||||
},
|
||||
{ key: 'cert', label: 'Certificate', render: (n) => <span className="text-xs text-slate-500 font-mono">{n.certificate_id || '—'}</span> },
|
||||
{ key: 'created', label: 'Sent', render: (n) => <span className="text-xs text-slate-400">{formatDateTime(n.created_at)}</span> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Notifications" subtitle={data ? `${data.total} notifications` : undefined} />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
) : (
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No notifications" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPolicies } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { PolicyRule } from '../api/types';
|
||||
|
||||
const severityStyles: Record<string, string> = {
|
||||
low: 'badge-info',
|
||||
medium: 'badge-warning',
|
||||
high: 'badge-danger',
|
||||
critical: 'badge-danger',
|
||||
};
|
||||
|
||||
export default function PoliciesPage() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['policies'],
|
||||
queryFn: () => getPolicies(),
|
||||
});
|
||||
|
||||
const columns: Column<PolicyRule>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Rule',
|
||||
render: (p) => (
|
||||
<div>
|
||||
<div className="font-medium text-slate-200">{p.name}</div>
|
||||
<div className="text-xs text-slate-500">{p.id}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'type', label: 'Type', render: (p) => <span className="text-sm text-slate-300">{p.type.replace(/_/g, ' ')}</span> },
|
||||
{
|
||||
key: 'severity',
|
||||
label: 'Severity',
|
||||
render: (p) => <span className={`badge ${severityStyles[p.severity] || 'badge-neutral'}`}>{p.severity}</span>,
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: 'Enabled',
|
||||
render: (p) => (
|
||||
<span className={p.enabled ? 'text-emerald-400' : 'text-slate-500'}>
|
||||
{p.enabled ? 'Yes' : 'No'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'created', label: 'Created', render: (p) => <span className="text-xs text-slate-400">{formatDateTime(p.created_at)}</span> },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Policies" subtitle={data ? `${data.total} rules` : undefined} />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
) : (
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No policy rules" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user