feat(frontend): c2 tasks panel + history import (sprint 8 phase 2)
- Add getC2Tasks / listCallbackHistory / importC2 API functions + types - useC2Tasks with 2500ms polling (stops when all tasks completed) - useC2CallbackHistory, useImportC2 hooks - C2TaskStatusBadge, C2TasksPanel (expandable output rows, polling indicator) - C2CallbackPicker extracted as shared component (reused in both modals) - ImportC2HistoryModal: 2-step callback picker → paginated history table - SimulationFormPage: RT card + tasks panel share left grid column; Import C2 history button - 37 new tests (api/c2, C2TasksPanel, ImportC2HistoryModal, SimulationFormPage panel visibility) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
87
frontend/src/components/C2CallbackPicker.tsx
Normal file
87
frontend/src/components/C2CallbackPicker.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { extractApiError } from '@/api/client';
|
||||
import type { C2Callback } from '@/api/types';
|
||||
|
||||
interface C2CallbackPickerProps {
|
||||
callbacks: C2Callback[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
error: unknown;
|
||||
selectedId: number | null;
|
||||
onSelect: (id: number) => void;
|
||||
rowTestId?: string;
|
||||
}
|
||||
|
||||
export function C2CallbackPicker({
|
||||
callbacks,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
selectedId,
|
||||
onSelect,
|
||||
rowTestId = 'c2-callback-row',
|
||||
}: C2CallbackPickerProps): JSX.Element {
|
||||
if (isLoading) {
|
||||
return <p className="text-[14px] text-graphite">Loading callbacks…</p>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="text-[14px] text-bloom-deep">
|
||||
Could not load callbacks: {extractApiError(error, 'Unknown error')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (callbacks.length === 0) {
|
||||
return <p className="text-[14px] text-graphite">No callbacks available.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-hairline overflow-x-auto">
|
||||
<table className="w-full text-[14px]">
|
||||
<thead>
|
||||
<tr className="bg-cloud border-b border-hairline">
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Display ID</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Active</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Host</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">User</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Domain</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Last check-in</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{callbacks.map((cb) => {
|
||||
const isSelected = selectedId === cb.display_id;
|
||||
return (
|
||||
<tr
|
||||
key={cb.display_id}
|
||||
data-testid={rowTestId}
|
||||
onClick={() => onSelect(cb.display_id)}
|
||||
className={`cursor-pointer border-b border-hairline ${
|
||||
isSelected ? 'bg-primary-soft' : 'hover:bg-cloud'
|
||||
}`}
|
||||
>
|
||||
<td className="px-md py-sm font-mono">{cb.display_id}</td>
|
||||
<td className="px-md py-sm">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-pill px-3 py-[4px] text-[12px] leading-[1.3] font-bold ${
|
||||
cb.active
|
||||
? 'bg-primary-soft text-primary-deep'
|
||||
: 'bg-cloud text-graphite border border-hairline'
|
||||
}`}
|
||||
>
|
||||
{cb.active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-md py-sm font-mono">{cb.host}</td>
|
||||
<td className="px-md py-sm font-mono">{cb.user}</td>
|
||||
<td className="px-md py-sm font-mono">{cb.domain}</td>
|
||||
<td className="px-md py-sm font-mono">{cb.last_checkin}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
frontend/src/components/C2TaskStatusBadge.tsx
Normal file
27
frontend/src/components/C2TaskStatusBadge.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
// Dedicated badge for Mythic task statuses — separate from simulation status badges.
|
||||
// submitted / processed → primary-soft (in-flight)
|
||||
// completed → success-soft
|
||||
// error* / fail* → warn-soft (task-level issue, not system error)
|
||||
// anything else → cloud / graphite (unknown/neutral)
|
||||
|
||||
interface C2TaskStatusBadgeProps {
|
||||
status: string;
|
||||
}
|
||||
|
||||
function badgeClass(status: string): string {
|
||||
const s = status.toLowerCase();
|
||||
if (s === 'completed') return 'bg-success-soft text-success';
|
||||
if (s.startsWith('error') || s.startsWith('fail')) return 'bg-warn-soft text-warn';
|
||||
if (s === 'submitted' || s === 'processed') return 'bg-primary-soft text-primary-deep';
|
||||
return 'bg-cloud text-graphite border border-hairline';
|
||||
}
|
||||
|
||||
export function C2TaskStatusBadge({ status }: C2TaskStatusBadgeProps): JSX.Element {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-pill px-3 py-[4px] text-[12px] leading-[1.3] font-bold ${badgeClass(status)}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
126
frontend/src/components/C2TasksPanel.tsx
Normal file
126
frontend/src/components/C2TasksPanel.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { useC2Tasks } from '@/hooks/useC2';
|
||||
import { C2TaskStatusBadge } from './C2TaskStatusBadge';
|
||||
|
||||
interface C2TasksPanelProps {
|
||||
simulationId: number;
|
||||
}
|
||||
|
||||
export function C2TasksPanel({ simulationId }: C2TasksPanelProps): JSX.Element {
|
||||
const query = useC2Tasks(simulationId, { enabled: true });
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||
|
||||
const tasks = query.data?.tasks ?? [];
|
||||
const isRefreshing = query.isFetching && !query.isLoading;
|
||||
|
||||
function toggleExpand(id: number, completed: boolean) {
|
||||
if (!completed) return;
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="c2-tasks-panel"
|
||||
className="card-product flex flex-col gap-md"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-[16px] font-medium text-ink">C2 Tasks</h3>
|
||||
{isRefreshing && (
|
||||
<span
|
||||
data-testid="c2-task-refresh-indicator"
|
||||
className="text-[12px] text-graphite"
|
||||
>
|
||||
Refreshing…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div className="border border-hairline rounded-none px-md py-md">
|
||||
<p className="text-[14px] text-graphite">
|
||||
No C2 tasks yet. Use Execute via C2 to launch commands.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-hairline overflow-x-auto">
|
||||
<table className="w-full text-[14px]">
|
||||
<thead>
|
||||
<tr className="bg-cloud border-b border-hairline">
|
||||
<th className="px-md py-sm text-left font-medium text-ink w-8" aria-label="Expand" />
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Task</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Command</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Source</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Status</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Completed at</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task) => {
|
||||
const isExpanded = expandedIds.has(task.id);
|
||||
const canExpand = task.completed && Boolean(task.output);
|
||||
return (
|
||||
<Fragment key={task.id}>
|
||||
<tr
|
||||
data-testid="c2-task-row"
|
||||
onClick={() => toggleExpand(task.id, task.completed)}
|
||||
className={`border-b border-hairline ${canExpand ? 'cursor-pointer hover:bg-cloud' : ''}`}
|
||||
>
|
||||
<td className="px-md py-sm text-graphite">
|
||||
{canExpand ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown size={14} aria-hidden />
|
||||
) : (
|
||||
<ChevronRight size={14} aria-hidden />
|
||||
)
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-md py-sm font-mono">#{task.mythic_task_display_id}</td>
|
||||
<td
|
||||
className="px-md py-sm font-mono max-w-[200px] truncate"
|
||||
title={task.command}
|
||||
>
|
||||
{task.command}
|
||||
</td>
|
||||
<td className="px-md py-sm">
|
||||
<span className="inline-flex items-center rounded-pill px-3 py-[4px] text-[12px] leading-[1.3] font-bold bg-cloud text-graphite border border-hairline">
|
||||
{task.mapping_applied ? 'MIMIC' : 'IMPORT'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-md py-sm">
|
||||
<C2TaskStatusBadge status={task.status} />
|
||||
</td>
|
||||
<td className="px-md py-sm font-mono text-graphite">
|
||||
{task.completed_at ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && task.output && (
|
||||
<tr className="border-b border-hairline bg-cloud">
|
||||
<td colSpan={6} className="px-md py-sm">
|
||||
<pre
|
||||
data-testid="c2-task-output"
|
||||
className="font-mono text-[12px] whitespace-pre-wrap text-ink"
|
||||
>
|
||||
{task.output}
|
||||
</pre>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useC2Callbacks, useExecuteC2 } from '@/hooks/useC2';
|
||||
import type { C2Callback } from '@/api/types';
|
||||
import { C2CallbackPicker } from './C2CallbackPicker';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
interface ExecuteViaC2ModalProps {
|
||||
@@ -11,11 +11,6 @@ interface ExecuteViaC2ModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function formatCheckin(ts: string): string {
|
||||
// Show ISO timestamp as-is — it's a data cell (font-mono)
|
||||
return ts;
|
||||
}
|
||||
|
||||
export function ExecuteViaC2Modal({
|
||||
simulationId,
|
||||
engagementId,
|
||||
@@ -31,7 +26,7 @@ export function ExecuteViaC2Modal({
|
||||
const [commands, setCommands] = useState(initialCommands);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const callbacks: C2Callback[] = callbacksQuery.data?.callbacks ?? [];
|
||||
const callbacks = callbacksQuery.data?.callbacks ?? [];
|
||||
|
||||
const commandLines = commands
|
||||
.split('\n')
|
||||
@@ -70,70 +65,18 @@ export function ExecuteViaC2Modal({
|
||||
Execute via C2
|
||||
</h2>
|
||||
|
||||
{/* Callback table */}
|
||||
{/* Callback picker */}
|
||||
<div className="flex flex-col gap-xs">
|
||||
<span className="text-[14px] font-medium text-ink">Select callback</span>
|
||||
|
||||
{callbacksQuery.isLoading && (
|
||||
<p className="text-[14px] text-graphite">Loading callbacks…</p>
|
||||
)}
|
||||
|
||||
{callbacksQuery.isError && (
|
||||
<p className="text-[14px] text-bloom-deep">
|
||||
Could not load callbacks: {extractApiError(callbacksQuery.error, 'Unknown error')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!callbacksQuery.isLoading && callbacks.length === 0 && !callbacksQuery.isError && (
|
||||
<p className="text-[14px] text-graphite">No callbacks available.</p>
|
||||
)}
|
||||
|
||||
{callbacks.length > 0 && (
|
||||
<div className="border border-hairline overflow-x-auto">
|
||||
<table className="w-full text-[14px]">
|
||||
<thead>
|
||||
<tr className="bg-cloud border-b border-hairline">
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Display ID</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Active</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Host</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">User</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Domain</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Last check-in</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{callbacks.map((cb) => {
|
||||
const isSelected = selectedId === cb.display_id;
|
||||
return (
|
||||
<tr
|
||||
key={cb.display_id}
|
||||
data-testid="c2-callback-row"
|
||||
onClick={() => setSelectedId(cb.display_id)}
|
||||
className={`cursor-pointer border-b border-hairline ${
|
||||
isSelected
|
||||
? 'bg-primary-soft'
|
||||
: 'hover:bg-cloud'
|
||||
}`}
|
||||
>
|
||||
<td className="px-md py-sm font-mono">{cb.display_id}</td>
|
||||
<td className="px-md py-sm">
|
||||
<span
|
||||
className={`badge-pill-${cb.active ? 'active' : 'inactive'}`}
|
||||
>
|
||||
{cb.active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-md py-sm font-mono">{cb.host}</td>
|
||||
<td className="px-md py-sm font-mono">{cb.user}</td>
|
||||
<td className="px-md py-sm font-mono">{cb.domain}</td>
|
||||
<td className="px-md py-sm font-mono">{formatCheckin(cb.last_checkin)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<C2CallbackPicker
|
||||
callbacks={callbacks}
|
||||
isLoading={callbacksQuery.isLoading}
|
||||
isError={callbacksQuery.isError}
|
||||
error={callbacksQuery.error}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
rowTestId="c2-callback-row"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Commands */}
|
||||
|
||||
252
frontend/src/components/ImportC2HistoryModal.tsx
Normal file
252
frontend/src/components/ImportC2HistoryModal.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useState } from 'react';
|
||||
import { extractApiError } from '@/api/client';
|
||||
import { useC2Callbacks, useC2CallbackHistory, useImportC2 } from '@/hooks/useC2';
|
||||
import { C2CallbackPicker } from './C2CallbackPicker';
|
||||
import { C2TaskStatusBadge } from './C2TaskStatusBadge';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
interface ImportC2HistoryModalProps {
|
||||
simulationId: number;
|
||||
engagementId: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ImportC2HistoryModal({
|
||||
simulationId,
|
||||
engagementId,
|
||||
onClose,
|
||||
}: ImportC2HistoryModalProps): JSX.Element {
|
||||
const { push } = useToast();
|
||||
|
||||
const callbacksQuery = useC2Callbacks(engagementId, { enabled: true });
|
||||
const importMutation = useImportC2(simulationId);
|
||||
|
||||
const [selectedCallbackId, setSelectedCallbackId] = useState<number | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set());
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const historyQuery = useC2CallbackHistory(engagementId, selectedCallbackId, {
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
enabled: selectedCallbackId !== null,
|
||||
});
|
||||
|
||||
const historyTasks = historyQuery.data?.tasks ?? [];
|
||||
const total = historyQuery.data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const callbacks = callbacksQuery.data?.callbacks ?? [];
|
||||
|
||||
function handleCallbackSelect(id: number) {
|
||||
setSelectedCallbackId(id);
|
||||
setPage(1);
|
||||
setCheckedIds(new Set());
|
||||
}
|
||||
|
||||
function toggleCheck(displayId: number) {
|
||||
setCheckedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(displayId)) {
|
||||
next.delete(displayId);
|
||||
} else {
|
||||
next.add(displayId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
const canImport = checkedIds.size > 0 && selectedCallbackId !== null;
|
||||
|
||||
const onImport = async () => {
|
||||
if (!canImport) return;
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const result = await importMutation.mutateAsync({
|
||||
callback_display_id: selectedCallbackId,
|
||||
task_display_ids: Array.from(checkedIds),
|
||||
});
|
||||
const msg =
|
||||
result.skipped > 0
|
||||
? `Imported ${result.imported} task(s), ${result.skipped} already attached`
|
||||
: `Imported ${result.imported} task(s)`;
|
||||
push(msg, 'success');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setSubmitError(extractApiError(err, 'Could not import tasks'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="c2-import-modal-title"
|
||||
data-testid="c2-import-modal"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
>
|
||||
<div className="modal-backdrop absolute inset-0" onClick={onClose} aria-hidden="true" />
|
||||
|
||||
<div className="relative card-product w-full max-w-4xl mx-md flex flex-col gap-md max-h-[90vh] overflow-y-auto">
|
||||
<h2 id="c2-import-modal-title" className="text-[20px] font-medium text-ink">
|
||||
Import C2 history
|
||||
</h2>
|
||||
|
||||
{/* Step 1: callback picker */}
|
||||
<div className="flex flex-col gap-xs">
|
||||
<span className="text-[14px] font-medium text-ink">Select callback</span>
|
||||
<C2CallbackPicker
|
||||
callbacks={callbacks}
|
||||
isLoading={callbacksQuery.isLoading}
|
||||
isError={callbacksQuery.isError}
|
||||
error={callbacksQuery.error}
|
||||
selectedId={selectedCallbackId}
|
||||
onSelect={handleCallbackSelect}
|
||||
rowTestId="c2-import-callback-row"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 2: history table (shown once a callback is selected) */}
|
||||
{selectedCallbackId !== null && (
|
||||
<div className="flex flex-col gap-xs">
|
||||
<span className="text-[14px] font-medium text-ink">
|
||||
Task history{' '}
|
||||
{total > 0 && (
|
||||
<span className="text-graphite font-normal">({total} total)</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{historyQuery.isLoading && (
|
||||
<p className="text-[14px] text-graphite">Loading history…</p>
|
||||
)}
|
||||
|
||||
{historyQuery.isError && (
|
||||
<p className="text-[14px] text-bloom-deep">
|
||||
{extractApiError(historyQuery.error, 'Could not load history')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!historyQuery.isLoading && historyTasks.length === 0 && !historyQuery.isError && (
|
||||
<p className="text-[14px] text-graphite">No task history for this callback.</p>
|
||||
)}
|
||||
|
||||
{historyTasks.length > 0 && (
|
||||
<>
|
||||
<div className="border border-hairline overflow-x-auto">
|
||||
<table className="w-full text-[14px]">
|
||||
<thead>
|
||||
<tr className="bg-cloud border-b border-hairline">
|
||||
<th className="px-md py-sm w-8" aria-label="Select" />
|
||||
<th className="px-md py-sm text-left font-medium text-ink">#</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Command</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Status</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Completed</th>
|
||||
<th className="px-md py-sm text-left font-medium text-ink">Timestamp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{historyTasks.map((task) => (
|
||||
<tr
|
||||
key={task.display_id}
|
||||
data-testid="c2-history-row"
|
||||
onClick={() => toggleCheck(task.display_id)}
|
||||
className="cursor-pointer border-b border-hairline hover:bg-cloud"
|
||||
>
|
||||
<td className="px-md py-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-testid="c2-history-row-checkbox"
|
||||
checked={checkedIds.has(task.display_id)}
|
||||
onChange={() => toggleCheck(task.display_id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-4 w-4 accent-primary"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-md py-sm font-mono">{task.display_id}</td>
|
||||
<td
|
||||
className="px-md py-sm font-mono max-w-[200px] truncate"
|
||||
title={task.command}
|
||||
>
|
||||
{task.command}
|
||||
</td>
|
||||
<td className="px-md py-sm">
|
||||
<C2TaskStatusBadge status={task.status} />
|
||||
</td>
|
||||
<td className="px-md py-sm text-[14px]">
|
||||
{task.completed ? 'Yes' : 'No'}
|
||||
</td>
|
||||
<td className="px-md py-sm font-mono text-graphite">
|
||||
{task.created_at}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center gap-md text-[14px] text-graphite">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="c2-history-prev"
|
||||
className="btn-outline-ink"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span>
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="c2-history-next"
|
||||
className="btn-outline-ink"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
{checkedIds.size > 0 && (
|
||||
<span className="ml-auto text-ink">
|
||||
{checkedIds.size} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submitError && (
|
||||
<p role="alert" className="text-[14px] text-bloom-deep">
|
||||
{submitError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-md pt-xs">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="c2-import-submit-btn"
|
||||
className="btn-primary"
|
||||
onClick={onImport}
|
||||
disabled={!canImport || importMutation.isPending}
|
||||
>
|
||||
{importMutation.isPending ? 'Importing…' : 'Import selected'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-outline-ink"
|
||||
onClick={onClose}
|
||||
disabled={importMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user