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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user