feat(phase-31): add campaign scheduling and recurring automation (T-233 to T-234)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user