import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertCircle,
Archive,
ChevronDown,
ChevronRight,
FileText,
Loader2,
Play,
RefreshCw,
Square,
Trash2,
} from 'lucide-react';
import type {
CronJobOverview,
CronJobsOverviewResponse,
DiscoveryPlanOverview,
DiscoveryPlanStatus,
Project,
ProjectDiscoveryPlansResponse,
WorkCycleOverview,
} from '../../types/app';
import { api } from '../../utils/api';
import { cn } from '../../lib/utils.js';
const POLL_INTERVAL_MS = 15_000;
type PlanDisplayStatus =
| 'created'
| 'preparingWorkspace'
| 'executing'
| 'completedWaiting'
| 'failed'
| 'archived';
function mapPlanStatus(status: DiscoveryPlanStatus): PlanDisplayStatus {
switch (status) {
case 'ready':
return 'created';
case 'queued':
return 'preparingWorkspace';
case 'running':
return 'executing';
case 'completed':
return 'completedWaiting';
case 'failed':
return 'failed';
case 'archived':
return 'archived';
default:
return 'created';
}
}
const PLAN_STATUS_STYLE: Record<PlanDisplayStatus, string> = {
created: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
preparingWorkspace: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
executing: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
completedWaiting: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
failed: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
archived: 'bg-neutral-100 text-neutral-500 dark:bg-neutral-800 dark:text-neutral-400',
};
const PLAN_STATUS_LABEL: Record<PlanDisplayStatus, { key: string; defaultValue: string }> = {
created: { key: 'plansCron.status.created', defaultValue: 'Created' },
preparingWorkspace: { key: 'plansCron.status.preparingWorkspace', defaultValue: 'Preparing Workspace' },
executing: { key: 'plansCron.status.executing', defaultValue: 'Executing' },
completedWaiting: { key: 'plansCron.status.completedWaiting', defaultValue: 'Completed' },
failed: { key: 'plansCron.status.failed', defaultValue: 'Failed' },
archived: { key: 'plansCron.status.archived', defaultValue: 'Archived' },
};
const CRON_STATUS_STYLE: Record<'scheduled' | 'running', string> = {
scheduled: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
running: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300',
};
const CRON_STATUS_LABEL: Record<'scheduled' | 'running', { key: string; defaultValue: string }> = {
scheduled: { key: 'plansCron.status.scheduled', defaultValue: 'Scheduled' },
running: { key: 'plansCron.status.running', defaultValue: 'Running' },
};
type UnifiedItem =
| { kind: 'plan'; data: DiscoveryPlanOverview; projectName: string; projectDisplayName: string; projectKey: string }
| { kind: 'cron'; data: CronJobOverview; projectName: string; projectDisplayName: string; projectKey: string };
function formatAbsoluteTime(iso: string | number): string {
const parsed = typeof iso === 'number' ? iso : Date.parse(iso);
if (Number.isNaN(parsed)) return '';
return new Date(parsed).toLocaleString([], {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
const COL = {
title: 'min-w-0 flex-1 max-w-[380px]',
createdAt: 'w-[150px] shrink-0',
status: 'w-[160px] shrink-0',
actions: 'w-[140px] shrink-0',
} as const;
type PlansAndCronJobsProps = {
onApplyWorkCycle?: (projectName: string, cycleId: string) => Promise<void>;
onOpenPlanDetail?: (planId: string, projectName: string, projectDisplayName: string, sourceRunId: string, projectKey: string) => void;
};
export default function PlansAndCronJobs({ onApplyWorkCycle, onOpenPlanDetail }: PlansAndCronJobsProps) {
const { t } = useTranslation('alwaysOn');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [plansByProject, setPlansByProject] = useState<Map<string, DiscoveryPlanOverview[]>>(new Map());
const [cyclesByProject, setCyclesByProject] = useState<Map<string, WorkCycleOverview[]>>(new Map());
const [cronJobs, setCronJobs] = useState<CronJobOverview[]>([]);
const [collapsedProjects, setCollapsedProjects] = useState<Set<string>>(new Set());
const [cycleBusy, setCycleBusy] = useState<string | null>(null);
const [confirmingArchiveCycle, setConfirmingArchiveCycle] = useState<string | null>(null);
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(new Set());
const toggleSection = (key: string) => {
setCollapsedSections((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const projectsRes = await api.projects();
if (!projectsRes.ok) throw new Error(`Projects: HTTP ${projectsRes.status}`);
const projectsList: Project[] = await projectsRes.json();
setProjects(projectsList);
const [cronRes, ...mixedResults] = await Promise.all([
api.allCronJobs(),
...projectsList.flatMap((p) => [
api.projectDiscoveryPlans(p.name),
api.projectWorkCycles(p.name),
]),
]);
if (cronRes.ok) {
const cronPayload = (await cronRes.json()) as CronJobsOverviewResponse;
setCronJobs(Array.isArray(cronPayload.jobs) ? cronPayload.jobs : []);
} else {
setCronJobs([]);
}
const newPlansByProject = new Map<string, DiscoveryPlanOverview[]>();
const newCyclesByProject = new Map<string, WorkCycleOverview[]>();
for (let i = 0; i < projectsList.length; i++) {
const planRes = mixedResults[i * 2];
const cycleRes = mixedResults[i * 2 + 1];
if (planRes && planRes.ok) {
const payload = (await planRes.json()) as ProjectDiscoveryPlansResponse;
if (Array.isArray(payload.plans) && payload.plans.length > 0) {
newPlansByProject.set(projectsList[i]!.name, payload.plans);
}
}
if (cycleRes && cycleRes.ok) {
const payload = (await cycleRes.json()) as { cycles?: WorkCycleOverview[] };
if (Array.isArray(payload.cycles) && payload.cycles.length > 0) {
newCyclesByProject.set(projectsList[i]!.name, payload.cycles);
}
}
}
setPlansByProject(newPlansByProject);
setCyclesByProject(newCyclesByProject);
} catch (caught) {
setError(caught instanceof Error ? caught.message : String(caught));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
const timer = window.setInterval(() => void refresh(), POLL_INTERVAL_MS);
return () => window.clearInterval(timer);
}, [refresh]);
const grouped = useMemo(() => {
const projectMap = new Map<string, Project>();
for (const p of projects) projectMap.set(p.name, p);
const result = new Map<string, { displayName: string; items: UnifiedItem[] }>();
for (const [projectName, plans] of plansByProject) {
const project = projectMap.get(projectName);
const displayName = project?.displayName || projectName;
if (!result.has(projectName)) {
result.set(projectName, { displayName, items: [] });
}
for (const plan of plans) {
result.get(projectName)!.items.push({
kind: 'plan',
data: plan,
projectName,
projectDisplayName: displayName,
projectKey: project?.fullPath || '',
});
}
}
const activeCronJobs = cronJobs.filter(
(j) => j.status === 'scheduled' || j.status === 'running',
);
const projectKeyToName = new Map<string, string>();
for (const p of projects) {
projectKeyToName.set(p.name, p.name);
if (p.fullPath) projectKeyToName.set(p.fullPath, p.name);
}
for (const job of activeCronJobs) {
const projectName = job.projectKey
? (projectKeyToName.get(job.projectKey) || job.projectKey)
: '__unassigned__';
const project = projectMap.get(projectName);
const displayName = project?.displayName || (projectName === '__unassigned__' ? '' : projectName);
if (!result.has(projectName)) {
result.set(projectName, { displayName, items: [] });
}
result.get(projectName)!.items.push({
kind: 'cron',
data: job,
projectName,
projectDisplayName: displayName,
projectKey: project?.fullPath || '',
});
}
for (const group of result.values()) {
group.items.sort((a, b) => {
const timeA = Date.parse(a.data.createdAt) || 0;
const timeB = Date.parse(b.data.createdAt) || 0;
return timeB - timeA;
});
}
return result;
}, [projects, plansByProject, cronJobs]);
const totalItems = useMemo(() => {
let count = 0;
for (const group of grouped.values()) count += group.items.length;
return count;
}, [grouped]);
const toggleProject = (key: string) => {
setCollapsedProjects((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
return (
<div className="w-full space-y-5 px-8 py-5">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h2 className="text-[20px] font-semibold tracking-tight text-neutral-900 dark:text-neutral-100">
{t('plansCron.title', { defaultValue: 'Plans & Cron Jobs' })}
</h2>
<p className="mt-0.5 text-[13px] text-neutral-500 dark:text-neutral-400">
{t('plansCron.subtitle', { defaultValue: 'All plans and cron jobs across projects.' })}
</p>
</div>
<button
type="button"
onClick={() => void refresh()}
disabled={loading}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-neutral-200 px-2.5 text-xxs text-neutral-600 transition hover:bg-neutral-50 disabled:opacity-50 dark:border-neutral-800 dark:text-neutral-300 dark:hover:bg-neutral-900"
>
<RefreshCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} strokeWidth={1.75} />
<span>{t('actions.refresh', { defaultValue: 'Refresh' })}</span>
</button>
</div>
{error ? (
<div className="flex items-center gap-2 text-xxs text-red-500">
<AlertCircle className="h-3.5 w-3.5" strokeWidth={1.75} />
<span>{error}</span>
</div>
) : null}
{loading && totalItems === 0 ? (
<div className="flex items-center gap-2 py-8 text-[13px] text-neutral-500 dark:text-neutral-400">
<Loader2 className="h-3.5 w-3.5 animate-spin" strokeWidth={1.75} />
<span>{t('plansCron.loading', { defaultValue: 'Loading plans and cron jobs…' })}</span>
</div>
) : totalItems === 0 && !loading ? (
<div className="py-8 text-center text-[13px] text-neutral-500 dark:text-neutral-400">
<FileText className="mx-auto mb-2 h-8 w-8 text-neutral-300 dark:text-neutral-600" strokeWidth={1.25} />
{t('plansCron.empty', { defaultValue: 'No plans or cron jobs found.' })}
</div>
) : (
<div className="space-y-4">
{[...grouped.entries()].map(([projectKey, { displayName, items }]) => {
const isCollapsed = collapsedProjects.has(projectKey);
const label =
projectKey === '__unassigned__'
? t('plansCron.unassigned', { defaultValue: 'Unassigned' })
: displayName;
return (
<div
key={projectKey}
className="overflow-hidden rounded-xl border border-neutral-200 bg-white dark:border-neutral-800 dark:bg-neutral-950"
>
{/* Project group header */}
<button
type="button"
onClick={() => toggleProject(projectKey)}
className="flex w-full items-center gap-2 px-5 py-3 text-left transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-900"
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4 shrink-0 text-neutral-400" strokeWidth={1.75} />
) : (
<ChevronDown className="h-4 w-4 shrink-0 text-neutral-400" strokeWidth={1.75} />
)}
<span className="text-[13px] font-semibold text-neutral-900 dark:text-neutral-100">
{label}
</span>
<span className="ml-auto text-xxs tabular-nums text-neutral-400 dark:text-neutral-500">
{items.length}
</span>
</button>
{!isCollapsed && (() => {
const planItems = items.filter((i) => i.kind === 'plan');
const cronItems = items.filter((i) => i.kind === 'cron');
const cycles = cyclesByProject.get(projectKey) ?? [];
const activeCycle = cycles.find((c) => c.status === 'active' || c.status === 'applying');
const hasCompletedPlan = planItems.some((p) => (p.data as DiscoveryPlanOverview).status === 'completed');
const canApply = !!activeCycle && activeCycle.status === 'active' && hasCompletedPlan;
const canArchive = !!activeCycle && activeCycle.status === 'active';
const isApplying = activeCycle?.status === 'applying';
const busy = !!activeCycle && cycleBusy === activeCycle.id;
const handleApply = async () => {
if (!activeCycle || busy) return;
setCycleBusy(activeCycle.id);
try {
if (onApplyWorkCycle) {
await onApplyWorkCycle(projectKey, activeCycle.id);
} else {
const res = await api.applyWorkCycle(projectKey, activeCycle.id);
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new Error(body?.error || `HTTP ${res.status}`);
}
}
await refresh();
} catch {
// Visible via refresh.
} finally {
setCycleBusy(null);
}
};
const handleArchive = async () => {
if (!activeCycle || busy) return;
setCycleBusy(activeCycle.id);
try {
const res = await api.archiveWorkCycle(projectKey, activeCycle.id);
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new Error(body?.error || `HTTP ${res.status}`);
}
await refresh();
} catch {
// Visible via refresh.
} finally {
setCycleBusy(null);
setConfirmingArchiveCycle(null);
}
};
const confirmingArchive = !!activeCycle && confirmingArchiveCycle === activeCycle.id;
return (
<>
{/* Plans sub-section */}
{planItems.length > 0 && (
<SubSection
sectionKey={`${projectKey}::plans`}
label={`${t('plansCron.type.plan', { defaultValue: 'Plan' })} (${planItems.length})`}
collapsedSections={collapsedSections}
toggleSection={toggleSection}
actions={
<div className="flex items-center gap-1.5">
{isApplying && (
<span className="inline-flex items-center gap-1 text-xxs text-sky-600 dark:text-sky-400">
<Loader2 className="h-3 w-3 animate-spin" strokeWidth={2} />
{t('plansCron.cycleStatus.applying', { defaultValue: 'Applying…' })}
</span>
)}
{canApply && !isApplying && (
<button
type="button"
disabled={busy}
onClick={() => void handleApply()}
className="inline-flex h-7 items-center rounded-md bg-emerald-600 px-2.5 text-[11px] font-medium text-white transition hover:bg-emerald-700 disabled:opacity-50 dark:bg-emerald-700 dark:hover:bg-emerald-600"
>
{busy ? (
<Loader2 className="h-3 w-3 animate-spin" strokeWidth={2} />
) : (
t('plansCron.actions.applyCycle', { defaultValue: 'Apply All' })
)}
</button>
)}
{canArchive && !confirmingArchive && (
<button
type="button"
disabled={busy}
onClick={() => setConfirmingArchiveCycle(activeCycle!.id)}
className="inline-flex h-7 items-center rounded-md border border-neutral-200 px-2 text-neutral-500 transition hover:border-red-300 hover:text-red-600 disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-400 dark:hover:border-red-700 dark:hover:text-red-400"
title={t('plansCron.actions.archiveCycle', { defaultValue: 'Archive' })}
>
<Archive className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
)}
{confirmingArchive && (
<div className="flex items-center gap-1">
<button
type="button"
disabled={busy}
onClick={() => void handleArchive()}
className="inline-flex h-7 items-center rounded-md bg-red-600 px-2.5 text-[11px] font-medium text-white transition hover:bg-red-700 disabled:opacity-50"
>
{busy ? (
<Loader2 className="h-3 w-3 animate-spin" strokeWidth={2} />
) : (
t('plansCron.actions.archiveCycle', { defaultValue: 'Archive' })
)}
</button>
<button
type="button"
onClick={() => setConfirmingArchiveCycle(null)}
className="inline-flex h-7 items-center rounded-md border border-neutral-200 px-2 text-[11px] text-neutral-500 transition hover:bg-neutral-100 dark:border-neutral-700 dark:text-neutral-400 dark:hover:bg-neutral-800"
>
✕
</button>
</div>
)}
</div>
}
>
<ColumnHeaders t={t} />
<div className="divide-y divide-neutral-100 dark:divide-neutral-900">
{planItems.map((item) => (
<ItemRow
key={`plan-${item.data.id}`}
item={item}
t={t}
onRefresh={refresh}
onOpenPlanDetail={onOpenPlanDetail}
/>
))}
</div>
</SubSection>
)}
{/* Cron Jobs sub-section */}
{cronItems.length > 0 && (
<SubSection
sectionKey={`${projectKey}::crons`}
label={`${t('plansCron.type.cronJob', { defaultValue: 'Cron Jobs' })} (${cronItems.length})`}
collapsedSections={collapsedSections}
toggleSection={toggleSection}
>
<ColumnHeaders t={t} />
<div className="divide-y divide-neutral-100 dark:divide-neutral-900">
{cronItems.map((item) => (
<ItemRow
key={`cron-${item.data.id}`}
item={item}
t={t}
onRefresh={refresh}
onOpenPlanDetail={onOpenPlanDetail}
/>
))}
</div>
</SubSection>
)}
</>
);
})()}
</div>
);
})}
</div>
)}
</div>
);
}
function SubSection({
sectionKey,
label,
collapsedSections,
toggleSection,
actions,
children,
}: {
sectionKey: string;
label: string;
collapsedSections: Set<string>;
toggleSection: (key: string) => void;
actions?: React.ReactNode;
children: React.ReactNode;
}) {
const isCollapsed = collapsedSections.has(sectionKey);
return (
<>
<div className="flex items-center gap-2 border-t border-neutral-200 bg-neutral-50/80 px-5 py-2 dark:border-neutral-800 dark:bg-neutral-900/30">
<button
type="button"
onClick={() => toggleSection(sectionKey)}
className="flex items-center gap-1.5 text-xxs font-semibold uppercase tracking-wider text-neutral-500 transition-colors hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200"
>
{isCollapsed ? (
<ChevronRight className="h-3.5 w-3.5 shrink-0" strokeWidth={1.75} />
) : (
<ChevronDown className="h-3.5 w-3.5 shrink-0" strokeWidth={1.75} />
)}
{label}
</button>
{!isCollapsed && actions && <div className="ml-auto">{actions}</div>}
</div>
{!isCollapsed && children}
</>
);
}
function ColumnHeaders({ t }: { t: (key: string, opts?: Record<string, string>) => string }) {
return (
<div className="flex items-center gap-4 border-b border-neutral-200 bg-neutral-50 px-5 py-2 dark:border-neutral-800 dark:bg-neutral-900/50">
<div className={COL.title}>
<span className="text-xxs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
{t('plansCron.columns.title', { defaultValue: 'Title' })}
</span>
</div>
<div className={COL.createdAt}>
<span className="text-xxs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
{t('plansCron.columns.createdAt', { defaultValue: 'Created' })}
</span>
</div>
<div className={COL.status}>
<span className="text-xxs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
{t('plansCron.columns.status', { defaultValue: 'Status' })}
</span>
</div>
<div className={COL.actions}>
<span className="text-xxs font-medium uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
{t('plansCron.columns.actions', { defaultValue: 'Actions' })}
</span>
</div>
</div>
);
}
function ItemRow({
item,
t,
onRefresh,
onOpenPlanDetail,
}: {
item: UnifiedItem;
t: (key: string, opts?: Record<string, string>) => string;
onRefresh: () => Promise<void>;
onOpenPlanDetail?: (planId: string, projectName: string, projectDisplayName: string, sourceRunId: string, projectKey: string) => void;
}) {
const [busy, setBusy] = useState(false);
const isPlan = item.kind === 'plan';
const plan = isPlan ? item.data : null;
const job = isPlan ? null : item.data;
const title = isPlan ? (plan!.title || '—') : (job!.prompt || '—');
const fullTitle = isPlan ? (plan!.title || '') : (job!.prompt || '');
const createdAt = isPlan ? plan!.createdAt : job!.createdAt;
let statusLabel: string;
let statusStyle: string;
let displayStatus: PlanDisplayStatus | null = null;
if (isPlan) {
displayStatus = mapPlanStatus(plan!.status);
const meta = PLAN_STATUS_LABEL[displayStatus];
statusLabel = t(meta.key, { defaultValue: meta.defaultValue });
statusStyle = PLAN_STATUS_STYLE[displayStatus];
} else {
const cs: 'scheduled' | 'running' = job!.status === 'running' ? 'running' : 'scheduled';
const meta = CRON_STATUS_LABEL[cs];
statusLabel = t(meta.key, { defaultValue: meta.defaultValue });
statusStyle = CRON_STATUS_STYLE[cs];
}
const showRetry = isPlan && displayStatus === 'failed';
const handleRetry = async () => {
if (!plan || busy) return;
setBusy(true);
try {
const res = await api.executeProjectDiscoveryPlan(item.projectName, plan.id, { source: 'manual' });
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new Error(body?.error || `HTTP ${res.status}`);
}
await onRefresh();
} catch {
} finally {
setBusy(false);
}
};
const handleCronDelete = async () => {
if (!job || busy) return;
setBusy(true);
try {
const res = await api.cronDelete(job.id);
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new Error(body?.error || `HTTP ${res.status}`);
}
await onRefresh();
} catch {
} finally {
setBusy(false);
}
};
const handleCronRunNow = async () => {
if (!job || busy) return;
setBusy(true);
try {
const res = await api.cronRunNow(job.id);
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new Error(body?.error || `HTTP ${res.status}`);
}
await onRefresh();
} catch {
} finally {
setBusy(false);
}
};
const handleCronStop = async () => {
if (!job || busy) return;
setBusy(true);
try {
const res = await api.cronStop(job.id);
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new Error(body?.error || `HTTP ${res.status}`);
}
await onRefresh();
} catch {
} finally {
setBusy(false);
}
};
const cronIsRunning = !isPlan && job?.status === 'running';
return (
<div className="flex items-center gap-4 px-5 py-2.5 transition-colors hover:bg-neutral-50 dark:hover:bg-neutral-900/40">
{/* Title */}
<div className={cn(COL.title, 'truncate text-[13px] text-neutral-900 dark:text-neutral-100')} title={fullTitle}>
{isPlan && onOpenPlanDetail ? (
<button
type="button"
onClick={() => onOpenPlanDetail(plan!.id, item.projectName, item.projectDisplayName, (plan as DiscoveryPlanOverview).sourceRunId || (plan as DiscoveryPlanOverview).sourceDiscoverySessionId || '', item.projectKey)}
className="truncate text-left hover:underline"
>
{title}
</button>
) : (
title
)}
</div>
{/* Created */}
<div className={cn(COL.createdAt, 'font-mono text-xxs tabular-nums text-neutral-500 dark:text-neutral-400')}>
{formatAbsoluteTime(createdAt)}
</div>
{/* Status */}
<div className={COL.status}>
<span className={cn('inline-block rounded-full px-2 py-0.5 text-[11px] font-medium', statusStyle)}>
{statusLabel}
</span>
</div>
{/* Actions */}
<div className={cn(COL.actions, 'flex items-center gap-1.5')}>
{isPlan ? (
<>
{showRetry && (
<button
type="button"
disabled={busy}
onClick={() => void handleRetry()}
className="inline-flex h-7 items-center rounded-md bg-blue-600 px-2.5 text-[11px] font-medium text-white transition hover:bg-blue-700 disabled:opacity-50 dark:bg-blue-700 dark:hover:bg-blue-600"
>
{busy ? (
<Loader2 className="h-3 w-3 animate-spin" strokeWidth={2} />
) : (
t('plansCron.actions.retry', { defaultValue: 'Retry' })
)}
</button>
)}
</>
) : (
<>
{cronIsRunning ? (
<button
type="button"
disabled={busy}
onClick={() => void handleCronStop()}
className="inline-flex h-7 items-center gap-1 rounded-md bg-red-600 px-2.5 text-[11px] font-medium text-white transition hover:bg-red-700 disabled:opacity-50 dark:bg-red-700 dark:hover:bg-red-600"
>
{busy ? (
<Loader2 className="h-3 w-3 animate-spin" strokeWidth={2} />
) : (
<>
<Square className="h-3 w-3" strokeWidth={2} />
{t('plansCron.actions.stop', { defaultValue: 'Stop' })}
</>
)}
</button>
) : (
<button
type="button"
disabled={busy}
onClick={() => void handleCronRunNow()}
className="inline-flex h-7 items-center gap-1 rounded-md bg-blue-600 px-2.5 text-[11px] font-medium text-white transition hover:bg-blue-700 disabled:opacity-50 dark:bg-blue-700 dark:hover:bg-blue-600"
>
{busy ? (
<Loader2 className="h-3 w-3 animate-spin" strokeWidth={2} />
) : (
<>
<Play className="h-3 w-3" strokeWidth={2} />
{t('plansCron.actions.runNow', { defaultValue: 'Run Now' })}
</>
)}
</button>
)}
<button
type="button"
disabled={busy}
onClick={() => void handleCronDelete()}
className="inline-flex h-7 items-center rounded-md border border-neutral-200 px-2 text-neutral-500 transition hover:border-red-300 hover:text-red-600 disabled:opacity-50 dark:border-neutral-700 dark:text-neutral-400 dark:hover:border-red-700 dark:hover:text-red-400"
title={t('plansCron.actions.delete', { defaultValue: 'Delete' })}
>
<Trash2 className="h-3.5 w-3.5" strokeWidth={1.75} />
</button>
</>
)}
</div>
</div>
);
}