Wrap next/link in a shared Link component that ships prefetch=false by default, so no route is ever prefetched (viewport or hover) anymore, and drop the DNS prefetch hint. Removes hidden background requests that were the source of intermittent issues.
183 lines
5.8 KiB
TypeScript
183 lines
5.8 KiB
TypeScript
import { count, desc, eq, gte } from "drizzle-orm";
|
|
import { AlertTriangle, Database, HeartPulse, Server } from "lucide-react";
|
|
import { redirect } from "next/navigation";
|
|
import { getTranslations } from "next-intl/server";
|
|
import Link from "@/components/link";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { fetchOpsHealth } from "@/lib/admin/ops-health";
|
|
import { db, EmulatorErrors, EmulatorSettings } from "@/lib/db";
|
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
|
import { HealthCheckClient } from "./health-check-client";
|
|
|
|
async function getDevOpsData() {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const dayAgo = now - 86400;
|
|
|
|
const [health, recentErrors, errorsTodayRows, emuVersionRows] =
|
|
await Promise.all([
|
|
fetchOpsHealth(),
|
|
db
|
|
.select()
|
|
.from(EmulatorErrors)
|
|
.orderBy(desc(EmulatorErrors.id))
|
|
.limit(20),
|
|
db
|
|
.select({ total: count() })
|
|
.from(EmulatorErrors)
|
|
.where(gte(EmulatorErrors.timestamp, dayAgo)),
|
|
db
|
|
.select({ value: EmulatorSettings.value })
|
|
.from(EmulatorSettings)
|
|
.where(eq(EmulatorSettings.key, "version"))
|
|
.limit(1),
|
|
]);
|
|
|
|
return {
|
|
dbStatus: health.dbOk ? ("healthy" as const) : ("error" as const),
|
|
dbLatency: health.dbLatencyMs,
|
|
redisOk: health.redisOk,
|
|
recentErrors,
|
|
errorsToday: Number(errorsTodayRows[0]?.total ?? 0),
|
|
emuVersion: emuVersionRows[0]?.value ?? "Unknown",
|
|
onlineUsers: health.onlineUsers,
|
|
};
|
|
}
|
|
|
|
export default async function DevOpsPage() {
|
|
const { session, permissions } = await getAdminContext();
|
|
if (!canAccess(permissions, PERMS.DEVOPS_VIEW, session.user.rank)) {
|
|
redirect("/admin");
|
|
}
|
|
|
|
const t = await getTranslations("pages.admin.devops");
|
|
const data = await getDevOpsData();
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Status cards */}
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Database</CardTitle>
|
|
<Database className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Badge
|
|
variant={data.dbStatus === "healthy" ? "default" : "destructive"}
|
|
>
|
|
{data.dbStatus === "healthy" ? "Healthy" : "Error"}
|
|
</Badge>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Latency: {data.dbLatency}ms
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Redis</CardTitle>
|
|
<Server className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Badge
|
|
variant={
|
|
data.redisOk === false
|
|
? "destructive"
|
|
: data.redisOk === true
|
|
? "default"
|
|
: "secondary"
|
|
}
|
|
>
|
|
{data.redisOk === true
|
|
? "Healthy"
|
|
: data.redisOk === false
|
|
? "Error"
|
|
: "Not configured"}
|
|
</Badge>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Cache / rate limits / sessions
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Emulator</CardTitle>
|
|
<Server className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<HealthCheckClient />
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Version: {data.emuVersion}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Online Users</CardTitle>
|
|
<HeartPulse className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{data.onlineUsers}</div>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
<Link
|
|
href="/admin/commandocentrum#online"
|
|
className="text-[var(--admin-accent)] underline-offset-2 hover:underline"
|
|
>
|
|
Live roster in CommandoCentrum
|
|
</Link>
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Errors (24h)</CardTitle>
|
|
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{data.errorsToday}</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Emulator errors today
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Recent errors */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<AlertTriangle className="h-4 w-4" /> Recent Emulator Errors
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{data.recentErrors.length === 0 ? (
|
|
<div className="admin-empty">{t("noRecentErrors")}</div>
|
|
) : (
|
|
<div className="space-y-2 max-h-[500px] overflow-y-auto">
|
|
{data.recentErrors.map((error) => (
|
|
<div key={error.id} className="rounded-md border p-3 text-sm">
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="font-mono text-xs text-muted-foreground">
|
|
#{error.id}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{new Date(error.timestamp * 1000).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<pre className="text-xs whitespace-pre-wrap break-all font-mono text-destructive">
|
|
{Buffer.from(error.stacktrace).toString("utf-8")}
|
|
</pre>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|