mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:11:31 +00:00
Implement M6: functional GUI views, GitHub Actions CI
Wire all remaining dashboard views to real API: agent detail page with heartbeat status and capabilities, audit trail with time range/ actor/resource filters, notifications with grouped-by-cert view and read/unread state, policies with severity summary bar, new issuers and targets list views. Add GitHub Actions CI with parallel Go and Frontend jobs. Update Makefile with test-cover and frontend-build targets. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@ const nav = [
|
||||
{ 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: '/issuers', label: 'Issuers', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
|
||||
{ to: '/targets', label: 'Targets', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
|
||||
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
];
|
||||
|
||||
|
||||
@@ -7,9 +7,12 @@ import DashboardPage from './pages/DashboardPage';
|
||||
import CertificatesPage from './pages/CertificatesPage';
|
||||
import CertificateDetailPage from './pages/CertificateDetailPage';
|
||||
import AgentsPage from './pages/AgentsPage';
|
||||
import AgentDetailPage from './pages/AgentDetailPage';
|
||||
import JobsPage from './pages/JobsPage';
|
||||
import NotificationsPage from './pages/NotificationsPage';
|
||||
import PoliciesPage from './pages/PoliciesPage';
|
||||
import IssuersPage from './pages/IssuersPage';
|
||||
import TargetsPage from './pages/TargetsPage';
|
||||
import AuditPage from './pages/AuditPage';
|
||||
import './index.css';
|
||||
|
||||
@@ -33,9 +36,12 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="certificates" element={<CertificatesPage />} />
|
||||
<Route path="certificates/:id" element={<CertificateDetailPage />} />
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route path="agents/:id" element={<AgentDetailPage />} />
|
||||
<Route path="jobs" element={<JobsPage />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route path="policies" element={<PoliciesPage />} />
|
||||
<Route path="issuers" element={<IssuersPage />} />
|
||||
<Route path="targets" element={<TargetsPage />} />
|
||||
<Route path="audit" element={<AuditPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAgent, getJobs } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime, timeAgo } 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>
|
||||
);
|
||||
}
|
||||
|
||||
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 AgentDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: agent, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['agent', id],
|
||||
queryFn: () => getAgent(id!),
|
||||
enabled: !!id,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const { data: jobs } = useQuery({
|
||||
queryKey: ['agent-jobs', id],
|
||||
queryFn: () => getJobs({ per_page: '10' }),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Filter jobs related to this agent (deployment jobs)
|
||||
const agentJobs = jobs?.data?.slice(0, 10) || [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Agent" />
|
||||
<div className="flex items-center justify-center flex-1 text-slate-400">Loading...</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !agent) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Agent" />
|
||||
<ErrorState error={error as Error || new Error('Not found')} onRetry={() => refetch()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const health = agent.status || heartbeatStatus(agent.last_heartbeat);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={agent.name}
|
||||
subtitle={agent.id}
|
||||
action={
|
||||
<button onClick={() => navigate('/agents')} className="btn btn-ghost text-xs">Back</button>
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Agent Info */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Agent Details</h3>
|
||||
<InfoRow label="Health" value={<StatusBadge status={health} />} />
|
||||
<InfoRow label="Hostname" value={<span className="font-mono text-xs">{agent.hostname || '—'}</span>} />
|
||||
<InfoRow label="IP Address" value={<span className="font-mono text-xs">{agent.ip_address || '—'}</span>} />
|
||||
<InfoRow label="Version" value={agent.version || '—'} />
|
||||
<InfoRow label="Last Heartbeat" value={
|
||||
agent.last_heartbeat ? (
|
||||
<span>
|
||||
{timeAgo(agent.last_heartbeat)}
|
||||
<span className="text-slate-500 ml-2 text-xs">{formatDateTime(agent.last_heartbeat)}</span>
|
||||
</span>
|
||||
) : '—'
|
||||
} />
|
||||
<InfoRow label="Registered" value={formatDateTime(agent.created_at)} />
|
||||
<InfoRow label="Updated" value={formatDateTime(agent.updated_at)} />
|
||||
</div>
|
||||
|
||||
{/* Capabilities */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Capabilities & Tags</h3>
|
||||
{agent.capabilities?.length ? (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-slate-400 mb-2">Capabilities</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{agent.capabilities.map((c) => (
|
||||
<span key={c} className="badge badge-info">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 mb-4">No capabilities reported</p>
|
||||
)}
|
||||
{agent.tags && Object.keys(agent.tags).length > 0 ? (
|
||||
<div>
|
||||
<p className="text-xs text-slate-400 mb-2">Tags</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(agent.tags).map(([k, v]) => (
|
||||
<span key={k} className="badge badge-neutral">{k}: {v}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">No tags</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Jobs */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Recent Jobs</h3>
|
||||
{!agentJobs.length ? (
|
||||
<p className="text-sm text-slate-500">No recent jobs</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{agentJobs.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.id}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-slate-400 font-mono">{j.certificate_id}</span>
|
||||
<StatusBadge status={j.status} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Heartbeat Timeline */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-300 mb-4">Heartbeat Status</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
health === 'Online' ? 'bg-emerald-400 animate-pulse' :
|
||||
health === 'Stale' ? 'bg-amber-400' : 'bg-red-400'
|
||||
}`} />
|
||||
<div>
|
||||
<p className="text-sm text-slate-200">{health}</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{health === 'Online' && 'Agent is responding to heartbeat checks'}
|
||||
{health === 'Stale' && 'Agent has not sent a heartbeat recently'}
|
||||
{health === 'Offline' && 'Agent is not responding'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAgents } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
@@ -17,6 +18,7 @@ function heartbeatStatus(lastHeartbeat: string): string {
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['agents'],
|
||||
queryFn: () => getAgents(),
|
||||
@@ -56,7 +58,7 @@ export default function AgentsPage() {
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
) : (
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No agents registered" />
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No agents registered" onRowClick={(a) => navigate(`/agents/${a.id}`)} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getAuditEvents } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
@@ -19,13 +20,39 @@ const actionColors: Record<string, string> = {
|
||||
policy_violated: 'text-red-400',
|
||||
};
|
||||
|
||||
const RESOURCE_TYPES = ['', 'certificate', 'agent', 'job', 'notification', 'policy', 'target', 'issuer'];
|
||||
const TIME_RANGES = [
|
||||
{ label: 'All time', value: '' },
|
||||
{ label: 'Last hour', value: '1h' },
|
||||
{ label: 'Last 24h', value: '24h' },
|
||||
{ label: 'Last 7 days', value: '7d' },
|
||||
{ label: 'Last 30 days', value: '30d' },
|
||||
];
|
||||
|
||||
export default function AuditPage() {
|
||||
const [resourceType, setResourceType] = useState('');
|
||||
const [actorFilter, setActorFilter] = useState('');
|
||||
const [timeRange, setTimeRange] = useState('');
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (resourceType) params.resource_type = resourceType;
|
||||
if (actorFilter) params.actor = actorFilter;
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['audit'],
|
||||
queryFn: () => getAuditEvents(),
|
||||
queryKey: ['audit', params],
|
||||
queryFn: () => getAuditEvents(params),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
// Client-side time range filtering (server may not support time params)
|
||||
const filtered = (data?.data || []).filter((e) => {
|
||||
if (!timeRange) return true;
|
||||
const ts = new Date(e.timestamp).getTime();
|
||||
const now = Date.now();
|
||||
const hours = timeRange === '1h' ? 1 : timeRange === '24h' ? 24 : timeRange === '7d' ? 168 : 720;
|
||||
return now - ts < hours * 3600 * 1000;
|
||||
});
|
||||
|
||||
const columns: Column<AuditEvent>[] = [
|
||||
{
|
||||
key: 'action',
|
||||
@@ -60,7 +87,7 @@ export default function AuditPage() {
|
||||
key: 'details',
|
||||
label: 'Details',
|
||||
render: (e) => {
|
||||
if (!e.details || Object.keys(e.details).length === 0) return <span className="text-slate-500">—</span>;
|
||||
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)}
|
||||
@@ -73,12 +100,48 @@ export default function AuditPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Audit Trail" subtitle={data ? `${data.total} events` : undefined} />
|
||||
<PageHeader title="Audit Trail" subtitle={data ? `${filtered.length} events` : undefined} />
|
||||
<div className="px-4 py-3 flex flex-wrap gap-3 border-b border-slate-700/50">
|
||||
<select
|
||||
value={resourceType}
|
||||
onChange={(e) => setResourceType(e.target.value)}
|
||||
className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">All resources</option>
|
||||
{RESOURCE_TYPES.filter(Boolean).map((t) => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter by actor..."
|
||||
value={actorFilter}
|
||||
onChange={(e) => setActorFilter(e.target.value)}
|
||||
className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 placeholder-slate-500 focus:outline-none focus:border-blue-500 w-40"
|
||||
/>
|
||||
<select
|
||||
value={timeRange}
|
||||
onChange={(e) => setTimeRange(e.target.value)}
|
||||
className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{TIME_RANGES.map((r) => (
|
||||
<option key={r.value} value={r.value}>{r.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{(resourceType || actorFilter || timeRange) && (
|
||||
<button
|
||||
onClick={() => { setResourceType(''); setActorFilter(''); setTimeRange(''); }}
|
||||
className="text-xs text-slate-400 hover:text-slate-200 transition-colors"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</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 audit events" />
|
||||
<DataTable columns={columns} data={filtered} isLoading={isLoading} emptyMessage="No audit events" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getIssuers } 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 { Issuer } from '../api/types';
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
local_ca: 'Local CA',
|
||||
acme: 'ACME',
|
||||
vault: 'Vault PKI',
|
||||
manual: 'Manual',
|
||||
};
|
||||
|
||||
export default function IssuersPage() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['issuers'],
|
||||
queryFn: () => getIssuers(),
|
||||
});
|
||||
|
||||
const columns: Column<Issuer>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Issuer',
|
||||
render: (i) => (
|
||||
<div>
|
||||
<div className="font-medium text-slate-200">{i.name}</div>
|
||||
<div className="text-xs text-slate-500 font-mono">{i.id}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: 'Type',
|
||||
render: (i) => (
|
||||
<span className="badge badge-neutral">{typeLabels[i.type] || i.type}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (i) => <StatusBadge status={i.status} />,
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
label: 'Config',
|
||||
render: (i) => {
|
||||
if (!i.config || Object.keys(i.config).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(i.config).slice(0, 60)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
label: 'Created',
|
||||
render: (i) => <span className="text-xs text-slate-400">{formatDateTime(i.created_at)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Issuers" subtitle={data ? `${data.total} issuers` : 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 issuers configured" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +1,193 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } 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 { formatDateTime, timeAgo } from '../api/utils';
|
||||
import type { Notification } from '../api/types';
|
||||
|
||||
const BASE = '/api/v1';
|
||||
|
||||
async function markNotificationRead(id: string) {
|
||||
const res = await fetch(`${BASE}/notifications/${id}/read`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Failed to mark as read');
|
||||
}
|
||||
|
||||
type ViewMode = 'list' | 'grouped';
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('grouped');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['notifications'],
|
||||
queryFn: () => getNotifications(),
|
||||
queryFn: () => getNotifications({ per_page: '100' }),
|
||||
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> },
|
||||
];
|
||||
const markRead = useMutation({
|
||||
mutationFn: markNotificationRead,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['notifications'] }),
|
||||
});
|
||||
|
||||
const notifications = data?.data || [];
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return notifications.filter((n) => {
|
||||
if (typeFilter && n.type !== typeFilter) return false;
|
||||
if (statusFilter && n.status !== statusFilter) return false;
|
||||
return true;
|
||||
});
|
||||
}, [notifications, typeFilter, statusFilter]);
|
||||
|
||||
const types = useMemo(() => [...new Set(notifications.map(n => n.type))], [notifications]);
|
||||
const statuses = useMemo(() => [...new Set(notifications.map(n => n.status))], [notifications]);
|
||||
|
||||
// Group by certificate_id
|
||||
const grouped = useMemo(() => {
|
||||
const groups: Record<string, Notification[]> = {};
|
||||
for (const n of filtered) {
|
||||
const key = n.certificate_id || 'general';
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(n);
|
||||
}
|
||||
return Object.entries(groups).sort(([, a], [, b]) => {
|
||||
const aTime = new Date(a[0].created_at).getTime();
|
||||
const bTime = new Date(b[0].created_at).getTime();
|
||||
return bTime - aTime;
|
||||
});
|
||||
}, [filtered]);
|
||||
|
||||
const unreadCount = filtered.filter(n => n.status === 'Pending' || n.status === 'pending').length;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Notifications" />
|
||||
<div className="flex items-center justify-center flex-1 text-slate-400">Loading...</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Notifications" />
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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()} />
|
||||
<PageHeader
|
||||
title="Notifications"
|
||||
subtitle={`${filtered.length} notifications${unreadCount ? ` (${unreadCount} unread)` : ''}`}
|
||||
/>
|
||||
<div className="px-4 py-3 flex flex-wrap items-center gap-3 border-b border-slate-700/50">
|
||||
<div className="flex rounded overflow-hidden border border-slate-600">
|
||||
<button
|
||||
onClick={() => setViewMode('grouped')}
|
||||
className={`px-3 py-1.5 text-xs transition-colors ${viewMode === 'grouped' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:text-slate-200'}`}
|
||||
>
|
||||
Grouped
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`px-3 py-1.5 text-xs transition-colors ${viewMode === 'list' ? 'bg-blue-600 text-white' : 'bg-slate-800 text-slate-400 hover:text-slate-200'}`}
|
||||
>
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">All types</option>
|
||||
{types.map(t => <option key={t} value={t}>{t.replace(/([A-Z])/g, ' $1').trim()}</option>)}
|
||||
</select>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="bg-slate-800 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-300 focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
{statuses.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
{(typeFilter || statusFilter) && (
|
||||
<button
|
||||
onClick={() => { setTypeFilter(''); setStatusFilter(''); }}
|
||||
className="text-xs text-slate-400 hover:text-slate-200 transition-colors"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
{viewMode === 'grouped' ? (
|
||||
grouped.length === 0 ? (
|
||||
<div className="text-center py-16 text-slate-500">No notifications</div>
|
||||
) : (
|
||||
grouped.map(([certId, items]) => (
|
||||
<div key={certId} className="card p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-mono text-slate-400">
|
||||
{certId === 'general' ? 'General' : certId}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500">{items.length} notification{items.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{items.map((n) => (
|
||||
<NotificationRow key={n.id} notification={n} onMarkRead={() => markRead.mutate(n.id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
) : (
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No notifications" />
|
||||
filtered.length === 0 ? (
|
||||
<div className="text-center py-16 text-slate-500">No notifications</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((n) => (
|
||||
<NotificationRow key={n.id} notification={n} onMarkRead={() => markRead.mutate(n.id)} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationRow({ notification: n, onMarkRead }: { notification: Notification; onMarkRead: () => void }) {
|
||||
const isUnread = n.status === 'Pending' || n.status === 'pending';
|
||||
return (
|
||||
<div className={`flex items-start justify-between py-2 px-3 rounded-lg transition-colors ${isUnread ? 'bg-slate-700/30 border-l-2 border-blue-500' : 'hover:bg-slate-700/20'}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm text-slate-200">{n.type.replace(/([A-Z])/g, ' $1').trim()}</span>
|
||||
<StatusBadge status={n.status} />
|
||||
<span className="text-xs text-slate-500">{n.channel}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 truncate">{n.message || n.subject}</p>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-xs text-slate-500">{n.recipient}</span>
|
||||
<span className="text-xs text-slate-600">{timeAgo(n.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{isUnread && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onMarkRead(); }}
|
||||
className="ml-3 text-xs text-blue-400 hover:text-blue-300 transition-colors whitespace-nowrap"
|
||||
>
|
||||
Mark read
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,12 +14,26 @@ const severityStyles: Record<string, string> = {
|
||||
critical: 'badge-danger',
|
||||
};
|
||||
|
||||
const severityDots: Record<string, string> = {
|
||||
low: 'bg-blue-400',
|
||||
medium: 'bg-amber-400',
|
||||
high: 'bg-orange-400',
|
||||
critical: 'bg-red-400',
|
||||
};
|
||||
|
||||
export default function PoliciesPage() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['policies'],
|
||||
queryFn: () => getPolicies(),
|
||||
});
|
||||
|
||||
const policies = data?.data || [];
|
||||
const enabledCount = policies.filter(p => p.enabled).length;
|
||||
const bySeverity = policies.reduce<Record<string, number>>((acc, p) => {
|
||||
acc[p.severity] = (acc[p.severity] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const columns: Column<PolicyRule>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
@@ -37,6 +51,18 @@ export default function PoliciesPage() {
|
||||
label: 'Severity',
|
||||
render: (p) => <span className={`badge ${severityStyles[p.severity] || 'badge-neutral'}`}>{p.severity}</span>,
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
label: 'Config',
|
||||
render: (p) => {
|
||||
if (!p.config || Object.keys(p.config).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(p.config).slice(0, 50)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: 'Enabled',
|
||||
@@ -52,11 +78,28 @@ export default function PoliciesPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Policies" subtitle={data ? `${data.total} rules` : undefined} />
|
||||
{policies.length > 0 && (
|
||||
<div className="px-4 py-3 flex flex-wrap gap-4 border-b border-slate-700/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-400">Enabled:</span>
|
||||
<span className="text-xs font-medium text-emerald-400">{enabledCount}</span>
|
||||
<span className="text-xs text-slate-600">/</span>
|
||||
<span className="text-xs text-slate-400">{policies.length}</span>
|
||||
</div>
|
||||
{Object.entries(bySeverity).map(([sev, count]) => (
|
||||
<div key={sev} className="flex items-center gap-1.5">
|
||||
<div className={`w-2 h-2 rounded-full ${severityDots[sev] || 'bg-slate-400'}`} />
|
||||
<span className="text-xs text-slate-300 capitalize">{sev}</span>
|
||||
<span className="text-xs text-slate-500">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</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 policy rules" />
|
||||
<DataTable columns={columns} data={policies} isLoading={isLoading} emptyMessage="No policy rules" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getTargets } 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 { Target } from '../api/types';
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
nginx: 'NGINX',
|
||||
f5_bigip: 'F5 BIG-IP',
|
||||
iis: 'IIS',
|
||||
apache: 'Apache',
|
||||
haproxy: 'HAProxy',
|
||||
};
|
||||
|
||||
export default function TargetsPage() {
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['targets'],
|
||||
queryFn: () => getTargets(),
|
||||
});
|
||||
|
||||
const columns: Column<Target>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Target',
|
||||
render: (t) => (
|
||||
<div>
|
||||
<div className="font-medium text-slate-200">{t.name}</div>
|
||||
<div className="text-xs text-slate-500 font-mono">{t.id}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: 'Type',
|
||||
render: (t) => (
|
||||
<span className="badge badge-neutral">{typeLabels[t.type] || t.type}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'hostname',
|
||||
label: 'Hostname',
|
||||
render: (t) => <span className="text-slate-300 font-mono text-xs">{t.hostname || '\u2014'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'agent',
|
||||
label: 'Agent',
|
||||
render: (t) => <span className="text-xs text-slate-400 font-mono">{t.agent_id || '\u2014'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (t) => <StatusBadge status={t.status} />,
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
label: 'Created',
|
||||
render: (t) => <span className="text-xs text-slate-400">{formatDateTime(t.created_at)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Deployment Targets" subtitle={data ? `${data.total} targets` : 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 deployment targets" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user