feat(phase-31): add campaign scheduling and recurring automation (T-233 to T-234)

This commit is contained in:
2026-02-10 08:38:00 +01:00
parent 4d124b42dd
commit 02034d60f0
7 changed files with 654 additions and 2 deletions

View File

@@ -36,6 +36,11 @@ export interface Campaign {
target_platform: string | null;
tags: string[];
created_at: string | null;
is_recurring: boolean;
recurrence_pattern: string | null;
next_run_at: string | null;
last_run_at: string | null;
parent_campaign_id: string | null;
tests: CampaignTest[];
progress: CampaignProgress;
}
@@ -151,3 +156,40 @@ export async function generateCampaignFromThreatActor(actorId: string): Promise<
const { data } = await client.post<Campaign>(`/campaigns/from-threat-actor/${actorId}`);
return data;
}
// ── Scheduling ─────────────────────────────────────────────────────
export interface SchedulePayload {
is_recurring: boolean;
recurrence_pattern?: string;
next_run_at?: string;
}
export interface CampaignHistoryEntry {
id: string;
name: string;
status: string;
test_count: number;
completion_pct: number;
created_at: string | null;
completed_at: string | null;
}
/** Configure recurrence scheduling for a campaign. */
export async function scheduleCampaign(
campaignId: string,
payload: SchedulePayload,
): Promise<Campaign> {
const { data } = await client.patch<Campaign>(`/campaigns/${campaignId}/schedule`, payload);
return data;
}
/** Get execution history (child campaigns) for a recurring campaign. */
export async function getCampaignHistory(campaignId: string): Promise<{
campaign_id: string;
campaign_name: string;
items: CampaignHistoryEntry[];
}> {
const { data } = await client.get(`/campaigns/${campaignId}/history`);
return data;
}

View File

@@ -13,13 +13,18 @@ import {
Zap,
Calendar,
Clock,
Repeat,
History,
} from "lucide-react";
import {
getCampaign,
activateCampaign,
completeCampaign,
removeTestFromCampaign,
scheduleCampaign,
getCampaignHistory,
type Campaign,
type CampaignHistoryEntry,
} from "../api/campaigns";
import { useAuth } from "../context/AuthContext";
import CampaignTimeline from "../components/CampaignTimeline";
@@ -101,6 +106,47 @@ export default function CampaignDetailPage() {
onError: (err: Error) => showToast(err.message, "error"),
});
const scheduleMutation = useMutation({
mutationFn: (payload: { is_recurring: boolean; recurrence_pattern?: string; next_run_at?: string }) =>
scheduleCampaign(campaignId!, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["campaign", campaignId] });
showToast("Schedule updated", "success");
},
onError: (err: Error) => showToast(err.message, "error"),
});
const { data: historyData } = useQuery({
queryKey: ["campaign-history", campaignId],
queryFn: () => getCampaignHistory(campaignId!),
enabled: !!campaignId && !!campaign?.is_recurring,
});
const [schedRecurring, setSchedRecurring] = useState(false);
const [schedPattern, setSchedPattern] = useState("monthly");
const [schedNextRun, setSchedNextRun] = useState("");
// Sync scheduling state from campaign when loaded
useState(() => {
if (campaign) {
setSchedRecurring(campaign.is_recurring || false);
setSchedPattern(campaign.recurrence_pattern || "monthly");
setSchedNextRun(campaign.next_run_at ? campaign.next_run_at.slice(0, 16) : "");
}
});
const handleScheduleSave = () => {
if (schedRecurring) {
scheduleMutation.mutate({
is_recurring: true,
recurrence_pattern: schedPattern,
next_run_at: schedNextRun || undefined,
});
} else {
scheduleMutation.mutate({ is_recurring: false });
}
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return "\u2014";
return new Date(dateStr).toLocaleDateString("en-US", {
@@ -291,6 +337,159 @@ export default function CampaignDetailPage() {
<CampaignTimeline tests={campaign.tests} />
</div>
{/* Scheduling Panel */}
{(canManage || campaign.is_recurring) && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center gap-2">
<Repeat className="h-5 w-5 text-cyan-400" />
<h2 className="text-lg font-semibold text-white">Scheduling</h2>
{campaign.next_run_at && (
<span className="ml-auto inline-flex items-center gap-1.5 rounded-full border border-cyan-500/30 bg-cyan-900/30 px-3 py-1 text-xs font-medium text-cyan-400">
<Calendar className="h-3.5 w-3.5" />
Next run: {formatDate(campaign.next_run_at)}
</span>
)}
</div>
{canManage && (
<div className="space-y-4">
{/* Recurring toggle */}
<label className="flex items-center gap-3 cursor-pointer">
<div
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
schedRecurring ? "bg-cyan-600" : "bg-gray-700"
}`}
onClick={() => setSchedRecurring(!schedRecurring)}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
schedRecurring ? "translate-x-5" : "translate-x-0"
}`}
/>
</div>
<span className="text-sm font-medium text-gray-300">
Recurring Campaign
</span>
</label>
{schedRecurring && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">
Frequency
</label>
<select
value={schedPattern}
onChange={(e) => setSchedPattern(e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white focus:border-cyan-500 focus:outline-none"
>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-gray-500">
Next Run At
</label>
<input
type="datetime-local"
value={schedNextRun}
onChange={(e) => setSchedNextRun(e.target.value)}
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white focus:border-cyan-500 focus:outline-none"
/>
</div>
</div>
)}
<button
onClick={handleScheduleSave}
disabled={scheduleMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{scheduleMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Save Schedule
</button>
</div>
)}
{!canManage && campaign.is_recurring && (
<div className="text-sm text-gray-400">
This campaign runs <span className="text-white font-medium">{campaign.recurrence_pattern}</span>.
{campaign.last_run_at && (
<span className="ml-1">Last run: {formatDate(campaign.last_run_at)}</span>
)}
</div>
)}
</div>
)}
{/* Execution History */}
{campaign.is_recurring && historyData && historyData.items.length > 0 && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center gap-2">
<History className="h-5 w-5 text-gray-400" />
<h2 className="text-lg font-semibold text-white">
Execution History ({historyData.items.length})
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="pb-3 pr-4 font-medium text-gray-400">Date</th>
<th className="pb-3 px-4 font-medium text-gray-400">Name</th>
<th className="pb-3 px-4 font-medium text-gray-400">Tests</th>
<th className="pb-3 px-4 font-medium text-gray-400">Progress</th>
<th className="pb-3 px-4 font-medium text-gray-400">Status</th>
</tr>
</thead>
<tbody>
{historyData.items.map((entry: CampaignHistoryEntry) => (
<tr
key={entry.id}
className="border-b border-gray-800/50 hover:bg-gray-800/30 transition-colors cursor-pointer"
onClick={() => navigate(`/campaigns/${entry.id}`)}
>
<td className="py-3 pr-4 text-xs text-gray-400">
{formatDate(entry.created_at)}
</td>
<td className="py-3 px-4 text-sm text-gray-200">
{entry.name}
</td>
<td className="py-3 px-4 text-sm text-gray-400">
{entry.test_count}
</td>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<div className="h-2 w-20 rounded-full bg-gray-800 overflow-hidden">
<div
className={`h-full rounded-full ${
entry.completion_pct === 100 ? "bg-green-500" : "bg-cyan-500"
}`}
style={{ width: `${entry.completion_pct}%` }}
/>
</div>
<span className="text-xs text-gray-400">{entry.completion_pct}%</span>
</div>
</td>
<td className="py-3 px-4">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
statusColors[entry.status] || statusColors.draft
}`}
>
{entry.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Tests Table */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">