mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-12 19:58:52 +00:00
feat(gui): add discovery triage, network scan management, and approval workflow pages (M24)
Three new GUI surfaces closing the backend-to-frontend gap for V2: - Discovery triage page: summary stats bar, DataTable with claim/dismiss actions, status/agent filters, collapsible scan history panel - Network scan target management: CRUD with create modal, enable/disable toggle, Scan Now button, last scan results display - Jobs page approval workflow: Approve/Reject buttons for AwaitingApproval jobs, rejection reason modal, pending approval banner with count, AwaitingApproval/AwaitingCSR added to status filter dropdown Also adds 13 new frontend tests, 4 API types, 12 API client functions, 2 sidebar nav items, 2 routes, and discovery status badge styles. Docs updated: README, architecture, quickstart, demo-advanced, CLAUDE.md, roadmap. Version bumped to v2.0.4. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+113
-9
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getJobs, cancelJob } from '../api/client';
|
||||
import { getJobs, cancelJob, approveRenewal, rejectRenewal } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
@@ -9,9 +9,48 @@ import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Job } from '../api/types';
|
||||
|
||||
function RejectModal({ job, onClose, onReject }: { job: Job; onClose: () => void; onReject: (reason: string) => void }) {
|
||||
const [reason, setReason] = useState('');
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md mx-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="px-6 py-4 border-b border-surface-border">
|
||||
<h3 className="text-lg font-semibold text-ink">Reject Job</h3>
|
||||
<p className="text-sm text-ink-muted mt-1">
|
||||
Rejecting job <span className="font-mono text-xs">{job.id}</span> for certificate <span className="font-mono text-xs">{job.certificate_id}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<label className="block text-sm font-medium text-ink mb-1">Reason</label>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={e => setReason(e.target.value)}
|
||||
placeholder="Why is this renewal being rejected?"
|
||||
className="w-full border border-surface-border rounded px-3 py-2 text-sm text-ink bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-6 py-3 border-t border-surface-border flex justify-end gap-2">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-ink-muted hover:text-ink rounded border border-surface-border">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReject(reason)}
|
||||
disabled={!reason.trim()}
|
||||
className="px-4 py-2 text-sm text-white bg-red-600 hover:bg-red-700 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JobsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('');
|
||||
const [rejectingJob, setRejectingJob] = useState<Job | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
@@ -29,6 +68,21 @@ export default function JobsPage() {
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }),
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: approveRenewal,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }),
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) => rejectRenewal(id, reason),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['jobs'] });
|
||||
setRejectingJob(null);
|
||||
},
|
||||
});
|
||||
|
||||
const awaitingCount = data?.data?.filter(j => j.status === 'AwaitingApproval').length || 0;
|
||||
|
||||
const columns: Column<Job>[] = [
|
||||
{
|
||||
key: 'id',
|
||||
@@ -53,14 +107,33 @@ export default function JobsPage() {
|
||||
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
|
||||
<div className="flex gap-2">
|
||||
{j.status === 'AwaitingApproval' && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); approveMutation.mutate(j.id); }}
|
||||
disabled={approveMutation.isPending}
|
||||
className="text-xs text-green-600 hover:text-green-700 font-medium"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setRejectingJob(j); }}
|
||||
className="text-xs text-red-500 hover:text-red-600 font-medium"
|
||||
>
|
||||
Reject
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(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>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -68,6 +141,27 @@ export default function JobsPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Jobs" subtitle={data ? `${data.total} jobs` : undefined} />
|
||||
|
||||
{/* Pending approval banner */}
|
||||
{awaitingCount > 0 && (
|
||||
<div className="mx-6 mt-3 px-4 py-2.5 bg-amber-50 border border-amber-200 rounded-lg flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-amber-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<span className="text-sm text-amber-800">
|
||||
<strong>{awaitingCount}</strong> job{awaitingCount !== 1 ? 's' : ''} awaiting approval
|
||||
</span>
|
||||
{statusFilter !== 'AwaitingApproval' && (
|
||||
<button
|
||||
onClick={() => setStatusFilter('AwaitingApproval')}
|
||||
className="text-xs text-amber-700 hover:text-amber-900 underline ml-1"
|
||||
>
|
||||
Show only
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-6 py-3 flex gap-3 border-b border-surface-border/50">
|
||||
<select
|
||||
value={statusFilter}
|
||||
@@ -76,6 +170,8 @@ export default function JobsPage() {
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="Pending">Pending</option>
|
||||
<option value="AwaitingApproval">Awaiting Approval</option>
|
||||
<option value="AwaitingCSR">Awaiting CSR</option>
|
||||
<option value="Running">Running</option>
|
||||
<option value="Completed">Completed</option>
|
||||
<option value="Failed">Failed</option>
|
||||
@@ -100,6 +196,14 @@ export default function JobsPage() {
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No jobs found" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rejectingJob && (
|
||||
<RejectModal
|
||||
job={rejectingJob}
|
||||
onClose={() => setRejectingJob(null)}
|
||||
onReject={(reason) => rejectMutation.mutate({ id: rejectingJob.id, reason })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user