250 lines
8.3 KiB
TypeScript
250 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useTranslations, useFormatter } from "next-intl";
|
|
import { Card } from "@/components/ui/card";
|
|
import type { CronRun } from "@/types";
|
|
|
|
interface Props {
|
|
initialRecent: CronRun[];
|
|
initialLastSuccess: {
|
|
monthlyIssue: CronRun | null;
|
|
reminders: CronRun | null;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Admin cron dashboard. Server pre-loads `initialRecent` and
|
|
* `initialLastSuccess`; "Run now" clicks POST to the admin
|
|
* endpoints, then re-fetch the history via GET /api/admin/cron/runs.
|
|
*
|
|
* The trigger buttons disable while busy and surface the resulting
|
|
* counters inline so the admin gets immediate feedback without
|
|
* needing to scroll to the history table.
|
|
*/
|
|
export function CronControls({ initialRecent, initialLastSuccess }: Props) {
|
|
const t = useTranslations("adminCron");
|
|
const fmt = useFormatter();
|
|
const [recent, setRecent] = useState(initialRecent);
|
|
const [lastSuccess, setLastSuccess] = useState(initialLastSuccess);
|
|
const [busy, setBusy] = useState<null | "issue" | "reminders">(null);
|
|
const [flash, setFlash] = useState<null | {
|
|
kind: "issue" | "reminders";
|
|
ok: boolean;
|
|
summary: string;
|
|
}>(null);
|
|
|
|
const refresh = async () => {
|
|
try {
|
|
const res = await fetch("/api/admin/cron/runs");
|
|
if (!res.ok) return;
|
|
const data = await res.json();
|
|
setRecent(data.recent);
|
|
setLastSuccess(data.lastSuccess);
|
|
} catch {
|
|
// swallow — refresh is opportunistic
|
|
}
|
|
};
|
|
|
|
const triggerIssue = async () => {
|
|
setBusy("issue");
|
|
setFlash(null);
|
|
try {
|
|
const res = await fetch("/api/admin/cron/issue-monthly", {
|
|
method: "POST",
|
|
});
|
|
const j = await res.json();
|
|
if (!res.ok) {
|
|
setFlash({
|
|
kind: "issue",
|
|
ok: false,
|
|
summary: j.error ?? `HTTP ${res.status}`,
|
|
});
|
|
} else {
|
|
setFlash({
|
|
kind: "issue",
|
|
ok: true,
|
|
summary: t("flashIssueOk", {
|
|
success: j.successCount,
|
|
skipped: j.skippedCount,
|
|
failure: j.failureCount,
|
|
}),
|
|
});
|
|
}
|
|
await refresh();
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
const triggerReminders = async () => {
|
|
setBusy("reminders");
|
|
setFlash(null);
|
|
try {
|
|
const res = await fetch("/api/admin/cron/send-reminders", {
|
|
method: "POST",
|
|
});
|
|
const j = await res.json();
|
|
if (!res.ok) {
|
|
setFlash({
|
|
kind: "reminders",
|
|
ok: false,
|
|
summary: j.error ?? `HTTP ${res.status}`,
|
|
});
|
|
} else {
|
|
setFlash({
|
|
kind: "reminders",
|
|
ok: true,
|
|
summary: t("flashRemindersOk", {
|
|
success: j.successCount,
|
|
skipped: j.skippedCount,
|
|
failure: j.failureCount,
|
|
}),
|
|
});
|
|
}
|
|
await refresh();
|
|
} finally {
|
|
setBusy(null);
|
|
}
|
|
};
|
|
|
|
const fmtRelative = (iso: string | null) => {
|
|
if (!iso) return t("never");
|
|
return fmt.dateTime(new Date(iso), {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
});
|
|
};
|
|
|
|
// Phase 6: surface failures prominently. Any run in the recent
|
|
// window with a non-zero failure_count drives a top-of-page
|
|
// banner — the row in the table is already red, but a banner
|
|
// means the admin doesn't have to scroll to notice.
|
|
const recentFailures = recent.filter((r) => r.failureCount > 0);
|
|
const hasRecentFailures = recentFailures.length > 0;
|
|
|
|
return (
|
|
<div className="space-y-8">
|
|
{hasRecentFailures && (
|
|
<div className="p-4 rounded-md border border-error bg-error/10 text-sm text-error">
|
|
<p className="font-medium mb-1">{t("failureBannerTitle")}</p>
|
|
<p className="text-xs">
|
|
{t("failureBannerBody", { count: recentFailures.length })}
|
|
</p>
|
|
</div>
|
|
)}
|
|
<section className="grid gap-4 md:grid-cols-2">
|
|
<Card>
|
|
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-2">
|
|
{t("monthlyIssue")}
|
|
</h2>
|
|
<p className="text-xs text-text-secondary mb-1">
|
|
{t("scheduleIssueLabel")}: <span className="font-mono">{t("scheduleIssueValue")}</span>
|
|
</p>
|
|
<p className="text-xs text-text-secondary mb-3">
|
|
{t("lastSuccess")}: <span className="font-mono">{fmtRelative(lastSuccess.monthlyIssue?.startedAt ?? null)}</span>
|
|
</p>
|
|
<button
|
|
onClick={triggerIssue}
|
|
disabled={busy !== null}
|
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{busy === "issue" ? t("running") : t("runIssueNow")}
|
|
</button>
|
|
</Card>
|
|
<Card>
|
|
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-2">
|
|
{t("reminders")}
|
|
</h2>
|
|
<p className="text-xs text-text-secondary mb-1">
|
|
{t("scheduleReminderLabel")}: <span className="font-mono">{t("scheduleReminderValue")}</span>
|
|
</p>
|
|
<p className="text-xs text-text-secondary mb-3">
|
|
{t("lastSuccess")}: <span className="font-mono">{fmtRelative(lastSuccess.reminders?.startedAt ?? null)}</span>
|
|
</p>
|
|
<button
|
|
onClick={triggerReminders}
|
|
disabled={busy !== null}
|
|
className="px-4 py-2 rounded-md bg-accent text-white text-sm font-medium hover:bg-accent-dim transition-colors disabled:opacity-50 cursor-pointer"
|
|
>
|
|
{busy === "reminders" ? t("running") : t("runRemindersNow")}
|
|
</button>
|
|
</Card>
|
|
</section>
|
|
|
|
{flash && (
|
|
<div
|
|
className={`p-3 rounded-md border text-sm ${
|
|
flash.ok
|
|
? "border-success bg-success/10 text-success"
|
|
: "border-error bg-error/10 text-error"
|
|
}`}
|
|
>
|
|
{flash.summary}
|
|
</div>
|
|
)}
|
|
|
|
<section>
|
|
<h2 className="text-xs uppercase tracking-wider text-text-muted mb-3">
|
|
{t("recentRuns")}
|
|
</h2>
|
|
<Card>
|
|
{recent.length === 0 ? (
|
|
<p className="text-sm text-text-muted italic py-4">
|
|
{t("noRunsYet")}
|
|
</p>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead className="text-xs text-text-muted text-left">
|
|
<tr>
|
|
<th className="pb-2">{t("startedCol")}</th>
|
|
<th className="pb-2">{t("kindCol")}</th>
|
|
<th className="pb-2">{t("triggeredByCol")}</th>
|
|
<th className="pb-2 text-right">{t("okCol")}</th>
|
|
<th className="pb-2 text-right">{t("skipCol")}</th>
|
|
<th className="pb-2 text-right">{t("failCol")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{recent.map((r) => (
|
|
<tr
|
|
key={r.id}
|
|
className={`border-t border-border align-top ${
|
|
r.failureCount > 0 ? "bg-error/5" : ""
|
|
}`}
|
|
>
|
|
<td className="py-2 text-xs font-mono">
|
|
{fmtRelative(r.startedAt)}
|
|
</td>
|
|
<td className="py-2 text-xs">
|
|
{t(`kind.${r.runKind}` as any)}
|
|
</td>
|
|
<td className="py-2 text-xs text-text-secondary font-mono">
|
|
{r.triggeredBy === "cron"
|
|
? t("triggeredByCron")
|
|
: r.triggeredBy.slice(0, 8) + "…"}
|
|
</td>
|
|
<td className="py-2 text-right font-mono text-xs text-success">
|
|
{r.successCount}
|
|
</td>
|
|
<td className="py-2 text-right font-mono text-xs text-text-secondary">
|
|
{r.skippedCount}
|
|
</td>
|
|
<td
|
|
className={`py-2 text-right font-mono text-xs ${
|
|
r.failureCount > 0 ? "text-error" : "text-text-muted"
|
|
}`}
|
|
>
|
|
{r.failureCount}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</Card>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|