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

@@ -0,0 +1,58 @@
"""add_campaign_scheduling
Revision ID: b017scheduling
Revises: b016retests
Create Date: 2026-02-10 02:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = "b017scheduling"
down_revision: Union[str, None] = "b016retests"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"campaigns",
sa.Column("is_recurring", sa.Boolean, server_default="false", nullable=False),
)
op.add_column(
"campaigns",
sa.Column("recurrence_pattern", sa.String, nullable=True),
)
op.add_column(
"campaigns",
sa.Column("next_run_at", sa.DateTime, nullable=True),
)
op.add_column(
"campaigns",
sa.Column("last_run_at", sa.DateTime, nullable=True),
)
op.add_column(
"campaigns",
sa.Column(
"parent_campaign_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("campaigns.id", ondelete="SET NULL"),
nullable=True,
),
)
op.create_index("ix_campaigns_next_run", "campaigns", ["next_run_at"])
op.create_index("ix_campaigns_parent", "campaigns", ["parent_campaign_id"])
def downgrade() -> None:
op.drop_index("ix_campaigns_parent", table_name="campaigns")
op.drop_index("ix_campaigns_next_run", table_name="campaigns")
op.drop_column("campaigns", "parent_campaign_id")
op.drop_column("campaigns", "last_run_at")
op.drop_column("campaigns", "next_run_at")
op.drop_column("campaigns", "recurrence_pattern")
op.drop_column("campaigns", "is_recurring")

View File

@@ -19,6 +19,7 @@ from app.services.mitre_sync_service import sync_mitre
from app.services.intel_service import scan_intel from app.services.intel_service import scan_intel
from app.services.notification_service import cleanup_old_notifications from app.services.notification_service import cleanup_old_notifications
from app.services.snapshot_service import create_snapshot, cleanup_old_snapshots from app.services.snapshot_service import create_snapshot, cleanup_old_snapshots
from app.services.campaign_scheduler_service import check_and_run_recurring_campaigns
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -80,6 +81,19 @@ def _run_weekly_snapshot() -> None:
db.close() db.close()
def _run_recurring_campaigns() -> None:
"""Check and run any due recurring campaigns."""
logger.info("Scheduled recurring campaigns check starting...")
db = SessionLocal()
try:
spawned = check_and_run_recurring_campaigns(db)
logger.info("Recurring campaigns check finished — spawned %d campaigns", spawned)
except Exception:
logger.exception("Recurring campaigns check failed")
finally:
db.close()
def _run_intel_scan() -> None: def _run_intel_scan() -> None:
"""Execute an intel scan inside its own DB session.""" """Execute an intel scan inside its own DB session."""
logger.info("Scheduled intel scan job starting...") logger.info("Scheduled intel scan job starting...")
@@ -142,8 +156,17 @@ def start_scheduler() -> None:
name="Weekly coverage snapshot (Sundays 00:00)", name="Weekly coverage snapshot (Sundays 00:00)",
replace_existing=True, replace_existing=True,
) )
scheduler.add_job(
_run_recurring_campaigns,
trigger="interval",
hours=24,
id="recurring_campaigns",
name="Recurring campaigns check (daily)",
replace_existing=True,
)
scheduler.start() scheduler.start()
logger.info( logger.info(
"Background scheduler started — mitre_sync (24h), intel_scan (7d), " "Background scheduler started — mitre_sync (24h), intel_scan (7d), "
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00)" "notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
"recurring_campaigns (daily)"
) )

View File

@@ -8,7 +8,7 @@ import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import ( from sqlalchemy import (
Column, String, Text, Integer, DateTime, Column, String, Text, Integer, Boolean, DateTime,
ForeignKey, Index, ForeignKey, Index,
) )
from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.dialects.postgresql import UUID, JSONB
@@ -56,6 +56,17 @@ class Campaign(Base):
tags = Column(JSONB, nullable=True, default=[]) tags = Column(JSONB, nullable=True, default=[])
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
# Recurring scheduling fields
is_recurring = Column(Boolean, default=False)
recurrence_pattern = Column(String, nullable=True) # weekly, monthly, quarterly
next_run_at = Column(DateTime, nullable=True)
last_run_at = Column(DateTime, nullable=True)
parent_campaign_id = Column(
UUID(as_uuid=True),
ForeignKey("campaigns.id", ondelete="SET NULL"),
nullable=True,
)
# Relationships # Relationships
threat_actor = relationship("ThreatActor") threat_actor = relationship("ThreatActor")
creator = relationship("User", foreign_keys=[created_by]) creator = relationship("User", foreign_keys=[created_by])
@@ -65,12 +76,23 @@ class Campaign(Base):
cascade="all, delete-orphan", cascade="all, delete-orphan",
order_by="CampaignTest.order_index", order_by="CampaignTest.order_index",
) )
parent_campaign = relationship(
"Campaign",
remote_side="Campaign.id",
foreign_keys=[parent_campaign_id],
)
child_campaigns = relationship(
"Campaign",
foreign_keys=[parent_campaign_id],
back_populates="parent_campaign",
)
__table_args__ = ( __table_args__ = (
Index('ix_campaigns_status', 'status'), Index('ix_campaigns_status', 'status'),
Index('ix_campaigns_type', 'type'), Index('ix_campaigns_type', 'type'),
Index('ix_campaigns_threat_actor', 'threat_actor_id'), Index('ix_campaigns_threat_actor', 'threat_actor_id'),
Index('ix_campaigns_created_by', 'created_by'), Index('ix_campaigns_created_by', 'created_by'),
Index('ix_campaigns_next_run', 'next_run_at'),
) )

View File

@@ -25,6 +25,7 @@ from app.services.campaign_service import (
get_campaign_progress, get_campaign_progress,
generate_campaign_from_threat_actor, generate_campaign_from_threat_actor,
) )
from app.services.campaign_scheduler_service import calculate_next_run
from app.services.notification_service import create_notification from app.services.notification_service import create_notification
from app.services.audit_service import log_action from app.services.audit_service import log_action
@@ -59,6 +60,12 @@ class AddTestPayload(BaseModel):
phase: Optional[str] = None phase: Optional[str] = None
class SchedulePayload(BaseModel):
is_recurring: bool
recurrence_pattern: Optional[str] = None # weekly, monthly, quarterly
next_run_at: Optional[str] = None
# ── Helpers ────────────────────────────────────────────────────────── # ── Helpers ──────────────────────────────────────────────────────────
def _serialize_campaign(db: Session, campaign: Campaign) -> dict: def _serialize_campaign(db: Session, campaign: Campaign) -> dict:
@@ -107,6 +114,11 @@ def _serialize_campaign(db: Session, campaign: Campaign) -> dict:
"target_platform": campaign.target_platform, "target_platform": campaign.target_platform,
"tags": campaign.tags or [], "tags": campaign.tags or [],
"created_at": campaign.created_at.isoformat() if campaign.created_at else None, "created_at": campaign.created_at.isoformat() if campaign.created_at else None,
"is_recurring": campaign.is_recurring or False,
"recurrence_pattern": campaign.recurrence_pattern,
"next_run_at": campaign.next_run_at.isoformat() if campaign.next_run_at else None,
"last_run_at": campaign.last_run_at.isoformat() if campaign.last_run_at else None,
"parent_campaign_id": str(campaign.parent_campaign_id) if campaign.parent_campaign_id else None,
"tests": tests, "tests": tests,
"progress": progress, "progress": progress,
} }
@@ -128,6 +140,10 @@ def _serialize_campaign_summary(db: Session, campaign: Campaign) -> dict:
"target_platform": campaign.target_platform, "target_platform": campaign.target_platform,
"tags": campaign.tags or [], "tags": campaign.tags or [],
"created_at": campaign.created_at.isoformat() if campaign.created_at else None, "created_at": campaign.created_at.isoformat() if campaign.created_at else None,
"is_recurring": campaign.is_recurring or False,
"recurrence_pattern": campaign.recurrence_pattern,
"next_run_at": campaign.next_run_at.isoformat() if campaign.next_run_at else None,
"last_run_at": campaign.last_run_at.isoformat() if campaign.last_run_at else None,
"test_count": progress["total"], "test_count": progress["total"],
"completion_pct": progress["completion_pct"], "completion_pct": progress["completion_pct"],
} }
@@ -522,3 +538,102 @@ def generate_campaign_from_actor(
) )
return _serialize_campaign(db, campaign) return _serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# PATCH /campaigns/{id}/schedule — Configure recurrence
# ---------------------------------------------------------------------------
@router.patch("/{campaign_id}/schedule")
def schedule_campaign(
campaign_id: str,
payload: SchedulePayload,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("admin")),
):
"""Configure or update the recurrence schedule for a campaign.
Only the campaign creator or admin can change scheduling.
"""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
# Check ownership or admin
if str(campaign.created_by) != str(current_user.id) and current_user.role != "admin":
raise HTTPException(status_code=403, detail="Only the creator or admin can configure scheduling")
campaign.is_recurring = payload.is_recurring
if payload.is_recurring:
if payload.recurrence_pattern not in ("weekly", "monthly", "quarterly"):
raise HTTPException(
status_code=400,
detail="recurrence_pattern must be 'weekly', 'monthly', or 'quarterly'",
)
campaign.recurrence_pattern = payload.recurrence_pattern
if payload.next_run_at:
campaign.next_run_at = datetime.fromisoformat(payload.next_run_at.replace("Z", "+00:00").replace("+00:00", ""))
elif not campaign.next_run_at:
campaign.next_run_at = calculate_next_run(datetime.utcnow(), payload.recurrence_pattern)
else:
campaign.recurrence_pattern = None
campaign.next_run_at = None
db.commit()
db.refresh(campaign)
log_action(
db,
user_id=current_user.id,
action="schedule_campaign",
entity_type="campaign",
entity_id=campaign.id,
details={
"is_recurring": campaign.is_recurring,
"recurrence_pattern": campaign.recurrence_pattern,
"next_run_at": campaign.next_run_at.isoformat() if campaign.next_run_at else None,
},
)
return _serialize_campaign(db, campaign)
# ---------------------------------------------------------------------------
# GET /campaigns/{id}/history — Execution history (child campaigns)
# ---------------------------------------------------------------------------
@router.get("/{campaign_id}/history")
def get_campaign_history(
campaign_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all child campaigns (execution history) of a recurring campaign."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
if not campaign:
raise HTTPException(status_code=404, detail="Campaign not found")
children = (
db.query(Campaign)
.filter(Campaign.parent_campaign_id == campaign_id)
.order_by(Campaign.created_at.desc())
.all()
)
return {
"campaign_id": str(campaign.id),
"campaign_name": campaign.name,
"items": [
{
"id": str(child.id),
"name": child.name,
"status": child.status,
"test_count": db.query(CampaignTest).filter(CampaignTest.campaign_id == child.id).count(),
"completion_pct": get_campaign_progress(db, child.id)["completion_pct"],
"created_at": child.created_at.isoformat() if child.created_at else None,
"completed_at": child.completed_at.isoformat() if child.completed_at else None,
}
for child in children
],
}

View File

@@ -0,0 +1,193 @@
"""Campaign scheduler service — recurring campaign execution.
Handles checking which recurring campaigns are due, cloning them with
fresh tests, and computing the next run date.
"""
import logging
import uuid
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from app.models.campaign import Campaign, CampaignTest
from app.models.test import Test
from app.models.enums import TestState
from app.services.notification_service import create_notification
from app.services.audit_service import log_action
from app.models.user import User
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Next-run calculation
# ---------------------------------------------------------------------------
def calculate_next_run(current_date: datetime, pattern: str) -> datetime:
"""Compute the next run date from *current_date* and a recurrence pattern.
Supported patterns:
- ``weekly`` : +7 days
- ``monthly`` : +30 days
- ``quarterly``: +90 days
"""
offsets = {
"weekly": timedelta(days=7),
"monthly": timedelta(days=30),
"quarterly": timedelta(days=90),
}
return current_date + offsets.get(pattern, timedelta(days=30))
# ---------------------------------------------------------------------------
# Clone a campaign
# ---------------------------------------------------------------------------
def _clone_campaign(db: Session, original: Campaign) -> Campaign:
"""Create a new child campaign from a recurring template.
1. Clone the campaign with a date-stamped name.
2. For each ``CampaignTest`` in the original, create a new ``Test``
with the same base data (in ``draft`` state) and link it.
3. Activate the new campaign.
"""
now = datetime.utcnow()
run_label = now.strftime("%Y-%m-%d")
child = Campaign(
name=f"{original.name} (Run {run_label})",
description=original.description,
type=original.type,
threat_actor_id=original.threat_actor_id,
status="active",
created_by=original.created_by,
target_platform=original.target_platform,
tags=original.tags or [],
parent_campaign_id=original.id,
)
db.add(child)
db.flush() # get child.id
# Clone each campaign_test with a fresh Test
original_cts = (
db.query(CampaignTest)
.filter(CampaignTest.campaign_id == original.id)
.order_by(CampaignTest.order_index)
.all()
)
for ct in original_cts:
src_test = ct.test
if not src_test:
continue
new_test = Test(
technique_id=src_test.technique_id,
name=src_test.name,
description=src_test.description,
platform=src_test.platform,
procedure_text=src_test.procedure_text,
tool_used=src_test.tool_used,
created_by=original.created_by,
state=TestState.draft,
)
db.add(new_test)
db.flush() # get new_test.id
new_ct = CampaignTest(
campaign_id=child.id,
test_id=new_test.id,
order_index=ct.order_index,
phase=ct.phase,
# depends_on is not copied — would need ID remapping
)
db.add(new_ct)
db.flush()
return child
# ---------------------------------------------------------------------------
# Check and run recurring campaigns (daily job)
# ---------------------------------------------------------------------------
def check_and_run_recurring_campaigns(db: Session) -> int:
"""Check all recurring campaigns and clone any that are due.
Returns the number of campaigns spawned.
"""
now = datetime.utcnow()
due_campaigns = (
db.query(Campaign)
.filter(
Campaign.is_recurring == True, # noqa: E712
Campaign.next_run_at <= now,
)
.all()
)
spawned = 0
for campaign in due_campaigns:
try:
child = _clone_campaign(db, campaign)
# Update the original's scheduling fields
campaign.last_run_at = now
campaign.next_run_at = calculate_next_run(now, campaign.recurrence_pattern or "monthly")
db.commit()
db.refresh(child)
# Audit
log_action(
db,
user_id=campaign.created_by,
action="recurring_campaign_run",
entity_type="campaign",
entity_id=child.id,
details={
"parent_campaign_id": str(campaign.id),
"child_campaign_name": child.name,
"pattern": campaign.recurrence_pattern,
},
)
# Notify
if campaign.created_by:
create_notification(
db,
user_id=campaign.created_by,
type="recurring_campaign_run",
title="Recurring campaign executed",
message=f'Campaign "{child.name}" was automatically created from recurring template "{campaign.name}".',
entity_type="campaign",
entity_id=child.id,
)
# Notify red_tech users
red_techs = db.query(User).filter(User.role == "red_tech", User.is_active == True).all() # noqa: E712
for user in red_techs:
create_notification(
db,
user_id=user.id,
type="campaign_activated",
title="New recurring campaign active",
message=f'Campaign "{child.name}" is now active and ready for execution.',
entity_type="campaign",
entity_id=child.id,
)
spawned += 1
logger.info("Spawned child campaign '%s' from parent '%s'", child.name, campaign.name)
except Exception:
db.rollback()
logger.exception("Failed to run recurring campaign '%s'", campaign.name)
return spawned

View File

@@ -36,6 +36,11 @@ export interface Campaign {
target_platform: string | null; target_platform: string | null;
tags: string[]; tags: string[];
created_at: string | null; 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[]; tests: CampaignTest[];
progress: CampaignProgress; progress: CampaignProgress;
} }
@@ -151,3 +156,40 @@ export async function generateCampaignFromThreatActor(actorId: string): Promise<
const { data } = await client.post<Campaign>(`/campaigns/from-threat-actor/${actorId}`); const { data } = await client.post<Campaign>(`/campaigns/from-threat-actor/${actorId}`);
return data; 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, Zap,
Calendar, Calendar,
Clock, Clock,
Repeat,
History,
} from "lucide-react"; } from "lucide-react";
import { import {
getCampaign, getCampaign,
activateCampaign, activateCampaign,
completeCampaign, completeCampaign,
removeTestFromCampaign, removeTestFromCampaign,
scheduleCampaign,
getCampaignHistory,
type Campaign, type Campaign,
type CampaignHistoryEntry,
} from "../api/campaigns"; } from "../api/campaigns";
import { useAuth } from "../context/AuthContext"; import { useAuth } from "../context/AuthContext";
import CampaignTimeline from "../components/CampaignTimeline"; import CampaignTimeline from "../components/CampaignTimeline";
@@ -101,6 +106,47 @@ export default function CampaignDetailPage() {
onError: (err: Error) => showToast(err.message, "error"), 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) => { const formatDate = (dateStr: string | null) => {
if (!dateStr) return "\u2014"; if (!dateStr) return "\u2014";
return new Date(dateStr).toLocaleDateString("en-US", { return new Date(dateStr).toLocaleDateString("en-US", {
@@ -291,6 +337,159 @@ export default function CampaignDetailPage() {
<CampaignTimeline tests={campaign.tests} /> <CampaignTimeline tests={campaign.tests} />
</div> </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 */} {/* Tests Table */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6"> <div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">