319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
import Link from "next/link";
|
||
import { redirect } from "next/navigation";
|
||
import { getServerSession } from "next-auth";
|
||
import { authOptions } from "@/lib/auth";
|
||
import { isAdminEmail } from "@/lib/admin";
|
||
import { prisma } from "@/lib/prisma";
|
||
import { systemHealthService } from "@/server/services/system-health.service";
|
||
|
||
interface JobSummary {
|
||
jobName: string;
|
||
lastRun: { at: Date; status: string; errorMessage: string | null } | null;
|
||
lastSuccess: Date | null;
|
||
totalRuns7d: number;
|
||
failedRuns7d: number;
|
||
}
|
||
|
||
async function getJobSummary(jobName: string): Promise<JobSummary> {
|
||
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||
const [lastRun, lastSuccessRow, totalRuns7d, failedRuns7d] =
|
||
await Promise.all([
|
||
prisma.cronRun.findFirst({
|
||
where: { jobName },
|
||
orderBy: { startedAt: "desc" },
|
||
select: { startedAt: true, status: true, errorMessage: true },
|
||
}),
|
||
prisma.cronRun.findFirst({
|
||
where: { jobName, status: "SUCCESS" },
|
||
orderBy: { startedAt: "desc" },
|
||
select: { startedAt: true },
|
||
}),
|
||
prisma.cronRun.count({
|
||
where: { jobName, startedAt: { gte: sevenDaysAgo } },
|
||
}),
|
||
prisma.cronRun.count({
|
||
where: {
|
||
jobName,
|
||
status: "FAILED",
|
||
startedAt: { gte: sevenDaysAgo },
|
||
},
|
||
}),
|
||
]);
|
||
return {
|
||
jobName,
|
||
lastRun: lastRun
|
||
? {
|
||
at: lastRun.startedAt,
|
||
status: lastRun.status,
|
||
errorMessage: lastRun.errorMessage,
|
||
}
|
||
: null,
|
||
lastSuccess: lastSuccessRow?.startedAt ?? null,
|
||
totalRuns7d,
|
||
failedRuns7d,
|
||
};
|
||
}
|
||
|
||
// Daftar cron yang dipantau. Tambah entry baru saat menambah cron route handler.
|
||
const TRACKED_JOBS = ["auto-complete-trips"] as const;
|
||
|
||
function healthOf(summary: JobSummary): "ok" | "stale" | "failed" {
|
||
if (summary.lastRun?.status === "FAILED") return "failed";
|
||
if (!summary.lastSuccess) return "stale";
|
||
const hoursSince =
|
||
(Date.now() - summary.lastSuccess.getTime()) / (1000 * 60 * 60);
|
||
// Asumsi cron daily — > 25 jam dianggap stale.
|
||
if (hoursSince > 25) return "stale";
|
||
return "ok";
|
||
}
|
||
|
||
export default async function AdminSystemPage() {
|
||
const session = await getServerSession(authOptions);
|
||
if (!session?.user) redirect("/login?callbackUrl=/admin/system");
|
||
if (!isAdminEmail(session.user.email)) {
|
||
return (
|
||
<div className="mx-auto max-w-2xl px-4 py-12 text-center">
|
||
<p className="text-sm text-neutral-600">
|
||
Halaman ini hanya untuk admin SeTrip.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const [summaries, recentRuns, stale] = await Promise.all([
|
||
Promise.all(TRACKED_JOBS.map(getJobSummary)),
|
||
prisma.cronRun.findMany({
|
||
orderBy: { startedAt: "desc" },
|
||
take: 20,
|
||
}),
|
||
systemHealthService.detectStale(),
|
||
]);
|
||
|
||
const hasAnyStale =
|
||
stale.stalePaymentsCount > 0 ||
|
||
stale.awaitingPayPastDepartureCount > 0 ||
|
||
stale.overduePayoutsCount > 0 ||
|
||
stale.stuckRefundsCount > 0;
|
||
|
||
return (
|
||
<div className="mx-auto max-w-5xl px-4 py-8 sm:py-12">
|
||
<header className="mb-6">
|
||
<h1 className="text-2xl font-bold text-neutral-900 sm:text-3xl">
|
||
System Health
|
||
</h1>
|
||
<p className="mt-1 text-sm text-neutral-500">
|
||
Status cron job otomatis. Refresh halaman ini setelah trigger cron
|
||
manual atau saat investigasi.
|
||
</p>
|
||
</header>
|
||
|
||
{hasAnyStale && (
|
||
<section className="mb-6 rounded-2xl border border-amber-300 bg-amber-50 p-4 sm:p-5">
|
||
<h2 className="mb-2 text-sm font-bold text-amber-900">
|
||
⚠️ Stale State Alerts
|
||
</h2>
|
||
<ul className="space-y-1 text-xs text-amber-900">
|
||
{stale.stalePaymentsCount > 0 && (
|
||
<li>
|
||
• <strong>{stale.stalePaymentsCount}</strong> Payment MIDTRANS
|
||
AWAITING > 25 jam — webhook mungkin tertunda. Cek manual lalu
|
||
reconcile.
|
||
</li>
|
||
)}
|
||
{stale.awaitingPayPastDepartureCount > 0 && (
|
||
<li>
|
||
• <strong>{stale.awaitingPayPastDepartureCount}</strong> Booking
|
||
AWAITING_PAY tapi trip sudah lewat tanggal berangkat — peserta
|
||
lupa bayar, butuh cleanup.
|
||
</li>
|
||
)}
|
||
{stale.overduePayoutsCount > 0 && (
|
||
<li>
|
||
• <strong>{stale.overduePayoutsCount}</strong> Payout HELD lewat
|
||
heldUntil > 1 hari — cron release mungkin tidak jalan, cek
|
||
cron history di bawah.{" "}
|
||
<Link
|
||
href="/admin/payouts?tab=HELD"
|
||
className="font-semibold text-amber-700 hover:underline"
|
||
>
|
||
Lihat HELD →
|
||
</Link>
|
||
</li>
|
||
)}
|
||
{stale.stuckRefundsCount > 0 && (
|
||
<li>
|
||
• <strong>{stale.stuckRefundsCount}</strong> Refund APPROVED
|
||
> 7 hari belum di-process.{" "}
|
||
<Link
|
||
href="/admin/refunds?tab=APPROVED"
|
||
className="font-semibold text-amber-700 hover:underline"
|
||
>
|
||
Lihat APPROVED →
|
||
</Link>
|
||
</li>
|
||
)}
|
||
</ul>
|
||
</section>
|
||
)}
|
||
|
||
<section className="mb-8">
|
||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||
Cron Jobs
|
||
</h2>
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
{summaries.map((s) => {
|
||
const health = healthOf(s);
|
||
const cls =
|
||
health === "ok"
|
||
? "border-emerald-200 bg-emerald-50/50"
|
||
: health === "stale"
|
||
? "border-amber-200 bg-amber-50/50"
|
||
: "border-red-200 bg-red-50/50";
|
||
const badge =
|
||
health === "ok"
|
||
? { label: "🟢 OK", cls: "bg-emerald-100 text-emerald-800" }
|
||
: health === "stale"
|
||
? {
|
||
label: "🟡 STALE",
|
||
cls: "bg-amber-100 text-amber-800",
|
||
}
|
||
: {
|
||
label: "🔴 FAILED",
|
||
cls: "bg-red-100 text-red-800",
|
||
};
|
||
return (
|
||
<div
|
||
key={s.jobName}
|
||
className={`rounded-2xl border p-4 shadow-sm sm:p-5 ${cls}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div>
|
||
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||
Job
|
||
</p>
|
||
<p className="font-mono text-sm font-bold text-neutral-800">
|
||
{s.jobName}
|
||
</p>
|
||
</div>
|
||
<span
|
||
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${badge.cls}`}
|
||
>
|
||
{badge.label}
|
||
</span>
|
||
</div>
|
||
<dl className="mt-3 space-y-1 text-xs text-neutral-700">
|
||
<div>
|
||
<dt className="inline font-semibold">Last run:</dt>{" "}
|
||
<dd className="inline">
|
||
{s.lastRun
|
||
? `${formatDateTime(s.lastRun.at)} · ${s.lastRun.status}`
|
||
: "Belum pernah"}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="inline font-semibold">Last success:</dt>{" "}
|
||
<dd className="inline">
|
||
{s.lastSuccess
|
||
? formatDateTime(s.lastSuccess)
|
||
: "Belum pernah"}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt className="inline font-semibold">7 hari terakhir:</dt>{" "}
|
||
<dd className="inline">
|
||
{s.totalRuns7d} run, {s.failedRuns7d} failed
|
||
</dd>
|
||
</div>
|
||
{s.lastRun?.errorMessage && (
|
||
<div className="mt-2 rounded-lg bg-red-100 p-2 text-[11px] text-red-800">
|
||
Error terakhir: {s.lastRun.errorMessage}
|
||
</div>
|
||
)}
|
||
</dl>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
<section>
|
||
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
|
||
Recent Runs (20 terakhir)
|
||
</h2>
|
||
{recentRuns.length === 0 ? (
|
||
<div className="rounded-2xl border border-dashed border-neutral-300 bg-white p-8 text-center">
|
||
<p className="text-sm text-neutral-500">
|
||
Belum ada cron run tercatat. Setelah cron berikutnya jalan, baris
|
||
pertama akan muncul di sini.
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<div className="overflow-x-auto rounded-2xl border border-neutral-200 bg-white shadow-sm">
|
||
<table className="min-w-full divide-y divide-neutral-100 text-sm">
|
||
<thead className="bg-neutral-50 text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
|
||
<tr>
|
||
<th className="px-3 py-2 text-left">Job</th>
|
||
<th className="px-3 py-2 text-left">Started</th>
|
||
<th className="px-3 py-2 text-left">Finished</th>
|
||
<th className="px-3 py-2 text-left">Status</th>
|
||
<th className="px-3 py-2 text-left">Note</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-neutral-100 text-xs text-neutral-700">
|
||
{recentRuns.map((r) => (
|
||
<tr key={r.id}>
|
||
<td className="px-3 py-2 font-mono">{r.jobName}</td>
|
||
<td className="px-3 py-2">{formatDateTime(r.startedAt)}</td>
|
||
<td className="px-3 py-2">
|
||
{r.finishedAt ? formatDateTime(r.finishedAt) : "—"}
|
||
</td>
|
||
<td className="px-3 py-2">
|
||
<StatusBadge value={r.status} />
|
||
</td>
|
||
<td className="px-3 py-2 text-neutral-500">
|
||
{r.errorMessage ??
|
||
(r.payload
|
||
? truncate(JSON.stringify(r.payload), 80)
|
||
: "—")}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function formatDateTime(d: Date): string {
|
||
return d.toLocaleString("id-ID", {
|
||
day: "2-digit",
|
||
month: "short",
|
||
year: "numeric",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
}
|
||
|
||
function truncate(s: string, max: number): string {
|
||
return s.length > max ? `${s.slice(0, max)}…` : s;
|
||
}
|
||
|
||
function StatusBadge({ value }: { value: string }) {
|
||
const cls =
|
||
value === "SUCCESS"
|
||
? "bg-emerald-100 text-emerald-800"
|
||
: value === "FAILED"
|
||
? "bg-red-100 text-red-800"
|
||
: "bg-amber-100 text-amber-800";
|
||
return (
|
||
<span
|
||
className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${cls}`}
|
||
>
|
||
{value}
|
||
</span>
|
||
);
|
||
}
|