2026-05-20 21:32:46 -05:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { CheckCircle2, AlertCircle, Clock, XCircle, Loader2 } from "lucide-react";
|
|
|
|
|
import type { CronStatusResponse } from "@/api/generated/model";
|
|
|
|
|
import { DashboardSection } from "./DashboardSection";
|
|
|
|
|
import { DashboardEmptyState } from "./DashboardEmptyState";
|
|
|
|
|
|
|
|
|
|
interface GatewayCronPanelProps {
|
|
|
|
|
cron: CronStatusResponse | null;
|
|
|
|
|
isLoading?: boolean;
|
|
|
|
|
hasGateways: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type StatusKey = "ok" | "error" | "running" | "pending" | "disabled" | "unknown";
|
|
|
|
|
|
|
|
|
|
const STATUS_META: Record<StatusKey, { icon: React.ElementType; cls: string; label: string }> = {
|
|
|
|
|
ok: { icon: CheckCircle2, cls: "text-[color:var(--success)]", label: "OK" },
|
|
|
|
|
error: { icon: AlertCircle, cls: "text-[color:var(--danger)]", label: "Error" },
|
|
|
|
|
running: { icon: Loader2, cls: "text-[color:var(--accent)] animate-spin", label: "Running" },
|
|
|
|
|
pending: { icon: Clock, cls: "text-muted", label: "Pending" },
|
|
|
|
|
disabled: { icon: XCircle, cls: "text-muted opacity-40", label: "Disabled" },
|
|
|
|
|
unknown: { icon: Clock, cls: "text-muted", label: "Unknown" },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function fmtMs(ms: number | null | undefined): string {
|
|
|
|
|
if (ms == null) return "";
|
|
|
|
|
if (ms < 1000) return `${ms}ms`;
|
|
|
|
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function relativeTime(iso: string | null | undefined): string {
|
|
|
|
|
if (!iso) return "never";
|
|
|
|
|
try {
|
|
|
|
|
const diff = Date.now() - new Date(iso).getTime();
|
|
|
|
|
if (diff < 60_000) return "just now";
|
|
|
|
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
|
|
|
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
|
|
|
return `${Math.floor(diff / 86_400_000)}d ago`;
|
|
|
|
|
} catch {
|
|
|
|
|
return "—";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function GatewayCronPanel({
|
|
|
|
|
cron,
|
|
|
|
|
isLoading = false,
|
|
|
|
|
hasGateways,
|
|
|
|
|
}: GatewayCronPanelProps) {
|
|
|
|
|
if (!hasGateways) return null;
|
|
|
|
|
|
|
|
|
|
const jobs = cron?.jobs ?? [];
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DashboardSection
|
|
|
|
|
title="Cron Jobs"
|
|
|
|
|
action={jobs.length > 0 ? { label: `${jobs.length}`, href: "#" } : undefined}
|
|
|
|
|
>
|
|
|
|
|
{isLoading && !cron ? (
|
2026-05-27 19:22:55 -05:00
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
{[0, 1].map((i) => (
|
|
|
|
|
<div
|
|
|
|
|
key={i}
|
|
|
|
|
className="flex min-w-0 items-start gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2"
|
|
|
|
|
>
|
|
|
|
|
<div className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-pulse rounded-full bg-[color:var(--surface-strong)]" />
|
|
|
|
|
<div className="min-w-0 flex-1 space-y-1.5">
|
|
|
|
|
<div className="flex items-baseline gap-2">
|
|
|
|
|
<div className="h-3.5 w-32 animate-pulse rounded bg-[color:var(--surface-strong)]" />
|
|
|
|
|
<div className="h-3 w-20 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-60" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-3">
|
|
|
|
|
<div className="h-3 w-16 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-50" />
|
|
|
|
|
<div className="h-3 w-16 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-50" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-3 w-8 shrink-0 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-60" />
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-05-20 21:32:46 -05:00
|
|
|
) : jobs.length === 0 ? (
|
|
|
|
|
<DashboardEmptyState message="No cron jobs found on this gateway." />
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
{jobs.map((job) => {
|
|
|
|
|
const meta = STATUS_META[(job.status as StatusKey) ?? "unknown"] ?? STATUS_META.unknown;
|
|
|
|
|
const Icon = meta.icon;
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={job.name}
|
|
|
|
|
className="flex min-w-0 items-start gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2"
|
|
|
|
|
>
|
|
|
|
|
<Icon className={`mt-0.5 h-3.5 w-3.5 shrink-0 ${meta.cls}`} />
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="flex items-baseline gap-2">
|
|
|
|
|
<p className="truncate text-sm font-medium text-strong">{job.name}</p>
|
|
|
|
|
{job.schedule && (
|
|
|
|
|
<code className="shrink-0 text-[11px] text-muted">{job.schedule}</code>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-0.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted">
|
|
|
|
|
<span>Last: {relativeTime(job.last_run)}</span>
|
|
|
|
|
{job.next_run && <span>Next: {relativeTime(job.next_run)}</span>}
|
|
|
|
|
{job.last_duration_ms != null && (
|
|
|
|
|
<span>{fmtMs(job.last_duration_ms)}</span>
|
|
|
|
|
)}
|
|
|
|
|
{job.last_error && (
|
|
|
|
|
<span className="text-[color:var(--danger)] truncate max-w-[180px]" title={job.last_error}>
|
|
|
|
|
{job.last_error}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<span className={`shrink-0 text-[11px] font-medium ${meta.cls}`}>
|
|
|
|
|
{meta.label}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</DashboardSection>
|
|
|
|
|
);
|
|
|
|
|
}
|