Files
setrip/app/admin/system/page.tsx
T
2026-05-21 11:59:02 +07:00

416 lines
14 KiB
TypeScript

import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import {
ArrowUpRight,
CircleAlert,
CircleCheck,
CircleX,
} from "lucide-react";
import { authOptions } from "@/lib/auth";
import { isAdminEmail } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
import { systemHealthService } from "@/server/services/system-health.service";
import { emailRepo } from "@/server/repositories/email.repo";
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", "process-email-jobs"] 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, emailStats] = await Promise.all([
Promise.all(TRACKED_JOBS.map(getJobSummary)),
prisma.cronRun.findMany({
orderBy: { startedAt: "desc" },
take: 20,
}),
systemHealthService.detectStale(),
emailRepo.stats(),
]);
const hasAnyStale =
stale.stalePaymentsCount > 0 ||
stale.awaitingPayPastDepartureCount > 0 ||
stale.overduePayoutsCount > 0 ||
stale.stuckRefundsCount > 0 ||
emailStats.deadLetter > 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 flex items-center gap-1.5 text-sm font-bold text-amber-900">
<CircleAlert size={16} strokeWidth={2} aria-hidden />
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 &gt; 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 &gt; 1 hari cron release mungkin tidak jalan, cek
cron history di bawah.{" "}
<Link
href="/admin/payouts?tab=HELD"
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
>
Lihat HELD
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</li>
)}
{stale.stuckRefundsCount > 0 && (
<li>
<strong>{stale.stuckRefundsCount}</strong> Refund APPROVED
&gt; 7 hari belum di-process.{" "}
<Link
href="/admin/refunds?tab=APPROVED"
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
>
Lihat APPROVED
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</li>
)}
{emailStats.deadLetter > 0 && (
<li>
<strong>{emailStats.deadLetter}</strong> email gagal kirim &
sudah habis 5 attempt cron berhenti retry, perlu retry
manual.{" "}
<Link
href="/admin/emails?tab=failed"
className="inline-flex items-center gap-1 font-semibold text-amber-700 hover:underline"
>
Lihat email gagal
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</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",
icon: CircleCheck,
cls: "bg-emerald-100 text-emerald-800",
}
: health === "stale"
? {
label: "STALE",
icon: CircleAlert,
cls: "bg-amber-100 text-amber-800",
}
: {
label: "FAILED",
icon: CircleX,
cls: "bg-red-100 text-red-800",
};
const BadgeIcon = badge.icon;
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={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${badge.cls}`}
>
<BadgeIcon size={12} strokeWidth={2.25} aria-hidden />
{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 className="mb-8">
<h2 className="mb-3 text-xs font-bold uppercase tracking-wide text-neutral-500">
Email
</h2>
<div className="grid gap-3 sm:grid-cols-3">
<EmailStat
label="Antri dikirim"
value={emailStats.queued}
tone={emailStats.queued > 0 ? "amber" : "ok"}
/>
<EmailStat
label="Gagal 24 jam"
value={emailStats.failed24h}
tone={emailStats.failed24h > 0 ? "red" : "ok"}
/>
<EmailStat
label="Perlu aksi manual"
value={emailStats.deadLetter}
tone={emailStats.deadLetter > 0 ? "red" : "ok"}
/>
</div>
<p className="mt-2 text-xs text-neutral-500">
<Link
href="/admin/emails"
className="inline-flex items-center gap-1 font-semibold text-primary-600 hover:underline"
>
Buka Email Log
<ArrowUpRight size={14} strokeWidth={2} aria-hidden />
</Link>
</p>
</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 EmailStat({
label,
value,
tone,
}: {
label: string;
value: number;
tone: "ok" | "amber" | "red";
}) {
const cls =
tone === "red"
? "border-red-200 bg-red-50/60"
: tone === "amber"
? "border-amber-200 bg-amber-50/60"
: "border-emerald-200 bg-emerald-50/50";
const valueCls =
tone === "red"
? "text-red-700"
: tone === "amber"
? "text-amber-700"
: "text-emerald-700";
return (
<div className={`rounded-2xl border p-4 shadow-sm ${cls}`}>
<p className="text-[10px] font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<p className={`mt-1 text-2xl font-bold ${valueCls}`}>{value}</p>
</div>
);
}
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>
);
}