mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-10 12:58:53 +00:00
feat: add frontend action buttons, fix notification auth bug, add 53 Vitest tests
Bug fix: - markNotificationRead was using raw fetch() without auth headers, bypassing the shared client's Authorization header. Moved to api/client.ts to use fetchJSON with proper auth. New action buttons: - CertificatesPage: "New Certificate" modal with form fields - CertificateDetailPage: "Deploy" button with target selector modal, "Archive" button with confirmation - IssuersPage: "Test Connection" and "Delete" per-row actions - TargetsPage: "Delete" per-row action - PoliciesPage: "Enable/Disable" toggle and "Delete" per-row actions New API client functions: - updateCertificate, archiveCertificate, registerAgent, createPolicy, updatePolicy, deletePolicy, getPolicyViolations, createIssuer, testIssuerConnection, deleteIssuer, createTarget, deleteTarget, markNotificationRead Frontend tests (53 tests, 2 files): - client.test.ts: 35 tests covering all API endpoints, auth headers, 401 handling, error parsing, HTTP methods, request bodies - utils.test.ts: 18 tests covering formatDate, formatDateTime, timeAgo, daysUntil, expiryColor CI: Added "Run Frontend Tests" step to frontend-build job Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getCertificate, getCertificateVersions, triggerRenewal } from '../api/client';
|
||||
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, getTargets } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
@@ -19,6 +20,8 @@ export default function CertificateDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [showDeploy, setShowDeploy] = useState(false);
|
||||
const [deployTargetId, setDeployTargetId] = useState('');
|
||||
|
||||
const { data: cert, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['certificate', id],
|
||||
@@ -32,6 +35,12 @@ export default function CertificateDetailPage() {
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const { data: targets } = useQuery({
|
||||
queryKey: ['targets'],
|
||||
queryFn: () => getTargets(),
|
||||
enabled: showDeploy,
|
||||
});
|
||||
|
||||
const renewMutation = useMutation({
|
||||
mutationFn: () => triggerRenewal(id!),
|
||||
onSuccess: () => {
|
||||
@@ -40,6 +49,23 @@ export default function CertificateDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const deployMutation = useMutation({
|
||||
mutationFn: () => triggerDeployment(id!, deployTargetId),
|
||||
onSuccess: () => {
|
||||
setShowDeploy(false);
|
||||
setDeployTargetId('');
|
||||
queryClient.invalidateQueries({ queryKey: ['certificate', id] });
|
||||
},
|
||||
});
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: () => archiveCertificate(id!),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] });
|
||||
navigate('/certificates');
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
@@ -70,6 +96,13 @@ export default function CertificateDetailPage() {
|
||||
<button onClick={() => navigate('/certificates')} className="btn btn-ghost text-xs">
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeploy(true)}
|
||||
disabled={cert.status === 'Archived'}
|
||||
className="btn btn-ghost text-xs border border-slate-600 disabled:opacity-50"
|
||||
>
|
||||
Deploy
|
||||
</button>
|
||||
<button
|
||||
onClick={() => renewMutation.mutate()}
|
||||
disabled={renewMutation.isPending || cert.status === 'Archived' || cert.status === 'RenewalInProgress'}
|
||||
@@ -77,6 +110,15 @@ export default function CertificateDetailPage() {
|
||||
>
|
||||
{renewMutation.isPending ? 'Renewing...' : 'Trigger Renewal'}
|
||||
</button>
|
||||
{cert.status !== 'Archived' && (
|
||||
<button
|
||||
onClick={() => { if (confirm('Archive this certificate? This cannot be undone.')) archiveMutation.mutate(); }}
|
||||
disabled={archiveMutation.isPending}
|
||||
className="btn btn-ghost text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
|
||||
>
|
||||
{archiveMutation.isPending ? 'Archiving...' : 'Archive'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -91,6 +133,21 @@ export default function CertificateDetailPage() {
|
||||
Failed to trigger renewal: {(renewMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
{deployMutation.isSuccess && (
|
||||
<div className="bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-lg px-4 py-3 text-sm">
|
||||
Deployment triggered. A deployment job has been created.
|
||||
</div>
|
||||
)}
|
||||
{deployMutation.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 deploy: {(deployMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
{archiveMutation.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 archive: {(archiveMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Certificate Info */}
|
||||
@@ -163,6 +220,39 @@ export default function CertificateDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showDeploy && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowDeploy(false)}>
|
||||
<div className="bg-slate-800 border border-slate-600 rounded-xl p-6 w-full max-w-md shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-slate-200 mb-4">Deploy Certificate</h2>
|
||||
{deployMutation.isError && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-3">
|
||||
{(deployMutation.error as Error).message}
|
||||
</div>
|
||||
)}
|
||||
<label className="text-xs text-slate-400 block mb-2">Select Target</label>
|
||||
<select
|
||||
value={deployTargetId}
|
||||
onChange={e => setDeployTargetId(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 mb-4"
|
||||
>
|
||||
<option value="">Choose a target...</option>
|
||||
{targets?.data?.map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name} ({t.type} — {t.hostname})</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setShowDeploy(false)} className="btn btn-ghost text-sm">Cancel</button>
|
||||
<button
|
||||
onClick={() => deployMutation.mutate()}
|
||||
disabled={!deployTargetId || deployMutation.isPending}
|
||||
className="btn btn-primary text-sm disabled:opacity-50"
|
||||
>
|
||||
{deployMutation.isPending ? 'Deploying...' : 'Deploy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getCertificates } from '../api/client';
|
||||
import { getCertificates, createCertificate } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
@@ -10,10 +10,101 @@ import ErrorState from '../components/ErrorState';
|
||||
import { formatDate, daysUntil, expiryColor } from '../api/utils';
|
||||
import type { Certificate } from '../api/types';
|
||||
|
||||
function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
||||
const [form, setForm] = useState({
|
||||
id: '',
|
||||
common_name: '',
|
||||
environment: 'production',
|
||||
issuer_id: '',
|
||||
owner_id: '',
|
||||
team_id: '',
|
||||
renewal_policy_id: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => createCertificate(form),
|
||||
onSuccess: () => onSuccess(),
|
||||
onError: (err: Error) => setError(err.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-slate-800 border border-slate-600 rounded-xl p-6 w-full max-w-lg shadow-2xl" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-slate-200 mb-4">New Certificate</h2>
|
||||
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-4">{error}</div>}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">ID (optional)</label>
|
||||
<input value={form.id} onChange={e => setForm(f => ({ ...f, id: e.target.value }))}
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
placeholder="mc-api-prod (auto-generated if empty)" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">Common Name *</label>
|
||||
<input value={form.common_name} onChange={e => setForm(f => ({ ...f, common_name: e.target.value }))}
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
placeholder="api.example.com" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">Environment</label>
|
||||
<select value={form.environment} onChange={e => setForm(f => ({ ...f, environment: e.target.value }))}
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200">
|
||||
<option value="production">Production</option>
|
||||
<option value="staging">Staging</option>
|
||||
<option value="development">Development</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">Issuer ID *</label>
|
||||
<input value={form.issuer_id} onChange={e => setForm(f => ({ ...f, issuer_id: e.target.value }))}
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
placeholder="iss-local" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">Owner ID</label>
|
||||
<input value={form.owner_id} onChange={e => setForm(f => ({ ...f, owner_id: e.target.value }))}
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
placeholder="o-alice" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">Team ID</label>
|
||||
<input value={form.team_id} onChange={e => setForm(f => ({ ...f, team_id: e.target.value }))}
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
placeholder="t-platform" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-slate-400 block mb-1">Policy ID</label>
|
||||
<input value={form.renewal_policy_id} onChange={e => setForm(f => ({ ...f, renewal_policy_id: e.target.value }))}
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
|
||||
placeholder="rp-standard" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button onClick={onClose} className="btn btn-ghost text-sm">Cancel</button>
|
||||
<button
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={!form.common_name || !form.issuer_id || mutation.isPending}
|
||||
className="btn btn-primary text-sm disabled:opacity-50"
|
||||
>
|
||||
{mutation.isPending ? 'Creating...' : 'Create Certificate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CertificatesPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [envFilter, setEnvFilter] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
@@ -60,6 +151,11 @@ export default function CertificatesPage() {
|
||||
<PageHeader
|
||||
title="Certificates"
|
||||
subtitle={data ? `${data.total} certificates` : undefined}
|
||||
action={
|
||||
<button onClick={() => setShowCreate(true)} className="btn btn-primary text-xs">
|
||||
+ New Certificate
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="px-6 py-3 flex gap-3 border-b border-slate-700/50">
|
||||
<select
|
||||
@@ -98,6 +194,15 @@ export default function CertificatesPage() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showCreate && (
|
||||
<CreateCertificateModal
|
||||
onClose={() => setShowCreate(false)}
|
||||
onSuccess={() => {
|
||||
setShowCreate(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['certificates'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getIssuers } from '../api/client';
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getIssuers, testIssuerConnection, deleteIssuer } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
@@ -16,11 +17,25 @@ const typeLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function IssuersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; msg: string } | null>(null);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['issuers'],
|
||||
queryFn: () => getIssuers(),
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
mutationFn: testIssuerConnection,
|
||||
onSuccess: (_data, id) => setTestResult({ id, ok: true, msg: 'Connection successful' }),
|
||||
onError: (err: Error, id) => setTestResult({ id, ok: false, msg: err.message }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteIssuer,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['issuers'] }),
|
||||
});
|
||||
|
||||
const columns: Column<Issuer>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
@@ -61,11 +76,38 @@ export default function IssuersPage() {
|
||||
label: 'Created',
|
||||
render: (i) => <span className="text-xs text-slate-400">{formatDateTime(i.created_at)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
render: (i) => (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); testMutation.mutate(i.id); }}
|
||||
disabled={testMutation.isPending}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
Test
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete issuer ${i.name}?`)) deleteMutation.mutate(i.id); }}
|
||||
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Issuers" subtitle={data ? `${data.total} issuers` : undefined} />
|
||||
{testResult && (
|
||||
<div className={`mx-6 mt-3 rounded-lg px-4 py-3 text-sm ${testResult.ok ? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-400' : 'bg-red-500/10 border border-red-500/20 text-red-400'}`}>
|
||||
{testResult.id}: {testResult.msg}
|
||||
<button onClick={() => setTestResult(null)} className="ml-3 text-xs opacity-60 hover:opacity-100">dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getNotifications } from '../api/client';
|
||||
import { getNotifications, markNotificationRead } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
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() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getPolicies } from '../api/client';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getPolicies, updatePolicy, deletePolicy } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
@@ -22,11 +22,23 @@ const severityDots: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function PoliciesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['policies'],
|
||||
queryFn: () => getPolicies(),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => updatePolicy(id, { enabled }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['policies'] }),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deletePolicy,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['policies'] }),
|
||||
});
|
||||
|
||||
const policies = data?.data || [];
|
||||
const enabledCount = policies.filter(p => p.enabled).length;
|
||||
const bySeverity = policies.reduce<Record<string, number>>((acc, p) => {
|
||||
@@ -67,12 +79,27 @@ export default function PoliciesPage() {
|
||||
key: 'enabled',
|
||||
label: 'Enabled',
|
||||
render: (p) => (
|
||||
<span className={p.enabled ? 'text-emerald-400' : 'text-slate-500'}>
|
||||
{p.enabled ? 'Yes' : 'No'}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleMutation.mutate({ id: p.id, enabled: !p.enabled }); }}
|
||||
className={`text-xs font-medium transition-colors ${p.enabled ? 'text-emerald-400 hover:text-emerald-300' : 'text-slate-500 hover:text-slate-300'}`}
|
||||
>
|
||||
{p.enabled ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ key: 'created', label: 'Created', render: (p) => <span className="text-xs text-slate-400">{formatDateTime(p.created_at)}</span> },
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
render: (p) => (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete policy ${p.name}?`)) deleteMutation.mutate(p.id); }}
|
||||
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getTargets } from '../api/client';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { getTargets, deleteTarget } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
@@ -17,11 +17,18 @@ const typeLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function TargetsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['targets'],
|
||||
queryFn: () => getTargets(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteTarget,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['targets'] }),
|
||||
});
|
||||
|
||||
const columns: Column<Target>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
@@ -60,6 +67,18 @@ export default function TargetsPage() {
|
||||
label: 'Created',
|
||||
render: (t) => <span className="text-xs text-slate-400">{formatDateTime(t.created_at)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
render: (t) => (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete target ${t.name}?`)) deleteMutation.mutate(t.id); }}
|
||||
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user